JTG Bursary Platform — Developer Test Plan

Milestone 1  ·  Tshepo  ·  13 suites  ·  98 test cases  ·  Internal
PHPUnit Feature Tests Unit Tests Security Tests Pre-UAT Checklist
Ready

Test Coverage Overview

Passing
0
Failing
0
Skipped
Pending
98
Total
98
Overall pass rate

Suite summary

SuiteTestsProgressPass / Fail / Skip
Auth & Roles8
0/8
Programme Setup10
0/10
Application Flow6
0/6
Document Verification8
0/8
Conflict of Interest6
0/6
Scoring & Assignment8
0/8
Awards & Waitlist11
0/11
Contracts6
0/6
POPIA & DSR10
0/10
Notifications5
0/5
Deadline Reminders5
0/5
Audit Log5
0/5
Security10
0/10
Filter:

Auth & Roles

0/8
IDTypeTest DescriptionKey AssertionStatusNotes
AUTH-001FeatureRegistration creates user with email_verified_at=null201. User in DB. email_verified_at null.
AUTH-002FeatureDuplicate email registration rejected422. No duplicate record.
AUTH-003FeatureLogin with unverified email blockedRedirect to verification prompt. No session.
AUTH-004FeatureVerification link sets email_verified_at and grants portal accessemail_verified_at not null. User lands on correct portal.
AUTH-005FeatureLearner blocked from /admin/dashboard403 or redirect. Admin content not returned.
AUTH-006FeatureReviewer blocked from /learner/dashboard403 or redirect.
AUTH-007FeatureSuper admin toggles user inactive — user cannot log inis_active=false. Login returns 403.
AUTH-008FeatureSuper admin changes user role — next login reflects new rolerole updated. User lands on new portal.

Programme Setup

0/10
IDTypeTest DescriptionKey AssertionStatusNotes
PROG-001FeatureCreate programme with all fields — record persists201. Programme in DB with correct fields.
PROG-002FeatureEligible learner sees restricted programme in listingRestricted programme ID in response.
PROG-003FeatureIneligible learner does NOT see restricted programmeRestricted programme ID absent from response.
PROG-004FeatureUniversal programme returned to all learnersProgramme ID in both eligible and ineligible responses.
PROG-005UnitConditional form rule serialised correctlyRule JSON matches expected structure.
PROG-006FeaturePublished form rejects missing required field with 422422. Error keyed to missing field.
PROG-007UnitRubric weights not summing to 100 fails validationValidationException thrown.
PROG-008FeatureNotification toggle for programme A does not affect programme BProgramme B config unchanged.
PROG-009FeatureAuto-assignment fires on approved_for_review transitionAssignment created. Reviewer notified.
PROG-010UnitContract template merge fields resolve — no placeholders in PDF outputassertStringNotContainsString("{{learner_name}}", $pdf).

Application Flow

0/6
IDTypeTest DescriptionKey AssertionStatusNotes
APP-001FeatureDraft save and reload — partial answers persistedstatus=draft. Form data intact after refetch.
APP-002FeatureSubmit transitions status to submittedstatus=submitted in DB.
APP-003FeatureApplicationSubmitted notification dispatched on submitNotification::assertSentTo($learner, ApplicationSubmitted).
APP-004UnitConditional field value not persisted when condition not met at submissionConditional field null in DB.
APP-005FeatureDuplicate application to same programme blocked422/409. Second record not created.
APP-006FeatureTimeline entry created on each status change with actor recordedtimeline count increments. actor_id correct.

Document Verification

0/8
IDTypeTest DescriptionKey AssertionStatusNotes
DOCS-001FeatureValid PDF upload: file stored, document status=pendingStorage::assertExists. status=pending.
DOCS-002FeatureExecutable file upload (.exe, .php) rejected422. File not stored.
DOCS-003FeatureUpload over size limit rejected422 with size error.
DOCS-004FeatureVerify document: status updates to verifieddocument status=verified.
DOCS-005FeatureReject document: reason persistedstatus=rejected. rejection_reason not null.
DOCS-006FeatureLearner can read own rejection reasonGET /documents/{id} returns rejection_reason to owner.
DOCS-007FeatureAll 3 docs verified → application auto-transitions to under_reviewstatus=under_review. No admin action required.
DOCS-008FeatureOnly 2 of 3 docs verified → status does NOT transitionApplication status unchanged after second verification.

Conflict of Interest

