diff --git a/packages/backend/src/__tests__/climb-queries.test.ts b/packages/backend/src/__tests__/climb-queries.test.ts index b27987cb..a8306257 100644 --- a/packages/backend/src/__tests__/climb-queries.test.ts +++ b/packages/backend/src/__tests__/climb-queries.test.ts @@ -4,10 +4,12 @@ import type { ParsedBoardRouteParameters, ClimbSearchParams } from '../db/querie import { getSizeEdges } from '../db/queries/util/product-sizes-data.js'; describe('Climb Query Functions', () => { + // Use valid size_id for kilter (7 = 12x14 Commercial) + // See packages/backend/src/db/queries/util/product-sizes-data.ts for valid IDs const testParams: ParsedBoardRouteParameters = { board_name: 'kilter', layout_id: 1, - size_id: 1, + size_id: 7, set_ids: [1, 2], angle: 40, }; @@ -231,7 +233,7 @@ describe('Climb Query Functions', () => { const result = await getClimbByUuid({ board_name: 'kilter', layout_id: 1, - size_id: 1, + size_id: 7, // Valid kilter size_id angle: 40, climb_uuid: 'non-existent-uuid-12345', }); @@ -240,16 +242,16 @@ describe('Climb Query Functions', () => { }); it('should handle different board names', async () => { - // Test with kilter + // Test with kilter (size_id 7 = 12x14 Commercial) const kilterResult = await getClimbByUuid({ board_name: 'kilter', layout_id: 1, - size_id: 1, + size_id: 7, angle: 40, climb_uuid: 'test-uuid', }); - // Test with tension + // Test with tension (size_id 1 = Full Wall) const tensionResult = await getClimbByUuid({ board_name: 'tension', layout_id: 1, diff --git a/packages/backend/src/__tests__/integration.test.ts b/packages/backend/src/__tests__/integration.test.ts index 9fe2b3eb..f838519f 100644 --- a/packages/backend/src/__tests__/integration.test.ts +++ b/packages/backend/src/__tests__/integration.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; import { createClient, Client } from 'graphql-ws'; import WebSocket from 'ws'; +import { v4 as uuidv4 } from 'uuid'; import { startServer } from '../server.js'; import type { ClimbQueueItem } from '@boardsesh/shared-schema'; @@ -12,12 +13,14 @@ const TEST_PORT = 8082; let testCounter = 0; const createTestSessionId = () => `test-session-${Date.now()}-${testCounter++}`; -const createTestClimb = (uuid: string): ClimbQueueItem => ({ - uuid, +// Creates a test climb with valid UUIDs +// The label parameter is just for debugging/naming, not used as UUID +const createTestClimb = (label: string): ClimbQueueItem => ({ + uuid: uuidv4(), climb: { - uuid: `climb-${uuid}`, + uuid: uuidv4(), setter_username: 'test-setter', - name: `Test Climb ${uuid}`, + name: `Test Climb ${label}`, description: 'A test climb', frames: 'test-frames', angle: 40, diff --git a/packages/backend/src/__tests__/session-persistence.test.ts b/packages/backend/src/__tests__/session-persistence.test.ts index d41c44a5..b0529eea 100644 --- a/packages/backend/src/__tests__/session-persistence.test.ts +++ b/packages/backend/src/__tests__/session-persistence.test.ts @@ -155,6 +155,17 @@ const createTestClimb = (): ClimbQueueItem => ({ suggested: false, }); +// Helper to register client and join session +const registerAndJoin = async ( + clientId: string, + sessionId: string, + boardPath: string, + username: string +) => { + roomManager.registerClient(clientId); + return roomManager.joinSession(clientId, sessionId, boardPath, username); +}; + describe('Session Persistence - Hybrid Redis + Postgres', () => { let mockRedis: Redis; @@ -177,7 +188,7 @@ describe('Session Persistence - Hybrid Redis + Postgres', () => { const boardPath = '/kilter/1/2/3/40'; // Create and join session - await roomManager.joinSession('client-1', sessionId, boardPath, 'User1'); + await registerAndJoin('client-1', sessionId, boardPath, 'User1'); // Verify active status let session = await db @@ -207,7 +218,7 @@ describe('Session Persistence - Hybrid Redis + Postgres', () => { const boardPath = '/kilter/1/2/3/40'; // Create session, join, and leave - await roomManager.joinSession('client-1', sessionId, boardPath, 'User1'); + await registerAndJoin('client-1', sessionId, boardPath, 'User1'); await roomManager.leaveSession('client-1'); // Verify inactive @@ -219,7 +230,7 @@ describe('Session Persistence - Hybrid Redis + Postgres', () => { expect(session[0]?.status).toBe('inactive'); // Rejoin - await roomManager.joinSession('client-2', sessionId, boardPath, 'User2'); + await registerAndJoin('client-2', sessionId, boardPath, 'User2'); // Verify back to active session = await db @@ -235,7 +246,7 @@ describe('Session Persistence - Hybrid Redis + Postgres', () => { const boardPath = '/kilter/1/2/3/40'; // Create session - await roomManager.joinSession('client-1', sessionId, boardPath, 'User1'); + await registerAndJoin('client-1', sessionId, boardPath, 'User1'); // End session await roomManager.endSession(sessionId); @@ -260,7 +271,7 @@ describe('Session Persistence - Hybrid Redis + Postgres', () => { const climb = createTestClimb(); // Create session and add climb to queue - await roomManager.joinSession('client-1', sessionId, boardPath, 'User1'); + await registerAndJoin('client-1', sessionId, boardPath, 'User1'); const currentState = await roomManager.getQueueState(sessionId); await roomManager.updateQueueState(sessionId, [climb], null, currentState.version); @@ -272,7 +283,7 @@ describe('Session Persistence - Hybrid Redis + Postgres', () => { await roomManager.initialize(mockRedis); // Rejoin should restore from Redis - const result = await roomManager.joinSession('client-2', sessionId, boardPath, 'User2'); + const result = await registerAndJoin('client-2', sessionId, boardPath, 'User2'); // Verify queue was restored from Redis expect(result.queue).toHaveLength(1); @@ -284,7 +295,7 @@ describe('Session Persistence - Hybrid Redis + Postgres', () => { const boardPath = '/kilter/1/2/3/40'; // Create session and make it inactive - await roomManager.joinSession('client-1', sessionId, boardPath, 'User1'); + await registerAndJoin('client-1', sessionId, boardPath, 'User1'); await roomManager.leaveSession('client-1'); // Clear in-memory state @@ -293,9 +304,9 @@ describe('Session Persistence - Hybrid Redis + Postgres', () => { // Multiple users join concurrently const results = await Promise.all([ - roomManager.joinSession('client-2', sessionId, boardPath, 'User2'), - roomManager.joinSession('client-3', sessionId, boardPath, 'User3'), - roomManager.joinSession('client-4', sessionId, boardPath, 'User4'), + registerAndJoin('client-2', sessionId, boardPath, 'User2'), + registerAndJoin('client-3', sessionId, boardPath, 'User3'), + registerAndJoin('client-4', sessionId, boardPath, 'User4'), ]); // Verify all joined successfully @@ -312,7 +323,7 @@ describe('Session Persistence - Hybrid Redis + Postgres', () => { const climb = createTestClimb(); // Create session and add climb to queue - await roomManager.joinSession('client-1', sessionId, boardPath, 'User1'); + await registerAndJoin('client-1', sessionId, boardPath, 'User1'); const currentState = await roomManager.getQueueState(sessionId); await roomManager.updateQueueState(sessionId, [climb], null, currentState.version); @@ -330,7 +341,7 @@ describe('Session Persistence - Hybrid Redis + Postgres', () => { await roomManager.initialize(mockRedis); // Rejoin should restore from Postgres - const result = await roomManager.joinSession('client-2', sessionId, boardPath, 'User2'); + const result = await registerAndJoin('client-2', sessionId, boardPath, 'User2'); // Verify queue was restored from Postgres expect(result.queue).toHaveLength(1); @@ -342,7 +353,7 @@ describe('Session Persistence - Hybrid Redis + Postgres', () => { const boardPath = '/kilter/1/2/3/40'; // Create and end session - await roomManager.joinSession('client-1', sessionId, boardPath, 'User1'); + await registerAndJoin('client-1', sessionId, boardPath, 'User1'); await roomManager.endSession(sessionId); // Clear in-memory state @@ -350,7 +361,7 @@ describe('Session Persistence - Hybrid Redis + Postgres', () => { await roomManager.initialize(mockRedis); // Try to rejoin ended session - should create a new session instead of restoring - const result = await roomManager.joinSession('client-2', sessionId, boardPath, 'User2'); + const result = await registerAndJoin('client-2', sessionId, boardPath, 'User2'); // Session should be created fresh (empty queue) expect(result.queue).toHaveLength(0); @@ -373,7 +384,7 @@ describe('Session Persistence - Hybrid Redis + Postgres', () => { const climb2 = createTestClimb(); // Create session - await roomManager.joinSession('client-1', sessionId, boardPath, 'User1'); + await registerAndJoin('client-1', sessionId, boardPath, 'User1'); // Add multiple climbs rapidly let currentState = await roomManager.getQueueState(sessionId); @@ -414,7 +425,7 @@ describe('Session Persistence - Hybrid Redis + Postgres', () => { const climb = createTestClimb(); // Create session and update queue - await roomManager.joinSession('client-1', sessionId, boardPath, 'User1'); + await registerAndJoin('client-1', sessionId, boardPath, 'User1'); const currentState = await roomManager.getQueueState(sessionId); await roomManager.updateQueueState(sessionId, [climb], null, currentState.version); @@ -439,7 +450,7 @@ describe('Session Persistence - Hybrid Redis + Postgres', () => { const climb2 = createTestClimb(); // Create session - await roomManager.joinSession('client-1', sessionId, boardPath, 'User1'); + await registerAndJoin('client-1', sessionId, boardPath, 'User1'); // First update let currentState = await roomManager.getQueueState(sessionId); @@ -496,7 +507,7 @@ describe('Session Persistence - Hybrid Redis + Postgres', () => { 'Test Session' ); - await roomManager.joinSession('client-1', sessionId, boardPath, 'User1'); + await registerAndJoin('client-1', sessionId, boardPath, 'User1'); // Query nearby sessions const nearby = await roomManager.findNearbySessions(37.7749, -122.4194, 10000); @@ -520,7 +531,7 @@ describe('Session Persistence - Hybrid Redis + Postgres', () => { 'Test Session' ); - await roomManager.joinSession('client-1', sessionId, boardPath, 'User1'); + await registerAndJoin('client-1', sessionId, boardPath, 'User1'); await roomManager.leaveSession('client-1'); // Clear in-memory state but Redis still has it @@ -549,7 +560,7 @@ describe('Session Persistence - Hybrid Redis + Postgres', () => { 'Test Session' ); - await roomManager.joinSession('client-1', sessionId, boardPath, 'User1'); + await registerAndJoin('client-1', sessionId, boardPath, 'User1'); await roomManager.leaveSession('client-1'); // Clear both in-memory and Redis (simulate TTL expiry) @@ -581,7 +592,7 @@ describe('Session Persistence - Hybrid Redis + Postgres', () => { 'Test Session' ); - await roomManager.joinSession('client-1', sessionId, boardPath, 'User1'); + await registerAndJoin('client-1', sessionId, boardPath, 'User1'); await roomManager.endSession(sessionId); // Query nearby sessions @@ -603,7 +614,7 @@ describe('Session Persistence - Hybrid Redis + Postgres', () => { const climb = createTestClimb(); // Should still work - await roomManager.joinSession('client-1', sessionId, boardPath, 'User1'); + await registerAndJoin('client-1', sessionId, boardPath, 'User1'); const currentState = await roomManager.getQueueState(sessionId); await roomManager.updateQueueState(sessionId, [climb], null, currentState.version); @@ -633,7 +644,7 @@ describe('Session Persistence - Hybrid Redis + Postgres', () => { const boardPath = '/kilter/1/2/3/40'; // Create session and leave - await roomManager.joinSession('client-1', sessionId, boardPath, 'User1'); + await registerAndJoin('client-1', sessionId, boardPath, 'User1'); await roomManager.leaveSession('client-1'); // Reset (simulate server restart) @@ -641,7 +652,7 @@ describe('Session Persistence - Hybrid Redis + Postgres', () => { await roomManager.initialize(); // Try to rejoin - should create new session (no restoration in Postgres-only mode) - const result = await roomManager.joinSession('client-2', sessionId, boardPath, 'User2'); + const result = await registerAndJoin('client-2', sessionId, boardPath, 'User2'); // Should be fresh session expect(result.queue).toHaveLength(0); @@ -655,7 +666,7 @@ describe('Session Persistence - Hybrid Redis + Postgres', () => { const climb = createTestClimb(); // Create session and update queue - await roomManager.joinSession('client-1', sessionId, boardPath, 'User1'); + await registerAndJoin('client-1', sessionId, boardPath, 'User1'); const currentState = await roomManager.getQueueState(sessionId); await roomManager.updateQueueState(sessionId, [climb], null, currentState.version); @@ -681,8 +692,8 @@ describe('Session Persistence - Hybrid Redis + Postgres', () => { const boardPath = '/kilter/1/2/3/40'; // Create session - await roomManager.joinSession('client-1', sessionId, boardPath, 'User1'); - await roomManager.joinSession('client-2', sessionId, boardPath, 'User2'); + await registerAndJoin('client-1', sessionId, boardPath, 'User1'); + await registerAndJoin('client-2', sessionId, boardPath, 'User2'); // Verify users in Redis const redisHashes = (mockRedis as any)._hashes as Map>; @@ -700,7 +711,7 @@ describe('Session Persistence - Hybrid Redis + Postgres', () => { const sessionId = uuidv4(); const boardPath = '/kilter/1/2/3/40'; - await roomManager.joinSession('client-1', sessionId, boardPath, 'User1'); + await registerAndJoin('client-1', sessionId, boardPath, 'User1'); // Simulate concurrent updates with same version const currentState = await roomManager.getQueueState(sessionId); @@ -733,7 +744,7 @@ describe('Session Persistence - Hybrid Redis + Postgres', () => { // Should not crash, might fall back to Postgres-only behavior await expect(async () => { - await roomManager.joinSession('client-1', sessionId, boardPath, 'User1'); + await registerAndJoin('client-1', sessionId, boardPath, 'User1'); }).rejects.toThrow(); }); }); diff --git a/packages/backend/src/__tests__/setup.ts b/packages/backend/src/__tests__/setup.ts index 138aa28e..ececc291 100644 --- a/packages/backend/src/__tests__/setup.ts +++ b/packages/backend/src/__tests__/setup.ts @@ -1,8 +1,7 @@ import { beforeAll, beforeEach, afterAll } from 'vitest'; -import { drizzle } from 'drizzle-orm/postgres-js'; import postgres from 'postgres'; import { sql } from 'drizzle-orm'; -import * as schema from '../db/schema.js'; +import { db as sharedDb } from '../db/client.js'; import { roomManager } from '../services/room-manager.js'; const TEST_DB_NAME = 'boardsesh_backend_test'; @@ -13,7 +12,6 @@ const connectionString = const baseConnectionString = connectionString.replace(/\/[^/]+$/, '/postgres'); let migrationClient: ReturnType; -let db: ReturnType; // SQL to create only the tables needed for backend tests const createTablesSQL = ` @@ -22,6 +20,16 @@ const createTablesSQL = ` DROP TABLE IF EXISTS "board_session_clients" CASCADE; DROP TABLE IF EXISTS "board_sessions" CASCADE; DROP TABLE IF EXISTS "users" CASCADE; + DROP TABLE IF EXISTS "kilter_climbs" CASCADE; + DROP TABLE IF EXISTS "kilter_climb_stats" CASCADE; + DROP TABLE IF EXISTS "kilter_difficulty_grades" CASCADE; + DROP TABLE IF EXISTS "kilter_ascents" CASCADE; + DROP TABLE IF EXISTS "kilter_bids" CASCADE; + DROP TABLE IF EXISTS "tension_climbs" CASCADE; + DROP TABLE IF EXISTS "tension_climb_stats" CASCADE; + DROP TABLE IF EXISTS "tension_difficulty_grades" CASCADE; + DROP TABLE IF EXISTS "tension_ascents" CASCADE; + DROP TABLE IF EXISTS "tension_bids" CASCADE; -- Create users table (minimal, needed for FK reference) CREATE TABLE IF NOT EXISTS "users" ( @@ -68,7 +76,153 @@ const createTablesSQL = ` "updated_at" timestamp DEFAULT now() NOT NULL ); - -- Create indexes + -- Create kilter tables for climb query tests + CREATE TABLE IF NOT EXISTS "kilter_climbs" ( + "uuid" text PRIMARY KEY NOT NULL, + "layout_id" integer, + "setter_id" integer, + "setter_username" text, + "name" text, + "description" text, + "hsm" integer, + "edge_left" integer, + "edge_right" integer, + "edge_bottom" integer, + "edge_top" integer, + "frames_count" integer DEFAULT 1, + "frames_pace" integer, + "frames" text, + "is_draft" boolean DEFAULT false, + "is_listed" boolean DEFAULT true, + "created_at" text + ); + + CREATE TABLE IF NOT EXISTS "kilter_climb_stats" ( + "climb_uuid" text NOT NULL, + "angle" integer NOT NULL, + "display_difficulty" double precision, + "benchmark_difficulty" double precision, + "ascensionist_count" bigint, + "difficulty_average" double precision, + "quality_average" double precision, + "fa_username" text, + "fa_at" timestamp, + PRIMARY KEY ("climb_uuid", "angle") + ); + + CREATE TABLE IF NOT EXISTS "kilter_difficulty_grades" ( + "difficulty" integer PRIMARY KEY NOT NULL, + "boulder_name" text, + "route_name" text, + "is_listed" boolean DEFAULT true + ); + + CREATE TABLE IF NOT EXISTS "kilter_ascents" ( + "uuid" text PRIMARY KEY NOT NULL, + "climb_uuid" text, + "angle" integer, + "is_mirror" boolean, + "user_id" integer, + "attempt_id" integer, + "bid_count" integer DEFAULT 1, + "quality" integer, + "difficulty" integer, + "is_benchmark" integer DEFAULT 0, + "comment" text DEFAULT '', + "climbed_at" text, + "created_at" text, + "synced" boolean DEFAULT true NOT NULL, + "sync_error" text + ); + + CREATE TABLE IF NOT EXISTS "kilter_bids" ( + "uuid" text PRIMARY KEY NOT NULL, + "user_id" integer, + "climb_uuid" text, + "angle" integer, + "is_mirror" boolean, + "bid_count" integer DEFAULT 1, + "comment" text DEFAULT '', + "climbed_at" text, + "created_at" text, + "synced" boolean DEFAULT true NOT NULL, + "sync_error" text + ); + + -- Create tension tables for climb query tests + CREATE TABLE IF NOT EXISTS "tension_climbs" ( + "uuid" text PRIMARY KEY NOT NULL, + "layout_id" integer, + "setter_id" integer, + "setter_username" text, + "name" text, + "description" text, + "hsm" integer, + "edge_left" integer, + "edge_right" integer, + "edge_bottom" integer, + "edge_top" integer, + "frames_count" integer DEFAULT 1, + "frames_pace" integer, + "frames" text, + "is_draft" boolean DEFAULT false, + "is_listed" boolean DEFAULT true, + "created_at" text + ); + + CREATE TABLE IF NOT EXISTS "tension_climb_stats" ( + "climb_uuid" text NOT NULL, + "angle" integer NOT NULL, + "display_difficulty" double precision, + "benchmark_difficulty" double precision, + "ascensionist_count" bigint, + "difficulty_average" double precision, + "quality_average" double precision, + "fa_username" text, + "fa_at" timestamp, + PRIMARY KEY ("climb_uuid", "angle") + ); + + CREATE TABLE IF NOT EXISTS "tension_difficulty_grades" ( + "difficulty" integer PRIMARY KEY NOT NULL, + "boulder_name" text, + "route_name" text, + "is_listed" boolean DEFAULT true + ); + + CREATE TABLE IF NOT EXISTS "tension_ascents" ( + "uuid" text PRIMARY KEY NOT NULL, + "climb_uuid" text, + "angle" integer, + "is_mirror" boolean, + "user_id" integer, + "attempt_id" integer, + "bid_count" integer DEFAULT 1, + "quality" integer, + "difficulty" integer, + "is_benchmark" integer DEFAULT 0, + "comment" text DEFAULT '', + "climbed_at" text, + "created_at" text, + "synced" boolean DEFAULT true NOT NULL, + "sync_error" text + ); + + CREATE TABLE IF NOT EXISTS "tension_bids" ( + "uuid" text PRIMARY KEY NOT NULL, + "user_id" integer, + "climb_uuid" text, + "angle" integer, + "is_mirror" boolean, + "bid_count" integer DEFAULT 1, + "comment" text DEFAULT '', + "climbed_at" text, + "created_at" text, + "synced" boolean DEFAULT true NOT NULL, + "sync_error" text + ); + + -- Create indexes for board_sessions CREATE INDEX IF NOT EXISTS "board_sessions_location_idx" ON "board_sessions" ("latitude", "longitude"); CREATE INDEX IF NOT EXISTS "board_sessions_discoverable_idx" ON "board_sessions" ("discoverable"); CREATE INDEX IF NOT EXISTS "board_sessions_user_idx" ON "board_sessions" ("created_by_user_id"); @@ -100,9 +254,8 @@ beforeAll(async () => { await adminClient.end(); } - // Now connect to the test database + // Now connect to the test database for schema creation migrationClient = postgres(connectionString, { max: 1, onnotice: () => {} }); - db = drizzle(migrationClient, { schema }); // Create tables directly (backend tests only need session tables) await migrationClient.unsafe(createTablesSQL); @@ -113,9 +266,19 @@ beforeEach(async () => { roomManager.reset(); // Clear all tables in correct order (respect foreign keys) - await db.execute(sql`TRUNCATE TABLE board_session_queues CASCADE`); - await db.execute(sql`TRUNCATE TABLE board_session_clients CASCADE`); - await db.execute(sql`TRUNCATE TABLE board_sessions CASCADE`); + // Use sharedDb (same instance as roomManager) to ensure consistency + await sharedDb.execute(sql`TRUNCATE TABLE board_session_queues CASCADE`); + await sharedDb.execute(sql`TRUNCATE TABLE board_session_clients CASCADE`); + await sharedDb.execute(sql`TRUNCATE TABLE board_sessions CASCADE`); + await sharedDb.execute(sql`TRUNCATE TABLE users CASCADE`); + + // Create test users that are referenced by tests + // The session-persistence tests use 'user-123' for discoverable sessions + await sharedDb.execute(sql` + INSERT INTO users (id, email, name) + VALUES ('user-123', 'test@example.com', 'Test User') + ON CONFLICT (id) DO NOTHING + `); }); afterAll(async () => {