# WP2 Report — The invariant test (the guard that outlives us)

**Status:** DONE — and the enforcement tests are **RED, as the brief demands** (reported as
INCOMPLETE while the WP3 flag is off — see §3).
**Repo/branch:** `/home/moonui2/moon-erp-be` · `hazemdev2` · commit **`c4924e2ee`** (on top of WP1 `25bc10ff9`) · not pushed, not merged
**File added:** `Modules/Inventory/tests/Feature/InventoryScopeInvariantTest.php` (the only file touched; pint clean; `chown moonui2`)
**Run command:** `cd /home/moonui2/moon-erp-be && /opt/cpanel/ea-php82/root/usr/bin/php -d memory_limit=1G vendor/bin/pest Modules/Inventory/tests/Feature/InventoryScopeInvariantTest.php`

> ⚠ **Incident found on arrival:** the working tree was in **detached HEAD at `7d191da44`** (the
> pre-WP1 base) — WP1's commit existed on `hazemdev2` but its files were absent from the tree, so
> the first test run died on `no such table: warehouse_user`. Fixed with `git checkout hazemdev2`
> (my test file was untracked, so nothing was lost). Likely fallout of the WP1 shell death noted in
> the ledger. Nothing of WP1 was missing once on the branch.

---

## 1. What the test is

Three tests in one file:

| Test | Today | Role |
|---|---|---|
| `router coverage: every api.inventory route is probed or exempted with a written reason` | ✅ **GREEN** | The tripwire. Enumerates `Route::getRoutes()` filtered to names `api.inventory.*` (**86 routes today**) and fails if any route is missing from the probe tables + exemption list, is stale, is double-listed, or has an exemption reason under 60 chars. **This is the test that fires the day someone adds an endpoint.** |
| `list/report endpoints return only assigned-warehouse data for a restricted user` | 🔴 RED (31/31 probes fail) | 31 data-level probes: seeded `…-KEEP…` markers must appear (positive control, checked FIRST — a broken probe reports itself as a broken probe, never as pass/leak), seeded `…-LEAK…` markers must not; three aggregate endpoints asserted numerically. |
| `by-id endpoints answer 404 for an out-of-scope record and not-404 for an in-scope one` | 🔴 RED (39/39 probes fail) | Out-of-scope id ⇒ **404** (403/2xx confirms the record exists — settled decision 4); in-scope twin must NOT 404 (catches over-filtering with a wrong column/pivot). Destructive probes ordered last so an unwired delete can never hand a later probe a false 404. |

**Route accounting: 86 enumerated = 31 list-probed + 39 by-id-probed + 16 exempted.**

Fixture: company mode `assigned` (`inventory.warehouse_data_scope`, written via the `Setting` model
per WP1 §4); keeper user assigned warehouse A only; warehouses B/C unassigned; every data kind
seeded twice (A = `KEEP`, B/C = `LEAK`): documents of all six types, stock balances, movements,
cost layers, serials, company + consignment lots, batch-tracked products, tagged approved issues,
and a production order issued from both warehouses. All names set in `name` AND `name_ar` so
localization can never hide a marker.

## 2. Design decisions (the four from the brief, plus what they forced)

1. **Router-enumerated.** No hand list anywhere; the probe/exemption tables are keyed by route
   name and *diffed against the live router* — a hand-table that rots now FAILS the coverage test
   (stale entries are errors too, in both directions).
2. **Exemptions carry written reasons** (16 entries, 5 distinct reasons), and the coverage test
   rejects thin ones (<60 chars). The three reason groups worth the owner's eyes:
   - **All 8 creation endpoints** (`*.store`, `opening-balances.bulk`): they fetch no existing rows,
     but whether a keeper may **create INTO an unassigned warehouse** is an explicitly-recorded
     **open write-path decision** (WP5/WP7 candidate) — exempted *visibly*, not silently.
   - **`issue-tags.*` (6):** company-wide label catalogue, no warehouse column; the tag *reports*
     (which do carry warehouse data) ARE probed.
   - **`reorder-alerts.notify`**, **`costing.update-method`**: broadcast action returning counts
     only / company-level setting write.
3. **Data, not code shape.** Every probe asserts on the HTTP response: sentinel strings seeded per
   warehouse (document numbers, product/warehouse/tag/partner/serial/batch/lot names) or exact
   numbers (`stats` 780 vs 2070, `product-cost` 5 vs 12, `reorder-alerts` per-row qty 0 vs 7).
4. **By-id covered, 404 semantics.** All 39 fetch-by-id routes (incl. every approval/commit/cancel
   action, `products-for-warehouse/{warehouse}`, `stock-balances/warehouse/{warehouse}`) — plus the
   in-scope-must-not-404 counter-probe on each.

Structural note for WP6: adding cash boxes = a second fixture + more entries in the same three
tables (probes are `route name → closure`); no copying of the test machinery.

