---
status: reviewed
createdAt: '2026-05-06'
reviewedBy: [ProductOwner, Architect, DevLead]
---

# pictureFrame — Comprehensive Test Plan

## 0. Scope and Goals

This plan covers unit, integration, and functional tests for all three layers of the pictureFrame system:

1. **Server** — Symfony 8 / PHP 8.4 backend (controllers, services, message handlers, repositories)
2. **Frontend** — Vue 3 SPA (stores, components, views)
3. **Firmware** — ESP32 Arduino C++ (control flow, protocol correctness)

Primary goals:
- Prevent regressions in the firmware ↔ server API contract (any break requires physical reflash of every deployed device)
- Cover all branching logic in `RotationService` and `DeviceImageController` — the most complex, failure-prone code
- FW-02 is the explicit regression test for the NVS header-read-after-end bug (headers read after `http.end()` → NVS never updated → 304 never fires)
- Give CI a green/red signal before any server deploy

---

## 1. Tooling and Infrastructure

### 1.1 PHP / Server

| Tool | Role | Already present? |
|---|---|---|
| PHPUnit 13.1 | Unit + integration + functional test runner | Yes (`phpunit.dist.xml`, `tests/bootstrap.php`) |
| `symfony/test-pack` | `WebTestCase`, `KernelTestCase`, test client | Needs adding |
| `dama/doctrine-test-bundle` | Wraps each test in a rolled-back transaction (fast isolation) | Needs adding |
| `doctrine/fixtures-bundle` | Seed fixture objects without raw SQL | Needs adding |
| Separate test database | `_test` suffix already in `doctrine.yaml` | Configured, DB needs creating |

**Required setup steps (all inside `ddev exec`):**

```bash
# Install test dependencies
ddev exec composer require --dev symfony/test-pack dama/doctrine-test-bundle doctrine/fixtures-bundle

# Create test DB and run migrations
ddev exec php bin/console doctrine:database:create --env=test
ddev exec php bin/console doctrine:migrations:migrate --no-interaction --env=test
```

**`phpunit.dist.xml` — add dama extension:**
```xml
<extensions>
    <bootstrap class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
</extensions>
```

**`.env.test.local`** (not committed — each developer creates this):
```
DATABASE_URL="pgsql://db:db@db:5432/pictureframe_test"
```

**`.env.test`** — add Messenger transport override so dispatched messages are observable in integration tests:
```
MESSENGER_TRANSPORT_DSN=in-memory://
```
Without this, `RenderImageMessage` dispatch assertions (IMG-01, SH-03, IMG-06) will silently not fire.

**Filesystem teardown for message handler tests:** `dama/doctrine-test-bundle` rolls back DB writes but NOT filesystem writes. Tests in `RenderImageMessageHandlerTest` that write `.bin` files to `storage/images/` must call a teardown that deletes the test image directory after each test. Use `setUp()`/`tearDown()` with a dedicated `storage/test/` prefix, or run handler tests in a separate suite without dama.

### 1.2 Frontend

| Tool | Role | Already present? |
|---|---|---|
| Vitest 2.x | Unit + component test runner (Vite-native) | Needs adding |
| `@vue/test-utils` | Mount Vue components in JSDOM | Needs adding |
| `@vitest/coverage-v8` | Coverage reporting | Needs adding |
| MSW 2.x | Intercept `fetch()` calls in tests | Needs adding |

Add to `frontend/package.json` devDependencies, then add `test` block to `vite.config.ts`:
```ts
test: {
  environment: 'jsdom',
  globals: true,
  setupFiles: ['./src/test/setup.ts'],
}
```

**Known gap:** `CropEditor.vue` and `StickerCanvas.vue` depend on Konva.js canvas APIs unavailable in JSDOM. These two components are excluded from component tests and do NOT count toward the 60% component coverage target. They require Playwright E2E or manual testing.

### 1.3 Firmware

| Tool | Role | Already present? |
|---|---|---|
| PlatformIO `native` env | Compiles C++ for host, runs Unity tests | Needs adding |
| Unity (bundled) | Assertions and test runner macros | Bundled |
| Hand-rolled Arduino mocks | Stubs for Arduino/ESP APIs | Needs writing |

