tests/integration/context-fields.test.ts
Rama main · réplica de lectura del repositorio original.
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<ReturnType<typeof buildServer>>,
adminKey: string,
projectId: string,
payload: Record<string, unknown>,
) {
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<ContextFieldResponse[]>()[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<ContextFieldResponse>()
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<ContextFieldResponse[]>()).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<ContextFieldResponse>()).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<ContextFieldResponse[]>()).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<ContextFieldResponse>()
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<ContextFieldResponse>().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<ContextFieldResponse>()
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<ContextFieldResponse[]>()[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<ContextFieldResponse[]>()).toHaveLength(1)
await app.close()
})
})