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()); }); });