**`platformio.ini` additions:**
```ini
[env:native-test]
platform = native
lib_deps =
    throwtheswitch/Unity@^2.6
build_flags = -DUNIT_TEST -Itest/mocks
test_build_src = no
```

`test_build_src = no` is **required** — `yes` would compile `setup()` and `loop()` from `main.cpp` for the host, colliding with PlatformIO's native `main()` shim (ODR violation / duplicate symbol linker error).

**HTTPClient injection — template, not polymorphism:** Arduino's `HTTPClient` methods are not virtual, so `normal_operation()` cannot accept an `HTTPClient&` base reference. Use a compile-time template instead:

```cpp
template<typename HTTP>
static void normal_operation_impl(const String& mac, HTTP& http) { ... }

// Production:
static void normal_operation(const String& mac) {
    WiFiClientSecure client;
    client.setInsecure();
    HTTPClient http;
    http.begin(client, url);
    normal_operation_impl(mac, http);
}
```

The mock `HTTP` type and the real `HTTPClient` share no base class — the template resolves at compile time. Production code is unchanged in behavior; tests instantiate with a mock type.

**Mock surface** — all files in `firmware/test/mocks/` (included before system headers via `-Itest/mocks` build flag):

| Mock file | Stubs | Notes |
|---|---|---|
| `Arduino.h` | `millis()`, `delay()`, `pinMode()`, `digitalRead()`, `String`, `Serial` | |
| `MockHTTPClient.h` | `begin()`, `GET()`, `header()`, `end()`, `writeToStream()`, `addHeader()`, `collectHeaders()` | `header()` must return empty string after `end()` is called — this is the FW-02 behavior |
| `WiFi.h` | `macAddress()`, `status()`, `begin()`, `mode()`, `softAP()`, `softAPIP()`, `localIP()` | |
| `WiFiClientSecure.h` | No-op constructor, `setInsecure()` | |
| `Preferences.h` | In-memory `std::map`-backed `getInt()`, `putInt()`, `getString()`, `putString()`, `clear()`, `begin()`, `end()` | |
| `LittleFS.h` / `FS.h` | In-memory file store; `writeToStream()` in `MockHTTPClient` pumps into this store | The wiring between HTTPClient mock and LittleFS mock must be explicit — mock HTTP holds a pointer to mock FS |
| `epd.h` | No-op stubs + call counters for each `epd_*` function | Shadows real header via `-Itest/mocks` — do NOT put mocks in `firmware/src/` |
| `esp_sleep.h` | Captures `sleepUs` in a global; separate `g_deepSleepStarted` boolean | Both `esp_sleep_enable_timer_wakeup` and `esp_deep_sleep_start` must be mocked |

---

## 2. Server Tests

### Test taxonomy

| Label used in this plan | Symfony base class | Scope |
|---|---|---|
| **Unit** | `PHPUnit\Framework\TestCase` | Pure PHP logic, mock repositories — no DB, no container |
| **Integration** | `KernelTestCase` + dama | Real DB, real container, no HTTP routing |
| **Functional** | `WebTestCase` + dama | Full HTTP stack: routing, firewall, kernel |

### 2.1 Unit Tests — `RotationService`

File: `tests/Unit/Service/RotationServiceTest.php`

Uses `PHPUnit\Framework\TestCase` with mock `EntityManager` and `DeviceImageHistoryRepository`. No DB, no container — pure selection algorithm logic.

