Ctrl K
ComunidadGuías…

tests/integration/projects.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<Array<{ slug: string }>>()
      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<Array<{ slug: string }>>()
      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<Array<{ slug: string }>>()
      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()
    })
  })
})