diff --git a/src/game-session/controllers/game-session.controller.ts b/src/game-session/controllers/game-session.controller.ts index 3810164..5a11280 100644 --- a/src/game-session/controllers/game-session.controller.ts +++ b/src/game-session/controllers/game-session.controller.ts @@ -56,6 +56,27 @@ export class GameSessionController { }; } + @Post(':id/resume') + async resumeSession( + @Param('id') sessionId: string, + @Query('userId') userId: string, + ) { + const session = await this.sessionService.resumeById(sessionId, userId); + return { + message: 'Session resumed successfully', + session, + }; + } + + @Get('suspended') + async getSuspendedSessions(@Query('userId') userId: string) { + const sessions = await this.sessionService.getSuspendedSessions(userId); + return { + message: 'Suspended sessions retrieved', + sessions, + }; + } + @Post(':id/end') async endSession( @Param('id') sessionId: string, diff --git a/src/game-session/crash-recovery.spec.ts b/src/game-session/crash-recovery.spec.ts new file mode 100644 index 0000000..14f3e38 --- /dev/null +++ b/src/game-session/crash-recovery.spec.ts @@ -0,0 +1,286 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { GameSession } from './entities/game-session.entity'; +import { GameSessionService } from './services/game-session.service'; +import { CrashRecoveryJob } from './services/crash-recovery.job'; +import { PlayerEventsService } from '../player-events/player-events.service'; +import { PuzzleVersionService } from '../puzzles/services/puzzle-version.service'; +import { CacheService } from '../cache/services/cache.service'; +import { NotificationService } from '../notifications/notification.service'; + +const SESSION_ID = 'session-uuid-1'; +const USER_ID = 'user-uuid-1'; +const GRACE_SECS = 300; + +function makeSession(overrides: Partial = {}): GameSession { + return { + id: SESSION_ID, + userId: USER_ID, + puzzleId: 'puzzle-1', + puzzleVersionId: undefined, + status: 'IN_PROGRESS', + state: { progressPercent: 40 }, + lastActiveAt: new Date(), + suspendedAt: null, + totalMoves: 10, + hintsUsed: 2, + duration: 5, + replayLog: [], + shareCode: null, + isSpectatorAllowed: false, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as GameSession; +} + +function makeMockRepo(session?: GameSession) { + return { + create: jest.fn((d) => d), + save: jest.fn((d) => Promise.resolve({ ...d })), + findOneBy: jest.fn(() => Promise.resolve(session ?? null)), + findOne: jest.fn(() => Promise.resolve(session ?? null)), + find: jest.fn(() => Promise.resolve(session ? [session] : [])), + update: jest.fn(() => Promise.resolve({ affected: 1 })), + }; +} + +describe('Crash Recovery — GameSessionService', () => { + let service: GameSessionService; + let repo: ReturnType; + let cache: { get: jest.Mock; set: jest.Mock; delete: jest.Mock }; + + beforeEach(async () => { + const session = makeSession(); + repo = makeMockRepo(session); + cache = { get: jest.fn(), set: jest.fn(), delete: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GameSessionService, + { provide: getRepositoryToken(GameSession), useValue: repo }, + { provide: PlayerEventsService, useValue: { emitPlayerEvent: jest.fn() } }, + { provide: PuzzleVersionService, useValue: { getCurrentVersionId: jest.fn() } }, + { provide: CacheService, useValue: cache }, + ], + }).compile(); + + service = module.get(GameSessionService); + }); + + describe('suspend()', () => { + it('marks the session SUSPENDED and records suspendedAt', async () => { + const result = await service.suspend(SESSION_ID); + expect(result.status).toBe('SUSPENDED'); + expect(result.suspendedAt).toBeInstanceOf(Date); + }); + + it('saves a state snapshot to Redis with extended TTL', async () => { + await service.suspend(SESSION_ID); + expect(cache.set).toHaveBeenCalledWith( + `session:suspended:${SESSION_ID}`, + expect.any(Object), + expect.objectContaining({ ttl: expect.any(Number) }), + ); + }); + + it('throws NotFoundException when session does not exist', async () => { + repo.findOneBy.mockResolvedValueOnce(null); + await expect(service.suspend(SESSION_ID)).rejects.toThrow(NotFoundException); + }); + }); + + describe('resumeById()', () => { + beforeEach(() => { + // Put the session in SUSPENDED state within the grace window + const suspended = makeSession({ + status: 'SUSPENDED', + suspendedAt: new Date(Date.now() - 60_000), // 1 min ago, well within 5 min window + }); + repo.findOneBy.mockResolvedValue(suspended); + cache.get.mockResolvedValue({ progressPercent: 40, currentLevel: 2 }); + }); + + it('restores status to IN_PROGRESS and clears suspendedAt', async () => { + const result = await service.resumeById(SESSION_ID, USER_ID); + expect(result.status).toBe('IN_PROGRESS'); + expect(result.suspendedAt).toBeNull(); + }); + + it('restores state from Redis snapshot', async () => { + const result = await service.resumeById(SESSION_ID, USER_ID); + expect(result.state).toEqual({ progressPercent: 40, currentLevel: 2 }); + expect(cache.delete).toHaveBeenCalledWith(`session:suspended:${SESSION_ID}`); + }); + + it('falls back to DB state when Redis snapshot is missing', async () => { + cache.get.mockResolvedValueOnce(null); + const suspended = makeSession({ + status: 'SUSPENDED', + suspendedAt: new Date(Date.now() - 60_000), + state: { progressPercent: 40 }, + }); + repo.findOneBy.mockResolvedValueOnce(suspended); + + const result = await service.resumeById(SESSION_ID, USER_ID); + expect(result.state).toEqual({ progressPercent: 40 }); + }); + + it('throws ForbiddenException when grace window has expired', async () => { + const expiredAt = new Date(Date.now() - (GRACE_SECS + 60) * 1000); + const expired = makeSession({ status: 'SUSPENDED', suspendedAt: expiredAt }); + repo.findOneBy.mockResolvedValueOnce(expired); + + await expect(service.resumeById(SESSION_ID, USER_ID)).rejects.toThrow(ForbiddenException); + }); + + it('throws ForbiddenException when session belongs to a different user', async () => { + const otherUser = makeSession({ status: 'SUSPENDED', suspendedAt: new Date(), userId: 'other-user' }); + repo.findOneBy.mockResolvedValueOnce(otherUser); + + await expect(service.resumeById(SESSION_ID, USER_ID)).rejects.toThrow(ForbiddenException); + }); + + it('throws ForbiddenException when session is not suspended', async () => { + const active = makeSession({ status: 'IN_PROGRESS' }); + repo.findOneBy.mockResolvedValueOnce(active); + + await expect(service.resumeById(SESSION_ID, USER_ID)).rejects.toThrow(ForbiddenException); + }); + + it('throws NotFoundException when session does not exist', async () => { + repo.findOneBy.mockResolvedValueOnce(null); + await expect(service.resumeById(SESSION_ID, USER_ID)).rejects.toThrow(NotFoundException); + }); + }); + + describe('getSuspendedSessions()', () => { + it('returns all SUSPENDED sessions for the user', async () => { + const suspended = makeSession({ status: 'SUSPENDED', suspendedAt: new Date() }); + repo.find.mockResolvedValueOnce([suspended]); + + const result = await service.getSuspendedSessions(USER_ID); + expect(result).toHaveLength(1); + expect(result[0].status).toBe('SUSPENDED'); + expect(repo.find).toHaveBeenCalledWith({ where: { userId: USER_ID, status: 'SUSPENDED' } }); + }); + + it('returns empty array when no suspended sessions exist', async () => { + repo.find.mockResolvedValueOnce([]); + const result = await service.getSuspendedSessions(USER_ID); + expect(result).toHaveLength(0); + }); + }); +}); + +describe('Crash Recovery — CrashRecoveryJob', () => { + let job: CrashRecoveryJob; + let repo: ReturnType; + let notificationService: { emitPushEvent: jest.Mock }; + let playerEventsService: { emitPlayerEvent: jest.Mock }; + let cache: { get: jest.Mock; set: jest.Mock; delete: jest.Mock }; + + beforeEach(async () => { + notificationService = { emitPushEvent: jest.fn().mockResolvedValue(undefined) }; + playerEventsService = { emitPlayerEvent: jest.fn().mockResolvedValue(undefined) }; + cache = { get: jest.fn(), set: jest.fn(), delete: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CrashRecoveryJob, + { provide: getRepositoryToken(GameSession), useValue: repo = makeMockRepo() }, + { provide: NotificationService, useValue: notificationService }, + { provide: PlayerEventsService, useValue: playerEventsService }, + { provide: CacheService, useValue: cache }, + ], + }).compile(); + + job = module.get(CrashRecoveryJob); + }); + + it('does nothing when there are no expired suspended sessions', async () => { + repo.find.mockResolvedValueOnce([]); + await job.expireSuspendedSessions(); + expect(repo.save).not.toHaveBeenCalled(); + expect(notificationService.emitPushEvent).not.toHaveBeenCalled(); + }); + + it('marks expired sessions as ABANDONED', async () => { + const expiredAt = new Date(Date.now() - (GRACE_SECS + 120) * 1000); + const session = makeSession({ status: 'SUSPENDED', suspendedAt: expiredAt }); + repo.find.mockResolvedValueOnce([session]); + + await job.expireSuspendedSessions(); + + expect(repo.save).toHaveBeenCalledWith( + expect.objectContaining({ status: 'ABANDONED' }), + ); + }); + + it('emits puzzle.abandoned player event with stats payload', async () => { + const expiredAt = new Date(Date.now() - (GRACE_SECS + 120) * 1000); + const session = makeSession({ + status: 'SUSPENDED', + suspendedAt: expiredAt, + hintsUsed: 3, + duration: 7, + state: { progressPercent: 65 }, + }); + repo.find.mockResolvedValueOnce([session]); + + await job.expireSuspendedSessions(); + + expect(playerEventsService.emitPlayerEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: 'puzzle.abandoned', + payload: expect.objectContaining({ + timePlayed: 7, + hintsUsed: 3, + progressPercent: 65, + }), + }), + ); + }); + + it('sends a push notification to the player', async () => { + const expiredAt = new Date(Date.now() - (GRACE_SECS + 120) * 1000); + const session = makeSession({ status: 'SUSPENDED', suspendedAt: expiredAt }); + repo.find.mockResolvedValueOnce([session]); + + await job.expireSuspendedSessions(); + + expect(notificationService.emitPushEvent).toHaveBeenCalledWith( + USER_ID, + 'sessionExpired', + expect.objectContaining({ + title: 'Session Expired', + }), + ); + }); + + it('cleans up the Redis snapshot after expiry', async () => { + const expiredAt = new Date(Date.now() - (GRACE_SECS + 120) * 1000); + const session = makeSession({ status: 'SUSPENDED', suspendedAt: expiredAt }); + repo.find.mockResolvedValueOnce([session]); + + await job.expireSuspendedSessions(); + + expect(cache.delete).toHaveBeenCalledWith(`session:suspended:${SESSION_ID}`); + }); + + it('continues processing other sessions if one throws', async () => { + const expiredAt = new Date(Date.now() - (GRACE_SECS + 120) * 1000); + const s1 = makeSession({ id: 'session-1', status: 'SUSPENDED', suspendedAt: expiredAt }); + const s2 = makeSession({ id: 'session-2', status: 'SUSPENDED', suspendedAt: expiredAt }); + repo.find.mockResolvedValueOnce([s1, s2]); + + // First save throws, second should still proceed + repo.save + .mockRejectedValueOnce(new Error('DB error')) + .mockResolvedValueOnce({ ...s2, status: 'ABANDONED' }); + + await expect(job.expireSuspendedSessions()).resolves.not.toThrow(); + expect(repo.save).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/game-session/entities/game-session.entity.ts b/src/game-session/entities/game-session.entity.ts index ea82194..5fbc719 100644 --- a/src/game-session/entities/game-session.entity.ts +++ b/src/game-session/entities/game-session.entity.ts @@ -32,7 +32,10 @@ export class GameSession { puzzleVersionId?: string; @Column({ default: 'IN_PROGRESS' }) - status: 'IN_PROGRESS' | 'COMPLETED' | 'ABANDONED'; + status: 'IN_PROGRESS' | 'COMPLETED' | 'ABANDONED' | 'SUSPENDED'; + + @Column({ type: 'timestamp', nullable: true }) + suspendedAt: Date | null; @Column('jsonb', { default: {} }) state: Record; // Dynamic game state object @@ -43,6 +46,9 @@ export class GameSession { @Column({ type: 'int', default: 0 }) totalMoves: number; + @Column({ type: 'int', default: 0 }) + hintsUsed: number; + @Column({ type: 'float', default: 0 }) duration: number; // in minutes diff --git a/src/game-session/game-session.module.ts b/src/game-session/game-session.module.ts index 1363ccc..8f53e6e 100644 --- a/src/game-session/game-session.module.ts +++ b/src/game-session/game-session.module.ts @@ -7,14 +7,17 @@ import { GameSessionService } from './services/game-session.service'; import { SpectatorService } from './services/spectator.service'; import { CleanupSessionJob } from './services/cleanup-session.job'; import { AutosaveSessionJob } from './services/autosave-session.job'; +import { CrashRecoveryJob } from './services/crash-recovery.job'; import { GameSessionController } from './controllers/game-session.controller'; import { PuzzlesModule } from '../puzzles/puzzles.module'; +import { NotificationsModule } from '../notifications/notifications.module'; @Module({ imports: [ TypeOrmModule.forFeature([GameSession, Spectator]), forwardRef(() => PlayerEventsModule), forwardRef(() => PuzzlesModule), + NotificationsModule, ], controllers: [GameSessionController], providers: [ @@ -22,6 +25,7 @@ import { PuzzlesModule } from '../puzzles/puzzles.module'; SpectatorService, CleanupSessionJob, AutosaveSessionJob, + CrashRecoveryJob, ], }) export class GameSessionModule {} diff --git a/src/game-session/services/crash-recovery.job.ts b/src/game-session/services/crash-recovery.job.ts new file mode 100644 index 0000000..2e826ad --- /dev/null +++ b/src/game-session/services/crash-recovery.job.ts @@ -0,0 +1,92 @@ +// services/crash-recovery.job.ts +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, LessThan } from 'typeorm'; +import { GameSession } from '../entities/game-session.entity'; +import { NotificationService } from '../../notifications/notification.service'; +import { PlayerEventsService } from '../../player-events/player-events.service'; +import { CacheService } from '../../cache/services/cache.service'; + +@Injectable() +export class CrashRecoveryJob { + private readonly logger = new Logger(CrashRecoveryJob.name); + + constructor( + @InjectRepository(GameSession) + private readonly sessionRepo: Repository, + private readonly notificationService: NotificationService, + private readonly playerEventsService: PlayerEventsService, + private readonly cacheService: CacheService, + ) {} + + @Cron(CronExpression.EVERY_MINUTE) + async expireSuspendedSessions() { + const graceWindowSecs = parseInt( + process.env.SESSION_GRACE_WINDOW_SECONDS ?? '300', + 10, + ); + const cutoff = new Date(Date.now() - graceWindowSecs * 1000); + + const expired = await this.sessionRepo.find({ + where: { status: 'SUSPENDED', suspendedAt: LessThan(cutoff) }, + }); + + if (!expired.length) return; + + this.logger.log(`Expiring ${expired.length} suspended session(s)`); + + for (const session of expired) { + try { + const timePlayed = session.duration; + const hintsUsed = session.hintsUsed; + const progressPercent = + (session.state?.progressPercent as number) ?? 0; + + session.status = 'ABANDONED'; + session.lastActiveAt = new Date(); + await this.sessionRepo.save(session); + + await this.playerEventsService.emitPlayerEvent({ + userId: session.userId, + sessionId: session.id, + eventType: 'puzzle.abandoned', + payload: { + sessionId: session.id, + reason: 'session expired after disconnect grace window', + endedAt: session.lastActiveAt, + timePlayed, + hintsUsed, + progressPercent, + }, + }); + + await this.notificationService.emitPushEvent( + session.userId, + 'sessionExpired', + { + title: 'Session Expired', + body: 'Your paused game session has expired. Start a new game to continue!', + data: { + sessionId: session.id, + progressPercent, + timePlayed, + hintsUsed, + }, + }, + ); + + await this.cacheService.delete(`session:suspended:${session.id}`); + + this.logger.log( + `Session ${session.id} expired — timePlayed=${timePlayed}m, hints=${hintsUsed}, progress=${progressPercent}%`, + ); + } catch (err) { + this.logger.error( + `Failed to expire session ${session.id}`, + (err as Error).stack, + ); + } + } + } +} diff --git a/src/game-session/services/game-session.service.ts b/src/game-session/services/game-session.service.ts index 21c7f3e..ef9c6c3 100644 --- a/src/game-session/services/game-session.service.ts +++ b/src/game-session/services/game-session.service.ts @@ -1,10 +1,15 @@ // services/game-session.service.ts -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, LessThan } from 'typeorm'; import { GameSession } from '../entities/game-session.entity'; import { PlayerEventsService } from '../../player-events/player-events.service'; import { PuzzleVersionService } from '../../puzzles/services/puzzle-version.service'; +import { CacheService } from '../../cache/services/cache.service'; + +const SUSPENDED_KEY = (id: string) => `session:suspended:${id}`; +const graceWindowSecs = () => + parseInt(process.env.SESSION_GRACE_WINDOW_SECONDS ?? '300', 10); @Injectable() export class GameSessionService { @@ -13,6 +18,7 @@ export class GameSessionService { private readonly sessionRepo: Repository, private readonly playerEventsService: PlayerEventsService, private readonly puzzleVersionService: PuzzleVersionService, + private readonly cacheService: CacheService, ) {} async create(userId: string, puzzleId?: string) { @@ -93,4 +99,48 @@ export class GameSessionService { async getById(sessionId: string) { return this.sessionRepo.findOne({ where: { id: sessionId } }); } + + async suspend(sessionId: string) { + const session = await this.sessionRepo.findOneBy({ id: sessionId }); + if (!session) throw new NotFoundException('Session not found'); + + session.status = 'SUSPENDED'; + session.suspendedAt = new Date(); + session.lastActiveAt = new Date(); + const saved = await this.sessionRepo.save(session); + + // Snapshot full state to Redis so reconnect can restore it exactly + const ttl = graceWindowSecs() + 60; // small buffer beyond the grace window + await this.cacheService.set(SUSPENDED_KEY(sessionId), saved.state, { ttl }); + + return saved; + } + + async resumeById(sessionId: string, userId: string) { + const session = await this.sessionRepo.findOneBy({ id: sessionId }); + if (!session) throw new NotFoundException('Session not found'); + if (session.userId !== userId) throw new ForbiddenException('Session does not belong to this user'); + if (session.status !== 'SUSPENDED') throw new ForbiddenException('Session is not suspended'); + + const cutoff = new Date(Date.now() - graceWindowSecs() * 1000); + if (session.suspendedAt && session.suspendedAt < cutoff) { + throw new ForbiddenException('Grace window has expired — session cannot be resumed'); + } + + // Restore state from Redis snapshot (falls back to DB state if cache miss) + const snapshot = await this.cacheService.get>(SUSPENDED_KEY(sessionId)); + if (snapshot) { + session.state = snapshot; + await this.cacheService.delete(SUSPENDED_KEY(sessionId)); + } + + session.status = 'IN_PROGRESS'; + session.suspendedAt = null; + session.lastActiveAt = new Date(); + return this.sessionRepo.save(session); + } + + async getSuspendedSessions(userId: string) { + return this.sessionRepo.find({ where: { userId, status: 'SUSPENDED' } }); + } }