| # | Test | What it asserts |
|---|---|---|
| R-01 | `advance_returns_null_when_pool_empty` | No ready RenderedAssets → `advance()` returns `null` |
| R-02 | `advance_picks_oldest_image_with_no_history` | Pool of 3 images, no history → returns oldest by `uploadedAt` |
| R-03 | `advance_skips_recently_shown_image` | Image A shown last → Image B returned next |
| R-04 | `advance_resets_when_all_candidates_in_window` | All pool images in uniqueness window → picks from full pool (no infinite loop) |
| R-05 | `advance_respects_uniqueness_window_size` | Window=2, pool=5 → only 2 IDs excluded |
| R-06 | `advance_writes_DeviceImageHistory_record` | `em->persist()` called with a `DeviceImageHistory` for the returned image |
| R-07 | `advance_updates_device_current_image` | `$device->getCurrentImage()` matches returned image |
| R-08 | `advance_only_considers_ready_assets` | Image with `pending` RenderedAsset excluded from pool |
| R-09 | `advance_respects_device_approved_images_only` | Image not approved for this device excluded from pool |
| R-10 | `advance_not_called_when_device_has_locked_image` | Controller bypasses `advance()` entirely when `lockedImage` set — assert `advance()` is never reached (test via controller, see I-06) |

### 2.2 Unit Tests — `TokenService`

File: `tests/Unit/Service/TokenServiceTest.php`

| # | Test | What it asserts |
|---|---|---|
| T-01 | `issue_creates_token_with_correct_type` | Returns `Token` with matching `TokenType` |
| T-02 | `issue_sets_expiry_in_future` | `expiresAt > now()` |
| T-03 | `consume_marks_token_used` | `usedAt` is set on the persisted entity |
| T-04 | `consume_returns_token_entity` | Return value is the `Token` object |
| T-05 | `consume_throws_on_expired_token` | Token past `expiresAt` → exception |
| T-06 | `consume_throws_on_already_used_token` | `usedAt` already set → exception |
| T-07 | `consume_throws_on_type_mismatch` | Requesting wrong `TokenType` → exception |

### 2.3 Unit Tests — `DeviceService`

File: `tests/Unit/Service/DeviceServiceTest.php`

| # | Test | What it asserts |
|---|---|---|
| D-01 | `link_associates_device_with_user` | `$device->getUser()` matches after link |
| D-02 | `link_idempotent_for_same_user` | Calling link twice does not throw or create duplicates |
| D-03 | `purgeHistory_removes_records_beyond_window` | History older than uniqueness window count is deleted |
| D-04 | `re_provision_clears_history_and_transfers_ownership` | Physical reset (clear credentials) wipes `DeviceImageHistory` and sets `user = null` on device — FR9 security invariant |

### 2.4 Functional Tests — `DeviceImageController`

File: `tests/Functional/Controller/DeviceImageControllerTest.php`

Uses `WebTestCase`. This is the highest-value test class — it covers the entire firmware-facing API contract.

| # | Route | Setup | Expected response |
|---|---|---|---|
| I-01 | `GET /api/device/{unknownMac}/image` | MAC not in DB | 404 |
| I-02 | `GET /api/device/{mac}/image` | Device exists, no approved images | 204 |
| I-03 | `GET /api/device/{mac}/image` | One ready image, no `X-Current-Image-Id` | 200, body is binary, `X-Image-Id` set, `X-Interval-Ms` set |
| I-04 | `GET /api/device/{mac}/image` + `X-Current-Image-Id: {id}` | ID matches current image | 304, no body, headers still set |
| I-05 | `GET /api/device/{mac}/image` + `X-Current-Image-Id: {staleId}` | ID does NOT match current image | 200, new image served |
| I-06 | `GET /api/device/{mac}/image` | Locked image set, no `X-Current-Image-Id` | 200, locked image served; `RotationService::advance()` NOT called (spy/mock) |
| I-07 | `GET /api/device/{mac}/image` + `X-Current-Image-Id: {lockedId}` | Locked image = current image | 304 |
| I-08 | `GET /api/device/{mac}/image` twice, second call changes image | Rotation advances: first call returns Image A, second call (different `X-Current-Image-Id`) returns Image B | Use a single-request test asserting `currentImage` changed in DB, not two HTTP calls through dama rollback |
| I-09 | `GET /api/device/{mac}/image` | `X-Interval-Ms` value | `rotationIntervalMinutes * 60 * 1000`, bounded by server ceiling |
| I-10 | `GET /api/device/{mac}/image` 200 response | `lastSeenAt` side effect | `device.lastSeenAt` updated after 200 poll |
| I-11 | `GET /api/device/{mac}/image` 304 response | `lastSeenAt` side effect | `device.lastSeenAt` also updated after 304 — a 304 is a successful poll |

