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('environments', () => { const db = process.env.TEST_DATABASE_URL ? getTestDb() : undefined beforeEach(async () => { process.env.DATABASE_URL = process.env.TEST_DATABASE_URL await truncateAll(db!) }) describe('POST /api/v1/admin/projects/:projectId/environments', () => { it('refuses to create more environments than the per-project cap', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey) const adminKey = await createAdminKey(app, rootKey, project.id) const create = (slug: string) => app.inject({ method: 'POST', url: `/api/v1/admin/projects/${project.id}/environments`, headers: { authorization: adminKey }, payload: { name: slug, slug, protected: false }, }) /** A new project already has development and production, so one fits. */ expect((await create('staging')).statusCode).toBe(201) const overflow = await create('preview') expect(overflow.statusCode).toBe(409) expect(overflow.json<{ message: string }>().message).toMatch(/at most 3 environments/i) /** Deleting one frees a slot again. */ const list = await app.inject({ method: 'GET', url: `/api/v1/admin/projects/${project.id}/environments`, headers: { authorization: adminKey }, }) const staging = list .json>() .find((e) => e.slug === 'staging')! await app.inject({ method: 'DELETE', url: `/api/v1/admin/projects/${project.id}/environments/${staging.id}`, headers: { authorization: adminKey }, }) expect((await create('preview')).statusCode).toBe(201) await app.close() }) it('creates an environment successfully and returns 201 with correct shape', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey) const adminKey = await createAdminKey(app, rootKey, project.id) const res = await app.inject({ method: 'POST', url: `/api/v1/admin/projects/${project.id}/environments`, headers: { authorization: adminKey }, payload: { name: 'QA', slug: 'qa' }, }) expect(res.statusCode).toBe(201) expect(res.json()).toMatchObject({ projectId: project.id, name: 'QA', slug: 'qa', }) const body = res.json<{ id: string projectId: string name: string slug: string createdAt: string }>() expect(typeof body.id).toBe('string') expect(typeof body.createdAt).toBe('string') await app.close() }) it('returns 409 on duplicate slug within the same project', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey) const adminKey = await createAdminKey(app, rootKey, project.id) await app.inject({ method: 'POST', url: `/api/v1/admin/projects/${project.id}/environments`, headers: { authorization: adminKey }, payload: { name: 'Canary', slug: 'canary' }, }) const duplicate = await app.inject({ method: 'POST', url: `/api/v1/admin/projects/${project.id}/environments`, headers: { authorization: adminKey }, payload: { name: 'Canary Again', slug: 'canary' }, }) expect(duplicate.statusCode).toBe(409) expect(duplicate.json()).toMatchObject({ error: 'Conflict', statusCode: 409, }) await app.close() }) it('allows the same slug in two different projects', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const projectA = await createProject(app, rootKey, 'project-alpha') const projectB = await createProject(app, rootKey, 'project-beta') const adminKeyA = await createAdminKey(app, rootKey, projectA.id) const adminKeyB = await createAdminKey(app, rootKey, projectB.id) const resA = await app.inject({ method: 'POST', url: `/api/v1/admin/projects/${projectA.id}/environments`, headers: { authorization: adminKeyA }, payload: { name: 'Canary', slug: 'canary' }, }) const resB = await app.inject({ method: 'POST', url: `/api/v1/admin/projects/${projectB.id}/environments`, headers: { authorization: adminKeyB }, payload: { name: 'Canary', slug: 'canary' }, }) expect(resA.statusCode).toBe(201) expect(resB.statusCode).toBe(201) await app.close() }) it('returns 400 when name is missing', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey) const adminKey = await createAdminKey(app, rootKey, project.id) const res = await app.inject({ method: 'POST', url: `/api/v1/admin/projects/${project.id}/environments`, headers: { authorization: adminKey }, payload: { slug: 'no-name' }, }) expect(res.statusCode).toBe(400) expect(res.json()).toMatchObject({ error: 'ValidationError', statusCode: 400, }) await app.close() }) it('returns 400 when slug is missing', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey) const adminKey = await createAdminKey(app, rootKey, project.id) const res = await app.inject({ method: 'POST', url: `/api/v1/admin/projects/${project.id}/environments`, headers: { authorization: adminKey }, payload: { name: 'No Slug' }, }) expect(res.statusCode).toBe(400) expect(res.json()).toMatchObject({ error: 'ValidationError', statusCode: 400, }) await app.close() }) }) describe('GET /api/v1/admin/projects/:projectId/environments', () => { it('returns the 2 auto-created environments after project creation', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey) const adminKey = await createAdminKey(app, rootKey, project.id) const res = await app.inject({ method: 'GET', url: `/api/v1/admin/projects/${project.id}/environments`, headers: { authorization: adminKey }, }) expect(res.statusCode).toBe(200) const envs = res.json>() expect(envs).toHaveLength(2) expect(envs.map((e) => e.slug).sort()).toEqual(['development', 'production']) await app.close() }) it('includes a newly created environment in addition to the 2 defaults', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey) const adminKey = await createAdminKey(app, rootKey, project.id) await app.inject({ method: 'POST', url: `/api/v1/admin/projects/${project.id}/environments`, headers: { authorization: adminKey }, payload: { name: 'QA', slug: 'qa' }, }) const res = await app.inject({ method: 'GET', url: `/api/v1/admin/projects/${project.id}/environments`, headers: { authorization: adminKey }, }) expect(res.statusCode).toBe(200) const envs = res.json>() expect(envs).toHaveLength(3) expect(envs.map((e) => e.slug)).toContain('qa') await app.close() }) it('returns environments ordered by createdAt with auto-created envs first', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey) const adminKey = await createAdminKey(app, rootKey, project.id) await app.inject({ method: 'POST', url: `/api/v1/admin/projects/${project.id}/environments`, headers: { authorization: adminKey }, payload: { name: 'QA', slug: 'qa' }, }) const res = await app.inject({ method: 'GET', url: `/api/v1/admin/projects/${project.id}/environments`, headers: { authorization: adminKey }, }) expect(res.statusCode).toBe(200) const envs = res.json>() expect(envs).toHaveLength(3) const defaultSlugs = ['development', 'production'] const defaultEnvs = envs.filter((e) => defaultSlugs.includes(e.slug)) const customEnvs = envs.filter((e) => !defaultSlugs.includes(e.slug)) /** * All default envs should appear before the custom one (ordered by createdAt) */ const lastDefaultIndex = Math.max(...defaultEnvs.map((e) => envs.indexOf(e))) const firstCustomIndex = Math.min(...customEnvs.map((e) => envs.indexOf(e))) expect(lastDefaultIndex).toBeLessThan(firstCustomIndex) /** * Verify the list is sorted ascending by createdAt */ const timestamps = envs.map((e) => new Date(e.createdAt).getTime()) const sorted = [...timestamps].sort((a, b) => a - b) expect(timestamps).toEqual(sorted) await app.close() }) }) describe('PATCH /api/v1/admin/projects/:projectId/environments/:environmentId', () => { it('updates name and protected and returns the updated environment', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey) const adminKey = await createAdminKey(app, rootKey, project.id) const envsRes = await app.inject({ method: 'GET', url: `/api/v1/admin/projects/${project.id}/environments`, headers: { authorization: adminKey }, }) const target = envsRes .json>() .find((e) => e.slug === 'development')! const res = await app.inject({ method: 'PATCH', url: `/api/v1/admin/projects/${project.id}/environments/${target.id}`, headers: { authorization: adminKey }, payload: { name: 'Dev Renamed', protected: true }, }) expect(res.statusCode).toBe(200) expect(res.json()).toMatchObject({ id: target.id, slug: 'development', name: 'Dev Renamed', protected: true, }) await app.close() }) it('returns 400 when the body is empty', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey) const adminKey = await createAdminKey(app, rootKey, project.id) const envsRes = await app.inject({ method: 'GET', url: `/api/v1/admin/projects/${project.id}/environments`, headers: { authorization: adminKey }, }) const target = envsRes.json>()[0] const res = await app.inject({ method: 'PATCH', url: `/api/v1/admin/projects/${project.id}/environments/${target.id}`, headers: { authorization: adminKey }, payload: {}, }) expect(res.statusCode).toBe(400) expect(res.json()).toMatchObject({ error: 'ValidationError', statusCode: 400 }) await app.close() }) it('returns 404 for a non-existent environmentId', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey) const adminKey = await createAdminKey(app, rootKey, project.id) const res = await app.inject({ method: 'PATCH', url: `/api/v1/admin/projects/${project.id}/environments/00000000-0000-0000-0000-000000000000`, headers: { authorization: adminKey }, payload: { name: 'Nope' }, }) expect(res.statusCode).toBe(404) expect(res.json()).toMatchObject({ error: 'NotFound', statusCode: 404 }) await app.close() }) }) describe('DELETE /api/v1/admin/projects/:projectId/environments/:environmentId', () => { it('deletes an environment and returns 204', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey) const adminKey = await createAdminKey(app, rootKey, project.id) const envsRes = await app.inject({ method: 'GET', url: `/api/v1/admin/projects/${project.id}/environments`, headers: { authorization: adminKey }, }) const envs = envsRes.json>() const target = envs.find((e) => e.slug === 'development')! const res = await app.inject({ method: 'DELETE', url: `/api/v1/admin/projects/${project.id}/environments/${target.id}`, headers: { authorization: adminKey }, }) expect(res.statusCode).toBe(204) const afterRes = await app.inject({ method: 'GET', url: `/api/v1/admin/projects/${project.id}/environments`, headers: { authorization: adminKey }, }) const remaining = afterRes.json>() expect(remaining.map((e) => e.slug)).not.toContain('development') await app.close() }) it('returns 404 for a non-existent environmentId', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const project = await createProject(app, rootKey) const adminKey = await createAdminKey(app, rootKey, project.id) const res = await app.inject({ method: 'DELETE', url: `/api/v1/admin/projects/${project.id}/environments/00000000-0000-0000-0000-000000000000`, headers: { authorization: adminKey }, }) expect(res.statusCode).toBe(404) expect(res.json()).toMatchObject({ error: 'NotFound', statusCode: 404, }) await app.close() }) it('returns 404 when deleting an environment from project A using project B admin key', 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 adminKeyB = await createAdminKey(app, rootKey, projectB.id) const envsRes = await app.inject({ method: 'GET', url: `/api/v1/admin/projects/${projectA.id}/environments`, headers: { authorization: adminKeyA }, }) const envs = envsRes.json>() const targetEnv = envs[0] /** * Attempt to delete project A's environment using project B's admin key and project B's URL */ const res = await app.inject({ method: 'DELETE', url: `/api/v1/admin/projects/${projectB.id}/environments/${targetEnv.id}`, headers: { authorization: adminKeyB }, }) expect(res.statusCode).toBe(404) expect(res.json()).toMatchObject({ error: 'NotFound', statusCode: 404, }) await app.close() }) }) })