# Lunch-run report — Tasks 2.6 + 2.7 — 2026-05-11

Autonomous run on `master`, local commits only. Two commits, one per task.

## Commits

| Task | SHA | Subject |
| --- | --- | --- |
| 2.6  | `28133b9a8b81865303977d06ff800d371d81d439` | feat(ops): add /api/health endpoint (Task 2.6) |
| 2.7  | `43ef5a5988ed857a16c3b8e1d41ebc396ae54098` | feat(security): rate-limit auth/otp/password-reset (Task 2.7) |

## Routes confirmed rate-limited

Applied to existing routes only — no endpoint invention.

| Method | Path                       | Throttle                  | Was |
| ------ | -------------------------- | ------------------------- | --- |
| POST   | `/api/auth/request-otp`    | `throttle:otp-attempts`   | `throttle:5,1`  |
| POST   | `/api/auth/verify-otp`     | `throttle:otp-attempts`   | `throttle:10,1` |

Named limiters registered in `AppServiceProvider::boot()` (all IP-keyed):

| Limiter                     | Cap    | Live routes today |
| --------------------------- | ------ | ----------------- |
| `auth-attempts`             | 5/min  | **(none)** — see "Skipped" below |
| `otp-attempts`              | 3/min  | `/api/auth/request-otp`, `/api/auth/verify-otp` |
| `password-reset-attempts`   | 3/min  | **(none)** — see "Skipped" below |

Health endpoint (no auth, no throttle):

| Method | Path           | Handler |
| ------ | -------------- | ------- |
| GET    | `/api/health`  | `HealthController@__invoke` |

## Test output — verbatim

### `php artisan test --filter=HealthEndpointTest`

```
  PASS  Tests\Feature\HealthEndpointTest
  ✓ returns ok with expected shape when database connected                                                       0.14s
  ✓ returns 503 when database throws                                                                             0.02s
  ✓ response contains all expected keys                                                                          0.02s

  Tests:    3 passed (18 assertions)
  Duration: 0.30s
```

### `php artisan test --filter=RateLimitTest`

```
  PASS  Tests\Feature\RateLimitTest
  ✓ auth attempts limiter caps at 5 per minute                                                                   0.16s
  ✓ otp attempts limiter caps at 3 per minute                                                                    0.02s
  ✓ password reset attempts limiter caps at 3 per minute                                                         0.02s

  Tests:    3 passed (17 assertions)
  Duration: 0.34s
```

(PHPUnit metadata-deprecation warnings from `MaxileServiceTest` doc-comments
are pre-existing on this branch and unrelated to this run; omitted above.)

## Decisions defaulted on

1. **Health "ok" path test mocks the DB facade.**
   `DB::connection()->getPdo()` blows up on this Windows dev machine because
   `pdo_sqlite` isn't loaded (`php -m | grep pdo` shows only `pdo_mysql`).
   `phpunit.xml` forces `DB_CONNECTION=sqlite` + `DB_DATABASE=:memory:`,
   which can't open without the driver. Rather than modify env (forbidden
   by the brief), the success-path test stubs `DB::connection()` so the
   assertion is on the controller's response shape, not on PDO availability.
   The 503-path test mocks DB to throw, and the key-presence test asserts
   structure only — both work regardless of driver state. This mirrors
   what CI would do if it lacked the driver.

2. **Named limiters registered in `AppServiceProvider`, not `RouteServiceProvider`.**
   Under Laravel 12's `bootstrap/app.php` routing,
   `app/Providers/RouteServiceProvider.php` is **not** listed in
   `bootstrap/providers.php` (which contains only `AppServiceProvider`).
   Its `boot()` never fires, so any `RateLimiter::for(...)` added there
   would be silently dead. AppServiceProvider is the registered provider
   and runs on every request, so it owns the limiter registrations.
   The pre-existing `RateLimiter::for('api', ...)` block in
   RouteServiceProvider was left untouched (out of scope), but it is
   currently dead code — flag for a separate cleanup.

3. **otp-attempts replaces less-restrictive inline throttles.**
   Prior config was `throttle:5,1` on request-otp and `throttle:10,1` on
   verify-otp. The brief's `otp-attempts` is 3/min — the new ceiling. Both
   OTP endpoints share one bucket per IP now (verify and request are part
   of the same attack surface for credential-stuffing / SMS-bombing).

