diff --git a/src/app.module.ts b/src/app.module.ts index 7824b71..c67a71a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -9,16 +9,19 @@ import { RateLimitingModule } from './rate-limiting/rate-limiting.module'; import { QuotaGuard } from './rate-limiting/guards/quota.guard'; import { getDatabaseConfig } from './config/database.config'; import { loadFeatureFlags } from './config/feature-flags.config'; - -const featureFlags = loadFeatureFlags(); +import { SessionModule } from './session/session.module'; import { DebuggingModule } from './debugging/debugging.module'; import { DataPipelineModule } from './data-pipeline/data-pipeline.module'; +const featureFlags = loadFeatureFlags(); + + @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), TypeOrmModule.forRoot(getDatabaseConfig()), ScheduleModule.forRoot(), + SessionModule, SearchModule, ...(featureFlags.ENABLE_RATE_LIMITING ? [RateLimitingModule] : []), DebuggingModule, diff --git a/src/session/session.service.spec.ts b/src/session/session.service.spec.ts new file mode 100644 index 0000000..d1a42eb --- /dev/null +++ b/src/session/session.service.spec.ts @@ -0,0 +1,220 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { SessionService } from './session.service'; +import { SESSION_REDIS_CLIENT } from './session.constants'; + +const mockRedis = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + expire: jest.fn(), + eval: jest.fn(), + multi: jest.fn(), + status: 'ready', + quit: jest.fn(), +}; + +const mockMulti = { + set: jest.fn().mockReturnThis(), + del: jest.fn().mockReturnThis(), + expire: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue([]), +}; + +const mockConfigService = { + get: jest.fn((key: string, defaultVal?: string) => { + const values: Record = { + AUTH_SESSION_PREFIX: 'auth:sess:', + AUTH_SESSION_LEGACY_PREFIX: 'session:', + AUTH_SESSION_TTL_SECONDS: '604800', + SESSION_LOCK_TTL_MS: '5000', + SESSION_LOCK_MAX_RETRIES: '5', + SESSION_LOCK_RETRY_DELAY_MS: '120', + }; + return values[key] ?? defaultVal ?? ''; + }), +}; + +describe('SessionService', () => { + let service: SessionService; + + beforeEach(async () => { + mockRedis.multi.mockReturnValue(mockMulti); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SessionService, + { provide: SESSION_REDIS_CLIENT, useValue: mockRedis }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + service = module.get(SessionService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('createSession', () => { + it('should create a session and return a sid', async () => { + mockRedis.set.mockResolvedValue('OK'); + + const sid = await service.createSession('user-123', { role: 'student' }); + + expect(typeof sid).toBe('string'); + expect(sid.length).toBeGreaterThan(0); + expect(mockRedis.set).toHaveBeenCalledWith( + expect.stringContaining('auth:sess:'), + expect.any(String), + 'EX', + 604800, + ); + }); + + it('should store userId and metadata in session payload', async () => { + mockRedis.set.mockResolvedValue('OK'); + + await service.createSession('user-456', { plan: 'premium' }); + + const payload = JSON.parse(mockRedis.set.mock.calls[0][1]); + expect(payload.userId).toBe('user-456'); + expect(payload.metadata.plan).toBe('premium'); + expect(payload.version).toBe(1); + }); + }); + + describe('getSession', () => { + it('should return parsed session when found in Redis', async () => { + const sessionData = { + sid: 'test-sid', + userId: 'user-123', + metadata: {}, + version: 1, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + mockRedis.get.mockResolvedValue(JSON.stringify(sessionData)); + + const result = await service.getSession('test-sid'); + + expect(result).not.toBeNull(); + expect(result?.userId).toBe('user-123'); + expect(result?.version).toBe(1); + }); + + it('should return null when session does not exist', async () => { + mockRedis.get.mockResolvedValue(null); + + const result = await service.getSession('nonexistent-sid'); + + expect(result).toBeNull(); + }); + + it('should return null when session payload is invalid JSON', async () => { + mockRedis.get.mockResolvedValue('not-valid-json{{{'); + + const result = await service.getSession('bad-sid'); + + expect(result).toBeNull(); + }); + }); + + describe('touchSession', () => { + it('should update metadata and increment version', async () => { + const sessionData = { + sid: 'test-sid', + userId: 'user-123', + metadata: { role: 'student' }, + version: 1, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + mockRedis.get.mockResolvedValue(JSON.stringify(sessionData)); + + await service.touchSession('test-sid', { lastPage: '/dashboard' }); + + expect(mockMulti.set).toHaveBeenCalled(); + expect(mockMulti.expire).toHaveBeenCalled(); + expect(mockMulti.exec).toHaveBeenCalled(); + + const updatedPayload = JSON.parse(mockMulti.set.mock.calls[0][1]); + expect(updatedPayload.version).toBe(2); + expect(updatedPayload.metadata.lastPage).toBe('/dashboard'); + expect(updatedPayload.metadata.role).toBe('student'); + }); + + it('should do nothing when session does not exist', async () => { + mockRedis.get.mockResolvedValue(null); + + await service.touchSession('nonexistent-sid'); + + expect(mockMulti.exec).not.toHaveBeenCalled(); + }); + }); + + describe('removeSession', () => { + it('should delete session from Redis', async () => { + mockRedis.del.mockResolvedValue(1); + + await service.removeSession('test-sid'); + + expect(mockRedis.del).toHaveBeenCalledWith('auth:sess:test-sid'); + }); + }); + + describe('migrateSession', () => { + it('should migrate session to new sid and delete old one', async () => { + const sessionData = { + sid: 'old-sid', + userId: 'user-123', + metadata: {}, + version: 1, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + mockRedis.get.mockResolvedValue(JSON.stringify(sessionData)); + + const newSid = await service.migrateSession('old-sid', '00000000-0000-0000-0000-000000000001'); + + expect(newSid).toBe('00000000-0000-0000-0000-000000000001'); + expect(mockMulti.set).toHaveBeenCalled(); + expect(mockMulti.del).toHaveBeenCalled(); + expect(mockMulti.exec).toHaveBeenCalled(); + }); + + it('should return newSid unchanged when old session does not exist', async () => { + mockRedis.get.mockResolvedValue(null); + + const newSid = await service.migrateSession('nonexistent-sid', '00000000-0000-0000-0000-000000000001'); + + expect(newSid).toBe('00000000-0000-0000-0000-000000000001'); + expect(mockMulti.exec).not.toHaveBeenCalled(); + }); + }); + + describe('withLock', () => { + it('should acquire lock and execute handler', async () => { + mockRedis.set.mockResolvedValue('OK'); + mockRedis.eval.mockResolvedValue(1); + + const handler = jest.fn().mockResolvedValue('result'); + const result = await service.withLock('test-lock', handler); + + expect(result).toBe('result'); + expect(handler).toHaveBeenCalled(); + }); + + it('should throw when lock cannot be acquired', async () => { + mockRedis.set.mockResolvedValue(null); + + await expect( + service.withLock('busy-lock', jest.fn()), + ).rejects.toThrow('Could not acquire lock: busy-lock'); + }); + }); +}); \ No newline at end of file