Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
220 changes: 220 additions & 0 deletions src/session/session.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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>(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');
});
});
});
Loading