Files
bibdle/tests/signin-migration.test.ts
George Powell 95725ab4fe Add test infrastructure and signin migration tests
- Add test-specific Drizzle config and database connection
- Create test version of auth module using test database
- Add comprehensive integration tests for signin migration logic
- Add unit tests for deduplication algorithm
- Tests cover edge cases like multiple duplicates, timing, and error handling

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-05 18:49:21 -05:00

287 lines
10 KiB
TypeScript

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<string, typeof allUserCompletions>();
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<string, typeof allUserCompletions>();
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());
});
});