### 2.5 Functional Tests — `DeviceApiController`

File: `tests/Functional/Controller/DeviceApiControllerTest.php`

| # | Route | Notes |
|---|---|---|
| A-01 | `GET /api/devices` (authenticated) | Returns only requesting user's devices |
| A-02 | `GET /api/devices` (unauthenticated) | 401 |
| A-03 | `PATCH /api/devices/{id}` | Updates name, orientation, rotationIntervalMinutes |
| A-04 | `PATCH /api/devices/{id}` — wrong user | 403 / 404 |
| A-05 | `PUT /api/devices/{id}/lock` | Sets `lockedImage`, response includes `lockedImageId` |
| A-06 | `PUT /api/devices/{id}/lock` — image not approved for device | 422 |
| A-07 | `DELETE /api/devices/{id}/lock` | Clears `lockedImage`, response has `lockedImageId: null` |
| A-08 | `PUT /api/devices/{id}/lock` — wrong user's device | 403 |

### 2.6 Functional Tests — `ImageApiController`

File: `tests/Functional/Controller/ImageApiControllerTest.php`

| # | Route | Notes |
|---|---|---|
| IMG-01 | `POST /api/images` | Valid upload → 201, `Image` in DB, `RenderImageMessage` in in-memory transport |
| IMG-02 | `POST /api/images` | No file → 422 |
| IMG-03 | `GET /api/images` | Returns authenticated user's images with thumbnail/original URLs |
| IMG-04 | `DELETE /api/images/{id}` | Soft-deletes image (`deletedAt` set, row still exists) |
| IMG-05 | `DELETE /api/images/{id}` — wrong user | 403 |
| IMG-06 | `POST /api/images/{id}/reprocess` | New `RenderImageMessage` in in-memory transport |

### 2.7 Functional Tests — `SharedImageApiController`

File: `tests/Functional/Controller/SharedImageApiControllerTest.php`

| # | Route | Notes |
|---|---|---|
| SH-01 | `GET /api/shared` | Paginated result, `totalPages`/`total`/`page` fields correct |
| SH-02 | `GET /api/shared?status=pending` | Filters by status |
| SH-03 | `PUT /api/shared/{id}/approve` | Status → approved, `RenderImageMessage` in in-memory transport |
| SH-04 | `PUT /api/shared/{id}/decline` | Status → declined |
| SH-05 | `PUT /api/shared/{id}/approve` — wrong user | 403 |
| SH-06 | Approve → image enters rotation pool | After approval + render, image appears in `RotationService::readyPool()` for the recipient's device |

### 2.8 Functional Tests — `TokenActionController`

File: `tests/Functional/Controller/TokenActionControllerTest.php`

| # | Test | Notes |
|---|---|---|
| TK-01 | Valid `share_approve` token | Approval performed, token marked used, redirected |
| TK-02 | Expired token | 404 or 410 |
| TK-03 | Already-used token | 404 or 410 |
| TK-04 | Wrong token type | 404 |
| TK-05 | `hard_delete_confirm` token | Image hard-deleted from DB and storage |

### 2.9 Functional Tests — `SetupController`

File: `tests/Functional/Controller/SetupControllerTest.php`

| # | Route | Notes |
|---|---|---|
| S-01 | `GET /setup/{mac}` — new device | Shows registration/login form |
| S-02 | `GET /setup/{mac}` — already-linked device | Redirects to configure step |
| S-03 | `POST /setup/{mac}/register` | Creates User, links Device to User |
| S-04 | `POST /setup/{mac}/register` — duplicate email | Validation error |
| S-05 | `POST /setup/{mac}/configure` | Saves `name`, `orientation`, `rotationIntervalMinutes` |

### 2.10 Functional Tests — `SecurityController`

File: `tests/Functional/Controller/SecurityControllerTest.php`

| # | Route | Notes |
|---|---|---|
| SEC-01 | `POST /login` valid credentials | Session cookie set, redirected |
| SEC-02 | `POST /login` wrong password | Error message, no session |
| SEC-03 | `POST /register` | Creates user with hashed password |
| SEC-04 | `POST /register` duplicate email | Form error |

