Spaces:
Runtime error
Runtime error
| 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 }) | |
| }) | |
| }) | |