import { beforeEach, describe, expect, it } from 'vitest' import { buildServer } from '../../src/server.js' import { createAdminKey, createProject, createRootKey } from '../helpers/fixtures.js' import { getTestDb, truncateAll } from '../helpers/db.js' const describeIfDb = process.env.TEST_DATABASE_URL ? describe : describe.skip interface ContextFieldResponse { id: string key: string type: string description: string | null enumValues: string[] | null } describeIfDb('context-fields', () => { const db = process.env.TEST_DATABASE_URL ? getTestDb() : undefined beforeEach(async () => { process.env.DATABASE_URL = process.env.TEST_DATABASE_URL await truncateAll(db!) }) /** Builds a server with a project and its admin key ready to use. */ async function setup() { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey, 'ctx-project') const adminKey = await createAdminKey(app, rootKey, project.id) return { app, adminKey, projectId: project.id } } function create( app: Awaited>, adminKey: string, projectId: string, payload: Record, ) { return app.inject({ method: 'POST', url: `/api/v1/admin/projects/${projectId}/context-fields`, headers: { authorization: adminKey }, payload, }) } it('refuses to create more context fields than the per-project cap', async () => { const { app, adminKey, projectId } = await setup() for (let i = 0; i < 25; i++) { const res = await create(app, adminKey, projectId, { key: `field_${i}`, type: 'string' }) expect(res.statusCode).toBe(201) } const overflow = await create(app, adminKey, projectId, { key: 'one_too_many', type: 'string' }) expect(overflow.statusCode).toBe(409) expect(overflow.json<{ message: string }>().message).toMatch(/at most 25 context fields/i) /** Deleting one frees a slot again. */ const list = await app.inject({ method: 'GET', url: `/api/v1/admin/projects/${projectId}/context-fields`, headers: { authorization: adminKey }, }) const first = list.json()[0] await app.inject({ method: 'DELETE', url: `/api/v1/admin/projects/${projectId}/context-fields/${first.id}`, headers: { authorization: adminKey }, }) expect( (await create(app, adminKey, projectId, { key: 'one_too_many', type: 'string' })).statusCode, ).toBe(201) await app.close() }) it('creates, lists, updates, and deletes a context field', async () => { const { app, adminKey, projectId } = await setup() const created = await create(app, adminKey, projectId, { key: 'plan', type: 'enum', description: 'Subscription tier', enumValues: ['free', 'pro'], }) expect(created.statusCode).toBe(201) const field = created.json() expect(field).toMatchObject({ key: 'plan', type: 'enum', description: 'Subscription tier', enumValues: ['free', 'pro'], }) expect(typeof field.id).toBe('string') const list = await app.inject({ method: 'GET', url: `/api/v1/admin/projects/${projectId}/context-fields`, headers: { authorization: adminKey }, }) expect(list.statusCode).toBe(200) expect(list.json()).toHaveLength(1) const patched = await app.inject({ method: 'PATCH', url: `/api/v1/admin/projects/${projectId}/context-fields/${field.id}`, headers: { authorization: adminKey }, payload: { type: 'string', description: 'Now a string' }, }) expect(patched.statusCode).toBe(200) expect(patched.json()).toMatchObject({ key: 'plan', type: 'string', description: 'Now a string', enumValues: null, }) const deleted = await app.inject({ method: 'DELETE', url: `/api/v1/admin/projects/${projectId}/context-fields/${field.id}`, headers: { authorization: adminKey }, }) expect(deleted.statusCode).toBe(204) const listAfter = await app.inject({ method: 'GET', url: `/api/v1/admin/projects/${projectId}/context-fields`, headers: { authorization: adminKey }, }) expect(listAfter.json()).toHaveLength(0) await app.close() }) it('returns 409 on a duplicate key within a project', async () => { const { app, adminKey, projectId } = await setup() const first = await create(app, adminKey, projectId, { key: 'country' }) expect(first.statusCode).toBe(201) const dup = await create(app, adminKey, projectId, { key: 'country' }) expect(dup.statusCode).toBe(409) await app.close() }) it('PATCH cannot change the key', async () => { const { app, adminKey, projectId } = await setup() const created = await create(app, adminKey, projectId, { key: 'region' }) const field = created.json() const patched = await app.inject({ method: 'PATCH', url: `/api/v1/admin/projects/${projectId}/context-fields/${field.id}`, headers: { authorization: adminKey }, payload: { key: 'renamed', type: 'number' }, }) expect(patched.statusCode).toBe(200) expect(patched.json().key).toBe('region') await app.close() }) it('returns 400 when enum type is missing enumValues', async () => { const { app, adminKey, projectId } = await setup() const res = await create(app, adminKey, projectId, { key: 'tier', type: 'enum' }) expect(res.statusCode).toBe(400) await app.close() }) it('PATCH without type is rejected (no silent reset to string)', async () => { const { app, adminKey, projectId } = await setup() const created = await create(app, adminKey, projectId, { key: 'plan', type: 'enum', enumValues: ['free', 'pro'], }) const field = created.json() const res = await app.inject({ method: 'PATCH', url: `/api/v1/admin/projects/${projectId}/context-fields/${field.id}`, headers: { authorization: adminKey }, payload: { description: 'just a note' }, }) expect(res.statusCode).toBe(400) // the enum field is untouched const list = await app.inject({ method: 'GET', url: `/api/v1/admin/projects/${projectId}/context-fields`, headers: { authorization: adminKey }, }) expect(list.json()[0]).toMatchObject({ type: 'enum', enumValues: ['free', 'pro'], }) await app.close() }) it('isolates fields per project: same key allowed in two projects, list is scoped', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const projectA = await createProject(app, rootKey, 'project-a') const projectB = await createProject(app, rootKey, 'project-b') const adminA = await createAdminKey(app, rootKey, projectA.id) const adminB = await createAdminKey(app, rootKey, projectB.id) expect((await create(app, adminA, projectA.id, { key: 'shared' })).statusCode).toBe(201) expect((await create(app, adminB, projectB.id, { key: 'shared' })).statusCode).toBe(201) const listA = await app.inject({ method: 'GET', url: `/api/v1/admin/projects/${projectA.id}/context-fields`, headers: { authorization: adminA }, }) expect(listA.json()).toHaveLength(1) await app.close() }) })