fix: auto-cleanup orphaned arcade sessions without valid rooms

Fixes critical bug where users were redirected to non-existent games
after room TTL deletion. This occurred because:

1. User creates arcade session in a room
2. Room expires via TTL cleanup
3. Session persists as orphan (roomId = null or points to deleted room)
4. useArcadeRedirect finds orphaned session
5. User redirected to /arcade/matching with no valid game state

Changes:

**Session validation (session-manager.ts)**
- getArcadeSession() now validates room association
- Auto-deletes sessions with no roomId
- Auto-deletes sessions pointing to non-existent rooms
- Returns undefined for orphaned sessions

**Session creation (session-manager.ts, route.ts, socket-server.ts)**
- createArcadeSession() now requires roomId parameter
- Socket server checks for existing user rooms before creating new ones
- Socket server auto-creates rooms when needed for backward compatibility
- API route requires roomId in request body

**Tests**
- Added orphaned-session-cleanup.test.ts: Unit/integration tests
- Added orphaned-session.e2e.test.ts: E2E regression tests
- Updated existing tests to provide roomId
- Tests cover TTL deletion, null roomId, and race conditions

This ensures sessions are always tied to valid rooms and prevents
orphaned session redirect loops.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-10-08 07:03:36 -05:00
parent 6800747f80
commit 3c002ab29d
7 changed files with 500 additions and 9 deletions

View File

@@ -0,0 +1,203 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { eq } from 'drizzle-orm'
import { db, schema } from '../src/db'
import { createArcadeSession, getArcadeSession } from '../src/lib/arcade/session-manager'
import { cleanupExpiredRooms, createRoom } from '../src/lib/arcade/room-manager'
/**
* E2E Test: Orphaned Session After Room TTL Deletion
*
* This test simulates the exact scenario reported by the user:
* 1. User creates a game session in a room
* 2. Room expires via TTL cleanup
* 3. User navigates to /arcade
* 4. System should NOT redirect to the orphaned game
* 5. User should see the arcade lobby normally
*/
describe('E2E: Orphaned Session Cleanup on Navigation', () => {
const testUserId = 'e2e-user-id'
const testGuestId = 'e2e-guest-id'
let testRoomId: string
beforeEach(async () => {
// Create test user (simulating new or returning visitor)
await db
.insert(schema.users)
.values({
id: testUserId,
guestId: testGuestId,
createdAt: new Date(),
})
.onConflictDoNothing()
})
afterEach(async () => {
// Clean up test data
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.userId, testUserId))
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
if (testRoomId) {
try {
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, testRoomId))
} catch {
// Room may already be deleted
}
}
})
it('should not redirect user to orphaned game after room TTL cleanup', async () => {
// === SETUP PHASE ===
// User creates or joins a room
const room = await createRoom({
name: 'My Game Room',
createdBy: testGuestId,
creatorName: 'Test Player',
gameName: 'matching',
gameConfig: { difficulty: 6, gameType: 'abacus-numeral', turnTimer: 30 },
ttlMinutes: 1, // Short TTL for testing
})
testRoomId = room.id
// User starts a game session
const session = await createArcadeSession({
userId: testGuestId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: {
gamePhase: 'playing',
cards: [],
gameCards: [],
flippedCards: [],
matchedPairs: 0,
totalPairs: 6,
currentPlayer: 'player-1',
difficulty: 6,
gameType: 'abacus-numeral',
turnTimer: 30,
},
activePlayers: ['player-1'],
roomId: room.id,
})
// Verify session was created
expect(session).toBeDefined()
expect(session.roomId).toBe(room.id)
// === TTL EXPIRATION PHASE ===
// Simulate time passing - room's TTL expires
// Set lastActivity to past so cleanup detects it
await db
.update(schema.arcadeRooms)
.set({
lastActivity: new Date(Date.now() - 2 * 60 * 1000), // 2 minutes ago
})
.where(eq(schema.arcadeRooms.id, room.id))
// Run cleanup (simulating background cleanup job)
const deletedCount = await cleanupExpiredRooms()
expect(deletedCount).toBeGreaterThan(0) // Room should be deleted
// === USER NAVIGATION PHASE ===
// User navigates to /arcade (arcade lobby)
// The useArcadeRedirect hook calls getArcadeSession to check for active session
const activeSession = await getArcadeSession(testGuestId)
// === ASSERTION PHASE ===
// Expected behavior: NO active session returned
// This prevents redirect to /arcade/matching which would be broken
expect(activeSession).toBeUndefined()
// Verify the orphaned session was cleaned up from database
const [orphanedSessionCheck] = await db
.select()
.from(schema.arcadeSessions)
.where(eq(schema.arcadeSessions.userId, testUserId))
.limit(1)
expect(orphanedSessionCheck).toBeUndefined()
})
it('should allow user to start new game after orphaned session cleanup', async () => {
// === SETUP: Create and orphan a session ===
const oldRoom = await createRoom({
name: 'Old Room',
createdBy: testGuestId,
creatorName: 'Test Player',
gameName: 'matching',
gameConfig: { difficulty: 6 },
ttlMinutes: 1,
})
await createArcadeSession({
userId: testGuestId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: { gamePhase: 'setup' },
activePlayers: ['player-1'],
roomId: oldRoom.id,
})
// Delete room (TTL cleanup)
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, oldRoom.id))
// === ACTION: User tries to access arcade ===
const orphanedSession = await getArcadeSession(testGuestId)
expect(orphanedSession).toBeUndefined() // Orphan cleaned up
// === ACTION: User creates new room and session ===
const newRoom = await createRoom({
name: 'New Room',
createdBy: testGuestId,
creatorName: 'Test Player',
gameName: 'matching',
gameConfig: { difficulty: 8 },
ttlMinutes: 60,
})
testRoomId = newRoom.id
const newSession = await createArcadeSession({
userId: testGuestId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: { gamePhase: 'setup' },
activePlayers: ['player-1', 'player-2'],
roomId: newRoom.id,
})
// === ASSERTION: New session works correctly ===
expect(newSession).toBeDefined()
expect(newSession.roomId).toBe(newRoom.id)
const activeSession = await getArcadeSession(testGuestId)
expect(activeSession).toBeDefined()
expect(activeSession?.roomId).toBe(newRoom.id)
})
it('should handle race condition: getArcadeSession called while room is being deleted', async () => {
// Create room and session
const room = await createRoom({
name: 'Race Condition Room',
createdBy: testGuestId,
creatorName: 'Test Player',
gameName: 'matching',
gameConfig: { difficulty: 6 },
ttlMinutes: 60,
})
testRoomId = room.id
await createArcadeSession({
userId: testGuestId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: { gamePhase: 'setup' },
activePlayers: ['player-1'],
roomId: room.id,
})
// Simulate race: delete room while getArcadeSession is checking
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room.id))
// Should gracefully handle and return undefined
const result = await getArcadeSession(testGuestId)
expect(result).toBeUndefined()
})
})

