# CYDM — Testing Strategy

> A microfinance core banking system holds members' savings and computes what they owe. A rounding bug is not a cosmetic defect — it is a regulatory finding and a member dispute. Testing here is load-bearing.

---

## 1. What we test, and how hard

| Layer | Tool | Coverage expectation |
|---|---|---|
| **Money & calculations** | Pest unit | Exhaustive. Worked examples, boundaries, property-based invariants. |
| **Tenant isolation** | Pest feature | Every module. Non-negotiable. |
| **Business workflows** | Pest feature | Happy path + every failure mode that changes state. |
| **Authorisation** | Pest feature | Every role × every protected route. |
| **UI flows** | Pest browser | Each user-facing journey, end to end. |
| **Accounting integrity** | Pest feature | Trial balance must balance after every transaction type. |

The rule of thumb: **anything that touches money, tenancy, or authorisation gets a test before it gets a commit.** Presentation-only changes do not.

---

## 2. Layout

```
tests/
├── Pest.php                 # bootstrap, shared helpers
├── TestCase.php
├── Unit/
│   ├── Money/               # cents arithmetic, rounding, formatting
│   ├── Loans/               # schedules, interest, EIR, allocation
│   └── Accounting/          # posting rules, balancing
├── Feature/
│   ├── Tenancy/             # isolation, resolution, provisioning
│   ├── Auth/                # login, 2FA, password policy, lockout
│   ├── Members/
│   ├── Savings/
│   ├── Loans/
│   ├── Accounting/
│   └── Reporting/           # BoT MSP forms
└── Browser/                 # Pest 4 browser tests
    ├── AuthFlowTest.php
    └── DashboardTest.php
```

---

## 3. Running tests

All commands run inside the workspace container, from `/var/www/cydm`.

```bash
php artisan test                          # everything
php artisan test --parallel                # faster, isolated databases
php artisan test tests/Feature/Loans       # one directory
php artisan test --filter=repayment        # by name
php artisan test --coverage                # coverage report
```

Tests run against the **`cydm_testing`** database, configured in `phpunit.xml`, so the development data is never touched. `RefreshDatabase` rebuilds the schema per test.

---

## 4. Tenant isolation testing

This is the single most important category of test in the project. A leak between tenants is an existential failure — it is the one bug that could end the business.

Every module that stores tenant data gets this test, adapted:

```php
it('never leaks records across tenants', function () {
    $tenantA = Tenant::factory()->create();
    $tenantB = Tenant::factory()->create();

    $memberOfA = Member::factory()->for($tenantA)->create();

    tenancy()->initialize($tenantB);

    expect(Member::find($memberOfA->id))->toBeNull();
    expect(Member::count())->toBe(0);
});
```

And the route-level counterpart — a leak through an unscoped route parameter is just as damaging as one through a model:

```php
it('returns 404 rather than another tenant\'s record', function () {
    $memberOfA = Member::factory()->for($tenantA)->create();

    actingAsTenantUser($tenantB)
        ->get("/members/{$memberOfA->id}")
        ->assertNotFound();     // never 403 — existence itself must not leak
});
```

> **Why 404 and not 403:** a 403 confirms the record exists. For a competitor SACCOS probing member IDs, that is itself a disclosure. Absence must be indistinguishable from denial.

### The CI guard

`withoutGlobalScope` is banned in application code. It is the one call that silently disables tenant scoping, and it is easy to add "just for this one query". CI greps for it:

```bash
! grep -rn "withoutGlobalScope" app/ --include="*.php"
```

Legitimate exceptions (super-admin cross-tenant reporting) live in a small, reviewed set of classes explicitly allow-listed in the CI script — never scattered through the codebase.

---

## 5. Money testing

Money is stored as **integer cents**. Every calculation is tested against hand-computed examples, because "it looks about right" is how rounding bugs ship.