### 2.11 Integration Tests — Message Handlers

**Note:** These tests run without `dama/doctrine-test-bundle` transaction wrapping because they write to the filesystem. Run in a separate PHPUnit suite (`tests/Integration/`) with their own teardown strategy: each test uses a unique `storage/test/{uuid}/` path, cleaned up in `tearDown()`.

File: `tests/Integration/MessageHandler/RenderImageMessageHandlerTest.php`

Requires Imagick installed (present in DDEV via `php8.4-imagick`). Run `ddev exec` — not on host.

| # | Test | Notes |
|---|---|---|
| MH-01 | Valid image + device model | `RenderedAsset.status` = `ready`, `.bin` file written to storage path |
| MH-01b | **4bpp palette contract** | Reads first bytes of written `.bin`; asserts palette indices match Spectra 6 map (BLACK=0x0, WHITE=0x1, YELLOW=0x2, RED=0x3, BLUE=0x5, GREEN=0x6) — prevents a server-side Imagick palette bug from silently producing a garbled display |
| MH-02 | Missing source image file | `RenderedAsset.status` = `failed`, no uncaught exception |
| MH-03 | Both orientations | Two `RenderedAsset` rows for same image (landscape + portrait) |

File: `tests/Integration/MessageHandler/RunImageCleanupMessageHandlerTest.php`

| # | Test | Notes |
|---|---|---|
| CL-01 | Image past retention with approvals | Soft-delete flag set |
| CL-02 | Image past retention with no approvals | Hard-deleted from DB and storage |
| CL-03 | Recent image | Untouched |

---

## 3. Frontend Tests

### 3.1 Store Tests

**`tests/frontend/stores/devices.test.ts`**

| # | Test | Notes |
|---|---|---|
| DS-01 | `fetchDevices` success | `devices` populated, `loading` false |
| DS-02 | `fetchDevices` network error | `error` set, `devices` empty |
| DS-03 | `updateDevice` patches local array | `devices[idx]` updated after PATCH |
| DS-04 | `lockImage` sets `lockedImageId` on local device | Updated from server response |
| DS-05 | `unlockImage` clears `lockedImageId` | `null` in local state |

**`tests/frontend/stores/images.test.ts`**

| # | Test | Notes |
|---|---|---|
| IM-01 | `fetchImages` success | `images` populated |
| IM-02 | Upload workflow state transitions | `setFile` → `setCrop` → `setStickers` → `setDevices` in correct order |
| IM-03 | `pendingCount` computed | Reflects `status === 'pending'` images |

**`tests/frontend/stores/auth.test.ts`**

| # | Test | Notes |
|---|---|---|
| AU-01 | Bootstraps from `window.__BOOTSTRAP_USER__` | `user` set, `isAuthenticated` true |
| AU-02 | No bootstrap data | `isAuthenticated` false |

**`tests/frontend/stores/toast.test.ts`**

| # | Test | Notes |
|---|---|---|
| TO-01 | `push` adds message | Queue length +1 |
| TO-02 | Auto-dismiss after 2.5s | Queue empty after timeout (Vitest fake timers) |

### 3.2 Component Tests

**`tests/frontend/components/FrameCard.test.ts`**

| # | Test | Notes |
|---|---|---|
| FC-01 | Status "ok" renders green badge | `lastSeenAt` recent |
| FC-02 | Status "sync-fail" renders yellow badge | `lastSeenAt` >2× interval ago |
| FC-03 | Status "no-wifi" badge | `lastSeenAt` null |

**`tests/frontend/components/BaseButton.test.ts`**

| # | Test | Notes |
|---|---|---|
| BB-01 | Loading spinner when `loading=true` | Label hidden |
| BB-02 | Disabled when `disabled=true` | `disabled` attribute present |
| BB-03 | Emits `click` when not disabled | |

**`tests/frontend/components/DevicePicker.test.ts`**

| # | Test | Notes |
|---|---|---|
| DP-01 | Selecting device emits `update:modelValue` | |
| DP-02 | Deselecting removes from list | |
| DP-03 | Pre-selected devices shown as checked | |

