Files
familysync/apps/api/tests/auth/user.test.ts
T
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
2026-06-11 20:35:18 -04:00

265 lines
7.9 KiB
TypeScript

/**
* 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<typeof vi.fn>;
insert: ReturnType<typeof vi.fn>;
};
// 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');
});
});