0/6
IDTypeTest DescriptionKey AssertionStatusNotes
COI-001FeatureScoring screen blocked without COI declaration403 or redirect. Scores form not returned.
COI-002FeatureNo-conflict declaration unlocks scoring immediatelycoi record created. Scoring endpoint 200.
COI-003FeatureConflict declaration removes app from reviewer queueApp absent from GET /reviewer/queue.
COI-004FeatureAdmin sees recused status after conflict declarationassigned_reviewer_status=recused in admin response.
COI-005FeatureDirect POST to scoring without COI declaration returns 403403. No score record created.
COI-006UnitDuplicate no-conflict declaration not created (idempotent)coi_declarations count = 1 for that reviewer+app.

Scoring & Assignment

0/8
IDTypeTest DescriptionKey AssertionStatusNotes
SCORE-001UnitWeighted total: (merit×0.40)+(need×0.35)+(motivation×0.25) — verified at edge valuesCalculated total matches expected for 0, 100, fractional inputs.
SCORE-002FeatureSubmit scores locks them — subsequent PUT returns 403submitted_at not null. PUT after lock → 403.
SCORE-003FeatureEdit locked scores via PATCH returns 403 and leaves scores unchanged403. scores record unchanged.
SCORE-004FeatureManual reviewer assignment creates record and fires notificationAssignment in DB. Notification::assertSentTo(reviewer).
SCORE-005FeatureBulk move to approved_for_review fires auto-assignment for each appOne assignment per app. One notification per reviewer.
SCORE-006FeatureCommittee recommendation saves without overwriting locked scoresrecommendation not null. submitted_at unchanged.
SCORE-007FeatureShortlist endpoint returns apps ordered by weighted_score DESCResponse sorted descending — confirmed with 3+ known scores.
SCORE-008FeatureBulk shortlist fires StatusChanged notification per application (not just first)Notification count = number of apps bulk-shortlisted.

Awards & Waitlist

0/11
IDTypeTest DescriptionKey AssertionStatusNotes
AWARD-001FeatureAward created — AwardOffered notification dispatchedawards record. Notification::assertSentTo(learner, AwardOffered).
AWARD-002UnitOffer letter PDF: all merge fields resolved — no {{placeholder}} stringsassertStringNotContainsString for all placeholders.
AWARD-003FeatureAccept award → status=accepted, ContractReady notification firesstatus=accepted. ContractReady notification sent.
AWARD-004FeatureDecline award → status=declined, irreversiblestatus=declined. PATCH back to accepted → 422.
AWARD-005FeatureDecline triggers WaitlistPromotionService — first waitlisted learner promotedNext waitlist entry status=promoted. WaitlistPromoted sent.
AWARD-006UnitDecline with no waitlisted learner completes without exceptionNo exception. award status=declined.
AWARD-007UnitWaitlistPromotionService promotes lowest waitlist_position (not random)Learner with position=1 promoted, not position=2.
AWARD-008UnitRace condition: concurrent declines produce exactly one promotionpromoted_count=1 after concurrent execution. No double-promotion.
AWARD-009FeatureWaitlistPromoted notification fires to promoted learnerNotification::assertSentTo(waitlistedLearner, WaitlistPromoted).
AWARD-010FeatureAdmin waitlist panel shows promoted_at timestamp after promotionGET /admin/waitlist: promoted_at not null.
AWARD-011FeatureBulk status change fires StatusChanged for every affected learnerNotification count = number of apps changed.

Contracts

0/6
IDTypeTest DescriptionKey AssertionStatusNotes
CONTRACT-001FeatureContract generated on award acceptance with correct merge datacontracts record. PDF contains real name, amount, programme.
CONTRACT-002FeatureSign contract records signed_at and ip_addresssigned_at not null. ip_address = request IP.
CONTRACT-003FeatureSigned contract downloadable via authenticated route200. Content-Type: application/pdf.
CONTRACT-004FeatureContract version history records generated → signed with timestampscontract_versions has two entries with timestamps.
CONTRACT-005FeatureAdmin contract search by learner name returns correct recordGET /admin/contracts?search=Thabo returns Thabo's contract.
CONTRACT-006FeatureSigning already-signed contract returns 422422. signed_at unchanged.

POPIA & DSR

