# Test Automation Backlog

Recommended after the Laravel 9→13 migration QA is signed off. Today the only
tests in the repo are the stock `tests/Feature/ExampleTest.php` and
`tests/Unit/ExampleTest.php` placeholders — everything below is net-new.

Ordered by risk, not by ease. P0s are things that are either a security
surface or move money; P2s are nice-to-have coverage.

---

## P0 — security-sensitive / no-auth surfaces

| Area | Type | Status | Why |
|---|---|---|---|
| Public poll link endpoints (`hashids`+`logger`, no `auth:sanctum`) — poll fetch | Feature (`Tests\Feature`) | ✅ Done — [`tests/Feature/PublicPollFetchTest.php`](tests/Feature/PublicPollFetchTest.php) (admin/stranger/sub-admin cases for `GET /api/poll/{id}/{key?}`, plus an authenticated-participant-without-a-key case) | Reachable by anyone holding a poll/participant/invitation hashid. `Design.md` documents open ambiguity here (temp-participant cleanup, re-answering via the same link) — tests pin down actual behavior and catch regressions in access control, not just happy-path shape. Also covers a real bug: a bare `/pr/{pollId}` link (no key) to a hidden poll used to strip `preference_list` from *every* participant, including the authenticated viewer's own row, crashing the frontend (`RenderData.compareChoices`, `RenderMixins.isChoiceVotesLimitReached`, and a third unguarded site, `RenderMixins.isAnswerEmpty`, found the same day on a real production poll). Server-side `preference_list` redaction is now **disabled** pending a proper fix — see SEC-009 in `SECURITY-BACKLOG.md` — because the backend's strict `user_id` identity match and the frontend's looser "current user" matching (`ModalParticipantMixins.identifyCurrentUser`) aren't proven equivalent, and kept crashing the page for real participants viewing their own poll |
| Poll answer-matrix pagination (`?paginate=1` shell mode, new `GET /api/poll/{id}/{key}/preferences`) | Feature + Unit | ✅ Done — [`tests/Feature/PollPaginationAndPreferencesTest.php`](tests/Feature/PollPaginationAndPreferencesTest.php), [`tests/Unit/Services/PollAccessResolverTest.php`](tests/Unit/Services/PollAccessResolverTest.php) | Built to fix a real 500 on large polls (~250k pivot rows at 500×500 crashed the full-payload endpoint before pagination) — that part stands independent of `hiddenPoll`. Admin/sub-admin resolution was extracted into `App\Services\PollAccessResolver`, shared by both endpoints. `PollAccessResolver` also falls back to matching the requester's own participant row by authenticated `user_id` when the URL key is absent/unmatched — being logged in as that participant is treated as at least as strong an identity signal as a hashid key in the URL, consistent with how the poll owner was already recognized via `user_id` regardless of key. This fallback is still verified correct for **email** visibility (`isSubAdmin`/`isAdmin` gating, unaffected by the SEC-009 rollback below), but `hiddenPoll`'s `preference_list` redaction itself — the privacy gap this work originally closed — is disabled again pending SEC-009 in `SECURITY-BACKLOG.md`; the tests here now document the current (redaction-disabled) behavior rather than the enforced one. **5.10.2026:** keyless `GET /poll/{id}/preferences` (a logged-in owner opening `/pr/{id}`; before, `getPoll`'s catch-all route read "preferences" as the key and returned 500) and a separate cheap `GET /poll/{id}[/{key}]/version` for the 20 s sync, backed by `App\Services\PollVersionResolver` (poll-wide, also sent as `X-Poll-Version` on the lean poll and on `/preferences`). The version includes row counts and `sum(value*id)` on purpose: pivot writes (`updateExistingPivot`, `attach`) leave `preferences.updated_at` untouched/NULL and a delete moves no `max()`, so a timestamp-only version missed answer changes and deletions (6 tests, incl. both). Temp participants are excluded from the version |
| Adding a choice writes its "no answer" rows in one bulk insert (`PollController::addNewChoice`) | Feature | ✅ Done — [`tests/Feature/PollChoiceCreationTest.php`](tests/Feature/PollChoiceCreationTest.php) (4 tests) | Was one `Preference::create()` **and** one `Log::info` per participant per added choice — 580 of each on the load-test poll, 58k inserts for a 100-term import. Now one chunked `insert()`; timestamps are written explicitly because `insert()` bypasses Eloquent and `Preference` has timestamps on (`create()` wrote them, and the version resolver reads `max(updated_at)`). Temp participants still get rows, as before. The guard against a regression is a query-count assertion (1 statement for 10 participants), not just the resulting rows. Response shape is untouched, so both frontends are unaffected. Writing the tests turned up a factory trap worth knowing: `ParticipantFactory::configure()` already inserts a preference row with a **random** 0/1 value for every choice that exists at the time the participant is created, with NULL timestamps — so a test that creates a choice before its participants must `updateOrCreate` its answers, never `create`, and must not assume an unanswered grid |
| A save writes only the answer rows it changes (`ParticipantController::applyChoiceUpdate`) | Feature | ✅ Done — [`tests/Feature/ParticipantAnswerSaveTest.php`](tests/Feature/ParticipantAnswerSaveTest.php) (7 tests) | Both frontends post **every** choice of the poll on every save, so one UPDATE ran per term regardless of what changed — 500 statements per save on a 500-term poll, inside the transaction and under the choice locks. The comparison reuses the row already read for the capacity check (extended to carry `answers`), so it costs no extra query. `committed_at` needs no special case: with an unchanged value neither of its branches fires. Two traps the tests pin: (1) the owner notification is gated on `$sendEmail != -1`, where `$sendEmail` is the **affected-row count of the last UPDATE** — skipping every row would have left it at -1 and silently stopped notifying anyone who re-saves the same answers, so a skipped row counts as handled; (2) a choice with no stored row keeps the old path (the UPDATE matches nothing) rather than being treated as unchanged. 6 of the 7 fail without the change; the notification one passes either way by design, since it guards what must *not* change |
| `?paginate=1` on `POST /poll/choices/{id}` returns the slim shape (`PollController::updatePollChoices`) | Feature | ✅ Done — [`tests/Feature/PollChoicesResponseTest.php`](tests/Feature/PollChoicesResponseTest.php) (6 tests) | The response eager-loaded `participants.choices`, i.e. one model per answered cell — the same hydration that 500s on the full poll payload, so on a large poll the save committed and *then* died building its reply. The flag is deliberately the same one `getPoll` already uses, rather than a `/v3/` route (this codebase's `/v2/participant` precedent left `V2_changeParticipantChoiceOld` behind as dead code). **The default response is unchanged on purpose and must stay so:** the classic frontend replaces its entire participants array from it, answers included ([`frontend/src/store/modules/poll.js`](frontend/src/store/modules/poll.js) `updateChoices`), so switching the shape by poll size instead of by flag would blank its grid and let the next save overwrite real answers with "no answer". A test asserts zero reads of `preferences` under the flag and more than zero without it. Also replaces the N×M nested payload scan in `removeAndUpdateChoices` with an id-keyed lookup, with a test for the first-occurrence-wins behaviour the old inner `break` gave duplicate ids |
| `?paginate=1` also drops the poll-wide invitation list (`PollController::getPoll`) | Feature | ✅ Done — [`tests/Feature/PollPaginationAndPreferencesTest.php`](tests/Feature/PollPaginationAndPreferencesTest.php) | Measured on test with the load-test poll (580 participants, 38 choices): the lean response is 686 KB / 1.7 s, of which participants are 612 KB and the invitation list 118 KB. No paginated client reads the poll-wide list — each participant carries its own `invitation`, which is what the views use — while the unflagged response keeps it, because the classic frontend rebuilds its invitation list from there. Same release removed a `Log::info` that serialized **all 578 invitations into the log on every poll load**; only the count is logged now. Test trap worth knowing: `Participant::invitation()` is a `hasOne` on `invitations.participant_id`, so setting only `participants.invitation_id` leaves the relation null and the "participants still carry their own invitation" assertion fails for the wrong reason |
| Poll version split into `shell_version` / `answers_version` (`PollVersionResolver`) | Feature | ✅ Done — [`tests/Feature/PollPaginationAndPreferencesTest.php`](tests/Feature/PollPaginationAndPreferencesTest.php) (4 tests) | The two halves cost very different amounts to reload: answers are one small request per 50 choices (0.3 s), the shell is the whole participant list (686 KB / 1.7 s). Someone answering is the common case and moves only `answers_version`, so the client refreshes the cheap half. **The subtle part:** the shell version deliberately does *not* use `max(participants.updated_at)`, because the answer write path ends in `$participant->touch()` — including it would move the shell on every answer and defeat the split entirely. It uses count + max(id) + max(created_at) instead, which still sees participants added or removed. The cost is that a plain **rename** moves nothing the version watches, so it reaches other viewers only with the next structural change or the client's 5-minute forced reload; custom field values are covered separately via `participant_values`. Tests pin: an answer (plus the touch) moves answers only, a new participant and a deleted one move the shell, and a changed field value moves the shell. That last test needs `$this->travel(2)->seconds()` — `updated_at` has second granularity, so an edit in the same second as the row's creation is invisible to the version, which is a real property of this design, not a test artifact |
| Public poll link endpoints — participant create/answer, comment add | Feature | ✅ Done — [`tests/Feature/PublicPollParticipantActionsTest.php`](tests/Feature/PublicPollParticipantActionsTest.php) (`POST /api/poll/{id}`, `/api/participant/{id}`, `/api/comment/{pollId}`) | Same endpoint group as `PublicPollFetchTest`. Found and fixed two undefined-key/property bugs along the way: `CommentController::addComment()` 500'd if a client omitted `comment.userId` (fixed: `if (!empty($newComment->userId))`), and `Poll::isHidden()` 500'd if `options['hiddenPoll']` was unset on legacy polls (fixed: defaults to `false`, matching the frontend's own default for new polls). Tests exercise the real default behavior, no workarounds needed |
| Public poll link endpoints — participant removal (`DELETE /api/participant/{id}`) | Feature | ✅ Done — [`tests/Feature/ParticipantRemovalTest.php`](tests/Feature/ParticipantRemovalTest.php) (4 tests) | Fixes a reproducible 500 on the bare `/pr/{pollId}` link. The controller deleted the participant's invitations *before* checking the participant existed, then read an undefined `$pollId`, which Laravel promotes to an ErrorException — so a repeated delete destroyed rows and answered 500 (the SPA's axios interceptor turns that into an "Oops, something went wrong!" alert). Confirmed with temporary instrumentation before fixing: both deletes came from `RenderData.cancelResponse`, which returned early without clearing `submittingParticipant` whenever `publicLinkKey` was null — always the case on a bare link, since `setPublicLinkKey` is only committed from `Poll.vue`'s `updateInvitation(key)`. The stale reference kept the view in submitting mode, so a second cancel click re-sent the same id. Endpoint is now idempotent (404 rather than an empty 200, which the Vuex `removeParticipant` action would have committed straight into the store and wiped the poll's invitations). Tests also cover the second real path: `PollController::getPoll` sweeps every `temp` participant of a poll on every read, for any visitor, so another tab can delete the row underneath an open session. 3 of the 4 tests fail on the unfixed controller; the happy-path one correctly passes either way |
| Public poll link endpoints — participant rename (`PUT /api/participant/name/{id}`) | Feature | ✅ Done — [`tests/Feature/ParticipantNameUpdateTest.php`](tests/Feature/ParticipantNameUpdateTest.php) (4 tests) | Fixes a 500 for every caller (Bolt's finding G6). The comment lookup was `where([['poll_id' => $id], ['user_id' => $uid]])`, which Laravel builds as ``where (`5` is null and `2` is null)`` — an "Unknown column" on MySQL — and the rename then wrote a `name` column that `comments` does not have (it is `user_name`). The participant was saved first, so the 500 hid a rename that had happened. Comments only carry `user_id`, so a participant without an account must not touch any: matching `user_id IS NULL` would rename every anonymous comment of the poll, and a test guards that. An unknown participant is now 404 instead of a null-dereference 500. SQLite reads the bad column as a string, so on the in-memory test database the bug only shows as comments never being renamed. Still open, deliberately not changed here: the route sits in the no-auth group and checks nothing about the caller, so anyone holding a participant id can rename it. |
| `hashids` middleware itself (`Converter`, `RequestDecoder`/`ResponseEncoder`, `Checker`) | Unit | ✅ Partial — [`tests/Unit/HashIds/CheckerTest.php`](tests/Unit/HashIds/CheckerTest.php) covers `Checker`'s field-matching rules against the live `config/hashids.php`; `RequestDecoder`/`ResponseEncoder` round-trip is exercised indirectly through `PublicPollFetchTest`'s real HTTP requests, but has no dedicated unit test yet | Every ID on the wire depends on this round-tripping correctly and the blacklist correctly deciding what's "an id". A bug here is an IDOR risk, not just a broken feature |
| CORS (`config/cors.php`, `HandleCors`) | Feature | ✅ Done — [`tests/Feature/CorsTest.php`](tests/Feature/CorsTest.php) | Directly motivated by the invalid `allowed_origins:['*']` + `supports_credentials:true` combo found in `MIGRATION-LOG.md`; fixed to an env-driven allowlist (`CORS_ALLOWED_ORIGINS`) and tested so it can't silently regress back to a wildcard |
| CSRF (Sanctum/`PreventRequestForgery` `Sec-Fetch-Site` check) | — | ✅ Verified, no action needed | Reviewed against `laravel/framework#59431`; the reported "bypass" is the framework's intended Fetch-Metadata defense (same-origin requests proven by the browser skip the token check) and isn't exploitable via real cross-site CSRF. No test needed beyond the existing CORS coverage |
| Invitation `visited` semantics | Feature | ✅ Done — [`tests/Feature/InvitationVisitedTest.php`](tests/Feature/InvitationVisitedTest.php) | `Design.md`'s Q6 resolved and documented in place: `visited` means "personal link opened at least once," not "has answered" — set once by `POST /invitation/visit/{id}`, read back by nothing in the backend, purely a display signal (open/closed envelope icon). Tests lock in the default, the set behavior, idempotency, and that it's not an access gate. Known unresolved limitation (crawler/link-preview bots can false-positive it) documented but intentionally not fixed, same as the sibling `participants.temp` concern |
| Profile update (`PUT /api/user`), SEC-001 | Feature | ✅ Done — [`tests/Feature/UserProfileUpdateTest.php`](tests/Feature/UserProfileUpdateTest.php) (14 tests) | Fixes a verified account takeover (see `SECURITY-BACKLOG.md` SEC-001): the controller changed whichever user the request body `id` named, and user hashids are embedded in every public poll link. Now acts only on the logged-in user. Validation was sized against the real users table: most users have an empty name (which also fixes an existing silent 500 when they saved their profile), 15 locales are stored, and old non-email values keep saving while unchanged. 8 tests failed on the unfixed code; putting the vulnerable lookup back fails exactly the 3 cross-user tests. One test initially passed on the vulnerable code for the wrong reason (a factory model has `locale = null`, so the request 500'd and the victim stayed unchanged). The attack request is now asserted to succeed and the payload uses fresh DB values |
| Account deletion (`POST /api/user/delete`), SEC-002 | Feature | ✅ Done — [`tests/Feature/UserAccountDeleteTest.php`](tests/Feature/UserAccountDeleteTest.php) (12 tests) | Fixes a verified P0: any logged-in user could wipe any account by typing its email in the delete dialog. The dialog is admin-only, so admins (`ROLE_ADMIN`, checked on the server) delete the account with the typed email; everyone else only their own, where the typed email is just a confirmation that must match. The 4 admin tests fail if the admin branch is dropped; the attack tests fail if everyone is treated as admin. Keeps the 200 + message contract the dialog depends on, and gives the same answer for registered and unknown emails (closes an enumeration leak). 4 tests failed on the unfixed code; the mutation back to lookup-by-email fails 3 |
| User record + unsubscribe (`GET /api/users/{id}`, `POST /api/users/{id}/unsubscribe`), SEC-003 | Feature | ✅ Done — [`tests/Feature/UserDataExposureTest.php`](tests/Feature/UserDataExposureTest.php) (9 tests) | Fixes a verified P0: both routes returned a full user record (email, Mollie customer id...) to anonymous visitors holding a user hashid, which is in every public poll link. `GET` now needs login and answers only for yourself or an admin; the unsubscribe link stays login-free for emails already sent but returns only `{name}`. 7 tests failed on the unfixed code; one mutation per safeguard (route auth, owner/admin check, trimmed response) is each caught. A first mutation run falsely showed two of them uncaught: the substitution command hadn't applied. Rerun with verified diffs |
| Beta role assignment (`POST /api/users/{user}/assign-beta`) | Feature | Not started — tracked as SEC-004 in [`SECURITY-BACKLOG.md`](SECURITY-BACKLOG.md) | Verified exploitable with a throwaway probe. Regression tests belong with the fix, on its own branch |

## P0 — money

| Area | Type | Status | Why |
|---|---|---|---|
| Mollie payment webhook (`/payment/webhook`) | Feature | ✅ Done — [`tests/Feature/PaymentWebhookTest.php`](tests/Feature/PaymentWebhookTest.php) | Required a small, careful DI refactor first: `PaymentController` built its Mollie client inline (`new MollieApiClient()` inside `App\Classes\Mollie::initialize()`), which nothing could intercept. Bound `MollieApiClient::class` in `AppServiceProvider` (same construction, same API key — production behavior verified identical via `config:cache`/`route:cache` and direct container resolution checks) and added it as a constructor param; the 4 other `Mollie::initialize()` call sites are untouched. Tests cover: missing payment id (400), payment not found (404), no matching local subscription (404), duplicate webhook no-op, failed payment status update, and the full successful-payment path (subscription update, invoice creation, premium role grant) |
| Admin notifications for new subscription / renewal / failed renewal | Feature | ✅ Done — [`tests/Feature/SubscriptionAdminNotificationTest.php`](tests/Feature/SubscriptionAdminNotificationTest.php) (12 tests) | Feature: `MAIL_SUBSCRIPTION_ALERT` gets one mail per event. Covers renewal (details incl. amount and new end date), repeated webhook stays silent, failed renewal, the first payment's own webhook stays silent (activation announces it instead), new subscription via `checkStatusAndMakeSubscription` (once, not again on a second call), failed first payment silent, a mail failure does not break the webhook, the mail shows the amount Mollie charged (not the current list price; falls back to list price if absent; failed renewals show the attempted amount), recipient fallback to the support address, comma-separated list with junk dropped. Mutation check: disabling the renewal, failed or new hook each fails at least one test. A first version of the tests passed for the wrong reason: `payment_id` is not mass-assignable on `Subscription`, so `Subscription::create(['payment_id' => …])` silently stored null and the duplicate-webhook guard never fired; helper now uses `forceFill` |
| Subscription webhook (`/subscription/webhook`) + `SyncMollieInvoices` | Feature | Not started | `MIGRATION-LOG.md` hop 2 already found and fixed a real bug in this exact command (Carbon 3 sign flip broke the "within 24h of start_date" check) — this class of date-math bug is exactly what regression tests are for |
| `Membership`/`Subscription`/`Invoice` state transitions | Unit/Feature | Not started | No coverage of upgrade/downgrade/cancel logic; these are the kind of edge cases that are easy to break silently |

## P1 — core domain logic

| Area | Type | Status | Why |
|---|---|---|---|
| Carbon `diffIn*` sign-flip regression guard | Feature | ✅ Done — [`tests/Feature/ParticipantChoiceNotificationTest.php`](tests/Feature/ParticipantChoiceNotificationTest.php) | The exact bug class hop 2 found in `ParticipantController` (5 call sites) and `AutomatedReminder`. Tests exercise the notification-threshold logic (`$diffInMins > 5`) directly on a non-first answer via `POST /api/participant/{id}`, backdating `updated_at` to 10 vs 2 minutes ago — confirmed by tracing that removing `abs()` would fail the "over 5 minutes" case exactly as the real bug did |
| Course-booking capacity (`ParticipantController::applyChoiceUpdate`, behind `POST /api/v2/participant/{id}` and admin cancel) | Feature | ✅ Partial — [`tests/Feature/BookingCapacityTest.php`](tests/Feature/BookingCapacityTest.php) (8 tests). The row lock itself is deliberately **not** covered here — see the note below | Aufgabe 1 of the Kursbuchung work: the capacity check was a textbook TOCTOU. `Preference::groupBy(...)->get()` counted existing bookings, then a separate `Preference::where(...)->update(...)` wrote the answer, with no lock or transaction spanning the two — two requests racing for the last seat both passed the check and both wrote. Now wrapped in `DB::transaction()` with `lockForUpdate()` on the `choices` rows (ordered by id, so multi-choice saves can't deadlock); the choice row is the serialization point rather than the counted preference rows, because a never-booked choice has no preference row to lock and the rows being written (`value` -1/0) are filtered out of the count predicate anyway. Verified in passing that the specific-limit branch read the limit from the **request payload** (`$choice['limit']`), not from `choices.limit` — so a raw API call could send its own cap; it now reads the locked `Choice` model. Counting narrowed from `value > 0` to `value IN (1,2)` so waitlist entries (`value = 3`, Aufgabe 3) don't consume seats, and the check is skipped when the participant already holds a seat, which was silently rejecting an ordinary `1 → 2` switch whenever other participants alone filled the limit. Tests cover: payload limit ignored, waitlist doesn't consume a seat, a full option is still joinable as a waitlist entry, `1 ↔ 2` keeps the held seat, a limit lowered below occupancy keeps existing bookings, partial-batch rejection still returns 200 + error body with the rest written, the poll-wide `votesLimit` path, and a no-limit poll accepting everything. Stashing the fix and re-running showed 3 of the 8 failing on the unfixed code; the other 5 are regression guards for behavior only the new code has (specific-mode enforcement was effectively inert before, since it did nothing without a payload limit). **Concurrency is untestable in this suite**: `phpunit.xml` runs SQLite in-memory, where Laravel compiles `lockForUpdate()` to a no-op and each connection gets its own database, so a green concurrency test there would prove nothing. Proving serialization needs a MySQL connection with two controlled transactions (assert the second blocks until the first commits), and a k6/JMeter pass at 50–100 concurrent users to confirm the new locking doesn't produce deadlocks or lock-wait timeouts — both pending as pre-deploy gates, not CI |
| `preferences.committed_at` (Aufgabe 4 — per-choice commit moment, ordering for the waitlist) | Feature | ✅ Done — [`tests/Feature/PreferenceCommittedAtTest.php`](tests/Feature/PreferenceCommittedAtTest.php) (18 tests) | Records *when* a participant committed to one specific choice, which is what orders a waitlist fairly — `created_at` can't serve (written for every choice at once when answers are first saved) and neither can `updated_at` (moves on any later edit). The committing set is `{1,2,3}`, deliberately **not** the capacity set `{1,2}` from the row above: a waitlist entry takes no seat but does take a queue position, so it earns a timestamp and must keep it when the answer moves within the set — otherwise switching answer would silently cost someone their place in the queue. Tests are data-provider driven over every transition: each committing value stamped on entry from a non-committing one, all six moves within `{1,2,3}` preserving the original moment, each non-committing value clearing it, a re-commit after a cancellation earning a *fresh* timestamp (back of the queue, not the original place), and a non-committing→non-committing write leaving it alone. Mutating the committing set to `{1,2}` — the exact confusion the spec warns about — fails 5 of them. **Rule corrected 24.09.2026 (Andreas), after the implementation report raised it:** moving a seat to the waitlist (`1/2 → 3`) now earns a *fresh* `committed_at` rather than keeping the old one — giving a seat up re-queues you at the back, like any other waitlist entry. Leaving the waitlist for a seat (`3 → 1/2`, including automatic promotion) is unaffected; only entering it. The data providers split accordingly: `1↔2` and `3→1/2` preserve the moment, `1/2→3` re-stamps it, and forcing that branch off fails exactly the two re-stamp cases. Implemented inline in the write path rather than as a model observer, because both write paths use query-builder mass updates (`Preference::where(...)->update()`, `updateExistingPivot()`), which do not fire Eloquent model events at all. Includes a test that re-runs the real migration's `up()` after dropping the column, because the backfill is a one-shot that sets the initial ordering for every pre-existing booking. That test found a genuine problem: it first passed for the wrong reason, since `Participant::choices()` has no `withTimestamps()`, so the `attach()` in `PollController::postNewParticipant` writes **NULL** `created_at` — meaning the spec'd `committed_at = created_at` backfill would have silently done nothing for the majority of production rows, and with every `created_at` null the "must not be backfilled" assertions held no matter what the `WHERE` clause did. Backfill now falls back to `COALESCE(preferences.created_at, participants.created_at)` (a correlated subquery, since `UPDATE ... JOIN` is MySQL-only and the suite runs SQLite), and the test pins both branches. **Known gap, deliberately not fixed here:** preference rows still get NULL timestamps on creation; adding `withTimestamps()` would change pivot-write behavior for every `attach()`/`sync()` caller on that relation, so it wants its own task |
| Waitlist auto-promotion (Aufgabe 5 — `ParticipantController::promoteFromWaitlist`) | Feature | ✅ Partial — [`tests/Feature/WaitlistPromotionTest.php`](tests/Feature/WaitlistPromotionTest.php) (10 tests); serialization is **not** covered, same SQLite limitation as the capacity row | Without it the waitlist is a dead end — people join `value = 3` and nothing ever moves them off. Giving up a seat on a full option promotes the longest-waiting entry (`committed_at ASC, id ASC`) to `1`. Two deviations from the written spec, both deliberate. **First:** the spec says promote when the option "was full at that moment", but a choice can legitimately sit *above* its limit (Aufgabe 1 keeps existing bookings when a limit is lowered), and promoting there pushes it further over with every cancellation and never converges — so the trigger is "a seat is genuinely free now", checked after the write. Test `test_an_option_over_its_limit_does_not_promote_until_a_seat_is_really_free` pins this: at limit 1 with 2 bookings, the first cancellation promotes nobody and the second does. **Second:** the participant who just released the seat is excluded from the candidates. This was found by a failing test, not by reading — moving a booking to the waitlist (`1 → 3`) keeps `committed_at` (Aufgabe 4's rule, which the spec states explicitly for exactly this transition), which makes that person the *oldest* queue entry and hands the seat straight back, silently undoing the change they asked for. Also required extending when the choice lock is taken: Aufgabe 1 only locked on *claims*, since releasing could not previously affect anyone else — a pure cancellation took no lock at all, so the spec's "must respect the same lock" was not satisfiable without this. **Resolved 24.09.2026:** the open question here — a participant who moved a booking to the waitlist kept their early `committed_at` and so outranked people who queued before they released the seat — was answered by correcting the `committed_at` rule (see the row above), not by changing the promotion. The self-exclusion stays in place regardless, on Andreas's instruction: with the corrected rule the releaser is the *newest* queue entry and would not be picked anyway, so it now only bites when they are the only one waiting — there it stops the seat bouncing straight back, leaving it free while they stay queued. `test_the_only_waitlist_entry_does_not_get_its_own_seat_back` pins that behavior down, since it is the one case where the exclusion is observable. Tests also cover the poll-wide `votesLimit` path, unlimited options never promoting, `1 ↔ 2` promoting nobody, one seat never promoting two people, and deterministic tie-breaking on identical timestamps |
| `deadlineMode` / `registrationDeadlineHours` + `polls.options` widened to TEXT (Aufgabe 2) | Feature | ✅ Done — [`tests/Feature/PollDeadlineModeTest.php`](tests/Feature/PollDeadlineModeTest.php) (6 tests) | Two mutually exclusive ways to express a registration deadline: `fixed` uses the poll-wide `polls.deadline` column, `perChoice` uses `options.registrationDeadlineHours` as an offset from `choice.start`. Enforcement is deliberately frontend-only (decision of 21.09.2026 — no payment flow, and the public link is already the weaker surface), so nothing rejects a late answer server-side and no write path gained a new rejection. What the server does guarantee is that switching mode actually *clears* the inactive field instead of merely hiding it, so no stale value survives for a reader that forgets to check the mode — applied in both `postDetails` and `postOptions`, since both overwrite `options` wholesale from the request. Removing the normalizer fails 3 of the 6. One test asserts unrelated flags (`privacy`, `hiddenPoll`, `votesLimit`) survive the switch, since `options` is schemaless and carries keys this code knows nothing about. The `VARCHAR(1000)` → `TEXT` migration restates NOT NULL explicitly: the column has never been nullable, and Laravel 11+ drops any attribute a `->change()` omits — the same mistake that silently made `polls.description` NOT NULL. **Caveat on the >1000-character test:** SQLite ignores `VARCHAR` length limits entirely, so it proves the payload round-trips but cannot prove the widening worked; only MySQL can, and on a non-strict connection the pre-migration failure mode is silent truncation of the whole JSON document rather than an error |
| `PollExportService` (raw PhpSpreadsheet, not Maatwebsite) | Feature | ✅ Done — [`tests/Feature/PollExportServiceTest.php`](tests/Feature/PollExportServiceTest.php) | 450 lines of direct spreadsheet manipulation backing the paid export feature; previously only verified manually each migration hop. Now automated: exports a date poll via the real endpoint, then reads the generated `.xlsx` back with `IOFactory::load()` and asserts actual cell contents (title, choice date/time headers, per-participant Yes/No answers in the right columns) — a genuine round-trip check, not just "did it not crash". Writes to the real gitignored `public/export/` dir, cleaned up in `tearDown()` |
| Contact import (`ContactsImport`) | Feature | ✅ Done — [`tests/Feature/ContactsImportTest.php`](tests/Feature/ContactsImportTest.php) | Real `POST /api/contacts/upload` (`auth:sanctum`) with a real CSV through `Excel::import()`. Found and fixed a real bug: a row with a blank/omitted name column (e.g. `email,` — PhpSpreadsheet's CSV reader trims the trailing empty field entirely, leaving no `$row[1]` at all) threw on `trim($row[1])`, and since the whole loop shares one DB transaction, that silently discarded the **entire batch** — not just the bad row, including contacts that already imported successfully. Fixed: `trim($row[1] ?? '')`. `UsersImport`/`UsersUpdate`/`ContactFromFileImport` remain uncovered — lower-traffic admin/internal import tools, not user-facing like this one |
| New poll on a freshly migrated DB (`POST /api/new`) | Feature | ✅ Done — [`tests/Feature/NewPollOnFreshDatabaseTest.php`](tests/Feature/NewPollOnFreshDatabaseTest.php) | Found creating the first poll on a fresh `migrate` (no seeders) — invisible on long-lived DBs. Two real bugs: no `isDefault` design existed so `postNew` read `->id` on null (fixed: migration `2026_09_17_080100_ensure_default_design_exists` inserts an ownerless default design, `designs.user_id` now nullable), and `polls.description` had become NOT NULL because the 2022 `->change()` migration didn't restate `nullable()`, which Laravel 11+ requires, while the form sends an empty description (fixed in that migration plus repair migration `2026_09_17_080000`). Verified each bug alone fails the test with its fix removed |
| New poll without an account (`POST /api/new`, `isRegisteredUser` false) — locale | Feature | ✅ Done — [`tests/Feature/NewPollAnonymousLocaleTest.php`](tests/Feature/NewPollAnonymousLocaleTest.php) (6 tests) | Fixes a 500 for visitors who are not signed in. That branch stores the request's locale on the shared dummy owner, and `users.locale` is a NOT NULL `char(2)`. A missing value, an empty string (Laravel turns it into null) or a regional one like `de-DE` made the save fail (`1048 Column 'locale' cannot be null` on MySQL, seen in the test server's log). Anything that is not a language code now becomes `de`, a regional code is cut to its language, and a registered user keeps their own locale. Found while chasing Bolt's intermittent `POST /new` 500; it does not explain those, which use the registered branch. Still open: the anonymous branch rewrites the one shared dummy user on every call, and `postNew` takes `user_id` from the request body. |
| Choice/deadline times serialized as UTC ISO 8601 (`GET /api/poll/{id}`) | Feature | ✅ Done — [`tests/Feature/PollDateSerializationTest.php`](tests/Feature/PollDateSerializationTest.php) | Found in production: 11:00 CEST saved, 09:00 shown. `Choice` (`start`/`end`) and `Poll` (`deadline`) declared the legacy `$dates` property, ignored since Laravel 10, so the API returned zone-less `"Y-m-d H:i:s"` UTC strings that the frontend's `new Date()` read as local time. Each re-save of an edited poll then wrote the wrong time back, shifting it again. Fixed by replacing `$dates` with `datetime` casts. Both tests verified to fail without the fix. Already-shifted polls are a separate data question |
| `SignalFlow` service | Unit/Feature | Not started | Cross-cutting service with no current coverage; scope depends on what it actually does at call time. Note: `PublicPollFetchTest` already establishes the `Http::fake()` pattern needed to test/avoid this service's outbound call |
| Legacy/v3 frontend shell selection (`FrontendController` + `App\Kulibri\Ui\UiVersionResolver`, `PUT /api/user/ui-version`) | Feature | ✅ Done — [`tests/Feature/UiVersionResolutionTest.php`](tests/Feature/UiVersionResolutionTest.php) (16 tests) | Every non-API URL goes through this, including every poll, invitation, sign-in and unsubscribe link already sent out by email. The key property: those links stay on the legacy frontend whatever the visitor prefers, until v3 explicitly lists the path in `config/kulibri_ui.php`. Also covers the `KULIBRI_UI_V3_ENABLED` master switch (off = everything legacy, `?ui=` ignored), the `?ui=` > cookie > `users.ui_version` > legacy order, invalid and array values being ignored, and the preference endpoint acting only on the logged-in user (a body `id` pointing at another user is ignored). Verified by mutation: removing the v3-path check fails the link tests, and re-encrypting the `kulibri_ui` cookie fails the 3 cookie tests. The real `legacy.blade.php` is gitignored build output, so tests use a stand-in from `tests/Fixtures/views` |
| Legacy → v3 handover on in-app navigation (`frontend/src/services/uiVersion.js`, `router.js`, embedded `kulibri-ui-config`) | Jest unit + Feature | ✅ Done — [`frontend/tests/unit/uiVersion.spec.js`](frontend/tests/unit/uiVersion.spec.js) (25 tests), 5 more in [`tests/Feature/UiVersionResolutionTest.php`](tests/Feature/UiVersionResolutionTest.php) | Found in browser testing: a v3 user who logged in through the legacy form stayed on the legacy dashboard, because the login pushes `/dashboard` inside the SPA and the server never chooses. Jest covers the pure decision rules (config parsing incl. unparsed Blade on the dev server, path matching, precedence, both shell directions, loop guard incl. blocked storage). The JS path matcher was checked against Laravel's `Request::is()` on 14 cases (all equal). Feature tests assert the rendered page embeds valid JSON with the right choice/source and that the template keeps the Blade line. The 5 `canOfferV3` tests cover the "Try the new design" opt-in in `NavUser.vue` (the only way into v3 besides `?ui=`): offered to a logged-in user on the legacy shell, hidden when v3 is off, when the page is already v3, for logged-out visitors, and without server config. Dropping the shell check fails one of them. The rule lives in `uiVersion.js` because importing `NavUser.vue` in Jest pulls in the whole app (a vue-select SCSS import breaks the run). End-to-end verified in headless Edge over the DevTools protocol with a temporary v3 user: login form → full load of `/dashboard` answered `X-Kulibri-UI: v3 (user)`, no page errors; `/?ui=legacy` makes no extra load. No automated browser test in the repo (Nightwatch e2e isn't maintained) |
| Frontend v3 (`frontend-v3/`): v3 → legacy handover guard, dashboard helpers | Vitest unit + Feature | ✅ Done (initial) — [`frontend-v3/src/__tests__/handover.spec.ts`](frontend-v3/src/__tests__/handover.spec.ts) (6), [`frontend-v3/src/__tests__/dashboard.spec.ts`](frontend-v3/src/__tests__/dashboard.spec.ts) (5), [`frontend-v3/src/__tests__/http.spec.ts`](frontend-v3/src/__tests__/http.spec.ts) (11), [`frontend-v3/src/__tests__/useTheme.spec.ts`](frontend-v3/src/__tests__/useTheme.spec.ts) (5: light default, stored dark restored, junk value ignored, toggle updates `<html>` and storage, blocked storage), [`frontend-v3/src/__tests__/router.spec.ts`](frontend-v3/src/__tests__/router.spec.ts) (5: every `meta.preview` route stays under `/preview/`, the `preview/*` pattern does not claim poll, sign-in or membership links, the retired `/preview/home` redirects to the dashboard, the legacy catch-all stays last). The v3 dashboard has 57 more, all Vitest: [`dashboard.spec.ts`](frontend-v3/src/__tests__/dashboard.spec.ts) (19, the rules: archive switch shows only archived as legacy does, sort by `updated_at`, owner/search filters, hidden-poll counts, deadline states, guests not counted twice), [`PollCard.spec.ts`](frontend-v3/src/__tests__/PollCard.spec.ts) (14), [`DashboardView.spec.ts`](frontend-v3/src/__tests__/DashboardView.spec.ts) (10: loading, error and retry, filters, empty messages, archive with confirmation and on failure), [`AppHeader.spec.ts`](frontend-v3/src/__tests__/AppHeader.spec.ts) (5, including the switch back to the classic UI), [`relativeTime.spec.ts`](frontend-v3/src/__tests__/relativeTime.spec.ts) (5), [`mappers.spec.ts`](frontend-v3/src/__tests__/mappers.spec.ts) (4, poll images stay same-origin). Verified by mutation, each caught: guests counted twice, archive switch including archived, hidden counts shown, classic switch not saving the choice, image URL back to the test server, archiving without asking. Building the CSS also matters: Tailwind silently drops a class it cannot build, and one (`bg-neutral-500/70`, whose palette entry is a CSS variable) was missing from the output until a check of every class against the built CSS found it. Server side, 2 tests in `UiVersionResolutionTest` run against the config as shipped (not the test's own `['dashboard']`): the shipped `v3_paths` never claim links already sent out, and `/preview/*` is v3 only for a visitor who chose v3. Verified by mutation: widening the pattern to `*` fails the first, moving the route to `/home` fails the router test; `vue-tsc` type check runs in every build; `UiVersionResolutionTest` asserts the v3 shell gets the config and that both templates keep the Blade line | Guard: first navigation to the loaded URL left alone, poll links and `/pr/new` handed to legacy with a full load, `/dashboard` kept for a v3 user and handed back for a user who switched, nothing without server config (Vite dev server), no double reload. Dashboard: archived filter, newest first, expiry, poll link. End-to-end verified with the built app on `:8000` in headless Edge (temporary user with two polls, removed afterwards): legacy login → `/dashboard` served v3 (`user`) listing both polls → clicking a poll full-loads `/pr/…` as legacy → "Classic view" reloads `/dashboard` as legacy (`cookie`), no page errors. HTTP client: transplanted from the Bolt prototype (`kulibri-rewrite`, `src/http.ts`) with its localStorage bearer token swapped for the shared Laravel session, since `auth:sanctum` accepts either and v3 is served same-origin by Laravel. Tests cover the `/api` base, the session cookie, `X-XSRF-TOKEN` read from the cookie and sent on writes but not reads, `Content-Type` left to the browser for `FormData` so the multipart boundary survives, 401/419 raising `ApiError` and firing one `unauthorized` event (and none after unsubscribe), 403, Laravel 422 validation errors surfaced, 204 returning without parsing, and a network failure becoming status 0. `axios` is gone from `frontend-v3` as a result. Not covered yet: component tests for the views, the transplanted `endpoints.ts`/`mappers.ts` (unused until the first screen lands), and an automated browser test |
| Frontend v3: creating a poll, with and without an account (`utils/pollSetup.ts`, `/pr/new` claimed in `config/kulibri_ui.php`, guard keeps new-poll → editor in the app) | Vitest + Feature | ✅ Done — 13 tests in [`frontend-v3/src/__tests__/pollSetup.spec.ts`](frontend-v3/src/__tests__/pollSetup.spec.ts), 3 in [`frontend-v3/src/__tests__/handover.spec.ts`](frontend-v3/src/__tests__/handover.spec.ts) ("creating a poll"), 5 in [`frontend-v3/src/__tests__/router.spec.ts`](frontend-v3/src/__tests__/router.spec.ts), `test_the_new_poll_page_is_v3_only_for_a_visitor_who_chose_v3` and `test_the_language_links_of_the_new_poll_page_stay_classic` in [`tests/Feature/UiVersionResolutionTest.php`](tests/Feature/UiVersionResolutionTest.php) | Without the claim the dashboard button did a full load that classic answers with a blank page; without the guard rule the step to the editor of the new (type -1) poll opened classic. A visitor without an account now creates a poll as in classic: the owner's name and e-mail are mandatory on the first save and move the poll off the shared account, and the first save writes `options.initialised` through `POST /poll/options`, which is what sends the mail with the management link. Browser check of the logged-in flow done 8 Oct; **the anonymous flow and the mail are still to check in a browser.** |
| Frontend v3: invite by picking people from another project (`utils/pollContacts.ts`, `GET /api/ownpolls`) | Vitest unit | ✅ Done — 10 tests in [`frontend-v3/src/__tests__/pollContacts.spec.ts`](frontend-v3/src/__tests__/pollContacts.spec.ts) | The classic rule (`SelectPollContacts.vue`): a participant is offered only with an account **and** an address already in the inviter's contacts; name from the project row, id from the contact; each person once, sorted by name; the current project and projects with nobody to offer are left out. Premium lock as in classic (`hasPremiumRole`). **Browser check open**, and two points wait for the PO (plan, question 10). |
| Frontend v3: one Premium rule and the classic gates (`utils/premium.ts`, `components/PremiumNotice.vue`, `i18n/premium.ts`) | Vitest unit + component | ✅ Done — 15 tests in [`frontend-v3/src/__tests__/premium.spec.ts`](frontend-v3/src/__tests__/premium.spec.ts), 4 in [`frontend-v3/src/__tests__/premiumNotice.spec.ts`](frontend-v3/src/__tests__/premiumNotice.spec.ts) | `isPremiumFor`: the poll creator's roles decide (admin or premium bit, also next to other bits), the visitor's roles only as a fallback, as classic's `isPremium`. The editor used `auth.user.plan` and now uses the same rule. Gates added to the editor to match classic: an own logo, saving a custom design and "Speichern unter", the non-standard templates in the dropdown (disabled, as `SelectField.vue` does), the automatic reminders. The gates themselves sit in `PollEditorView.vue`, which has no page-level test; only the rules and the notice are tested. **Browser check open** with a free and a premium account. |
| Frontend v3: the four new Premium rules (hidden mode, Excel export, invitations over the free limit, messages to participants) | Vitest unit | ✅ Done in the new UI (rules); server side below — 18 tests in [`frontend-v3/src/__tests__/premium.spec.ts`](frontend-v3/src/__tests__/premium.spec.ts) | Hidden mode: a free owner keeps it on a poll that has it and can switch it off, but not on (`mayToggleHidden`). Invitations: free up to `FREE_INVITE_LIMIT` (50), counted as classic counts: the poll's participants (from the paged `participant_count`) plus the new recipients, in both invite panels; sending is blocked above it. Excel export and the reminder panel show the notice instead. The gates sit in `PollEditorView.vue`, `PollDetailView.vue`, `InvitePanel.vue` and `ParticipantReminderPanel.vue`, none of which has a page-level test, except that a refused copy of a hidden poll shows the Premium notice (`pollCopyDelete.spec.ts`). **Browser check open** with a free and a premium account. |
| Frontend v3: an intermittent test (`participantAdmin.spec.ts`, "closes the dialog and loads the poll again after a successful delete") | Vitest | ⚠️ Flaky, not fixed — found 8 Oct | It fails in about 3 of 8 runs of its file and passes alone (6 of 6). The same rate on the commit before the Premium work (3 of 8), so it is not from that change. Likely the timing of an `until(...)` wait (see the rule in `docs/frontend-v3-rules.md`: `until` does not fail on a timeout, so the assertion after it is what fails). A full-suite run passes most of the time; rerun before reading a failure there as a regression. |
| Premium on the server: `User::isPremium()`, hidden mode, Excel export, invitations over `app.free_invite_limit`, messages to participants | Feature | ✅ Done — 19 tests in [`tests/Feature/PremiumEnforcementTest.php`](tests/Feature/PremiumEnforcementTest.php), `PollExportServiceTest` now uses a premium owner | The rule over every role combination (0, beta, alpha, premium, admin, premium+beta, premium+admin; the old `roles != 1` got the last four wrong). Hidden mode: a free owner is refused 402 switching it on in `postDetails`, `postOptions` and `postNew`; keeps it where it is already on and can switch it off, not back on; the string "false" is not hidden mode. Export, invitations and reminders: 402 for a free owner, nothing sent and nothing created, no limit for premium; empty address entries are not counted. Mutation check: reverting the rule to `roles == 1` fails 5 tests. Open, see SEC-014: image upload by subscription, logo and design routes, sub-admin, grant and revoke rules. The classic Jest suite has one old broken file (`store-roles.spec.js`, first line garbled), not touched. |
| Classic: Premium rules (`frontend/src/shared/premium.js`, `PremiumDialogMixin`, gates in `Headline.vue`, `EditPollOptions.vue`, `EditKulibri.vue`, `EditPoll.vue`, `EditInvites.vue`, `SendParticipantReminder.vue`) | Jest unit | ✅ Done (rules) — 11 tests in [`frontend/tests/unit/premium.spec.js`](frontend/tests/unit/premium.spec.js); the broken `store-roles.spec.js` runs again (classic suite: 6 suites, 83 tests) | `mayToggleHidden` (a free account cannot switch hidden mode on, keeps it where it is on and can switch it off), `isPremiumRefusal` and `premiumRefusalFeature` (the server's 402 `premium_required`). Flipping the rule fails the tests. The store actions `updateDetails` and `sendInvitations` now pass on a Premium refusal and swallow everything else as before. The component changes have no test (classic has no component tests; the `*.nospec.js` files are switched off): **browser check open**. |
| Poll-link routing and the one-off classic view (`UiVersionResolver` poll rule, `?classic=1`, embedded config, anonymous boot) | Feature + Jest + Vitest | ✅ Done — [`tests/Feature/UiPollRouteResolutionTest.php`](tests/Feature/UiPollRouteResolutionTest.php) (20), 2 changed in [`UiVersionResolutionTest.php`](tests/Feature/UiVersionResolutionTest.php); [`frontend/tests/unit/uiVersion.spec.js`](frontend/tests/unit/uiVersion.spec.js) (11 new: `isPollPath`, handover for poll links); Vitest 16 new: [`loginGuard.spec.ts`](frontend-v3/src/__tests__/loginGuard.spec.ts) (6), [`links.spec.ts`](frontend-v3/src/__tests__/links.spec.ts) (4), [`LegacyPageView.spec.ts`](frontend-v3/src/__tests__/LegacyPageView.spec.ts) (1), [`AppHeader.spec.ts`](frontend-v3/src/__tests__/AppHeader.spec.ts) (3 more, for a visitor without a session), [`handover.spec.ts`](frontend-v3/src/__tests__/handover.spec.ts) (2 more) | Step 0 of the date-poll (Terminplan) read mode, see the archived `docs/archive/migration/SCREENS.md`. `/pr/{id}[/{key}[/{publicKey}]]` is one path for every poll type and both roles, so the resolver decides per poll: v3 only for a visitor who chose v3, when `polls.type` is in `KULIBRI_UI_V3_POLL_TYPES` and the poll is not hidden (SEC-009); everything else stays legacy. The guarantees that matter: with no type configured (the default) nothing changes for any link; a visitor who did not choose v3 never costs a poll lookup (counted through `DB::listen`); `pr/new` is never decoded; an unknown poll, a type v3 does not serve, a hidden poll and a failing lookup all fall back to legacy with a distinct `X-Kulibri-UI-Reason`. `?classic=1` serves legacy for one request without a cookie, and the embedded config keeps the real preference so later navigation follows it. Verified by mutation, each caught: poll rule active without types (also fails the older "links already sent out stay legacy" tests), lookup before the visitor check, hidden polls served, `pr/new` treated as an id, `?classic` leaking into the embedded preference, lookup errors not caught. Two test slips fixed on the way: `withUnencryptedCookies()` stays set for the rest of a test, and the env-parsing test read the developer's real `.env`, so it now sets the value itself. The JS `isPollPath()` was compared with the server's `pollRouteId()` on 23 paths (double slashes, case, encoded characters): all agree, the one apparent difference (`//pr/abc`) was the test harness reading it as a host. Jest 24 (legacy) rejects `expect(value, message)` that Vitest accepts, so the shared module's spec avoids it. End-to-end in headless Edge on a dedicated server (port 8000 was another project by then), temporary owner with a date poll, a workshop and a hidden poll, all removed afterwards: 22 checks, e.g. an anonymous visitor gets v3 without being sent to sign-in, workshop and hidden poll stay classic, a signed-in v3 user clicking a date poll is served v3 (`user`) and the fallback's classic link serves legacy once without changing the account, and the login guard sends an anonymous visitor from `/dashboard` to `/signin?url=…`. **That run found a real bug**: legacy's `Poll.vue` redirects a three-part link to the public link with a full-page redirect that dropped the query, so `?classic=1` was lost and the visitor was bounced back into v3. Fixed by keeping the query and hash; the browser check is the only test of it (the component is too large to mount). Not covered: an automated browser test in the repo |
| Frontend v3 date-poll read view (`src/poll/*`, `components/poll/*`, `views/PollView.vue`, `utils/sanitize.ts`, `resolveLocale`) | Vitest + browser | ✅ Done (read mode) — Vitest 311 in 26 files, among them `pollRules`, `pollApi`, `pollModel`, `usePoll`, `pollGrid` (22: windowing, sort by name/added/date, search, initial view), `sanitize`, `AnswerMark`, `OptionCard`, `DayPanels`, `PollHeader`, `CommentList`, `PollView` (incl. 5 table-view tests), `resolveLocale`; PHP resolver rule for custom participant fields in `UiPollRouteResolutionTest` (25 in the file; polls with tasks are served in v3 now, and `KULIBRI_UI_V3_FEATURE_GUARD=false` switches the rule off on a dev or test box) | Date panels, answer table (virtualised rows/columns, search, sort, Table/Dates switch remembered in localStorage), comments; hidden polls show nobody's answers to non-admins (SEC-009). Headless Edge run of 33 checks covered the date-panel view; the table view is covered by Vitest only and **has not yet been checked in a browser**. The per-option totals use client-side counts: a server `counts` change in `PollController` was declined, so it is not done. Not covered: answering/editing (later steps) |
| Frontend v3 dashboard poll cache (`utils/pollCache.ts`, `HomeView.vue`, `stores/auth.ts`) | Vitest | ✅ Done — [`pollCache.spec.ts`](frontend-v3/src/__tests__/pollCache.spec.ts) (8), [`dashboardCache.spec.ts`](frontend-v3/src/__tests__/dashboardCache.spec.ts) (6) | Stale while revalidate, like the classic `polls` key: the list of the last visit is shown at once, the answer of `GET /polls` replaces it and the stored copy. Own key `kulibri.v3.polls` with the user id, so one user never sees the list of another; cleared on logout and when the session is gone; archive/unarchive write through. A failing server keeps the cached cards with a notice; damaged data, a full or blocked storage and a list over 2 MB mean "no cache". Mutation check: with the cache read switched off two tests fail (the first version of the test passed anyway because `until()` does not fail on a timeout, so each wait is now followed by an explicit `expect`). Size measured on the production copy: 364 polls = 327 kB (about 0.9 kB per poll), so the 2 MB guard covers roughly 2,200 polls. Not covered: write-through after archiving in a browser |
| Frontend v3 poll loading and sync in `PollDetailView` (characterisation before `usePollData`) | Vitest | ✅ Done — [`pollDataLoading.spec.ts`](frontend-v3/src/__tests__/pollDataLoading.spec.ts) (11) | Safety net for extracting `usePollData` (`V3-PARITY-PLAN.md`, section 6.4): the request order of a load, the editor redirect for an uninitialised poll, the sync on return to the tab (nothing changed: only the version call; only answers changed: answers only; poll or participants changed: full reload and the new participant shows; version unreadable: full reload; after five minutes: full reload; no second check within two seconds; warning after three failed checks) and the route change (the sync follows the new poll). Mutation check: with the answers-only branch and the five-minute net switched off two tests fail. **Found a real bug, now fixed**: nothing ignored a response that arrived after the user moved on, so with poll 3 in the address bar the view showed poll 2 when its response came in last. It was recorded as `it.fails`; `usePollData` fixes it with a request token and the test is a plain `it` now. Not covered: sync with an open row (the edited row keeps its server values), writes during a sync |
| Frontend v3 `usePollData` (`composables/usePollData.ts`, extracted from `PollDetailView`) | Vitest + browser | ✅ Done — [`usePollData.spec.ts`](frontend-v3/src/__tests__/usePollData.spec.ts) (20), plus the 10 page-level tests of `pollDataLoading.spec.ts` and the existing `pollDetail.spec.ts` unchanged | One place for the poll on show: load (results loaded, deleted, failed, superseded, skipped; `afterLoad` runs before `loading` ends), answers-only refresh with its "updated" sign, `setPoll`/`patch`, and the sync with its guards (an edited row keeps the view's values, a busy view drops the data and the sync reads it again). Every load and refresh takes a number, so a late answer for a poll the user left is ignored. Mutation check: removing the load guard, the refresh guard, the busy check or the protected row fails 5 tests (the stale load in two). Two slips in my own tests found on the way: a component that is not unmounted leaves its sync listening on `document` and answers the next test's checks, and the ref returns a reactive proxy, so `toBe` on the object failed inside `afterLoad` and showed up as a failed load. `PollDetailView` is 78 lines shorter (4,612 to 4,534); the view keeps working out the own row, the answer grid and the drafts. Browser: two poll pages (one with tasks, one with participant fields) load in the new UI through the real stack in headless Chrome. Not covered: a logged-in session in a browser (editing a row while the sync runs) |
| Frontend v3 visibility rules of a hidden poll (`utils/pollVisibility.ts`, cut 1 of `PollDetailView`) | Vitest | ✅ Done — [`pollVisibility.spec.ts`](frontend-v3/src/__tests__/pollVisibility.spec.ts) (22), plus the 5 hidden-poll page tests of `pollDetail.spec.ts` unchanged | Who sees whose answers, as pure functions: `pollVisibility` (admin sees all; on a hidden poll a visitor sees no other rows and no counts, unless statistics are on, then counts but never names), `isRowVisible` (only linked rows when others are hidden), `commentsVisibleTo` (a hidden poll shows the admin all messages, a participant those of their row or account, a visitor with neither only anonymous ones; newest first; the input list is not changed) and `mayWriteComment` (the admin of a hidden poll only reads). The view keeps its names (`isHiddenPoll`, `canSeeOthers`, `canSeeCounts`) as computed values over the module, so the template did not change. The "hidden poll logic" section of the view turned out to be 4 computed values; the rest of what the plan counted there (participant link, admin link, invitation list) belongs to the sharing cut. Mutation check: dropping the admin exception and letting a participant with a row see anonymous messages fails 4 tests, in the unit tests and in the page tests. This is the frontend's side of SEC-009; the server still sends the other answers |
| Frontend v3 poll theme (`utils/pollTheme.ts`, `composables/usePollTheme.ts`, cut 8 of `PollDetailView`) | Vitest + browser | ✅ Done — [`pollTheme.spec.ts`](frontend-v3/src/__tests__/pollTheme.spec.ts) (18) | The look of a poll page from the owner's design: background image or gradient, header overlay, panel mode and opacity, ink colors and the `--k-*` variables for light and dark. The expected values in the spec are the ones the view computed before the move, written out literally from the old code, not copied from the new module. `useTheme` (light/dark of the app shell) does not overlap: it is a different thing, the per-user choice of the shell. The composable exposes only the 12 names the rest of the view uses, so the template did not change. The view dropped from 81 to 20 color literals, so the budget in `designTokens.spec.ts` went down to 20. Browser: three non-hidden polls with dark and light designs (one with a photo, one with a custom `/storage/` image) screenshotted in headless Chrome before and after the move: byte-identical. **Lesson from that check**: the first pair of polls was hidden, so the server served the classic page and the "identical" result compared two classic pages; looking at the screenshot caught it, and the resolver header is now checked before a comparison. Not covered: a design edited live in the side panel (the preview path) in a browser |
| Frontend v3 participant administration (`composables/useParticipantAdmin.ts`, `components/participants/Participant{Edit,Delete,Add}Dialog.vue`, cut 5 of `PollDetailView`) | Vitest + browser-level HTML diff | ✅ Done — [`participantAdmin.spec.ts`](frontend-v3/src/__tests__/participantAdmin.spec.ts) (18, written and committed before the move), [`useParticipantAdmin.spec.ts`](frontend-v3/src/__tests__/useParticipantAdmin.spec.ts) (19); four source-reading specs (`emailValidation`, `invitationMail`, `participantForm`, `subAdminWiring`) now read the composable and the components | The edit, delete and add dialogs of the poll page: state and requests in the composable, markup in three presentational components, the visitor's own row stays in the view and reaches the composable through hooks (`isLocalRow`, `isOwnRow`, `onOwnRowDeleted`, `cancelLocalParticipation`). Page level, recorded before the move: edit (prefill, read-only address, one PUT with the trimmed name, reload, empty name, 500 sentence versus client message, cancel, extra fields with the "(nur Admin)" mark and a mandatory one), delete (server message keeps the dialog open, reload after success, `(du)` for the own row, the local row never asks the server), add (anonymous POST with all options, invitation with `Name<address>` where `,` and `;` become spaces, only filled extra fields sent, empty name, bad address, server message, cancel forgets input). Mutation check: dropping the own-row hook, the local-row shortcut or the sub-admin rule fails 5 tests. **Rendered HTML of the five dialog states (delete, edit, edit with errors, add, add with errors) is identical between the old view and the new one** (12,566 characters each, after removing Vue's scoped-style attributes). The two entry points to the edit dialog (any row, the visitor's own) are one function now; I expected that to fix a stale validation error on reopening, but the test passes on the old view too (the form-error watcher already cleared it), so the change is behaviour-neutral and the test stays as a guard. The comment-reminder assertion in `subAdminWiring.spec.ts` went from two copies to one for that reason. `PollDetailView` is 311 lines shorter (4,409 to 4,098). Not covered: the dialogs clicked through in a real browser (only HTML equality and jsdom) |
| Frontend v3 copying and deleting a poll from the poll page (glue in `PollDetailView`, cut 11) | Vitest | ✅ Done — [`pollCopyDelete.spec.ts`](frontend-v3/src/__tests__/pollCopyDelete.spec.ts) (12) | The header buttons, the two dialogs and where the visitor ends up, which no page-level test covered (`usePollCopy`, `usePollDelete` and `CopyPollDialog` have their own specs). Copy: asks first and sends nothing before "Ja", "Nein" closes, the options are copied only when the switch is on (it is off every time the dialog opens), the copy opens in the editor, a failed options copy says so and offers "Kopie öffnen", a failed create shows the message and stays, an earlier error and result are forgotten on reopening. Delete: asks first and names the tasks, "Abbrechen" closes, success goes to the dashboard, a failure shows the message and stays, an earlier error is forgotten. Mutation check, one at a time: dropping the navigation after delete and not resetting the copy dialog each fail exactly their own test; stable over 4 runs. The cut itself needed no code change: the view already used both composables and no duplicate is left |
| Frontend v3 user menu (`AppHeader.vue`, `utils/initials.ts`) | Vitest | ✅ Done — [`AppHeader.spec.ts`](frontend-v3/src/__tests__/AppHeader.spec.ts) (5 new: badge and name, item order, support link per language, closes on Escape/outside click, none for anonymous), [`initials.spec.ts`](frontend-v3/src/__tests__/initials.spec.ts) (3) | Mirrors the classic user menu: Dashboard, Contact support, My account, Classic view (where classic has "Try the new design"), Log out. Classic's "My profile" is a side panel of the legacy SPA and is not offered. Not checked in a browser yet |
| Frontend v3 account status tab and dashboard status chip (`components/account/MembershipStatus.vue`, `StatusLadder`, `PremiumOffer`, `CancelConsequences`, `SubscriptionDialogs`, `components/StatusChip.vue`, `composables/useStatusBadge.ts`, `utils/membership.ts` `daysUntil`/`statusBadge`) | Vitest + browser | ✅ Done — [`membershipPages.spec.ts`](frontend-v3/src/__tests__/membershipPages.spec.ts) (status tab rewritten for the redesign: ladder, registered with price and comparison, premium facts and benefits, consequences of cancelling, cancelled with days left, link to the invoices), [`membershipSubscription.spec.ts`](frontend-v3/src/__tests__/membershipSubscription.spec.ts) (new labels, cancel dialog with the consequences), [`statusChip.spec.ts`](frontend-v3/src/__tests__/statusChip.spec.ts) (8: no request for a registered user, one for premium, cancelled with end date, countdown in the last 30 days, failure stays premium, opens the account page, none without user, placed before the user menu), [`membership.spec.ts`](frontend-v3/src/__tests__/membership.spec.ts) (5 new: calendar days, the 30-day boundary) | Redesign of October 2026. The list of what a cancellation changes was checked against the code: premium is only enforced while editing (classic `isPremium`, v3 `hasPremiumRole`), `ProcessCanceledSubscriptions` only resets the role, so assigned designs and set reminders stay. Verified by mutation, each caught: the 30-day boundary off by one, loading the status for every user, the chip removed from the header, a consequence dropped, the dialog without consequences, the invoices link not wired. Checked in headless Chrome against a temporary harness with stubbed responses (desktop 1280 px and phone 390 px, all four states, the dialog, the four chips); the comparison table did not fit a phone and was fixed. Not covered: the page against the real backend (no test account), the dashboard's embedded status tab in a browser |
| Auth flows: SPA session auth vs. `Api\AuthController` (mobile/Flutter, `auth:sanctum`) | Feature | ✅ Done — [`tests/Feature/AuthFlowsTest.php`](tests/Feature/AuthFlowsTest.php) (38 tests) | Full sweep: SPA login/logout, mobile token login/me/logout, registration (+ reactivation-of-inactive-account behavior), password reset, email verification (verify/resend), Google social login incl. `redirectToProvider` (unmocked — exercises the real local Socialite config, no network call) and a failing Socialite exchange, and the support login backdoor's gating (session and token login; the token login now also honours the backdoor and refuses unverified emails, 2026-10-04). Found and fixed 6 real bugs along the way: the hardcoded backdoor password (see `ROADMAP.md`), an undefined-key `locale` crash in registration, `POST /email/resend` 500ing for any unauthenticated request (now 401), new Google-registered users being created `active: false` unlike email registration (fixed to `true`), an *existing* inactive/deactivated user being able to fully log in via Google bypassing deactivation (now blocked), and a failed/thrown Socialite exchange (declined consent, expired callback, network hiccup) 500ing instead of redirecting gracefully. Deliberately skipped real-browser e2e for Google OAuth — flaky/slow by nature, and Socialite-mocked Feature tests cover the actual app-logic risk (see conversation). Reworked 2026-09-19 after the backdoor's ROLE_ADMIN check on the *target* account was removed (6ab39355): that check only ever permitted support logging in as another admin, which is the one case the feature is not for — support opens a **customer's** account to see what the customer sees. The old tests were worse than wrong, they were inert: they set the password with `putenv()`, and `$_ENV` (populated from the developer's own `.env`) wins over `putenv()` in Laravel's adapter order, so `env()` returned the local `.env` value and the backdoor never matched. One test failed loudly, the other passed for entirely the wrong reason. The password now lives in `config/auth.php` (which also fixes it silently disabling itself under `config:cache`) and the tests set it with `config()`. Coverage is now: works against an ordinary customer, works against an admin too, rejects a wrong password, does **not** bypass email verification, logs every use (the only audit record until the real impersonation feature exists), and is unreachable when unconfigured — including via an empty password. That last case found a live 500: `ConvertEmptyStringsToNull` rewrites a blank password to null, and both `password_verify()` and the backdoor check are typed `string`, so submitting the login form with a real email and an empty password crashed instead of saying "invalid password". |
| Registration for token clients (`POST /api/register`, kulibri-rewrite) | Feature | ✅ Done — [`tests/Feature/ApiRegistrationTest.php`](tests/Feature/ApiRegistrationTest.php) (11 tests) | New route, because the web `POST /register` needs a CSRF cookie (419) and a token client has none. Same `RegisterController`, so the behaviour is the classic one: 201 `{id, name, email}`, **no token**, the address must be confirmed before login. Covered: no token and no login after registering, the verification mail is queued, password 8 to 128 characters (the old `min:4` is gone, for the web route too), optional `locale` and a 3-letter `locale` now a 422 instead of a 500 on the `char(2)` column, required fields, an active account's email rejected and that account left untouched, an inactive (invited) account still claimed by registering (pinned, as for the web route), the web route using the same password rule. **Found while writing it:** a numeric `throttle:5,1` shares its counter key with the global `throttle:2000,1` of the `api` group, so each request counted twice and, worse, every API call of that IP counted against the register limit (would block the whole API after a few calls). The route now uses a named limiter (`register`, 5 per minute and IP, defined in `RouteServiceProvider`). Verified by mutation: switching back to `throttle:5,1` fails both the limit test and the "does not count other API calls" test |
| Google sign-in for token clients (`POST /api/auth/google`, kulibri-rewrite) | Feature | ✅ Done — [`tests/Feature/GoogleTokenLoginTest.php`](tests/Feature/GoogleTokenLoginTest.php) (11 tests) | New, purely additive route (own `Api\GoogleAuthController`); the session redirect flow of the classic frontend (`SocialAuthController`, `GET /auth/google/*`) is not touched and its tests in `AuthFlowsTest` still pass. The client opens Google's popup (`initCodeClient`, so the existing styled button can stay) and posts the one-time authorization code; the backend exchanges it through Socialite (`stateless()`, redirect URI `postmessage`) and answers like `/api/login` with a Sanctum token. **Why a code:** Google only exchanges a code that was issued to our client and only with our client secret, so no audience check of our own is needed (an earlier version verified ID tokens / access tokens by hand with `tokeninfo`/`userinfo` and needed one; replaced before it was deployed). Covered: new account (verified, active, hashed random password instead of the plaintext `'social'` of the redirect flow, name falls back to the email), existing account signed in with its password unchanged, deactivated account refused (403, no token), an address Google has not verified or a profile without an email refused (401, no account), a code Google rejects refused (401, nothing created), required code. **Design choice:** an existing account that never confirmed its address is confirmed by the Google sign-in and its password is replaced with a random one, because someone else may have pre-registered the address with a password of their own. Named limiter `google-login` (10 per minute and IP) for the same reason as `register`. Verified by mutation: dropping the verified check, `redirectUrl('postmessage')` or `stateless()` fails tests; switching to `throttle:10,1` fails both limiter tests. Not covered: the real Google exchange (Socialite is mocked), including whether `postmessage` is accepted for your client, which the first sign-in on test shows |
| Membership, payment and invoice routes: caller only (SEC-011) | Feature | ✅ Done — [`tests/Feature/SubscriptionOwnershipTest.php`](tests/Feature/SubscriptionOwnershipTest.php) (23 tests) | Fixes a verified P0: these routes took the user id from the URL. Five had no login at all (start a payment, membership page data, invoice list, one invoice by key, start/delete trial), and the cancel route and `POST /subscribe/{id}` only needed a login: any logged-in user could cancel someone else's subscription. Routes now sit behind `auth:sanctum, verified`; `PaymentController::ensureCaller()` refuses an id that is not the caller's (403), an invoice that is not theirs is 404, `start-trial` is removed (the button is off in the classic UI, and a trial hands a paid plan to anyone) and `delete-trial` stays as an admin-only tool for the classic UI's "Delete Subscription" menu item (403 for everyone else, 422 instead of a crash when there is nothing to delete). 12 of the first 19 tests failed on the unfixed code; the owner paths (cancel with no Mollie customer, payment with a faked Mollie client, own invoices) pass before and after. Verified by mutation: dropping the check on cancel or on the invoice fails the matching tests. A refused request must never reach Mollie: a client double that fails the test when used. **Found while writing it:** the response encoder rewrites a plain `id` field of a Payment (`tr_...` became an empty string), so the test asserts on `_links.checkout.href`, which is what the classic UI reads; and removed routes answer 405, not 404, where another route matches the URL shape. The hashids response encoder crashes on a bare-string JSON body (found by the no-subscription test), so errors in that group must be `{error: ...}` objects. Not covered: the Mollie-side success path of `POST /subscribe/{id}` (own id reaches the order lookup only), and `SEC-012` helper routes (not fixed) |
| Company invoices: billing profile (`GET/PUT /api/billing-profile`) and invoices that record their data when issued | Feature | ✅ Done — [`tests/Feature/BillingProfileTest.php`](tests/Feature/BillingProfileTest.php) (13 tests), [`tests/Feature/InvoiceIssuingTest.php`](tests/Feature/InvoiceIssuingTest.php) (14 tests) | An invoice had no running number (a random 19-character token in the URL), no recipient, and showed the *current* price of its membership, so a later price change altered it. Now `App\Services\InvoiceIssuer` records, in the transaction that creates the invoice: a running number `KU-<year>-<5 digits>` per year (counter row locked), the issue date, the recipient and net, VAT, rate and gross. With a company name in the billing profile it is a company invoice (company, address, VAT ID, contact name), otherwise a private one. The profile validates that a company or any address field needs a complete address, a VAT ID needs a company, country in capitals, VAT ID without spaces; empty values clear it; it is always the caller's own (no id in the URL) and goes with the account on deletion, while issued invoices keep their own copy of the recipient (statutory retention). A free membership (trial) gets no number. Old invoices are out of scope and keep the new columns empty; the response still works for them. Verified by mutation: ignoring the company, keeping the profile on account deletion, dropping the address rule, and copying a VAT ID without a company each fail the matching tests. SQLite (tests) returns decimals as numbers, MySQL as strings, so amount assertions compare values. Tax treatment is unchanged (one VAT rate for everyone, no reverse charge). Not covered: two requests taking a number at the same moment (the row lock is a no-op on SQLite). |

## P2 — supporting coverage

| Area | Type | Status | Why |
|---|---|---|---|
| Image upload (`UserController::imageUpload`/`logoUpload`, Intervention v3) | Feature | ✅ Done — [`tests/Feature/ImageUploadTest.php`](tests/Feature/ImageUploadTest.php) | Migration hop 3 verified quality/output correctness manually via `tinker`; now automated — real JPEG/PNG uploads through the real GD-backed Intervention v3 calls, checking actual files land on `storage/app/public`, PNG→JPG conversion, and the per-user image-count limit (`config('app.allow_images')`) rejects with 422 once hit |
| Frontend: role-bitmask getters (`isPremium`/`isBeta`/`isAlpha` in `store/store.js`) | Jest unit | ✅ Done — [`frontend/tests/unit/store-roles.spec.js`](frontend/tests/unit/store-roles.spec.js) (10 tests) | `roles` is a bitmask (`User::ROLE_PREMIUM` 1 / `ROLE_BETA` 2 / `ROLE_ALPHA` 4 / `ROLE_ADMIN` 256, matched server-side with `($roles & $role) === $role`), but `isPremium` tested it as a magnitude: `roles > ADMIN || roles == PREMIUM`. A real premium user silently lost every premium feature the moment `addBetaRole()` OR'd in the beta bit (3 is neither `> 256` nor `== 1`), a plain admin (256) wasn't premium while an admin+beta (258) was. Fixed to the bitmask form the neighbouring getters already used, plus the missing null-guard on `state.poll.poll.user` in the admin-link branch. All four failures verified to fail against the old getter. First real frontend test — needed a `moduleNameMapper` entry for stylesheets in `jest.config.js`, since `transformIgnorePatterns` skips `node_modules` and the store's import chain reaches `vue-select`'s raw scss. The equivalent backend magnitude checks (`PaymentController.php:863`, `UpdateAdminStats.php:133`, `CheckAndCreateSubscription.php:396`) are still unfixed — they decide who gets charged and downgraded, so they're a separate task |
| Frontend: retry after a 419 (`frontend/src/shared/csrfRetry.js`, wired into `HTTP` in `shared/http.js`) | Jest unit | ✅ Done — [`frontend/tests/unit/csrfRetry.spec.js`](frontend/tests/unit/csrfRetry.spec.js) (19 tests) | After the session cookie expires the remember-me cookie (400 days) still signs the user in again, but a write answers 419 (CSRF token missing or stale) and the SPA sent every 419 to `/signin`, anonymous participants on public poll pages included. Now one refresh of `/sanctum/csrf-cookie` and one repeat of the request; only a second failure counts as a lost session. A 419 is rejected before any controller runs, so repeating it cannot double an action. Real axios with a scripted adapter; mutation-checked (never retry, no shared refresh, refresh flag never reset, failed refresh treated as success). Not covered: the wiring in `shared/http.js` itself (importing it pulls in the router and every view) and a browser run. `HTTP_APP` is left as it was. |
| Frontend v3: retry after a 419 (`send()` in `frontend-v3/src/api/http.ts`) | Vitest unit | ✅ Done — [`frontend-v3/src/__tests__/http.spec.ts`](frontend-v3/src/__tests__/http.spec.ts) (7 tests in "419 retry") | Same rule as the classic row above: on a 419 for a write, fetch `/sanctum/csrf-cookie` once (concurrent failures share one request), repeat the write once with the new `X-XSRF-TOKEN`; a second 419 or a failed refresh is a lost session (`unauthorized` event). Also for `silent` (public-page) requests. 401 and 403 are not repeated. **Browser check still open**: delete only the session cookie, keep `remember_web_…`, then write. |
| Frontend: public poll link construction (`ownerId.substring(0,4) + ownerId`) | Jest unit | Not started | Small, isolated, deterministic — cheap to lock down, and it's a separate ID scheme from backend hashids that's easy to get subtly wrong |
| Frontend: Vuex modules for poll/participant state | Jest unit | Not started | `frontend/` already has Jest wired up (`test:unit`) but essentially unused; start with the modules that back the P0 backend flows above |
| Golden-path e2e: create poll → answer via public link → view results | Nightwatch (already configured, unused) | Not started | One end-to-end smoke test covering the core product loop catches integration issues unit/feature tests miss, without committing to a large e2e suite up front |
| `backup:cleanup` (retention window + the never-delete-everything guard) | Feature | ✅ Done — [`tests/Feature/BackupCleanupTest.php`](tests/Feature/BackupCleanupTest.php) (9 tests) | Covers the retention-window boundary, the missing-directory no-op, and the config-driven default. Extended 2026-09-19 with the guard that matters: if *every* dump on disk has expired, the backup job has not run for longer than the retention window, and pruning on schedule would destroy the last restore point at the exact moment the system is least able to make a new one — so the newest is kept and `--force` is required to prune it. Not hypothetical: on 2026-09-19 all 17 production dumps were older than the 14-day window (see the `backup:database` row), and the offsite copy is an rsync mirror with `--delete`, so it would have followed within the hour. Also swapped `putenv()` for `config()`, since the commands no longer read `env()` at runtime |
| `backup:database` (dump validation + failure alerting) | Feature + Unit | ✅ Done — [`tests/Unit/Services/BackupInspectorTest.php`](tests/Unit/Services/BackupInspectorTest.php) (14 tests), [`tests/Feature/BackupDatabaseTest.php`](tests/Feature/BackupDatabaseTest.php) (6 tests) | Previously untestable because it shells out to `mysqldump`, which is not installed in CI or on the dev machines — so the judgement was extracted into `App\Services\BackupInspector`, which decides whether a file on disk is a usable dump and needs no subprocess at all. That judgement did not exist before: the command called `File::put()` on whatever mysqldump printed and reported success on exit code 0, so `db_kulibri_20260827-14.sql` sat in the production backup directory at **0 bytes** for three weeks, indistinguishable from a real backup to anything that only lists the folder. Unit tests pin down empty, truncated (no `-- Dump completed` trailer), missing, and marker-in-the-body-but-not-the-tail; the feature tests provoke a real failure with an unresolvable command name and assert the two things that were wrong in production — the unusable file is deleted rather than left looking like a backup, and `--alert` actually mails. The dump is also now redirected to disk by the shell instead of buffered through `$process->getOutput()`, which held all ~800 MB in PHP memory as a string. Extended the same day for gzip: dumps are now written as `.sql.gz`, so the inspector decompresses the stream in a rolling window to reach the trailer (a 120 MB dump expands to ~816 MB — holding that in memory is the exact mistake the class exists to prevent), and both the cleanup glob and the health check's freshness probe match either extension so a mixed directory prunes correctly. The truncated-gzip case is covered explicitly: that is what a dump cut off by a full disk looks like, and the offsite host was at 99% when this was written. |
| Alive ping (`GET /api/health`, `GET /ping`) | Feature | ✅ Done — [`tests/Feature/HealthCheckTest.php`](tests/Feature/HealthCheckTest.php) (16 tests) | New: uptime-monitoring endpoint that drives a real login → fetch polls → logout round trip against the server's own public URL (see `Hosting.md`). Faked-HTTP tests pin down the property that actually matters — every broken step becomes a 503, never a 200, since a monitor that reports healthy while the app is down is worse than no monitor — plus the token gating that keeps timings/errors/poll counts out of the public body. Includes one unfaked contract test running the three real endpoints end to end, so the check can't silently drift if `Api\AuthController`'s `token` key or `getMyPolls`' JSON-string return shape ever changes and leave the ping measuring nothing. Also replaced the old `dd("pong")` `/ping` route with a real dependency-free liveness probe |
| Health check alerting (`health:check --alert`, `HealthCheckAlertMail`, backup staleness) | Feature | ✅ Done — [`tests/Feature/HealthCheckCommandTest.php`](tests/Feature/HealthCheckCommandTest.php) (26 tests) | Cron-side half of the alive ping, scheduled every 10 min. The alerting rule is what the tests pin down: one mail when the check flips ok→fail, one on recovery, nothing in between — wrong in one direction and a three-hour outage sends 18 mails the team learns to ignore, wrong in the other and an outage is never announced. Also covers the deliberate choice to leave the cached state unchanged when the alert send itself throws, so the next run retries instead of treating an undelivered alert as reported. `HealthCheckAlertMail` is intentionally not `ShouldQueue` (an outage alert must not depend on a queue being worked), which is why `Mail::assertSent` works here rather than the `assertQueued` the other mailables need. Extended after the first real outage on test: a broken log channel must not stop the alert about it — `LogSendingEmail` writes a line on every `MessageSending`, so the send threw and the catch block's own `Log::error` threw again, turning a clean failure message into an uncaught StreamHandler exception. Tests now cover the alert going out with logging broken, the new `logs` step reporting the cause, and a failed send with broken logging still exiting cleanly. Extended 2026-09-19 with backup-freshness alerting after production ran 2026-09-01 to 2026-09-19 with no database backup at all: the app moved to a new server, nobody installed the scheduler's crontab entry, and the one job that would have noticed — this one — was on that same dead schedule. The freshness check therefore rides on `GET /api/health`, where an **external** monitor can see it from off-box, because a check that only runs from the scheduler cannot detect a scheduler that is not running. It reports `warn`, never `fail`, and the tests pin that down: a stale dump must not flip the endpoint or the exit code, since an uptime monitor that pages the on-call for a backup gap is one people learn to ignore. Alerting is edge-triggered on its own cache key so a liveness alert and a backup alert can fire together without masking each other |
| Test-server data scrub (`test-env:sanitize`) | Feature | ✅ Done — [`tests/Feature/SanitizeTestEnvironmentTest.php`](tests/Feature/SanitizeTestEnvironmentTest.php) (18 tests) | Makes a production copy safe for the test server: every email in `users`, `contacts`, `invitations`, `tickets` and the two statistics tables becomes an `@example.test` address (same real address → same fake one everywhere; addresses containing a `SANITIZE_KEEP_PATTERNS` fragment stay, e.g. `mroustchev` keeps `mroustchev+1@gmail.com`; fragments under 4 characters are refused), Mollie ids, remember/ticket tokens and billing-profile data are cleared, and password resets, API tokens and queued/failed jobs are emptied. Passwords are deliberately kept (the test server relies on existing logins). It deletes data, so it refuses unless six independent guards pass: `SANITIZE_ALLOWED=true`, not `APP_ENV=production`, connected DB name and `APP_URL` equal `SANITIZE_EXPECTED_DB`/`_URL`, no `SANITIZE_PROD_MARKERS` fragment in DB name/host/URL, plus a confirmation (or `--force`). Mutation check: removing any one guard fails its own test, except "expected values must be set", which is redundant with the two equality checks (null never equals the real DB name) and is kept as a second layer. `--dry-run` still runs the guards. Still to do: the Ansible restore playbook that calls it, and a mail allowlist guard for the test server (not part of this command) |
| Design routes (`/api/designs`): auth required, own designs only, poll assignment, delete fallback | Feature | ✅ Done — [`tests/Feature/DesignAccessTest.php`](tests/Feature/DesignAccessTest.php) (10 tests) | Found while wiring the Bolt frontend: the routes had no `auth:sanctum`, so bearer-token clients got no user (500 on save, own designs never listed) and any design id could be changed or deleted (SEC-010). |
| Removed data-migration routes (`/delete-designs`, `/update-design/{id}`, `/update-choice-with-default-value`, `/update-user-in-design/{id}`) | Feature | ✅ Done — [`tests/Feature/DataMigrationRoutesRemovedTest.php`](tests/Feature/DataMigrationRoutesRemovedTest.php) (2 tests) | SEC-012. The paths must reach only the frontend catch-all, and requesting them must leave polls and designs unchanged. Fails (2 of 2) with the routes in place. |
| Archive per user (G18: `PUT /api/poll/{id}/archive`, `PollArchive`, `Poll::scopeOnDashboardOf`, `poll_user_archive`, `NotifyPollAdmins`) | Feature + checked on production data | ✅ Done — [`tests/Feature/PollArchivePerUserTest.php`](tests/Feature/PollArchivePerUserTest.php) (16) | Archiving is a matter of the person looking at their dashboard: a participant archiving does not change the creator's or anybody else's dashboard, and the other way round; without a body the state toggles (the classic dashboard), with `{archived: true|false}` it is set and setting twice changes nothing (a non-boolean is 422); anonymous callers get 401 and nothing is stored; a poll that is not on the caller's own dashboard (or is deleted, or unknown) is 404; archiving does not save the poll, so `polls.updated_at` and everybody's order stay; several participant rows of one user in a poll still give one card; the old shared flag `polls.isArchive` is not read by the dashboard any more; the table cascades on user and poll delete. The one-time copy of the old flag (`PollArchive::backfillFromLegacyFlag`, run by the migration) keeps what everybody sees today (creator and participants with an account, not guests, not deleted polls) and can run twice. `polls:notify-admins` counts polls the owner has not archived and ignores what participants archived. Mutation check: five rule breaks (list ignores the per-user flag, no dashboard check, backfill without participants, explicit state ignored, cron counting a participant's archive) fail 12 tests, and moving the route back into the public group fails the 401 test. **On a copy of production (6 Oct 2026): the migration takes 3.9 s and copies exactly 96,909 rows (6,708 polls, 33,538 users); for every user, what is archived on their dashboard today equals the table, with 0 missing and 0 extra; no participant or poll names a missing user; rolled back afterwards.** Not covered: the classic and the new dashboard against the new answer in a browser (the answer keeps its shape), and the mobile app (not visible from here) |

---

## Suggested sequencing

1. P0 security surfaces first (hashids, public link endpoints, CORS/CSRF) — highest blast radius, and directly tied to the CLAUDE.md-documented open questions.
2. P0 money paths — second-highest blast radius, already has one proven-real bug class (Carbon sign flip) to guard against.
3. P1 domain logic, prioritizing anything `MIGRATION-LOG.md` already flagged as previously-broken-and-fixed (regression tests are the highest-leverage tests you can write).
4. P2 once the backend has a baseline — extend into the existing-but-idle frontend Jest/Nightwatch setup rather than standing up new tooling.
