--- 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 ``` **`.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 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(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()` 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