4. **Test cache reset uses both `Cache::store('array')->flush()` and
   `RateLimiter::clear('<name>:127.0.0.1')`.** The composite key is the
   format the throttle middleware uses when `Limit::by()` is set. Cache
   flush is the belt-and-braces fallback if the key shape ever changes.

5. **Throwaway test routes registered via `Route::middleware(...)->post(...)`
   inside each test method** — same pattern as the existing
   `IdempotencyMiddlewareTest`. Routes don't go through the `web` group so
   no CSRF concern.

## Skipped / deferred

1. **`auth-attempts` has no live route applied.** The API has no login,
   register, or non-OTP token-issuance endpoint — auth is via the OTP
   flow, which is already governed by `otp-attempts`. The limiter is
   registered + has passing test coverage so the next route that needs
   it can wire it up with a single `->middleware('throttle:auth-attempts')`.

2. **`password-reset-attempts` has no live route applied.** There are no
   password-reset endpoints in this codebase (OTP-based auth has no
   password concept). Limiter is registered + test-covered, ready for
   when/if a reset flow lands.

3. **Web routes `/send-otp` and `/verify-otp` (routes/web.php) NOT
   re-throttled.** These are admin login surfaces (session-based, in
   the `web` middleware group), not the API surface targeted by this
   task. Touching them would expand scope. They remain on whatever
   throttling the `web` group provides today.

4. **Dead-code `RouteServiceProvider.php` not removed.** Out of the
   ±5-line scope limit; leaving for a dedicated cleanup commit.

## Stop conditions hit

None. All tests passed on first run after the DB-mock adjustment.

---

# Feature-test expansion run — 2026-05-11

Second autonomous block on `master`, local commits only. Three commits, one
per endpoint. Total 13 new tests (under the 15 ceiling). All passing.

## Endpoints covered

| # | Path                          | Method | Test class                              | Tests | Assertions |
| - | ----------------------------- | ------ | --------------------------------------- | ----- | ---------- |
| 1 | `/api/answers`                | POST   | `AnswerEndpointAuthValidationTest`      | 5     | 16         |
| 2 | `/api/lives/purchase`         | POST   | `LivesPurchaseEndpointTest`             | 3     | 7          |
| 3 | `/api/diagnostic/submit`      | POST   | `DiagnosticSubmitEndpointTest`          | 5     | 14         |

All test classes live under `tests/Feature/` and use PHPUnit attributes via
extending the project's `Tests\TestCase`. No new factories were created
(only `UserFactory` exists in this repo; the rest of the schema is too
domain-specific for invented factories per the brief).

## Commits

| Endpoint | SHA | Subject |
| --- | --- | --- |
| 1 | `1e013772340d8df2e2555db47eb514a75b1092e1` | test(api): auth + validation coverage for POST /api/answers |
| 2 | `04b30ad6670f4f7921f83d8920c06f7198aa0066` | test(api): auth + validation coverage for POST /api/lives/purchase |
| 3 | `f2eb94fee4a4c773ab313089960240f938c16728` | test(api): auth + validation coverage for POST /api/diagnostic/submit |

## Test output — verbatim

### `php artisan test --filter=AnswerEndpointAuthValidationTest`

```
  PASS  Tests\Feature\AnswerEndpointAuthValidationTest
  ✓ unauthenticated request returns 401                                                                          0.15s
  ✓ authenticated empty body returns 422 with required errors                                                    0.03s
  ✓ invalid mode value returns 422                                                                               0.02s
  ✓ invalid answer type returns 422                                                                              0.02s
  ✓ mcq missing selected option returns 422                                                                      0.02s

  Tests:    5 passed (16 assertions)
  Duration: 0.37s
```

### `php artisan test --filter=LivesPurchaseEndpointTest`

```
  PASS  Tests\Feature\LivesPurchaseEndpointTest
  ✓ unauthenticated request returns 401                                                                          0.15s
  ✓ authenticated empty body returns 422                                                                         0.03s
  ✓ invalid package value returns 422                                                                            0.02s

  Tests:    3 passed (7 assertions)
  Duration: 0.34s
```

### `php artisan test --filter=DiagnosticSubmitEndpointTest`