**`tests/frontend/components/ShareSheet.test.ts`**

| # | Test | Notes |
|---|---|---|
| SS-01 | Valid email submits share API call | `POST /api/share` intercepted by MSW |
| SS-02 | Empty email shows validation error, no API call | |
| SS-03 | Success closes sheet | |

### 3.3 View Integration Tests

**`tests/frontend/views/LibraryView.test.ts`**

| # | Test | Notes |
|---|---|---|
| LV-01 | Mounted in "uploaded" tab | Image grid renders |
| LV-02 | Switch to "shared" tab | `fetchShared` called, ApproveCards render |
| LV-03 | Lock chip shown for approved devices | Chip present below image |
| LV-04 | Unlocked chip click calls `lockImage` | Store action called |
| LV-05 | Locked chip (solid) click calls `unlockImage` | Store action called |
| LV-06 | Share button opens ShareSheet | `shareSheetOpen` true |
| LV-07 | Empty library state | Empty-state prompt visible when `images.length === 0` |

**`tests/frontend/views/HomeView.test.ts`**

| # | Test | Notes |
|---|---|---|
| HV-01 | Renders FrameCard for each device | N cards for N devices |
| HV-02 | Empty state shown when no devices | Prompt/empty message visible |

---

## 4. Firmware Tests

### 4.1 Architecture

PlatformIO `native` env (`test_build_src = no`) compiles only `firmware/test/` test files and the specific source files they include explicitly. `setup()` and `loop()` from `main.cpp` are excluded — they would collide with PlatformIO's native `main()` shim.

The testable logic in `main.cpp` is extracted into:
- `operation.cpp` / `operation.h` — `normal_operation_impl<HTTP>(mac, http)` template function
- `provisioning.cpp` / `provisioning.h` — `ap_ssid_from_mac()`, `attempt_wifi()`

`epd.h` is shadowed by `firmware/test/mocks/epd.h` via the `-Itest/mocks` build flag in `[env:native-test]`.

### 4.2 Unit Tests — `normal_operation_impl()` control flow

File: `firmware/test/test_normal_operation.cpp`

| # | Setup | Expected |
|---|---|---|
| FW-01 | HTTP 200, `X-Image-Id: 42`, `X-Interval-Ms: 60000` | File written to mock FS, `epd_draw_image_from_file` call count = 1, NVS `img_id = 42`, sleep = 60000ms, `g_deepSleepStarted = true` |
| FW-02 | HTTP 200, assert header read order | `newId` is `"42"` (non-empty) — verifies headers read BEFORE `http.end()`. **Regression test for NVS bug.** |
| FW-03 | HTTP 304 | `epd_init` call count = 0, `epd_draw_image_from_file` call count = 0, sleep set from `X-Interval-Ms`, `g_deepSleepStarted = true` |
| FW-04 | HTTP 204 | `show_setup_qr` (or `epd_draw_setup_screen`) call count = 1, `epd_draw_image_from_file` call count = 0 |
| FW-05 | HTTP 404 | `show_setup_qr` call count = 1 |
| FW-06 | HTTP 500 | `epd_fill(COLOR_YELLOW)` called |
| FW-07 | NVS has saved `img_id = 99` | `X-Current-Image-Id: 99` present in request headers sent by mock HTTP |
| FW-08 | NVS `img_id` at default (-1) | `X-Current-Image-Id` header NOT added to request |
| FW-09 | `X-Interval-Ms: 120000`, `FETCH_INTERVAL_MS = 300000` | `sleepMs = 120000` (server value honored, within ceiling) |
| FW-10 | `X-Interval-Ms: 600000`, `FETCH_INTERVAL_MS = 300000` | `sleepMs = 300000` (capped at firmware ceiling) |
| FW-11 | `X-Interval-Ms` absent | `sleepMs = FETCH_INTERVAL_MS` (default) |

### 4.3 Unit Tests — Provisioning

File: `firmware/test/test_provisioning.cpp`

