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

291 lines
10 KiB
TypeScript

import { test, expect } from '@playwright/test'
import { API_KEY_HEADER, createTestTask, deleteTestTask } from './helpers'
test.describe('Tasks CRUD', () => {
const cleanup: number[] = []
test.afterEach(async ({ request }) => {
for (const id of cleanup) {
await deleteTestTask(request, id).catch(() => {})
}
cleanup.length = 0
})
// ── POST /api/tasks ──────────────────────────
test('POST creates task with minimal fields (title only)', async ({ request }) => {
const { id, res, body } = await createTestTask(request)
cleanup.push(id)
expect(res.status()).toBe(201)
expect(body.task).toBeDefined()
expect(body.task.title).toContain('e2e-task-')
expect(body.task.status).toBe('inbox')
expect(body.task.priority).toBe('medium')
})
test('POST creates task with all fields', async ({ request }) => {
const { id, res, body } = await createTestTask(request, {
description: 'Full task',
status: 'assigned',
priority: 'high',
assigned_to: 'agent-x',
tags: ['e2e', 'test'],
metadata: { source: 'e2e' },
})
cleanup.push(id)
expect(res.status()).toBe(201)
expect(body.task.description).toBe('Full task')
expect(body.task.status).toBe('assigned')
expect(body.task.priority).toBe('high')
expect(body.task.assigned_to).toBe('agent-x')
expect(body.task.tags).toEqual(['e2e', 'test'])
expect(body.task.metadata).toEqual({ source: 'e2e' })
})
test('POST persists implementation target metadata for deterministic repo routing', async ({ request }) => {
const { id, res, body } = await createTestTask(request, {
metadata: {
implementation_repo: 'builderz-labs/mission-control',
code_location: '/apps/api',
},
})
cleanup.push(id)
expect(res.status()).toBe(201)
expect(body.task.metadata.implementation_repo).toBe('builderz-labs/mission-control')
expect(body.task.metadata.code_location).toBe('/apps/api')
})
test('POST ignores client-supplied created_by and uses authenticated actor', async ({ request }) => {
const title = `e2e-task-actor-${Date.now()}`
const res = await request.post('/api/tasks', {
headers: API_KEY_HEADER,
data: {
title,
created_by: 'spoofed-agent',
},
})
expect(res.status()).toBe(201)
const body = await res.json()
const id = Number(body.task.id)
cleanup.push(id)
expect(body.task.created_by).not.toBe('spoofed-agent')
expect(body.task.created_by).toBe('API Access')
})
test('POST rejects empty title', async ({ request }) => {
const res = await request.post('/api/tasks', {
headers: API_KEY_HEADER,
data: { title: '' },
})
expect(res.status()).toBe(400)
})
test('POST allows duplicate title', async ({ request }) => {
const { id, body: first } = await createTestTask(request)
cleanup.push(id)
const res = await request.post('/api/tasks', {
headers: API_KEY_HEADER,
data: { title: first.task.title },
})
expect(res.status()).toBe(201)
const body = await res.json()
cleanup.push(body.task.id)
expect(body.task.title).toBe(first.task.title)
expect(body.task.id).not.toBe(first.task.id)
})
// ── GET /api/tasks ───────────────────────────
test('GET list returns tasks with pagination shape', async ({ request }) => {
const { id } = await createTestTask(request)
cleanup.push(id)
const res = await request.get('/api/tasks', { headers: API_KEY_HEADER })
expect(res.status()).toBe(200)
const body = await res.json()
expect(body).toHaveProperty('tasks')
expect(body).toHaveProperty('total')
expect(body).toHaveProperty('page')
expect(body).toHaveProperty('limit')
expect(Array.isArray(body.tasks)).toBe(true)
})
test('GET list filters by status', async ({ request }) => {
const { id } = await createTestTask(request, { status: 'review' })
cleanup.push(id)
const res = await request.get('/api/tasks?status=review', { headers: API_KEY_HEADER })
const body = await res.json()
for (const t of body.tasks) {
expect(t.status).toBe('review')
}
})
test('GET list filters by priority', async ({ request }) => {
const { id } = await createTestTask(request, { priority: 'critical' })
cleanup.push(id)
const res = await request.get('/api/tasks?priority=critical', { headers: API_KEY_HEADER })
const body = await res.json()
for (const t of body.tasks) {
expect(t.priority).toBe('critical')
}
})
test('GET list respects limit and offset', async ({ request }) => {
const res = await request.get('/api/tasks?limit=2&offset=0', { headers: API_KEY_HEADER })
const body = await res.json()
expect(body.tasks.length).toBeLessThanOrEqual(2)
expect(body.limit).toBe(2)
})
// ── GET /api/tasks/[id] ──────────────────────
test('GET single returns task by id', async ({ request }) => {
const { id } = await createTestTask(request)
cleanup.push(id)
const res = await request.get(`/api/tasks/${id}`, { headers: API_KEY_HEADER })
expect(res.status()).toBe(200)
const body = await res.json()
expect(body.task).toBeDefined()
expect(body.task.id).toBe(id)
})
test('GET single returns 404 for missing task', async ({ request }) => {
const res = await request.get('/api/tasks/999999', { headers: API_KEY_HEADER })
expect(res.status()).toBe(404)
})
test('GET single returns 400 for non-numeric id', async ({ request }) => {
const res = await request.get('/api/tasks/abc', { headers: API_KEY_HEADER })
expect(res.status()).toBe(400)
})
// ── PUT /api/tasks/[id] ──────────────────────
test('PUT updates task fields', async ({ request }) => {
const { id } = await createTestTask(request)
cleanup.push(id)
const res = await request.put(`/api/tasks/${id}`, {
headers: API_KEY_HEADER,
data: { title: 'Updated title', priority: 'high' },
})
expect(res.status()).toBe(200)
const body = await res.json()
expect(body.task.title).toBe('Updated title')
expect(body.task.priority).toBe('high')
})
test('PUT updates implementation target metadata and GET returns persisted values', async ({ request }) => {
const { id } = await createTestTask(request, {
metadata: { implementation_repo: 'builderz-labs/mission-control', code_location: '/apps/api' },
})
cleanup.push(id)
const updateRes = await request.put(`/api/tasks/${id}`, {
headers: API_KEY_HEADER,
data: {
metadata: {
implementation_repo: 'torreypjones/mission-control',
code_location: '/src/app/api/tasks',
},
},
})
expect(updateRes.status()).toBe(200)
const readRes = await request.get(`/api/tasks/${id}`, { headers: API_KEY_HEADER })
expect(readRes.status()).toBe(200)
const readBody = await readRes.json()
expect(readBody.task.metadata.implementation_repo).toBe('torreypjones/mission-control')
expect(readBody.task.metadata.code_location).toBe('/src/app/api/tasks')
})
test('PUT returns 404 for missing task', async ({ request }) => {
const res = await request.put('/api/tasks/999999', {
headers: API_KEY_HEADER,
data: { title: 'no-op' },
})
expect(res.status()).toBe(404)
})
test('PUT with empty body still succeeds (Zod defaults fill fields)', async ({ request }) => {
const { id } = await createTestTask(request)
cleanup.push(id)
const res = await request.put(`/api/tasks/${id}`, {
headers: API_KEY_HEADER,
data: {},
})
// Zod's partial schema fills defaults (status, priority, tags, metadata),
// so there are always fields to update — API returns 200, not 400
expect(res.status()).toBe(200)
})
test('PUT returns 403 when moving to done without Aegis approval', async ({ request }) => {
const { id } = await createTestTask(request)
cleanup.push(id)
const res = await request.put(`/api/tasks/${id}`, {
headers: API_KEY_HEADER,
data: { status: 'done' },
})
expect(res.status()).toBe(403)
const body = await res.json()
expect(body.error).toContain('Aegis')
})
// ── DELETE /api/tasks/[id] ───────────────────
test('DELETE removes task', async ({ request }) => {
const { id } = await createTestTask(request)
const res = await request.delete(`/api/tasks/${id}`, { headers: API_KEY_HEADER })
expect(res.status()).toBe(200)
const body = await res.json()
expect(body.success).toBe(true)
})
test('DELETE returns 404 for missing task', async ({ request }) => {
const res = await request.delete('/api/tasks/999999', { headers: API_KEY_HEADER })
expect(res.status()).toBe(404)
})
// ── Full lifecycle ───────────────────────────
test('full lifecycle: create → read → update → delete → confirm gone', async ({ request }) => {
// Create
const { id, res: createRes } = await createTestTask(request, { description: 'lifecycle test' })
expect(createRes.status()).toBe(201)
// Read
const readRes = await request.get(`/api/tasks/${id}`, { headers: API_KEY_HEADER })
expect(readRes.status()).toBe(200)
const readBody = await readRes.json()
expect(readBody.task.description).toBe('lifecycle test')
// Update
const updateRes = await request.put(`/api/tasks/${id}`, {
headers: API_KEY_HEADER,
data: { status: 'in_progress', priority: 'critical' },
})
expect(updateRes.status()).toBe(200)
const updateBody = await updateRes.json()
expect(updateBody.task.status).toBe('in_progress')
expect(updateBody.task.priority).toBe('critical')
// Delete
const deleteRes = await request.delete(`/api/tasks/${id}`, { headers: API_KEY_HEADER })
expect(deleteRes.status()).toBe(200)
// Confirm gone
const goneRes = await request.get(`/api/tasks/${id}`, { headers: API_KEY_HEADER })
expect(goneRes.status()).toBe(404)
})
})