diff --git a/apps/api/tests/auth/user.test.ts b/apps/api/tests/auth/user.test.ts index 48b91ab..0d9aae3 100644 --- a/apps/api/tests/auth/user.test.ts +++ b/apps/api/tests/auth/user.test.ts @@ -1,22 +1,202 @@ /** - * Wave 0 stub — Auth: upsertUser color round-robin + identity stability + * Auth: upsertUser color round-robin + identity stability * - * These tests are RED stubs. Implementation lives in: - * apps/api/src/auth/user.ts (Plan 02) - * - * Tests will be filled GREEN in Plan 02 when upsertUser is implemented. + * Tests for apps/api/src/auth/user.ts (Plan 02) */ -import { describe, it } from 'vitest' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock the db singleton at module level (Vitest hoisting — must be top-level) +vi.mock('../../src/db/client.js', () => ({ + db: { + select: vi.fn(), + insert: vi.fn(), + }, +})) + +// Import after mock is set up +import { db } from '../../src/db/client.js' +import { upsertUser, COLOR_PALETTE } from '../../src/auth/user.js' + +const mockDb = db as { + select: ReturnType + insert: ReturnType +} + +// Chainable builder factory used in multiple tests +function makeSelectChain(resolvedValue: unknown[]) { + const chain = { + from: vi.fn(), + where: vi.fn(), + limit: vi.fn().mockResolvedValue(resolvedValue), + } + chain.from.mockReturnValue(chain) + chain.where.mockReturnValue(chain) + return chain +} + +function makeInsertChain(returningIdValue: { id: number }[]) { + const chain = { + values: vi.fn(), + $returningId: vi.fn().mockResolvedValue(returningIdValue), + } + chain.values.mockReturnValue(chain) + return chain +} + +describe('COLOR_PALETTE', () => { + it('exports at least 4 distinct hex colors', () => { + expect(COLOR_PALETTE).toBeDefined() + expect(COLOR_PALETTE.length).toBeGreaterThanOrEqual(4) + for (const c of COLOR_PALETTE) { + // Each entry must be a 7-char hex string like #4A90D9 + expect(c).toMatch(/^#[0-9A-Fa-f]{6}$/) + } + // All colors must be distinct + const unique = new Set(COLOR_PALETTE) + expect(unique.size).toBe(COLOR_PALETTE.length) + }) +}) describe('upsertUser', () => { - it.todo('assigns a color from the palette on first login (Plan 02)') + beforeEach(() => { + vi.clearAllMocks() + }) - it.todo('returns the same color on subsequent logins for the same oidc identity (Plan 02)') + it('assigns palette[0] to the first user inserted', async () => { + const iss = 'https://auth.example.com' + const sub = 'user-sub-001' - it.todo('assigns distinct colors to two different users (Plan 02)') + // First select: no existing user + // Second select (count): count = 0 + // Third select (re-fetch after insert): return the inserted row + let selectCallCount = 0 + mockDb.select.mockImplementation(() => { + selectCallCount++ + if (selectCallCount === 1) { + // Lookup by iss+sub — not found + return makeSelectChain([]) + } + if (selectCallCount === 2) { + // COUNT(*) query — 0 users + return { + from: vi.fn().mockResolvedValue([{ count: 0 }]), + } + } + // Re-fetch after insert + return makeSelectChain([ + { id: 1, oidcIss: iss, oidcSub: sub, displayName: null, color: COLOR_PALETTE[0], createdAt: new Date() }, + ]) + }) - it.todo('uses oidc_iss + oidc_sub as the composite identity key, not email (Plan 02)') + mockDb.insert.mockReturnValue(makeInsertChain([{ id: 1 }])) - it.todo('returns the full user row including id, color, displayName (Plan 02)') + const user = await upsertUser(iss, sub) + + expect(user).toBeDefined() + expect(user!.color).toBe(COLOR_PALETTE[0]) + expect(user!.id).toBe(1) + }) + + it('assigns palette[1] to the second distinct user', async () => { + const iss = 'https://auth.example.com' + const sub2 = 'user-sub-002' + + let selectCallCount = 0 + mockDb.select.mockImplementation(() => { + selectCallCount++ + if (selectCallCount === 1) { + return makeSelectChain([]) // not found + } + if (selectCallCount === 2) { + // COUNT(*) — 1 existing user + return { + from: vi.fn().mockResolvedValue([{ count: 1 }]), + } + } + return makeSelectChain([ + { id: 2, oidcIss: iss, oidcSub: sub2, displayName: null, color: COLOR_PALETTE[1], createdAt: new Date() }, + ]) + }) + + mockDb.insert.mockReturnValue(makeInsertChain([{ id: 2 }])) + + const user = await upsertUser(iss, sub2) + + expect(user!.color).toBe(COLOR_PALETTE[1]) + }) + + it('returns the same user row on re-upsert (idempotent — no duplicate insert)', async () => { + const iss = 'https://auth.example.com' + const sub = 'user-sub-001' + const existingRow = { + id: 1, + oidcIss: iss, + oidcSub: sub, + displayName: 'Lucas', + color: COLOR_PALETTE[0], + createdAt: new Date(), + } + + // select returns existing row immediately + mockDb.select.mockImplementation(() => makeSelectChain([existingRow])) + + const user = await upsertUser(iss, sub, 'Lucas') + + // Must NOT call insert (idempotent path) + expect(mockDb.insert).not.toHaveBeenCalled() + expect(user!.id).toBe(1) + expect(user!.color).toBe(COLOR_PALETTE[0]) + }) + + it('uses oidc_iss + oidc_sub as identity key, never email', async () => { + const iss = 'https://auth.example.com' + const sub = 'user-sub-003' + + let selectCallCount = 0 + mockDb.select.mockImplementation(() => { + selectCallCount++ + if (selectCallCount === 1) return makeSelectChain([]) + if (selectCallCount === 2) { + return { from: vi.fn().mockResolvedValue([{ count: 0 }]) } + } + return makeSelectChain([ + { id: 3, oidcIss: iss, oidcSub: sub, displayName: null, color: COLOR_PALETTE[0], createdAt: new Date() }, + ]) + }) + mockDb.insert.mockReturnValue(makeInsertChain([{ id: 3 }])) + + // Pass a displayName (e.g. email) — identity still keyed on iss+sub + await upsertUser(iss, sub, 'lucas@example.com') + + // The insert values must include oidcIss and oidcSub, not email as key + const insertValues = mockDb.insert.mock.results[0]?.value?.values.mock.calls[0]?.[0] + expect(insertValues).toBeDefined() + expect(insertValues.oidcIss).toBe(iss) + expect(insertValues.oidcSub).toBe(sub) + // No 'email' property should be used as an identity field + expect(insertValues).not.toHaveProperty('email') + }) + + it('returns the full user row including id, color, displayName', async () => { + const iss = 'https://auth.example.com' + const sub = 'user-sub-004' + const existingRow = { + id: 42, + oidcIss: iss, + oidcSub: sub, + displayName: 'Alice', + color: '#9B6DC5', + createdAt: new Date(), + } + + mockDb.select.mockImplementation(() => makeSelectChain([existingRow])) + + const user = await upsertUser(iss, sub, 'Alice') + + expect(user).toBeDefined() + expect(user!.id).toBe(42) + expect(user!.color).toBe('#9B6DC5') + expect(user!.displayName).toBe('Alice') + }) })