| # | Test | Expected |
|---|---|---|
| FW-12 | MAC `AA:BB:CC:DD:EE:FF` | SSID = `PictureFrame-EEFF` |
| FW-13 | MAC `1C:C3:AB:D1:91:F8` | SSID = `PictureFrame-91F8` |
| FW-14 | WiFi connects within timeout | `attempt_wifi()` returns `true` |
| FW-15 | WiFi never connects | `attempt_wifi()` returns `false` after `WIFI_TIMEOUT_MS` |
| FW-16 | WiFi connect fails → re-provisioning | `loop()` on `attempt_wifi()` failure: `epd_fill(COLOR_RED)` called, then `enter_provisioning()` called — confirms provisioning state machine re-enters correctly |

### 4.4 Unit Tests — Button-hold re-provisioning

File: `firmware/test/test_reset_button.cpp`

| # | Test | Expected |
|---|---|---|
| FW-17 | `digitalRead(PIN_BTN_RESET)` returns `LOW` for ≥ `RESET_HOLD_MS` | `clear_creds = true`, `prefs.clear()` called, provisioning mode entered |
| FW-18 | Button released before `RESET_HOLD_MS` | `clear_creds = false`, normal boot continues |

---

## 5. Coverage Targets

| Layer | Target | Priority |
|---|---|---|
| `RotationService` | 100% line | Critical |
| `DeviceImageController` | 100% line | Critical |
| `TokenService` | 95%+ | High |
| All other `src/Service/` | 80%+ | High |
| All `src/Controller/` | 80%+ | High |
| `src/MessageHandler/` | 85%+ | High (rendering pipeline is high-risk) |
| Frontend stores | 80%+ | High |
| Frontend components | 60%+ (excluding CropEditor, StickerCanvas) | Medium |
| Firmware control flow (`operation.cpp`) | 90%+ branches | High |

---

## 6. CI Integration

All three suites must pass before merge to `master`.

```bash
# Server
ddev exec php bin/phpunit --coverage-text

# Firmware
cd firmware && pio test -e native-test

# Frontend
cd frontend && npx vitest run --coverage
```

---

## 7. Implementation Order

**Phase 1 — Server functional + integration tests** (highest ROI, minimal new tooling)
1. `composer require --dev symfony/test-pack dama/doctrine-test-bundle doctrine/fixtures-bundle`
2. Register dama extension in `phpunit.dist.xml`
3. Add `MESSENGER_TRANSPORT_DSN=in-memory://` to `.env.test`
4. Create + migrate test DB
5. Write `DeviceImageControllerTest` (I-01 through I-11) — covers the firmware API contract
6. Write `RotationServiceTest` (R-01 through R-09) — pure unit, no DB
7. Write remaining controller + service tests

**Phase 2 — Firmware native tests** (second priority — protects the one thing that requires a physical reflash)
1. Extract `normal_operation_impl<HTTP>()` template into `operation.cpp`
2. Add `[env:native-test]` to `platformio.ini` (`test_build_src = no`)
3. Write mock headers in `firmware/test/mocks/`
4. Write `test_normal_operation.cpp` (FW-01 through FW-11, FW-02 is the regression test)
5. Write `test_provisioning.cpp` (FW-12 through FW-16)
6. Write `test_reset_button.cpp` (FW-17 through FW-18)

**Phase 3 — Frontend unit tests**
1. Add Vitest + `@vue/test-utils` + MSW to `package.json`
2. Add `test` block to `vite.config.ts`
3. Write store tests (no DOM needed — fastest to write)
4. Write component tests

**Phase 4 — CI pipeline**
1. Add Gitea Actions workflow
2. Wire all three test commands in sequence

---

## 8. Known Gaps (explicitly out of scope for V1 tests)

- `CropEditor.vue` and `StickerCanvas.vue` — Konva.js canvas not testable in JSDOM; require Playwright E2E
- Admin moderation panel (`AdminModerationController`) — not yet implemented
- Collections approval (FR21) — not yet implemented
- E-ink display pixel-level rendering correctness — no practical automated test; validated by eye on hardware
- Interval timing drift accuracy (±5 min PRD requirement) — requires a time-series integration test; deferred to post-V1