View File

@@ -7,7 +7,9 @@ import {
getArcadeSession,
updateSessionActivity,
} from './src/lib/arcade/session-manager'
import type { GameMove } from './src/lib/arcade/validation'
import { createRoom, getRoomById } from './src/lib/arcade/room-manager'
import { getUserRooms } from './src/lib/arcade/room-membership'
import type { GameMove, GameName } from './src/lib/arcade/validation'
import { matchingGameValidator } from './src/lib/arcade/validation/MatchingGameValidator'
export function initializeSocketServer(httpServer: HTTPServer) {
@@ -85,15 +87,52 @@ export function initializeSocketServer(httpServer: HTTPServer) {
turnTimer: 30,
})
// Check if user is already in a room for this game
const userRoomIds = await getUserRooms(data.userId)
let room = null
// Look for an existing active room for this game
for (const roomId of userRoomIds) {
const existingRoom = await getRoomById(roomId)
if (
existingRoom &&
existingRoom.gameName === 'matching' &&
existingRoom.status !== 'finished'
) {
room = existingRoom
console.log('🏠 Using existing room:', room.code)
break
}
}
// If no suitable room exists, create a new one
if (!room) {
room = await createRoom({
name: 'Auto-generated Room',
createdBy: data.userId,
creatorName: 'Player',
gameName: 'matching' as GameName,
gameConfig: {
difficulty: 6,
gameType: 'abacus-numeral',
turnTimer: 30,
},
ttlMinutes: 60,
})
console.log('🏠 Created new room:', room.code)
}
// Now create the session linked to the room
await createArcadeSession({
userId: data.userId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState,
activePlayers,
roomId: room.id,
})
console.log('✅ Session created successfully')
console.log('✅ Session created successfully with room association')
// Notify all connected clients about the new session
const newSession = await getArcadeSession(data.userId)

View File

