mission-control/tests/projects-crud.spec.ts

333 lines
12 KiB
TypeScript

import { test, expect } from '@playwright/test'
import { API_KEY_HEADER, createTestProject, deleteTestProject } from './helpers'
test.describe('Projects CRUD', () => {
const cleanup: number[] = []
test.afterEach(async ({ request }) => {
for (const id of cleanup) {
await deleteTestProject(request, id).catch(() => {})
}
cleanup.length = 0
})
// ── POST /api/projects ──────────────────────────
test('POST creates project with minimal fields (name only)', async ({ request }) => {
const { id, res, body } = await createTestProject(request)
cleanup.push(id)
expect(res.status()).toBe(201)
expect(body.project).toBeDefined()
expect(body.project.name).toContain('e2e-project-')
expect(body.project.status).toBe('active')
expect(body.project.slug).toBeTruthy()
expect(body.project.ticket_prefix).toBeTruthy()
})
test('POST creates project with all enhanced fields', async ({ request }) => {
const { id, res, body } = await createTestProject(request, {
description: 'Full project for e2e',
ticket_prefix: 'E2EFULL',
github_repo: 'test-org/test-repo',
deadline: Math.floor(Date.now() / 1000) + 86400,
color: '#3b82f6',
})
cleanup.push(id)
expect(res.status()).toBe(201)
expect(body.project.description).toBe('Full project for e2e')
expect(body.project.ticket_prefix).toBe('E2EFULL')
expect(body.project.github_repo).toBe('test-org/test-repo')
expect(body.project.deadline).toBeGreaterThan(0)
expect(body.project.color).toBe('#3b82f6')
})
test('POST rejects empty name', async ({ request }) => {
const res = await request.post('/api/projects', {
headers: API_KEY_HEADER,
data: { name: '' },
})
expect(res.status()).toBe(400)
})
test('POST rejects duplicate slug', async ({ request }) => {
const { id } = await createTestProject(request, { name: 'Duplicate Test Project' })
cleanup.push(id)
const res = await request.post('/api/projects', {
headers: API_KEY_HEADER,
data: { name: 'Duplicate Test Project' },
})
expect(res.status()).toBe(409)
})
// ── GET /api/projects ───────────────────────────
test('GET list returns projects with enhanced fields', async ({ request }) => {
const { id } = await createTestProject(request, {
description: 'Listed project',
github_repo: 'org/repo',
color: '#ef4444',
})
cleanup.push(id)
const res = await request.get('/api/projects', { headers: API_KEY_HEADER })
expect(res.status()).toBe(200)
const body = await res.json()
expect(body).toHaveProperty('projects')
expect(Array.isArray(body.projects)).toBe(true)
const found = body.projects.find((p: any) => p.id === id)
expect(found).toBeDefined()
expect(found.github_repo).toBe('org/repo')
expect(found.color).toBe('#ef4444')
expect(typeof found.task_count).toBe('number')
expect(Array.isArray(found.assigned_agents)).toBe(true)
})
test('GET list excludes archived by default', async ({ request }) => {
const { id } = await createTestProject(request)
cleanup.push(id)
// Archive the project
await request.patch(`/api/projects/${id}`, {
headers: API_KEY_HEADER,
data: { status: 'archived' },
})
const res = await request.get('/api/projects', { headers: API_KEY_HEADER })
const body = await res.json()
const found = body.projects.find((p: any) => p.id === id)
expect(found).toBeUndefined()
// But includeArchived=1 should show it
const resArchived = await request.get('/api/projects?includeArchived=1', { headers: API_KEY_HEADER })
const bodyArchived = await resArchived.json()
const foundArchived = bodyArchived.projects.find((p: any) => p.id === id)
expect(foundArchived).toBeDefined()
expect(foundArchived.status).toBe('archived')
})
// ── GET /api/projects/[id] ──────────────────────
test('GET single returns enriched project', async ({ request }) => {
const { id } = await createTestProject(request, {
description: 'Single project',
github_repo: 'owner/repo',
color: '#10b981',
})
cleanup.push(id)
const res = await request.get(`/api/projects/${id}`, { headers: API_KEY_HEADER })
expect(res.status()).toBe(200)
const body = await res.json()
expect(body.project).toBeDefined()
expect(body.project.id).toBe(id)
expect(body.project.github_repo).toBe('owner/repo')
expect(body.project.color).toBe('#10b981')
expect(typeof body.project.task_count).toBe('number')
expect(Array.isArray(body.project.assigned_agents)).toBe(true)
})
test('GET single returns 404 for missing project', async ({ request }) => {
const res = await request.get('/api/projects/999999', { headers: API_KEY_HEADER })
expect(res.status()).toBe(404)
})
// ── PATCH /api/projects/[id] ────────────────────
test('PATCH updates basic fields', async ({ request }) => {
const { id } = await createTestProject(request)
cleanup.push(id)
const res = await request.patch(`/api/projects/${id}`, {
headers: API_KEY_HEADER,
data: { name: 'Updated Project Name', description: 'Updated description' },
})
expect(res.status()).toBe(200)
const body = await res.json()
expect(body.project.name).toBe('Updated Project Name')
expect(body.project.description).toBe('Updated description')
})
test('PATCH updates enhanced fields (github_repo, deadline, color)', async ({ request }) => {
const { id } = await createTestProject(request)
cleanup.push(id)
const deadline = Math.floor(Date.now() / 1000) + 172800
const res = await request.patch(`/api/projects/${id}`, {
headers: API_KEY_HEADER,
data: {
github_repo: 'new-org/new-repo',
deadline,
color: '#8b5cf6',
},
})
expect(res.status()).toBe(200)
const body = await res.json()
expect(body.project.github_repo).toBe('new-org/new-repo')
expect(body.project.deadline).toBe(deadline)
expect(body.project.color).toBe('#8b5cf6')
})
test('PATCH can clear optional fields with null', async ({ request }) => {
const { id } = await createTestProject(request, {
github_repo: 'will-clear/repo',
color: '#ef4444',
})
cleanup.push(id)
const res = await request.patch(`/api/projects/${id}`, {
headers: API_KEY_HEADER,
data: { github_repo: null, color: null, deadline: null },
})
expect(res.status()).toBe(200)
const body = await res.json()
expect(body.project.github_repo).toBeNull()
expect(body.project.color).toBeNull()
expect(body.project.deadline).toBeNull()
})
test('PATCH returns 404 for missing project', async ({ request }) => {
const res = await request.patch('/api/projects/999999', {
headers: API_KEY_HEADER,
data: { name: 'no-op' },
})
expect(res.status()).toBe(404)
})
test('PATCH rejects archiving default project', async ({ request }) => {
// Get the general project
const listRes = await request.get('/api/projects?includeArchived=1', { headers: API_KEY_HEADER })
const listBody = await listRes.json()
const general = listBody.projects.find((p: any) => p.slug === 'general')
expect(general).toBeDefined()
const res = await request.patch(`/api/projects/${general.id}`, {
headers: API_KEY_HEADER,
data: { status: 'archived' },
})
expect(res.status()).toBe(400)
})
// ── DELETE /api/projects/[id] ───────────────────
test('DELETE archives project by default', async ({ request }) => {
const { id } = await createTestProject(request)
const res = await request.delete(`/api/projects/${id}`, { headers: API_KEY_HEADER })
expect(res.status()).toBe(200)
const body = await res.json()
expect(body.mode).toBe('archive')
// Verify it's archived, not deleted
const getRes = await request.get(`/api/projects/${id}`, { headers: API_KEY_HEADER })
expect(getRes.status()).toBe(200)
const getBody = await getRes.json()
expect(getBody.project.status).toBe('archived')
})
test('DELETE with mode=delete permanently removes project', async ({ request }) => {
const { id } = await createTestProject(request)
const res = await request.delete(`/api/projects/${id}?mode=delete`, { headers: API_KEY_HEADER })
expect(res.status()).toBe(200)
const body = await res.json()
expect(body.mode).toBe('delete')
// Verify it's gone
const getRes = await request.get(`/api/projects/${id}`, { headers: API_KEY_HEADER })
expect(getRes.status()).toBe(404)
})
test('DELETE rejects deleting default project', async ({ request }) => {
const listRes = await request.get('/api/projects?includeArchived=1', { headers: API_KEY_HEADER })
const listBody = await listRes.json()
const general = listBody.projects.find((p: any) => p.slug === 'general')
expect(general).toBeDefined()
const res = await request.delete(`/api/projects/${general.id}?mode=delete`, { headers: API_KEY_HEADER })
expect(res.status()).toBe(400)
})
// ── Full lifecycle ───────────────────────────
test('full lifecycle: create → read → update → archive → reactivate → delete', async ({ request }) => {
// Create with enhanced fields
const { id, res: createRes } = await createTestProject(request, {
description: 'Lifecycle test project',
github_repo: 'lifecycle/test',
color: '#f59e0b',
})
expect(createRes.status()).toBe(201)
// Read single
const readRes = await request.get(`/api/projects/${id}`, { headers: API_KEY_HEADER })
expect(readRes.status()).toBe(200)
const readBody = await readRes.json()
expect(readBody.project.description).toBe('Lifecycle test project')
expect(readBody.project.github_repo).toBe('lifecycle/test')
expect(readBody.project.color).toBe('#f59e0b')
// Update
const updateRes = await request.patch(`/api/projects/${id}`, {
headers: API_KEY_HEADER,
data: { name: 'Updated Lifecycle', github_repo: 'updated/repo', deadline: 1893456000 },
})
expect(updateRes.status()).toBe(200)
const updateBody = await updateRes.json()
expect(updateBody.project.name).toBe('Updated Lifecycle')
expect(updateBody.project.github_repo).toBe('updated/repo')
expect(updateBody.project.deadline).toBe(1893456000)
// Archive
const archiveRes = await request.patch(`/api/projects/${id}`, {
headers: API_KEY_HEADER,
data: { status: 'archived' },
})
expect(archiveRes.status()).toBe(200)
expect((await archiveRes.json()).project.status).toBe('archived')
// Reactivate
const activateRes = await request.patch(`/api/projects/${id}`, {
headers: API_KEY_HEADER,
data: { status: 'active' },
})
expect(activateRes.status()).toBe(200)
expect((await activateRes.json()).project.status).toBe('active')
// Delete permanently
const deleteRes = await request.delete(`/api/projects/${id}?mode=delete`, { headers: API_KEY_HEADER })
expect(deleteRes.status()).toBe(200)
// Confirm gone
const goneRes = await request.get(`/api/projects/${id}`, { headers: API_KEY_HEADER })
expect(goneRes.status()).toBe(404)
})
// ── Task count ───────────────────────────
test('project task_count reflects actual tasks', async ({ request }) => {
const { id } = await createTestProject(request, { ticket_prefix: 'TCNT' })
cleanup.push(id)
// Create a task in this project
const taskRes = await request.post('/api/tasks', {
headers: API_KEY_HEADER,
data: { title: `task-count-test-${Date.now()}`, project_id: id },
})
expect(taskRes.status()).toBe(201)
const taskBody = await taskRes.json()
const taskId = taskBody.task.id
// Check project task_count
const getRes = await request.get(`/api/projects/${id}`, { headers: API_KEY_HEADER })
const getBody = await getRes.json()
expect(getBody.project.task_count).toBeGreaterThanOrEqual(1)
// Cleanup task
await request.delete(`/api/tasks/${taskId}`, { headers: API_KEY_HEADER })
})
})