| Suite | Tests | Progress | Pass / Fail / Skip |
|---|---|---|---|
| Auth & Roles | 8 | 0/8 | |
| Programme Setup | 10 | 0/10 | |
| Application Flow | 6 | 0/6 | |
| Document Verification | 8 | 0/8 | |
| Conflict of Interest | 6 | 0/6 | |
| Scoring & Assignment | 8 | 0/8 | |
| Awards & Waitlist | 11 | 0/11 | |
| Contracts | 6 | 0/6 | |
| POPIA & DSR | 10 | 0/10 | |
| Notifications | 5 | 0/5 | |
| Deadline Reminders | 5 | 0/5 | |
| Audit Log | 5 | 0/5 | |
| Security | 10 | 0/10 |
| ID | Type | Test Description | Key Assertion | Status | Notes |
|---|---|---|---|---|---|
| AUTH-001 | Feature | Registration creates user with email_verified_at=null | 201. User in DB. email_verified_at null. | ||
| AUTH-002 | Feature | Duplicate email registration rejected | 422. No duplicate record. | ||
| AUTH-003 | Feature | Login with unverified email blocked | Redirect to verification prompt. No session. | ||
| AUTH-004 | Feature | Verification link sets email_verified_at and grants portal access | email_verified_at not null. User lands on correct portal. | ||
| AUTH-005 | Feature | Learner blocked from /admin/dashboard | 403 or redirect. Admin content not returned. | ||
| AUTH-006 | Feature | Reviewer blocked from /learner/dashboard | 403 or redirect. | ||
| AUTH-007 | Feature | Super admin toggles user inactive — user cannot log in | is_active=false. Login returns 403. | ||
| AUTH-008 | Feature | Super admin changes user role — next login reflects new role | role updated. User lands on new portal. |
| ID | Type | Test Description | Key Assertion | Status | Notes |
|---|---|---|---|---|---|
| PROG-001 | Feature | Create programme with all fields — record persists | 201. Programme in DB with correct fields. | ||
| PROG-002 | Feature | Eligible learner sees restricted programme in listing | Restricted programme ID in response. | ||
| PROG-003 | Feature | Ineligible learner does NOT see restricted programme | Restricted programme ID absent from response. | ||
| PROG-004 | Feature | Universal programme returned to all learners | Programme ID in both eligible and ineligible responses. | ||
| PROG-005 | Unit | Conditional form rule serialised correctly | Rule JSON matches expected structure. | ||
| PROG-006 | Feature | Published form rejects missing required field with 422 | 422. Error keyed to missing field. | ||
| PROG-007 | Unit | Rubric weights not summing to 100 fails validation | ValidationException thrown. | ||
| PROG-008 | Feature | Notification toggle for programme A does not affect programme B | Programme B config unchanged. | ||
| PROG-009 | Feature | Auto-assignment fires on approved_for_review transition | Assignment created. Reviewer notified. | ||
| PROG-010 | Unit | Contract template merge fields resolve — no placeholders in PDF output | assertStringNotContainsString("{{learner_name}}", $pdf). |
| ID | Type | Test Description | Key Assertion | Status | Notes |
|---|---|---|---|---|---|
| APP-001 | Feature | Draft save and reload — partial answers persisted | status=draft. Form data intact after refetch. | ||
| APP-002 | Feature | Submit transitions status to submitted | status=submitted in DB. | ||
| APP-003 | Feature | ApplicationSubmitted notification dispatched on submit | Notification::assertSentTo($learner, ApplicationSubmitted). | ||
| APP-004 | Unit | Conditional field value not persisted when condition not met at submission | Conditional field null in DB. | ||
| APP-005 | Feature | Duplicate application to same programme blocked | 422/409. Second record not created. | ||
| APP-006 | Feature | Timeline entry created on each status change with actor recorded | timeline count increments. actor_id correct. |
| ID | Type | Test Description | Key Assertion | Status | Notes |
|---|---|---|---|---|---|
| DOCS-001 | Feature | Valid PDF upload: file stored, document status=pending | Storage::assertExists. status=pending. | ||
| DOCS-002 | Feature | Executable file upload (.exe, .php) rejected | 422. File not stored. | ||
| DOCS-003 | Feature | Upload over size limit rejected | 422 with size error. | ||
| DOCS-004 | Feature | Verify document: status updates to verified | document status=verified. | ||
| DOCS-005 | Feature | Reject document: reason persisted | status=rejected. rejection_reason not null. | ||
| DOCS-006 | Feature | Learner can read own rejection reason | GET /documents/{id} returns rejection_reason to owner. | ||
| DOCS-007 | Feature | All 3 docs verified → application auto-transitions to under_review | status=under_review. No admin action required. | ||
| DOCS-008 | Feature | Only 2 of 3 docs verified → status does NOT transition | Application status unchanged after second verification. |
| ID | Type | Test Description | Key Assertion | Status | Notes |
|---|---|---|---|---|---|
| COI-001 | Feature | Scoring screen blocked without COI declaration | 403 or redirect. Scores form not returned. | ||
| COI-002 | Feature | No-conflict declaration unlocks scoring immediately | coi record created. Scoring endpoint 200. | ||
| COI-003 | Feature | Conflict declaration removes app from reviewer queue | App absent from GET /reviewer/queue. | ||
| COI-004 | Feature | Admin sees recused status after conflict declaration | assigned_reviewer_status=recused in admin response. | ||
| COI-005 | Feature | Direct POST to scoring without COI declaration returns 403 | 403. No score record created. | ||
| COI-006 | Unit | Duplicate no-conflict declaration not created (idempotent) | coi_declarations count = 1 for that reviewer+app. |
| ID | Type | Test Description | Key Assertion | Status | Notes |
|---|---|---|---|---|---|
| SCORE-001 | Unit | Weighted total: (merit×0.40)+(need×0.35)+(motivation×0.25) — verified at edge values | Calculated total matches expected for 0, 100, fractional inputs. | ||
| SCORE-002 | Feature | Submit scores locks them — subsequent PUT returns 403 | submitted_at not null. PUT after lock → 403. | ||
| SCORE-003 | Feature | Edit locked scores via PATCH returns 403 and leaves scores unchanged | 403. scores record unchanged. | ||
| SCORE-004 | Feature | Manual reviewer assignment creates record and fires notification | Assignment in DB. Notification::assertSentTo(reviewer). | ||
| SCORE-005 | Feature | Bulk move to approved_for_review fires auto-assignment for each app | One assignment per app. One notification per reviewer. | ||
| SCORE-006 | Feature | Committee recommendation saves without overwriting locked scores | recommendation not null. submitted_at unchanged. | ||
| SCORE-007 | Feature | Shortlist endpoint returns apps ordered by weighted_score DESC | Response sorted descending — confirmed with 3+ known scores. | ||
| SCORE-008 | Feature | Bulk shortlist fires StatusChanged notification per application (not just first) | Notification count = number of apps bulk-shortlisted. |
| ID | Type | Test Description | Key Assertion | Status | Notes |
|---|---|---|---|---|---|
| AWARD-001 | Feature | Award created — AwardOffered notification dispatched | awards record. Notification::assertSentTo(learner, AwardOffered). | ||
| AWARD-002 | Unit | Offer letter PDF: all merge fields resolved — no {{placeholder}} strings | assertStringNotContainsString for all placeholders. | ||
| AWARD-003 | Feature | Accept award → status=accepted, ContractReady notification fires | status=accepted. ContractReady notification sent. | ||
| AWARD-004 | Feature | Decline award → status=declined, irreversible | status=declined. PATCH back to accepted → 422. | ||
| AWARD-005 | Feature | Decline triggers WaitlistPromotionService — first waitlisted learner promoted | Next waitlist entry status=promoted. WaitlistPromoted sent. | ||
| AWARD-006 | Unit | Decline with no waitlisted learner completes without exception | No exception. award status=declined. | ||
| AWARD-007 | Unit | WaitlistPromotionService promotes lowest waitlist_position (not random) | Learner with position=1 promoted, not position=2. | ||
| AWARD-008 | Unit | Race condition: concurrent declines produce exactly one promotion | promoted_count=1 after concurrent execution. No double-promotion. | ||
| AWARD-009 | Feature | WaitlistPromoted notification fires to promoted learner | Notification::assertSentTo(waitlistedLearner, WaitlistPromoted). | ||
| AWARD-010 | Feature | Admin waitlist panel shows promoted_at timestamp after promotion | GET /admin/waitlist: promoted_at not null. | ||
| AWARD-011 | Feature | Bulk status change fires StatusChanged for every affected learner | Notification count = number of apps changed. |
| ID | Type | Test Description | Key Assertion | Status | Notes |
|---|---|---|---|---|---|
| CONTRACT-001 | Feature | Contract generated on award acceptance with correct merge data | contracts record. PDF contains real name, amount, programme. | ||
| CONTRACT-002 | Feature | Sign contract records signed_at and ip_address | signed_at not null. ip_address = request IP. | ||
| CONTRACT-003 | Feature | Signed contract downloadable via authenticated route | 200. Content-Type: application/pdf. | ||
| CONTRACT-004 | Feature | Contract version history records generated → signed with timestamps | contract_versions has two entries with timestamps. | ||
| CONTRACT-005 | Feature | Admin contract search by learner name returns correct record | GET /admin/contracts?search=Thabo returns Thabo's contract. | ||
| CONTRACT-006 | Feature | Signing already-signed contract returns 422 | 422. signed_at unchanged. |
| ID | Type | Test Description | Key Assertion | Status | Notes |
|---|---|---|---|---|---|
| POPIA-001 | Feature | Learner submits access request — DSR record created with reference number | data_subject_requests record. reference_number not null. | ||
| POPIA-002 | Feature | Admin acknowledges request — status=acknowledged | dsr status=acknowledged. | ||
| POPIA-003 | Feature | Acknowledgement triggers notification to learner | Notification::assertSentTo(learner, DsrAcknowledged). | ||
| POPIA-004 | Feature | Data export JSON contains learner's own profile, applications, documents | JSON keys: personal, academic, financial, applications, documents. | ||
| POPIA-005 | Feature | Data export contains no other learner's records | No records with user_id != $learner->id in export. | ||
| POPIA-006 | Feature | Erasure request creates DSR record with type=erasure | data_subject_requests with type=erasure. | ||
| POPIA-007 | Unit | DataAnonymisationService wipes all PII fields | name, email, id_number, phone, address, financial_need_text all anonymised. | ||
| POPIA-008 | Feature | Learner cannot log in after erasure | Login attempt returns 401/404. | ||
| POPIA-009 | Unit | Erasure report PDF written to storage/erasure-reports/ | Storage::assertExists("erasure-reports/{id}.pdf"). | ||
| POPIA-010 | Feature | Retention policy settings persist after save | retention_policies record updated in DB. |
| ID | Type | Test Description | Key Assertion | Status | Notes |
|---|---|---|---|---|---|
| NOTIFY-001 | Feature | ApplicationObserver fires StatusChanged on single model save | Notification::assertSentTo after $app->save(). | ||
| NOTIFY-002 | Feature | Bulk query builder update does NOT trigger observer | Notification::assertNothingSent() after ->update(). | ||
| NOTIFY-003 | Feature | ApplicationStatusService bulk update fires one notification per learner | Notification count = number of apps updated via service. | ||
| NOTIFY-004 | Feature | Learner marks notification as read — unread_count decrements | read=true. unread_count decremented. | ||
| NOTIFY-005 | Feature | Learner cannot mark another learner's notification as read | 403 on PATCH /notifications/{other_id}. |
| ID | Type | Test Description | Key Assertion | Status | Notes |
|---|---|---|---|---|---|
| REMIND-001 | Feature | --dry-run outputs list, sends zero emails | Mail::assertNothingSent(). Output contains app IDs. | ||
| REMIND-002 | Feature | First run sends reminders to all qualifying applications | Mail::assertSent(DeadlineReminder) for each qualifying app. | ||
| REMIND-003 | Feature | Second run sends zero duplicate emails (idempotency) | Mail count after second run = 0. | ||
| REMIND-004 | Feature | Past-deadline applications do not receive reminders | Mail::assertNotSent for past-deadline apps. | ||
| REMIND-005 | Unit | Command registered in Console/Kernel — php artisan list shows it | Artisan::call resolves. Command present in list. |
| ID | Type | Test Description | Key Assertion | Status | Notes |
|---|---|---|---|---|---|
| AUDIT-001 | Feature | Status change creates audit_log entry with user_id and timestamp | audit_logs record with correct user_id and changed_to. | ||
| AUDIT-002 | Feature | Bulk shortlist creates one audit entry per app, all attributed to programme admin | audit_logs count += n. All entries have same user_id. | ||
| AUDIT-003 | Feature | Erasure execution creates audit entry with action=erasure | audit_logs record with action=erasure, actor=admin, target=learner. | ||
| AUDIT-004 | Feature | Reviewer assignment (auto + manual) each creates audit entry | audit_logs record with action=reviewer_assigned for both paths. | ||
| AUDIT-005 | Feature | Audit log filterable by date range via query params | Response contains only entries within the date range. |
| ID | Type | Test Description | Key Assertion | Status | Notes |
|---|---|---|---|---|---|
| SEC-001 | Feature | Learner A cannot read Learner B's application | GET /applications/{B_id} as A → 403. | ||
| SEC-002 | Feature | Reviewer cannot access unassigned applications | GET /reviewer/applications/{unassigned} → 403. | ||
| SEC-003 | Feature | Sponsor cannot POST/PUT/DELETE any resource | All mutation requests → 403. | ||
| SEC-004 | Feature | Unauthenticated request to protected route returns 401/redirect | 401 or 302 to /login. | ||
| SEC-005 | Feature | POST without CSRF token rejected on non-API routes | 419 CSRF mismatch. | ||
| SEC-006 | Unit | Sensitive fields not mass-assignable (role, is_active, email_verified_at) | $model->fill(["role" => "super_admin"]) does not update role. | ||
| SEC-007 | Feature | Spoofed MIME type upload (.php as .pdf) rejected | 422. File not stored. | ||
| SEC-008 | Feature | Path traversal filename sanitised on upload | Stored filename sanitised. No traversal possible. | ||
| SEC-009 | Feature | Direct POST to scoring endpoint without COI returns 403 | 403. No score record created. | ||
| SEC-010 | Feature | Learner cannot download another learner's contract PDF | GET /contracts/{other}/download → 403. |
| ✓ | Item | Notes |
|---|---|---|
| php artisan migrate:fresh --seed runs without errors | ||
| All 8 seeded accounts created with correct roles | ||
| All foreign key constraints satisfied by seeded state | ||
| No orphaned records produced by seeders | ||
| LearnerProfile seeded for both Thabo and Lerato with complete PII |
| ✓ | Item | Notes |
|---|---|---|
| php artisan route:list: no duplicate or missing routes | ||
| All model factories exist and generate valid records | ||
| All policies registered in AuthServiceProvider | ||
| php artisan config:cache and route:cache run without errors | ||
| No N+1 queries on application list, shortlist, and reviewer queue pages | ||
| All queued jobs implement ShouldQueue | ||
| QUEUE_CONNECTION=sync confirmed in .env.testing |
| ✓ | Item | Notes |
|---|---|---|
| php artisan storage:link creates symlink correctly | ||
| Document uploads accessible via /storage/ URL | ||
| Offer letter PDFs generated under storage/app/public/offer-letters/ | ||
| Signed contract PDFs stored under storage/app/public/contracts/ | ||
| Erasure report PDFs stored under storage/app/public/erasure-reports/ | ||
| storage/app/public writable by the web server process |
| ✓ | Item | Notes |
|---|---|---|
| All notification classes implement toMail() correctly | ||
| All email subjects, from addresses, and body verified in Mailtrap | ||
| No notification silently swallows exceptions | ||
| Bulk update via ApplicationStatusService fires one notification per learner |
| ✓ | Item | Notes |
|---|---|---|
| php artisan test exits with 0 — all tests passing | ||
| No tests marked skipped or incomplete without documented reason | ||
| Coverage ≥80% on: WaitlistPromotionService, DataAnonymisationService, ApplicationStatusService, DeadlineReminderService | ||
| All 97 test cases in this plan have a corresponding test method | ||
| No test uses hardcoded IDs, URLs, or relies on insertion order |
| ✓ | Item | Notes |
|---|---|---|
| All mutating routes are POST/PUT/PATCH/DELETE (no GET mutations) | ||
| Authorization policy exists for every multi-role accessible model | ||
| File upload validation rejects .php, .exe, .sh and other executable types | ||
| No sensitive data committed to version control | ||
| Error pages (404, 403, 500) do not expose stack traces or env variables |
| ✓ | Item | Notes |
|---|---|---|
| Fresh deploy to staging: no 500 errors on first page load for each portal | ||
| Email delivery confirmed — register test account, receive verification email | ||
| Document upload and retrieval confirmed on staging server | ||
| Offer letter PDF generated on staging (real file, not empty) | ||
| php artisan reminders:deadline --dry-run runs without error on staging |
Issues discovered during development that are easy to re-introduce. Review these before touching the relevant code areas.