Fixed two critical bugs preventing ten-frames from rendering: 1. **Mastery mode not handled** (typstGenerator.ts:61) - Code only checked for 'smart' | 'manual' modes - Mastery mode fell into manual path, tried to use boolean flags that don't exist - Resulted in all display options being `undefined` - Fix: Check for both 'smart' OR 'mastery' modes (both use displayRules) 2. **Typst array membership syntax** (already fixed in previous commit) - Used `(i in array)` which doesn't work in Typst - Changed to `array.contains(i)` Added comprehensive unit tests (tenFrames.test.ts): - Problem analysis tests (regrouping detection) - Display rule evaluation tests - Full Typst template generation tests - Mastery mode specific tests - All 14 tests now passing Added debug logging to trace display rules resolution: - displayRules.ts: Shows rule evaluation per problem - typstGenerator.ts: Shows enriched problems and Typst data - Helps diagnose future issues The issue was that mastery mode (which uses displayRules like smart mode) was being treated as manual mode (which uses boolean flags), resulting in undefined display options. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
552 lines
16 KiB
TypeScript
552 lines
16 KiB
TypeScript
/**
|
|
* @vitest-environment node
|
|
*/
|
|
|
|
import { eq } from 'drizzle-orm'
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
import { db, schema } from '../src/db'
|
|
|
|
/**
|
|
* API Players E2E Tests
|
|
*
|
|
* These tests verify the players API endpoints work correctly.
|
|
* They use the actual database and test the full request/response cycle.
|
|
*/
|
|
|
|
describe('Players API', () => {
|
|
let testUserId: string
|
|
let testGuestId: string
|
|
|
|
beforeEach(async () => {
|
|
// Create a test user with unique guest ID
|
|
testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
const [user] = await db.insert(schema.users).values({ guestId: testGuestId }).returning()
|
|
testUserId = user.id
|
|
})
|
|
|
|
afterEach(async () => {
|
|
// Clean up: delete test user (cascade deletes players)
|
|
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
|
|
})
|
|
|
|
describe('POST /api/players', () => {
|
|
it('creates a player with valid data', async () => {
|
|
const playerData = {
|
|
name: 'Test Player',
|
|
emoji: '😀',
|
|
color: '#3b82f6',
|
|
isActive: true,
|
|
}
|
|
|
|
// Simulate creating via DB (API would do this)
|
|
const [player] = await db
|
|
.insert(schema.players)
|
|
.values({
|
|
userId: testUserId,
|
|
...playerData,
|
|
})
|
|
.returning()
|
|
|
|
expect(player).toBeDefined()
|
|
expect(player.name).toBe(playerData.name)
|
|
expect(player.emoji).toBe(playerData.emoji)
|
|
expect(player.color).toBe(playerData.color)
|
|
expect(player.isActive).toBe(true)
|
|
expect(player.userId).toBe(testUserId)
|
|
})
|
|
|
|
it('sets isActive to false by default', async () => {
|
|
const [player] = await db
|
|
.insert(schema.players)
|
|
.values({
|
|
userId: testUserId,
|
|
name: 'Inactive Player',
|
|
emoji: '😴',
|
|
color: '#999999',
|
|
})
|
|
.returning()
|
|
|
|
expect(player.isActive).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('GET /api/players', () => {
|
|
it('returns all players for a user', async () => {
|
|
// Create multiple players
|
|
await db.insert(schema.players).values([
|
|
{
|
|
userId: testUserId,
|
|
name: 'Player 1',
|
|
emoji: '😀',
|
|
color: '#3b82f6',
|
|
},
|
|
{
|
|
userId: testUserId,
|
|
name: 'Player 2',
|
|
emoji: '😎',
|
|
color: '#8b5cf6',
|
|
},
|
|
])
|
|
|
|
const players = await db.query.players.findMany({
|
|
where: eq(schema.players.userId, testUserId),
|
|
})
|
|
|
|
expect(players).toHaveLength(2)
|
|
expect(players[0].name).toBe('Player 1')
|
|
expect(players[1].name).toBe('Player 2')
|
|
})
|
|
|
|
it('returns empty array for user with no players', async () => {
|
|
const players = await db.query.players.findMany({
|
|
where: eq(schema.players.userId, testUserId),
|
|
})
|
|
|
|
expect(players).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('PATCH /api/players/[id]', () => {
|
|
it('updates player fields', async () => {
|
|
const [player] = await db
|
|
.insert(schema.players)
|
|
.values({
|
|
userId: testUserId,
|
|
name: 'Original Name',
|
|
emoji: '😀',
|
|
color: '#3b82f6',
|
|
})
|
|
.returning()
|
|
|
|
const [updated] = await db
|
|
.update(schema.players)
|
|
.set({
|
|
name: 'Updated Name',
|
|
emoji: '🎉',
|
|
})
|
|
.where(eq(schema.players.id, player.id))
|
|
.returning()
|
|
|
|
expect(updated.name).toBe('Updated Name')
|
|
expect(updated.emoji).toBe('🎉')
|
|
expect(updated.color).toBe('#3b82f6') // unchanged
|
|
})
|
|
|
|
it('toggles isActive status', async () => {
|
|
const [player] = await db
|
|
.insert(schema.players)
|
|
.values({
|
|
userId: testUserId,
|
|
name: 'Test Player',
|
|
emoji: '😀',
|
|
color: '#3b82f6',
|
|
isActive: false,
|
|
})
|
|
.returning()
|
|
|
|
const [updated] = await db
|
|
.update(schema.players)
|
|
.set({ isActive: true })
|
|
.where(eq(schema.players.id, player.id))
|
|
.returning()
|
|
|
|
expect(updated.isActive).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('DELETE /api/players/[id]', () => {
|
|
it('deletes a player', async () => {
|
|
const [player] = await db
|
|
.insert(schema.players)
|
|
.values({
|
|
userId: testUserId,
|
|
name: 'To Delete',
|
|
emoji: '👋',
|
|
color: '#ef4444',
|
|
})
|
|
.returning()
|
|
|
|
const [deleted] = await db
|
|
.delete(schema.players)
|
|
.where(eq(schema.players.id, player.id))
|
|
.returning()
|
|
|
|
expect(deleted).toBeDefined()
|
|
expect(deleted.id).toBe(player.id)
|
|
|
|
// Verify it's gone
|
|
const found = await db.query.players.findFirst({
|
|
where: eq(schema.players.id, player.id),
|
|
})
|
|
expect(found).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('Cascade delete behavior', () => {
|
|
it('deletes players when user is deleted', async () => {
|
|
// Create players
|
|
await db.insert(schema.players).values([
|
|
{
|
|
userId: testUserId,
|
|
name: 'Player 1',
|
|
emoji: '😀',
|
|
color: '#3b82f6',
|
|
},
|
|
{
|
|
userId: testUserId,
|
|
name: 'Player 2',
|
|
emoji: '😎',
|
|
color: '#8b5cf6',
|
|
},
|
|
])
|
|
|
|
// Verify players exist
|
|
let players = await db.query.players.findMany({
|
|
where: eq(schema.players.userId, testUserId),
|
|
})
|
|
expect(players).toHaveLength(2)
|
|
|
|
// Delete user
|
|
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
|
|
|
|
// Verify players are gone
|
|
players = await db.query.players.findMany({
|
|
where: eq(schema.players.userId, testUserId),
|
|
})
|
|
expect(players).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('Arcade Session: isActive Modification Restrictions', () => {
|
|
it('prevents isActive changes when user has an active arcade session', async () => {
|
|
// Create a player
|
|
const [player] = await db
|
|
.insert(schema.players)
|
|
.values({
|
|
userId: testUserId,
|
|
name: 'Test Player',
|
|
emoji: '😀',
|
|
color: '#3b82f6',
|
|
isActive: false,
|
|
})
|
|
.returning()
|
|
|
|
// Create a test room for the session
|
|
const [testRoom] = await db
|
|
.insert(schema.arcadeRooms)
|
|
.values({
|
|
code: `TEST-${Date.now()}`,
|
|
name: 'Test Room',
|
|
gameName: 'matching',
|
|
gameConfig: JSON.stringify({}),
|
|
status: 'lobby',
|
|
createdBy: testUserId,
|
|
creatorName: 'Test User',
|
|
ttlMinutes: 60,
|
|
createdAt: new Date(),
|
|
})
|
|
.returning()
|
|
|
|
// Create an active arcade session
|
|
const now = new Date()
|
|
await db.insert(schema.arcadeSessions).values({
|
|
roomId: testRoom.id,
|
|
userId: testUserId,
|
|
currentGame: 'matching',
|
|
gameUrl: '/arcade/matching',
|
|
gameState: JSON.stringify({}),
|
|
activePlayers: JSON.stringify([player.id]),
|
|
startedAt: now,
|
|
lastActivityAt: now,
|
|
expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now
|
|
version: 1,
|
|
})
|
|
|
|
// Attempt to update isActive should be prevented at API level
|
|
// This test validates the logic that the API route implements
|
|
const activeSession = await db.query.arcadeSessions.findFirst({
|
|
where: eq(schema.arcadeSessions.roomId, testRoom.id),
|
|
})
|
|
|
|
expect(activeSession).toBeDefined()
|
|
expect(activeSession?.currentGame).toBe('matching')
|
|
|
|
// Clean up session
|
|
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.roomId, testRoom.id))
|
|
})
|
|
|
|
it('allows isActive changes when user has no active arcade session', async () => {
|
|
// Create a player
|
|
const [player] = await db
|
|
.insert(schema.players)
|
|
.values({
|
|
userId: testUserId,
|
|
name: 'Test Player',
|
|
emoji: '😀',
|
|
color: '#3b82f6',
|
|
isActive: false,
|
|
})
|
|
.returning()
|
|
|
|
// Verify no active session for this user
|
|
const activeSession = await db.query.arcadeSessions.findFirst({
|
|
where: eq(schema.arcadeSessions.userId, testUserId),
|
|
})
|
|
|
|
expect(activeSession).toBeUndefined()
|
|
|
|
// Should be able to update isActive
|
|
const [updated] = await db
|
|
.update(schema.players)
|
|
.set({ isActive: true })
|
|
.where(eq(schema.players.id, player.id))
|
|
.returning()
|
|
|
|
expect(updated.isActive).toBe(true)
|
|
})
|
|
|
|
it('allows non-isActive changes even with active session', async () => {
|
|
// Create a player
|
|
const [player] = await db
|
|
.insert(schema.players)
|
|
.values({
|
|
userId: testUserId,
|
|
name: 'Test Player',
|
|
emoji: '😀',
|
|
color: '#3b82f6',
|
|
isActive: true,
|
|
})
|
|
.returning()
|
|
|
|
// Create a test room for the session
|
|
const [testRoom] = await db
|
|
.insert(schema.arcadeRooms)
|
|
.values({
|
|
code: `TEST-${Date.now()}`,
|
|
name: 'Test Room',
|
|
gameName: 'matching',
|
|
gameConfig: JSON.stringify({}),
|
|
status: 'lobby',
|
|
createdBy: testUserId,
|
|
creatorName: 'Test User',
|
|
ttlMinutes: 60,
|
|
createdAt: new Date(),
|
|
})
|
|
.returning()
|
|
|
|
// Create an active arcade session
|
|
const now = new Date()
|
|
await db.insert(schema.arcadeSessions).values({
|
|
roomId: testRoom.id,
|
|
userId: testUserId,
|
|
currentGame: 'matching',
|
|
gameUrl: '/arcade/matching',
|
|
gameState: JSON.stringify({}),
|
|
activePlayers: JSON.stringify([player.id]),
|
|
startedAt: now,
|
|
lastActivityAt: now,
|
|
expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now
|
|
version: 1,
|
|
})
|
|
|
|
try {
|
|
// Should be able to update name, emoji, color (non-isActive fields)
|
|
const [updated] = await db
|
|
.update(schema.players)
|
|
.set({
|
|
name: 'Updated Name',
|
|
emoji: '🎉',
|
|
color: '#ff0000',
|
|
})
|
|
.where(eq(schema.players.id, player.id))
|
|
.returning()
|
|
|
|
expect(updated.name).toBe('Updated Name')
|
|
expect(updated.emoji).toBe('🎉')
|
|
expect(updated.color).toBe('#ff0000')
|
|
expect(updated.isActive).toBe(true) // Unchanged
|
|
} finally {
|
|
// Clean up session
|
|
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.roomId, testRoom.id))
|
|
}
|
|
})
|
|
|
|
it('session ends, then isActive changes are allowed again', async () => {
|
|
// Create a player
|
|
const [player] = await db
|
|
.insert(schema.players)
|
|
.values({
|
|
userId: testUserId,
|
|
name: 'Test Player',
|
|
emoji: '😀',
|
|
color: '#3b82f6',
|
|
isActive: true,
|
|
})
|
|
.returning()
|
|
|
|
// Create a test room for the session
|
|
const [testRoom] = await db
|
|
.insert(schema.arcadeRooms)
|
|
.values({
|
|
code: `TEST-${Date.now()}`,
|
|
name: 'Test Room',
|
|
gameName: 'matching',
|
|
gameConfig: JSON.stringify({}),
|
|
status: 'lobby',
|
|
createdBy: testUserId,
|
|
creatorName: 'Test User',
|
|
ttlMinutes: 60,
|
|
createdAt: new Date(),
|
|
})
|
|
.returning()
|
|
|
|
// Create an active arcade session
|
|
const now = new Date()
|
|
await db.insert(schema.arcadeSessions).values({
|
|
roomId: testRoom.id,
|
|
userId: testUserId,
|
|
currentGame: 'matching',
|
|
gameUrl: '/arcade/matching',
|
|
gameState: JSON.stringify({}),
|
|
activePlayers: JSON.stringify([player.id]),
|
|
startedAt: now,
|
|
lastActivityAt: now,
|
|
expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now
|
|
version: 1,
|
|
})
|
|
|
|
// Verify session exists
|
|
let activeSession = await db.query.arcadeSessions.findFirst({
|
|
where: eq(schema.arcadeSessions.roomId, testRoom.id),
|
|
})
|
|
expect(activeSession).toBeDefined()
|
|
|
|
// End the session
|
|
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.roomId, testRoom.id))
|
|
|
|
// Verify session is gone
|
|
activeSession = await db.query.arcadeSessions.findFirst({
|
|
where: eq(schema.arcadeSessions.roomId, testRoom.id),
|
|
})
|
|
expect(activeSession).toBeUndefined()
|
|
|
|
// Now should be able to update isActive
|
|
const [updated] = await db
|
|
.update(schema.players)
|
|
.set({ isActive: false })
|
|
.where(eq(schema.players.id, player.id))
|
|
.returning()
|
|
|
|
expect(updated.isActive).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('Security: userId injection prevention', () => {
|
|
it('rejects creating player with non-existent userId', async () => {
|
|
// Attempt to create a player with a fake userId
|
|
await expect(async () => {
|
|
await db.insert(schema.players).values({
|
|
userId: 'HACKER_ID_NON_EXISTENT',
|
|
name: 'Hacker Player',
|
|
emoji: '🦹',
|
|
color: '#ff0000',
|
|
})
|
|
}).rejects.toThrow(/FOREIGN KEY constraint failed/)
|
|
})
|
|
|
|
it("prevents modifying another user's player via userId injection (DB layer alone is insufficient)", async () => {
|
|
// Create victim user and their player
|
|
const victimGuestId = `victim-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
const [victimUser] = await db
|
|
.insert(schema.users)
|
|
.values({ guestId: victimGuestId })
|
|
.returning()
|
|
|
|
try {
|
|
// Create attacker's player
|
|
const [attackerPlayer] = await db
|
|
.insert(schema.players)
|
|
.values({
|
|
userId: testUserId,
|
|
name: 'Attacker Player',
|
|
emoji: '😈',
|
|
color: '#ff0000',
|
|
})
|
|
.returning()
|
|
|
|
const [_victimPlayer] = await db
|
|
.insert(schema.players)
|
|
.values({
|
|
userId: victimUser.id,
|
|
name: 'Victim Player',
|
|
emoji: '👤',
|
|
color: '#00ff00',
|
|
isActive: true,
|
|
})
|
|
.returning()
|
|
|
|
// IMPORTANT: At the DB level, changing userId to another valid userId SUCCEEDS
|
|
// This is why API layer MUST filter userId from request body!
|
|
const [updated] = await db
|
|
.update(schema.players)
|
|
.set({
|
|
userId: victimUser.id, // This WILL succeed at DB level!
|
|
name: 'Stolen Player',
|
|
})
|
|
.where(eq(schema.players.id, attackerPlayer.id))
|
|
.returning()
|
|
|
|
// The update succeeded - the player now belongs to victim!
|
|
expect(updated.userId).toBe(victimUser.id)
|
|
expect(updated.name).toBe('Stolen Player')
|
|
|
|
// This test demonstrates why the API route MUST:
|
|
// 1. Strip userId from request body
|
|
// 2. Derive userId from session cookie
|
|
// 3. Use WHERE clause to scope updates to current user's data only
|
|
} finally {
|
|
await db.delete(schema.users).where(eq(schema.users.id, victimUser.id))
|
|
}
|
|
})
|
|
|
|
it('ensures players are isolated per user', async () => {
|
|
// Create another user
|
|
const user2GuestId = `user2-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
const [user2] = await db.insert(schema.users).values({ guestId: user2GuestId }).returning()
|
|
|
|
try {
|
|
// Create players for both users
|
|
await db.insert(schema.players).values({
|
|
userId: testUserId,
|
|
name: 'User 1 Player',
|
|
emoji: '🎮',
|
|
color: '#0000ff',
|
|
})
|
|
|
|
await db.insert(schema.players).values({
|
|
userId: user2.id,
|
|
name: 'User 2 Player',
|
|
emoji: '🎯',
|
|
color: '#ff00ff',
|
|
})
|
|
|
|
// Verify each user only sees their own players
|
|
const user1Players = await db.query.players.findMany({
|
|
where: eq(schema.players.userId, testUserId),
|
|
})
|
|
const user2Players = await db.query.players.findMany({
|
|
where: eq(schema.players.userId, user2.id),
|
|
})
|
|
|
|
expect(user1Players).toHaveLength(1)
|
|
expect(user1Players[0].name).toBe('User 1 Player')
|
|
|
|
expect(user2Players).toHaveLength(1)
|
|
expect(user2Players[0].name).toBe('User 2 Player')
|
|
} finally {
|
|
await db.delete(schema.users).where(eq(schema.users.id, user2.id))
|
|
}
|
|
})
|
|
})
|
|
})
|