From c05dac11711448a1e6bc6d52b30b6160ac7c8242 Mon Sep 17 00:00:00 2001 From: rdev-worker Date: Wed, 11 Feb 2026 05:13:35 +0000 Subject: [PATCH] build: /create-qa-plan data-models --- .sdlc/features/data-models/manifest.yaml | 2 +- .sdlc/features/data-models/qa-plan.md | 216 +++++++++++++++++++++++ 2 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 .sdlc/features/data-models/qa-plan.md diff --git a/.sdlc/features/data-models/manifest.yaml b/.sdlc/features/data-models/manifest.yaml index db37c41..2507efa 100644 --- a/.sdlc/features/data-models/manifest.yaml +++ b/.sdlc/features/data-models/manifest.yaml @@ -18,7 +18,7 @@ artifacts: approved_by: user approved_at: 2026-02-11T05:04:37.331925447Z qa_plan: - status: pending + status: draft path: qa-plan.md qa_results: status: pending diff --git a/.sdlc/features/data-models/qa-plan.md b/.sdlc/features/data-models/qa-plan.md new file mode 100644 index 0000000..97f763b --- /dev/null +++ b/.sdlc/features/data-models/qa-plan.md @@ -0,0 +1,216 @@ +# QA Plan: Core Data Models & Persistence + +## Test Scenarios + +### Happy Path + +| ID | Scenario | Input | Expected Output | Derived From | +|----|----------|-------|-----------------|--------------| +| HP-1 | Create a project with valid name and description | `POST /projects` `{"name":"Alpha","description":"My project"}` | 201 Created; `{data: {id: UUID, name: "Alpha", description: "My project", created_at, updated_at}}` | AC: Projects CRUD | +| HP-2 | List all projects | `GET /projects` (with 2+ projects created) | 200 OK; `{data: [{...}, {...}]}` | AC: Projects CRUD | +| HP-3 | Get a project by ID | `GET /projects/{id}` | 200 OK; `{data: {id, name, description, created_at, updated_at}}` | AC: Projects CRUD | +| HP-4 | Update a project | `PUT /projects/{id}` `{"name":"Beta","description":"Updated"}` | 200 OK; updated fields reflected | AC: Projects CRUD | +| HP-5 | Delete a project | `DELETE /projects/{id}` | 204 No Content | AC: Projects CRUD | +| HP-6 | Create a task in a project | `POST /projects/{projectId}/tasks` `{"title":"Fix bug","status":"open","priority":"high"}` | 201 Created; `{data: {id, project_id, title, status: "open", priority: "high", ...}}` | AC: Tasks CRUD | +| HP-7 | List tasks in a project | `GET /projects/{projectId}/tasks` | 200 OK; `{data: [tasks...]}` | AC: Tasks CRUD | +| HP-8 | Get a task by ID | `GET /tasks/{id}` | 200 OK; `{data: {id, project_id, title, ...}}` | AC: Tasks CRUD | +| HP-9 | Update a task | `PUT /tasks/{id}` `{"title":"Updated","status":"done","priority":"low"}` | 200 OK; updated fields reflected | AC: Tasks CRUD | +| HP-10 | Delete a task | `DELETE /tasks/{id}` | 204 No Content | AC: Tasks CRUD | +| HP-11 | Filter tasks by status | `GET /projects/{projectId}/tasks?status=open` | 200 OK; only tasks with status "open" | AC: Tasks filtering | +| HP-12 | Filter tasks by priority | `GET /projects/{projectId}/tasks?priority=high` | 200 OK; only tasks with priority "high" | AC: Tasks filtering | +| HP-13 | Filter tasks by status AND priority | `GET /projects/{projectId}/tasks?status=open&priority=critical` | 200 OK; tasks matching both filters | AC: Tasks filtering | +| HP-14 | Create a label | `POST /labels` `{"name":"Bug","color":"#FF5733"}` | 201 Created; `{data: {id, name: "Bug", color: "#FF5733", created_at}}` | AC: Labels CRUD | +| HP-15 | Create a label without color | `POST /labels` `{"name":"Feature"}` | 201 Created; color is empty/null | AC: Labels color optional | +| HP-16 | List all labels | `GET /labels` | 200 OK; `{data: [labels...]}` | AC: Labels CRUD | +| HP-17 | Get a label by ID | `GET /labels/{id}` | 200 OK; `{data: {id, name, color, created_at}}` | AC: Labels CRUD | +| HP-18 | Update a label | `PUT /labels/{id}` `{"name":"Enhancement","color":"#00FF00"}` | 200 OK; updated fields reflected | AC: Labels CRUD | +| HP-19 | Delete a label | `DELETE /labels/{id}` | 204 No Content | AC: Labels CRUD | +| HP-20 | Attach a label to a task | `POST /tasks/{taskId}/labels/{labelId}` | 201 Created or 204 No Content | AC: Task-Label join | +| HP-21 | List labels for a task | `GET /tasks/{taskId}/labels` | 200 OK; `{data: [labels...]}` | AC: Task-Label join | +| HP-22 | Detach a label from a task | `DELETE /tasks/{taskId}/labels/{labelId}` | 204 No Content | AC: Task-Label join | +| HP-23 | Assign a user to a task | `POST /tasks/{taskId}/assignments` `{"user_id":"user-123"}` | 201 Created; `{data: {id, task_id, user_id: "user-123", assigned_at}}` | AC: Assignments CRUD | +| HP-24 | List assignments for a task | `GET /tasks/{taskId}/assignments` | 200 OK; `{data: [assignments...]}` | AC: Assignments CRUD | +| HP-25 | Remove an assignment | `DELETE /tasks/{taskId}/assignments/{assignmentId}` | 204 No Content | AC: Assignments CRUD | +| HP-26 | Task defaults status=open, priority=medium | `POST /projects/{projectId}/tasks` `{"title":"No defaults"}` (omit status/priority) | 201 Created; status="open", priority="medium" | AC: Tasks status/priority enums | +| HP-27 | All task statuses accepted | `POST` with each of open, in_progress, done, cancelled | 201 Created for each | AC: Tasks status enum | +| HP-28 | All task priorities accepted | `POST` with each of low, medium, high, critical | 201 Created for each | AC: Tasks priority enum | + +### Edge Cases + +| ID | Scenario | Input | Expected Output | Derived From | +|----|----------|-------|-----------------|--------------| +| EC-1 | Project name exactly 1 character | `POST /projects` `{"name":"A"}` | 201 Created | AC: Project name 1-100 chars | +| EC-2 | Project name exactly 100 characters | `POST /projects` `{"name":"<100 chars>"}` | 201 Created | AC: Project name 1-100 chars | +| EC-3 | Project description exactly 500 characters | `POST /projects` `{"name":"X","description":"<500 chars>"}` | 201 Created | AC: Description max 500 | +| EC-4 | Task title exactly 1 character | `POST /projects/{id}/tasks` `{"title":"X"}` | 201 Created | AC: Task title 1-200 chars | +| EC-5 | Task title exactly 200 characters | `POST /projects/{id}/tasks` `{"title":"<200 chars>"}` | 201 Created | AC: Task title 1-200 chars | +| EC-6 | Task description exactly 2000 characters | `POST /projects/{id}/tasks` `{"title":"T","description":"<2000 chars>"}` | 201 Created | AC: Description max 2000 | +| EC-7 | Label name exactly 1 character | `POST /labels` `{"name":"X"}` | 201 Created | AC: Label name 1-50 chars | +| EC-8 | Label name exactly 50 characters | `POST /labels` `{"name":"<50 chars>"}` | 201 Created | AC: Label name 1-50 chars | +| EC-9 | Label color with lowercase hex | `POST /labels` `{"name":"L","color":"#ff5733"}` | 201 Created | AC: Color hex format | +| EC-10 | Label color with uppercase hex | `POST /labels` `{"name":"L2","color":"#FF5733"}` | 201 Created | AC: Color hex format | +| EC-11 | Label color with mixed case hex | `POST /labels` `{"name":"L3","color":"#aAbBcC"}` | 201 Created | AC: Color hex format | +| EC-12 | Delete project cascades to tasks | Create project → create task → delete project → GET task | 404 Not Found for the task | AC: Cascade delete | +| EC-13 | Delete project cascades to task-labels | Create project → task → label → attach → delete project → GET task labels | 404 or empty | AC: Cascade delete | +| EC-14 | Delete project cascades to assignments | Create project → task → assign → delete project → GET assignments | 404 or empty | AC: Cascade delete | +| EC-15 | Delete label cascades to task-labels | Create label → attach to task → delete label → list task labels | Label no longer in list | AC: Label cascade | +| EC-16 | Delete task cascades to assignments | Create task → assign user → delete task → GET assignment | 404 | AC: Implicit cascade | +| EC-17 | Delete task cascades to task-labels | Create task → attach label → delete task → check task-label | Association removed | AC: Implicit cascade | +| EC-18 | List tasks with no filters returns all | `GET /projects/{id}/tasks` (no query params) | All tasks for project | AC: Tasks filtering | +| EC-19 | List tasks with non-matching filter | `GET /projects/{id}/tasks?status=cancelled` (no cancelled tasks) | 200 OK; `{data: []}` | AC: Tasks filtering | +| EC-20 | List projects when empty | `GET /projects` (no projects created) | 200 OK; `{data: []}` | AC: Projects list | +| EC-21 | List labels when empty | `GET /labels` (no labels created) | 200 OK; `{data: []}` | AC: Labels list | +| EC-22 | Assign user with opaque string ID | `POST /tasks/{id}/assignments` `{"user_id":"user@email.com"}` | 201 Created | AC: user_id is opaque string | +| EC-23 | Assign user with UUID-format user_id | `POST /tasks/{id}/assignments` `{"user_id":"550e8400-e29b-41d4-a716-446655440000"}` | 201 Created | AC: user_id flexibility | +| EC-24 | Multiple labels on same task | Attach 3 different labels to one task → list | 200 OK; all 3 labels returned | AC: Task-Label many-to-many | +| EC-25 | Same label on multiple tasks | Attach label to 3 different tasks | All 3 attachments succeed | AC: Labels are global | +| EC-26 | Multiple users assigned to same task | Assign 3 users to one task → list | 200 OK; all 3 assignments returned | AC: Assignments | +| EC-27 | Project with empty description | `POST /projects` `{"name":"No Desc"}` (omit description) | 201 Created; description is empty/null | AC: Description optional | +| EC-28 | Task with empty description | `POST /projects/{id}/tasks` `{"title":"T"}` (omit description) | 201 Created; description is empty/null | AC: Description optional | + +### Error Cases + +| ID | Scenario | Input | Expected Output | Derived From | +|----|----------|-------|-----------------|--------------| +| ER-1 | Create project with empty name | `POST /projects` `{"name":""}` | 400 Bad Request | AC: Project name required | +| ER-2 | Create project with name > 100 chars | `POST /projects` `{"name":"<101 chars>"}` | 400 Bad Request | AC: Project name max 100 | +| ER-3 | Create project with description > 500 chars | `POST /projects` `{"name":"X","description":"<501 chars>"}` | 400 Bad Request | AC: Description max 500 | +| ER-4 | Create project with duplicate name | `POST /projects` `{"name":"Existing"}` (name already exists) | 409 Conflict | AC: Duplicate project name | +| ER-5 | Update project with duplicate name | `PUT /projects/{id}` with name matching another project | 409 Conflict | AC: Duplicate project name | +| ER-6 | Get non-existent project | `GET /projects/{random-uuid}` | 404 Not Found | AC: Projects CRUD | +| ER-7 | Update non-existent project | `PUT /projects/{random-uuid}` | 404 Not Found | AC: Projects CRUD | +| ER-8 | Delete non-existent project | `DELETE /projects/{random-uuid}` | 404 Not Found | AC: Projects CRUD | +| ER-9 | Get project with invalid ID format | `GET /projects/not-a-uuid` | 400 Bad Request | AC: UUID primary keys | +| ER-10 | Create task with empty title | `POST /projects/{id}/tasks` `{"title":""}` | 400 Bad Request | AC: Task title required | +| ER-11 | Create task with title > 200 chars | `POST /projects/{id}/tasks` `{"title":"<201 chars>"}` | 400 Bad Request | AC: Task title max 200 | +| ER-12 | Create task with description > 2000 chars | `POST /projects/{id}/tasks` `{"title":"T","description":"<2001 chars>"}` | 400 Bad Request | AC: Description max 2000 | +| ER-13 | Create task with invalid status | `POST /projects/{id}/tasks` `{"title":"T","status":"invalid"}` | 400 Bad Request | AC: Status enum | +| ER-14 | Create task with invalid priority | `POST /projects/{id}/tasks` `{"title":"T","priority":"invalid"}` | 400 Bad Request | AC: Priority enum | +| ER-15 | Create task for non-existent project | `POST /projects/{random-uuid}/tasks` `{"title":"T"}` | 404 Not Found | AC: Task project_id validated | +| ER-16 | Get non-existent task | `GET /tasks/{random-uuid}` | 404 Not Found | AC: Tasks CRUD | +| ER-17 | Update non-existent task | `PUT /tasks/{random-uuid}` | 404 Not Found | AC: Tasks CRUD | +| ER-18 | Delete non-existent task | `DELETE /tasks/{random-uuid}` | 404 Not Found | AC: Tasks CRUD | +| ER-19 | Create label with empty name | `POST /labels` `{"name":""}` | 400 Bad Request | AC: Label name required | +| ER-20 | Create label with name > 50 chars | `POST /labels` `{"name":"<51 chars>"}` | 400 Bad Request | AC: Label name max 50 | +| ER-21 | Create label with invalid color format | `POST /labels` `{"name":"L","color":"red"}` | 400 Bad Request | AC: Color hex format | +| ER-22 | Create label with invalid hex (too short) | `POST /labels` `{"name":"L","color":"#FFF"}` | 400 Bad Request | AC: Color hex format | +| ER-23 | Create label with invalid hex (no #) | `POST /labels` `{"name":"L","color":"FF5733"}` | 400 Bad Request | AC: Color hex format | +| ER-24 | Create label with duplicate name | `POST /labels` `{"name":"Existing"}` (name already exists) | 409 Conflict | AC: Duplicate label name | +| ER-25 | Update label with duplicate name | `PUT /labels/{id}` with name matching another label | 409 Conflict | AC: Duplicate label name | +| ER-26 | Get non-existent label | `GET /labels/{random-uuid}` | 404 Not Found | AC: Labels CRUD | +| ER-27 | Attach non-existent label to task | `POST /tasks/{taskId}/labels/{random-uuid}` | 404 Not Found | AC: Assign non-existent label | +| ER-28 | Attach label to non-existent task | `POST /tasks/{random-uuid}/labels/{labelId}` | 404 Not Found | AC: Assign non-existent task | +| ER-29 | Duplicate task-label association | Attach same label to same task twice | 409 Conflict | AC: Duplicate task-label | +| ER-30 | Detach label not attached to task | `DELETE /tasks/{taskId}/labels/{labelId}` (not attached) | 404 Not Found | AC: Task-Label CRUD | +| ER-31 | Assign user to non-existent task | `POST /tasks/{random-uuid}/assignments` `{"user_id":"u1"}` | 404 Not Found | AC: Assign non-existent task | +| ER-32 | Duplicate user assignment to same task | Assign same user_id to same task twice | 409 Conflict | AC: Duplicate assignment | +| ER-33 | Remove non-existent assignment | `DELETE /tasks/{taskId}/assignments/{random-uuid}` | 404 Not Found | AC: Assignments CRUD | +| ER-34 | Create project with missing body | `POST /projects` (no body or `{}`) | 400 Bad Request | AC: Name required | +| ER-35 | Create task with missing body | `POST /projects/{id}/tasks` (no body or `{}`) | 400 Bad Request | AC: Title required | +| ER-36 | Create assignment with missing user_id | `POST /tasks/{id}/assignments` `{}` | 400 Bad Request | AC: user_id required | +| ER-37 | Create project with invalid JSON | `POST /projects` `{invalid json}` | 400 Bad Request | AC: Request binding | +| ER-38 | Write endpoint without auth (when enabled) | `POST /projects` without auth token (AUTH_ENABLED=true) | 401 Unauthorized | AC: Auth on writes | + +## Test Data Requirements + +### Fixtures +- **Seed project**: A pre-created project with known UUID for task creation tests +- **Seed tasks**: 3-5 tasks with varying status/priority for filter testing +- **Seed labels**: 2-3 labels for attachment testing +- **Seed assignments**: 1-2 assignments for listing/removal tests + +### Mocks +- **Mock repositories**: Each service test uses local mock structs implementing port interfaces (following `example_test.go` pattern) +- **Mock services**: Each handler test uses mock service implementations +- **No-op logger**: All tests use `logging.Nop()` for structured logger + +### Test Isolation +- Each unit test creates its own mock instances — no shared mutable state +- Handler tests use `httptest.NewRecorder()` and fresh chi routers per test case +- Domain tests are pure functions with no external dependencies + +## Integration Test Plan + +### Cross-Layer Integration (via handler tests with mocks) +1. **Request → Handler → Service → Response**: Verify the full handler pipeline processes requests, calls service methods, and returns correct HTTP status codes and response envelopes +2. **Validation → Error Mapping**: Confirm that `app.BindAndValidate()` errors produce 400 responses and domain errors map to correct HTTP status codes (404, 409) +3. **URL Parameter Extraction**: Verify `chi.URLParam()` correctly extracts `{id}`, `{projectId}`, `{taskId}`, `{labelId}`, `{assignmentId}` from route patterns + +### Cross-Entity Integration (via service tests with mocks) +4. **Task → Project FK validation**: TaskService.Create calls ProjectRepository.Get to validate project existence before creating task +5. **AssociationService → Task + Label validation**: AttachLabel verifies both task and label exist before creating the association +6. **AssociationService → Task validation for assignments**: AssignUser verifies task exists before creating assignment + +### Database Schema Integration (manual/migration verification) +7. **Migration ordering**: Migrations execute in order (001 → 002 → 003) without errors on a fresh database +8. **FK constraint enforcement**: Inserting a task with non-existent project_id fails at the DB level +9. **Cascade delete verification**: Deleting a project removes all its tasks, their task_labels, and their assignments +10. **Unique constraint enforcement**: Inserting duplicate project names or label names fails at the DB level + +### Route Registration Integration +11. **All endpoints routable**: Every endpoint defined in the spec is registered in `routes.go` and responds (not 404) +12. **Auth middleware on write routes**: POST/PUT/DELETE routes have auth middleware applied when AUTH_ENABLED=true +13. **Public read routes**: GET routes are accessible without authentication + +## Performance Considerations + +### Load Expectations (v1) +- Small data volumes expected (no pagination per spec) +- Target: < 50ms response time for all CRUD operations under normal load +- Target: < 100ms for filtered task listings + +### Index Verification +- Confirm indexes exist on: `tasks.project_id`, `tasks.status`, `tasks.priority`, `task_labels.label_id`, `assignments.task_id`, `assignments.user_id` +- Verify unique indexes on `projects.name` and `labels.name` (implied by UNIQUE constraint) + +### Benchmarks to Consider +- Task listing with status filter across 100+ tasks in a project (verify index usage) +- Cascade delete of a project with 50+ tasks, each with labels and assignments + +### Connection Pool +- Verify `pkg/database` pool settings (25 max open, 5 idle) are adequate for expected concurrent requests +- No additional caching layer required for v1 + +## Manual Verification Steps + +1. **Migration on fresh database**: Run `database.MustRunMigrations()` on an empty PostgreSQL instance and verify all tables, constraints, and indexes are created correctly +2. **OpenAPI spec rendering**: Verify `/docs` endpoint renders the Scalar UI with all new schemas and endpoint documentation +3. **Response envelope format**: Manually inspect a few responses to confirm `{data, meta}` envelope structure +4. **Cascade delete end-to-end**: Create a project → tasks → labels → attach labels → assign users → delete project → verify all related data is gone +5. **Existing Example entity unaffected**: Verify the Example CRUD endpoints still work correctly after all new routes are added +6. **Main.go wiring**: Verify the application starts successfully with `DATABASE_URL` configured, runs migrations, and serves all endpoints +7. **SQL migration idempotency**: Run migrations twice and verify no errors on second run (tracked by `schema_migrations` table) + +## Acceptance Criteria Coverage Matrix + +| Acceptance Criterion | Test IDs | +|---------------------|----------| +| Project CRUD endpoints | HP-1, HP-2, HP-3, HP-4, HP-5 | +| Project name validation (1-100 chars) | EC-1, EC-2, ER-1, ER-2 | +| Project description validation (max 500) | EC-3, ER-3 | +| Duplicate project name → 409 | ER-4, ER-5 | +| Project cascade delete | EC-12, EC-13, EC-14 | +| Task CRUD endpoints | HP-6, HP-7, HP-8, HP-9, HP-10 | +| Task status enum | HP-27, ER-13 | +| Task priority enum | HP-28, ER-14 | +| Task title validation (1-200 chars) | EC-4, EC-5, ER-10, ER-11 | +| Task description validation (max 2000) | EC-6, ER-12 | +| Task default status/priority | HP-26 | +| Task project FK validation | ER-15 | +| Task filtering by status/priority | HP-11, HP-12, HP-13, EC-18, EC-19 | +| Label CRUD endpoints | HP-14, HP-15, HP-16, HP-17, HP-18, HP-19 | +| Label name validation (1-50 chars, unique) | EC-7, EC-8, ER-19, ER-20, ER-24, ER-25 | +| Label color validation (optional, hex) | EC-9, EC-10, EC-11, HP-15, ER-21, ER-22, ER-23 | +| Label cascade delete | EC-15 | +| Task-Label association CRUD | HP-20, HP-21, HP-22 | +| Task-Label duplicate → 409 | ER-29 | +| Task-Label non-existent → 404 | ER-27, ER-28 | +| Assignment CRUD | HP-23, HP-24, HP-25 | +| Assignment duplicate → 409 | ER-32 | +| Assignment non-existent task → 404 | ER-31 | +| UUID primary keys | ER-9 | +| Response envelope pattern | HP-1 through HP-28 (all verify envelope) | +| Error types (httperror) | ER-1 through ER-38 | +| Auth on write endpoints | ER-38 | +| Domain entity constructors/validation | HP-26, HP-27, HP-28 + all ER validation cases | +| Unit tests pass | All HP/EC/ER scenarios implemented as Go tests |