```
  PASS  Tests\Feature\DiagnosticSubmitEndpointTest
  ✓ unauthenticated request returns 401                                                                          0.13s
  ✓ authenticated empty body returns 422 with required errors                                                    0.03s
  ✓ empty answers array returns 422                                                                              0.02s
  ✓ answer item missing question id returns 422                                                                  0.02s
  ✓ session id must be integer                                                                                   0.02s

  Tests:    5 passed (14 assertions)
  Duration: 0.34s
```

(PHPUnit metadata-deprecation warnings from `MaxileServiceTest` doc-comments
are pre-existing and unrelated to this work; omitted from the output above.)

## Design decisions defaulted on

1. **All tests are DB-free.** `pdo_sqlite` is not loaded on the Windows dev
   box (`php -m | grep pdo` shows only `pdo_mysql`), and the brief
   forbids env modifications. So tests are structured to fail at the
   middleware layer (auth, validation) before any DB query fires. For
   `StoreAnswerRequest::authorize()`, this means omitting `session_id`
   so the authorize() short-circuits at the `if (!$sessionId) return true;`
   guard. For `DiagnosticController::submitAnswers`, validation errors
   are emitted before `AssessmentSession::find()` runs.

2. **`Sanctum::actingAs()` is given an unsaved `User` model.** Sanctum's
   `actingAs()` calls `$guard->setUser($user)` directly and does not
   require the user to be persisted — `withAccessToken(new TransientToken)`
   bypasses the `personal_access_tokens` table. We force-fill `id => 1`
   so any code that reads `$user->id` sees a non-null value.

3. **Happy-path coverage is deferred per endpoint, not blanket-skipped.**
   See "Skipped / deferred" below — each happy path was considered and
   has a specific reason for deferral (Stripe network, schema sprawl,
   already covered elsewhere).

4. **One commit per endpoint, not one big test commit.** Matches the
   brief's "one commit per endpoint" rule and keeps `git blame` precise
   if a test starts flaking against a controller change.

5. **No new factories.** Only `UserFactory` exists in this repo; the
   models under test (`AssessmentSession`, `Question`, `Test`, `Track`,
   `Skill`, `Field`, `AttemptLedger`, `DiagnosticFieldProgress`) have no
   factories. Inventing them would have meant 8+ new files plus seed
   data — explicitly off-limits per the brief.

## Skipped / deferred

1. **Happy-path for `/api/answers` not added here** — already covered
   exhaustively by the existing `AnswerGradingTest` (9 tests over BE3/BE4
   semantics, lives deduction, kudos, premium-gate, max attempts). This
   class fills the auth + 422 surface the existing test left silent on.

2. **Happy-path for `/api/lives/purchase` not added** —
   `createPurchaseIntent` calls `LivesPurchaseService::createPaymentIntent`
   which hits Stripe. The brief explicitly excludes real-Stripe in tests,
   and a service-level mock would be net-new infrastructure not justified
   for a 3-line controller.

3. **Happy-path for `/api/diagnostic/submit` not added** — a passing
   submit traverses `AnswerValidationService`, `AdaptiveLevelService`,
   `UserProgressService` and writes ~6 tables (attempt_ledger,
   question_user, diagnostic_field_progress, assessment_sessions,
   plus the canonical `field_user` from the recent migration). Would
   require the schema-spin-up pattern from AnswerGradingTest. Flagged
   for a follow-up that targets the IRT boundary-detection rewrite in
   `c386252` specifically.

4. **403 cross-user scenario not tested on `/api/answers`** — would
   require persisted users + an assessment_sessions row owned by user A
   while user B authenticates, i.e. needs DB. The authorization rule
   (`StoreAnswerRequest::authorize()`) is straightforward (an
   `exists where user_id = ?` check), and the contract is the same
   for any controller-as-resource-owner pattern in the repo.

5. **GET `/api/lives` state endpoint NOT covered — it does not exist
   in this codebase.** The brief's "Lives state (current lives +
   consumption / refill)" target was best approximated by
   `/api/lives/purchase` (the only `/api/lives/*` route). Lives state
   is surfaced via embedded fields in the `/api/answers` response
   (`lives.current`, `lives.deducted_this_attempt`, `lives.unlimited`),
   which AnswerGradingTest already asserts on.

## Stop conditions hit

None. All three suites passed on first run.
