diff --git a/drizzle.test.config.ts b/drizzle.test.config.ts new file mode 100644 index 0000000..9f6dcc5 --- /dev/null +++ b/drizzle.test.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'drizzle-kit'; + +if (!process.env.TEST_DATABASE_URL) throw new Error('TEST_DATABASE_URL is not set'); + +export default defineConfig({ + schema: './src/lib/server/db/schema.ts', + dialect: 'sqlite', + dbCredentials: { url: process.env.TEST_DATABASE_URL }, + verbose: true, + strict: true +}); \ No newline at end of file diff --git a/src/lib/server/auth.test.ts b/src/lib/server/auth.test.ts new file mode 100644 index 0000000..a7e835f --- /dev/null +++ b/src/lib/server/auth.test.ts @@ -0,0 +1,115 @@ +import type { RequestEvent } from '@sveltejs/kit'; +import { eq } from 'drizzle-orm'; +import { sha256 } from '@oslojs/crypto/sha2'; +import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding'; +import { testDb as db } from '$lib/server/db/test'; +import * as table from '$lib/server/db/schema'; + +const DAY_IN_MS = 1000 * 60 * 60 * 24; + +export const sessionCookieName = 'auth-session'; + +export function generateSessionToken() { + const bytes = crypto.getRandomValues(new Uint8Array(18)); + const token = encodeBase64url(bytes); + return token; +} + +export async function createSession(token: string, userId: string) { + const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); + const session: table.Session = { + id: sessionId, + userId, + expiresAt: new Date(Date.now() + DAY_IN_MS * 30) + }; + await db.insert(table.session).values(session); + return session; +} + +export async function validateSessionToken(token: string) { + const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); + const [result] = await db + .select({ + // Adjust user table here to tweak returned data + user: { id: table.user.id, email: table.user.email }, + session: table.session + }) + .from(table.session) + .innerJoin(table.user, eq(table.session.userId, table.user.id)) + .where(eq(table.session.id, sessionId)); + + if (!result) { + return { session: null, user: null }; + } + const { session, user } = result; + + const sessionExpired = Date.now() >= session.expiresAt.getTime(); + if (sessionExpired) { + await db.delete(table.session).where(eq(table.session.id, session.id)); + return { session: null, user: null }; + } + + const renewSession = Date.now() >= session.expiresAt.getTime() - DAY_IN_MS * 15; + if (renewSession) { + session.expiresAt = new Date(Date.now() + DAY_IN_MS * 30); + await db + .update(table.session) + .set({ expiresAt: session.expiresAt }) + .where(eq(table.session.id, session.id)); + } + + return { session, user }; +} + +export type SessionValidationResult = Awaited>; + +export async function invalidateSession(sessionId: string) { + await db.delete(table.session).where(eq(table.session.id, sessionId)); +} + +export function setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date) { + event.cookies.set(sessionCookieName, token, { + expires: expiresAt, + path: '/' + }); +} + +export function deleteSessionTokenCookie(event: RequestEvent) { + event.cookies.delete(sessionCookieName, { + path: '/' + }); +} + +export async function hashPassword(password: string): Promise { + return await Bun.password.hash(password, { + algorithm: 'argon2id', + memoryCost: 4, + timeCost: 3 + }); +} + +export async function verifyPassword(password: string, hash: string): Promise { + try { + return await Bun.password.verify(password, hash); + } catch { + return false; + } +} + +export async function createUser(anonymousId: string, email: string, passwordHash: string, firstName?: string, lastName?: string) { + const user: table.User = { + id: anonymousId, // Use anonymousId as the user ID to preserve stats + email, + passwordHash, + firstName: firstName || null, + lastName: lastName || null, + isPrivate: false + }; + await db.insert(table.user).values(user); + return user; +} + +export async function getUserByEmail(email: string) { + const [user] = await db.select().from(table.user).where(eq(table.user.email, email)); + return user || null; +} \ No newline at end of file diff --git a/src/lib/server/db/test.ts b/src/lib/server/db/test.ts new file mode 100644 index 0000000..1adee5c --- /dev/null +++ b/src/lib/server/db/test.ts @@ -0,0 +1,9 @@ +import { drizzle } from 'drizzle-orm/bun-sqlite'; +import { Database } from 'bun:sqlite'; +import * as schema from './schema'; + +if (!Bun.env.TEST_DATABASE_URL) throw new Error('TEST_DATABASE_URL is not set'); + +const testClient = new Database(Bun.env.TEST_DATABASE_URL); + +export const testDb = drizzle(testClient, { schema }); \ No newline at end of file diff --git a/tests/signin-migration-unit.test.ts b/tests/signin-migration-unit.test.ts new file mode 100644 index 0000000..b6427c4 --- /dev/null +++ b/tests/signin-migration-unit.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; + +describe('Signin Migration Logic (Unit Tests)', () => { + + // Test the deduplication algorithm independently + it('should correctly identify and remove duplicates keeping earliest', () => { + // Mock completion data structure + type MockCompletion = { + id: string; + anonymousId: string; + date: string; + guessCount: number; + completedAt: Date; + }; + + // Test data: multiple completions on same date + const allUserCompletions: MockCompletion[] = [ + { + id: 'comp1', + anonymousId: 'user123', + date: '2024-01-01', + guessCount: 4, + completedAt: new Date('2024-01-01T08:00:00Z') // Earliest + }, + { + id: 'comp2', + anonymousId: 'user123', + date: '2024-01-01', + guessCount: 2, + completedAt: new Date('2024-01-01T14:00:00Z') // Later + }, + { + id: 'comp3', + anonymousId: 'user123', + date: '2024-01-01', + guessCount: 6, + completedAt: new Date('2024-01-01T20:00:00Z') // Latest + }, + { + id: 'comp4', + anonymousId: 'user123', + date: '2024-01-02', + guessCount: 3, + completedAt: new Date('2024-01-02T09:00:00Z') // Unique date + } + ]; + + // Implement the deduplication logic from signin server action + const dateGroups = new Map(); + for (const completion of allUserCompletions) { + const date = completion.date; + if (!dateGroups.has(date)) { + dateGroups.set(date, []); + } + dateGroups.get(date)!.push(completion); + } + + // Process dates with duplicates + const duplicateIds: string[] = []; + const keptEntries: MockCompletion[] = []; + + for (const [date, completions] of dateGroups) { + if (completions.length > 1) { + // Sort by completedAt timestamp (earliest first) + completions.sort((a, b) => a.completedAt.getTime() - b.completedAt.getTime()); + + // Keep the first (earliest), mark the rest for deletion + const toKeep = completions[0]; + const toDelete = completions.slice(1); + + keptEntries.push(toKeep); + duplicateIds.push(...toDelete.map(c => c.id)); + } else { + // Single entry for this date, keep it + keptEntries.push(completions[0]); + } + } + + // Verify the logic worked correctly + expect(duplicateIds).toHaveLength(2); // comp2 and comp3 should be deleted + expect(duplicateIds).toContain('comp2'); + expect(duplicateIds).toContain('comp3'); + expect(duplicateIds).not.toContain('comp1'); // comp1 should be kept (earliest) + expect(duplicateIds).not.toContain('comp4'); // comp4 should be kept (unique date) + + // Verify kept entries + expect(keptEntries).toHaveLength(2); + + // Check that the earliest entry for 2024-01-01 was kept + const jan1Entry = keptEntries.find(e => e.date === '2024-01-01'); + expect(jan1Entry).toBeTruthy(); + expect(jan1Entry!.id).toBe('comp1'); // Earliest timestamp + expect(jan1Entry!.guessCount).toBe(4); + expect(jan1Entry!.completedAt.getTime()).toBe(new Date('2024-01-01T08:00:00Z').getTime()); + + // Check that unique date entry was preserved + const jan2Entry = keptEntries.find(e => e.date === '2024-01-02'); + expect(jan2Entry).toBeTruthy(); + expect(jan2Entry!.id).toBe('comp4'); + }); + + it('should handle no duplicates correctly', () => { + type MockCompletion = { + id: string; + anonymousId: string; + date: string; + guessCount: number; + completedAt: Date; + }; + + // Test data: all unique dates + const allUserCompletions: MockCompletion[] = [ + { + id: 'comp1', + anonymousId: 'user123', + date: '2024-01-01', + guessCount: 4, + completedAt: new Date('2024-01-01T08:00:00Z') + }, + { + id: 'comp2', + anonymousId: 'user123', + date: '2024-01-02', + guessCount: 2, + completedAt: new Date('2024-01-02T14:00:00Z') + } + ]; + + // Run deduplication logic + const dateGroups = new Map(); + for (const completion of allUserCompletions) { + if (!dateGroups.has(completion.date)) { + dateGroups.set(completion.date, []); + } + dateGroups.get(completion.date)!.push(completion); + } + + const duplicateIds: string[] = []; + for (const [date, completions] of dateGroups) { + if (completions.length > 1) { + completions.sort((a, b) => a.completedAt.getTime() - b.completedAt.getTime()); + const toDelete = completions.slice(1); + duplicateIds.push(...toDelete.map(c => c.id)); + } + } + + // Should find no duplicates + expect(duplicateIds).toHaveLength(0); + }); + + it('should handle edge case with same timestamp', () => { + type MockCompletion = { + id: string; + anonymousId: string; + date: string; + guessCount: number; + completedAt: Date; + }; + + // Edge case: same completion time (very unlikely but possible) + const sameTime = new Date('2024-01-01T08:00:00Z'); + const allUserCompletions: MockCompletion[] = [ + { + id: 'comp1', + anonymousId: 'user123', + date: '2024-01-01', + guessCount: 3, + completedAt: sameTime + }, + { + id: 'comp2', + anonymousId: 'user123', + date: '2024-01-01', + guessCount: 5, + completedAt: sameTime + } + ]; + + // Run deduplication logic + const dateGroups = new Map(); + for (const completion of allUserCompletions) { + if (!dateGroups.has(completion.date)) { + dateGroups.set(completion.date, []); + } + dateGroups.get(completion.date)!.push(completion); + } + + const duplicateIds: string[] = []; + for (const [date, completions] of dateGroups) { + if (completions.length > 1) { + completions.sort((a, b) => a.completedAt.getTime() - b.completedAt.getTime()); + const toDelete = completions.slice(1); + duplicateIds.push(...toDelete.map(c => c.id)); + } + } + + // Should still remove one duplicate (deterministically based on array order) + expect(duplicateIds).toHaveLength(1); + // Since they have the same timestamp, it keeps the first one in the sorted array + expect(duplicateIds[0]).toBe('comp2'); // Second entry gets removed + }); + + it('should validate migration condition logic', () => { + // Test the condition check that determines when migration should occur + const testCases = [ + { + anonymousId: 'device2-id', + userId: 'device1-id', + shouldMigrate: true, + description: 'Different IDs should trigger migration' + }, + { + anonymousId: 'same-id', + userId: 'same-id', + shouldMigrate: false, + description: 'Same IDs should not trigger migration' + }, + { + anonymousId: null as any, + userId: 'user-id', + shouldMigrate: false, + description: 'Null anonymous ID should not trigger migration' + }, + { + anonymousId: undefined as any, + userId: 'user-id', + shouldMigrate: false, + description: 'Undefined anonymous ID should not trigger migration' + }, + { + anonymousId: '', + userId: 'user-id', + shouldMigrate: false, + description: 'Empty anonymous ID should not trigger migration' + } + ]; + + for (const testCase of testCases) { + // This is the exact condition from signin/+page.server.ts + const shouldMigrate = !!(testCase.anonymousId && testCase.anonymousId !== testCase.userId); + + expect(shouldMigrate).toBe(testCase.shouldMigrate); + } + }); +}); \ No newline at end of file diff --git a/tests/signin-migration.test.ts b/tests/signin-migration.test.ts new file mode 100644 index 0000000..63c1c54 --- /dev/null +++ b/tests/signin-migration.test.ts @@ -0,0 +1,287 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { testDb as db } from '../src/lib/server/db/test'; +import { user, session, dailyCompletions } from '../src/lib/server/db/schema'; +import * as auth from '../src/lib/server/auth.test'; +import { eq, inArray } from 'drizzle-orm'; +import crypto from 'node:crypto'; + +// Test helper functions +function generateTestUUID() { + return crypto.randomUUID(); +} + +async function createTestUser(anonymousId: string, email: string, password: string = 'testpass123') { + const passwordHash = await auth.hashPassword(password); + const testUser = await auth.createUser(anonymousId, email, passwordHash, 'Test', 'User'); + return testUser; +} + +async function createTestCompletion(anonymousId: string, date: string, guessCount: number, completedAt: Date) { + const completion = { + id: generateTestUUID(), + anonymousId, + date, + guessCount, + completedAt + }; + await db.insert(dailyCompletions).values(completion); + return completion; +} + +async function clearTestData() { + // Clear test data in reverse dependency order + await db.delete(session); + await db.delete(dailyCompletions); + await db.delete(user); +} + +describe('Signin Stats Migration', () => { + beforeEach(async () => { + await clearTestData(); + }); + + afterEach(async () => { + await clearTestData(); + }); + + it('should migrate stats from local anonymous ID to user ID on signin', async () => { + // Setup: Create user with device 1 anonymous ID + const device1AnonymousId = generateTestUUID(); + const device2AnonymousId = generateTestUUID(); + const email = 'test@example.com'; + + const testUser = await createTestUser(device1AnonymousId, email); + + // Add some completions for device 1 (user's original device) + await createTestCompletion(device1AnonymousId, '2024-01-01', 3, new Date('2024-01-01T08:00:00Z')); + await createTestCompletion(device1AnonymousId, '2024-01-02', 5, new Date('2024-01-02T09:00:00Z')); + + // Add some completions for device 2 (before signin) + await createTestCompletion(device2AnonymousId, '2024-01-03', 2, new Date('2024-01-03T10:00:00Z')); + await createTestCompletion(device2AnonymousId, '2024-01-04', 4, new Date('2024-01-04T11:00:00Z')); + + // Verify initial state + const initialDevice1Stats = await db + .select() + .from(dailyCompletions) + .where(eq(dailyCompletions.anonymousId, device1AnonymousId)); + const initialDevice2Stats = await db + .select() + .from(dailyCompletions) + .where(eq(dailyCompletions.anonymousId, device2AnonymousId)); + + expect(initialDevice1Stats).toHaveLength(2); + expect(initialDevice2Stats).toHaveLength(2); + + // Simulate signin action - this is what happens in signin/+page.server.ts + const user = await auth.getUserByEmail(email); + expect(user).toBeTruthy(); + + // Migrate stats (simulating the signin logic) + if (device2AnonymousId && device2AnonymousId !== user!.id) { + // Update all daily completions from device2 anonymous ID to user's ID + await db + .update(dailyCompletions) + .set({ anonymousId: user!.id }) + .where(eq(dailyCompletions.anonymousId, device2AnonymousId)); + } + + // Verify migration worked + const finalUserStats = await db + .select() + .from(dailyCompletions) + .where(eq(dailyCompletions.anonymousId, user!.id)); + const remainingDevice2Stats = await db + .select() + .from(dailyCompletions) + .where(eq(dailyCompletions.anonymousId, device2AnonymousId)); + + expect(finalUserStats).toHaveLength(4); // All 4 completions now under user ID + expect(remainingDevice2Stats).toHaveLength(0); // No more completions under device2 ID + + // Verify the actual data is correct + const dates = finalUserStats.map(c => c.date).sort(); + expect(dates).toEqual(['2024-01-01', '2024-01-02', '2024-01-03', '2024-01-04']); + }); + + it('should deduplicate entries for same date keeping earliest completion', async () => { + // Setup: User played same day on both devices + const device1AnonymousId = generateTestUUID(); + const device2AnonymousId = generateTestUUID(); + const email = 'test@example.com'; + + const testUser = await createTestUser(device1AnonymousId, email); + + // Both devices played on same date - device1 played earlier and better + const date = '2024-01-01'; + const earlierTime = new Date('2024-01-01T08:00:00Z'); + const laterTime = new Date('2024-01-01T14:00:00Z'); + + await createTestCompletion(device1AnonymousId, date, 3, earlierTime); // Better score, earlier + await createTestCompletion(device2AnonymousId, date, 5, laterTime); // Worse score, later + + // Also add unique dates to ensure they're preserved + await createTestCompletion(device1AnonymousId, '2024-01-02', 4, new Date('2024-01-02T09:00:00Z')); + await createTestCompletion(device2AnonymousId, '2024-01-03', 2, new Date('2024-01-03T10:00:00Z')); + + // Migrate stats + const user = await auth.getUserByEmail(email); + await db + .update(dailyCompletions) + .set({ anonymousId: user!.id }) + .where(eq(dailyCompletions.anonymousId, device2AnonymousId)); + + // Implement deduplication logic (from signin server action) + const allUserCompletions = await db + .select() + .from(dailyCompletions) + .where(eq(dailyCompletions.anonymousId, user!.id)); + + // Group by date to find duplicates + const dateGroups = new Map(); + for (const completion of allUserCompletions) { + const date = completion.date; + if (!dateGroups.has(date)) { + dateGroups.set(date, []); + } + dateGroups.get(date)!.push(completion); + } + + // Process dates with duplicates + const duplicateIds: string[] = []; + for (const [date, completions] of dateGroups) { + if (completions.length > 1) { + // Sort by completedAt timestamp (earliest first) + completions.sort((a, b) => a.completedAt.getTime() - b.completedAt.getTime()); + + // Keep the first (earliest), mark the rest for deletion + const toDelete = completions.slice(1); + duplicateIds.push(...toDelete.map(c => c.id)); + } + } + + // Delete duplicate entries + if (duplicateIds.length > 0) { + await db + .delete(dailyCompletions) + .where(inArray(dailyCompletions.id, duplicateIds)); + } + + // Verify deduplication worked correctly + const finalStats = await db + .select() + .from(dailyCompletions) + .where(eq(dailyCompletions.anonymousId, user!.id)); + + expect(finalStats).toHaveLength(3); // One duplicate removed + + // Verify the correct entry was kept for the duplicate date + const duplicateDateEntry = finalStats.find(c => c.date === date); + expect(duplicateDateEntry).toBeTruthy(); + expect(duplicateDateEntry!.guessCount).toBe(3); // Better score kept + expect(duplicateDateEntry!.completedAt.getTime()).toBe(earlierTime.getTime()); // Earlier time kept + + // Verify unique dates are preserved + const allDates = finalStats.map(c => c.date).sort(); + expect(allDates).toEqual(['2024-01-01', '2024-01-02', '2024-01-03']); + }); + + it('should handle no migration when anonymous ID matches user ID', async () => { + // Setup: User signing in from same device they signed up on + const anonymousId = generateTestUUID(); + const email = 'test@example.com'; + + const testUser = await createTestUser(anonymousId, email); + + // Add some completions + await createTestCompletion(anonymousId, '2024-01-01', 3, new Date('2024-01-01T08:00:00Z')); + await createTestCompletion(anonymousId, '2024-01-02', 5, new Date('2024-01-02T09:00:00Z')); + + // Verify initial state + const initialStats = await db + .select() + .from(dailyCompletions) + .where(eq(dailyCompletions.anonymousId, anonymousId)); + expect(initialStats).toHaveLength(2); + + // Simulate signin with same anonymous ID (no migration needed) + const user = await auth.getUserByEmail(email); + + // Migration logic should skip when IDs match + const shouldMigrate = anonymousId && anonymousId !== user!.id; + expect(shouldMigrate).toBe(false); + + // Verify no changes + const finalStats = await db + .select() + .from(dailyCompletions) + .where(eq(dailyCompletions.anonymousId, anonymousId)); + expect(finalStats).toHaveLength(2); + expect(finalStats[0].anonymousId).toBe(anonymousId); + }); + + it('should handle multiple duplicates for same date correctly', async () => { + // Edge case: User played same date on 3+ devices + const device1AnonymousId = generateTestUUID(); + const device2AnonymousId = generateTestUUID(); + const device3AnonymousId = generateTestUUID(); + const email = 'test@example.com'; + + const testUser = await createTestUser(device1AnonymousId, email); + + const date = '2024-01-01'; + // Three completions on same date at different times + await createTestCompletion(device1AnonymousId, date, 4, new Date('2024-01-01T08:00:00Z')); // Earliest + await createTestCompletion(device2AnonymousId, date, 2, new Date('2024-01-01T14:00:00Z')); // Middle + await createTestCompletion(device3AnonymousId, date, 6, new Date('2024-01-01T20:00:00Z')); // Latest + + // Migrate all to user ID + const user = await auth.getUserByEmail(email); + await db + .update(dailyCompletions) + .set({ anonymousId: user!.id }) + .where(eq(dailyCompletions.anonymousId, device2AnonymousId)); + await db + .update(dailyCompletions) + .set({ anonymousId: user!.id }) + .where(eq(dailyCompletions.anonymousId, device3AnonymousId)); + + // Implement deduplication + const allUserCompletions = await db + .select() + .from(dailyCompletions) + .where(eq(dailyCompletions.anonymousId, user!.id)); + + const dateGroups = new Map(); + for (const completion of allUserCompletions) { + if (!dateGroups.has(completion.date)) { + dateGroups.set(completion.date, []); + } + dateGroups.get(completion.date)!.push(completion); + } + + const duplicateIds: string[] = []; + for (const [_, completions] of dateGroups) { + if (completions.length > 1) { + completions.sort((a, b) => a.completedAt.getTime() - b.completedAt.getTime()); + const toDelete = completions.slice(1); + duplicateIds.push(...toDelete.map(c => c.id)); + } + } + + // Delete duplicates + for (const id of duplicateIds) { + await db.delete(dailyCompletions).where(eq(dailyCompletions.id, id)); + } + + // Verify only earliest kept + const finalStats = await db + .select() + .from(dailyCompletions) + .where(eq(dailyCompletions.anonymousId, user!.id)); + + expect(finalStats).toHaveLength(1); // 2 duplicates removed + expect(finalStats[0].guessCount).toBe(4); // First device's score + expect(finalStats[0].completedAt.getTime()).toBe(new Date('2024-01-01T08:00:00Z').getTime()); + }); +}); \ No newline at end of file