/** * 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 (used colors): no existing users → no colors in use → palette[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) { // Used-colors query — no existing users return { from: vi.fn().mockResolvedValue([]), }; } // 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) { // Used-colors query — one existing user already holds palette[0], // so the next member must get the first unused color: palette[1]. return { from: vi.fn().mockResolvedValue([{ color: COLOR_PALETTE[0] }]), }; } 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]); }); // Regression (Gate 2): a new member must get a color NOT already in use, even // after a deletion. The old COUNT(*) % palette logic reused an in-use slot // when the user count had shifted (two members both got #E8734A). With colors // [0] and [2] taken (slot [1] freed by a delete), the next member fills [1]. it('assigns the first UNUSED palette color (no collision after deletions)', async () => { const iss = 'https://auth.example.com'; const sub = 'user-sub-005'; let selectCallCount = 0; mockDb.select.mockImplementation(() => { selectCallCount++; if (selectCallCount === 1) return makeSelectChain([]); // not found if (selectCallCount === 2) { // palette[0] and palette[2] in use; palette[1] is free return { from: vi .fn() .mockResolvedValue([{ color: COLOR_PALETTE[0] }, { color: COLOR_PALETTE[2] }]), }; } return makeSelectChain([ { id: 5, oidcIss: iss, oidcSub: sub, displayName: null, color: COLOR_PALETTE[1], createdAt: new Date(), }, ]); }); mockDb.insert.mockReturnValue(makeInsertChain([{ id: 5 }])); await upsertUser(iss, sub); // The inserted row's color must be the first unused palette entry (palette[1]). const insertValues = mockDb.insert.mock.results[0]?.value?.values.mock.calls[0]?.[0]; expect(insertValues.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'); }); });