Files
football2801 4002ff9fbf
CI / test (push) Has been cancelled
chore: stage all in-progress work before repo split
Web app: new entities (Image, RenderedAsset, SharedImage, Token,
DeviceImageHistory), enums, repositories, controllers, message handlers,
migrations, tests, frontend upload/library/sticker UI, Vue components.

Firmware: EPD background screen binaries + gen scripts, setup_bg header.

Infra: ddev config, test bundle, gitignore coverage dir.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 12:11:31 -04:00

25 KiB
Raw Permalink Blame History

status, createdAt, reviewedBy
status createdAt reviewedBy
reviewed 2026-05-06
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):

# 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:

<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:

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:

[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 requiredyes 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:

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 setFilesetCropsetStickerssetDevices 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.hnormal_operation_impl<HTTP>(mac, http) template function
  • provisioning.cpp / provisioning.hap_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.

# 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