From 302cf5cb0da66ffd464ca4d169de87722b497303 Mon Sep 17 00:00:00 2001 From: Wind-Explorer Date: Tue, 31 Mar 2026 00:43:22 +0800 Subject: [PATCH] redis pt 5: fix tests --- src/auth/auth.service.spec.ts | 30 +++++++++++++++ src/dolls/dolls.service.spec.ts | 60 +++++++++++++++++++++-------- src/friends/friends.service.spec.ts | 42 ++++++++++++++++++++ src/users/users.service.spec.ts | 31 +++++++++++++++ src/ws/state/state.gateway.spec.ts | 47 ++++++++++++++++++++++ 5 files changed, 194 insertions(+), 16 deletions(-) diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index 1c1f7e8..2d6037c 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -4,8 +4,11 @@ import { UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { Test, TestingModule } from '@nestjs/testing'; import { decode, sign } from 'jsonwebtoken'; +import { CacheService } from '../common/cache/cache.service'; +import { CacheTagsService } from '../common/cache/cache-tags.service'; import { PrismaService } from '../database/prisma.service'; import { AuthService } from './auth.service'; import { sha256 } from './auth.utils'; @@ -58,6 +61,27 @@ describe('AuthService', () => { $transaction: jest.fn(), }; + const mockEventEmitter = { + emit: jest.fn(), + }; + + const mockCacheService = { + get: jest.fn().mockResolvedValue(null), + set: jest.fn().mockResolvedValue(true), + del: jest.fn().mockResolvedValue(true), + getNamespacedKey: jest + .fn() + .mockImplementation( + (namespace: string, key: string) => `friendolls:${namespace}:${key}`, + ), + recordError: jest.fn(), + }; + + const mockCacheTagsService = { + rememberKeyForTag: jest.fn().mockResolvedValue(undefined), + invalidateTag: jest.fn().mockResolvedValue(undefined), + }; + const socialProfile: SocialAuthProfile = { provider: 'google', providerSubject: 'google-user-123', @@ -94,6 +118,9 @@ describe('AuthService', () => { AuthService, { provide: PrismaService, useValue: mockPrismaService }, { provide: ConfigService, useValue: mockConfigService }, + { provide: EventEmitter2, useValue: mockEventEmitter }, + { provide: CacheService, useValue: mockCacheService }, + { provide: CacheTagsService, useValue: mockCacheTagsService }, ], }).compile(); @@ -135,6 +162,9 @@ describe('AuthService', () => { const localService = new AuthService( mockPrismaService as unknown as PrismaService, mockConfigService as unknown as ConfigService, + mockEventEmitter as unknown as EventEmitter2, + mockCacheService as unknown as CacheService, + mockCacheTagsService as unknown as CacheTagsService, ); expect(() => diff --git a/src/dolls/dolls.service.spec.ts b/src/dolls/dolls.service.spec.ts index 5fb8669..24a6757 100644 --- a/src/dolls/dolls.service.spec.ts +++ b/src/dolls/dolls.service.spec.ts @@ -4,6 +4,9 @@ import { DollsService } from './dolls.service'; import { PrismaService } from '../database/prisma.service'; import { NotFoundException, ForbiddenException } from '@nestjs/common'; import { Doll } from '@prisma/client'; +import { CacheService } from '../common/cache/cache.service'; +import { CacheTagsService } from '../common/cache/cache-tags.service'; +import { FriendsService } from '../friends/friends.service'; describe('DollsService', () => { let service: DollsService; @@ -31,9 +34,6 @@ describe('DollsService', () => { findFirst: jest.fn().mockResolvedValue(mockDoll), update: jest.fn().mockResolvedValue(mockDoll), }, - friendship: { - findMany: jest.fn().mockResolvedValue([]), - }, $transaction: jest.fn((callback) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-return return callback(mockPrismaService); @@ -47,6 +47,25 @@ describe('DollsService', () => { emit: jest.fn(), }; + const mockCacheService = { + get: jest.fn().mockResolvedValue(null), + set: jest.fn().mockResolvedValue(true), + getNamespacedKey: jest + .fn() + .mockImplementation( + (namespace: string, key: string) => `friendolls:${namespace}:${key}`, + ), + recordError: jest.fn(), + }; + + const mockCacheTagsService = { + rememberKeyForTag: jest.fn().mockResolvedValue(undefined), + }; + + const mockFriendsService = { + areFriends: jest.fn().mockResolvedValue(false), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -59,6 +78,18 @@ describe('DollsService', () => { provide: EventEmitter2, useValue: mockEventEmitter, }, + { + provide: CacheService, + useValue: mockCacheService, + }, + { + provide: CacheTagsService, + useValue: mockCacheTagsService, + }, + { + provide: FriendsService, + useValue: mockFriendsService, + }, ], }).compile(); @@ -112,10 +143,7 @@ describe('DollsService', () => { const ownerId = 'friend-1'; const requestingUserId = 'user-1'; - // Mock friendship - jest - .spyOn(prismaService.friendship, 'findMany') - .mockResolvedValueOnce([{ friendId: ownerId } as any]); + (mockFriendsService.areFriends as jest.Mock).mockResolvedValueOnce(true); await service.listByOwner(ownerId, requestingUserId); @@ -134,10 +162,7 @@ describe('DollsService', () => { const ownerId = 'stranger-1'; const requestingUserId = 'user-1'; - // Mock empty friendship (default) - jest - .spyOn(prismaService.friendship, 'findMany') - .mockResolvedValueOnce([]); + (mockFriendsService.areFriends as jest.Mock).mockResolvedValueOnce(false); await expect( service.listByOwner(ownerId, requestingUserId), @@ -163,7 +188,10 @@ describe('DollsService', () => { }); it('should throw NotFoundException if doll not accessible', async () => { - jest.spyOn(prismaService.doll, 'findFirst').mockResolvedValueOnce(null); + jest + .spyOn(prismaService.doll, 'findFirst') + .mockResolvedValueOnce({ ...mockDoll, userId: 'user-2' }); + (mockFriendsService.areFriends as jest.Mock).mockResolvedValueOnce(false); await expect(service.findOne('doll-1', 'user-1')).rejects.toThrow( NotFoundException, @@ -179,7 +207,7 @@ describe('DollsService', () => { expect(prismaService.doll.update).toHaveBeenCalled(); }); - it('should throw ForbiddenException if not owner', async () => { + it('should throw NotFoundException if not owner and not a friend', async () => { jest .spyOn(prismaService.doll, 'findFirst') .mockResolvedValueOnce({ ...mockDoll, userId: 'user-2' }); @@ -187,7 +215,7 @@ describe('DollsService', () => { const updateDto = { name: 'Updated Doll' }; await expect( service.update('doll-1', 'user-1', updateDto), - ).rejects.toThrow(ForbiddenException); + ).rejects.toThrow(NotFoundException); }); }); @@ -203,13 +231,13 @@ describe('DollsService', () => { }); }); - it('should throw ForbiddenException if not owner', async () => { + it('should throw NotFoundException if not owner and not a friend', async () => { jest .spyOn(prismaService.doll, 'findFirst') .mockResolvedValueOnce({ ...mockDoll, userId: 'user-2' }); await expect(service.remove('doll-1', 'user-1')).rejects.toThrow( - ForbiddenException, + NotFoundException, ); }); }); diff --git a/src/friends/friends.service.spec.ts b/src/friends/friends.service.spec.ts index 19698b0..b081054 100644 --- a/src/friends/friends.service.spec.ts +++ b/src/friends/friends.service.spec.ts @@ -2,6 +2,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { FriendsService } from './friends.service'; import { PrismaService } from '../database/prisma.service'; +import { CacheService } from '../common/cache/cache.service'; +import { CacheTagsService } from '../common/cache/cache-tags.service'; import { NotFoundException, BadRequestException, @@ -17,6 +19,8 @@ enum FriendRequestStatus { describe('FriendsService', () => { let service: FriendsService; let eventEmitter: EventEmitter2; + let cacheService: CacheService; + let cacheTagsService: CacheTagsService; const mockUser1 = { id: 'user-1', @@ -90,6 +94,21 @@ describe('FriendsService', () => { emit: jest.fn(), }; + const mockCacheService = { + get: jest.fn().mockResolvedValue(null), + set: jest.fn().mockResolvedValue(true), + getNamespacedKey: jest + .fn() + .mockImplementation( + (namespace: string, key: string) => `friendolls:${namespace}:${key}`, + ), + recordError: jest.fn(), + }; + + const mockCacheTagsService = { + rememberKeyForTag: jest.fn().mockResolvedValue(undefined), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -102,11 +121,21 @@ describe('FriendsService', () => { provide: EventEmitter2, useValue: mockEventEmitter, }, + { + provide: CacheService, + useValue: mockCacheService, + }, + { + provide: CacheTagsService, + useValue: mockCacheTagsService, + }, ], }).compile(); service = module.get(FriendsService); eventEmitter = module.get(EventEmitter2); + cacheService = module.get(CacheService); + cacheTagsService = module.get(CacheTagsService); jest.clearAllMocks(); }); @@ -420,6 +449,8 @@ describe('FriendsService', () => { createdAt: 'desc', }, }); + expect(cacheService.set).toHaveBeenCalled(); + expect(cacheTagsService.rememberKeyForTag).toHaveBeenCalled(); }); }); @@ -469,6 +500,12 @@ describe('FriendsService', () => { const result = await service.areFriends('user-1', 'user-2'); expect(result).toBe(true); + expect(cacheService.set).toHaveBeenCalledWith( + expect.any(String), + '1', + expect.any(Number), + ); + expect(cacheTagsService.rememberKeyForTag).toHaveBeenCalled(); }); it('should return false when users are not friends', async () => { @@ -477,6 +514,11 @@ describe('FriendsService', () => { const result = await service.areFriends('user-1', 'user-2'); expect(result).toBe(false); + expect(cacheService.set).toHaveBeenCalledWith( + expect.any(String), + '0', + expect.any(Number), + ); }); }); }); diff --git a/src/users/users.service.spec.ts b/src/users/users.service.spec.ts index cde782e..4fc3bea 100644 --- a/src/users/users.service.spec.ts +++ b/src/users/users.service.spec.ts @@ -5,9 +5,13 @@ import { NotFoundException, ForbiddenException } from '@nestjs/common'; import { User } from '@prisma/client'; import { UpdateUserDto } from './dto/update-user.dto'; import { EventEmitter2 } from '@nestjs/event-emitter'; +import { CacheService } from '../common/cache/cache.service'; +import { CacheTagsService } from '../common/cache/cache-tags.service'; describe('UsersService', () => { let service: UsersService; + let cacheService: CacheService; + let cacheTagsService: CacheTagsService; const mockUser: User & { passwordHash?: string | null } = { id: '550e8400-e29b-41d4-a716-446655440000', @@ -39,6 +43,21 @@ describe('UsersService', () => { emit: jest.fn(), }; + const mockCacheService = { + get: jest.fn().mockResolvedValue(null), + set: jest.fn().mockResolvedValue(true), + getNamespacedKey: jest + .fn() + .mockImplementation( + (namespace: string, key: string) => `friendolls:${namespace}:${key}`, + ), + recordError: jest.fn(), + }; + + const mockCacheTagsService = { + rememberKeyForTag: jest.fn().mockResolvedValue(undefined), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -51,10 +70,20 @@ describe('UsersService', () => { provide: EventEmitter2, useValue: mockEventEmitter, }, + { + provide: CacheService, + useValue: mockCacheService, + }, + { + provide: CacheTagsService, + useValue: mockCacheTagsService, + }, ], }).compile(); service = module.get(UsersService); + cacheService = module.get(CacheService); + cacheTagsService = module.get(CacheTagsService); jest.clearAllMocks(); }); @@ -227,6 +256,8 @@ describe('UsersService', () => { username: 'asc', }, }); + expect(cacheService.set).toHaveBeenCalled(); + expect(cacheTagsService.rememberKeyForTag).toHaveBeenCalled(); }); it('should exclude specified user from results', async () => { diff --git a/src/ws/state/state.gateway.spec.ts b/src/ws/state/state.gateway.spec.ts index 4f74dad..553f0ef 100644 --- a/src/ws/state/state.gateway.spec.ts +++ b/src/ws/state/state.gateway.spec.ts @@ -6,6 +6,7 @@ import { JwtVerificationService } from '../../auth/services/jwt-verification.ser import { PrismaService } from '../../database/prisma.service'; import { UserSocketService } from './user-socket.service'; import { WsNotificationService } from './ws-notification.service'; +import { ConfigService } from '@nestjs/config'; import { SendInteractionDto } from '../dto/send-interaction.dto'; import { WsException } from '@nestjs/websockets'; @@ -45,6 +46,7 @@ describe('StateGateway', () => { let mockUserSocketService: Partial; let mockRedisClient: { publish: jest.Mock }; let mockRedisSubscriber: { subscribe: jest.Mock; on: jest.Mock }; + let mockConfigService: { get: jest.Mock }; let mockWsNotificationService: { setIo: jest.Mock; emitToUser: jest.Mock; @@ -52,6 +54,8 @@ describe('StateGateway', () => { emitToSocket: jest.Mock; updateActiveDollCache: jest.Mock; publishActiveDollUpdate: jest.Mock; + clearSenderNameCache: jest.Mock; + maybeTouchPresence: jest.Mock; }; beforeEach(async () => { @@ -92,9 +96,12 @@ describe('StateGateway', () => { mockUserSocketService = { setSocket: jest.fn().mockResolvedValue(undefined), removeSocket: jest.fn().mockResolvedValue(undefined), + removeSocketById: jest.fn().mockResolvedValue(undefined), + touchLastSeen: jest.fn().mockResolvedValue(undefined), getSocket: jest.fn().mockResolvedValue(null), isUserOnline: jest.fn().mockResolvedValue(false), getFriendsSockets: jest.fn().mockResolvedValue([]), + cleanupStalePresence: jest.fn().mockResolvedValue(0), }; mockRedisClient = { @@ -106,6 +113,10 @@ describe('StateGateway', () => { on: jest.fn(), }; + mockConfigService = { + get: jest.fn().mockReturnValue(undefined), + }; + mockWsNotificationService = { setIo: jest.fn(), emitToUser: jest.fn(), @@ -113,6 +124,8 @@ describe('StateGateway', () => { emitToSocket: jest.fn(), updateActiveDollCache: jest.fn(), publishActiveDollUpdate: jest.fn(), + clearSenderNameCache: jest.fn().mockResolvedValue(undefined), + maybeTouchPresence: jest.fn().mockResolvedValue(undefined), }; const module: TestingModule = await Test.createTestingModule({ @@ -125,6 +138,7 @@ describe('StateGateway', () => { { provide: PrismaService, useValue: mockPrismaService }, { provide: UserSocketService, useValue: mockUserSocketService }, { provide: WsNotificationService, useValue: mockWsNotificationService }, + { provide: ConfigService, useValue: mockConfigService }, { provide: 'REDIS_CLIENT', useValue: mockRedisClient }, { provide: 'REDIS_SUBSCRIBER_CLIENT', useValue: mockRedisSubscriber }, ], @@ -161,9 +175,32 @@ describe('StateGateway', () => { expect(mockRedisSubscriber.subscribe).toHaveBeenCalledWith( 'active-doll-update', 'friend-cache-update', + 'user-profile-cache-invalidate', expect.any(Function), ); }); + + it('should route user profile cache invalidation messages', async () => { + gateway.afterInit(); + + const onCalls = (mockRedisSubscriber.on as jest.Mock).mock.calls; + const messageHandler = onCalls.find( + (call) => call[0] === 'message', + )?.[1] as ((channel: string, message: string) => void) | undefined; + + expect(messageHandler).toBeDefined(); + + messageHandler?.( + 'user-profile-cache-invalidate', + JSON.stringify({ userId: 'user-1' }), + ); + + await Promise.resolve(); + + expect( + mockWsNotificationService.clearSenderNameCache, + ).toHaveBeenCalledWith('user-1'); + }); }); describe('handleConnection', () => { @@ -260,6 +297,9 @@ describe('StateGateway', () => { 'user-id', 'client1', ); + expect(mockUserSocketService.touchLastSeen).toHaveBeenCalledWith( + 'user-id', + ); // 2. Fetch State (DB) expect(mockPrismaService.user!.findUnique).toHaveBeenCalledWith({ @@ -359,6 +399,13 @@ describe('StateGateway', () => { expect(mockUserSocketService.getSocket).toHaveBeenCalledWith('user-id'); expect(mockUserSocketService.removeSocket).toHaveBeenCalledWith( 'user-id', + 'client1', + ); + expect(mockUserSocketService.touchLastSeen).toHaveBeenCalledWith( + 'user-id', + ); + expect(mockUserSocketService.removeSocketById).toHaveBeenCalledWith( + 'client1', ); expect(mockWsNotificationService.emitToSocket).toHaveBeenCalledWith( 'friend-socket-id',