mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
- 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>
287 lines
10 KiB
TypeScript
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());
|
|
});
|
|
}); |