/** * Auth: upsertUser color round-robin + identity stability * * Tests for apps/api/src/auth/user.ts (Plan 02) */ 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', () => { beforeEach(() => { vi.clearAllMocks() }) it('assigns palette[0] to the first user inserted', async () => { const iss = 'https://auth.example.com' const sub = 'user-sub-001' // 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() }, ]) }) mockDb.insert.mockReturnValue(makeInsertChain([{ id: 1 }])) 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') }) })