test(01-02): add failing tests for upsertUser color assignment + identity
- Replace it.todo stubs with real failing tests (RED gate) - Tests cover palette[0] first user, palette[1] second user, idempotent re-upsert, iss+sub identity key, full row shape - Tests fail: src/auth/user.ts does not exist yet
This commit is contained in:
@@ -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:
|
* Tests for apps/api/src/auth/user.ts (Plan 02)
|
||||||
* apps/api/src/auth/user.ts (Plan 02)
|
|
||||||
*
|
|
||||||
* Tests will be filled GREEN in Plan 02 when upsertUser is implemented.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
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<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', () => {
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user