```php
it('generates a reducing-balance schedule matching the manual calculation', function () {
    // TZS 1,000,000 at 2.5%/month reducing, 12 monthly instalments
    $schedule = (new LoanCalculator)->reducingBalance(
        principal: 100_000_000,   // cents
        monthlyRate: 0.025,
        instalments: 12,
    );

    expect($schedule)->toHaveCount(12);

    // Verified by hand against the standard annuity formula
    expect($schedule[0]->principal)->toBe(7_364_806);
    expect($schedule[0]->interest)->toBe(2_500_000);

    // The invariant that actually matters: principal repaid == principal lent.
    // Any rounding scheme must place the residual cent somewhere explicit.
    expect(collect($schedule)->sum('principal'))->toBe(100_000_000);
});
```

Invariants asserted across every schedule, for many random inputs:

- Principal components sum **exactly** to the principal advanced.
- No instalment is negative.
- The closing balance is exactly zero.
- Interest never accrues on interest unless the product explicitly capitalises.

Floats are banned in financial code — a rule recorded in `.ai/rules/` so agents inherit it too.

---

## 6. Accounting integrity

Double-entry gives us a self-checking property, and we lean on it hard:

```php
it('leaves the trial balance in balance after any transaction', function (string $transaction) {
    performTransaction($transaction);

    $trialBalance = app(TrialBalance::class)->generate();

    expect($trialBalance->totalDebits)->toBe($trialBalance->totalCredits);
})->with([
    'savings deposit', 'savings withdrawal', 'loan disbursement',
    'loan repayment', 'interest accrual', 'fee charge',
    'share purchase', 'dividend payment', 'write-off',
]);
```

A scheduled job runs the same assertion against live data daily and alerts on drift. If the ledger ever goes out of balance in production, we want to hear about it that day, not at the annual audit.

---

## 7. Browser testing

Pest 4 ships real browser testing, so UI is verified rather than assumed. **A feature is not done until the page renders and the flow completes in a browser.**

```php
it('lets a member sign in and reach their dashboard', function () {
    $user = User::factory()->create();

    visit('/login')
        ->fill('email', $user->email)
        ->fill('password', 'password')
        ->press('Log in')
        ->assertPathIs('/dashboard')
        ->assertSee('Portfolio at Risk')
        ->assertNoJavaScriptErrors();
});
```

`assertNoJavaScriptErrors()` is worth applying broadly — a React error boundary swallowing an exception can leave a page that renders but does nothing.

Also checked per screen:

- Renders correctly in light **and** dark mode.
- Renders in Swahili **and** English.
- Usable at 375px (field officers work from phones).
- Keyboard navigable, visible focus states.

---

## 8. What we deliberately do not test

Being honest about this keeps the suite fast and meaningful:

- **Framework behaviour.** Eloquent saves records. Laravel validates. Not our job.
- **Third-party gateway internals.** We test our adapter against a fake; the provider's sandbox is checked manually per integration.
- **Generated shadcn components**, unless we modified their behaviour.
- **Exact pixel layout.** Brittle, low value. We assert structure and content.

---

## 9. Test data

Factories carry realistic Tanzanian data — Swahili names, valid NIDA-format numbers, real region/district/ward values, TZS amounts at plausible microfinance scale. Realistic fixtures surface bugs that `foo`/`bar` never will: name lengths, character encoding, amounts crossing threshold boundaries.

```php
Member::factory()->count(50)->create();          // varied, realistic
Member::factory()->highRisk()->create();          // triggers enhanced CDD
Loan::factory()->inArrears(days: 45)->create();   // lands in a known PAR bucket
```

The seeded **demo tenant** is a complete miniature institution — members, savings, loans in every classification bucket, a full year of transactions — so dashboards and reports have something meaningful to show, and so report totals can be checked against known values.

---

## 10. CI

Every push runs:

1. `composer install` + `npm ci`
2. Pint (formatting) and PHPStan (static analysis)
3. `php artisan test --parallel`
4. The `withoutGlobalScope` guard
5. `npm run build` (catches TypeScript errors)
6. Browser tests

A red build blocks merge. The tenant-isolation guard failing is treated as a stop-everything event, not a normal test failure.
