Gate 2 A3 fail: a second member (amelia) got the same color as the first (luc), both #E8734A. Color was assigned by COUNT(*) % palette; a deleted spike user shifted the count so two live members landed on the same slot. Replace with 'first palette color not already in use by another user' (fall back to count round-robin only once the palette is exhausted) — guarantees distinct, stable colors for up to palette length members. +1 regression test (deletion frees a slot → next member fills it, no collision).
237 lines
7.5 KiB
TypeScript
237 lines
7.5 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')
|
|
})
|
|
})
|