Spaces:
Runtime error
test: add 94 E2E tests covering all CRUD routes + fix middleware location
Browse filesAdd comprehensive Playwright E2E test coverage for all major API routes:
- tasks-crud (18 tests): full lifecycle, filters, Aegis approval gate
- agents-crud (15 tests): CRUD, lookup by name/id, admin-only delete
- task-comments (7 tests): threaded comments, validation
- workflows-crud (8 tests): workflow template lifecycle
- webhooks-crud (9 tests): secret masking, regeneration
- alerts-crud (8 tests): alert rule lifecycle
- notifications (7 tests): delivery tracking, read status
- quality-review (6 tests): reviews with batch lookup
- search-and-export (7 tests): global search, export, activities
- user-management (8 tests): user admin CRUD
- helpers.ts: shared factory functions and cleanup utilities
Infrastructure fixes:
- Move middleware.ts to src/middleware.ts (Next.js 16 Turbopack
requires middleware in src/ when using src/app/ directory β the
root-level file was silently ignored, breaking CSRF protection)
- Add MC_DISABLE_RATE_LIMIT env var to bypass non-critical rate
limiters during E2E runs (login limiter stays active via critical flag)
- Fix limit-caps test: /api/activities caps at 500, not 200
- Set playwright workers=1, fullyParallel=false for serial execution
- Add CSRF origin fallback to request.nextUrl.host
Roadmap additions from user feedback:
- Agent-agnostic gateway support (not just OpenClaw)
- Direct CLI integration (Codex, Claude Code, etc.)
- Native macOS app (Electron or Tauri)
146/146 E2E tests passing (up from 51).
- README.md +6 -4
- package.json +7 -2
- playwright.config.ts +2 -1
- src/lib/rate-limit.ts +5 -0
- middleware.ts β src/middleware.ts +4 -2
- tests/README.md +50 -3
- tests/agents-crud.spec.ts +197 -0
- tests/alerts-crud.spec.ts +126 -0
- tests/helpers.ts +140 -0
- tests/limit-caps.spec.ts +12 -12
- tests/notifications.spec.ts +107 -0
- tests/quality-review.spec.ts +111 -0
- tests/search-and-export.spec.ts +77 -0
- tests/task-comments.spec.ts +108 -0
- tests/tasks-crud.spec.ts +231 -0
- tests/user-management.spec.ts +111 -0
- tests/webhooks-crud.spec.ts +142 -0
- tests/workflows-crud.spec.ts +120 -0
|
@@ -29,7 +29,7 @@ Running AI agents at scale means juggling sessions, tasks, costs, and reliabilit
|
|
| 29 |
- **Zero external dependencies** β SQLite database, single `pnpm start` to run, no Redis/Postgres/Docker required
|
| 30 |
- **Role-based access** β Viewer, operator, and admin roles with session + API key auth
|
| 31 |
- **Quality gates** β Built-in review system that blocks task completion without sign-off
|
| 32 |
-
- **Multi-gateway** β Connect to multiple
|
| 33 |
|
| 34 |
## Quick Start
|
| 35 |
|
|
@@ -94,8 +94,8 @@ Outbound webhooks with delivery history, configurable alert rules with cooldowns
|
|
| 94 |
|
| 95 |
```
|
| 96 |
mission-control/
|
| 97 |
-
βββ middleware.ts # Auth gate + network access control
|
| 98 |
βββ src/
|
|
|
|
| 99 |
β βββ app/
|
| 100 |
β β βββ page.tsx # SPA shell β routes all panels
|
| 101 |
β β βββ login/page.tsx # Login page
|
|
@@ -128,7 +128,7 @@ mission-control/
|
|
| 128 |
| Charts | Recharts 3 |
|
| 129 |
| Real-time | WebSocket + Server-Sent Events |
|
| 130 |
| Auth | scrypt hashing, session tokens, RBAC |
|
| 131 |
-
| Testing | Vitest + Playwright (
|
| 132 |
|
| 133 |
## Authentication
|
| 134 |
|
|
@@ -331,7 +331,9 @@ See [open issues](https://github.com/builderz-labs/mission-control/issues) for p
|
|
| 331 |
|
| 332 |
**Up next:**
|
| 333 |
|
| 334 |
-
- [ ]
|
|
|
|
|
|
|
| 335 |
- [ ] OpenAPI / Swagger documentation
|
| 336 |
- [ ] Webhook retry with exponential backoff
|
| 337 |
- [ ] OAuth approval UI improvements
|
|
|
|
| 29 |
- **Zero external dependencies** β SQLite database, single `pnpm start` to run, no Redis/Postgres/Docker required
|
| 30 |
- **Role-based access** β Viewer, operator, and admin roles with session + API key auth
|
| 31 |
- **Quality gates** β Built-in review system that blocks task completion without sign-off
|
| 32 |
+
- **Multi-gateway** β Connect to multiple agent gateways simultaneously (OpenClaw, and more coming soon)
|
| 33 |
|
| 34 |
## Quick Start
|
| 35 |
|
|
|
|
| 94 |
|
| 95 |
```
|
| 96 |
mission-control/
|
|
|
|
| 97 |
βββ src/
|
| 98 |
+
β βββ middleware.ts # Auth gate + CSRF + network access control
|
| 99 |
β βββ app/
|
| 100 |
β β βββ page.tsx # SPA shell β routes all panels
|
| 101 |
β β βββ login/page.tsx # Login page
|
|
|
|
| 128 |
| Charts | Recharts 3 |
|
| 129 |
| Real-time | WebSocket + Server-Sent Events |
|
| 130 |
| Auth | scrypt hashing, session tokens, RBAC |
|
| 131 |
+
| Testing | Vitest + Playwright (146 E2E tests) |
|
| 132 |
|
| 133 |
## Authentication
|
| 134 |
|
|
|
|
| 331 |
|
| 332 |
**Up next:**
|
| 333 |
|
| 334 |
+
- [ ] Agent-agnostic gateway support β connect any orchestration framework (OpenClaw, ZeroClaw, OpenFang, NeoBot, IronClaw, etc.), not just OpenClaw
|
| 335 |
+
- [ ] Direct CLI integration β connect tools like Codex, Claude Code, or custom CLIs directly without requiring a gateway
|
| 336 |
+
- [ ] Native macOS app (Electron or Tauri)
|
| 337 |
- [ ] OpenAPI / Swagger documentation
|
| 338 |
- [ ] Webhook retry with exponential backoff
|
| 339 |
- [ ] OAuth approval UI improvements
|
|
@@ -1,7 +1,7 @@
|
|
| 1 |
{
|
| 2 |
"name": "mission-control",
|
| 3 |
"version": "1.2.0",
|
| 4 |
-
"description": "OpenClaw Mission Control
|
| 5 |
"scripts": {
|
| 6 |
"dev": "next dev --hostname 127.0.0.1",
|
| 7 |
"build": "next build",
|
|
@@ -68,5 +68,10 @@
|
|
| 68 |
"repository": {
|
| 69 |
"type": "git",
|
| 70 |
"url": "https://github.com/builderz-labs/mission-control.git"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
}
|
| 72 |
-
}
|
|
|
|
| 1 |
{
|
| 2 |
"name": "mission-control",
|
| 3 |
"version": "1.2.0",
|
| 4 |
+
"description": "OpenClaw Mission Control \u2014 open-source agent orchestration dashboard",
|
| 5 |
"scripts": {
|
| 6 |
"dev": "next dev --hostname 127.0.0.1",
|
| 7 |
"build": "next build",
|
|
|
|
| 68 |
"repository": {
|
| 69 |
"type": "git",
|
| 70 |
"url": "https://github.com/builderz-labs/mission-control.git"
|
| 71 |
+
},
|
| 72 |
+
"pnpm": {
|
| 73 |
+
"onlyBuiltDependencies": [
|
| 74 |
+
"better-sqlite3"
|
| 75 |
+
]
|
| 76 |
}
|
| 77 |
+
}
|
|
@@ -6,7 +6,8 @@ export default defineConfig({
|
|
| 6 |
expect: {
|
| 7 |
timeout: 10_000
|
| 8 |
},
|
| 9 |
-
fullyParallel:
|
|
|
|
| 10 |
reporter: [['list']],
|
| 11 |
use: {
|
| 12 |
baseURL: process.env.E2E_BASE_URL || 'http://127.0.0.1:3005',
|
|
|
|
| 6 |
expect: {
|
| 7 |
timeout: 10_000
|
| 8 |
},
|
| 9 |
+
fullyParallel: false,
|
| 10 |
+
workers: 1,
|
| 11 |
reporter: [['list']],
|
| 12 |
use: {
|
| 13 |
baseURL: process.env.E2E_BASE_URL || 'http://127.0.0.1:3005',
|
|
@@ -9,6 +9,8 @@ interface RateLimiterOptions {
|
|
| 9 |
windowMs: number
|
| 10 |
maxRequests: number
|
| 11 |
message?: string
|
|
|
|
|
|
|
| 12 |
}
|
| 13 |
|
| 14 |
export function createRateLimiter(options: RateLimiterOptions) {
|
|
@@ -25,6 +27,8 @@ export function createRateLimiter(options: RateLimiterOptions) {
|
|
| 25 |
if (cleanupInterval.unref) cleanupInterval.unref()
|
| 26 |
|
| 27 |
return function checkRateLimit(request: Request): NextResponse | null {
|
|
|
|
|
|
|
| 28 |
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
|
| 29 |
const now = Date.now()
|
| 30 |
const entry = store.get(ip)
|
|
@@ -50,6 +54,7 @@ export const loginLimiter = createRateLimiter({
|
|
| 50 |
windowMs: 60_000,
|
| 51 |
maxRequests: 5,
|
| 52 |
message: 'Too many login attempts. Try again in a minute.',
|
|
|
|
| 53 |
})
|
| 54 |
|
| 55 |
export const mutationLimiter = createRateLimiter({
|
|
|
|
| 9 |
windowMs: number
|
| 10 |
maxRequests: number
|
| 11 |
message?: string
|
| 12 |
+
/** If true, MC_DISABLE_RATE_LIMIT will not bypass this limiter */
|
| 13 |
+
critical?: boolean
|
| 14 |
}
|
| 15 |
|
| 16 |
export function createRateLimiter(options: RateLimiterOptions) {
|
|
|
|
| 27 |
if (cleanupInterval.unref) cleanupInterval.unref()
|
| 28 |
|
| 29 |
return function checkRateLimit(request: Request): NextResponse | null {
|
| 30 |
+
// Allow disabling non-critical rate limiting for E2E tests
|
| 31 |
+
if (process.env.MC_DISABLE_RATE_LIMIT === '1' && !options.critical) return null
|
| 32 |
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
|
| 33 |
const now = Date.now()
|
| 34 |
const entry = store.get(ip)
|
|
|
|
| 54 |
windowMs: 60_000,
|
| 55 |
maxRequests: 5,
|
| 56 |
message: 'Too many login attempts. Try again in a minute.',
|
| 57 |
+
critical: true,
|
| 58 |
})
|
| 59 |
|
| 60 |
export const mutationLimiter = createRateLimiter({
|
|
@@ -82,8 +82,10 @@ export function middleware(request: NextRequest) {
|
|
| 82 |
if (origin) {
|
| 83 |
let originHost: string
|
| 84 |
try { originHost = new URL(origin).host } catch { originHost = '' }
|
| 85 |
-
const requestHost = request.headers.get('host')
|
| 86 |
-
|
|
|
|
|
|
|
| 87 |
return NextResponse.json({ error: 'CSRF origin mismatch' }, { status: 403 })
|
| 88 |
}
|
| 89 |
}
|
|
|
|
| 82 |
if (origin) {
|
| 83 |
let originHost: string
|
| 84 |
try { originHost = new URL(origin).host } catch { originHost = '' }
|
| 85 |
+
const requestHost = request.headers.get('host')?.split(',')[0]?.trim()
|
| 86 |
+
|| request.nextUrl.host
|
| 87 |
+
|| ''
|
| 88 |
+
if (originHost && requestHost && originHost !== requestHost) {
|
| 89 |
return NextResponse.json({ error: 'CSRF origin mismatch' }, { status: 403 })
|
| 90 |
}
|
| 91 |
}
|
|
@@ -1,6 +1,53 @@
|
|
| 1 |
# E2E Tests
|
| 2 |
|
| 3 |
-
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# E2E Tests
|
| 2 |
|
| 3 |
+
Playwright end-to-end specs for Mission Control API and UI.
|
| 4 |
|
| 5 |
+
## Running
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
# Start the dev server first (or let Playwright auto-start via reuseExistingServer)
|
| 9 |
+
pnpm dev --hostname 127.0.0.1 --port 3005
|
| 10 |
+
|
| 11 |
+
# Run all tests
|
| 12 |
+
pnpm test:e2e
|
| 13 |
+
|
| 14 |
+
# Run a specific spec
|
| 15 |
+
pnpm exec playwright test tests/tasks-crud.spec.ts
|
| 16 |
+
```
|
| 17 |
+
|
| 18 |
+
## Test Environment
|
| 19 |
+
|
| 20 |
+
Tests require `.env.local` with:
|
| 21 |
+
- `API_KEY=test-api-key-e2e-12345`
|
| 22 |
+
- `MC_DISABLE_RATE_LIMIT=1` (bypasses mutation/read rate limits, keeps login rate limit active)
|
| 23 |
+
|
| 24 |
+
## Spec Files
|
| 25 |
+
|
| 26 |
+
### Security & Auth
|
| 27 |
+
- `auth-guards.spec.ts` β All API routes return 401 without auth
|
| 28 |
+
- `csrf-validation.spec.ts` β CSRF origin header validation
|
| 29 |
+
- `legacy-cookie-removed.spec.ts` β Old cookie format rejected
|
| 30 |
+
- `login-flow.spec.ts` β Login, session, redirect lifecycle
|
| 31 |
+
- `rate-limiting.spec.ts` β Login brute-force protection
|
| 32 |
+
- `timing-safe-auth.spec.ts` β Constant-time API key comparison
|
| 33 |
+
|
| 34 |
+
### CRUD Lifecycle
|
| 35 |
+
- `tasks-crud.spec.ts` β Tasks POST/GET/PUT/DELETE with filters, Aegis gate
|
| 36 |
+
- `agents-crud.spec.ts` β Agents CRUD, lookup by name/id, admin-only delete
|
| 37 |
+
- `task-comments.spec.ts` β Threaded comments on tasks
|
| 38 |
+
- `workflows-crud.spec.ts` β Workflow template CRUD
|
| 39 |
+
- `webhooks-crud.spec.ts` β Webhooks with secret masking and regeneration
|
| 40 |
+
- `alerts-crud.spec.ts` β Alert rule CRUD with full lifecycle
|
| 41 |
+
- `user-management.spec.ts` β User admin CRUD
|
| 42 |
+
|
| 43 |
+
### Features
|
| 44 |
+
- `notifications.spec.ts` β Notification delivery and read tracking
|
| 45 |
+
- `quality-review.spec.ts` β Quality reviews with batch lookup
|
| 46 |
+
- `search-and-export.spec.ts` β Global search, data export, activity feed
|
| 47 |
+
|
| 48 |
+
### Infrastructure
|
| 49 |
+
- `limit-caps.spec.ts` β Endpoint limit caps enforced
|
| 50 |
+
- `delete-body.spec.ts` β DELETE body standardization
|
| 51 |
+
|
| 52 |
+
### Shared
|
| 53 |
+
- `helpers.ts` β Factory functions (`createTestTask`, `createTestAgent`, etc.) and cleanup helpers
|
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { test, expect } from '@playwright/test'
|
| 2 |
+
import { API_KEY_HEADER, createTestAgent, deleteTestAgent } from './helpers'
|
| 3 |
+
|
| 4 |
+
test.describe('Agents CRUD', () => {
|
| 5 |
+
const cleanup: number[] = []
|
| 6 |
+
|
| 7 |
+
test.afterEach(async ({ request }) => {
|
| 8 |
+
for (const id of cleanup) {
|
| 9 |
+
await deleteTestAgent(request, id).catch(() => {})
|
| 10 |
+
}
|
| 11 |
+
cleanup.length = 0
|
| 12 |
+
})
|
| 13 |
+
|
| 14 |
+
// ββ POST /api/agents βββββββββββββββββββββββββ
|
| 15 |
+
|
| 16 |
+
test('POST creates agent with name and role', async ({ request }) => {
|
| 17 |
+
const { id, res, body } = await createTestAgent(request)
|
| 18 |
+
cleanup.push(id)
|
| 19 |
+
|
| 20 |
+
expect(res.status()).toBe(201)
|
| 21 |
+
expect(body.agent).toBeDefined()
|
| 22 |
+
expect(body.agent.name).toContain('e2e-agent-')
|
| 23 |
+
expect(body.agent.role).toBe('tester')
|
| 24 |
+
expect(body.agent.status).toBe('offline')
|
| 25 |
+
})
|
| 26 |
+
|
| 27 |
+
test('POST rejects missing name', async ({ request }) => {
|
| 28 |
+
const res = await request.post('/api/agents', {
|
| 29 |
+
headers: API_KEY_HEADER,
|
| 30 |
+
data: { role: 'tester' },
|
| 31 |
+
})
|
| 32 |
+
expect(res.status()).toBe(400)
|
| 33 |
+
})
|
| 34 |
+
|
| 35 |
+
test('POST rejects duplicate name', async ({ request }) => {
|
| 36 |
+
const { id, body: first } = await createTestAgent(request)
|
| 37 |
+
cleanup.push(id)
|
| 38 |
+
|
| 39 |
+
const res = await request.post('/api/agents', {
|
| 40 |
+
headers: API_KEY_HEADER,
|
| 41 |
+
data: { name: first.agent.name, role: 'duplicate' },
|
| 42 |
+
})
|
| 43 |
+
expect(res.status()).toBe(409)
|
| 44 |
+
})
|
| 45 |
+
|
| 46 |
+
// ββ GET /api/agents ββββββββββββββββββββββββββ
|
| 47 |
+
|
| 48 |
+
test('GET list returns agents with pagination and taskStats', async ({ request }) => {
|
| 49 |
+
const { id } = await createTestAgent(request)
|
| 50 |
+
cleanup.push(id)
|
| 51 |
+
|
| 52 |
+
const res = await request.get('/api/agents', { headers: API_KEY_HEADER })
|
| 53 |
+
expect(res.status()).toBe(200)
|
| 54 |
+
const body = await res.json()
|
| 55 |
+
expect(body).toHaveProperty('agents')
|
| 56 |
+
expect(body).toHaveProperty('total')
|
| 57 |
+
expect(body).toHaveProperty('page')
|
| 58 |
+
expect(body).toHaveProperty('limit')
|
| 59 |
+
expect(Array.isArray(body.agents)).toBe(true)
|
| 60 |
+
|
| 61 |
+
// Every agent should have taskStats
|
| 62 |
+
for (const a of body.agents) {
|
| 63 |
+
expect(a.taskStats).toBeDefined()
|
| 64 |
+
expect(a.taskStats).toHaveProperty('total')
|
| 65 |
+
}
|
| 66 |
+
})
|
| 67 |
+
|
| 68 |
+
// ββ GET /api/agents/[id] βββββββββββββββββββββ
|
| 69 |
+
|
| 70 |
+
test('GET single by numeric id', async ({ request }) => {
|
| 71 |
+
const { id } = await createTestAgent(request)
|
| 72 |
+
cleanup.push(id)
|
| 73 |
+
|
| 74 |
+
const res = await request.get(`/api/agents/${id}`, { headers: API_KEY_HEADER })
|
| 75 |
+
expect(res.status()).toBe(200)
|
| 76 |
+
const body = await res.json()
|
| 77 |
+
expect(body.agent.id).toBe(id)
|
| 78 |
+
})
|
| 79 |
+
|
| 80 |
+
test('GET single by name', async ({ request }) => {
|
| 81 |
+
const { id, name } = await createTestAgent(request)
|
| 82 |
+
cleanup.push(id)
|
| 83 |
+
|
| 84 |
+
const res = await request.get(`/api/agents/${name}`, { headers: API_KEY_HEADER })
|
| 85 |
+
expect(res.status()).toBe(200)
|
| 86 |
+
const body = await res.json()
|
| 87 |
+
expect(body.agent.name).toBe(name)
|
| 88 |
+
})
|
| 89 |
+
|
| 90 |
+
test('GET single returns 404 for missing', async ({ request }) => {
|
| 91 |
+
const res = await request.get('/api/agents/999999', { headers: API_KEY_HEADER })
|
| 92 |
+
expect(res.status()).toBe(404)
|
| 93 |
+
})
|
| 94 |
+
|
| 95 |
+
// ββ PUT /api/agents/[id] βββββββββββββββββββββ
|
| 96 |
+
|
| 97 |
+
test('PUT by id updates role', async ({ request }) => {
|
| 98 |
+
const { id } = await createTestAgent(request)
|
| 99 |
+
cleanup.push(id)
|
| 100 |
+
|
| 101 |
+
const res = await request.put(`/api/agents/${id}`, {
|
| 102 |
+
headers: API_KEY_HEADER,
|
| 103 |
+
data: { role: 'reviewer' },
|
| 104 |
+
})
|
| 105 |
+
expect(res.status()).toBe(200)
|
| 106 |
+
const body = await res.json()
|
| 107 |
+
expect(body.agent.role).toBe('reviewer')
|
| 108 |
+
})
|
| 109 |
+
|
| 110 |
+
test('PUT by id returns 404 for missing', async ({ request }) => {
|
| 111 |
+
const res = await request.put('/api/agents/999999', {
|
| 112 |
+
headers: API_KEY_HEADER,
|
| 113 |
+
data: { role: 'reviewer' },
|
| 114 |
+
})
|
| 115 |
+
expect(res.status()).toBe(404)
|
| 116 |
+
})
|
| 117 |
+
|
| 118 |
+
// ββ PUT /api/agents (bulk by name) βββββββββββ
|
| 119 |
+
|
| 120 |
+
test('PUT by name updates status', async ({ request }) => {
|
| 121 |
+
const { id, name } = await createTestAgent(request)
|
| 122 |
+
cleanup.push(id)
|
| 123 |
+
|
| 124 |
+
const res = await request.put('/api/agents', {
|
| 125 |
+
headers: API_KEY_HEADER,
|
| 126 |
+
data: { name, status: 'online' },
|
| 127 |
+
})
|
| 128 |
+
expect(res.status()).toBe(200)
|
| 129 |
+
const body = await res.json()
|
| 130 |
+
expect(body.success).toBe(true)
|
| 131 |
+
})
|
| 132 |
+
|
| 133 |
+
test('PUT by name returns 404 for missing', async ({ request }) => {
|
| 134 |
+
const res = await request.put('/api/agents', {
|
| 135 |
+
headers: API_KEY_HEADER,
|
| 136 |
+
data: { name: 'nonexistent-agent-xyz', status: 'online' },
|
| 137 |
+
})
|
| 138 |
+
expect(res.status()).toBe(404)
|
| 139 |
+
})
|
| 140 |
+
|
| 141 |
+
test('PUT by name returns 400 when no fields provided', async ({ request }) => {
|
| 142 |
+
const { id, name } = await createTestAgent(request)
|
| 143 |
+
cleanup.push(id)
|
| 144 |
+
|
| 145 |
+
const res = await request.put('/api/agents', {
|
| 146 |
+
headers: API_KEY_HEADER,
|
| 147 |
+
data: { name },
|
| 148 |
+
})
|
| 149 |
+
expect(res.status()).toBe(400)
|
| 150 |
+
})
|
| 151 |
+
|
| 152 |
+
// ββ DELETE /api/agents/[id] ββββββββββββββββββ
|
| 153 |
+
|
| 154 |
+
test('DELETE removes agent (admin via API key)', async ({ request }) => {
|
| 155 |
+
const { id, name } = await createTestAgent(request)
|
| 156 |
+
|
| 157 |
+
const res = await request.delete(`/api/agents/${id}`, { headers: API_KEY_HEADER })
|
| 158 |
+
expect(res.status()).toBe(200)
|
| 159 |
+
const body = await res.json()
|
| 160 |
+
expect(body.success).toBe(true)
|
| 161 |
+
expect(body.deleted).toBe(name)
|
| 162 |
+
})
|
| 163 |
+
|
| 164 |
+
test('DELETE returns 404 for missing agent', async ({ request }) => {
|
| 165 |
+
const res = await request.delete('/api/agents/999999', { headers: API_KEY_HEADER })
|
| 166 |
+
expect(res.status()).toBe(404)
|
| 167 |
+
})
|
| 168 |
+
|
| 169 |
+
// ββ Full lifecycle βββββββββββββββββββββββββββ
|
| 170 |
+
|
| 171 |
+
test('full lifecycle: create β read β update β delete', async ({ request }) => {
|
| 172 |
+
// Create
|
| 173 |
+
const { id, name, res: createRes } = await createTestAgent(request, { role: 'builder' })
|
| 174 |
+
expect(createRes.status()).toBe(201)
|
| 175 |
+
|
| 176 |
+
// Read
|
| 177 |
+
const readRes = await request.get(`/api/agents/${id}`, { headers: API_KEY_HEADER })
|
| 178 |
+
expect(readRes.status()).toBe(200)
|
| 179 |
+
const readBody = await readRes.json()
|
| 180 |
+
expect(readBody.agent.role).toBe('builder')
|
| 181 |
+
|
| 182 |
+
// Update via [id]
|
| 183 |
+
const updateRes = await request.put(`/api/agents/${id}`, {
|
| 184 |
+
headers: API_KEY_HEADER,
|
| 185 |
+
data: { role: 'architect' },
|
| 186 |
+
})
|
| 187 |
+
expect(updateRes.status()).toBe(200)
|
| 188 |
+
|
| 189 |
+
// Delete
|
| 190 |
+
const deleteRes = await request.delete(`/api/agents/${id}`, { headers: API_KEY_HEADER })
|
| 191 |
+
expect(deleteRes.status()).toBe(200)
|
| 192 |
+
|
| 193 |
+
// Confirm gone
|
| 194 |
+
const goneRes = await request.get(`/api/agents/${name}`, { headers: API_KEY_HEADER })
|
| 195 |
+
expect(goneRes.status()).toBe(404)
|
| 196 |
+
})
|
| 197 |
+
})
|
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { test, expect } from '@playwright/test'
|
| 2 |
+
import { API_KEY_HEADER, createTestAlert, deleteTestAlert } from './helpers'
|
| 3 |
+
|
| 4 |
+
test.describe('Alerts CRUD', () => {
|
| 5 |
+
const cleanup: number[] = []
|
| 6 |
+
|
| 7 |
+
test.afterEach(async ({ request }) => {
|
| 8 |
+
for (const id of cleanup) {
|
| 9 |
+
await deleteTestAlert(request, id).catch(() => {})
|
| 10 |
+
}
|
| 11 |
+
cleanup.length = 0
|
| 12 |
+
})
|
| 13 |
+
|
| 14 |
+
// ββ POST /api/alerts βββββββββββββββββββββββββ
|
| 15 |
+
|
| 16 |
+
test('POST creates alert rule with all required fields', async ({ request }) => {
|
| 17 |
+
const { id, res, body } = await createTestAlert(request)
|
| 18 |
+
cleanup.push(id)
|
| 19 |
+
|
| 20 |
+
expect(res.status()).toBe(201)
|
| 21 |
+
expect(body.rule).toBeDefined()
|
| 22 |
+
expect(body.rule.name).toContain('e2e-alert-')
|
| 23 |
+
expect(body.rule.entity_type).toBe('task')
|
| 24 |
+
expect(body.rule.condition_operator).toBe('equals')
|
| 25 |
+
})
|
| 26 |
+
|
| 27 |
+
test('POST rejects missing required fields', async ({ request }) => {
|
| 28 |
+
const res = await request.post('/api/alerts', {
|
| 29 |
+
headers: API_KEY_HEADER,
|
| 30 |
+
data: { name: 'incomplete-alert' },
|
| 31 |
+
})
|
| 32 |
+
expect(res.status()).toBe(400)
|
| 33 |
+
})
|
| 34 |
+
|
| 35 |
+
test('POST rejects invalid entity_type', async ({ request }) => {
|
| 36 |
+
const res = await request.post('/api/alerts', {
|
| 37 |
+
headers: API_KEY_HEADER,
|
| 38 |
+
data: {
|
| 39 |
+
name: 'bad-entity',
|
| 40 |
+
entity_type: 'invalid',
|
| 41 |
+
condition_field: 'status',
|
| 42 |
+
condition_operator: 'equals',
|
| 43 |
+
condition_value: 'test',
|
| 44 |
+
},
|
| 45 |
+
})
|
| 46 |
+
expect(res.status()).toBe(400)
|
| 47 |
+
})
|
| 48 |
+
|
| 49 |
+
// ββ GET /api/alerts ββββββββββββββββββββββββββ
|
| 50 |
+
|
| 51 |
+
test('GET returns rules array', async ({ request }) => {
|
| 52 |
+
const { id } = await createTestAlert(request)
|
| 53 |
+
cleanup.push(id)
|
| 54 |
+
|
| 55 |
+
const res = await request.get('/api/alerts', { headers: API_KEY_HEADER })
|
| 56 |
+
expect(res.status()).toBe(200)
|
| 57 |
+
const body = await res.json()
|
| 58 |
+
expect(body.rules).toBeDefined()
|
| 59 |
+
expect(Array.isArray(body.rules)).toBe(true)
|
| 60 |
+
})
|
| 61 |
+
|
| 62 |
+
// ββ PUT /api/alerts ββββββββββββββββββββββββββ
|
| 63 |
+
|
| 64 |
+
test('PUT updates alert rule fields', async ({ request }) => {
|
| 65 |
+
const { id } = await createTestAlert(request)
|
| 66 |
+
cleanup.push(id)
|
| 67 |
+
|
| 68 |
+
const res = await request.put('/api/alerts', {
|
| 69 |
+
headers: API_KEY_HEADER,
|
| 70 |
+
data: { id, name: 'updated-alert', description: 'Updated desc' },
|
| 71 |
+
})
|
| 72 |
+
expect(res.status()).toBe(200)
|
| 73 |
+
const body = await res.json()
|
| 74 |
+
expect(body.rule.name).toBe('updated-alert')
|
| 75 |
+
expect(body.rule.description).toBe('Updated desc')
|
| 76 |
+
})
|
| 77 |
+
|
| 78 |
+
test('PUT returns 404 for missing rule', async ({ request }) => {
|
| 79 |
+
const res = await request.put('/api/alerts', {
|
| 80 |
+
headers: API_KEY_HEADER,
|
| 81 |
+
data: { id: 999999, name: 'nope' },
|
| 82 |
+
})
|
| 83 |
+
expect(res.status()).toBe(404)
|
| 84 |
+
})
|
| 85 |
+
|
| 86 |
+
// ββ DELETE /api/alerts βββββββββββββββββββββββ
|
| 87 |
+
|
| 88 |
+
test('DELETE removes alert rule', async ({ request }) => {
|
| 89 |
+
const { id } = await createTestAlert(request)
|
| 90 |
+
|
| 91 |
+
const res = await request.delete('/api/alerts', {
|
| 92 |
+
headers: API_KEY_HEADER,
|
| 93 |
+
data: { id },
|
| 94 |
+
})
|
| 95 |
+
expect(res.status()).toBe(200)
|
| 96 |
+
const body = await res.json()
|
| 97 |
+
expect(body.deleted).toBe(true)
|
| 98 |
+
})
|
| 99 |
+
|
| 100 |
+
// ββ Full lifecycle βββββββββββββββββββββββββββ
|
| 101 |
+
|
| 102 |
+
test('full lifecycle: create β list β update β delete', async ({ request }) => {
|
| 103 |
+
// Create
|
| 104 |
+
const { id, res: createRes } = await createTestAlert(request)
|
| 105 |
+
expect(createRes.status()).toBe(201)
|
| 106 |
+
|
| 107 |
+
// List
|
| 108 |
+
const listRes = await request.get('/api/alerts', { headers: API_KEY_HEADER })
|
| 109 |
+
const listBody = await listRes.json()
|
| 110 |
+
expect(listBody.rules.some((r: any) => r.id === id)).toBe(true)
|
| 111 |
+
|
| 112 |
+
// Update
|
| 113 |
+
const updateRes = await request.put('/api/alerts', {
|
| 114 |
+
headers: API_KEY_HEADER,
|
| 115 |
+
data: { id, enabled: 0 },
|
| 116 |
+
})
|
| 117 |
+
expect(updateRes.status()).toBe(200)
|
| 118 |
+
|
| 119 |
+
// Delete
|
| 120 |
+
const deleteRes = await request.delete('/api/alerts', {
|
| 121 |
+
headers: API_KEY_HEADER,
|
| 122 |
+
data: { id },
|
| 123 |
+
})
|
| 124 |
+
expect(deleteRes.status()).toBe(200)
|
| 125 |
+
})
|
| 126 |
+
})
|
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { APIRequestContext } from '@playwright/test'
|
| 2 |
+
|
| 3 |
+
export const API_KEY_HEADER = { 'x-api-key': 'test-api-key-e2e-12345' }
|
| 4 |
+
|
| 5 |
+
function uid() {
|
| 6 |
+
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
// --- Task helpers ---
|
| 10 |
+
|
| 11 |
+
export async function createTestTask(
|
| 12 |
+
request: APIRequestContext,
|
| 13 |
+
overrides: Record<string, unknown> = {}
|
| 14 |
+
) {
|
| 15 |
+
const title = `e2e-task-${uid()}`
|
| 16 |
+
const res = await request.post('/api/tasks', {
|
| 17 |
+
headers: API_KEY_HEADER,
|
| 18 |
+
data: { title, ...overrides },
|
| 19 |
+
})
|
| 20 |
+
const body = await res.json()
|
| 21 |
+
return { id: body.task?.id as number, title, res, body }
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export async function deleteTestTask(request: APIRequestContext, id: number) {
|
| 25 |
+
return request.delete(`/api/tasks/${id}`, { headers: API_KEY_HEADER })
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
// --- Agent helpers ---
|
| 29 |
+
|
| 30 |
+
export async function createTestAgent(
|
| 31 |
+
request: APIRequestContext,
|
| 32 |
+
overrides: Record<string, unknown> = {}
|
| 33 |
+
) {
|
| 34 |
+
const name = `e2e-agent-${uid()}`
|
| 35 |
+
const res = await request.post('/api/agents', {
|
| 36 |
+
headers: API_KEY_HEADER,
|
| 37 |
+
data: { name, role: 'tester', ...overrides },
|
| 38 |
+
})
|
| 39 |
+
const body = await res.json()
|
| 40 |
+
return { id: body.agent?.id as number, name, res, body }
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export async function deleteTestAgent(request: APIRequestContext, id: number) {
|
| 44 |
+
return request.delete(`/api/agents/${id}`, { headers: API_KEY_HEADER })
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
// --- Workflow helpers ---
|
| 48 |
+
|
| 49 |
+
export async function createTestWorkflow(
|
| 50 |
+
request: APIRequestContext,
|
| 51 |
+
overrides: Record<string, unknown> = {}
|
| 52 |
+
) {
|
| 53 |
+
const name = `e2e-wf-${uid()}`
|
| 54 |
+
const res = await request.post('/api/workflows', {
|
| 55 |
+
headers: API_KEY_HEADER,
|
| 56 |
+
data: { name, task_prompt: 'Test prompt for e2e', ...overrides },
|
| 57 |
+
})
|
| 58 |
+
const body = await res.json()
|
| 59 |
+
return { id: body.template?.id as number, name, res, body }
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
export async function deleteTestWorkflow(request: APIRequestContext, id: number) {
|
| 63 |
+
return request.delete('/api/workflows', {
|
| 64 |
+
headers: API_KEY_HEADER,
|
| 65 |
+
data: { id },
|
| 66 |
+
})
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
// --- Webhook helpers ---
|
| 70 |
+
|
| 71 |
+
export async function createTestWebhook(
|
| 72 |
+
request: APIRequestContext,
|
| 73 |
+
overrides: Record<string, unknown> = {}
|
| 74 |
+
) {
|
| 75 |
+
const name = `e2e-webhook-${uid()}`
|
| 76 |
+
const res = await request.post('/api/webhooks', {
|
| 77 |
+
headers: API_KEY_HEADER,
|
| 78 |
+
data: { name, url: 'https://example.com/hook', ...overrides },
|
| 79 |
+
})
|
| 80 |
+
const body = await res.json()
|
| 81 |
+
return { id: body.id as number, name, res, body }
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
export async function deleteTestWebhook(request: APIRequestContext, id: number) {
|
| 85 |
+
return request.delete('/api/webhooks', {
|
| 86 |
+
headers: API_KEY_HEADER,
|
| 87 |
+
data: { id },
|
| 88 |
+
})
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
// --- Alert helpers ---
|
| 92 |
+
|
| 93 |
+
export async function createTestAlert(
|
| 94 |
+
request: APIRequestContext,
|
| 95 |
+
overrides: Record<string, unknown> = {}
|
| 96 |
+
) {
|
| 97 |
+
const name = `e2e-alert-${uid()}`
|
| 98 |
+
const res = await request.post('/api/alerts', {
|
| 99 |
+
headers: API_KEY_HEADER,
|
| 100 |
+
data: {
|
| 101 |
+
name,
|
| 102 |
+
entity_type: 'task',
|
| 103 |
+
condition_field: 'status',
|
| 104 |
+
condition_operator: 'equals',
|
| 105 |
+
condition_value: 'inbox',
|
| 106 |
+
...overrides,
|
| 107 |
+
},
|
| 108 |
+
})
|
| 109 |
+
const body = await res.json()
|
| 110 |
+
return { id: body.rule?.id as number, name, res, body }
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
export async function deleteTestAlert(request: APIRequestContext, id: number) {
|
| 114 |
+
return request.delete('/api/alerts', {
|
| 115 |
+
headers: API_KEY_HEADER,
|
| 116 |
+
data: { id },
|
| 117 |
+
})
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
// --- User helpers ---
|
| 121 |
+
|
| 122 |
+
export async function createTestUser(
|
| 123 |
+
request: APIRequestContext,
|
| 124 |
+
overrides: Record<string, unknown> = {}
|
| 125 |
+
) {
|
| 126 |
+
const username = `e2e-user-${uid()}`
|
| 127 |
+
const res = await request.post('/api/auth/users', {
|
| 128 |
+
headers: API_KEY_HEADER,
|
| 129 |
+
data: { username, password: 'testpass123', display_name: username, ...overrides },
|
| 130 |
+
})
|
| 131 |
+
const body = await res.json()
|
| 132 |
+
return { id: body.user?.id as number, username, res, body }
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
export async function deleteTestUser(request: APIRequestContext, id: number) {
|
| 136 |
+
return request.delete('/api/auth/users', {
|
| 137 |
+
headers: API_KEY_HEADER,
|
| 138 |
+
data: { id },
|
| 139 |
+
})
|
| 140 |
+
}
|
|
@@ -7,19 +7,19 @@ import { test, expect } from '@playwright/test'
|
|
| 7 |
|
| 8 |
const API_KEY_HEADER = { 'x-api-key': 'test-api-key-e2e-12345' }
|
| 9 |
|
| 10 |
-
//
|
| 11 |
-
const LIMIT_ENDPOINTS = [
|
| 12 |
-
'/api/agents',
|
| 13 |
-
'/api/tasks',
|
| 14 |
-
'/api/activities',
|
| 15 |
-
'/api/logs',
|
| 16 |
-
'/api/chat/conversations',
|
| 17 |
-
'/api/spawn',
|
| 18 |
]
|
| 19 |
|
| 20 |
test.describe('Limit Caps (Issue #19)', () => {
|
| 21 |
-
for (const endpoint of LIMIT_ENDPOINTS) {
|
| 22 |
-
test(`${endpoint}?limit=9999 does not return more than
|
| 23 |
const res = await request.get(`${endpoint}?limit=9999`, {
|
| 24 |
headers: API_KEY_HEADER
|
| 25 |
})
|
|
@@ -35,12 +35,12 @@ test.describe('Limit Caps (Issue #19)', () => {
|
|
| 35 |
const possibleArrayKeys = ['agents', 'tasks', 'activities', 'logs', 'conversations', 'history', 'data']
|
| 36 |
for (const key of possibleArrayKeys) {
|
| 37 |
if (Array.isArray(body[key])) {
|
| 38 |
-
expect(body[key].length).toBeLessThanOrEqual(
|
| 39 |
}
|
| 40 |
}
|
| 41 |
// Also check if body itself is an array
|
| 42 |
if (Array.isArray(body)) {
|
| 43 |
-
expect(body.length).toBeLessThanOrEqual(
|
| 44 |
}
|
| 45 |
})
|
| 46 |
}
|
|
|
|
| 7 |
|
| 8 |
const API_KEY_HEADER = { 'x-api-key': 'test-api-key-e2e-12345' }
|
| 9 |
|
| 10 |
+
// Endpoints with their server-side caps
|
| 11 |
+
const LIMIT_ENDPOINTS: { path: string; cap: number }[] = [
|
| 12 |
+
{ path: '/api/agents', cap: 200 },
|
| 13 |
+
{ path: '/api/tasks', cap: 200 },
|
| 14 |
+
{ path: '/api/activities', cap: 500 },
|
| 15 |
+
{ path: '/api/logs', cap: 200 },
|
| 16 |
+
{ path: '/api/chat/conversations', cap: 200 },
|
| 17 |
+
{ path: '/api/spawn', cap: 200 },
|
| 18 |
]
|
| 19 |
|
| 20 |
test.describe('Limit Caps (Issue #19)', () => {
|
| 21 |
+
for (const { path: endpoint, cap } of LIMIT_ENDPOINTS) {
|
| 22 |
+
test(`${endpoint}?limit=9999 does not return more than ${cap} items`, async ({ request }) => {
|
| 23 |
const res = await request.get(`${endpoint}?limit=9999`, {
|
| 24 |
headers: API_KEY_HEADER
|
| 25 |
})
|
|
|
|
| 35 |
const possibleArrayKeys = ['agents', 'tasks', 'activities', 'logs', 'conversations', 'history', 'data']
|
| 36 |
for (const key of possibleArrayKeys) {
|
| 37 |
if (Array.isArray(body[key])) {
|
| 38 |
+
expect(body[key].length).toBeLessThanOrEqual(cap)
|
| 39 |
}
|
| 40 |
}
|
| 41 |
// Also check if body itself is an array
|
| 42 |
if (Array.isArray(body)) {
|
| 43 |
+
expect(body.length).toBeLessThanOrEqual(cap)
|
| 44 |
}
|
| 45 |
})
|
| 46 |
}
|
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { test, expect } from '@playwright/test'
|
| 2 |
+
import { API_KEY_HEADER, createTestTask, deleteTestTask } from './helpers'
|
| 3 |
+
|
| 4 |
+
test.describe('Notifications', () => {
|
| 5 |
+
const cleanup: number[] = []
|
| 6 |
+
|
| 7 |
+
test.afterEach(async ({ request }) => {
|
| 8 |
+
for (const id of cleanup) {
|
| 9 |
+
await deleteTestTask(request, id).catch(() => {})
|
| 10 |
+
}
|
| 11 |
+
cleanup.length = 0
|
| 12 |
+
})
|
| 13 |
+
|
| 14 |
+
// ββ GET /api/notifications βββββββββββββββββββ
|
| 15 |
+
|
| 16 |
+
test('GET returns notifications for recipient', async ({ request }) => {
|
| 17 |
+
// Create a task assigned to an agent (triggers notification)
|
| 18 |
+
const { id } = await createTestTask(request, { assigned_to: 'notif-agent' })
|
| 19 |
+
cleanup.push(id)
|
| 20 |
+
|
| 21 |
+
const res = await request.get('/api/notifications?recipient=notif-agent', {
|
| 22 |
+
headers: API_KEY_HEADER,
|
| 23 |
+
})
|
| 24 |
+
expect(res.status()).toBe(200)
|
| 25 |
+
const body = await res.json()
|
| 26 |
+
expect(body.notifications).toBeDefined()
|
| 27 |
+
expect(Array.isArray(body.notifications)).toBe(true)
|
| 28 |
+
expect(body).toHaveProperty('total')
|
| 29 |
+
expect(body).toHaveProperty('unreadCount')
|
| 30 |
+
})
|
| 31 |
+
|
| 32 |
+
test('GET returns 400 without recipient param', async ({ request }) => {
|
| 33 |
+
const res = await request.get('/api/notifications', { headers: API_KEY_HEADER })
|
| 34 |
+
expect(res.status()).toBe(400)
|
| 35 |
+
})
|
| 36 |
+
|
| 37 |
+
test('GET filters by unread_only', async ({ request }) => {
|
| 38 |
+
const res = await request.get('/api/notifications?recipient=notif-agent&unread_only=true', {
|
| 39 |
+
headers: API_KEY_HEADER,
|
| 40 |
+
})
|
| 41 |
+
expect(res.status()).toBe(200)
|
| 42 |
+
const body = await res.json()
|
| 43 |
+
// All returned notifications should be unread (read_at is null)
|
| 44 |
+
for (const n of body.notifications) {
|
| 45 |
+
expect(n.read_at).toBeNull()
|
| 46 |
+
}
|
| 47 |
+
})
|
| 48 |
+
|
| 49 |
+
// ββ POST /api/notifications ββββββββββββββββββ
|
| 50 |
+
|
| 51 |
+
test('POST marks notifications as delivered', async ({ request }) => {
|
| 52 |
+
// Create assignment to trigger a notification
|
| 53 |
+
const { id } = await createTestTask(request, { assigned_to: 'deliver-agent' })
|
| 54 |
+
cleanup.push(id)
|
| 55 |
+
|
| 56 |
+
const res = await request.post('/api/notifications', {
|
| 57 |
+
headers: API_KEY_HEADER,
|
| 58 |
+
data: { action: 'mark-delivered', agent: 'deliver-agent' },
|
| 59 |
+
})
|
| 60 |
+
expect(res.status()).toBe(200)
|
| 61 |
+
const body = await res.json()
|
| 62 |
+
expect(body.success).toBe(true)
|
| 63 |
+
})
|
| 64 |
+
|
| 65 |
+
test('POST rejects missing agent', async ({ request }) => {
|
| 66 |
+
const res = await request.post('/api/notifications', {
|
| 67 |
+
headers: API_KEY_HEADER,
|
| 68 |
+
data: { action: 'mark-delivered' },
|
| 69 |
+
})
|
| 70 |
+
expect(res.status()).toBe(400)
|
| 71 |
+
})
|
| 72 |
+
|
| 73 |
+
// ββ PUT /api/notifications βββββββββββββββββββ
|
| 74 |
+
|
| 75 |
+
test('PUT marks specific notification ids as read', async ({ request }) => {
|
| 76 |
+
// Create assignment
|
| 77 |
+
const { id: taskId } = await createTestTask(request, { assigned_to: 'read-agent' })
|
| 78 |
+
cleanup.push(taskId)
|
| 79 |
+
|
| 80 |
+
// Get the notification id
|
| 81 |
+
const listRes = await request.get('/api/notifications?recipient=read-agent', {
|
| 82 |
+
headers: API_KEY_HEADER,
|
| 83 |
+
})
|
| 84 |
+
const listBody = await listRes.json()
|
| 85 |
+
const notifIds = listBody.notifications.map((n: any) => n.id)
|
| 86 |
+
|
| 87 |
+
if (notifIds.length > 0) {
|
| 88 |
+
const res = await request.put('/api/notifications', {
|
| 89 |
+
headers: API_KEY_HEADER,
|
| 90 |
+
data: { ids: notifIds },
|
| 91 |
+
})
|
| 92 |
+
expect(res.status()).toBe(200)
|
| 93 |
+
const body = await res.json()
|
| 94 |
+
expect(body.success).toBe(true)
|
| 95 |
+
}
|
| 96 |
+
})
|
| 97 |
+
|
| 98 |
+
test('PUT marks all as read for recipient', async ({ request }) => {
|
| 99 |
+
const res = await request.put('/api/notifications', {
|
| 100 |
+
headers: API_KEY_HEADER,
|
| 101 |
+
data: { recipient: 'read-agent', markAllRead: true },
|
| 102 |
+
})
|
| 103 |
+
expect(res.status()).toBe(200)
|
| 104 |
+
const body = await res.json()
|
| 105 |
+
expect(body.success).toBe(true)
|
| 106 |
+
})
|
| 107 |
+
})
|
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { test, expect } from '@playwright/test'
|
| 2 |
+
import { API_KEY_HEADER, createTestTask, deleteTestTask } from './helpers'
|
| 3 |
+
|
| 4 |
+
test.describe('Quality Review', () => {
|
| 5 |
+
const cleanup: number[] = []
|
| 6 |
+
|
| 7 |
+
test.afterEach(async ({ request }) => {
|
| 8 |
+
for (const id of cleanup) {
|
| 9 |
+
await deleteTestTask(request, id).catch(() => {})
|
| 10 |
+
}
|
| 11 |
+
cleanup.length = 0
|
| 12 |
+
})
|
| 13 |
+
|
| 14 |
+
// ββ POST /api/quality-review βββββββββββββββββ
|
| 15 |
+
|
| 16 |
+
test('POST creates review for existing task', async ({ request }) => {
|
| 17 |
+
const { id: taskId } = await createTestTask(request)
|
| 18 |
+
cleanup.push(taskId)
|
| 19 |
+
|
| 20 |
+
const res = await request.post('/api/quality-review', {
|
| 21 |
+
headers: API_KEY_HEADER,
|
| 22 |
+
data: {
|
| 23 |
+
taskId,
|
| 24 |
+
reviewer: 'aegis',
|
| 25 |
+
status: 'approved',
|
| 26 |
+
notes: 'Looks good to me',
|
| 27 |
+
},
|
| 28 |
+
})
|
| 29 |
+
expect(res.status()).toBe(200)
|
| 30 |
+
const body = await res.json()
|
| 31 |
+
expect(body.success).toBe(true)
|
| 32 |
+
expect(body.id).toBeDefined()
|
| 33 |
+
})
|
| 34 |
+
|
| 35 |
+
test('POST returns 404 for non-existent task', async ({ request }) => {
|
| 36 |
+
const res = await request.post('/api/quality-review', {
|
| 37 |
+
headers: API_KEY_HEADER,
|
| 38 |
+
data: {
|
| 39 |
+
taskId: 999999,
|
| 40 |
+
reviewer: 'aegis',
|
| 41 |
+
status: 'rejected',
|
| 42 |
+
notes: 'Task does not exist',
|
| 43 |
+
},
|
| 44 |
+
})
|
| 45 |
+
expect(res.status()).toBe(404)
|
| 46 |
+
})
|
| 47 |
+
|
| 48 |
+
test('POST rejects missing required fields', async ({ request }) => {
|
| 49 |
+
const { id: taskId } = await createTestTask(request)
|
| 50 |
+
cleanup.push(taskId)
|
| 51 |
+
|
| 52 |
+
const res = await request.post('/api/quality-review', {
|
| 53 |
+
headers: API_KEY_HEADER,
|
| 54 |
+
data: { taskId },
|
| 55 |
+
})
|
| 56 |
+
expect(res.status()).toBe(400)
|
| 57 |
+
})
|
| 58 |
+
|
| 59 |
+
// ββ GET /api/quality-review ββββββββββββββββββ
|
| 60 |
+
|
| 61 |
+
test('GET returns reviews for taskId', async ({ request }) => {
|
| 62 |
+
const { id: taskId } = await createTestTask(request)
|
| 63 |
+
cleanup.push(taskId)
|
| 64 |
+
|
| 65 |
+
// Create a review first
|
| 66 |
+
await request.post('/api/quality-review', {
|
| 67 |
+
headers: API_KEY_HEADER,
|
| 68 |
+
data: {
|
| 69 |
+
taskId,
|
| 70 |
+
reviewer: 'aegis',
|
| 71 |
+
status: 'approved',
|
| 72 |
+
notes: 'LGTM',
|
| 73 |
+
},
|
| 74 |
+
})
|
| 75 |
+
|
| 76 |
+
const res = await request.get(`/api/quality-review?taskId=${taskId}`, {
|
| 77 |
+
headers: API_KEY_HEADER,
|
| 78 |
+
})
|
| 79 |
+
expect(res.status()).toBe(200)
|
| 80 |
+
const body = await res.json()
|
| 81 |
+
expect(body.reviews).toBeDefined()
|
| 82 |
+
expect(Array.isArray(body.reviews)).toBe(true)
|
| 83 |
+
expect(body.reviews.length).toBeGreaterThanOrEqual(1)
|
| 84 |
+
})
|
| 85 |
+
|
| 86 |
+
test('GET batch lookup by taskIds', async ({ request }) => {
|
| 87 |
+
const { id: taskId1 } = await createTestTask(request)
|
| 88 |
+
const { id: taskId2 } = await createTestTask(request)
|
| 89 |
+
cleanup.push(taskId1, taskId2)
|
| 90 |
+
|
| 91 |
+
// Create review for task1
|
| 92 |
+
await request.post('/api/quality-review', {
|
| 93 |
+
headers: API_KEY_HEADER,
|
| 94 |
+
data: { taskId: taskId1, reviewer: 'aegis', status: 'approved', notes: 'ok' },
|
| 95 |
+
})
|
| 96 |
+
|
| 97 |
+
const res = await request.get(`/api/quality-review?taskIds=${taskId1},${taskId2}`, {
|
| 98 |
+
headers: API_KEY_HEADER,
|
| 99 |
+
})
|
| 100 |
+
expect(res.status()).toBe(200)
|
| 101 |
+
const body = await res.json()
|
| 102 |
+
expect(body.latest).toBeDefined()
|
| 103 |
+
expect(body.latest[taskId1]).not.toBeNull()
|
| 104 |
+
expect(body.latest[taskId2]).toBeNull() // no review for task2
|
| 105 |
+
})
|
| 106 |
+
|
| 107 |
+
test('GET returns 400 without taskId', async ({ request }) => {
|
| 108 |
+
const res = await request.get('/api/quality-review', { headers: API_KEY_HEADER })
|
| 109 |
+
expect(res.status()).toBe(400)
|
| 110 |
+
})
|
| 111 |
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { test, expect } from '@playwright/test'
|
| 2 |
+
import { API_KEY_HEADER, createTestTask, deleteTestTask } from './helpers'
|
| 3 |
+
|
| 4 |
+
test.describe('Search and Export', () => {
|
| 5 |
+
const cleanup: number[] = []
|
| 6 |
+
|
| 7 |
+
test.afterEach(async ({ request }) => {
|
| 8 |
+
for (const id of cleanup) {
|
| 9 |
+
await deleteTestTask(request, id).catch(() => {})
|
| 10 |
+
}
|
| 11 |
+
cleanup.length = 0
|
| 12 |
+
})
|
| 13 |
+
|
| 14 |
+
// ββ GET /api/search ββββββββββββββββββββββββββ
|
| 15 |
+
|
| 16 |
+
test('search returns results for valid query', async ({ request }) => {
|
| 17 |
+
// Create a task with a searchable term
|
| 18 |
+
const { id } = await createTestTask(request, { title: 'searchable-zebra-test' })
|
| 19 |
+
cleanup.push(id)
|
| 20 |
+
|
| 21 |
+
const res = await request.get('/api/search?q=searchable-zebra', {
|
| 22 |
+
headers: API_KEY_HEADER,
|
| 23 |
+
})
|
| 24 |
+
expect(res.status()).toBe(200)
|
| 25 |
+
const body = await res.json()
|
| 26 |
+
expect(body.query).toBe('searchable-zebra')
|
| 27 |
+
expect(body.results).toBeDefined()
|
| 28 |
+
expect(Array.isArray(body.results)).toBe(true)
|
| 29 |
+
expect(body.count).toBeGreaterThanOrEqual(1)
|
| 30 |
+
})
|
| 31 |
+
|
| 32 |
+
test('search returns 400 for short query', async ({ request }) => {
|
| 33 |
+
const res = await request.get('/api/search?q=a', { headers: API_KEY_HEADER })
|
| 34 |
+
expect(res.status()).toBe(400)
|
| 35 |
+
})
|
| 36 |
+
|
| 37 |
+
test('search returns 400 for empty query', async ({ request }) => {
|
| 38 |
+
const res = await request.get('/api/search?q=', { headers: API_KEY_HEADER })
|
| 39 |
+
expect(res.status()).toBe(400)
|
| 40 |
+
})
|
| 41 |
+
|
| 42 |
+
// ββ GET /api/export ββββββββββββββββββββββββββ
|
| 43 |
+
|
| 44 |
+
test('export returns tasks as JSON', async ({ request }) => {
|
| 45 |
+
const res = await request.get('/api/export?type=tasks&format=json', {
|
| 46 |
+
headers: API_KEY_HEADER,
|
| 47 |
+
})
|
| 48 |
+
expect(res.status()).toBe(200)
|
| 49 |
+
const body = await res.json()
|
| 50 |
+
expect(body.type).toBe('tasks')
|
| 51 |
+
expect(body.data).toBeDefined()
|
| 52 |
+
expect(Array.isArray(body.data)).toBe(true)
|
| 53 |
+
expect(body.count).toBeDefined()
|
| 54 |
+
})
|
| 55 |
+
|
| 56 |
+
test('export rejects missing type', async ({ request }) => {
|
| 57 |
+
const res = await request.get('/api/export', { headers: API_KEY_HEADER })
|
| 58 |
+
expect(res.status()).toBe(400)
|
| 59 |
+
})
|
| 60 |
+
|
| 61 |
+
test('export rejects invalid type', async ({ request }) => {
|
| 62 |
+
const res = await request.get('/api/export?type=invalid', { headers: API_KEY_HEADER })
|
| 63 |
+
expect(res.status()).toBe(400)
|
| 64 |
+
})
|
| 65 |
+
|
| 66 |
+
// ββ GET /api/activities ββββββββββββββββββββββ
|
| 67 |
+
|
| 68 |
+
test('activities returns activity feed', async ({ request }) => {
|
| 69 |
+
const res = await request.get('/api/activities', { headers: API_KEY_HEADER })
|
| 70 |
+
expect(res.status()).toBe(200)
|
| 71 |
+
const body = await res.json()
|
| 72 |
+
expect(body.activities).toBeDefined()
|
| 73 |
+
expect(Array.isArray(body.activities)).toBe(true)
|
| 74 |
+
expect(body).toHaveProperty('total')
|
| 75 |
+
expect(body).toHaveProperty('hasMore')
|
| 76 |
+
})
|
| 77 |
+
})
|
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { test, expect } from '@playwright/test'
|
| 2 |
+
import { API_KEY_HEADER, createTestTask, deleteTestTask } from './helpers'
|
| 3 |
+
|
| 4 |
+
test.describe('Task Comments', () => {
|
| 5 |
+
const cleanup: number[] = []
|
| 6 |
+
|
| 7 |
+
test.afterEach(async ({ request }) => {
|
| 8 |
+
for (const id of cleanup) {
|
| 9 |
+
await deleteTestTask(request, id).catch(() => {})
|
| 10 |
+
}
|
| 11 |
+
cleanup.length = 0
|
| 12 |
+
})
|
| 13 |
+
|
| 14 |
+
// ββ POST /api/tasks/[id]/comments ββββββββββββ
|
| 15 |
+
|
| 16 |
+
test('POST adds comment to existing task', async ({ request }) => {
|
| 17 |
+
const { id } = await createTestTask(request)
|
| 18 |
+
cleanup.push(id)
|
| 19 |
+
|
| 20 |
+
const res = await request.post(`/api/tasks/${id}/comments`, {
|
| 21 |
+
headers: API_KEY_HEADER,
|
| 22 |
+
data: { content: 'Test comment from e2e' },
|
| 23 |
+
})
|
| 24 |
+
expect(res.status()).toBe(201)
|
| 25 |
+
const body = await res.json()
|
| 26 |
+
expect(body.comment).toBeDefined()
|
| 27 |
+
expect(body.comment.content).toBe('Test comment from e2e')
|
| 28 |
+
expect(body.comment.task_id).toBe(id)
|
| 29 |
+
})
|
| 30 |
+
|
| 31 |
+
test('POST rejects empty content', async ({ request }) => {
|
| 32 |
+
const { id } = await createTestTask(request)
|
| 33 |
+
cleanup.push(id)
|
| 34 |
+
|
| 35 |
+
const res = await request.post(`/api/tasks/${id}/comments`, {
|
| 36 |
+
headers: API_KEY_HEADER,
|
| 37 |
+
data: { content: '' },
|
| 38 |
+
})
|
| 39 |
+
expect(res.status()).toBe(400)
|
| 40 |
+
})
|
| 41 |
+
|
| 42 |
+
test('POST returns 404 for non-existent task', async ({ request }) => {
|
| 43 |
+
const res = await request.post('/api/tasks/999999/comments', {
|
| 44 |
+
headers: API_KEY_HEADER,
|
| 45 |
+
data: { content: 'orphan comment' },
|
| 46 |
+
})
|
| 47 |
+
expect(res.status()).toBe(404)
|
| 48 |
+
})
|
| 49 |
+
|
| 50 |
+
test('POST creates threaded reply', async ({ request }) => {
|
| 51 |
+
const { id } = await createTestTask(request)
|
| 52 |
+
cleanup.push(id)
|
| 53 |
+
|
| 54 |
+
// Create parent comment
|
| 55 |
+
const parentRes = await request.post(`/api/tasks/${id}/comments`, {
|
| 56 |
+
headers: API_KEY_HEADER,
|
| 57 |
+
data: { content: 'Parent comment' },
|
| 58 |
+
})
|
| 59 |
+
const parentBody = await parentRes.json()
|
| 60 |
+
const parentId = parentBody.comment.id
|
| 61 |
+
|
| 62 |
+
// Create reply
|
| 63 |
+
const replyRes = await request.post(`/api/tasks/${id}/comments`, {
|
| 64 |
+
headers: API_KEY_HEADER,
|
| 65 |
+
data: { content: 'Reply comment', parent_id: parentId },
|
| 66 |
+
})
|
| 67 |
+
expect(replyRes.status()).toBe(201)
|
| 68 |
+
const replyBody = await replyRes.json()
|
| 69 |
+
expect(replyBody.comment.parent_id).toBe(parentId)
|
| 70 |
+
})
|
| 71 |
+
|
| 72 |
+
// ββ GET /api/tasks/[id]/comments βββββββββββββ
|
| 73 |
+
|
| 74 |
+
test('GET returns comments array for task', async ({ request }) => {
|
| 75 |
+
const { id } = await createTestTask(request)
|
| 76 |
+
cleanup.push(id)
|
| 77 |
+
|
| 78 |
+
// Add a comment
|
| 79 |
+
await request.post(`/api/tasks/${id}/comments`, {
|
| 80 |
+
headers: API_KEY_HEADER,
|
| 81 |
+
data: { content: 'First comment' },
|
| 82 |
+
})
|
| 83 |
+
|
| 84 |
+
const res = await request.get(`/api/tasks/${id}/comments`, { headers: API_KEY_HEADER })
|
| 85 |
+
expect(res.status()).toBe(200)
|
| 86 |
+
const body = await res.json()
|
| 87 |
+
expect(body.comments).toBeDefined()
|
| 88 |
+
expect(Array.isArray(body.comments)).toBe(true)
|
| 89 |
+
expect(body.comments.length).toBeGreaterThanOrEqual(1)
|
| 90 |
+
expect(body.total).toBeGreaterThanOrEqual(1)
|
| 91 |
+
})
|
| 92 |
+
|
| 93 |
+
test('GET returns empty array for task with no comments', async ({ request }) => {
|
| 94 |
+
const { id } = await createTestTask(request)
|
| 95 |
+
cleanup.push(id)
|
| 96 |
+
|
| 97 |
+
const res = await request.get(`/api/tasks/${id}/comments`, { headers: API_KEY_HEADER })
|
| 98 |
+
expect(res.status()).toBe(200)
|
| 99 |
+
const body = await res.json()
|
| 100 |
+
expect(body.comments).toEqual([])
|
| 101 |
+
expect(body.total).toBe(0)
|
| 102 |
+
})
|
| 103 |
+
|
| 104 |
+
test('GET returns 404 for non-existent task', async ({ request }) => {
|
| 105 |
+
const res = await request.get('/api/tasks/999999/comments', { headers: API_KEY_HEADER })
|
| 106 |
+
expect(res.status()).toBe(404)
|
| 107 |
+
})
|
| 108 |
+
})
|
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { test, expect } from '@playwright/test'
|
| 2 |
+
import { API_KEY_HEADER, createTestTask, deleteTestTask } from './helpers'
|
| 3 |
+
|
| 4 |
+
test.describe('Tasks CRUD', () => {
|
| 5 |
+
const cleanup: number[] = []
|
| 6 |
+
|
| 7 |
+
test.afterEach(async ({ request }) => {
|
| 8 |
+
for (const id of cleanup) {
|
| 9 |
+
await deleteTestTask(request, id).catch(() => {})
|
| 10 |
+
}
|
| 11 |
+
cleanup.length = 0
|
| 12 |
+
})
|
| 13 |
+
|
| 14 |
+
// ββ POST /api/tasks ββββββββββββββββββββββββββ
|
| 15 |
+
|
| 16 |
+
test('POST creates task with minimal fields (title only)', async ({ request }) => {
|
| 17 |
+
const { id, res, body } = await createTestTask(request)
|
| 18 |
+
cleanup.push(id)
|
| 19 |
+
|
| 20 |
+
expect(res.status()).toBe(201)
|
| 21 |
+
expect(body.task).toBeDefined()
|
| 22 |
+
expect(body.task.title).toContain('e2e-task-')
|
| 23 |
+
expect(body.task.status).toBe('inbox')
|
| 24 |
+
expect(body.task.priority).toBe('medium')
|
| 25 |
+
})
|
| 26 |
+
|
| 27 |
+
test('POST creates task with all fields', async ({ request }) => {
|
| 28 |
+
const { id, res, body } = await createTestTask(request, {
|
| 29 |
+
description: 'Full task',
|
| 30 |
+
status: 'assigned',
|
| 31 |
+
priority: 'high',
|
| 32 |
+
assigned_to: 'agent-x',
|
| 33 |
+
tags: ['e2e', 'test'],
|
| 34 |
+
metadata: { source: 'e2e' },
|
| 35 |
+
})
|
| 36 |
+
cleanup.push(id)
|
| 37 |
+
|
| 38 |
+
expect(res.status()).toBe(201)
|
| 39 |
+
expect(body.task.description).toBe('Full task')
|
| 40 |
+
expect(body.task.status).toBe('assigned')
|
| 41 |
+
expect(body.task.priority).toBe('high')
|
| 42 |
+
expect(body.task.assigned_to).toBe('agent-x')
|
| 43 |
+
expect(body.task.tags).toEqual(['e2e', 'test'])
|
| 44 |
+
expect(body.task.metadata).toEqual({ source: 'e2e' })
|
| 45 |
+
})
|
| 46 |
+
|
| 47 |
+
test('POST rejects empty title', async ({ request }) => {
|
| 48 |
+
const res = await request.post('/api/tasks', {
|
| 49 |
+
headers: API_KEY_HEADER,
|
| 50 |
+
data: { title: '' },
|
| 51 |
+
})
|
| 52 |
+
expect(res.status()).toBe(400)
|
| 53 |
+
})
|
| 54 |
+
|
| 55 |
+
test('POST rejects duplicate title', async ({ request }) => {
|
| 56 |
+
const { id, body: first } = await createTestTask(request)
|
| 57 |
+
cleanup.push(id)
|
| 58 |
+
|
| 59 |
+
const res = await request.post('/api/tasks', {
|
| 60 |
+
headers: API_KEY_HEADER,
|
| 61 |
+
data: { title: first.task.title },
|
| 62 |
+
})
|
| 63 |
+
expect(res.status()).toBe(409)
|
| 64 |
+
})
|
| 65 |
+
|
| 66 |
+
// ββ GET /api/tasks βββββββββββββββββββββββββββ
|
| 67 |
+
|
| 68 |
+
test('GET list returns tasks with pagination shape', async ({ request }) => {
|
| 69 |
+
const { id } = await createTestTask(request)
|
| 70 |
+
cleanup.push(id)
|
| 71 |
+
|
| 72 |
+
const res = await request.get('/api/tasks', { headers: API_KEY_HEADER })
|
| 73 |
+
expect(res.status()).toBe(200)
|
| 74 |
+
const body = await res.json()
|
| 75 |
+
expect(body).toHaveProperty('tasks')
|
| 76 |
+
expect(body).toHaveProperty('total')
|
| 77 |
+
expect(body).toHaveProperty('page')
|
| 78 |
+
expect(body).toHaveProperty('limit')
|
| 79 |
+
expect(Array.isArray(body.tasks)).toBe(true)
|
| 80 |
+
})
|
| 81 |
+
|
| 82 |
+
test('GET list filters by status', async ({ request }) => {
|
| 83 |
+
const { id } = await createTestTask(request, { status: 'review' })
|
| 84 |
+
cleanup.push(id)
|
| 85 |
+
|
| 86 |
+
const res = await request.get('/api/tasks?status=review', { headers: API_KEY_HEADER })
|
| 87 |
+
const body = await res.json()
|
| 88 |
+
for (const t of body.tasks) {
|
| 89 |
+
expect(t.status).toBe('review')
|
| 90 |
+
}
|
| 91 |
+
})
|
| 92 |
+
|
| 93 |
+
test('GET list filters by priority', async ({ request }) => {
|
| 94 |
+
const { id } = await createTestTask(request, { priority: 'critical' })
|
| 95 |
+
cleanup.push(id)
|
| 96 |
+
|
| 97 |
+
const res = await request.get('/api/tasks?priority=critical', { headers: API_KEY_HEADER })
|
| 98 |
+
const body = await res.json()
|
| 99 |
+
for (const t of body.tasks) {
|
| 100 |
+
expect(t.priority).toBe('critical')
|
| 101 |
+
}
|
| 102 |
+
})
|
| 103 |
+
|
| 104 |
+
test('GET list respects limit and offset', async ({ request }) => {
|
| 105 |
+
const res = await request.get('/api/tasks?limit=2&offset=0', { headers: API_KEY_HEADER })
|
| 106 |
+
const body = await res.json()
|
| 107 |
+
expect(body.tasks.length).toBeLessThanOrEqual(2)
|
| 108 |
+
expect(body.limit).toBe(2)
|
| 109 |
+
})
|
| 110 |
+
|
| 111 |
+
// ββ GET /api/tasks/[id] ββββββββββββββββββββββ
|
| 112 |
+
|
| 113 |
+
test('GET single returns task by id', async ({ request }) => {
|
| 114 |
+
const { id } = await createTestTask(request)
|
| 115 |
+
cleanup.push(id)
|
| 116 |
+
|
| 117 |
+
const res = await request.get(`/api/tasks/${id}`, { headers: API_KEY_HEADER })
|
| 118 |
+
expect(res.status()).toBe(200)
|
| 119 |
+
const body = await res.json()
|
| 120 |
+
expect(body.task).toBeDefined()
|
| 121 |
+
expect(body.task.id).toBe(id)
|
| 122 |
+
})
|
| 123 |
+
|
| 124 |
+
test('GET single returns 404 for missing task', async ({ request }) => {
|
| 125 |
+
const res = await request.get('/api/tasks/999999', { headers: API_KEY_HEADER })
|
| 126 |
+
expect(res.status()).toBe(404)
|
| 127 |
+
})
|
| 128 |
+
|
| 129 |
+
test('GET single returns 400 for non-numeric id', async ({ request }) => {
|
| 130 |
+
const res = await request.get('/api/tasks/abc', { headers: API_KEY_HEADER })
|
| 131 |
+
expect(res.status()).toBe(400)
|
| 132 |
+
})
|
| 133 |
+
|
| 134 |
+
// ββ PUT /api/tasks/[id] ββββββββββββββββββββββ
|
| 135 |
+
|
| 136 |
+
test('PUT updates task fields', async ({ request }) => {
|
| 137 |
+
const { id } = await createTestTask(request)
|
| 138 |
+
cleanup.push(id)
|
| 139 |
+
|
| 140 |
+
const res = await request.put(`/api/tasks/${id}`, {
|
| 141 |
+
headers: API_KEY_HEADER,
|
| 142 |
+
data: { title: 'Updated title', priority: 'high' },
|
| 143 |
+
})
|
| 144 |
+
expect(res.status()).toBe(200)
|
| 145 |
+
const body = await res.json()
|
| 146 |
+
expect(body.task.title).toBe('Updated title')
|
| 147 |
+
expect(body.task.priority).toBe('high')
|
| 148 |
+
})
|
| 149 |
+
|
| 150 |
+
test('PUT returns 404 for missing task', async ({ request }) => {
|
| 151 |
+
const res = await request.put('/api/tasks/999999', {
|
| 152 |
+
headers: API_KEY_HEADER,
|
| 153 |
+
data: { title: 'no-op' },
|
| 154 |
+
})
|
| 155 |
+
expect(res.status()).toBe(404)
|
| 156 |
+
})
|
| 157 |
+
|
| 158 |
+
test('PUT with empty body still succeeds (Zod defaults fill fields)', async ({ request }) => {
|
| 159 |
+
const { id } = await createTestTask(request)
|
| 160 |
+
cleanup.push(id)
|
| 161 |
+
|
| 162 |
+
const res = await request.put(`/api/tasks/${id}`, {
|
| 163 |
+
headers: API_KEY_HEADER,
|
| 164 |
+
data: {},
|
| 165 |
+
})
|
| 166 |
+
// Zod's partial schema fills defaults (status, priority, tags, metadata),
|
| 167 |
+
// so there are always fields to update β API returns 200, not 400
|
| 168 |
+
expect(res.status()).toBe(200)
|
| 169 |
+
})
|
| 170 |
+
|
| 171 |
+
test('PUT returns 403 when moving to done without Aegis approval', async ({ request }) => {
|
| 172 |
+
const { id } = await createTestTask(request)
|
| 173 |
+
cleanup.push(id)
|
| 174 |
+
|
| 175 |
+
const res = await request.put(`/api/tasks/${id}`, {
|
| 176 |
+
headers: API_KEY_HEADER,
|
| 177 |
+
data: { status: 'done' },
|
| 178 |
+
})
|
| 179 |
+
expect(res.status()).toBe(403)
|
| 180 |
+
const body = await res.json()
|
| 181 |
+
expect(body.error).toContain('Aegis')
|
| 182 |
+
})
|
| 183 |
+
|
| 184 |
+
// ββ DELETE /api/tasks/[id] βββββββββββββββββββ
|
| 185 |
+
|
| 186 |
+
test('DELETE removes task', async ({ request }) => {
|
| 187 |
+
const { id } = await createTestTask(request)
|
| 188 |
+
|
| 189 |
+
const res = await request.delete(`/api/tasks/${id}`, { headers: API_KEY_HEADER })
|
| 190 |
+
expect(res.status()).toBe(200)
|
| 191 |
+
const body = await res.json()
|
| 192 |
+
expect(body.success).toBe(true)
|
| 193 |
+
})
|
| 194 |
+
|
| 195 |
+
test('DELETE returns 404 for missing task', async ({ request }) => {
|
| 196 |
+
const res = await request.delete('/api/tasks/999999', { headers: API_KEY_HEADER })
|
| 197 |
+
expect(res.status()).toBe(404)
|
| 198 |
+
})
|
| 199 |
+
|
| 200 |
+
// ββ Full lifecycle βββββββββββββββββββββββββββ
|
| 201 |
+
|
| 202 |
+
test('full lifecycle: create β read β update β delete β confirm gone', async ({ request }) => {
|
| 203 |
+
// Create
|
| 204 |
+
const { id, res: createRes } = await createTestTask(request, { description: 'lifecycle test' })
|
| 205 |
+
expect(createRes.status()).toBe(201)
|
| 206 |
+
|
| 207 |
+
// Read
|
| 208 |
+
const readRes = await request.get(`/api/tasks/${id}`, { headers: API_KEY_HEADER })
|
| 209 |
+
expect(readRes.status()).toBe(200)
|
| 210 |
+
const readBody = await readRes.json()
|
| 211 |
+
expect(readBody.task.description).toBe('lifecycle test')
|
| 212 |
+
|
| 213 |
+
// Update
|
| 214 |
+
const updateRes = await request.put(`/api/tasks/${id}`, {
|
| 215 |
+
headers: API_KEY_HEADER,
|
| 216 |
+
data: { status: 'in_progress', priority: 'critical' },
|
| 217 |
+
})
|
| 218 |
+
expect(updateRes.status()).toBe(200)
|
| 219 |
+
const updateBody = await updateRes.json()
|
| 220 |
+
expect(updateBody.task.status).toBe('in_progress')
|
| 221 |
+
expect(updateBody.task.priority).toBe('critical')
|
| 222 |
+
|
| 223 |
+
// Delete
|
| 224 |
+
const deleteRes = await request.delete(`/api/tasks/${id}`, { headers: API_KEY_HEADER })
|
| 225 |
+
expect(deleteRes.status()).toBe(200)
|
| 226 |
+
|
| 227 |
+
// Confirm gone
|
| 228 |
+
const goneRes = await request.get(`/api/tasks/${id}`, { headers: API_KEY_HEADER })
|
| 229 |
+
expect(goneRes.status()).toBe(404)
|
| 230 |
+
})
|
| 231 |
+
})
|
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { test, expect } from '@playwright/test'
|
| 2 |
+
import { API_KEY_HEADER, createTestUser, deleteTestUser } from './helpers'
|
| 3 |
+
|
| 4 |
+
test.describe('User Management', () => {
|
| 5 |
+
const cleanup: number[] = []
|
| 6 |
+
|
| 7 |
+
test.afterEach(async ({ request }) => {
|
| 8 |
+
for (const id of cleanup) {
|
| 9 |
+
await deleteTestUser(request, id).catch(() => {})
|
| 10 |
+
}
|
| 11 |
+
cleanup.length = 0
|
| 12 |
+
})
|
| 13 |
+
|
| 14 |
+
// ββ POST /api/auth/users βββββββββββββββββββββ
|
| 15 |
+
|
| 16 |
+
test('POST creates user', async ({ request }) => {
|
| 17 |
+
const { id, res, body } = await createTestUser(request)
|
| 18 |
+
cleanup.push(id)
|
| 19 |
+
|
| 20 |
+
expect(res.status()).toBe(201)
|
| 21 |
+
expect(body.user).toBeDefined()
|
| 22 |
+
expect(body.user.username).toContain('e2e-user-')
|
| 23 |
+
expect(body.user.role).toBe('operator')
|
| 24 |
+
})
|
| 25 |
+
|
| 26 |
+
test('POST rejects duplicate username', async ({ request }) => {
|
| 27 |
+
const { id, body: first } = await createTestUser(request)
|
| 28 |
+
cleanup.push(id)
|
| 29 |
+
|
| 30 |
+
const res = await request.post('/api/auth/users', {
|
| 31 |
+
headers: API_KEY_HEADER,
|
| 32 |
+
data: {
|
| 33 |
+
username: first.user.username,
|
| 34 |
+
password: 'testpass123',
|
| 35 |
+
},
|
| 36 |
+
})
|
| 37 |
+
expect(res.status()).toBe(409)
|
| 38 |
+
})
|
| 39 |
+
|
| 40 |
+
test('POST rejects missing username', async ({ request }) => {
|
| 41 |
+
const res = await request.post('/api/auth/users', {
|
| 42 |
+
headers: API_KEY_HEADER,
|
| 43 |
+
data: { password: 'testpass123' },
|
| 44 |
+
})
|
| 45 |
+
expect(res.status()).toBe(400)
|
| 46 |
+
})
|
| 47 |
+
|
| 48 |
+
test('POST rejects missing password', async ({ request }) => {
|
| 49 |
+
const res = await request.post('/api/auth/users', {
|
| 50 |
+
headers: API_KEY_HEADER,
|
| 51 |
+
data: { username: 'no-password-user' },
|
| 52 |
+
})
|
| 53 |
+
expect(res.status()).toBe(400)
|
| 54 |
+
})
|
| 55 |
+
|
| 56 |
+
// ββ GET /api/auth/users ββββββββββββββββββββββ
|
| 57 |
+
|
| 58 |
+
test('GET returns users list', async ({ request }) => {
|
| 59 |
+
const res = await request.get('/api/auth/users', { headers: API_KEY_HEADER })
|
| 60 |
+
expect(res.status()).toBe(200)
|
| 61 |
+
const body = await res.json()
|
| 62 |
+
expect(body.users).toBeDefined()
|
| 63 |
+
expect(Array.isArray(body.users)).toBe(true)
|
| 64 |
+
})
|
| 65 |
+
|
| 66 |
+
// ββ PUT /api/auth/users ββββββββββββββββββββββ
|
| 67 |
+
|
| 68 |
+
test('PUT updates display_name and role', async ({ request }) => {
|
| 69 |
+
const { id } = await createTestUser(request)
|
| 70 |
+
cleanup.push(id)
|
| 71 |
+
|
| 72 |
+
const res = await request.put('/api/auth/users', {
|
| 73 |
+
headers: API_KEY_HEADER,
|
| 74 |
+
data: { id, display_name: 'Updated Name', role: 'viewer' },
|
| 75 |
+
})
|
| 76 |
+
expect(res.status()).toBe(200)
|
| 77 |
+
const body = await res.json()
|
| 78 |
+
expect(body.user.display_name).toBe('Updated Name')
|
| 79 |
+
expect(body.user.role).toBe('viewer')
|
| 80 |
+
})
|
| 81 |
+
|
| 82 |
+
test('PUT returns 404 for missing user', async ({ request }) => {
|
| 83 |
+
const res = await request.put('/api/auth/users', {
|
| 84 |
+
headers: API_KEY_HEADER,
|
| 85 |
+
data: { id: 999999, display_name: 'nope' },
|
| 86 |
+
})
|
| 87 |
+
expect(res.status()).toBe(404)
|
| 88 |
+
})
|
| 89 |
+
|
| 90 |
+
// ββ DELETE /api/auth/users βββββββββββββββββββ
|
| 91 |
+
|
| 92 |
+
test('DELETE removes user', async ({ request }) => {
|
| 93 |
+
const { id } = await createTestUser(request)
|
| 94 |
+
|
| 95 |
+
const res = await request.delete('/api/auth/users', {
|
| 96 |
+
headers: API_KEY_HEADER,
|
| 97 |
+
data: { id },
|
| 98 |
+
})
|
| 99 |
+
expect(res.status()).toBe(200)
|
| 100 |
+
const body = await res.json()
|
| 101 |
+
expect(body.success).toBe(true)
|
| 102 |
+
})
|
| 103 |
+
|
| 104 |
+
test('DELETE returns 404 for missing user', async ({ request }) => {
|
| 105 |
+
const res = await request.delete('/api/auth/users', {
|
| 106 |
+
headers: API_KEY_HEADER,
|
| 107 |
+
data: { id: 999999 },
|
| 108 |
+
})
|
| 109 |
+
expect(res.status()).toBe(404)
|
| 110 |
+
})
|
| 111 |
+
})
|
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { test, expect } from '@playwright/test'
|
| 2 |
+
import { API_KEY_HEADER, createTestWebhook, deleteTestWebhook } from './helpers'
|
| 3 |
+
|
| 4 |
+
test.describe('Webhooks CRUD', () => {
|
| 5 |
+
const cleanup: number[] = []
|
| 6 |
+
|
| 7 |
+
test.afterEach(async ({ request }) => {
|
| 8 |
+
for (const id of cleanup) {
|
| 9 |
+
await deleteTestWebhook(request, id).catch(() => {})
|
| 10 |
+
}
|
| 11 |
+
cleanup.length = 0
|
| 12 |
+
})
|
| 13 |
+
|
| 14 |
+
// ββ POST /api/webhooks βββββββββββββββββββββββ
|
| 15 |
+
|
| 16 |
+
test('POST creates webhook with name and valid URL', async ({ request }) => {
|
| 17 |
+
const { id, res, body } = await createTestWebhook(request)
|
| 18 |
+
cleanup.push(id)
|
| 19 |
+
|
| 20 |
+
expect(res.status()).toBe(200) // webhook POST returns 200, not 201
|
| 21 |
+
expect(body.id).toBeDefined()
|
| 22 |
+
expect(body.name).toContain('e2e-webhook-')
|
| 23 |
+
expect(body.secret).toBeDefined()
|
| 24 |
+
expect(body.secret.length).toBeGreaterThan(10) // full secret shown on creation
|
| 25 |
+
expect(body.enabled).toBe(true)
|
| 26 |
+
})
|
| 27 |
+
|
| 28 |
+
test('POST rejects invalid URL', async ({ request }) => {
|
| 29 |
+
const res = await request.post('/api/webhooks', {
|
| 30 |
+
headers: API_KEY_HEADER,
|
| 31 |
+
data: { name: 'bad-url-hook', url: 'not-a-url' },
|
| 32 |
+
})
|
| 33 |
+
expect(res.status()).toBe(400)
|
| 34 |
+
})
|
| 35 |
+
|
| 36 |
+
test('POST rejects missing name', async ({ request }) => {
|
| 37 |
+
const res = await request.post('/api/webhooks', {
|
| 38 |
+
headers: API_KEY_HEADER,
|
| 39 |
+
data: { url: 'https://example.com/hook' },
|
| 40 |
+
})
|
| 41 |
+
expect(res.status()).toBe(400)
|
| 42 |
+
})
|
| 43 |
+
|
| 44 |
+
// ββ GET /api/webhooks ββββββββββββββββββββββββ
|
| 45 |
+
|
| 46 |
+
test('GET returns webhooks with masked secrets', async ({ request }) => {
|
| 47 |
+
const { id } = await createTestWebhook(request)
|
| 48 |
+
cleanup.push(id)
|
| 49 |
+
|
| 50 |
+
const res = await request.get('/api/webhooks', { headers: API_KEY_HEADER })
|
| 51 |
+
expect(res.status()).toBe(200)
|
| 52 |
+
const body = await res.json()
|
| 53 |
+
expect(body.webhooks).toBeDefined()
|
| 54 |
+
expect(Array.isArray(body.webhooks)).toBe(true)
|
| 55 |
+
|
| 56 |
+
// Secrets should be masked in list response
|
| 57 |
+
const found = body.webhooks.find((w: any) => w.id === id)
|
| 58 |
+
expect(found).toBeDefined()
|
| 59 |
+
expect(found.secret).toContain('β’β’β’β’β’β’')
|
| 60 |
+
})
|
| 61 |
+
|
| 62 |
+
// ββ PUT /api/webhooks ββββββββββββββββββββββββ
|
| 63 |
+
|
| 64 |
+
test('PUT updates webhook name', async ({ request }) => {
|
| 65 |
+
const { id } = await createTestWebhook(request)
|
| 66 |
+
cleanup.push(id)
|
| 67 |
+
|
| 68 |
+
const res = await request.put('/api/webhooks', {
|
| 69 |
+
headers: API_KEY_HEADER,
|
| 70 |
+
data: { id, name: 'updated-hook' },
|
| 71 |
+
})
|
| 72 |
+
expect(res.status()).toBe(200)
|
| 73 |
+
const body = await res.json()
|
| 74 |
+
expect(body.success).toBe(true)
|
| 75 |
+
})
|
| 76 |
+
|
| 77 |
+
test('PUT regenerates secret', async ({ request }) => {
|
| 78 |
+
const { id } = await createTestWebhook(request)
|
| 79 |
+
cleanup.push(id)
|
| 80 |
+
|
| 81 |
+
const res = await request.put('/api/webhooks', {
|
| 82 |
+
headers: API_KEY_HEADER,
|
| 83 |
+
data: { id, regenerate_secret: true },
|
| 84 |
+
})
|
| 85 |
+
expect(res.status()).toBe(200)
|
| 86 |
+
const body = await res.json()
|
| 87 |
+
expect(body.secret).toBeDefined()
|
| 88 |
+
expect(body.secret.length).toBeGreaterThan(10)
|
| 89 |
+
})
|
| 90 |
+
|
| 91 |
+
test('PUT returns 404 for missing webhook', async ({ request }) => {
|
| 92 |
+
const res = await request.put('/api/webhooks', {
|
| 93 |
+
headers: API_KEY_HEADER,
|
| 94 |
+
data: { id: 999999, name: 'nope' },
|
| 95 |
+
})
|
| 96 |
+
expect(res.status()).toBe(404)
|
| 97 |
+
})
|
| 98 |
+
|
| 99 |
+
// ββ DELETE /api/webhooks βββββββββββββββββββββ
|
| 100 |
+
|
| 101 |
+
test('DELETE removes webhook', async ({ request }) => {
|
| 102 |
+
const { id } = await createTestWebhook(request)
|
| 103 |
+
|
| 104 |
+
const res = await request.delete('/api/webhooks', {
|
| 105 |
+
headers: API_KEY_HEADER,
|
| 106 |
+
data: { id },
|
| 107 |
+
})
|
| 108 |
+
expect(res.status()).toBe(200)
|
| 109 |
+
const body = await res.json()
|
| 110 |
+
expect(body.success).toBe(true)
|
| 111 |
+
})
|
| 112 |
+
|
| 113 |
+
// ββ Full lifecycle βββββββββββββββββββββββββββ
|
| 114 |
+
|
| 115 |
+
test('full lifecycle: create β read (masked) β update β delete', async ({ request }) => {
|
| 116 |
+
// Create
|
| 117 |
+
const { id, body: createBody } = await createTestWebhook(request)
|
| 118 |
+
const fullSecret = createBody.secret
|
| 119 |
+
expect(fullSecret.length).toBeGreaterThan(10)
|
| 120 |
+
|
| 121 |
+
// Read (secret should be masked)
|
| 122 |
+
const listRes = await request.get('/api/webhooks', { headers: API_KEY_HEADER })
|
| 123 |
+
const listBody = await listRes.json()
|
| 124 |
+
const found = listBody.webhooks.find((w: any) => w.id === id)
|
| 125 |
+
expect(found.secret).toContain('β’β’β’β’β’β’')
|
| 126 |
+
expect(found.secret).not.toBe(fullSecret)
|
| 127 |
+
|
| 128 |
+
// Update
|
| 129 |
+
const updateRes = await request.put('/api/webhooks', {
|
| 130 |
+
headers: API_KEY_HEADER,
|
| 131 |
+
data: { id, name: 'lifecycle-hook' },
|
| 132 |
+
})
|
| 133 |
+
expect(updateRes.status()).toBe(200)
|
| 134 |
+
|
| 135 |
+
// Delete
|
| 136 |
+
const deleteRes = await request.delete('/api/webhooks', {
|
| 137 |
+
headers: API_KEY_HEADER,
|
| 138 |
+
data: { id },
|
| 139 |
+
})
|
| 140 |
+
expect(deleteRes.status()).toBe(200)
|
| 141 |
+
})
|
| 142 |
+
})
|
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { test, expect } from '@playwright/test'
|
| 2 |
+
import { API_KEY_HEADER, createTestWorkflow, deleteTestWorkflow } from './helpers'
|
| 3 |
+
|
| 4 |
+
test.describe('Workflows CRUD', () => {
|
| 5 |
+
const cleanup: number[] = []
|
| 6 |
+
|
| 7 |
+
test.afterEach(async ({ request }) => {
|
| 8 |
+
for (const id of cleanup) {
|
| 9 |
+
await deleteTestWorkflow(request, id).catch(() => {})
|
| 10 |
+
}
|
| 11 |
+
cleanup.length = 0
|
| 12 |
+
})
|
| 13 |
+
|
| 14 |
+
// ββ POST /api/workflows ββββββββββββββββββββββ
|
| 15 |
+
|
| 16 |
+
test('POST creates workflow template', async ({ request }) => {
|
| 17 |
+
const { id, res, body } = await createTestWorkflow(request)
|
| 18 |
+
cleanup.push(id)
|
| 19 |
+
|
| 20 |
+
expect(res.status()).toBe(201)
|
| 21 |
+
expect(body.template).toBeDefined()
|
| 22 |
+
expect(body.template.name).toContain('e2e-wf-')
|
| 23 |
+
expect(body.template.task_prompt).toBe('Test prompt for e2e')
|
| 24 |
+
expect(body.template.model).toBe('sonnet')
|
| 25 |
+
})
|
| 26 |
+
|
| 27 |
+
test('POST rejects missing name', async ({ request }) => {
|
| 28 |
+
const res = await request.post('/api/workflows', {
|
| 29 |
+
headers: API_KEY_HEADER,
|
| 30 |
+
data: { task_prompt: 'prompt only' },
|
| 31 |
+
})
|
| 32 |
+
expect(res.status()).toBe(400)
|
| 33 |
+
})
|
| 34 |
+
|
| 35 |
+
test('POST rejects missing task_prompt', async ({ request }) => {
|
| 36 |
+
const res = await request.post('/api/workflows', {
|
| 37 |
+
headers: API_KEY_HEADER,
|
| 38 |
+
data: { name: 'name only' },
|
| 39 |
+
})
|
| 40 |
+
expect(res.status()).toBe(400)
|
| 41 |
+
})
|
| 42 |
+
|
| 43 |
+
// ββ GET /api/workflows βββββββββββββββββββββββ
|
| 44 |
+
|
| 45 |
+
test('GET returns templates array', async ({ request }) => {
|
| 46 |
+
const { id } = await createTestWorkflow(request)
|
| 47 |
+
cleanup.push(id)
|
| 48 |
+
|
| 49 |
+
const res = await request.get('/api/workflows', { headers: API_KEY_HEADER })
|
| 50 |
+
expect(res.status()).toBe(200)
|
| 51 |
+
const body = await res.json()
|
| 52 |
+
expect(body.templates).toBeDefined()
|
| 53 |
+
expect(Array.isArray(body.templates)).toBe(true)
|
| 54 |
+
})
|
| 55 |
+
|
| 56 |
+
// ββ PUT /api/workflows βββββββββββββββββββββββ
|
| 57 |
+
|
| 58 |
+
test('PUT updates template fields', async ({ request }) => {
|
| 59 |
+
const { id } = await createTestWorkflow(request)
|
| 60 |
+
cleanup.push(id)
|
| 61 |
+
|
| 62 |
+
const res = await request.put('/api/workflows', {
|
| 63 |
+
headers: API_KEY_HEADER,
|
| 64 |
+
data: { id, name: 'updated-wf-name', description: 'Updated desc' },
|
| 65 |
+
})
|
| 66 |
+
expect(res.status()).toBe(200)
|
| 67 |
+
const body = await res.json()
|
| 68 |
+
expect(body.template.name).toBe('updated-wf-name')
|
| 69 |
+
expect(body.template.description).toBe('Updated desc')
|
| 70 |
+
})
|
| 71 |
+
|
| 72 |
+
test('PUT returns 404 for missing template', async ({ request }) => {
|
| 73 |
+
const res = await request.put('/api/workflows', {
|
| 74 |
+
headers: API_KEY_HEADER,
|
| 75 |
+
data: { id: 999999, name: 'nope' },
|
| 76 |
+
})
|
| 77 |
+
expect(res.status()).toBe(404)
|
| 78 |
+
})
|
| 79 |
+
|
| 80 |
+
// ββ DELETE /api/workflows ββββββββββββββββββββ
|
| 81 |
+
|
| 82 |
+
test('DELETE removes template', async ({ request }) => {
|
| 83 |
+
const { id } = await createTestWorkflow(request)
|
| 84 |
+
|
| 85 |
+
const res = await request.delete('/api/workflows', {
|
| 86 |
+
headers: API_KEY_HEADER,
|
| 87 |
+
data: { id },
|
| 88 |
+
})
|
| 89 |
+
expect(res.status()).toBe(200)
|
| 90 |
+
const body = await res.json()
|
| 91 |
+
expect(body.success).toBe(true)
|
| 92 |
+
})
|
| 93 |
+
|
| 94 |
+
// ββ Full lifecycle βββββββββββββββββββββββββββ
|
| 95 |
+
|
| 96 |
+
test('full lifecycle: create β list β update β delete', async ({ request }) => {
|
| 97 |
+
// Create
|
| 98 |
+
const { id, name, res: createRes } = await createTestWorkflow(request)
|
| 99 |
+
expect(createRes.status()).toBe(201)
|
| 100 |
+
|
| 101 |
+
// List
|
| 102 |
+
const listRes = await request.get('/api/workflows', { headers: API_KEY_HEADER })
|
| 103 |
+
const listBody = await listRes.json()
|
| 104 |
+
expect(listBody.templates.some((t: any) => t.id === id)).toBe(true)
|
| 105 |
+
|
| 106 |
+
// Update
|
| 107 |
+
const updateRes = await request.put('/api/workflows', {
|
| 108 |
+
headers: API_KEY_HEADER,
|
| 109 |
+
data: { id, description: 'lifecycle update' },
|
| 110 |
+
})
|
| 111 |
+
expect(updateRes.status()).toBe(200)
|
| 112 |
+
|
| 113 |
+
// Delete
|
| 114 |
+
const deleteRes = await request.delete('/api/workflows', {
|
| 115 |
+
headers: API_KEY_HEADER,
|
| 116 |
+
data: { id },
|
| 117 |
+
})
|
| 118 |
+
expect(deleteRes.status()).toBe(200)
|
| 119 |
+
})
|
| 120 |
+
})
|