0/10
IDTypeTest DescriptionKey AssertionStatusNotes
POPIA-001FeatureLearner submits access request — DSR record created with reference numberdata_subject_requests record. reference_number not null.
POPIA-002FeatureAdmin acknowledges request — status=acknowledgeddsr status=acknowledged.
POPIA-003FeatureAcknowledgement triggers notification to learnerNotification::assertSentTo(learner, DsrAcknowledged).
POPIA-004FeatureData export JSON contains learner's own profile, applications, documentsJSON keys: personal, academic, financial, applications, documents.
POPIA-005FeatureData export contains no other learner's recordsNo records with user_id != $learner->id in export.
POPIA-006FeatureErasure request creates DSR record with type=erasuredata_subject_requests with type=erasure.
POPIA-007UnitDataAnonymisationService wipes all PII fieldsname, email, id_number, phone, address, financial_need_text all anonymised.
POPIA-008FeatureLearner cannot log in after erasureLogin attempt returns 401/404.
POPIA-009UnitErasure report PDF written to storage/erasure-reports/Storage::assertExists("erasure-reports/{id}.pdf").
POPIA-010FeatureRetention policy settings persist after saveretention_policies record updated in DB.

Notifications

0/5
IDTypeTest DescriptionKey AssertionStatusNotes
NOTIFY-001FeatureApplicationObserver fires StatusChanged on single model saveNotification::assertSentTo after $app->save().
NOTIFY-002FeatureBulk query builder update does NOT trigger observerNotification::assertNothingSent() after ->update().
NOTIFY-003FeatureApplicationStatusService bulk update fires one notification per learnerNotification count = number of apps updated via service.
NOTIFY-004FeatureLearner marks notification as read — unread_count decrementsread=true. unread_count decremented.
NOTIFY-005FeatureLearner cannot mark another learner's notification as read403 on PATCH /notifications/{other_id}.

Deadline Reminders

0/5
IDTypeTest DescriptionKey AssertionStatusNotes
REMIND-001Feature--dry-run outputs list, sends zero emailsMail::assertNothingSent(). Output contains app IDs.
REMIND-002FeatureFirst run sends reminders to all qualifying applicationsMail::assertSent(DeadlineReminder) for each qualifying app.
REMIND-003FeatureSecond run sends zero duplicate emails (idempotency)Mail count after second run = 0.
REMIND-004FeaturePast-deadline applications do not receive remindersMail::assertNotSent for past-deadline apps.
REMIND-005UnitCommand registered in Console/Kernel — php artisan list shows itArtisan::call resolves. Command present in list.

Audit Log

0/5
IDTypeTest DescriptionKey AssertionStatusNotes
AUDIT-001FeatureStatus change creates audit_log entry with user_id and timestampaudit_logs record with correct user_id and changed_to.
AUDIT-002FeatureBulk shortlist creates one audit entry per app, all attributed to programme adminaudit_logs count += n. All entries have same user_id.
AUDIT-003FeatureErasure execution creates audit entry with action=erasureaudit_logs record with action=erasure, actor=admin, target=learner.
AUDIT-004FeatureReviewer assignment (auto + manual) each creates audit entryaudit_logs record with action=reviewer_assigned for both paths.
AUDIT-005FeatureAudit log filterable by date range via query paramsResponse contains only entries within the date range.

Security

0/10
IDTypeTest DescriptionKey AssertionStatusNotes
SEC-001FeatureLearner A cannot read Learner B's applicationGET /applications/{B_id} as A → 403.
SEC-002FeatureReviewer cannot access unassigned applicationsGET /reviewer/applications/{unassigned} → 403.
SEC-003FeatureSponsor cannot POST/PUT/DELETE any resourceAll mutation requests → 403.
SEC-004FeatureUnauthenticated request to protected route returns 401/redirect401 or 302 to /login.
SEC-005FeaturePOST without CSRF token rejected on non-API routes419 CSRF mismatch.
SEC-006UnitSensitive fields not mass-assignable (role, is_active, email_verified_at)$model->fill(["role" => "super_admin"]) does not update role.
SEC-007FeatureSpoofed MIME type upload (.php as .pdf) rejected422. File not stored.
SEC-008FeaturePath traversal filename sanitised on uploadStored filename sanitised. No traversal possible.
SEC-009FeatureDirect POST to scoring endpoint without COI returns 403403. No score record created.
SEC-010FeatureLearner cannot download another learner's contract PDFGET /contracts/{other}/download → 403.
Pre-UAT checklist:0/0

DB & Migrations

ItemNotes
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

Application

ItemNotes
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

File Storage

ItemNotes
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