## 3. Expected-red mechanism (how WP3 flips it on)

A single constant at the top of the test file:

```php
const INVENTORY_SCOPE_INVARIANT_WIRED = false;   // ← WP3 flips this to true
```

- **`false` (now):** probes still RUN on every suite pass; when they fail, the test reports
  **INCOMPLETE** with the full per-route worklist in the message — so WP3–WP5 suite runs don't
  read an expected red as a fresh regression, yet the worklist stays visible and **shrinks live**
  as WP3 wires controllers.
- **WP3 flips it to `true`** (one line, same file) when the wiring lands: any remaining unscoped
  route becomes a hard FAIL.
- **The flag cannot rot:** if all probes pass while the flag is still `false`, the test **FAILS**
  with "flip INVENTORY_SCOPE_INVARIANT_WIRED to true" — a permanently-expected-red guard protects
  nothing, so green-while-unflipped is itself an error.
- The router-coverage test is a hard gate **already today** (it is green and stays enforced).

## 4. ✅ Acceptance criterion 5 — proof the tripwire fires

Temporarily appended to `Modules/Inventory/routes/api.php`:
`Route::get('leak-demo', fn () => response()->json(['data' => []]))->name('leak-demo.index');`
then ran the coverage test; output (verbatim), after which the route was removed and the test
re-ran green:

```
   FAIL  Modules\Inventory\tests\Feature\InventoryScopeInvariantTest
  ⨯ router coverage: every api.inventory route is probed or exempted wi… 4.49s
  ────────────────────────────────────────────────────────────────────────────
   FAILED  … > router coverage: every api.inventory route is probed or exempted…
  Route-coverage invariant violated:

  ✗ NEW/UNCOVERED route api.inventory.leak-demo.index (GET /api/inventory/leak-demo)
      → scope it with ResourceScope::apply(...) AND add a probe for it in
        inventoryScopeInvariantListProbes()/ByIdProbes(),
        OR add it to inventoryScopeInvariantExemptions() with a WRITTEN reason.
        Silent unscoped routes are the bug this test exists to prevent.

  Tests:    1 failed (1 assertions)
```

`git diff` on `routes/api.php` is empty after restoration (`git checkout --`).

## 5. 🔴 THE CURRENT FAILURE LIST — WP3's exact worklist (70 routes)

Verified run with the flag temporarily `true` (full red): **31 + 39 fail, all for genuine
data-leak reasons — zero probe errors, zero positive-control failures.** All route names below
carry the `api.inventory.` prefix.

### 5a. List/report routes leaking other warehouses' data (31)

Remedy per WP1 §8: `ResourceScope::apply($query, auth()->user(), 'warehouse', [...])` /
`applyReport(...)`; transfers need `['columns' => ['from_warehouse_id','to_warehouse_id']]`;
balances/movements/aggregates need `['owner' => null]`; the warehouse catalogue itself needs
`['columns' => ['id']]` (or qualified `warehouses.id`).

| # | Route | Leak observed |
|---|---|---|
| 1 | `warehouses.index` | unassigned warehouse name leaked |
| 2 | `warehouses.tree` | unassigned warehouse name leaked |
| 3 | `receipts.index` | out-of-scope receipt number leaked |
| 4 | `issues.index` | out-of-scope issue number leaked |
| 5 | `transfers.index` | transfer between two unassigned warehouses leaked |
| 6 | `counts.index` | out-of-scope count number leaked |
| 7 | `adjustments.index` | out-of-scope adjustment number leaked |
| 8 | `opening-balances.index` | out-of-scope opening receipt leaked |
| 9 | `stock-balances.index` | balance of a product held only in an unassigned warehouse leaked |
| 10 | `stock-balances.lot-owners` | consignment customer whose lots live only in an unassigned warehouse leaked |
| 11 | `stock-balances.by-product` | per-warehouse balance rows include the unassigned warehouse |
| 12 | `stock-balances.product-lots` | lot (batch) held in an unassigned warehouse leaked |
| 13 | `stock-card.show` | movements in an unassigned warehouse leaked |
| 14 | `movements.index` | movements of an unassigned warehouse leaked |
| 15 | `reports.movement-summary` | product moving only in an unassigned warehouse leaked |
| 16 | `reports.slow-moving` | dead stock of an unassigned warehouse leaked |
| 17 | `reports.warehouse-summary` | unassigned warehouse's row (qty+value) leaked |
| 18 | `reports.expiry` | serial stored in an unassigned warehouse leaked |
| 19 | `reports.expiring-batches` | batch stored in an unassigned warehouse leaked |
| 20 | `reports.lot-reconciliation` | batch product stocked only in an unassigned warehouse leaked |
| 21 | `reports.production-order-issues` | إذن صرف drawn from an unassigned warehouse leaked |
| 22 | `reports.issue-tags` | tag used only by an unassigned warehouse's issues leaked |
| 23 | `reports.issue-tag-detail` | the tag's out-of-scope إذن صرف leaked |
| 24 | `reports.aging` | cost layers of an unassigned warehouse leaked |
| 25 | `reports.costs` | cost KPIs of a product stocked only in an unassigned warehouse leaked |
| 26 | `costing.cost-layers` | layers of the unassigned warehouse leaked |
| 27 | `costing.valuation` | valuation rows of the unassigned warehouse leaked |
| 28 | `costing.product-cost` | `data.total_quantity` = **12** (company-wide) — expected **5** (assigned only) |
| 29 | `reorder-alerts.index` | out-of-scope product alerts with `current_quantity` **7** — expected **0** (its stock is invisible to this keeper) |
| 30 | `reorder-report.index` | per-warehouse breakdown includes the unassigned warehouse |
| 31 | `stats.index` | `data.total_inventory_value` = **2070** (company-wide) — expected **780** (assigned only) |