@@ -47,10 +47,16 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { userId, gameName, gameUrl, initialState, activePlayers } = body
const { userId, gameName, gameUrl, initialState, activePlayers, roomId } = body
if (!userId || !gameName || !gameUrl || !initialState || !activePlayers) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
if (!userId || !gameName || !gameUrl || !initialState || !activePlayers || !roomId) {
return NextResponse.json(
{
error:
'Missing required fields (userId, gameName, gameUrl, initialState, activePlayers, roomId)',
},
{ status: 400 }
)
}
const session = await createArcadeSession({
@@ -59,6 +65,7 @@ export async function POST(request: NextRequest) {
gameUrl,
initialState,
activePlayers,
roomId,
})
return NextResponse.json({

View File

@@ -8,6 +8,7 @@ import {
deleteArcadeSession,
getArcadeSession,
} from '../session-manager'
import { createRoom, deleteRoom } from '../room-manager'
/**
* Integration test for the full arcade session flow
@@ -16,6 +17,7 @@ import {
describe('Arcade Session Integration', () => {
const testUserId = 'integration-test-user'
const testGuestId = 'integration-test-guest'
let testRoomId: string
beforeEach(async () => {
// Create test user
@@ -27,11 +29,25 @@ describe('Arcade Session Integration', () => {
createdAt: new Date(),
})
.onConflictDoNothing()
// Create test room
const room = await createRoom({
name: 'Test Room',
createdBy: testGuestId,
creatorName: 'Test User',
gameName: 'matching',
gameConfig: { difficulty: 6, gameType: 'abacus-numeral', turnTimer: 30 },
ttlMinutes: 60,
})
testRoomId = room.id
})
afterEach(async () => {
// Clean up
await deleteArcadeSession(testUserId)
await deleteArcadeSession(testGuestId)
if (testRoomId) {
await deleteRoom(testRoomId)
}
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
})
@@ -63,11 +79,12 @@ describe('Arcade Session Integration', () => {
}
const session = await createArcadeSession({
userId: testUserId,
userId: testGuestId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState,
activePlayers: ['1'],
roomId: testRoomId,
})
expect(session).toBeDefined()
@@ -165,11 +182,12 @@ describe('Arcade Session Integration', () => {
}
await createArcadeSession({
userId: testUserId,
userId: testGuestId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: playingState,
activePlayers: ['1'],
roomId: testRoomId,
})
// First move: flip card 1

View File

@@ -0,0 +1,199 @@
import { eq } from 'drizzle-orm'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { db, schema } from '@/db'
import { createArcadeSession, deleteArcadeSession, getArcadeSession } from '../session-manager'
import { createRoom, deleteRoom } from '../room-manager'
/**
* Integration tests for orphaned session cleanup
*
* These tests ensure that sessions without valid rooms are properly
* cleaned up to prevent the bug where users get redirected to
* non-existent games when rooms have been TTL deleted.
*/
describe('Orphaned Session Cleanup', () => {
const testUserId = 'orphan-test-user-id'
const testGuestId = 'orphan-test-guest-id'
let testRoomId: string
beforeEach(async () => {
// Create test user
await db
.insert(schema.users)
.values({
id: testUserId,
guestId: testGuestId,
createdAt: new Date(),
})
.onConflictDoNothing()
// Create test room
const room = await createRoom({
name: 'Orphan Test Room',
createdBy: testGuestId,
creatorName: 'Test User',
gameName: 'matching',
gameConfig: { difficulty: 6, gameType: 'abacus-numeral', turnTimer: 30 },
ttlMinutes: 60,
})
testRoomId = room.id
})
afterEach(async () => {
// Clean up
await deleteArcadeSession(testGuestId)
if (testRoomId) {
try {
await deleteRoom(testRoomId)
} catch {
// Room may have been deleted in test
}
}
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
})
it('should return undefined when session has no roomId', async () => {
// Create a session with a valid room
const session = await createArcadeSession({
userId: testGuestId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: { gamePhase: 'setup' },
activePlayers: ['player-1'],
roomId: testRoomId,
})
expect(session).toBeDefined()
expect(session.roomId).toBe(testRoomId)
// Manually set roomId to null to simulate orphaned session
await db
.update(schema.arcadeSessions)
.set({ roomId: null })
.where(eq(schema.arcadeSessions.userId, testUserId))
// Getting the session should auto-delete it and return undefined
const result = await getArcadeSession(testGuestId)
expect(result).toBeUndefined()
// Verify session was actually deleted
const [directCheck] = await db
.select()
.from(schema.arcadeSessions)
.where(eq(schema.arcadeSessions.userId, testUserId))
.limit(1)
expect(directCheck).toBeUndefined()
})
it('should return undefined when session room has been deleted', async () => {
// Create a session with a valid room
const session = await createArcadeSession({
userId: testGuestId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: { gamePhase: 'setup' },
activePlayers: ['player-1'],
roomId: testRoomId,
})
expect(session).toBeDefined()
expect(session.roomId).toBe(testRoomId)
// Delete the room (simulating TTL expiration)
await deleteRoom(testRoomId)
// Getting the session should detect missing room and auto-delete
const result = await getArcadeSession(testGuestId)
expect(result).toBeUndefined()
// Verify session was actually deleted
const [directCheck] = await db
.select()
.from(schema.arcadeSessions)
.where(eq(schema.arcadeSessions.userId, testUserId))
.limit(1)
expect(directCheck).toBeUndefined()
})
it('should return valid session when room exists', async () => {
// Create a session with a valid room
const session = await createArcadeSession({
userId: testGuestId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: { gamePhase: 'setup' },
activePlayers: ['player-1'],
roomId: testRoomId,
})
expect(session).toBeDefined()
// Getting the session should work fine when room exists
const result = await getArcadeSession(testGuestId)
expect(result).toBeDefined()
expect(result?.roomId).toBe(testRoomId)
expect(result?.currentGame).toBe('matching')
})
it('should handle multiple getArcadeSession calls idempotently', async () => {
// Create a session with a valid room
await createArcadeSession({
userId: testGuestId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: { gamePhase: 'setup' },
activePlayers: ['player-1'],
roomId: testRoomId,
})
// Delete the room
await deleteRoom(testRoomId)
// Multiple calls should all return undefined and not error
const result1 = await getArcadeSession(testGuestId)
const result2 = await getArcadeSession(testGuestId)
const result3 = await getArcadeSession(testGuestId)
expect(result1).toBeUndefined()
expect(result2).toBeUndefined()
expect(result3).toBeUndefined()
})
it('should prevent orphaned sessions from causing redirect loops', async () => {
/**
* Regression test for the specific bug:
* - Room gets TTL deleted
* - Session persists with null/invalid roomId
* - User visits /arcade
* - useArcadeRedirect finds the orphaned session
* - User gets redirected to /arcade/matching
* - But there's no valid game to play
*
* Fix: getArcadeSession should auto-delete orphaned sessions
*/
// 1. Create session with room
await createArcadeSession({
userId: testGuestId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: { gamePhase: 'setup' },
activePlayers: ['player-1'],
roomId: testRoomId,
})
// 2. Room gets TTL deleted
await deleteRoom(testRoomId)
// 3. User's client checks for active session (like useArcadeRedirect does)
const activeSession = await getArcadeSession(testGuestId)
// 4. Should return undefined, preventing redirect
expect(activeSession).toBeUndefined()
// 5. User can now proceed to arcade lobby normally
// (no redirect to non-existent game)
})
})

View File

@@ -104,6 +104,7 @@ describe('session-manager', () => {
gameUrl: '/arcade/matching',
initialState: {},
activePlayers: ['1'],
roomId: 'test-room-id',
})
// Verify user lookup by guestId
@@ -160,6 +161,7 @@ describe('session-manager', () => {
gameUrl: '/arcade/matching',
initialState: {},
activePlayers: ['1'],
roomId: 'test-room-id',
})
// Verify user was created

View File

@@ -13,6 +13,7 @@ export interface CreateSessionOptions {
gameUrl: string
initialState: unknown
activePlayers: string[] // Player IDs (UUIDs)
roomId: string // Required - sessions must be associated with a room
}
export interface SessionUpdateResult {
@@ -71,6 +72,7 @@ export async function createArcadeSession(
gameUrl: options.gameUrl,
gameState: options.initialState as any,
activePlayers: options.activePlayers as any,
roomId: options.roomId, // Associate session with room
startedAt: now,
lastActivityAt: now,
expiresAt,
@@ -96,8 +98,29 @@ export async function getArcadeSession(guestId: string): Promise<schema.ArcadeSe
.where(eq(schema.arcadeSessions.userId, userId))
.limit(1)
if (!session) return undefined
// Check if session has expired
if (session && session.expiresAt < new Date()) {
if (session.expiresAt < new Date()) {
await deleteArcadeSession(guestId)
return undefined
}
// Check if session has a valid room association
// Sessions without rooms are orphaned and should be cleaned up
if (!session.roomId) {
console.log('[Session Manager] Deleting orphaned session without room:', session.userId)
await deleteArcadeSession(guestId)
return undefined
}
// Verify the room still exists
const room = await db.query.arcadeRooms.findFirst({
where: eq(schema.arcadeRooms.id, session.roomId),
})
if (!room) {
console.log('[Session Manager] Deleting session with non-existent room:', session.roomId)
await deleteArcadeSession(guestId)
return undefined
}