Email & Notifications

ItemNotes
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

Test Suite

ItemNotes
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

Security

ItemNotes
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

Staging Smoke Test

ItemNotes
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.

🚨 Bulk update observer bypass
Application::where(…)->update() does NOT fire ApplicationObserver. StatusChanged notifications will silently not fire for any bulk operation using this pattern.
Fix: Always use ApplicationStatusService for bulk status changes. It iterates the collection and calls save() on each, triggering the observer — or explicitly dispatches notifications itself.
🚨 Waitlist race condition
Two concurrent award declines can both attempt to promote the same waitlisted learner. Without a lock this results in double-promotion and two WaitlistPromoted emails to the same person.
Fix: WaitlistPromotionService must use DB::transaction() with lockForUpdate() on the waitlist query. AWARD-008 covers this. Check the lock is in place before every deploy.
⚠ COI hard gate — direct POST bypass
The COI gate must be enforced at the policy layer, not just the UI. A reviewer can bypass the frontend entirely and POST directly to the scoring endpoint.
Fix: ReviewerScorePolicy must check for a no_conflict COI declaration before authorising any score creation. SEC-009 covers this directly.
🚨 POPIA erasure is irreversible
DataAnonymisationService::anonymise() permanently overwrites PII. There is no undo. Confirm DB_DATABASE in .env is not pointed at production before running erasure tests.
Fix: In tests, always use a factory-created user. Never use real data. Run php artisan config:clear before tests to ensure .env.testing is loaded, not .env.
⚠ Queue driver in tests
If QUEUE_CONNECTION=database and queue:work is not running during tests, queued notifications will appear to pass (job dispatched) but never actually send.
Fix: Set QUEUE_CONNECTION=sync in .env.testing. Assert on the notification class itself, not just on the job being pushed to the queue.
ℹ Eligibility filter and missing LearnerProfile
The eligibility filter compares learner institution from LearnerProfile. If LearnerProfile does not exist for a test user, the filter may fail-closed or throw a null comparison error.
Fix: Always create a LearnerProfile via factory in any test that exercises eligibility filtering. PROG-002 and PROG-003 depend on this.
⚠ docx SimpleField for page numbers
PageNumberElement generates invalid XML that Word rejects. The footer shows a broken field instead of a page number.
Fix: Always use SimpleField("PAGE") in footer paragraphs when generating .docx files via the docx library.
Phase 1 — Unit test suite
Phase 1
Scope
Service classes: WaitlistPromotionService, DataAnonymisationService, ApplicationStatusService, DeadlineReminderService, weighted score calculation, contract merge fields, COI idempotency.
Tool
PHPUnit. QUEUE_CONNECTION=sync. Storage::fake(). Notification::fake(). Mail::fake().
Gate
All unit tests passing. ≥80% coverage on service classes. No skipped tests without documented reason.
Phase 2 — Feature test suite — admin and programme flows
Phase 2
Scope
AUTH-001–008, PROG-001–010, APP-001–006, DOCS-001–008, COI-001–006.
Key paths
Registration gate, email verification, role middleware, eligibility filter, form builder, rubric validation, auto-assignment, document verification auto-transition.
Gate
All tests in these suites passing. Bulk update observer bypass confirmed via NOTIFY-002.
Phase 3 — Feature test suite — review, awards, and contracts
Phase 3
Scope
SCORE-001–008, AWARD-001–011, CONTRACT-001–006.
Key paths
COI gate, score locking, weighted total, waitlist promotion race condition, offer letter merge data, contract signing, version history.
Gate
AWARD-008 (race condition) must pass. All offer letter PDF assertions passing.
Phase 4 — Feature test suite — POPIA, notifications, reminders, audit, security
Phase 4
Scope
POPIA-001–010, NOTIFY-001–005, REMIND-001–005, AUDIT-001–005, SEC-001–010.
Key paths
Erasure anonymisation, bulk notification bypass, idempotency, cross-learner access control, COI direct POST bypass, CSRF, mass-assignment.
Gate
Zero failing tests. All 97 test cases covered.
Phase 5 — Pre-UAT checklist and staging smoke test
Phase 5
Morning
Complete the pre-UAT checklist (all 38 items). Fix any outstanding issues.
Afternoon
Deploy to staging. Run manual smoke test of the critical happy path for each portal.
Gate
php artisan test exits 0. All pre-UAT checklist items ticked. Staging smoke test passed. Hand off to UAT.