### 5b. By-id routes serving out-of-scope records (39; status observed instead of the required 404)

Remedy: apply the same scope to the find/fetch query → natural 404 (settled decision 4). Where a
state/flow check currently answers before the fetch (the 422 rows), WP3 must **fetch-scoped
first** — a 422 on an invisible record still proves it exists.

| Routes | Observed |
|---|---|
| `warehouses.show` · `warehouses.update` · `warehouses.destroy` | 200 |
| `counts.products-for-warehouse` · `stock-balances.by-warehouse` | 200 |
| `receipts.show` · `receipts.update` · `receipts.destroy` | 200 |
| `receipts.submit-approval` · `receipts.approval.approve` · `receipts.approval.reject` · `receipts.approve` · `receipts.cancel` | 422 |
| `issues.show` · `issues.update` · `issues.destroy` | 200 |
| `issues.submit-approval` · `issues.approval.approve` · `issues.approval.reject` · `issues.approve` · `issues.cancel` | 422 |
| `transfers.show` · `transfers.update` · `transfers.cancel` | 200 |
| `transfers.ship` · `transfers.receive` · `transfers.destroy` | 422 |
| `counts.show` · `counts.update` · `counts.destroy` | 200 |
| `counts.finalize` | 422 |
| `adjustments.show` · `adjustments.cancel` | 200 |
| `adjustments.submit-approval` · `adjustments.approval.approve` · `adjustments.approval.reject` · `adjustments.approve` | 422 |
| `opening-balances.show` | 200 |
| `opening-balances.cancel` | 422 |

### 5c. Exempt (16 — each with its written reason in the test file)

`warehouses.store` · `receipts.store` · `issues.store` · `transfers.store` · `counts.store` ·
`adjustments.store` · `opening-balances.store` · `opening-balances.bulk` ·
`issue-tags.{index,store,show,update,destroy,similar}` · `reorder-alerts.notify` ·
`costing.update-method`

## 6. Run evidence (committed state, flag `false`)

```
   WARN  Modules\Inventory\tests\Feature\InventoryScopeInvariantTest
  ✓ router coverage: every api.inventory route is probed or exempted wi… 4.65s
  … list/report endpoints return only assigned-warehouse data for a res… 4.97s
  … by-id endpoints answer 404 for an out-of-scope record and not-404 f… 4.98s

  Tests:    2 incomplete, 1 passed (2 assertions)
```

The two "…" rows are the INCOMPLETE (expected-red) tests; each one's message begins
`EXPECTED RED — Inventory wiring (WP3) has not landed; this list IS WP3's worklist.` followed by
the full per-route list of §5. With the flag `true` the same run is `2 failed, 1 passed`.

## 7. Hand-offs / notes

- **WP3:** work §5a+§5b top to bottom; run this file after each controller; the incomplete-message
  list shrinks as you go. When it's empty, **flip `INVENTORY_SCOPE_INVARIANT_WIRED = true`** (the
  test itself will demand it) and also flip `is_implemented => true` on
  `inventory.warehouse_data_scope` (WP1 §4). The three numeric probes (`product-cost` = 5,
  `stats` = 780, `reorder-alerts` leak-row qty = 0) define the expected *totals* semantics:
  aggregates computed over assigned warehouses only.
- **Owner decision queued (visible in the exemption list):** may a keeper CREATE documents into an
  unassigned warehouse? All 8 creation endpoints are exempt-with-reason pending that decision
  (WP5/WP7); flipping any of them later = move the entry from the exemption table to the probe
  tables.
- **WP6 (cash boxes):** extend the same three tables + a cash fixture; the machinery (enumeration,
  verdict, flag) is resource-agnostic. Consider a second constant for the cash flag if WP6 lands
  separately from its wiring.
- Existing suites are untouched (no production code changed); every top-level helper in the file is
  prefixed `inventoryScopeInvariant` (Pest single-process rule).
- No CHANGELOG bullet (nothing user-visible); commit tagged `[skip-changelog]`.
