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 describeIfDb('projects', () => { const db = process.env.TEST_DATABASE_URL ? getTestDb() : undefined beforeEach(async () => { process.env.DATABASE_URL = process.env.TEST_DATABASE_URL await truncateAll(db!) }) /** * Tests for creating new projects */ describe('POST /api/v1/admin/projects', () => { it('creates a project successfully and returns 201 with correct shape', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const response = await app.inject({ method: 'POST', url: '/api/v1/admin/projects', headers: { authorization: rootKey }, payload: { name: 'My Project', slug: 'my-project' }, }) expect(response.statusCode).toBe(201) expect(response.json()).toMatchObject({ name: 'My Project', slug: 'my-project', }) const body = response.json<{ id: string name: string slug: string createdAt: string updatedAt: string }>() expect(typeof body.id).toBe('string') expect(typeof body.createdAt).toBe('string') expect(typeof body.updatedAt).toBe('string') await app.close() }) it('auto-creates development and production environments', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey, 'env-check') const response = await app.inject({ method: 'GET', url: `/api/v1/admin/projects/${project.id}/environments`, headers: { authorization: rootKey }, }) expect(response.statusCode).toBe(200) const envs = response.json>() const slugs = envs.map((e) => e.slug).sort() expect(slugs).toEqual(['development', 'production']) await app.close() }) it('returns 409 on duplicate slug', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) await createProject(app, rootKey, 'clash') const response = await app.inject({ method: 'POST', url: '/api/v1/admin/projects', headers: { authorization: rootKey }, payload: { name: 'Another', slug: 'clash' }, }) expect(response.statusCode).toBe(409) expect(response.json()).toMatchObject({ error: 'Conflict', statusCode: 409 }) await app.close() }) it('returns 400 when name is missing', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const response = await app.inject({ method: 'POST', url: '/api/v1/admin/projects', headers: { authorization: rootKey }, payload: { slug: 'no-name' }, }) expect(response.statusCode).toBe(400) await app.close() }) it('returns 400 when slug is missing', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const response = await app.inject({ method: 'POST', url: '/api/v1/admin/projects', headers: { authorization: rootKey }, payload: { name: 'No Slug' }, }) expect(response.statusCode).toBe(400) await app.close() }) it('returns 403 when called with a project-scoped admin key', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey, 'existing') const adminKey = await createAdminKey(app, rootKey, project.id) const response = await app.inject({ method: 'POST', url: '/api/v1/admin/projects', headers: { authorization: adminKey }, payload: { name: 'Sneaky', slug: 'sneaky' }, }) expect(response.statusCode).toBe(403) expect(response.json()).toMatchObject({ error: 'Forbidden', statusCode: 403 }) await app.close() }) }) /** * Tests for listing all projects */ describe('GET /api/v1/admin/projects', () => { it('root key sees all projects when multiple exist', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) await createProject(app, rootKey, 'alpha') await createProject(app, rootKey, 'beta') const response = await app.inject({ method: 'GET', url: '/api/v1/admin/projects', headers: { authorization: rootKey }, }) expect(response.statusCode).toBe(200) const projects = response.json>() const slugs = projects.map((p) => p.slug).sort() expect(slugs).toContain('alpha') expect(slugs).toContain('beta') expect(projects.length).toBeGreaterThanOrEqual(2) await app.close() }) it('project-scoped admin key sees only its own project', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey, 'mine') await createProject(app, rootKey, 'theirs') const adminKey = await createAdminKey(app, rootKey, project.id) const response = await app.inject({ method: 'GET', url: '/api/v1/admin/projects', headers: { authorization: adminKey }, }) expect(response.statusCode).toBe(200) const projects = response.json>() expect(projects.length).toBe(1) expect(projects[0].slug).toBe('mine') await app.close() }) it('returns empty array when no projects exist (root key)', async () => { const app = await buildServer({ db, skipBootSeed: true }) const rootKey = await createRootKey(db!) const response = await app.inject({ method: 'GET', url: '/api/v1/admin/projects', headers: { authorization: rootKey }, }) expect(response.statusCode).toBe(200) expect(response.json()).toEqual([]) await app.close() }) }) /** * Tests for retrieving a single project by ID */ describe('GET /api/v1/admin/projects/:projectId', () => { it('returns the project by id', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey, 'fetch-me') const response = await app.inject({ method: 'GET', url: `/api/v1/admin/projects/${project.id}`, headers: { authorization: rootKey }, }) expect(response.statusCode).toBe(200) expect(response.json()).toMatchObject({ id: project.id, slug: 'fetch-me' }) await app.close() }) it('returns 404 for a non-existent project id', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const response = await app.inject({ method: 'GET', url: '/api/v1/admin/projects/00000000-0000-0000-0000-000000000000', headers: { authorization: rootKey }, }) expect(response.statusCode).toBe(404) expect(response.json()).toMatchObject({ error: 'NotFound', statusCode: 404 }) await app.close() }) it('returns 403 when a project admin tries to access a different project', 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 adminKeyA = await createAdminKey(app, rootKey, projectA.id) const response = await app.inject({ method: 'GET', url: `/api/v1/admin/projects/${projectB.id}`, headers: { authorization: adminKeyA }, }) expect(response.statusCode).toBe(403) expect(response.json()).toMatchObject({ error: 'Forbidden', statusCode: 403 }) await app.close() }) }) /** * Tests for updating a project */ describe('PATCH /api/v1/admin/projects/:projectId', () => { it('updates name successfully and returns 200 with updated name', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey, 'patch-name') const response = await app.inject({ method: 'PATCH', url: `/api/v1/admin/projects/${project.id}`, headers: { authorization: rootKey }, payload: { name: 'Updated Name' }, }) expect(response.statusCode).toBe(200) expect(response.json()).toMatchObject({ id: project.id, name: 'Updated Name' }) await app.close() }) it('updates slug successfully', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey, 'old-slug') const response = await app.inject({ method: 'PATCH', url: `/api/v1/admin/projects/${project.id}`, headers: { authorization: rootKey }, payload: { slug: 'new-slug' }, }) expect(response.statusCode).toBe(200) expect(response.json()).toMatchObject({ id: project.id, slug: 'new-slug' }) await app.close() }) it('returns 404 for a non-existent project', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const response = await app.inject({ method: 'PATCH', url: '/api/v1/admin/projects/00000000-0000-0000-0000-000000000000', headers: { authorization: rootKey }, payload: { name: 'Ghost' }, }) expect(response.statusCode).toBe(404) expect(response.json()).toMatchObject({ error: 'NotFound', statusCode: 404 }) await app.close() }) it('returns 409 when patching to a slug already taken by another project', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) await createProject(app, rootKey, 'taken') const project = await createProject(app, rootKey, 'to-patch') const response = await app.inject({ method: 'PATCH', url: `/api/v1/admin/projects/${project.id}`, headers: { authorization: rootKey }, payload: { slug: 'taken' }, }) expect(response.statusCode).toBe(409) expect(response.json()).toMatchObject({ error: 'Conflict', statusCode: 409 }) await app.close() }) it('returns 400 when body is empty', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey, 'empty-patch') const response = await app.inject({ method: 'PATCH', url: `/api/v1/admin/projects/${project.id}`, headers: { authorization: rootKey }, payload: {}, }) expect(response.statusCode).toBe(400) await app.close() }) it('persists flagDefaults settings and preserves them across an unrelated patch', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey, 'with-settings') const saved = await app.inject({ method: 'PATCH', url: `/api/v1/admin/projects/${project.id}`, headers: { authorization: rootKey }, payload: { settings: { flagDefaults: { defaultState: 'dev', requireDescription: true, staleFlagDays: 60 }, }, }, }) expect(saved.statusCode).toBe(200) expect(saved.json()).toMatchObject({ settings: { flagDefaults: { defaultState: 'dev', requireDescription: true, staleFlagDays: 60 }, }, }) // A name-only patch must not wipe the stored settings. await app.inject({ method: 'PATCH', url: `/api/v1/admin/projects/${project.id}`, headers: { authorization: rootKey }, payload: { name: 'Renamed' }, }) const fetched = await app.inject({ method: 'GET', url: `/api/v1/admin/projects/${project.id}`, headers: { authorization: rootKey }, }) expect(fetched.json()).toMatchObject({ name: 'Renamed', settings: { flagDefaults: { defaultState: 'dev', requireDescription: true, staleFlagDays: 60 }, }, }) await app.close() }) }) /** * Tests for deleting a project */ describe('DELETE /api/v1/admin/projects/:projectId', () => { it('deletes a project and returns 204', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey, 'to-delete') const response = await app.inject({ method: 'DELETE', url: `/api/v1/admin/projects/${project.id}`, headers: { authorization: rootKey }, }) expect(response.statusCode).toBe(204) await app.close() }) it('returns 404 when deleting a non-existent project', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const response = await app.inject({ method: 'DELETE', url: '/api/v1/admin/projects/00000000-0000-0000-0000-000000000000', headers: { authorization: rootKey }, }) expect(response.statusCode).toBe(404) expect(response.json()).toMatchObject({ error: 'NotFound', statusCode: 404 }) await app.close() }) it('returns 403 when called with a project-scoped admin key', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey, 'no-delete') const adminKey = await createAdminKey(app, rootKey, project.id) const response = await app.inject({ method: 'DELETE', url: `/api/v1/admin/projects/${project.id}`, headers: { authorization: adminKey }, }) expect(response.statusCode).toBe(403) expect(response.json()).toMatchObject({ error: 'Forbidden', statusCode: 403 }) await app.close() }) }) })