build: /create-qa-plan data-models
Some checks are pending
ci/woodpecker/push/woodpecker Pipeline is pending

This commit is contained in:
rdev-worker 2026-02-11 05:13:35 +00:00
parent 3fd8f99b3e
commit c05dac1171
2 changed files with 217 additions and 1 deletions

View File

@ -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

View File

@ -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 |