diff --git a/examples/polls/CMakeLists.txt b/examples/polls/CMakeLists.txt new file mode 100644 index 00000000..9ebbc3d5 --- /dev/null +++ b/examples/polls/CMakeLists.txt @@ -0,0 +1,54 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# polls — rung 3 of the application ladder (examples/polls/README.md). +# All target wiring lives in morph_add_rung() (cmake/morph_add_rung.cmake); +# this file only pulls in polls-specific sources it doesn't know about, then +# calls it. + +cmake_minimum_required(VERSION 3.25) + +morph_add_rung(NAME polls) + +# morph_add_rung() only globs src/models/*.cpp, src/db/*.cpp and +# src/app/*.cpp into ladder_polls_lib (cmake/morph_add_rung.cmake:91-92) +# — it does not know about this rung's src/auth/ (Tasks 1-10's +# PollsAuthorizer), so without an explicit target_sources() call the rung +# fails to link with undefined polls::auth::PollsAuthorizer symbols. +# Mirrors bookmarks' own CMakeLists.txt treatment of src/import/ and src/dto/. +# (src/db/schema.cpp needs no equivalent line here -- the glob above already +# covers src/db/*.cpp.) +if(TARGET ladder_polls_lib) + target_sources(ladder_polls_lib PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/src/auth/polls_authorizer.cpp") +endif() + +# ladder_polls_lib is native-only (morph_add_rung.cmake's own comment: +# "ladder__gui_wasm never links ladder__lib — so this target +# genuinely never needs to build under Emscripten at all"), so the +# target_sources() call above silently no-ops under EMSCRIPTEN. Nothing in +# ladder_polls_gui_wasm references PollsAuthorizer today, so this has not +# yet produced bookmarks' identical undefined-symbol link failure — but the +# same trap is there the moment it does. polls_authorizer.cpp has no +# persistence dependency, so it is equally at home in ladder_polls_gui_lib, +# which does build under Emscripten and is what ladder_polls_gui_wasm links. +# Mirrors bookmarks' own CMakeLists.txt treatment of the identical gap for +# src/dto/auth_dto.cpp. +if(TARGET ladder_polls_gui_lib AND NOT TARGET ladder_polls_lib) + target_sources(ladder_polls_gui_lib PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/src/auth/polls_authorizer.cpp") +endif() + +# ── The WASM client's server url ──────────────────────────────────────────── +# Same mechanism as pastebin's/bookmarks' own CMakeLists.txt — see either +# file's comment. Port 8767 matches ladder_polls_server's own compiled-in +# default (src/server/main.cpp), the next free port after pastebin's 8765 and +# bookmarks' 8766. +if(TARGET ladder_polls_gui_wasm) + if(NOT DEFINED MORPH_LADDER_POLLS_WASM_SERVER_URL) + set(MORPH_LADDER_POLLS_WASM_SERVER_URL "ws://127.0.0.1:8767" CACHE STRING + "URL polls' WASM client connects to; must be a reachable ladder_polls_server.") + endif() + target_compile_definitions(ladder_polls_gui_wasm PRIVATE + MORPH_LADDER_POLLS_WASM_SERVER_URL="${MORPH_LADDER_POLLS_WASM_SERVER_URL}" + ) +endif() diff --git a/examples/polls/README.md b/examples/polls/README.md new file mode 100644 index 00000000..def5862b --- /dev/null +++ b/examples/polls/README.md @@ -0,0 +1,487 @@ +# polls — rung 3 of the [application ladder](../LADDER.md) + +**Status: shipped** — every rung-3 task is complete; see +[Definition of done](#definition-of-done) for what that does and does not +mean, and ["The client, and its known gaps"](#the-client-and-its-known-gaps--stated-rather-than-smoothed-over) +for what the shipped client cannot reach (there is no native desktop entry +point at all, so the live multi-client demo the DoD asks for has not been +run; the WASM client is written and CI-gated but has never been compiled +here). Design decisions below were resolved in writing before implementation +began, per [`LADDER.md`](../LADDER.md)'s discipline rule. + +## Design decisions (resolved before implementation) + +Research done ahead of writing this rung's implementation plan surfaced two +places where this README's own framing does not match the framework as it +actually exists, plus decisions the README named but left open. Recorded +here, in writing, before any task starts — the discipline rule this ladder +runs on. + +1. **`session::Principal` is not a capability-token mechanism — correction.** + This README originally described participant identity as "the participant + token in `session::Context` ... `session::Principal` (added in #34) + carrying a capability token instead of a user identity." The real + `session::Principal` (`docs/spec/session/session.md`) is a client-side, + `Bridge`-scoped UI cache populated *after* login from server-returned + data — it has no wire representation and does not participate in + dispatch authorization at all ("Setting a `Principal` does not affect + `Context` or dispatch behavior in any way"). There is no existing + framework mechanism for a bare shared-secret-per-entity capability token. + **Resolved shape**: `Context::token` carries the poll's admin secret; + `PollModel::execute()` verifies it itself, by comparing against the poll + row's stored `adminToken` column — the same shape as a + `SigningAuthorizer`-verified token, but hand-verified in the model rather + than by an `IAuthorizer`, since no framework authorizer verifies bare + shared secrets. `Context::principal` carries the free-text + `participantName` `SubmitVotes` already names as an action field. + **`UndoLastVoteChange`'s "principal-scoped" therefore means keyed on + `(pollId, participantName)`**, not a framework-authenticated identity. + + **What shipped, stated exactly** (corrected after the final whole-branch + review found this section overclaiming): `FinalizePoll` is the *only* + token-gated action in `PollModel`. `SubmitVotes`, `UpdateVotes`, + `AddComment`, `UndoLastVoteChange`, `GetPollState`, `GetEventsSince` and + the keyed `OpenPoll` attach are all reachable by anyone who can name the + `pollId`, with no token check at all — which is the intended design, not + a gap: `pollId` is 16 bytes of `std::random_device` entropy in base64url, + so knowing it *is* the capability (design decision 2 says as much: + "attaching to a poll by id is meant to be as open as knowing the link"). + A participant gate would add no authority in any case, since one + participant token is minted per *poll*, not per participant, and every + voter would present the same secret. `CreatePollResult::participantToken` + is accordingly generated, stored, returned and shown by + `CreatePollView.qml` — and **verified by nothing**; it is reserved for a + later rung wanting a second, separately revocable capability level. An + earlier draft carried a `PollModel::requireParticipant()` helper with no + call sites; it was removed rather than left implying a check that does + not happen. +2. **Registration identity applies to shared/keyed registration too, not + just plain registration.** `registerModelShared`/`attachModel`'s wire + form is still a `register` envelope (`docs/spec/core/shared_instances.md`: + "`register` grows `primary` and `shared`" — additive, same envelope + kind), and `wire::makeRegisterShared` carries the caller's session, just + like plain `wire::makeRegister` now does. So `authorizeRegister` *could* + gate `OpenPoll{pollId}` (the keyed attach) by admin/participant identity + — this rung deliberately doesn't. **Resolved shape**: `authorizeRegister` + stays unconditionally permissive for `PollModel` (attaching to a poll by + id is meant to be as open as knowing the link, by design — this is not a + regression, and not something the framework forces), and the one action + that must distinguish admin from participant (`FinalizePoll` — in the + shipped rung, the only one that does) re-checks the caller's token + against the poll row's own `adminToken` column inside + `PollModel::execute()`. This mirrors rung 2's shape for a different + reason than it originally did: bookmarks' `authorizeInstance` is now + genuinely enforcing but checks *instance* ownership, which `PollModel` + (shared/keyed, not per-caller-owned) has no equivalent of at all — so the + model's own re-check was never standing in for a defeated hook, it is + simply the only layer that could ever express this distinction. +3. **Undo is entirely app-level; the framework journal contributes nothing + to it.** `SessionLog::undoLast()` (`docs/spec/journal/journal.md`) "pops + the most recent entry and replays the remainder against a fresh, + detached model instance" — no principal filtering, and the returned + holder cannot be installed into a live shared instance. This is not a + bug to work around at the call site; the framework's own journal design + record states plainly that "reversing a checkpointed action durably + needs a compensating action" at the app level. **Resolved shape**: + `PollModel` owns a small per-`(pollId, participantName)` vote-history + table of its own (not the framework's `FileActionLog`/journal), and + `UndoLastVoteChange` reads and reverses the caller's own most recent + entry from it via ordinary mutation. The framework journal remains wired + for audit-trail purposes (same two-independent-write default every + single-row action in rung 2 used) but is orthogonal to undo. +4. **`GetEventsSince` is genuinely new work, not a `GetChangesSince` port.** + Rung 2's `GetChangesSince` is a timestamp-diffed-current-state view + (`WHERE updatedAtMs > since OR (updatedAtMs = since AND id > lastId)` — + the `id` tie-break is issue #43's fix for the millisecond-boundary case a + bare `updatedAtMs > since` can silently drop; still a current-state view, + returning full current rows, not a log) — not the Zulip append-only + event-log pattern this rung's own "morph subsystems exercised" section + correctly calls for. **Resolved shape**: a genuine `poll_events` table + (sequence id + payload per mutation), with a **table-wide monotonic + autoincrement sequence id, not a timestamp** — rung 2's + `BulkEdit`/`MergeTags` idempotency-key fix rounds (Tasks 8/9) both hit + millisecond-collision bugs from timestamp-keyed uniqueness; an + autoincrement primary key sidesteps that class of bug entirely, and + the README's own requirement ("a client holding `lastEventId=42`... sees + nothing new forever, silently") is exactly what a durable, never-reused + sequence id guarantees. **The "and/or epoch token" alternative the + original strain-point text offered is resolved to: not needed.** Durable + SQLite persistence of the event log alone already closes the gap + (an in-memory-only list dying at refcount zero) the epoch token existed + to catch; a poll's row-level data plus its event table both survive + instance rebirth by construction once persisted, so a reborn instance + naturally continues the same global sequence with no separate epoch + concept to design, test, or explain. `GetEventsSince{lastEventId}` + returns every event with `id > lastEventId` for the poll, oldest first; + an empty poll's-worth of history (a truly stale cursor, e.g. `lastEventId` + far beyond the table's current max) is handled the same way any + over-advanced cursor is — see the model task for the exact response + shape. +5. **`messagesPerSecond` is not a framework gap — already implemented.** + `QtWebSocketServerConfig::messagesPerSecond` (`docs/spec/core/backend.md`) + is a real, shipped, separately-tested per-connection token bucket; a + frame that finds an empty bucket is dropped silently. This rung's own + "run this rung's harness with `messagesPerSecond` configured ON" is a + **test-harness configuration decision**, not new framework work — the + client-side execute-deadline prerequisite below is what actually needs + building; the rate limiter it must survive already exists. +6. **`CreatePoll` runs from the native/desktop client only — never from a + WASM tab.** Closing framework prerequisite #1 (below) discovered a + second, narrower gap it does not close: + `Bridge::assignHandlerPrimary`'s promote step (filing a freshly-created + shared instance into the directory under its generated key) has no + async path — `IBackend::assignPrimary` is still a synchronous `sendSync` + on `QtWebSocketBackend`, with no `assignPrimaryAsync` anywhere in the + tree. `CreatePoll` is a result-keyed *creating* action (the instance + doesn't exist until the call returns and names it), so a WASM tab + dispatching it would still abort the page at the promote step — filed + as `docs/findings/032-assignprimary-has-no-async-path.md`. **Resolved + shape**: this matches Rallly's own anchor UX exactly (an organizer + creates via the main app/site; participants open a shared link in + whatever browser tab they have), so the rung's own design already wants + this split — `CreatePoll` is native-client-only by design, not merely + worked around; every WASM tab's role is strictly the participant-attach + story (`OpenPoll`, payload-keyed, fully covered by the prerequisite work + below), never poll creation. +7. **`OpenPoll::pollId` (and any field a `BRIDGE_MODEL_KEY`/`BRIDGE_KEY_FROM` + macro deduces a key type from) must be plain `std::string`, not a strong + type.** `morph::model::ModelKey`'s concept (`include/morph/core/model_key.hpp`) + requires an exact `std::same_as` or `std::integral` + match — a wrapper type like rung 1/2's `PasteId`/`BookmarkId` does not + satisfy it, since the macro deduces `PrimaryKey` directly from the + member's own declared type via `MemberTypeOf`. This is a genuine, + narrow exception to `IMPLEMENTATION.md` rule 3 ("only `std::string` is a + permitted plain type"), not a violation of it: `pollId` is a shareable + link identifier, the same natural-string-identity category rule 3 + already carves out for URLs and titles — it is generated once + server-side as an unguessable random token (mirroring the admin/ + participant tokens' own generation), never user-typed, and never + confused with an ordinary integer id precisely because it *is* a + string. Every other identity field this rung defines (`OptionId`, the + event log's sequence id) is never the target of a keying macro and + stays a strong type, per the usual rule. + +## Framework prerequisites (built as part of this rung, before the app tasks that depend on them consume them) + +Two items `LADDER.md`'s "Framework prerequisites" section names as blocking +this rung specifically, both confirmed still open by direct inspection of +the current framework source (not assumed from the ladder doc alone): + +- **Async shared/keyed attach.** `IBackend::registerModelAsync`'s own doc + comment (`include/morph/core/backend.hpp`) explicitly scopes itself out of + `registerModelShared`/`attachModel`, which remain synchronous (nest a + `QEventLoop`) — the very first `OpenPoll` a WASM tab makes aborts the + page. Built as this rung's first framework-level task, mirroring + `registerModelAsync`'s existing opt-in/fallback shape (backend returns + `true` and later invokes exactly one callback, or returns `false` and the + caller falls back to the synchronous path unaffected) so every backend + that has not opted in keeps its current behavior. +- **Client-side execute deadline.** No timeout exists anywhere on a + `Completion` today — a frame silently dropped by `messagesPerSecond`, or + a genuinely hung server, blocks the calling `Completion` forever. + `Completion::state()` already exposes the underlying + `CompletionState`, and `CompletionState::setException` is + idempotent-guarded (`if (ready) return;`), so the fix needs no + `Completion`/`CompletionState` API changes — only a new client-side timer + that races a delayed `setException(ClientTimeoutError)` against the real + reply. Built as this rung's second framework-level task, before the + polling helper (`GetEventsSince` on a client timer) that is untestable + without it. + +Group scheduling polls, Doodle-style: create a poll with +candidate dates, send one link to participants, everyone votes yes / if-need-be +/ no, the organizer finalizes a date. The first genuinely *concurrent +multi-client* rung: many participants converge on one shared poll instance. + +## Reference implementations + +- **[Rallly](https://github.com/lukevella/rallly)** (TypeScript, Next.js + + tRPC + Prisma, AGPL) — the anchor. Its tRPC procedures are already typed + request/response actions, and the codebase verifiably contains **no + websockets/SSE/socket.io at all**: concurrent voters see each other's votes + on refetch. It is living proof this category needs no push. Data model to + copy (from `packages/database/prisma/models/`): `Poll`, `Option`, + `Participant`, `Vote (yes|ifNeedBe|no)`, `Comment`. Ignore the SaaS + billing/licensing packages entirely. +- [Framadate](https://framagit.org/framasoft/framadate/framadate) — archived; + do not use. + +## What to implement + +`PollModel` keyed by poll id — **the shared-instance showcase**: + +``` +BRIDGE_MODEL_KEY(PollModel, OpenPoll, &OpenPoll::pollId); +BridgeHandler handler{bridge, &ui}; +``` + +Actions, in build order: + +1. `CreatePoll { title, options[] }` → admin + participant link tokens. +2. `OpenPoll { pollId }` (the keyed action), `GetPollState {}`. +3. `SubmitVotes { participantName, votes[] }` — **anonymous**: participants + have no account; the participant token in `session::Context` is the whole + identity. `UpdateVotes`, `AddComment`. +4. `FinalizePoll { optionId }` — admin-token-gated state transition; the poll + becomes read-only. +5. `UndoLastVoteChange` — user-facing undo, **redesigned per review**: it + must be *principal-scoped* ("undo *my* last change") and implemented as a + **compensating action**, not `SessionLog::undoLast()` — which (a) pops + the newest entry *regardless of principal* (A's undo would kill B's + vote), and (b) returns a fresh **detached** holder that no API can + install into the live server registry, so replay-undo cannot mutate a + shared instance at all. Write the interleaving test first (A votes, B + votes, A undoes → assert whose vote died) — its outcome is the rung's + headline design record. +6. **`GetEventsSince { lastEventId }`** — this rung's framework-level + deliverable: the Zulip-pattern generic polling action (see below). + **Event storage, resolved (design decision 4 above)**: shared instances + are destroyed *immediately* at refcount zero, so an in-instance event + list dies the moment all tabs briefly close (a link shared in chat + produces exactly this) — solved by persisting events to a genuine + `poll_events` SQLite table keyed by a table-wide monotonic autoincrement + sequence id, not an epoch token: a reborn instance reads the same + durable table and continues the same sequence, so a client holding + `lastEventId = 42` simply gets every real event after 42, rebirth or + not. Test: attach N, mutate, detach all (verify destruction via + `instances()`), attach again, poll with the pre-death cursor. + +Persistence: SQLite tables mirroring Rallly's Prisma models, plus the event +log table above. + +## morph subsystems exercised + +- **Shared instances end-to-end**: N clients (desktop + several WASM tabs) + attach to one server-side `PollModel` instance; refcounted lifetime when + tabs close; `handler.instances()` for an organizer dashboard. +- **Anonymous principals**: no framework identity at all — `Context::token` + carries the poll's admin-or-participant secret, hand-verified by + `PollModel::execute()` itself against the poll row's own columns (design + decision 1 above; there is no framework `IAuthorizer` for bare shared + secrets, so this rung does not add one). +- **Event polling — the pattern the rest of the ladder reuses.** morph has no + server push and in-process-only subscriptions, so remote clients must ask. + Implement the [Zulip events-system pattern](https://zulip.readthedocs.io/en/stable/subsystems/events-system.html) + in miniature: every mutation appends to a per-poll event list (sequence id + + payload); clients poll `GetEventsSince` on a timer and apply increments; + a stale client falls back to `GetPollState`. Zulip proves an entire chat + product ships on exactly this; here it debuts at toy scale. +- **Journal as user feature**: vote-change history and undo, not just audit. + +## Expected strain points + +- **WASM + shared handlers may not work at all today [framework + prerequisite]**: the shared/keyed attach path + (`registerModelShared`/`attachModel`) is synchronous and nests an event + loop — which **aborts the page on the WASM main thread**; + `registerModelAsync` covers only the plain path. A WASM tab's very first + `OpenPoll` hits this. Run the "several WASM tabs" demo literally, before + any polling logic exists; schedule async attach as a framework issue (see + [`../LADDER.md`](../LADDER.md) § Framework prerequisites). +- **The polling helper must own a client-side timeout**: a rate-limited + server drops frames silently and morph has no execute deadline — an + unwrapped poll call hangs its completion forever. Every later rung + inherits this helper; get it right here — and **run this rung's harness + with `messagesPerSecond` configured ON** (a polling app is the abuse case + the limiter exists for; the helper's timeout is untested until the + limiter actually drops its frames). +- Poll-interval latency: two voters editing simultaneously see each other + only on the next tick — measure and document acceptable intervals. +- `subscribe` fan-out is in-process only: verify the documented limit that + two *remote* clients do not see each other's results without polling, and + show `GetEventsSince` closing the gap. This rung is also the **first test + anywhere of `AllowShared` over the real WebSocket transport** — the + framework itself gains coverage here. +- **Poisoned-instance attach**: opening a stale/mistyped poll link exercises + the documented shared-instance failure modes (half-hydrated instance, + eviction only on *next* attach, the failing handler not self-healing); + also race two attaches against a failing first hydration. +- **Duplicate `SubmitVotes` on retry** must not double-count: the strand + serializes but does not dedup — participant-token + option uniqueness is + a model invariant, tested under retry. +- A vote in flight (or queued offline) when `FinalizePoll` lands must + dead-letter with a user-visible outcome, not vanish. +- Timezone display of candidate dates (`morph::time` is UTC-only; + per-participant local rendering is GUI logic) — a good dual-mode + + WASM-parity presenter test. +- **Shared-instance churn soak** (framework-grade, promoted to + `tests/soak/`): threads racing register-or-attach / deregister / + closeConnection / execute on one key under TSan — never two live + instances for a key, attach counts never leak, every completion resolves. + +## Definition of done + +- Live demo: one organizer + three participant clients on the remote + backend, votes converging via polling; finalize locks the poll everywhere. + **Not satisfied.** This rung ships no native desktop entry point + (`examples/polls/gui/main.cpp` does not exist — no task in its plan wrote + one), and its only GUI binary, `gui_wasm/main_wasm.cpp`, has never been + compiled for want of an Emscripten toolchain here. Nothing in this rung has + therefore been run as an application against a real server. What *is* + verified is every layer beneath that: `tests/test_app.cpp` drives the + remote backend end to end, `tests/test_poll_qml_bridges.cpp` drives the + whole QML-facing adapter including one real `EventPoller` tick, and + `tests/test_shared_instance_lifecycle.cpp` covers multi-handler + convergence on one shared poll. Writing the desktop entry point and + running the demo is named follow-up work, not a claim made here. +- Principal-scoped undo restores the caller's previous vote via a + compensating action, verified by the two-principal interleaving test -- + "principal-scoped" here means keyed on `(pollId, participantName)` per + design decision 1, not a framework-authenticated identity; the + `SessionLog::undoLast` limitation is documented in the rung's design + record. + **Confirmed (Task 8):** the interleaving test (A votes, B votes, A undoes) + passes against a real SQLite-backed `PollModel` -- A's undo restores only + A's prior (no-vote) state via `UndoLastVoteChange`, and B's vote survives + completely untouched, the exact outcome `SessionLog::undoLast()` + (principal-blind, pops the newest entry regardless of who made it) could + never have produced. +- Event log survives full detach/reattach (instance rebirth) and a stale + cursor triggers a clean full resync, verified by test. + **Confirmed (Task 9):** a `BackendRig`-driven test attaches two + `AllowShared` `PollModel` handlers to the same poll (`instances()` shows + one live key), drops every handler naming that poll, and confirms via a + fresh handler's own `instances()` that the shared instance is genuinely + gone (empty directory, not just "no crash"). A brand-new handler then + reattaches via `OpenPoll` and calls `GetEventsSince` with the pre-death + cursor: it gets exactly the events written after that cursor, including + ones recorded before the instance died -- confirmed independently against + the real on-disk SQLite file (`sqlite3` inspection of `poll_events`), not + just the in-memory assertions. No epoch token was needed, exactly as + design decision 2 above predicts. +- The event-polling helper (with its client-side timeout) is factored so + [`kanban`](../kanban) can lift it. + **Confirmed (Task 15):** `morph::ladder::gui::EventPoller` + lives in `examples/common/gui/event_poller.hpp`, not in this rung — it + names no `polls::` type, taking its event and cursor types as template + parameters and its backend reach as a caller-supplied `Dispatch` closure, + which is what lets kanban wire its own feed without re-deriving the + retry-vs-fatal decision tree. Its behaviour is covered by + `examples/common/testkit/test_event_poller.cpp` against a synthetic + dispatch, independently of `polls` entirely; `PollBridge::startPolling` is + merely its first consumer. + +## The client, and its known gaps — stated rather than smoothed over + +Task 16 built the GUI shell: `gui_lib/poll_schemas.hpp` (the +`{actionType: schema}` document), `gui_lib/poll_forms_controller.{hpp,cpp}` +(the one `BridgeHandler` every already-open-poll +action shares), `gui_lib/poll_qml_bridges.{hpp,cpp}` (`PollBridge`, the one +QML-facing adapter, wrapping both `PollFormsController` and `PollPresenter`), +and `gui/qml/{Main,CreatePollView,VoteView}.qml`. Three of `PollModel`'s nine +actions are genuinely schema-driven (`AddComment`, `FinalizePoll`, +`UndoLastVoteChange` — all scalar-field DTOs, rendered by the shipped +`MorphForms` `DynamicForm`); the rest are dedicated `PollBridge` invokables, +for the reasons below. + +**No `gui/main.cpp`, still.** Task 16's brief scoped the desktop client's +entry point out (`gui/*.cpp` is absent from its file list), no later task in +this rung's plan added one, and the branch's final whole-branch review chose +to name the gap rather than close it. Wiring `ladder_polls_gui` together, and +with it the live end-to-end organizer-plus-participants demo the Definition +of Done above asks for, is follow-up work. The one entry point that *does* +exist is `gui_wasm/main_wasm.cpp` (Task 18), the browser client — which +cannot create polls (`nativeClient: false`) and has never been compiled. +Today `ladder_polls_qml`/`ladder_polls_gui_lib` build and +are proven by the offscreen engine-load smoke test +(`tests/test_gui_qml_smoke.cpp`) and the adapter-layer suite +(`tests/test_poll_qml_bridges.cpp`), including one real end-to-end +`EventPoller` tick (`PollBridge's EventPoller applies a live event and +refreshes state, end to end`) — but nothing here has yet been run as an +actual desktop application against a real server. + +Known gaps: + +- **`DynamicForm` has no control for a JSON `array` field** (finding 031, + discovered during rung 2's own GUI shell). `CreatePoll::options` is + `std::vector` and hits this directly, so `CreatePoll` is + excluded from `poll_schemas.hpp`'s document entirely and driven instead by + `gui/qml/CreatePollView.qml`'s own hand-written option-label list editor + (add/remove rows), submitted through `PollBridge::createPoll(title, + optionLabels)` — the same shape rung 2's `BulkEdit` workaround established. + **The same finding also blocks `SubmitVotes`/`UpdateVotes`**, whose one + required field beyond `participantName` is `std::vector` — not + called out by name in finding 031 itself (rung 2 has no array-of-struct + DTO field to have found it with), but the identical rendering gap. Both are + excluded from the schema document too and driven by + `gui/qml/VoteView.qml`'s hand-rolled per-option Yes/If-need-be/No radio + picker, via `PollBridge::submitVotes`/`updateVotes`. +- **`BridgeHandler::executeJson` silently skips the payload-keyed attach + step on an `AllowShared` handler** — a new finding this task surfaced, + filed as + [finding 034](../../docs/findings/034-executejson-skips-payload-keyed-attach-for-allowshared-handlers.md). + `ActionExecuteRegistry::registerAction` (`morph/core/bridge.hpp`) + closes its stored executor over the *plain* `BridgeHandler` overload + of `execute()`, regardless of the real handler's `Sharing` + argument, so `kShared` resolves `false` at that call site no matter what — + dispatching `OpenPoll` (this rung's one payload-keyed action) through + `executeJson` on an `AllowShared` handler therefore never attaches; it + dispatches straight to `executeVia` with whatever `currentId` the binding + already has, failing "handler not bound" on a fresh handler. `OpenPoll` is + therefore never routed through `submitIfValid`/`executeJson` anywhere in + this client — `PollFormsController::openPoll(pollId)` calls the templated + `execute()` directly instead, which resolves the real + `AllowShared` branch at compile time. Every other action `PollModel` + registers is unkeyed, so it dispatches identically either way and this gap + never bites them — but it is a real, general framework gap for any future + keyed `AllowShared` model that tries to schema-drive its own attach action. +- **`PollFormsController` cannot be a verbatim copy of + `bookmarks::gui::BookmarkFormsController`'s per-model-handler shape.** + Every one of bookmarks' three models is plain (`NoSharing`), so which + handler object serves a given call never matters there. `PollModel` is + `AllowShared` and keyed: an `AllowShared` handler starts unattached and + only joins the poll's shared instance the first time a payload-keyed + action dispatches through *that specific handler object* — every other + action on the same poll must reuse that exact handler. `PollFormsController` + therefore owns exactly one `BridgeHandler`, shared + by `openPoll`/`getPollState`/`submitVotes`/`updateVotes`/`getEventsSince` + and the three schema-driven actions alike, rather than one handler per + concern. See that class's own doc comment for the full reasoning, and + `tests/test_poll_qml_bridges.cpp`'s "threads openPoll's attach through + every later action on the same poll" case for the regression proof. +- **The event-driven results display resyncs on every applied event rather + than applying a true increment.** `PollEvent{id, kind, summary}` carries no + vote-tally delta — only a human-readable summary — so + `PollBridge::onEventApplied` relays it to `eventReceived` (for a live + activity log) and separately schedules a debounced `refresh()` + (`GetPollState`) to update the actual tallies. This is simple and correct + but is one full state refetch per tick that had at least one event, not + the increment-application the Zulip pattern's `README`-level description + suggests — acceptable at this rung's toy scale, worth reconsidering if a + later rung's event volume makes it not. +- **The `CreatePoll` screen is native-client-only by gate, not by absence.** + `gui/qml/Main.qml`'s `nativeClient` property (default `true`) hides — not + merely disables — the one button that reaches `CreatePollView.qml` (see + design decision 6 above for why `CreatePoll` must never run from a WASM + tab). `gui_wasm/main_wasm.cpp` (Task 18) is what flips it, passing + `nativeClient: false` as an initial property. The consequence, stated + plainly: **the browser client cannot create a poll at all.** A WASM + participant either follows a `?poll=` link or pastes a poll id on the + landing screen; some organizer on some other client had to create it, and + today no such client exists (see the next bullet). +- **No native desktop entry point exists, so nothing here has been run as an + application.** There is no `examples/polls/gui/main.cpp`; no task in this + rung's plan wrote one, and this fix round deliberately did not add one + either. `gui_wasm/main_wasm.cpp` is the only GUI client binary this rung + ships, and it has never been compiled (no Emscripten toolchain here — the + `ladder-wasm` CI job is a compile gate). Writing `gui/main.cpp` and running + the organizer-plus-participants demo is named follow-up work. +- **`Bridge::setExecuteDeadline` used to be unusable from a browser tab, and + the fix is CI-compile-verified only.** `EventPoller`'s constructor calls it + unconditionally, and it lazily builds a `TimeoutScheduler`, which spawned a + `std::thread` — impossible in the `wasm_singlethread` Qt build these + clients target. `include/morph/core/timeout_scheduler.hpp` now selects a + browser-timer (`emscripten_async_call`) build of itself under + `__EMSCRIPTEN__ && !__EMSCRIPTEN_PTHREADS__`, so deadlines still fire, on + the main thread. Neither the original hazard nor the fix has been observed + on a real Emscripten build; see that header's `@file` comment and + `docs/spec/core/completion.md`. +- **No admin-token persistence.** `PollBridge::setAdminToken` installs the + token as the shared `Bridge`'s default session for the remainder of the + process; nothing writes it to disk or a keychain. Reopening the app (or + the organizer coming back later) needs the admin token pasted in again — + `CreatePollView.qml` shows it once, selectable, and says so. +- **The offscreen QML smoke test proves loading, not behavior** — same scope + note as rung 2's own smoke test (`tests/test_gui_qml_smoke.cpp`'s own + header comment). The behavioral half is `tests/test_poll_qml_bridges.cpp` + plus `tests/test_poll_presenter.cpp`. diff --git a/examples/polls/gui/qml/CreatePollView.qml b/examples/polls/gui/qml/CreatePollView.qml new file mode 100644 index 00000000..468be214 --- /dev/null +++ b/examples/polls/gui/qml/CreatePollView.qml @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// The organizer's create-poll screen. Native-client-only (Main.qml only ever +// pushes this behind its nativeClient gate) — see examples/polls/README.md's +// resolved design decision 6. +// +// CreatePoll::options is std::vector -- a JSON array of +// *objects*, not the array-of-strings DynamicForm's array-field control +// (src/qt/forms/qml/DynamicForm.qml's arrayJsonLiteral) supports — this +// whole screen is therefore driven by hand, not by a DynamicForm at all, +// exactly like rung 2's BulkEdit workaround: a plain +// title TextField plus a small hand-written option-label list editor (add/ +// remove rows), submitted via PollBridge::createPoll(title, optionLabels) +// directly. See poll_schemas.hpp's own doc comment. +// +// `pollBridge` defaults to null so this same file also loads with nothing +// wired up, which is exactly what the offscreen engine-load smoke test +// (tests/test_gui_qml_smoke.cpp) does. + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Item { + id: page + + property var pollBridge: null + + /// Emitted when the organizer chooses to go straight to the freshly + /// created poll's vote view. Main.qml listens and pushes VoteView. + signal openRequested(string pollId) + + property string titleText: "" + property var optionLabels: ["", ""] // CreatePoll requires 2-20 options + property var lastResult: null // {pollId, adminToken, participantToken} + property string status: "" + property bool statusIsError: false + + readonly property bool canSubmit: page.pollBridge !== null + && page.titleText.trim() !== "" + && page.optionLabels.length >= 2 + && page.optionLabels.every(function (label) { return label.trim() !== "" }) + + function addOption() { + page.optionLabels = page.optionLabels.concat([""]) + } + + function removeOption(index) { + if (page.optionLabels.length <= 2) + return + const next = page.optionLabels.slice() + next.splice(index, 1) + page.optionLabels = next + } + + function setOption(index, text) { + const next = page.optionLabels.slice() + next[index] = text + page.optionLabels = next + } + + Connections { + target: page.pollBridge + + function onCreated(result) { + page.lastResult = result + page.status = "poll created — copy the admin token before leaving this screen" + page.statusIsError = false + } + + function onFailed(message) { + page.status = message + page.statusIsError = true + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 8 + + RowLayout { + Layout.fillWidth: true + Button { + text: "< Back" + onClicked: page.StackView.view.pop() + } + Label { + Layout.fillWidth: true + font.bold: true + text: "Create a poll" + } + } + + Label { + Layout.fillWidth: true + visible: page.status !== "" + wrapMode: Text.Wrap + color: page.statusIsError ? "#d33" : palette.text + text: page.status + } + + ColumnLayout { + Layout.fillWidth: true + visible: page.lastResult === null + spacing: 6 + + Label { text: "Title" } + TextField { + Layout.fillWidth: true + placeholderText: "e.g. Team offsite" + onTextChanged: page.titleText = text + } + + Label { text: "Candidate dates/options (2-20)" } + + Repeater { + model: page.optionLabels + + delegate: RowLayout { + id: row + required property string modelData + required property int index + Layout.fillWidth: true + + TextField { + Layout.fillWidth: true + placeholderText: "e.g. 2026-09-01" + text: row.modelData + onTextChanged: page.setOption(row.index, text) + } + + Button { + text: "remove" + enabled: page.optionLabels.length > 2 + onClicked: page.removeOption(row.index) + } + } + } + + Button { + text: "+ add option" + enabled: page.optionLabels.length < 20 + onClicked: page.addOption() + } + + Button { + Layout.fillWidth: true + text: "Create poll" + enabled: page.canSubmit + onClicked: page.pollBridge.createPoll(page.titleText, page.optionLabels) + } + } + + ColumnLayout { + Layout.fillWidth: true + visible: page.lastResult !== null + spacing: 6 + + Label { + Layout.fillWidth: true + text: "Poll id (share this link's id with participants):" + } + TextField { + Layout.fillWidth: true + readOnly: true + selectByMouse: true + text: page.lastResult ? page.lastResult.pollId : "" + } + + Label { + Layout.fillWidth: true + text: "Admin token (keep this — needed to finalize the poll):" + } + TextField { + Layout.fillWidth: true + readOnly: true + selectByMouse: true + text: page.lastResult ? page.lastResult.adminToken : "" + } + + Label { + Layout.fillWidth: true + text: "Participant token (goes out with the shared link):" + } + TextField { + Layout.fillWidth: true + readOnly: true + selectByMouse: true + text: page.lastResult ? page.lastResult.participantToken : "" + } + + Button { + Layout.fillWidth: true + text: "Open this poll now" + onClicked: page.openRequested(page.lastResult.pollId) + } + } + } +} diff --git a/examples/polls/gui/qml/Main.qml b/examples/polls/gui/qml/Main.qml new file mode 100644 index 00000000..9a3c224b --- /dev/null +++ b/examples/polls/gui/qml/Main.qml @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// polls' desktop shell: a StackView holding the landing screen (inline, +// below — this rung ships only three QML files per its task brief, so there +// is no separate LandingView.qml) plus the two screens it can push: +// CreatePollView (native-client-only — see nativeClient below) and VoteView. +// +// The controller properties below are supplied by a client's own entry point +// through QQmlApplicationEngine::setInitialProperties. Exactly one such entry +// point exists today: gui_wasm/main_wasm.cpp, the browser client. There is +// deliberately no gui/main.cpp — no task in this rung's plan wrote a native +// desktop entry point, and adding one is named follow-up work in +// examples/polls/README.md ("No gui/main.cpp yet"), not an oversight this +// file works around. +// +// Every property defaults to a value that makes this file load with nothing +// wired up at all, which is exactly what the offscreen engine-load smoke test +// (tests/test_gui_qml_smoke.cpp) relies on. + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +ApplicationWindow { + id: root + width: 1000 + height: 720 + visible: true + title: "polls — morph application ladder, rung 3" + + property var pollBridge: null + + /// Whether this build may create polls. `CreatePoll` is native-client-only + /// per this rung's Global Constraints (examples/polls/README.md, + /// resolved design decision 6: a WASM tab's `assignHandlerPrimary` promote + /// step has no async path and would abort the page). Defaults to `true`, + /// the value a native desktop shell would leave alone; + /// gui_wasm/main_wasm.cpp passes `nativeClient: false` as an initial + /// property, which hides (not merely disables — see the Button below) the + /// one UI affordance that reaches CreatePollView. + property bool nativeClient: true + + /// Set by the WASM client, which parses `?poll=` from the page url + /// (`gui_wasm/main_wasm.cpp`'s `EM_JS` shim) so a participant following a shared link + /// lands directly on that poll's vote view instead of the landing page. + /// Empty (the default) preserves today's behaviour exactly — the + /// `StackView` below still starts on, and stays on, `landingPage`; every + /// existing QML smoke test's assertions are unaffected. Passed the same + /// way as `pollBridge`/`nativeClient` above: a root-object property set + /// from C++ via `QQmlApplicationEngine::setInitialProperties` right after + /// the engine is constructed. + property string initialPollId: "" + + /// The whole `{actionType: schema}` document, parsed once here rather + /// than per form: it is a CONSTANT property on the controller, so one + /// parse is all it can ever need. + property var schemas: root.pollBridge ? JSON.parse(root.pollBridge.schemasJson) : ({}) + + ColumnLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 6 + + Label { + font.bold: true + text: "polls" + } + + StackView { + id: stack + Layout.fillWidth: true + Layout.fillHeight: true + initialItem: landingPage + + // Pushes straight to the shared poll named by a WASM client's + // `?poll=` link, on top of the still-loaded landingPage (so + // VoteView's own "< Back" button returns somewhere sensible + // rather than exiting). A no-op — root.initialPollId stays "" — + // for every client that does not set it, native or WASM. + Component.onCompleted: { + if (root.initialPollId !== "") + stack.push(votePage, { pollId: root.initialPollId }) + } + } + } + + Component { + id: landingPage + + Item { + id: landing + property string joinPollId: "" + + ColumnLayout { + anchors.centerIn: parent + width: Math.min(landing.width - 32, 460) + spacing: 12 + + Label { + Layout.fillWidth: true + font.pixelSize: 18 + font.bold: true + text: "Doodle-style scheduling polls" + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + Label { text: "Open a poll (paste the shared link's id)" } + + RowLayout { + Layout.fillWidth: true + + TextField { + id: pollIdField + Layout.fillWidth: true + placeholderText: "poll id" + onTextChanged: landing.joinPollId = text + } + + Button { + text: "Open" + enabled: root.pollBridge !== null && landing.joinPollId.trim() !== "" + onClicked: stack.push(votePage, { pollId: landing.joinPollId.trim() }) + } + } + } + + // The one affordance that reaches CreatePollView — absent + // (not merely disabled) when nativeClient is false, so a WASM + // build that sets it never even renders a path there. See + // root.nativeClient's own doc comment. + Button { + Layout.fillWidth: true + visible: root.nativeClient + text: "Create a new poll (organizer)" + enabled: root.pollBridge !== null + onClicked: stack.push(createPage) + } + } + } + } + + Component { + id: createPage + + CreatePollView { + pollBridge: root.pollBridge + onOpenRequested: function (pollId) { + stack.push(votePage, { pollId: pollId }) + } + } + } + + Component { + id: votePage + + VoteView { + pollBridge: root.pollBridge + schemas: root.schemas + onBackRequested: { + if (root.pollBridge) + root.pollBridge.stopPolling() + stack.pop() + } + } + } +} diff --git a/examples/polls/gui/qml/VoteView.qml b/examples/polls/gui/qml/VoteView.qml new file mode 100644 index 00000000..15c9f7b6 --- /dev/null +++ b/examples/polls/gui/qml/VoteView.qml @@ -0,0 +1,354 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// The vote view: OpenPoll (on load) + SubmitVotes/UpdateVotes (hand-rolled — +// OneVote's `votes` array of objects hits the same DynamicForm array-of- +// strings-only gap CreatePoll::options does) + AddComment/FinalizePoll/ +// UndoLastVoteChange (genuinely schema-driven, via DynamicForm) + the live, +// event-driven results display +// wired to Task 15's EventPoller (through PollBridge — see +// poll_qml_bridges.hpp's own doc comment for the wiring). +// +// `pollBridge`/`schemas` default to null/{} so this same file also loads +// standalone with nothing wired up, which is exactly what the offscreen +// engine-load smoke test (tests/test_gui_qml_smoke.cpp) does. + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import MorphForms + +Item { + id: page + + property var pollBridge: null + property var schemas: ({}) + property string pollId: "" + + signal backRequested() + + property var state: null // GetPollStateResult, as PollBridge's toVariantMap renders it + property string participantName: "" + property bool hasVoted: false + property var activityLog: [] // [{id, kind, summary}], newest last + + property string status: "" + property bool statusIsError: false + + function report(message, isError) { + page.status = message + page.statusIsError = isError + } + + // One entry per currently-known option: {optionId, choice}. Rebuilt + // whenever `state.options` changes so a newly-opened poll (or a + // resync after a live event) always has a picker row per option, and a + // prior selection survives a resync that didn't change the option list. + property var picks: ({}) + + function pickFor(optionId) { + return page.picks[optionId] || "No" + } + + function setPick(optionId, choice) { + const next = Object.assign({}, page.picks) + next[optionId] = choice + page.picks = next + } + + function votesPayload() { + const out = [] + if (!page.state) + return out + for (let i = 0; i < page.state.options.length; ++i) { + const optionId = page.state.options[i].id + out.push({ optionId: optionId, choice: page.pickFor(optionId) }) + } + return out + } + + Component.onCompleted: { + if (page.pollBridge && page.pollId !== "") + page.pollBridge.openPoll(page.pollId) + } + + Connections { + target: page.pollBridge + + function onOpened(newState) { + page.state = newState + page.hasVoted = false + page.activityLog = [] + page.report("", false) + } + + function onStateChanged(newState) { + page.state = newState + } + + function onEventReceived(event) { + // Newest last, capped so a long-lived open poll does not grow + // this list without bound — the live tallies (state.options) + // are the source of truth; this is a human-readable log only. + const next = page.activityLog.concat([event]) + page.activityLog = next.length > 200 ? next.slice(next.length - 200) : next + } + + function onReplyReceived(actionType, ok, payload) { + if (!ok) { + page.report(actionType + ": " + payload, true) + return + } + page.report(actionType + " ok", false) + if (actionType === "AddComment") + commentForm.resetFields() + else if (actionType === "FinalizePoll") + finalizeForm.resetFields() + else if (actionType === "UndoLastVoteChange") + undoForm.resetFields() + page.pollBridge.refresh() + } + + function onPollingStopped(message) { + page.report("live updates stopped: " + message, true) + } + + function onFailed(message) { + page.report(message, true) + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 8 + + RowLayout { + Layout.fillWidth: true + Button { + text: "< Back" + onClicked: page.backRequested() + } + Label { + Layout.fillWidth: true + font.bold: true + elide: Text.ElideRight + text: page.state ? (page.state.title + (page.state.finalized ? " (finalized)" : "")) : "opening…" + } + } + + Label { + Layout.fillWidth: true + visible: page.status !== "" + wrapMode: Text.Wrap + color: page.statusIsError ? "#d33" : palette.text + text: page.status + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 8 + + // ── Pane 1: results + the vote picker ────────────────────────── + ColumnLayout { + Layout.preferredWidth: 380 + Layout.fillHeight: true + spacing: 6 + + Label { text: "Your name" } + TextField { + Layout.fillWidth: true + placeholderText: "participant name" + onTextChanged: page.participantName = text + } + + Label { font.bold: true; text: "Options" } + + ListView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: page.state ? page.state.options : [] + + delegate: ColumnLayout { + id: optionRow + required property var modelData + width: ListView.view ? ListView.view.width : 0 + spacing: 2 + + Label { + font.bold: true + text: optionRow.modelData.label + " — yes: " + optionRow.modelData.yesCount + + " if-need-be: " + optionRow.modelData.ifNeedBeCount + + " no: " + optionRow.modelData.noCount + + " (#" + optionRow.modelData.id + ")" + } + + RowLayout { + ButtonGroup { id: choiceGroup } + + RadioButton { + text: "Yes" + enabled: page.state && !page.state.finalized + ButtonGroup.group: choiceGroup + checked: page.pickFor(optionRow.modelData.id) === "Yes" + // `toggled` fires on both the newly-checked button + // (checked -> true) and, since these three share one + // exclusive ButtonGroup, on whichever button the + // selection just left (checked -> false) -- only the + // former should write a pick, or the unchecked + // sibling's own unconditional setPick can overwrite + // this click's selection right back to itself, + // depending on which button's `toggled` QML fires + // second. + onToggled: if (checked) page.setPick(optionRow.modelData.id, "Yes") + } + RadioButton { + text: "If need be" + enabled: page.state && !page.state.finalized + ButtonGroup.group: choiceGroup + checked: page.pickFor(optionRow.modelData.id) === "IfNeedBe" + onToggled: if (checked) page.setPick(optionRow.modelData.id, "IfNeedBe") + } + RadioButton { + text: "No" + enabled: page.state && !page.state.finalized + ButtonGroup.group: choiceGroup + checked: page.pickFor(optionRow.modelData.id) === "No" + onToggled: if (checked) page.setPick(optionRow.modelData.id, "No") + } + } + } + } + + Button { + Layout.fillWidth: true + text: page.hasVoted ? "Update my votes" : "Submit my votes" + enabled: page.pollBridge !== null && page.state !== null && !page.state.finalized + && page.participantName.trim() !== "" + onClicked: { + if (page.hasVoted) + page.pollBridge.updateVotes(page.participantName, page.votesPayload()) + else + page.pollBridge.submitVotes(page.participantName, page.votesPayload()) + page.hasVoted = true + } + } + + DynamicForm { + id: undoForm + Layout.fillWidth: true + actionType: "UndoLastVoteChange" + schema: page.schemas["UndoLastVoteChange"] || ({}) + controller: null + } + Button { + Layout.fillWidth: true + text: "Undo my last vote change" + enabled: page.pollBridge !== null && undoForm.ready + onClicked: page.pollBridge.submitIfValid("UndoLastVoteChange", undoForm.previewLine) + } + } + + // ── Pane 2: comments + finalize (admin) ──────────────────────── + ColumnLayout { + Layout.preferredWidth: 320 + Layout.fillHeight: true + spacing: 6 + + Label { font.bold: true; text: "Comments (" + (page.state ? page.state.comments.length : 0) + ")" } + + ListView { + Layout.fillWidth: true + Layout.preferredHeight: 160 + clip: true + model: page.state ? page.state.comments : [] + + delegate: Label { + required property var modelData + width: ListView.view ? ListView.view.width : 0 + wrapMode: Text.Wrap + text: modelData.participantName + ": " + modelData.body + } + } + + DynamicForm { + id: commentForm + Layout.fillWidth: true + actionType: "AddComment" + schema: page.schemas["AddComment"] || ({}) + controller: null + } + Button { + Layout.fillWidth: true + text: "Add comment" + enabled: page.pollBridge !== null && commentForm.ready + onClicked: page.pollBridge.submitIfValid("AddComment", commentForm.previewLine) + } + + Label { + Layout.topMargin: 12 + font.bold: true + text: "Admin" + } + + RowLayout { + Layout.fillWidth: true + TextField { + id: adminTokenField + Layout.fillWidth: true + placeholderText: "admin token" + echoMode: TextInput.Password + } + Button { + text: "use" + enabled: page.pollBridge !== null && adminTokenField.text !== "" + onClicked: page.pollBridge.setAdminToken(adminTokenField.text) + } + } + + DynamicForm { + id: finalizeForm + Layout.fillWidth: true + actionType: "FinalizePoll" + schema: page.schemas["FinalizePoll"] || ({}) + controller: null + } + Button { + Layout.fillWidth: true + text: "Finalize poll" + enabled: page.pollBridge !== null && page.state !== null && !page.state.finalized + && finalizeForm.ready + onClicked: page.pollBridge.submitIfValid("FinalizePoll", finalizeForm.previewLine) + } + } + + // ── Pane 3: live activity log (the Zulip-pattern demo) ───────── + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 6 + + Label { font.bold: true; text: "Live activity" } + + ListView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + verticalLayoutDirection: ListView.BottomToTop + model: page.activityLog + + delegate: Label { + required property var modelData + width: ListView.view ? ListView.view.width : 0 + elide: Text.ElideRight + opacity: 0.8 + text: "#" + modelData.id + " [" + modelData.kind + "] " + modelData.summary + } + } + } + } + } +} diff --git a/examples/polls/gui_lib/poll_forms_controller.cpp b/examples/polls/gui_lib/poll_forms_controller.cpp new file mode 100644 index 00000000..1a76ce52 --- /dev/null +++ b/examples/polls/gui_lib/poll_forms_controller.cpp @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "poll_forms_controller.hpp" + +#include + +namespace polls::gui { + +PollFormsController::PollFormsController(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor* executor, + std::string schemasJson) + : _handler{bridge, executor}, _schemasJson{std::move(schemasJson)} {} + +::morph::async::Completion PollFormsController::openPoll(std::string pollId) { + return _handler.execute(OpenPoll{.pollId = std::move(pollId)}); +} + +::morph::async::Completion PollFormsController::getPollState() { + return _handler.execute(GetPollState{}); +} + +::morph::async::Completion PollFormsController::submitVotes(SubmitVotes action) { + return _handler.execute(std::move(action)); +} + +::morph::async::Completion PollFormsController::updateVotes(UpdateVotes action) { + return _handler.execute(std::move(action)); +} + +::morph::async::Completion PollFormsController::getEventsSince(GetEventsSince action) { + return _handler.execute(std::move(action)); +} + +} // namespace polls::gui diff --git a/examples/polls/gui_lib/poll_forms_controller.hpp b/examples/polls/gui_lib/poll_forms_controller.hpp new file mode 100644 index 00000000..350107e1 --- /dev/null +++ b/examples/polls/gui_lib/poll_forms_controller.hpp @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include "polls/models/poll_model.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace polls::gui { + +/// @brief Owns the *one* `BridgeHandler` a vote-view +/// screen dispatches every already-open-poll action through, and +/// exposes both the schema-driven `submitIfValid` surface +/// `bookmarks::gui::BookmarkFormsController` established and the +/// typed convenience methods that surface cannot cover. +/// +/// @par Why this is not a verbatim copy of `BookmarkFormsController` +/// `BookmarkFormsController` owns one `BridgeHandler` *per model* (three, for +/// three models) precisely because `BookmarkModel`/`TagModel`/`AuthModel` are +/// all plain (`NoSharing`) — each handler registers its own private instance +/// eagerly at construction, so which handler object serves a given call +/// never matters. `PollModel` is different: it is `AllowShared` and keyed by +/// `pollId` (`poll_model.hpp`'s own doc comment; this rung's shared-instance +/// showcase). An `AllowShared` handler starts **unattached** and only joins +/// the poll's shared instance the first time a payload-keyed action +/// (`OpenPoll`) dispatches through *that specific handler object* — every +/// other action on the same poll must reuse that exact handler, or it hits +/// "handler not bound" (no instance to run against). A second, independently +/// constructed `BridgeHandler` — as +/// `BookmarkFormsController`'s per-model shape would produce if copied +/// verbatim — would need its *own* `OpenPoll` attach before anything routed +/// through it could work, doubling the shared instance's live attachment +/// count for no benefit and, worse, silently failing every call issued +/// before that second attach completed. So this class owns exactly one +/// `_handler`, and every method below — schema-driven or typed — dispatches +/// through it. +/// +/// @par Why `openPoll`/`submitVotes`/`updateVotes`/`getEventsSince` are not schema-driven +/// - `openPoll`: `OpenPoll` is this rung's one payload-keyed action. +/// `openPoll()` below calls the templated `execute()` directly +/// rather than going through the generic `submitIfValid`/`executeJson` +/// path other actions use — a workaround for a gap found while building +/// this class: `executeJson` used to silently skip the payload-keyed +/// attach step entirely on an `AllowShared` handler. +/// `ActionExecuteRegistry::registerAction` now builds one executor per +/// `Sharing` policy and `executeJson` dispatches through the handler's own +/// real `Sharing` parameter, so this specific gap is closed — but this +/// class was never migrated to route `OpenPoll` through the generic path, +/// and `submitIfValid` below still refuses `OpenPoll` unconditionally +/// (see its own doc comment's allow-list) rather than relying on the +/// now-fixed `executeJson`. +/// - `submitVotes`/`updateVotes`: `SubmitVotes::votes`/`UpdateVotes::votes` +/// are `std::vector` — a JSON array of *objects*, not the +/// array-of-strings `DynamicForm`'s array-field control supports. +/// `gui/qml/VoteView.qml` drives these from a +/// hand-rolled picker; the two methods below give that picker's C++-side +/// adapter (`PollBridge`) a `Completion`-returning call to attach its own +/// `.then()`/`.onError()` to, on the same attached `_handler`. +/// - `getEventsSince`: exists **only** for `morph::ladder::gui::EventPoller`'s +/// `Dispatch` closure (see that class's own doc comment's "production-safe +/// wiring" section) — never called directly by QML. It deliberately +/// returns a fresh `Completion` per call rather than +/// routing through any shared signal, so concurrent ticks/actions on this +/// same `_handler` can never cross-attribute a failure (each `execute()` +/// call gets its own independent `CompletionState`; nothing here is +/// multiplexed the way a `Presenter`'s signals are). +/// +/// @par `PollPresenter` is intentionally not reused here +/// `PollPresenter` (`poll_presenter.hpp`) already threads one shared +/// `_handler` correctly across `openPoll`/`submitVotes`/.../`getEventsSince` +/// — but only via `void` methods that report exclusively through Qt +/// signals, one of which (`failed(QString)`) is shared by all nine actions. +/// Building a generic per-call `submitIfValid(actionType, body, onReply, +/// onError)` on top of that would mean temporarily connecting `onReply`/ +/// `onError` to those shared signals per call, reproducing exactly the +/// cross-attribution hazard `EventPoller`'s own doc comment warns against +/// for the identical reason. This class instead owns its own handler and +/// gets a genuine per-call `Completion` for every dispatch, `PollPresenter` +/// included nowhere in its implementation. `PollPresenter` remains the right +/// tool for `PollBridge::createPoll` (a `NoSharing` handler, no attachment +/// story to preserve), which is the one thing this class does not cover. +class PollFormsController { + public: + /// @param bridge The shared `Bridge` `AppContext` owns. + /// @param executor The executor `Completion` callbacks land on. + /// @param schemasJson Pre-assembled `{actionType: schemaJson()}` map + /// — `poll_schemas.hpp`'s `pollSchemasJson()` builds the one every + /// shell passes. + PollFormsController(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor* executor, std::string schemasJson); + + /// @brief The `{actionType: schema}` JSON supplied at construction. + /// @return A reference to the cached schema-set JSON. + [[nodiscard]] const std::string& schemasJson() const noexcept { return _schemasJson; } + + /// @brief Dispatches @p bodyJson as @p actionType's body via + /// `BridgeHandler::executeJson`, invoking @p onReply / @p onError + /// on the GUI thread once the reply arrives. + /// + /// @p actionType must be one of `kSchemaActions` below (`AddComment`, + /// `FinalizePoll`, `UndoLastVoteChange`) — every other `PollModel` action + /// is still registered on `_handler` (every action shares one model's + /// handler here) but is deliberately refused by this method rather than + /// dispatched: `OpenPoll` still goes through `openPoll()`'s own + /// `execute()` call instead of this generic path (see this + /// class's own doc comment for why that split still exists even though + /// `executeJson` itself no longer mis-dispatches a payload-keyed action). + /// + /// @tparam OnReply Callable invoked with the result JSON (`std::string`) on success. + /// @tparam OnError Callable invoked with the `std::exception_ptr` on failure. + /// @param actionType One of `kSchemaActions`. + /// @param bodyJson Fully-assembled JSON body for the action. + /// @param onReply Success callback. + /// @param onError Failure callback. + template + void submitIfValid(std::string actionType, std::string bodyJson, OnReply onReply, OnError onError) { + if (std::ranges::find(kSchemaActions, actionType) == kSchemaActions.end()) { + onError(std::make_exception_ptr(std::runtime_error{ + "PollFormsController::submitIfValid: '" + actionType + + "' is not a schema-driven action (see poll_schemas.hpp / this class's own doc comment)"})); + return; + } + _handler.executeJson(actionType, bodyJson) + .then([onReply = std::move(onReply)](std::string resultJson) mutable { onReply(std::move(resultJson)); }) + .onError([onError = std::move(onError)](const std::exception_ptr& err) mutable { onError(err); }); + } + + /// @brief Attaches `_handler` to the poll named by @p pollId and returns + /// its full current state. See this class's own doc comment for + /// why this bypasses `submitIfValid` entirely. + /// @param pollId The poll's shareable link id. + /// @return Completion resolving with the poll's full current state. + [[nodiscard]] ::morph::async::Completion openPoll(std::string pollId); + + /// @brief Returns the current state of the poll `_handler` is attached + /// to. A plain refresh — `GetPollState` carries no fields a + /// person types, so it is not part of the schema document. + /// @return Completion resolving with the poll's full current state. + [[nodiscard]] ::morph::async::Completion getPollState(); + + /// @brief First-time vote submission. See this class's own doc comment + /// for why `SubmitVotes` is not schema-driven. + /// @param action The participant's display name and full vote set. + /// @return Completion resolving with the freshly-rebuilt poll state. + [[nodiscard]] ::morph::async::Completion submitVotes(SubmitVotes action); + + /// @brief Replaces a participant's votes wholesale. See this class's own + /// doc comment for why `UpdateVotes` is not schema-driven. + /// @param action The participant's display name and full new vote set. + /// @return Completion resolving with the freshly-rebuilt poll state. + [[nodiscard]] ::morph::async::Completion updateVotes(UpdateVotes action); + + /// @brief Lists every event recorded on the attached poll strictly after + /// @p action.lastEventId. Exists only for + /// `morph::ladder::gui::EventPoller`'s `Dispatch` closure — see + /// this class's own doc comment. + /// @param action Carries `lastEventId`, the poller's current cursor. + /// @return Completion resolving with the events, oldest first. + [[nodiscard]] ::morph::async::Completion getEventsSince(GetEventsSince action); + + /// @brief The three action-type ids `submitIfValid` accepts, matching + /// `poll_schemas.hpp`'s document exactly. + static constexpr std::array kSchemaActions{"AddComment", "FinalizePoll", + "UndoLastVoteChange"}; + + private: + ::morph::bridge::BridgeHandler _handler; + std::string _schemasJson; +}; + +} // namespace polls::gui diff --git a/examples/polls/gui_lib/poll_presenter.cpp b/examples/polls/gui_lib/poll_presenter.cpp new file mode 100644 index 00000000..738804d9 --- /dev/null +++ b/examples/polls/gui_lib/poll_presenter.cpp @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "poll_presenter.hpp" + +namespace polls::gui { + +PollPresenter::PollPresenter(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor* executor, QObject* parent) + : Presenter{parent}, _creator{bridge, executor}, _handler{bridge, executor} {} + +void PollPresenter::reportError(const std::exception_ptr& err) { + try { + std::rethrow_exception(err); + } catch (const std::exception& ex) { + emit failed(QString::fromStdString(ex.what())); + } +} + +void PollPresenter::createPoll(CreatePoll action) { + track( + _creator.execute(std::move(action)), [this](CreatePollResult result) { emit created(std::move(result)); }, + [this](const std::exception_ptr& err) { reportError(err); }); +} + +void PollPresenter::openPoll(std::string pollId) { + track( + _handler.execute(OpenPoll{.pollId = std::move(pollId)}), + [this](GetPollStateResult result) { emit opened(std::move(result)); }, + [this](const std::exception_ptr& err) { reportError(err); }); +} + +void PollPresenter::getPollState(GetPollState action) { + track( + _handler.execute(std::move(action)), + [this](GetPollStateResult result) { emit stateLoaded(std::move(result)); }, + [this](const std::exception_ptr& err) { reportError(err); }); +} + +void PollPresenter::submitVotes(SubmitVotes action) { + track( + _handler.execute(std::move(action)), + [this](GetPollStateResult result) { emit votesSubmitted(std::move(result)); }, + [this](const std::exception_ptr& err) { reportError(err); }); +} + +void PollPresenter::updateVotes(UpdateVotes action) { + track( + _handler.execute(std::move(action)), + [this](GetPollStateResult result) { emit votesUpdated(std::move(result)); }, + [this](const std::exception_ptr& err) { reportError(err); }); +} + +void PollPresenter::addComment(AddComment action) { + track( + _handler.execute(std::move(action)), + [this](GetPollStateResult result) { emit commentAdded(std::move(result)); }, + [this](const std::exception_ptr& err) { reportError(err); }); +} + +void PollPresenter::finalizePoll(FinalizePoll action) { + track( + _handler.execute(std::move(action)), + [this](GetPollStateResult result) { emit finalized(std::move(result)); }, + [this](const std::exception_ptr& err) { reportError(err); }); +} + +void PollPresenter::undoLastVoteChange(UndoLastVoteChange action) { + track( + _handler.execute(std::move(action)), + [this](UndoLastVoteChangeResult result) { emit voteChangeUndone(std::move(result)); }, + [this](const std::exception_ptr& err) { reportError(err); }); +} + +void PollPresenter::getEventsSince(GetEventsSince action) { + track( + _handler.execute(std::move(action)), + [this](GetEventsSinceResult result) { emit eventsReceived(std::move(result)); }, + [this](const std::exception_ptr& err) { reportError(err); }); +} + +} // namespace polls::gui diff --git a/examples/polls/gui_lib/poll_presenter.hpp b/examples/polls/gui_lib/poll_presenter.hpp new file mode 100644 index 00000000..944cf58e --- /dev/null +++ b/examples/polls/gui_lib/poll_presenter.hpp @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include "gui/presenter.hpp" +#include "polls/dto/event_dto.hpp" +#include "polls/dto/poll_dto.hpp" +#include "polls/dto/vote_dto.hpp" + +#include +#include + +// See pastebin::gui::PastePresenter's identical guard and doc comment +// (examples/pastebin/gui_lib/paste_presenter.hpp) for why moc must never see +// morph/core/bridge.hpp: its template machinery produces bogus moc output +// the same way poll_model.hpp historically did when it transitively pulled +// in Lightweight's DataMapper machinery through the since-removed +// polls/db/db_model.hpp -- poll_model.hpp itself no longer has any +// Lightweight/ODBC dependency at all, now that PollModel acquires a +// connection per execute() call from Lightweight::GlobalDataMapperPool() +// instead of owning one, but this guard stays for bridge.hpp's own sake. +#ifndef Q_MOC_RUN +#include "polls/models/poll_model.hpp" + +#include +#include +#endif + +namespace polls::gui { + +/// @brief Routes every `PollModel` action through two `BridgeHandler`s. +/// Translates and routes only — no domain logic +/// (`IMPLEMENTATION.md` rule 2). +/// +/// Two handlers, not one — this is the one real subtlety this presenter has +/// to get right, and getting it wrong fails every action at runtime with +/// "handler not bound" (confirmed empirically before this file settled on +/// the shape below): +/// +/// - `_creator`, a plain (`NoSharing`) `BridgeHandler`, used +/// only by `createPoll`. `CreatePoll` carries no key of its own — it is +/// not `OpenPoll`, this rung's one `BRIDGE_MODEL_KEY`-registered action +/// (`poll_model.hpp`) — so dispatching it lands in +/// `BridgeHandler::execute`'s final, un-keyed `else` branch +/// (`morph/core/bridge.hpp`), which requires `_binding` to already be +/// bound to *some* instance. A plain handler satisfies that by +/// registering its own private instance eagerly at construction; an +/// `AllowShared` handler deliberately does not (`AllowShared`'s own doc +/// comment: "A shared handler that only ever runs *keyless* actions +/// never attaches, and its `execute` fails fast with 'handler not +/// bound'"). Mirrors `test_app.cpp`'s/`test_shared_instance_lifecycle.cpp`'s +/// own two-handler precedent (their `creator`, a plain `BridgeHandler`, +/// used identically). +/// - `_handler`, a `BridgeHandler`, used by every +/// other action. `PollModel` is keyed by `pollId` +/// (`BRIDGE_MODEL_KEY(PollModel, OpenPoll, &OpenPoll::pollId)`, +/// `poll_model.hpp`) — this rung's shared-instance showcase — so this +/// handler must join the shared instance directory the same way +/// `test_app.cpp`'s/`test_shared_instance_lifecycle.cpp`'s own `viewer`/ +/// `handler` do, or `openPoll`'s keyed attach below fails to bind to (or +/// create) the poll's shared instance at all. +class PollPresenter : public ::morph::ladder::gui::Presenter { + Q_OBJECT + public: + /// @param bridge The shared `Bridge` `AppContext` owns. + /// @param executor The executor `Completion` callbacks land on. + /// @param parent Optional `QObject` parent. + PollPresenter(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor* executor, QObject* parent = nullptr); + + /// @brief Creates a new poll. Emits `created` on success, `failed` on error. + /// @param action The poll's title and candidate options. + void createPoll(CreatePoll action); + + /// @brief Convenience wrapper around the keyed attach action — + /// dispatches `OpenPoll{.pollId = pollId}` (`handler_.execute`'s + /// payload-keyed attach) rather than requiring the caller to + /// build the DTO itself, since `pollId` is `OpenPoll`'s only + /// field. Attaches this handler to the named poll and returns its + /// full current state. Emits `opened` on success, `failed` on + /// error. + /// + /// Task 15's polling helper drives its first `GetEventsSince` + /// call off this method's `opened` signal (`.lastEventId` in the + /// returned `GetPollStateResult` is exactly the starting cursor + /// `getEventsSince()` below needs) — that timer wiring is Task + /// 15's own job; this method only exposes the primitive. + /// @param pollId The poll's shareable link id. + void openPoll(std::string pollId); + + /// @brief Returns the current state of the poll this handler was last + /// attached to via `openPoll`. Emits `stateLoaded` on success, + /// `failed` on error. + /// @param action Carries no fields of its own. + void getPollState(GetPollState action); + + /// @brief First-time vote submission for a participant against this + /// handler's attached poll. Emits `votesSubmitted` on success, + /// `failed` on error. + /// @param action The participant's display name and full vote set. + void submitVotes(SubmitVotes action); + + /// @brief Replaces a participant's votes wholesale against this + /// handler's attached poll. Emits `votesUpdated` on success, + /// `failed` on error. + /// @param action The participant's display name and full new vote set. + void updateVotes(UpdateVotes action); + + /// @brief Adds one comment to this handler's attached poll. Emits + /// `commentAdded` on success, `failed` on error. + /// @param action The participant's display name and comment body. + void addComment(AddComment action); + + /// @brief Admin-token-gated: marks this handler's attached poll + /// finalized. Emits `finalized` on success, `failed` on error. + /// @param action The winning option's id. + void finalizePoll(FinalizePoll action); + + /// @brief Reverses a participant's own most recent vote change against + /// this handler's attached poll. Emits `voteChangeUndone` on + /// success, `failed` on error. + /// @param action The participant whose own most recent vote change is undone. + void undoLastVoteChange(UndoLastVoteChange action); + + /// @brief Lists every event recorded for this handler's attached poll + /// strictly after `action.lastEventId`. Emits `eventsReceived` on + /// success, `failed` on error. + /// + /// This method exposes the primitive Task 15's polling helper + /// drives on a timer — this task builds only the primitive, not + /// the timer/polling loop itself (see this rung's task brief). + /// @param action Carries `lastEventId`, the caller's cursor. + void getEventsSince(GetEventsSince action); + + signals: + void created(CreatePollResult result); + void opened(GetPollStateResult result); + void stateLoaded(GetPollStateResult result); + void votesSubmitted(GetPollStateResult result); + void votesUpdated(GetPollStateResult result); + void commentAdded(GetPollStateResult result); + void finalized(GetPollStateResult result); + void voteChangeUndone(UndoLastVoteChangeResult result); + void eventsReceived(GetEventsSinceResult result); + /// @brief Emitted for any action's typed error — @p message is + /// `std::exception::what()`, ready for direct display. + void failed(QString message); + + private: + /// @brief Shared error-display body passed as every `track()` call's + /// third argument below — see `Presenter::track()`'s doc comment + /// (`examples/common/gui/presenter.hpp`) for why it is passed as + /// `track()`'s `onErr` parameter rather than attached via a + /// separate `.onError()` call beforehand. + void reportError(const std::exception_ptr& err); + + ::morph::bridge::BridgeHandler _creator; + ::morph::bridge::BridgeHandler _handler; +}; + +} // namespace polls::gui diff --git a/examples/polls/gui_lib/poll_qml_bridges.cpp b/examples/polls/gui_lib/poll_qml_bridges.cpp new file mode 100644 index 00000000..0d2db548 --- /dev/null +++ b/examples/polls/gui_lib/poll_qml_bridges.cpp @@ -0,0 +1,354 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "poll_qml_bridges.hpp" + +#include "poll_schemas.hpp" + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace polls::gui { + +namespace { + +/// @brief An `OptionId` as the plain number QML rows carry, or `-1` when +/// unengaged (Lightweight's `ServerSideAutoIncrement` starts at 1, so +/// `-1` is never a real id). Same convention as +/// `bookmarks::gui::idNumber`. +[[nodiscard]] qlonglong idNumber(const OptionId& id) { return id.hasValue() ? static_cast(*id) : -1; } + +/// @brief A `PollEventId` as the plain number a cursor/event row carries. +[[nodiscard]] qlonglong idNumber(const PollEventId& id) { return id.hasValue() ? static_cast(*id) : -1; } + +/// @brief A `Count` rendered via `morph::units::toString` — an integer text, +/// since `polls::Count` is always a whole number (`units.hpp`). +/// +/// `morph::units::toString`, not `std::format("{}", count)`: see +/// `pastebin::gui::readsText`'s identical note (`paste_qml_bridges.cpp`) — +/// Emscripten's bundled libc++ fails to compile the `std::format` call for +/// this `Quantity`-family type outright. +[[nodiscard]] QString countText(const Count& count) { + return QString::fromStdString(morph::units::toString(count)); +} + +[[nodiscard]] QString choiceText(VoteChoice choice) { + switch (choice) { + case VoteChoice::Yes: + return QStringLiteral("Yes"); + case VoteChoice::IfNeedBe: + return QStringLiteral("IfNeedBe"); + case VoteChoice::No: + return QStringLiteral("No"); + default: + return QStringLiteral("No"); + } +} + +/// @brief Parses one of `VoteView.qml`'s picker strings back into a +/// `VoteChoice`. Anything not `"Yes"`/`"IfNeedBe"` is `No` — the same +/// fail-safe default a missing/garbled radio selection should have, +/// never silently dropping the vote row entirely. +/// @param text One of `"Yes"`/`"IfNeedBe"`/`"No"`. +/// @return The matching `VoteChoice`. +[[nodiscard]] VoteChoice parseChoice(const QString& text) { + if (text == QStringLiteral("Yes")) { + return VoteChoice::Yes; + } + if (text == QStringLiteral("IfNeedBe")) { + return VoteChoice::IfNeedBe; + } + return VoteChoice::No; +} + +/// @brief `votes` (as `submitVotes`/`updateVotes` receive it from QML) into +/// the typed `OneVote` vector both `SubmitVotes`/`UpdateVotes` need. +/// @param votes `{optionId, choice}` maps. +/// @return The decoded vote set, in the same order. +[[nodiscard]] std::vector decodeVotes(const QVariantList& votes) { + std::vector out; + out.reserve(static_cast(votes.size())); + for (const QVariant& entry : votes) { + const QVariantMap row = entry.toMap(); + out.push_back(OneVote{.optionId = OptionId{.value = row.value(QStringLiteral("optionId")).toLongLong()}, + .choice = parseChoice(row.value(QStringLiteral("choice")).toString())}); + } + return out; +} + +[[nodiscard]] QVariantMap toVariantMap(const PollOptionView& option) { + return QVariantMap{ + {"id", idNumber(option.id)}, + {"label", QString::fromStdString(option.label)}, + {"yesCount", countText(option.yesCount)}, + {"ifNeedBeCount", countText(option.ifNeedBeCount)}, + {"noCount", countText(option.noCount)}, + }; +} + +[[nodiscard]] QVariantMap toVariantMap(const ParticipantVoteView& vote) { + return QVariantMap{ + {"participantName", QString::fromStdString(vote.participantName)}, + {"optionId", idNumber(vote.optionId)}, + {"choice", choiceText(vote.choice)}, + }; +} + +[[nodiscard]] QVariantMap toVariantMap(const CommentView& comment) { + return QVariantMap{ + {"participantName", QString::fromStdString(comment.participantName)}, + {"body", QString::fromStdString(comment.body)}, + }; +} + +template +[[nodiscard]] QVariantList toVariantList(const Rows& rows) { + QVariantList out; + out.reserve(static_cast(rows.size())); + for (const auto& row : rows) { + out.append(toVariantMap(row)); + } + return out; +} + +/// @brief An opaque token newtype (`AdminToken`/`ParticipantToken`) as the +/// plain string a QML row carries — empty when unengaged, the same +/// "empty means absent" convention every other string field in these +/// maps already uses. +template +[[nodiscard]] QString tokenText(const TokenT& token) { + return token.hasValue() ? QString::fromStdString(*token) : QString{}; +} + +[[nodiscard]] QVariantMap toVariantMap(const CreatePollResult& result) { + return QVariantMap{ + {"pollId", QString::fromStdString(result.pollId)}, + {"adminToken", tokenText(result.adminToken)}, + {"participantToken", tokenText(result.participantToken)}, + }; +} + +[[nodiscard]] QVariantMap toVariantMap(const GetPollStateResult& state) { + return QVariantMap{ + {"pollId", QString::fromStdString(state.pollId)}, + {"title", QString::fromStdString(state.title)}, + // Projected to a plain bool for QML, which has no notion of a C++ + // enum class: `Finalized` is the DTO's own two-state type, this map + // is the GUI-facing view of it. + {"finalized", state.finalized == Finalized::Yes}, + {"finalizedOptionId", idNumber(state.finalizedOptionId)}, + {"options", toVariantList(state.options)}, + {"votes", toVariantList(state.votes)}, + {"comments", toVariantList(state.comments)}, + {"lastEventId", idNumber(state.lastEventId)}, + }; +} + +[[nodiscard]] QVariantMap toVariantMap(const PollEvent& event) { + return QVariantMap{ + {"id", idNumber(event.id)}, + {"kind", QString::fromStdString(event.kind)}, + {"summary", QString::fromStdString(event.summary)}, + }; +} + +/// @brief Renders @p err's message the same way `PollPresenter::reportError` +/// does — `std::exception::what()`, or a canned message for anything +/// that is not a `std::exception`. +[[nodiscard]] QString describeFailure(const std::exception_ptr& err) { + try { + std::rethrow_exception(err); + } catch (const std::exception& ex) { + return QString::fromUtf8(ex.what()); + } catch (...) { + return QStringLiteral("unknown error"); + } +} + +} // namespace + +PollBridge::PollBridge(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor* executor, QObject* parent) + : QObject{parent}, + _presenter{bridge, executor}, + _forms{bridge, executor, pollSchemasJson()}, + _bridge{bridge}, + _executor{executor} { + connect(&_presenter, &PollPresenter::created, this, + [this](CreatePollResult result) { emit created(toVariantMap(result)); }); + connect(&_presenter, &PollPresenter::failed, this, &PollBridge::failed); + + _refreshDebounce.setSingleShot(true); + _refreshDebounce.setInterval(0); + connect(&_refreshDebounce, &QTimer::timeout, this, &PollBridge::refresh); +} + +QString PollBridge::schemasJson() const { + return QString::fromStdString(_forms.schemasJson()); +} + +void PollBridge::createPoll(const QString& title, const QVariantList& optionLabels) { + CreatePoll action; + action.title = title.toStdString(); + action.options.reserve(static_cast(optionLabels.size())); + for (const QVariant& label : optionLabels) { + action.options.push_back(CreatePollOption{.label = label.toString().toStdString()}); + } + _presenter.createPoll(std::move(action)); +} + +void PollBridge::openPoll(const QString& pollId) { + const std::string pollIdStd = pollId.toStdString(); + _forms.openPoll(pollIdStd) + .then([this, alive = std::weak_ptr{_liveness}](GetPollStateResult result) { + if (alive.expired()) { + return; + } + const PollEventId cursor = result.lastEventId; + emit opened(toVariantMap(result)); + startPolling(cursor); + }) + .onError([this, alive = std::weak_ptr{_liveness}](const std::exception_ptr& err) { + if (alive.expired()) { + return; + } + emit failed(describeFailure(err)); + }); +} + +void PollBridge::refresh() { + _forms.getPollState() + .then([this, alive = std::weak_ptr{_liveness}](GetPollStateResult result) { + if (alive.expired()) { + return; + } + emit stateChanged(toVariantMap(result)); + }) + .onError([this, alive = std::weak_ptr{_liveness}](const std::exception_ptr& err) { + if (alive.expired()) { + return; + } + emit failed(describeFailure(err)); + }); +} + +void PollBridge::submitVotes(const QString& participantName, const QVariantList& votes) { + _forms.submitVotes(SubmitVotes{.participantName = participantName.toStdString(), .votes = decodeVotes(votes)}) + .then([this, alive = std::weak_ptr{_liveness}](GetPollStateResult result) { + if (alive.expired()) { + return; + } + emit stateChanged(toVariantMap(result)); + }) + .onError([this, alive = std::weak_ptr{_liveness}](const std::exception_ptr& err) { + if (alive.expired()) { + return; + } + emit failed(describeFailure(err)); + }); +} + +void PollBridge::updateVotes(const QString& participantName, const QVariantList& votes) { + _forms.updateVotes(UpdateVotes{.participantName = participantName.toStdString(), .votes = decodeVotes(votes)}) + .then([this, alive = std::weak_ptr{_liveness}](GetPollStateResult result) { + if (alive.expired()) { + return; + } + emit stateChanged(toVariantMap(result)); + }) + .onError([this, alive = std::weak_ptr{_liveness}](const std::exception_ptr& err) { + if (alive.expired()) { + return; + } + emit failed(describeFailure(err)); + }); +} + +void PollBridge::setAdminToken(const QString& token) { + ::morph::session::Context session; + session.token = token.toStdString(); + _bridge.setDefaultSession(session); +} + +void PollBridge::submitIfValid(const QString& actionType, const QString& bodyJson) { + _forms.submitIfValid( + actionType.toStdString(), bodyJson.toStdString(), + [this, actionType, alive = std::weak_ptr{_liveness}](std::string resultJson) { + if (alive.expired()) { + return; + } + emit replyReceived(actionType, true, QString::fromStdString(resultJson)); + }, + [this, actionType, alive = std::weak_ptr{_liveness}](const std::exception_ptr& err) { + if (alive.expired()) { + return; + } + emit replyReceived(actionType, false, describeFailure(err)); + }); +} + +void PollBridge::stopPolling() { + if (_poller) { + _poller->stop(); + } +} + +void PollBridge::startPolling(PollEventId cursor) { + // Declaration-order note in poll_qml_bridges.hpp explains why `_poller` + // may safely outlive individual ticks of `_forms`'s handler but must + // itself be torn down before `_forms` is. + _poller = std::make_unique( + _bridge, cursor, + [this, alive = std::weak_ptr{_liveness}](PollEventId lastEventId, Poller::OnSuccess onSuccess, + Poller::OnError onError) { + if (alive.expired()) { + return; + } + // The production-safe Dispatch shape event_poller.hpp's own doc + // comment asks for: built directly over one call's own + // Completion, never over a Presenter's shared failed(QString) + // signal. PollFormsController::getEventsSince returns a fresh, + // independent Completion per call — see + // that method's own doc comment. onSuccess/onError are + // EventPoller's own callbacks, already guarded on its own + // _liveness token (see event_poller.hpp) — nothing further to + // add here beyond not touching `_forms` past this object's own + // lifetime, which the `alive` check above already covers. + _forms.getEventsSince(GetEventsSince{.lastEventId = lastEventId}) + .then([lastEventId, onSuccess](GetEventsSinceResult result) { + const PollEventId newLastEventId = + result.events.empty() ? lastEventId : result.events.back().id; + onSuccess(std::move(result.events), newLastEventId); + }) + .onError([onError](const std::exception_ptr& err) { onError(err); }); + }, + [this, alive = std::weak_ptr{_liveness}](const PollEvent& event) { + if (alive.expired()) { + return; + } + onEventApplied(event); + }, + [this, alive = std::weak_ptr{_liveness}](const QString& message) { + if (alive.expired()) { + return; + } + emit pollingStopped(message); + }); +} + +void PollBridge::onEventApplied(const PollEvent& event) { + emit eventReceived(toVariantMap(event)); + // Coalesces a whole tick's worth of events into one refresh() rather + // than one per event — QTimer::start() on an already-running singleShot + // timer restarts it, so a burst within the same event-loop turn still + // fires refresh() exactly once, on the next turn. + _refreshDebounce.start(); +} + +} // namespace polls::gui diff --git a/examples/polls/gui_lib/poll_qml_bridges.hpp b/examples/polls/gui_lib/poll_qml_bridges.hpp new file mode 100644 index 00000000..47c38d7a --- /dev/null +++ b/examples/polls/gui_lib/poll_qml_bridges.hpp @@ -0,0 +1,240 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include + +#include + +// Guarded exactly like bookmark_qml_bridges.hpp's own includes: AUTOMOC runs +// moc over this header, and moc must not be pointed at morph's template-heavy +// bridge.hpp or event_poller.hpp — see poll_presenter.hpp's identical guard +// and doc comment for the full rationale. +#ifndef Q_MOC_RUN +#include "gui/event_poller.hpp" +#include "poll_forms_controller.hpp" +#include "poll_presenter.hpp" + +#include +#include +#endif + +/// @file +/// `PollBridge` — the one QML-facing adapter this rung's GUI shell needs, +/// mirroring bookmarks' `FormsBridge`/`BookmarkBridge` split folded into a +/// single class: this rung has exactly one model (`PollModel`), so splitting +/// "the schema-driven forms adapter" from "the domain adapter" the way +/// bookmarks does for its three models would only add a second class with +/// nothing of its own to route between. See `poll_forms_controller.hpp`'s +/// own doc comment for why `PollBridge` wraps *both* `PollFormsController` +/// (every already-open-poll action) and `PollPresenter` (`createPoll` only, +/// which needs no attachment story) rather than either alone. + +namespace polls::gui { + +/// @brief QML-facing face of `PollFormsController`/`PollPresenter`, plus the +/// one `morph::ladder::gui::EventPoller` a +/// vote view owns while a poll is open. +/// +/// Same surface `DynamicForm.qml` expects of a controller — a `schemasJson` +/// property, `submitIfValid(actionType, bodyJson)`, and a `replyReceived` +/// signal — for `AddComment`/`FinalizePoll`/`UndoLastVoteChange`. Every other +/// action (`createPoll`, `openPoll`, `refresh`, `submitVotes`/`updateVotes`) +/// is a dedicated invokable, because none of them are schema-driven (see +/// `poll_schemas.hpp`'s own doc comment for why, action by action). +/// +/// @par Member declaration order is load-bearing +/// `_forms` must be declared **before** `_poller`. `EventPoller`'s own doc +/// comment establishes that destroying an `EventPoller` mid-tick is safe +/// (its `_liveness` token — its own last-declared member — is destroyed +/// first, so a completion callback that arrives afterward finds +/// `alive.expired() == true` and no-ops before touching anything else). That +/// guarantee only protects the `EventPoller` object itself; the *dispatch* +/// closure `startPolling()` builds below also calls back into `_forms` +/// (`PollFormsController::getEventsSince`), so `_forms`'s own +/// `BridgeHandler` must still be alive for as long as `_poller` might still +/// be mid-teardown. Members are destroyed in reverse declaration order, so +/// declaring `_forms` first — and therefore destroying it *after* `_poller` +/// — is what makes that true. Reordering the two members reintroduces a +/// use-after-free identical in shape to the one `EventPoller`'s own C1 fix +/// round closed (see this rung's `progress.md`, Task 15). +class PollBridge : public QObject { + Q_OBJECT + + /// @brief `{actionType: schema}` JSON for `AddComment`/`FinalizePoll`/ + /// `UndoLastVoteChange` — everything the QML renderer needs. + Q_PROPERTY(QString schemasJson READ schemasJson CONSTANT) + + public: + /// @param bridge The shared `Bridge` `AppContext` owns. + /// @param executor The executor `Completion` callbacks land on. + /// @param parent Optional `QObject` parent. + PollBridge(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor* executor, QObject* parent = nullptr); + + /// @brief The schema document supplied to the wrapped + /// `PollFormsController` (`poll_schemas.hpp`). + /// @return `{actionType: schema}` JSON. + [[nodiscard]] QString schemasJson() const; + + /// @brief Creates a new poll. Native-client-only (this rung's Global + /// Constraints — see `examples/polls/README.md`); nothing in this + /// method itself enforces that, `gui/qml/Main.qml`'s own + /// `nativeClient` gate does. Emits `created` on success, `failed` + /// on error. + /// @param title The poll's title. + /// @param optionLabels Candidate option labels, in order — driven by + /// `CreatePollView.qml`'s hand-written list editor, a workaround + /// for `DynamicForm`'s array-field control only handling + /// arrays of strings, not `CreatePollOption` objects; see + /// `poll_schemas.hpp`. + Q_INVOKABLE void createPoll(const QString& title, const QVariantList& optionLabels); + + /// @brief Attaches to the poll named by @p pollId and starts the + /// `EventPoller` ticking `GetEventsSince` on it. Emits `opened` on + /// success, `failed` on error. + /// @param pollId The poll's shareable link id. + Q_INVOKABLE void openPoll(const QString& pollId); + + /// @brief Re-reads the attached poll's full current state. Emits + /// `stateChanged` on success, `failed` on error. + Q_INVOKABLE void refresh(); + + /// @brief First-time vote submission. Emits `stateChanged` on success, + /// `failed` on error. + /// @param participantName The voter's display name. + /// @param votes `{optionId, choice}` maps — `choice` one of + /// `"Yes"`/`"IfNeedBe"`/`"No"`, matching `VoteView.qml`'s picker. + Q_INVOKABLE void submitVotes(const QString& participantName, const QVariantList& votes); + + /// @brief Replaces a participant's votes wholesale. Emits `stateChanged` + /// on success, `failed` on error. + /// @param participantName The voter's display name. + /// @param votes Same shape as `submitVotes`. + Q_INVOKABLE void updateVotes(const QString& participantName, const QVariantList& votes); + + /// @brief Installs @p token as the shared `Bridge`'s default session + /// token — this rung's whole admin identity (`FinalizePoll`'s + /// `requireAdmin()` compares it against the poll's stored admin + /// token; see `examples/polls/README.md`'s resolved design + /// decision 1). Every other action needs no token at all. + /// @param token The poll's admin token, as `CreatePollResult` returned it. + Q_INVOKABLE void setAdminToken(const QString& token); + + /// @brief Dispatches @p bodyJson as @p actionType's body through + /// `PollFormsController::submitIfValid` — `AddComment`, + /// `FinalizePoll` or `UndoLastVoteChange` only (see that + /// method's own doc comment). Emits `replyReceived` when the + /// reply (or the error) arrives. + /// @param actionType One of `PollFormsController::kSchemaActions`. + /// @param bodyJson Fully-assembled JSON body, as `DynamicForm` builds it. + Q_INVOKABLE void submitIfValid(const QString& actionType, const QString& bodyJson); + + /// @brief Stops the `EventPoller`'s timer without treating it as a fatal + /// error — a vote view calls this when it is hidden/closed. A + /// no-op if no poll is currently open. + Q_INVOKABLE void stopPolling(); + + signals: + /// @brief `createPoll` succeeded. @p result carries `pollId`, + /// `adminToken`, `participantToken`. + /// @param result The new poll's identifiers, as a property bag. + void created(const QVariantMap& result); + + /// @brief `openPoll` succeeded and polling has started. @p state is the + /// poll's full current state. + /// @param state The poll's state, as a property bag. + void opened(const QVariantMap& state); + + /// @brief `refresh`/`submitVotes`/`updateVotes` succeeded, or an + /// applied live event triggered a resync. @p state is the poll's + /// full current state. + /// @param state The poll's state, as a property bag. + void stateChanged(const QVariantMap& state); + + /// @brief One `PollEvent` the `EventPoller` just applied — for a live + /// activity log. Never itself a source of tally updates (`kind`/ + /// `summary` carry no vote counts); `stateChanged` follows + /// shortly after, debounced, for that. + /// @param event `{id, kind, summary}`. + void eventReceived(const QVariantMap& event); + + /// @brief One `AddComment`/`FinalizePoll`/`UndoLastVoteChange` reply. + /// @param actionType The action the reply belongs to. + /// @param ok Whether the dispatch succeeded. + /// @param payload Result JSON, or the error message. + void replyReceived(const QString& actionType, bool ok, const QString& payload); + + /// @brief The `EventPoller` stopped for good (a non-timeout failure — + /// e.g. a stale cursor after the poll's event log was pruned in + /// a way this rung never actually does, or the poll no longer + /// exists). Polling does not resume on its own; the view should + /// show this and let the user re-open the poll. + /// @param message What `EventPoller::OnFatalError` reported. + void pollingStopped(const QString& message); + + /// @brief Any of `createPoll`/`openPoll`/`refresh`/`submitVotes`/ + /// `updateVotes`'s failures, already rendered as a message. + /// @param message The model's own `what()`. + void failed(const QString& message); + + private: +#ifndef Q_MOC_RUN + using Poller = ::morph::ladder::gui::EventPoller; + + /// @brief Builds and starts `_poller` against the just-opened poll. Its + /// `Dispatch` closure reuses `_forms`'s already-attached handler + /// via `PollFormsController::getEventsSince` — see this class's + /// own doc comment for why a *second*, independently-attached + /// handler is deliberately not used here. + /// + /// Constructs `Poller` with no interval/deadline override, so the real + /// unscaled `Poller::kDefaultExecuteDeadline` is always armed — see that + /// constant's own doc comment (`event_poller.hpp`) for the CI-flakiness + /// risk this carries under a scaled `MORPH_LADDER_DEADLINE_MS` run, and + /// why it is not "fixed" here by exposing an override on this adapter. + /// @param cursor The starting cursor — `GetPollStateResult::lastEventId` + /// from the `openPoll` call that just succeeded. + void startPolling(PollEventId cursor); + + /// @brief `_poller`'s `ApplyEvent`: relays @p event as `eventReceived` + /// and schedules a debounced `refresh()`. + /// @param event One event `_poller` just applied. + void onEventApplied(const PollEvent& event); +#endif + + PollPresenter _presenter; + PollFormsController _forms; + std::unique_ptr _poller; + ::morph::bridge::Bridge& _bridge; + ::morph::exec::IExecutor* _executor; + /// @brief Debounces `stateChanged` after a burst of applied events in + /// one poll tick — see `.cpp`'s `onEventApplied`. + QTimer _refreshDebounce; + + /// @brief Weak-observable proof this object still exists. + /// + /// `PollBridge` is a `QObject`, but its `.then()`/`.onError()` completion + /// callbacks (`openPoll`, `refresh`, `submitVotes`, `updateVotes`, + /// `submitIfValid`, `startPolling`'s `Dispatch`) are plain + /// `std::function`-based `Completion` continuations, not + /// `QObject::connect`-based signal/slot connections — Qt's own + /// auto-disconnect-on-destruction machinery does not apply to them at + /// all. Every one of those callbacks captures raw `this`; destroying a + /// `PollBridge` while any of them is still in flight (an ordinary GUI + /// case — a view closing mid-request) would otherwise write into freed + /// memory. Same pattern, same reasoning, and the same **must remain the + /// last declared member** requirement as + /// `morph::ladder::gui::EventPoller::_liveness` + /// (`examples/common/gui/event_poller.hpp`) and + /// `morph::bridge::Bridge::_liveness` (`include/morph/core/bridge.hpp`): + /// members are destroyed in reverse declaration order, so the + /// last-declared member is destroyed first, and the weak_ptr each + /// callback captures observes that before anything else it might touch + /// has been torn down. + std::shared_ptr _liveness{std::make_shared()}; +}; + +} // namespace polls::gui diff --git a/examples/polls/gui_lib/poll_schemas.hpp b/examples/polls/gui_lib/poll_schemas.hpp new file mode 100644 index 00000000..4fda0720 --- /dev/null +++ b/examples/polls/gui_lib/poll_schemas.hpp @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include + +#include "polls/dto/poll_dto.hpp" +#include "polls/dto/vote_dto.hpp" + +/// @file +/// The one schema document `polls::gui::PollFormsController` renders from — +/// same split as `bookmarks::gui::bookmarkSchemasJson()` +/// (`examples/bookmarks/gui_lib/bookmark_schemas.hpp`) and for the same +/// reason: whatever composes a `PollFormsController` (the desktop client, a +/// future WASM client, the tests) builds the identical `{actionType: schema}` +/// map, never its own. +/// +/// @par Only three actions are genuinely schema-driven +/// `AddComment` and `UndoLastVoteChange` are entered as free text +/// (`participantName`/`body`, `participantName`); `FinalizePoll` is entered +/// as a number (the winning option's id, read off the results the vote view +/// already displays). All three are DTOs of scalar fields only, so +/// `DynamicForm` renders them exactly as it renders `Login`/`RenameTag` in +/// rung 2. +/// +/// Every other `PollModel` action is deliberately absent, for one of three +/// reasons: +/// +/// - `CreatePoll` — `options` is `std::vector`, a JSON +/// array of *objects*, not the array-of-strings `DynamicForm`'s +/// array-field control supports (a gap first hit during rung 2's own GUI +/// shell). Mirrors rung 2's `BulkEdit` workaround: +/// excluded here, driven by a hand-written QML list editor in +/// `gui/qml/CreatePollView.qml` instead, which calls +/// `PollBridge::createPoll(title, optionLabels)` directly rather than +/// going through this schema/`submitIfValid` path at all. +/// - `SubmitVotes`/`UpdateVotes` — same finding: `votes` is +/// `std::vector`, equally array-typed. `gui/qml/VoteView.qml` +/// drives these from a hand-rolled per-option Yes/If-need-be/No picker, +/// via `PollBridge::submitVotes`/`updateVotes`, which build the typed +/// action in C++ and dispatch it through +/// `PollFormsController::submitVotes`/`updateVotes` — the same *handler* +/// `OpenPoll`/`AddComment`/... use, just not the same *path* (see that +/// class's own doc comment for why routing must stay on one handler here). +/// - `OpenPoll`/`GetPollState`/`GetEventsSince` — `OpenPoll` is this rung's +/// one `BRIDGE_MODEL_KEY`-registered (payload-keyed) action. At the time +/// this class was built, dispatching a payload-keyed action through +/// `BridgeHandler::executeJson` on an `AllowShared` handler silently +/// skipped the attach step entirely — `ActionExecuteRegistry:: +/// registerAction`'s stored executor closed over the *plain* +/// `BridgeHandler` overload of `execute()` regardless of +/// the real handler's `Sharing` policy, so the payload-keyed attach branch +/// never ran. `registerAction` now builds one executor per `Sharing` +/// policy and `executeJson` dispatches through the handler's own real +/// policy, so this specific mis-dispatch is closed framework-side. +/// `OpenPoll` is still dispatched only via +/// `PollFormsController::openPoll(pollId)`, which calls the templated +/// `BridgeHandler::execute()` directly +/// — this rung was never migrated to route it through the now-fixed +/// generic path instead. +/// `GetPollState`/`GetEventsSince` take no user-entered fields at all (a +/// refresh and a polling tick, not something a person fills in), so both +/// are exposed as plain typed methods instead of schema forms — `Login`'s +/// own precedent notwithstanding, there is nothing here for a person to +/// type. +/// - `CreatePoll` also needs no session/token gate to render — this rung has +/// no signed-token mechanism at all (`polls::auth::PollsAuthorizer`'s own +/// `@file` comment); the admin/participant tokens it returns are opaque +/// strings the organizer copies out of `CreatePollResult` by hand. +/// +namespace polls::gui { + +/// @return `{"AddComment": …, "FinalizePoll": …, "UndoLastVoteChange": …}`. +[[nodiscard]] inline std::string pollSchemasJson() { + return std::string{"{\"AddComment\":"} + ::morph::forms::schemaJson() + + ",\"FinalizePoll\":" + ::morph::forms::schemaJson() + + ",\"UndoLastVoteChange\":" + ::morph::forms::schemaJson() + "}"; +} + +} // namespace polls::gui diff --git a/examples/polls/gui_wasm/main_wasm.cpp b/examples/polls/gui_wasm/main_wasm.cpp new file mode 100644 index 00000000..82b7dc0a --- /dev/null +++ b/examples/polls/gui_wasm/main_wasm.cpp @@ -0,0 +1,276 @@ +// SPDX-License-Identifier: Apache-2.0 + +/// @file +/// polls' WebAssembly client shell — rung 3's counterpart to +/// `examples/bookmarks/gui_wasm/main_wasm.cpp` (rung 2) and +/// `examples/pastebin/gui_wasm/main_wasm.cpp` (rung 1), mirrored from +/// bookmarks' structurally, with three genuinely new things neither prior +/// rung's WASM client needed. +/// +/// This file is the *only* difference between the browser client and a +/// desktop client. (This rung, as of this task, ships no +/// `examples/polls/gui/main.cpp` at all — no task in this plan wrote one — +/// so today this is in fact polls' *only* GUI client binary; see this file's +/// "Verification status" section below for what that implies.) Everything +/// with behaviour in it — `gui_lib/poll_presenter.hpp`, +/// `gui_lib/poll_forms_controller.hpp`, `gui_lib/poll_qml_bridges.hpp`, +/// `gui_lib/poll_schemas.hpp`, and the QML itself (`gui/qml/{Main,VoteView, +/// CreatePollView}.qml`, built into the `Polls` module) — is shared verbatim +/// with whatever desktop client a future task adds. That is +/// `examples/TESTING.md`'s "same client code" requirement, and its explicit +/// ban on bank's `gui_wasm` shadow-header pattern: no model, DTO, presenter +/// or QML file has a WASM variant here. +/// +/// @par Mode and the WASM server url +/// Always `Remote` — a browser has no ODBC and no in-process server to be +/// `Local` against (`examples/IMPLEMENTATION.md` rule 4's WASM clause). The +/// url is baked in at build time via `MORPH_LADDER_POLLS_WASM_SERVER_URL` +/// (`../CMakeLists.txt`), following pastebin's/bookmarks' own convention — a +/// page served from a static bundle has no argv to read one from. +/// +/// @par No database bootstrap, no `TokenIssuer` — same as every ladder rung's +/// WASM client, but for a slightly different reason here: this rung has +/// **no `TokenIssuer`/signed tokens at all**, native or WASM +/// (`examples/polls/README.md`'s Global Constraints, judgment call 2 — a +/// deliberate departure from rung 1/2's pattern, forced by there being no +/// framework authorizer for bare shared secrets). `CreatePoll` mints its +/// admin/participant tokens itself, inside `PollModel::execute()`; there is +/// no signing secret for this file to *not* set up, unlike pastebin's/ +/// bookmarks' own "no bootstrap" note. +/// +/// @par `nativeClient: false` — the only way `CreatePollView.qml` stays reachable-nowhere +/// `CreatePoll` is native-client-only (`examples/polls/README.md`'s Global +/// Constraints). `gui/qml/Main.qml`'s `ApplicationWindow` declares +/// `property bool nativeClient: true` for exactly this file to flip — its own +/// doc comment (written by Task 16, before this file existed) already +/// anticipates "a future gui_wasm/main_wasm.cpp is expected to pass +/// `nativeClient: false` as an initial property". Passed the same way as +/// `pollBridge` below, through `QQmlApplicationEngine::setInitialProperties` +/// (a root-object property set from C++ right after the engine is +/// constructed — the same mechanism bookmarks' own WASM client uses for its +/// controller properties, generalised here to a plain `bool`). +/// +/// Verified, not assumed, that this actually makes `CreatePollView.qml` +/// unreachable: grepping `gui/qml/*.qml` for every reference to `createPage`/ +/// `CreatePollView` turns up exactly one route to it — `Main.qml`'s landing +/// screen's "Create a new poll (organizer)" `Button`, whose `visible` is +/// `root.nativeClient` (not merely `enabled` — an invisible `Button` in Qt +/// Quick Controls receives no hit-testing at all, so this is not just a +/// dimmed affordance a determined user could still click). With +/// `nativeClient: false`, nothing in the shared QML ever calls +/// `stack.push(createPage)`; `CreatePollView.qml` itself is still linked into +/// the one shared `ladder_polls_qml` module both a future desktop client and +/// this binary would use (`examples/TESTING.md`'s "same client code" rule +/// bans a WASM-only QML variant that would omit it entirely), but a shipped +/// component that no code path ever instantiates is exactly as unreachable, +/// from a participant's perspective, as one that was never compiled in. +/// +/// @par The pollId URL parameter — the participant's way in, without `CreatePoll` +/// A WASM participant needs a way to land on a specific poll's `VoteView` +/// without going through the native-only `CreatePollView`/organizer flow. +/// `gui/qml/Main.qml`'s landing screen already offers a manual `TextField` + +/// "Open" button for pasting a poll id by hand — that alone is enough to use +/// this client at all — but a shared poll *link* (`https://.../?poll=`) +/// should skip that step. Neither `examples/common/wasm_spike/main_wasm.cpp` +/// (rung 0's WASM-remote spike) nor pastebin's/bookmarks' own WASM clients +/// establish any URL-parameter precedent — none of them takes anything from +/// the page url at all, both baking their server url in at *build* time +/// instead of reading anything at *run* time. +/// +/// Researched two ways to read the browser url from a Qt-for-WebAssembly +/// binary before picking one: +/// - **Qt's documented-in-forums-only "URL query becomes argv" behaviour** +/// (`?arg1&arg2` turning into extra `QGuiApplication::arguments()` +/// entries) turns out to require either the `--emrun` Emscripten link +/// flag (this project's WASM targets do not pass it — `emrun` is a local +/// dev-server convenience, not something a static-bundle deploy uses) or +/// hand-patching the generated `qtloader.js`'s `Module.arguments` after +/// the fact, outside this repository's CMake entirely. Both are +/// build-configuration-shaped, not something `main_wasm.cpp` itself can +/// rely on, and neither is present in `doc.qt.io/qt-6/wasm.html`'s +/// current text — it looks like older/unofficial `qtloader.js` behaviour +/// that this project's build does not opt into. +/// - **Reading `window.location.search` directly**, via a small Emscripten +/// `EM_JS` shim, needs no such flags: `EM_JS`/`EM_ASM` code is inlined +/// directly into the generated JS module and always has access to the +/// runtime's internal helpers (`UTF8ToString`, `stringToUTF8`, +/// `lengthBytesUTF8`, `_malloc`) regardless of `EXPORTED_RUNTIME_METHODS` +/// — unlike calling into `Module.*` from *external* JS, which those +/// exports actually gate. This also avoids requiring Embind's `--bind` +/// (`emscripten::val` would need it; this target's CMake does not pass +/// it), so `pollsWasmQueryPollId()` below is the chosen mechanism — +/// established here as this repository's first precedent for reading the +/// browser url from a WASM QML client, for a future rung to reuse or +/// improve on. +/// +/// The `poll` parameter is absent (empty string) whenever the page was +/// opened without one — the manual `TextField` path on the landing screen +/// still works identically in that case; `Main.qml`'s new `initialPollId` +/// property (added by this task, empty by default, so every prior QML smoke +/// test's assertions are unaffected) is a no-op unless this file passes it a +/// non-empty value. +/// +/// @par Note what is *not* here, and why this is the first WASM binary that can say so honestly +/// No `asyncRegistrationEnabled` flag, no `setConnectHandler`, no +/// hand-rolled wait-for-binding timer — `AppContext` +/// (`examples/common/gui/app_context.hpp`) owns the first two generically, +/// confirmed still true by reading `examples/common/gui/app_context.cpp:37`, +/// which builds this client's `QtWebSocketBackend` with +/// `Config{.asyncRegistrationEnabled = true}` for every ladder GUI/WASM app, +/// polls included, unconditionally. No new wiring was needed here beyond +/// what `AppContext` already provides. +/// +/// More interestingly: this is also the first ladder WASM client with +/// *no `bound`-gated (or hand-rolled retry-timer) bootstrap dispatch anywhere +/// in its QML*, and that is not an oversight — `gui/qml/VoteView.qml`'s +/// `Component.onCompleted` fires `pollBridge.openPoll(pollId)` exactly once, +/// unconditionally, with nothing resembling pastebin's `PasteBridge::bound`/ +/// bookmarks' `BookmarkBridge::bound` gating (both covering the "handler not +/// bound" window that opens on connect and closes when registration +/// settles). Read `include/morph/core/bridge.hpp` to confirm this is +/// actually safe rather than assuming this rung's `EventPoller` quietly +/// papers over a real gap: +/// - Pastebin's/bookmarks' plain (`NoSharing`) handlers each call +/// `Bridge::registerHandler(binding)` at construction, which — via +/// `registerHandlerImpl` — issues a real `registerModelAsync` round trip +/// to the backend. Until that reply lands, any call through the handler +/// fails "handler not bound"; that window is exactly why those two +/// rungs' bridges expose a `bound` signal their QML gates the first +/// dispatch on. +/// - `PollFormsController`'s handler (`BridgeHandler`) is built via `Bridge::registerSharedHandler()` +/// instead (`bridge.hpp`'s `BridgeHandler::makeBinding`, `kShared` +/// branch), whose own doc comment says plainly: "this registers nothing +/// on the backend: a shared handler has no instance until a keyed action +/// ... tells it which one it wants." There is no preliminary round trip +/// to race at all. The handler's first, and only, network operation is +/// `Bridge::attachHandlerAsync` itself, fired directly from +/// `PollFormsController::openPoll()` — which `VoteView.qml`'s +/// `Component.onCompleted` only ever calls after `PollBridge` has been +/// constructed, which this file only ever does from inside +/// `ctx.onReady()` (below), by which point the socket is already +/// connected and there is no *second*, separate registration step left +/// to still be pending (that window never opens in the first place). +/// This is the exact keyed-attach async path a shared handler needs to +/// get right, and this file is the first real WASM binary to actually +/// dispatch through it. +/// +/// @par Verification status +/// Structurally complete and reviewed, **never compiled**: no Emscripten +/// toolchain was available in the environment this was authored in, exactly +/// as rung 0's spike, rung 1's and rung 2's own `gui_wasm/main_wasm.cpp` +/// record for themselves. This file carries strictly more unverified surface +/// than either of those: the `pollsWasmQueryPollId()` `EM_JS` shim below is +/// this repository's first use of `EM_JS`/raw Emscripten JS interop anywhere +/// (previously only Qt's own WASM platform layer touched JS at all), and the +/// keyed-attach dispatch path it feeds (`OpenPoll` → `attachHandlerAsync`) +/// has, per the reasoning above, literally never run inside a real WASM +/// binary before. The `ladder-wasm` compile gate in +/// `.github/workflows/wasm-ladder.yml` (which this task extends with a named +/// `ladder_polls_gui_wasm` target) is what will actually prove the compile +/// half; nothing short of a live browser session against a real +/// `ladder_polls_server` proves the runtime half — `EM_JS`'s JS body is not +/// type-checked by anything at C++ compile time, and the whole point of this +/// file is a control-flow shape (`OpenPoll`'s async attach) this repository +/// has only exercised natively before now. + +#include +#include +#include +#include +#include + +#include "gui/app_context.hpp" +#include "poll_qml_bridges.hpp" + +#include + +#include +#include + +namespace { + +// Returns a `_malloc`'d, NUL-terminated UTF-8 copy of the `poll` query +// parameter's value, or `0` (null) if the page url has none. Freed by the +// caller with `std::free` — the same underlying allocator Emscripten's +// `_malloc` uses, per the standard EM_JS "return a JS string to C++" idiom +// (see this file's own header comment for why EM_JS rather than Embind's +// `emscripten::val`). `UTF8ToString`/`stringToUTF8`/`lengthBytesUTF8`/ +// `_malloc` are Emscripten runtime internals, reachable from EM_JS-inlined +// code without needing `-sEXPORTED_RUNTIME_METHODS` (that flag only gates +// calls *into* `Module.*` from external JS, not EM_JS's own body). +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -- EM_JS's macro-generated shape +EM_JS(char*, pollsWasmQueryPollId, (), { + var params = new URLSearchParams(window.location.search); + var value = params.get('poll'); + if (value === null) { + return 0; + } + var length = lengthBytesUTF8(value) + 1; + var ptr = _malloc(length); + stringToUTF8(value, ptr, length); + return ptr; +}); + +/// @brief The `?poll=` query parameter from the browser's current +/// url, or an empty string if the page was opened without one. +/// @return The poll id a shared link named, or `QString{}`. +[[nodiscard]] QString initialPollIdFromUrl() { + char* raw = pollsWasmQueryPollId(); + if (raw == nullptr) { + return QString{}; + } + QString pollId = QString::fromUtf8(raw); + std::free(raw); + return pollId; +} + +} // namespace + +int main(int argc, char** argv) { + QGuiApplication qtApp{argc, argv}; + + // Always Remote — see this file's header comment. `AppContext` builds the + // QtWebSocketBackend with asyncRegistrationEnabled=true, which is what + // makes registration WASM-safe at all (the synchronous path nests a + // QEventLoop and aborts the page — examples/TESTING.md, "WASM reality"). + ::morph::ladder::gui::AppContext ctx{ + ::morph::ladder::gui::Remote{.url = QUrl{QString::fromUtf8(MORPH_LADDER_POLLS_WASM_SERVER_URL)}}}; + + // Read once, before the engine exists: this is a pure page-url read, not + // a network call, so it has no readiness dependency on `ctx`. + const QString initialPollId = initialPollIdFromUrl(); + + QQmlApplicationEngine engine; + std::unique_ptr pollBridge; + + // Built from inside onReady(), never before it: a Remote context is not + // usable the line after its constructor returns, and a registration or + // attach issued before the socket is up fails permanently with no retry + // (docs/findings/017). Identical to bookmarks'/pastebin's own Remote + // clients, and — per this file's header comment — load-bearing here for + // a second, distinct reason: `PollBridge`'s handler's *first* network + // call is `OpenPoll`'s async attach itself, with no prior "registration" + // step to race, so this is also the point past which that attach is + // always safe to issue. + ctx.onReady([&] { + pollBridge = std::make_unique(ctx.bridge(), ctx.executor()); + engine.setInitialProperties({ + {QStringLiteral("pollBridge"), QVariant::fromValue(pollBridge.get())}, + // Hides Main.qml's one route to CreatePollView (native-only) — + // see this file's own header comment for why this is genuinely + // unreachable, not merely dimmed. + {QStringLiteral("nativeClient"), false}, + // Empty when the page url named no poll — Main.qml then behaves + // exactly as before this task, starting on the landing screen. + {QStringLiteral("initialPollId"), initialPollId}, + }); + engine.loadFromModule(MORPH_LADDER_QML_URI, "Main"); + if (engine.rootObjects().isEmpty()) { + qWarning("ladder_polls_gui_wasm: QML engine produced no root object"); + } + }); + + qInfo("ladder_polls_gui_wasm: connecting to %s ...", MORPH_LADDER_POLLS_WASM_SERVER_URL); + return QGuiApplication::exec(); +} diff --git a/examples/polls/include/polls/app/app.hpp b/examples/polls/include/polls/app/app.hpp new file mode 100644 index 00000000..8195fff0 --- /dev/null +++ b/examples/polls/include/polls/app/app.hpp @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include "polls/auth/polls_authorizer.hpp" + +#include +#include +#include + +#include +#include +#include + +/// @file +/// `polls::app::App` -- this rung's server bootstrap. Mirrors +/// `bookmarks::app::App` (`examples/bookmarks/include/bookmarks/app/app.hpp`) +/// closely, minus everything that rung's `App` owns and this one has no +/// equivalent for: +/// +/// - No `TokenIssuer`/`AuthModel` wiring. This rung has no signed-token +/// mechanism at all -- `CreatePoll` mints its own bare +/// admin/participant tokens directly inside `PollModel::execute()` +/// (`polls/auth/polls_authorizer.hpp`'s own `@file` comment). There is +/// nothing for this `App` to install process-wide beyond the action log. +/// - No background worker/timer, and therefore no `QObject`/`QTimer` +/// inheritance and no internal client `Bridge`. Every mutation this +/// rung's `PollModel` performs (vote, comment, finalize, undo) is +/// synchronous, immediate, inside the calling `execute()` -- there is no +/// async job (no metadata fetch, no expiry sweep, no outbox relay) for a +/// timer to drive. `App` is therefore plain C++, not Qt-dependent at +/// all: only the *tests* that dispatch a real client through `server()` +/// need Qt (for `BridgeHandler`'s completion delivery), not `App` +/// itself. +namespace polls::app { + +/// @brief Owns the server-side pieces this rung's deployment shares: the +/// worker pool, the `RemoteServer` with a real `auth::PollsAuthorizer` +/// installed, and the durable `FileActionLog` (installed process-wide via +/// `morph::journal::setActionLog`, so every `PollModel` instance +/// auto-attaches -- the same convention `bookmarks::app::App`/ +/// `pastebin::app::App` use). Nothing here decides deployment mode -- that +/// stays `examples/common/gui::AppContext`'s job on the client side; this +/// is exclusively the server side. +class App { + public: + /// @brief Wires up the whole server side: worker pool, `RemoteServer` + /// (with `auth::PollsAuthorizer` and this rung's `maxLiveModels` + /// cap installed), and the durable action log. + /// @param actionLogPath Where `FileActionLog` persists entries. + /// @param workers Size of the model worker pool. + explicit App(std::filesystem::path actionLogPath, std::size_t workers = 4); + + /// @brief Detaches the process-wide default action log. + ~App(); + + App(const App&) = delete; + App& operator=(const App&) = delete; + App(App&&) = delete; + App& operator=(App&&) = delete; + + /// @brief The server every transport (a `QtWebSocketServer`, a test's + /// `SimulatedRemoteBackend`) wraps or dispatches against. + /// @return The shared `RemoteServer`; never null. + [[nodiscard]] std::shared_ptr<::morph::backend::RemoteServer> server() const noexcept { return _server; } + + private: + std::shared_ptr<::morph::journal::FileActionLog> _actionLog; + ::morph::exec::ThreadPoolExecutor _pool; + std::shared_ptr<::morph::backend::RemoteServer> _server; +}; + +} // namespace polls::app diff --git a/examples/polls/include/polls/auth/polls_authorizer.hpp b/examples/polls/include/polls/auth/polls_authorizer.hpp new file mode 100644 index 00000000..b12a15be --- /dev/null +++ b/examples/polls/include/polls/auth/polls_authorizer.hpp @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include + +/// @file +/// This rung's one `IAuthorizer`. Narrower than +/// `bookmarks::auth::BookmarksAuthorizer` (`examples/bookmarks/include/bookmarks/auth/bookmarks_authorizer.hpp`) +/// by design, not by omission: this rung has no signed-token mechanism at +/// all -- no `SigningAuthorizer`, no `TokenIssuer` (see the rung README's +/// resolved design decision 1). The admin token `CreatePoll` generates is a +/// bare, server-generated random string, compared directly against a poll +/// row's own `adminToken` column entirely inside `PollModel::execute()` +/// (`requireAdmin()`, `poll_model.cpp`) -- there is no framework-level +/// primitive for verifying a bare shared secret, so there is nothing for an +/// `IAuthorizer::authorize()` override to check here. `PollsAuthorizer` +/// therefore leaves `authorize()` at `AllowAllAuthorizer`'s inherited +/// always-`true` and its whole body is the two instance-lifecycle hooks +/// below. +/// +/// @par How this relates to `BookmarksAuthorizer`, precisely +/// The two share one idea -- both leave `authorizeRegister`/ +/// `authorizeInstance` unconditionally permissive, by design rather than +/// necessity (the framework can gate both on identity now that `register`/ +/// `attach` envelopes carry the caller's session; neither authorizer chooses +/// to) -- and nothing else. They are not +/// structurally alike: `BookmarksAuthorizer` derives from +/// `SigningAuthorizer`, overrides `authorize()` with a real carve-out on top +/// of genuine signed-token verification, ships principal-validation helpers, +/// and defines every body inline in its own header. `PollsAuthorizer` +/// derives from `AllowAllAuthorizer`, overrides nothing that decides +/// anything, and splits a `.cpp` (`src/auth/polls_authorizer.cpp`) for two +/// one-line `return true;` bodies -- a heavier file layout than bookmarks' +/// for a strictly smaller class. Read "mirrors bookmarks" claims about this +/// type as "reaches the same conclusion about those two hooks", never as +/// "is the same shape". +/// +/// `register`/`attach`/`assign`/`deregister` envelopes now carry the +/// caller's authenticated session (both plain `wire::makeRegister` and +/// `wire::makeRegisterShared`, the keyed `OpenPoll{pollId}` attach +/// `PollModel` uses), so `authorizeRegister` *could* gate a poll attach by +/// admin/participant identity -- but this rung chooses not to: attaching to +/// a poll by id is meant to be as open as knowing the shareable link, by +/// design (the rung README's resolved design decision 2). What actually +/// enforces admin-vs-participant is entirely inside `PollModel::execute()`: +/// `FinalizePoll` -- the model's *only* token-gated action -- calls +/// `requireAdmin()` itself, re-checking the caller's token against the +/// poll row's own stored column on every dispatch. This mirrors rung 2's +/// shape for a different reason, though: bookmarks' `authorizeInstance` is +/// now genuinely enforcing but checks *instance* ownership, which +/// `PollModel` has no equivalent of at all (its instances are shared/keyed +/// by pollId, not owned by a caller) -- so the model's own re-check is not +/// standing in for a defeated framework hook, it is simply the only layer +/// that could ever express this rung's admin-vs-participant distinction. + +namespace polls::auth { + +/// @brief This rung's `IAuthorizer`: unconditionally permissive on every +/// hook. See this file's `@file` comment for why that is the +/// correct, verified shape here rather than an oversight. +class PollsAuthorizer : public ::morph::session::AllowAllAuthorizer { + public: + using AllowAllAuthorizer::AllowAllAuthorizer; + + /// @brief Admits every registration, by this rung's own design -- not + /// because identity is unavailable to gate on. + /// + /// Same conclusion as `BookmarksAuthorizer::authorizeRegister` (not the + /// same shape -- see this file's `@file` comment), extended: this covers + /// not only a plain `PollModel` registration but also the keyed + /// `OpenPoll` attach path (`registerModelShared`/`attachModel`'s wire + /// form, which now carries a session too, exactly like plain + /// `wire::makeRegister`). Admitting an unauthenticated attach gives away + /// exactly what knowing the `pollId` already gives away, which by this + /// rung's design is everything except finalizing: `FinalizePoll` is the + /// one action that re-checks a token (`PollModel::requireAdmin()`, + /// against the poll row's own `adminToken` column), and every other + /// action is ungated on purpose -- see `poll_model.hpp`'s "What is + /// actually gated" section for the full, exact statement. This hook + /// stays permissive regardless of whether @p ctx carries a real + /// principal or not, since attaching to a poll by id is meant to be as + /// open as knowing the shareable link -- gating it now would change this + /// rung's own product decision, not merely close a framework gap. + /// @param ctx Per-call session for the register envelope. + /// Populated with the caller's verified principal when + /// it holds a valid session, empty otherwise; ignored + /// either way -- see above. + /// @param modelType Target model type id. `RemoteServer` has already + /// rejected a type its registry does not know by the + /// time this runs. + /// @return `true`, always -- see this function's own doc comment. + [[nodiscard]] bool authorizeRegister([[maybe_unused]] const ::morph::session::Context& ctx, + [[maybe_unused]] std::string_view modelType) const override; + + /// @brief Admits every per-instance operation -- there is no owner + /// principal to check against here. + /// + /// `BookmarksAuthorizer::authorizeInstance` compares a recorded owner + /// principal against `ctx.principal`, and is now genuinely enforcing for + /// bookmarks' plain-registered models. That comparison presumes a + /// per-caller owner concept `PollModel` never has in the first place: + /// its instances are exclusively shared/keyed by `pollId` + /// (`BRIDGE_MODEL_KEY`), which `RemoteServer` records ownerless by + /// design (there is no single owning caller for a shared instance) -- + /// independent of, and unaffected by, whether register envelopes carry + /// a session. This rung does not even attempt the comparison: the + /// admin-vs-participant boundary this rung actually has lives entirely + /// inside `PollModel::execute()`, not at the instance-ownership layer. + /// @param ctx Per-call session. Ignored -- see above. + /// @param modelType Ignored: the same rule applies to every model. + /// @param actionType Ignored. + /// @param modelId Ignored: there is no per-instance owner to key on. + /// @param ownerPrincipal Ignored -- always empty in practice: `PollModel` + /// instances are exclusively shared/keyed, and + /// shared instances are recorded ownerless by + /// design, not because owners can't be tracked. + /// @return `true`, always -- see this function's own doc comment. + [[nodiscard]] bool authorizeInstance([[maybe_unused]] const ::morph::session::Context& ctx, + [[maybe_unused]] std::string_view modelType, + [[maybe_unused]] std::string_view actionType, + [[maybe_unused]] std::uint64_t modelId, + [[maybe_unused]] std::string_view ownerPrincipal) const override; +}; + +} // namespace polls::auth diff --git a/examples/polls/include/polls/core/errors.hpp b/examples/polls/include/polls/core/errors.hpp new file mode 100644 index 00000000..83d915fa --- /dev/null +++ b/examples/polls/include/polls/core/errors.hpp @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +/// @file +/// Domain exceptions. A model's `execute(...)` throws one of these; morph +/// captures it as a `std::exception_ptr` and delivers it to the caller's +/// `.onError(...)` callback. See `bookmarks/core/errors.hpp` for the +/// identical shape and rationale this mirrors. + +namespace polls { + +/// @brief Base of every polls-specific error a model throws. +struct PollsError : std::runtime_error { + using std::runtime_error::runtime_error; +}; + +/// @brief No poll/option exists at the given id — it never existed, or it +/// was deleted. +struct NotFound : PollsError { + using PollsError::PollsError; +}; + +/// @brief An action's `validate()` rejected its input. +struct ValidationError : PollsError { + using PollsError::PollsError; +}; + +/// @brief A write lost a race: the target row changed between this +/// client's read and its write, or an operation conflicts with +/// the current state (e.g., finalizing an already-finalized poll). +struct Conflict : PollsError { + using PollsError::PollsError; +}; + +/// @brief The caller is authenticated, but the target row exists and is +/// owned by a different principal or the caller lacks required +/// permissions (e.g., only the admin can finalize or edit options). +/// Distinguished from `NotFound` deliberately: a model's own re-check +/// needs its own typed signal for authorization failures. +struct Forbidden : PollsError { + using PollsError::PollsError; +}; + +} // namespace polls diff --git a/examples/polls/include/polls/core/types.hpp b/examples/polls/include/polls/core/types.hpp new file mode 100644 index 00000000..5b17fe39 --- /dev/null +++ b/examples/polls/include/polls/core/types.hpp @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include + +/// @file +/// Polls' strong id types and constants. `OptionId` and `PollEventId` wrap +/// auto-incrementing integers (SQLite row ids), following `BookmarkId`'s +/// pattern. `PollId` itself is not a strong type (see Global Constraints), +/// but `kTokenBytes` is shared by implementations and tests to ensure +/// consistency on generated token lengths. + +namespace polls { + +/// @brief Length in bytes of a generated `pollId`/admin-token/participant-token +/// string: 22 URL-safe base64 characters encoding 16 random bytes, +/// matching a nanoid-shaped unguessable identifier. Shared by +/// `CreatePoll`'s implementation (Task 5) and its tests so the two +/// never drift. +inline constexpr std::size_t kTokenBytes = 22; + +/// @brief Strong identifier for one candidate date/time option within a poll. +/// Never the target of a `BRIDGE_MODEL_KEY`/`BRIDGE_KEY_FROM` macro — +/// `PollModel` is keyed by `pollId` alone (see `OpenPoll` in +/// `dto/poll_dto.hpp`), so this stays an ordinary strong type per +/// `IMPLEMENTATION.md` rule 3. +struct OptionId { + /// @brief The payload; `0` means "not entered" (analogous to empty optional). + std::int64_t value{0}; + + /// @brief Whether a value has been entered. + /// @return `true` if the payload is non-zero. + [[nodiscard]] constexpr bool hasValue() const { return value != 0; } + + /// @brief Returns the payload as-is. Never UB, unlike + /// `std::optional::operator*` -- `value` is a plain + /// `std::int64_t` with `0` as its own "not entered" sentinel, not + /// a `std::optional` this wraps, so there is no engaged/empty + /// state distinction below the surface for this to violate. Check + /// `hasValue()` first when `0` vs. a real id matters to the + /// caller; this always returns whatever `value` holds either way. + /// @return The payload, verbatim. + [[nodiscard]] constexpr std::int64_t operator*() const { return value; } + + /// @brief Equality on the payload. + [[nodiscard]] constexpr bool operator==(const OptionId&) const = default; +}; + +/// @brief Strong identifier for one row in the `poll_events` append-only log. +/// Table-wide monotonic (not per-poll), autoincrement — see this +/// plan's Global Constraints on why a sequence id, not a timestamp. +struct PollEventId { + /// @brief The payload; `0` means "not entered" (analogous to empty optional). + std::int64_t value{0}; + + /// @brief Whether a value has been entered. + /// @return `true` if the payload is non-zero. + [[nodiscard]] constexpr bool hasValue() const { return value != 0; } + + /// @brief Returns the payload as-is. Never UB, unlike + /// `std::optional::operator*` -- `value` is a plain + /// `std::int64_t` with `0` as its own "not entered" sentinel, not + /// a `std::optional` this wraps, so there is no engaged/empty + /// state distinction below the surface for this to violate. Check + /// `hasValue()` first when `0` vs. a real id matters to the + /// caller; this always returns whatever `value` holds either way. + /// @return The payload, verbatim. + [[nodiscard]] constexpr std::int64_t operator*() const { return value; } + + /// @brief Equality on the payload. + [[nodiscard]] constexpr bool operator==(const PollEventId&) const = default; +}; + +/// @brief One participant's answer for one option. +enum class VoteChoice { Yes, IfNeedBe, No }; + +} // namespace polls + +/// @brief On the wire an `OptionId` is its underlying integer. +template <> +struct glz::meta { + static constexpr auto value = &polls::OptionId::value; + static constexpr std::string_view name = "OptionId"; +}; + +/// @brief On the wire a `PollEventId` is its underlying integer. +template <> +struct glz::meta { + static constexpr auto value = &polls::PollEventId::value; + static constexpr std::string_view name = "PollEventId"; +}; + +/// @brief Reflects `VoteChoice` as its enumerator names rather than a bare +/// ordinal -- same rationale and `glz::enumerate` shape as +/// `glz::meta` (`dto/poll_dto.hpp`): a raw integer +/// both degrades the schema writer's `$defs` entry to an any-type +/// union and accepts any out-of-range value silently instead of +/// rejecting it during decode. Persistence is unaffected: `votes` +/// stores this as its own `choice` `std::uint8_t` column +/// (`db/poll_entity.hpp`), never as this JSON form. +template <> +struct glz::meta { + using enum polls::VoteChoice; + static constexpr auto value = glz::enumerate(Yes, IfNeedBe, No); +}; diff --git a/examples/polls/include/polls/db/database.hpp b/examples/polls/include/polls/db/database.hpp new file mode 100644 index 00000000..b8092784 --- /dev/null +++ b/examples/polls/include/polls/db/database.hpp @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +namespace polls::db { + +/// @brief Points Lightweight's default connection at @p connectionString and +/// applies every pending migration. Production-bootstrap-only, called +/// once by Task 17's server app -- see `bookmarks::db::setup`'s +/// identical doc comment for why tests never call this. +/// @param connectionString ODBC connection string. +void setup(const std::string& connectionString); + +} // namespace polls::db diff --git a/examples/polls/include/polls/db/poll_entity.hpp b/examples/polls/include/polls/db/poll_entity.hpp new file mode 100644 index 00000000..cc79950c --- /dev/null +++ b/examples/polls/include/polls/db/poll_entity.hpp @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#ifndef __EMSCRIPTEN__ +#include +#endif + +#include "polls/core/types.hpp" +#include "polls/dto/poll_dto.hpp" +#include "polls/dto/vote_dto.hpp" + +#include +#include +#include + +/// @file +/// Six ladder-rung-3 entities. Every child table (`OptionRecord`, +/// `VoteRecord`, `CommentRecord`, `VoteHistoryRecord`, `PollEventRecord`) +/// deliberately carries **zero** relation-typed members beyond `BelongsTo` +/// (no `HasMany`, no `HasManyThrough`) -- see +/// `bookmarks::db::BookmarkRecord`'s identical file comment +/// (`examples/bookmarks/include/bookmarks/db/bookmark_entity.hpp`) for the +/// verified reason: `DataMapper::Update()`'s non-reflection path calls +/// `field.IsModified()` on every member via `EnumerateRecordMembers` (which +/// does not filter by field kind), and neither relation type declares that +/// method, so a record embedding one fails to compile the instant `Update()` +/// is instantiated for it. Reads against a parent poll always go through a +/// plain `Query().Where(FieldNameOf<&T::poll>, "=", pollDbId)` call in +/// the model (`poll_model.cpp`, Task 5+), never through an embedded +/// relation field on `PollRecord`. + +namespace polls::db { + +#ifndef __EMSCRIPTEN__ + +/// @brief One row of the `polls` table. +struct PollRecord { + static constexpr std::string_view TableName = "polls"; + + Light::Field id; // 0 + /// The shareable link id -- see this rung's Global Constraints. Fixed-width, + /// ASCII, `kTokenBytes` long: the same ID/token-shaped case bank's `number` + /// and pastebin's `id` are, so `SqlAnsiString` -- a fixed + /// capacity matching the token's own fixed length, unlike this rung's + /// free-form Unicode text fields (`title`, `participantName`, `body`, + /// etc.), each bounded instead to its own DTO-level `kMax*Bytes` cap + /// (see each field's own doc comment below). + Light::Field, Light::SqlRealName{"poll_id"}> pollId; // 1 + /// Kept by the organizer only. + Light::Field, Light::SqlRealName{"admin_token"}> adminToken; // 2 + /// Handed out with the shared link. + Light::Field, Light::SqlRealName{"participant_token"}> participantToken; // 3 + /// Free-form Unicode text, per this rung's own convention (see the + /// `pollId` doc comment above) -- bounded to `kMaxTitleBytes` + /// (`polls/dto/poll_dto.hpp`), the same cap `CreatePoll::validate()` + /// already enforces at the DTO boundary; `poll_model.cpp`'s + /// `static_assert` pins the two together. + Light::Field, Light::SqlRealName{"title"}> title; // 4 + Light::Field finalized{false}; // 5 + /// 0 = not finalized; FK-shaped but not FK-enforced (SQLite). + Light::Field finalizedOptionId{0}; // 6 + Light::Field createdAtMs{0}; // 7 +}; + +/// @brief One row of the `poll_options` table. +struct OptionRecord { + static constexpr std::string_view TableName = "poll_options"; + + Light::Field id; // 0 + Light::BelongsTo<&PollRecord::id, Light::SqlRealName{"poll_id"}> poll; // 1 + /// Free-form Unicode text, bounded to `kMaxOptionLabelBytes` + /// (`polls/dto/poll_dto.hpp`) -- see `PollRecord::title`'s doc comment + /// for the same convention. + Light::Field, Light::SqlRealName{"label"}> label; // 2 + /// Preserves `CreatePoll`'s option order across storage/query. + Light::Field sortOrder{0}; // 3 +}; + +/// @brief One participant's current vote for one option. Unique on +/// (pollId, participantName, optionId) so a retried `SubmitVotes` +/// cannot double-count -- see Task 6's own doc comment on the exact +/// index this rung's DoD names. +struct VoteRecord { + static constexpr std::string_view TableName = "votes"; + + Light::Field id; // 0 + Light::BelongsTo<&PollRecord::id, Light::SqlRealName{"poll_id"}> poll; // 1 + Light::BelongsTo<&OptionRecord::id, Light::SqlRealName{"option_id"}> option; // 2 + /// Free-form Unicode text, bounded to `kMaxParticipantNameBytes` + /// (`polls/dto/vote_dto.hpp`) -- see `PollRecord::title`'s doc comment + /// for the same convention. + Light::Field, Light::SqlRealName{"participant_name"}> participantName; // 3 + /// `VoteChoice`'s underlying value. + Light::Field choice{std::uint8_t{0}}; // 4 +}; + +/// @brief One row of the `comments` table. +struct CommentRecord { + static constexpr std::string_view TableName = "comments"; + + Light::Field id; // 0 + Light::BelongsTo<&PollRecord::id, Light::SqlRealName{"poll_id"}> poll; // 1 + /// Free-form Unicode text, bounded to `kMaxParticipantNameBytes` + /// (`polls/dto/vote_dto.hpp`) -- see `PollRecord::title`'s doc comment + /// for the same convention. + Light::Field, Light::SqlRealName{"participant_name"}> participantName; // 2 + /// Free-form Unicode text, bounded to `kMaxCommentBytes` + /// (`polls/dto/vote_dto.hpp`) -- see `PollRecord::title`'s doc comment + /// for the same convention. + Light::Field, Light::SqlRealName{"body"}> body; // 3 + Light::Field createdAtMs{0}; // 4 +}; + +/// @brief Undo's own history, one row per vote-changing call +/// (`SubmitVotes`/`UpdateVotes`), storing the *previous* state so +/// `UndoLastVoteChange` can restore it. Never read by anything but +/// `UndoLastVoteChange` -- not the audit trail (the framework +/// journal covers that separately). +struct VoteHistoryRecord { + static constexpr std::string_view TableName = "vote_history"; + + Light::Field id; // 0 + Light::BelongsTo<&PollRecord::id, Light::SqlRealName{"poll_id"}> poll; // 1 + /// Free-form Unicode text, bounded to `kMaxParticipantNameBytes` + /// (`polls/dto/vote_dto.hpp`) -- see `PollRecord::title`'s doc comment + /// for the same convention. + Light::Field, Light::SqlRealName{"participant_name"}> participantName; // 2 + /// The pre-change vote set, JSON-encoded. Unbounded: no business limit + /// on this serialized form exists today, so this is + /// `Light::SqlMaxDynamicAnsiString`, not a fixed-capacity + /// `SqlAnsiString` -- matching pastebin's `content` field precedent + /// for unbounded storage (`pastebin/db/paste_entity.hpp`), minus the + /// wide/UTF-8 concern that field carries (this JSON payload is + /// ASCII-safe base64/plain-integer content, produced only by + /// `encodeVotesJson()` in this same TU). + Light::Field previousVotesJson; // 3 + Light::Field createdAtMs{0}; // 4 +}; + +/// @brief The event log. Table-wide autoincrement `id` is `PollEventId`'s +/// wire value directly -- see this plan's Global Constraints. +struct PollEventRecord { + static constexpr std::string_view TableName = "poll_events"; + + Light::Field id; // 0 + Light::BelongsTo<&PollRecord::id, Light::SqlRealName{"poll_id"}> poll; // 1 + /// A short internal enum-like tag ("vote"/"comment"/"finalize", see + /// `PollModel`'s writers) -- 32 bytes comfortably covers every literal + /// this TU emits today, with headroom, and there is no existing + /// DTO-level constant for it (this column is never validated at the DTO + /// boundary the way `title`/`label`/etc. are). + Light::Field, Light::SqlRealName{"kind"}> kind; // 2 + /// Free text describing one event; unbounded, like + /// `VoteHistoryRecord::previousVotesJson` -- no existing bound applies + /// and none is invented here, since this is a human-readable summary + /// string, not a wire-validated field. + Light::Field summary; // 3 + Light::Field createdAtMs{0}; // 4 +}; + +#else +// Client-only (WASM) build: entity shapes are never instantiated, only +// referenced by type in code that never runs there -- this stub pattern is +// what a WASM client falls back on since PollModel's own header (unlike a +// declaration-only facade) still pulls this file in transitively, purely to +// name these types (BridgeHandler is a template over the model +// type, and PollModel's execute() signatures still name db::PollRecord et +// al. even though poll_model.cpp -- the only place any of these types is +// ever instantiated -- is never compiled for Emscripten at all). +struct PollRecord {}; +struct OptionRecord {}; +struct VoteRecord {}; +struct CommentRecord {}; +struct VoteHistoryRecord {}; +struct PollEventRecord {}; +#endif + +} // namespace polls::db diff --git a/examples/polls/include/polls/dto/event_dto.hpp b/examples/polls/include/polls/dto/event_dto.hpp new file mode 100644 index 00000000..5bcb827c --- /dev/null +++ b/examples/polls/include/polls/dto/event_dto.hpp @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once +#include "polls/core/types.hpp" +#include +#include + +namespace polls { + +/// @brief One row of `poll_events` -- the Zulip-pattern generic polling +/// payload. `kind` is a small closed set (`"vote"`, `"comment"`, +/// `"finalize"`) a client switches on to know how to apply the +/// increment without re-fetching `GetPollState`. +struct PollEvent { + PollEventId id; + std::string kind; + std::string summary; // human-readable, e.g. "alice voted", "poll finalized" +}; + +struct GetEventsSince { + PollEventId lastEventId; // {} (value 0) means "from the beginning" + + // A negative value static_cast's to a huge number in + // execute(GetEventsSince)'s `id > lastEventId` comparison (poll_model.cpp), + // silently matching zero rows instead of erroring -- indistinguishable + // from a genuinely idle poll, so a poller with a corrupted cursor would + // believe the poll is idle rather than desyncing loudly. PollEventId's + // own wire encoding is its bare (signed) int64 payload + // (glz::meta, core/types.hpp), so a negative value is + // genuinely reachable from a malformed or malicious client, not merely a + // local invariant this type already enforces. + [[nodiscard]] bool validate() const noexcept { return lastEventId.value >= 0; } +}; + +struct GetEventsSinceResult { + std::vector events; // oldest first, every id > lastEventId +}; + +} // namespace polls diff --git a/examples/polls/include/polls/dto/poll_dto.hpp b/examples/polls/include/polls/dto/poll_dto.hpp new file mode 100644 index 00000000..7bd2e2c3 --- /dev/null +++ b/examples/polls/include/polls/dto/poll_dto.hpp @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include "polls/core/types.hpp" +#include "polls/units.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace polls { + +constexpr std::size_t kMaxTitleBytes = 200; +constexpr std::size_t kMaxOptionLabelBytes = 100; +constexpr std::size_t kMinOptions = 2; +constexpr std::size_t kMaxOptions = 20; + +/// @brief One candidate date/time, as free text (Rallly stores these as +/// ISO-ish date strings; this rung follows suit rather than parsing +/// into `morph::time::Timestamp`, since `morph::time` is UTC-only +/// and per-participant local rendering is explicitly GUI logic per +/// the README's "Expected strain points"). +struct CreatePollOption { + std::string label; +}; + +struct CreatePoll { + std::string title; + std::vector options; + + [[nodiscard]] bool validate() const noexcept { + if (title.empty() || title.size() > kMaxTitleBytes) { + return false; + } + if (options.size() < kMinOptions || options.size() > kMaxOptions) { + return false; + } + for (const auto& opt : options) { + if (opt.label.empty() || opt.label.size() > kMaxOptionLabelBytes) { + return false; + } + } + return true; + } +}; + +/// @brief Opaque capability-token newtype for the organizer's secret +/// (`examples/IMPLEMENTATION.md` rule 3's protocol-scalars row: +/// capability/confirmation tokens get a named opaque wrapper per +/// role, never a loose `std::string`). Same shape and rationale as +/// `bookmarks::AuthToken` — read that type's doc comment for the +/// `fromOptional`/`hasValue()` factory argument, which applies here +/// verbatim. Distinct from `ParticipantToken` below *by type*, not +/// merely by field name: the two are never interchangeable, and only +/// this one satisfies `PollModel::requireAdmin()`. +struct AdminToken { + /// @brief The payload; `std::nullopt` means "no token". + std::optional value; + + /// @brief Constructs the empty state. + constexpr AdminToken() noexcept = default; + + /// @brief Engages with @p token. + explicit AdminToken(std::string token) noexcept : value{std::move(token)} {} + + /// @brief Adopts an optional payload as-is. + /// @param payload The optional payload to adopt as-is. + /// @return An `AdminToken` wrapping @p payload directly. + [[nodiscard]] static AdminToken fromOptional(std::optional payload) noexcept { + AdminToken result; + result.value = std::move(payload); + return result; + } + + /// @brief Whether a value has been entered. + /// @return `true` if the payload is engaged. + [[nodiscard]] bool hasValue() const noexcept { return value.has_value(); } + + /// @brief Unchecked access to the engaged value (UB when empty, exactly + /// like `std::optional::operator*`). + /// @return The engaged value. + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + [[nodiscard]] const std::string& operator*() const noexcept { return *value; } + + /// @brief Equality/ordering on the payload; empty compares only equal to empty. + [[nodiscard]] auto operator<=>(const AdminToken&) const noexcept = default; +}; + +/// @brief Opaque capability-token newtype for the secret handed out with the +/// shared link. Same shape as `AdminToken` above and, deliberately, a +/// *different type* from it. +/// +/// @warning Generated, stored and returned, but **verified by nothing** in +/// the shipped rung — see `polls/models/poll_model.hpp`'s `@file` comment and +/// the rung README's resolved design decision 1. `pollId` is itself the +/// 128-bit shared secret that gates reaching a poll at all; this token is +/// reserved for a later rung that wants a second, revocable capability level. +struct ParticipantToken { + /// @brief The payload; `std::nullopt` means "no token". + std::optional value; + + /// @brief Constructs the empty state. + constexpr ParticipantToken() noexcept = default; + + /// @brief Engages with @p token. + explicit ParticipantToken(std::string token) noexcept : value{std::move(token)} {} + + /// @brief Adopts an optional payload as-is. + /// @param payload The optional payload to adopt as-is. + /// @return A `ParticipantToken` wrapping @p payload directly. + [[nodiscard]] static ParticipantToken fromOptional(std::optional payload) noexcept { + ParticipantToken result; + result.value = std::move(payload); + return result; + } + + /// @brief Whether a value has been entered. + /// @return `true` if the payload is engaged. + [[nodiscard]] bool hasValue() const noexcept { return value.has_value(); } + + /// @brief Unchecked access to the engaged value (UB when empty, exactly + /// like `std::optional::operator*`). + /// @return The engaged value. + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + [[nodiscard]] const std::string& operator*() const noexcept { return *value; } + + /// @brief Equality/ordering on the payload; empty compares only equal to empty. + [[nodiscard]] auto operator<=>(const ParticipantToken&) const noexcept = default; +}; + +/// @brief Whether a poll has been finalized. A two-enumerator `enum class`, +/// never a bare `bool`, per `examples/IMPLEMENTATION.md` rule 3 — +/// same convention as `pastebin::Visibility`/`bookmarks::ReadState` +/// on the wire and `PollModel::WriteHistory` internally. +enum class Finalized : std::uint8_t { No, Yes }; + +struct CreatePollResult { + std::string pollId; // the shareable link id -- see Global Constraints + AdminToken adminToken; // kept by the organizer only + ParticipantToken participantToken; // handed out with the shared link; verified by nothing today +}; + +/// @brief The keyed attach action -- `BRIDGE_MODEL_KEY(PollModel, OpenPoll, &OpenPoll::pollId)`. +struct OpenPoll { + std::string pollId; + + [[nodiscard]] bool validate() const noexcept { return !pollId.empty(); } +}; + +struct GetPollState { + [[nodiscard]] bool validate() const noexcept { return true; } +}; + +struct PollOptionView { + OptionId id; + std::string label; + // Default-initialized to an engaged zero, not Quantity's default empty + // state -- Quantity arithmetic is empty-propagating (empty + anything = + // empty forever), which silently broke buildState()'s incremental vote + // tally until Task 6 caught it. These initializers close that footgun + // at the type itself, not just at buildState()'s one call site, so a + // future construction site can't reintroduce the same bug silently. + Count yesCount = Count::fromDouble(0.0); + Count ifNeedBeCount = Count::fromDouble(0.0); + Count noCount = Count::fromDouble(0.0); +}; + +struct ParticipantVoteView { + std::string participantName; + OptionId optionId; + VoteChoice choice; +}; + +struct CommentView { + std::string participantName; + std::string body; +}; + +struct GetPollStateResult { + std::string pollId; + std::string title; + Finalized finalized{Finalized::No}; + OptionId finalizedOptionId; // hasValue() == false unless finalized == Finalized::Yes + std::vector options; + std::vector votes; + std::vector comments; + PollEventId lastEventId; // GetEventsSince's starting cursor for a fresh client +}; + +} // namespace polls + +/// @brief Reflects `AdminToken` as its bare payload — same rationale and +/// shape as `glz::meta`: the wire form of an +/// opaque scalar newtype is the scalar, not an object with a `value` +/// member. +template <> +struct glz::meta { + static constexpr auto value = &polls::AdminToken::value; + static constexpr std::string_view name = "AdminToken"; +}; + +/// @brief Reflects `ParticipantToken` as its bare payload — see +/// `glz::meta` above. +template <> +struct glz::meta { + static constexpr auto value = &polls::ParticipantToken::value; + static constexpr std::string_view name = "ParticipantToken"; +}; + +/// @brief Reflects `Finalized` as the strings `"No"`/`"Yes"` rather than its +/// underlying `0`/`1` — same rationale and `glz::enumerate` shape as +/// `glz::meta` (a bare ordinal also degrades the +/// schema writer's `$defs` entry to an any-type union). Persistence is +/// unaffected: the `polls` table stores this as its own `finalized` +/// boolean column (`db/poll_entity.hpp`), never as this JSON form. +template <> +struct glz::meta { + using enum polls::Finalized; + static constexpr auto value = glz::enumerate(No, Yes); +}; diff --git a/examples/polls/include/polls/dto/vote_dto.hpp b/examples/polls/include/polls/dto/vote_dto.hpp new file mode 100644 index 00000000..572a41b3 --- /dev/null +++ b/examples/polls/include/polls/dto/vote_dto.hpp @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once +#include "polls/core/types.hpp" + +#include +#include +#include +#include + +namespace polls { + +constexpr std::size_t kMaxParticipantNameBytes = 80; +constexpr std::size_t kMaxCommentBytes = 500; + +struct OneVote { + OptionId optionId; + VoteChoice choice; +}; + +/// @brief Whether @p votes names the same `optionId` more than once. +/// +/// Without this check, two entries for the same option collide with +/// `idx_votes_poll_participant_option`'s unique index and throw a raw, +/// unhandled SQL constraint-violation exception instead of the typed +/// `ValidationError` every other bad-input path in this model produces. +/// @param votes The vote list to check. +/// @return `true` if any `optionId` repeats. +[[nodiscard]] inline bool hasDuplicateOptionId(const std::vector& votes) { + for (std::size_t i = 0; i < votes.size(); ++i) { + for (std::size_t j = i + 1; j < votes.size(); ++j) { + if (votes[i].optionId == votes[j].optionId) { + return true; + } + } + } + return false; +} + +/// @brief First-time vote submission for one participant. Idempotent on +/// retry: a duplicate submission with the same participantName is +/// rejected by the option-uniqueness invariant (Task 6), never +/// double-counted. +struct SubmitVotes { + std::string participantName; + std::vector votes; + + [[nodiscard]] bool validate() const noexcept { + return !participantName.empty() && participantName.size() <= kMaxParticipantNameBytes && !votes.empty() && + !hasDuplicateOptionId(votes); + } +}; + +/// @brief Replaces an existing participant's votes wholesale. +struct UpdateVotes { + std::string participantName; + std::vector votes; + + [[nodiscard]] bool validate() const noexcept { + return !participantName.empty() && participantName.size() <= kMaxParticipantNameBytes && !votes.empty() && + !hasDuplicateOptionId(votes); + } +}; + +struct AddComment { + std::string participantName; + std::string body; + + [[nodiscard]] bool validate() const noexcept { + return !participantName.empty() && participantName.size() <= kMaxParticipantNameBytes && !body.empty() && + body.size() <= kMaxCommentBytes; + } +}; + +/// @brief Admin-token-gated: the poll becomes read-only. +struct FinalizePoll { + OptionId optionId; + + [[nodiscard]] bool validate() const noexcept { return optionId.hasValue(); } +}; + +/// @brief Reverses the calling participant's own most recent vote change -- +/// a compensating action against `vote_history`, never +/// `SessionLog::undoLast()`. See the README's resolved design +/// decision 3. +struct UndoLastVoteChange { + std::string participantName; + + [[nodiscard]] bool validate() const noexcept { + return !participantName.empty() && participantName.size() <= kMaxParticipantNameBytes; + } +}; + +/// @brief Whether an undo actually put a prior vote set back. A +/// two-enumerator `enum class`, never a bare `bool`, per +/// `examples/IMPLEMENTATION.md` rule 3 — same convention as +/// `polls::Finalized` (`dto/poll_dto.hpp`) and +/// `PollModel::WriteHistory`. +enum class Restored : std::uint8_t { No, Yes }; + +struct UndoLastVoteChangeResult { + // Restored::No is unreachable in practice: there being nothing to undo + // throws Conflict instead of returning it (see Task 8). It exists so the + // field has a meaningful default rather than a fabricated success value. + Restored restored{Restored::No}; +}; + +} // namespace polls + +/// @brief Reflects `Restored` as the strings `"No"`/`"Yes"` rather than its +/// underlying `0`/`1` — see `glz::meta` +/// (`dto/poll_dto.hpp`) for the full rationale. +template <> +struct glz::meta { + using enum polls::Restored; + static constexpr auto value = glz::enumerate(No, Yes); +}; diff --git a/examples/polls/include/polls/models/poll_model.hpp b/examples/polls/include/polls/models/poll_model.hpp new file mode 100644 index 00000000..bb3abcb6 --- /dev/null +++ b/examples/polls/include/polls/models/poll_model.hpp @@ -0,0 +1,307 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include "polls/core/errors.hpp" +#include "polls/dto/event_dto.hpp" +#include "polls/dto/poll_dto.hpp" +#include "polls/dto/vote_dto.hpp" + +#include +#include +#include + +#include +#include +#include +#include + +/// @file +/// `PollModel` -- this rung's one entity-owning model, keyed by `pollId` +/// (`BRIDGE_MODEL_KEY` below, `BridgeHandler` at the +/// wiring layer). +/// +/// Unlike `bookmarks::BookmarkModel`'s "declared once, complete" header +/// (`examples/bookmarks/include/bookmarks/models/bookmark_model.hpp`, which +/// pre-declares every `execute()` overload the whole rung ever adds, before +/// most of them have bodies), this header declares **only** the actions +/// implemented so far: Task 5's `CreatePoll`/`OpenPoll`/`GetPollState`, +/// Task 6's `SubmitVotes`/`UpdateVotes`/`AddComment`, plus Task 7's +/// `FinalizePoll` below. +/// Verified reason for the deviation, not a style choice: `BRIDGE_REGISTER_ACTION` +/// (`morph/core/registry.hpp`) unconditionally instantiates a static-init-time +/// registrar (`ActionExecuteRegistry::registerAction`, +/// `morph/core/bridge.hpp`) whose stored lambda takes the *address* of +/// `Model::execute(Action)` -- unlike `ActionTraits::Result`'s +/// `decltype(...)` (declaration-only, never ODR-uses the body), +/// this registrar genuinely needs a linkable definition. Registering +/// `FinalizePoll`/`UndoLastVoteChange`/`GetEventsSince` here before Tasks +/// 7-9 give them bodies produced a real `ld: symbol(s) not found` failure +/// against this task's own test binary (confirmed by hand before this +/// header was written this way) -- so Tasks 7/8/9 each add their own +/// action's declaration **and** its `BRIDGE_REGISTER_ACTION` line to this +/// header alongside their own `.cpp` body, not just a `.cpp` change. +/// Task 9's `GetEventsSince` below is the last of these -- every action this +/// rung's DTOs declare now has a real `execute()` body. +/// +/// Registered plain, not `AllowShared` at the *authorization* layer -- the +/// shared *instance* directory is what `AllowShared` opts into at the +/// wiring layer; what token gating exists is entirely this model's own job. +/// There is no framework authorizer for a bare shared-secret-per-entity +/// capability token (this rung's admin token), so `requireAdmin()` +/// hand-verifies `session::current()->token` against the poll row's own +/// `adminToken` column -- see the rung README's resolved design decision 1. +/// +/// @par What is actually gated, stated exactly +/// **`execute(FinalizePoll)` is the only token-gated action in this model.** +/// Every other action -- `SubmitVotes`, `UpdateVotes`, `AddComment`, +/// `UndoLastVoteChange`, `GetPollState`, `GetEventsSince`, and the keyed +/// `OpenPoll` attach itself -- runs for any caller that can name the +/// `pollId`, with no token check of any kind. That is the design, not an +/// omission: `pollId` is a 22-character base64url encoding of 16 bytes of +/// `std::random_device` entropy (see `randomToken()` in this model's `.cpp`), +/// so knowing it *is* the capability, exactly as design decision 2 says +/// ("attaching to a poll by id is meant to be as open as knowing the link"). +/// A participant gate on top of it would add no authority anyway: one +/// participant token is minted per *poll*, not per participant, so every +/// voter shares the same secret and it can distinguish no one from anyone. +/// +/// `CreatePollResult::participantToken` is therefore generated, stored, +/// returned and displayed -- and verified by nothing. It is reserved for a +/// later rung that wants a second capability level the organizer can hand +/// out and revoke separately from the link itself; until such a rung exists, +/// no code reads it back. An earlier draft of this header carried a private +/// `requireParticipant()` helper "every later participant-gated action +/// reuses"; it had no call sites and has been removed rather than left to +/// imply a check that does not happen. + +namespace polls { + +/// @brief One scheduling poll: its options, votes, comments, and event log, +/// backed by SQLite via Lightweight. Keyed by `pollId` -- see the +/// `BRIDGE_MODEL_KEY` declaration below. +/// +/// Holds no database state itself: each `execute()` acquires a +/// `Lightweight::GlobalDataMapperPool()` connection for its own duration and +/// returns it before returning, rather than owning a connection for its own +/// lifetime -- including while this instance is shared across every +/// participant of the same poll (`AllowShared`, below): dispatched calls +/// against a shared instance are still serialized one at a time on its own +/// strand, so no two `execute()` calls ever contend for one acquisition. +class PollModel { + public: + /// @brief Creates a poll with its candidate options. + /// @param action Title and 2-20 bounded-label options. + /// @return The generated `pollId`/`adminToken`/`participantToken`. + CreatePollResult execute(const CreatePoll& action); + + /// @brief Attaches this handler to the poll named by `action.pollId` and + /// returns its full current state. The keyed attach action -- + /// `BRIDGE_MODEL_KEY(PollModel, OpenPoll, &OpenPoll::pollId)`. + /// @param action The poll's shareable link id. + /// @return The poll's full current state. + GetPollStateResult execute(const OpenPoll& action); + + /// @brief Returns the current state of the poll this handler was last + /// attached to via `execute(OpenPoll)`. + /// @param action Carries no fields of its own. + /// @return The poll's full current state. + GetPollStateResult execute(const GetPollState& action); + + /// @brief First-time vote submission for `action.participantName` against + /// this handler's attached poll. Idempotent on retry: a duplicate + /// submission for the same participant is a replace, not a second + /// set of rows (`applyVotes()`'s delete-then-recreate, backed by + /// `votes`' own `(poll, participantName, option)` unique index -- + /// see `poll_entity.hpp`). + /// @param action The participant's display name and full vote set. + /// @return The freshly-rebuilt state of this handler's attached poll. + /// @throws ValidationError if `action.validate()` rejects the input. + /// @throws Conflict if the poll is already finalized. + GetPollStateResult execute(const SubmitVotes& action); + + /// @brief Replaces `action.participantName`'s votes wholesale against + /// this handler's attached poll. Same underlying write as + /// `execute(SubmitVotes)` (both go through `applyVotes()`) -- + /// kept as a distinct action only so the event log records + /// "updated votes" rather than "submitted votes". + /// @param action The participant's display name and full new vote set. + /// @return The freshly-rebuilt state of this handler's attached poll. + /// @throws ValidationError if `action.validate()` rejects the input. + /// @throws Conflict if the poll is already finalized. + GetPollStateResult execute(const UpdateVotes& action); + + /// @brief Adds one comment to this handler's attached poll. Writes no + /// `VoteHistoryRecord` -- comments are not undoable (only vote + /// *changes* are, matching `UndoLastVoteChange`'s own name). + /// @param action The participant's display name and comment body. + /// @return The freshly-rebuilt state of this handler's attached poll. + /// @throws ValidationError if `action.validate()` rejects the input. + /// @throws Conflict if the poll is already finalized (finalizing makes + /// a poll read-only -- see `FinalizePoll`'s own doc comment). + GetPollStateResult execute(const AddComment& action); + + /// @brief Admin-token-gated state transition: marks this handler's + /// attached poll finalized with `action.optionId` as the winning + /// option. Makes the poll read-only for every future write (see + /// `SubmitVotes`/`UpdateVotes`/`AddComment`'s own `Conflict` + /// checks). The caller must present the poll's own admin token + /// in `session::current()->token` -- checked via `requireAdmin()` + /// **before** the poll's `finalized` state is even inspected, so + /// a caller with no token or the wrong (e.g. participant) token + /// learns nothing about whether the poll happens to already be + /// finalized (see this method's `.cpp` doc comment for why the + /// ordering matters). + /// @param action The winning option's id. + /// @return The freshly-rebuilt state of this handler's attached poll, + /// with `finalized == Finalized::Yes` and `finalizedOptionId` set. + /// @throws ValidationError if `action.validate()` rejects the input. + /// @throws Forbidden if the caller's token is not this poll's admin token. + /// @throws Conflict if the poll is already finalized. + GetPollStateResult execute(const FinalizePoll& action); + + /// @brief Reverses `action.participantName`'s own most recent vote + /// change against this handler's attached poll -- a + /// principal-scoped **compensating action**, not + /// `SessionLog::undoLast()` (see this rung's README, resolved + /// design decision 3, and this method's own `.cpp` doc comment + /// for the headline design record this task exists to produce). + /// Reads `db::VoteHistoryRecord`'s most recent row for + /// `(pollId, action.participantName)`, restores the vote set it + /// captured via the same delete-then-recreate write `applyVotes()` + /// (Task 6) already implements -- passing `WriteHistory::No` so + /// the restore itself writes no new history row -- then deletes + /// that one consumed row inside the very same transaction as the + /// restore write: undo is one-shot, not a redo stack, and there is + /// no window where the restore is committed but the consumed row + /// (or a spurious new one) still exists. + /// @param action The participant whose own most recent vote change is undone. + /// @return `.restored == Restored::Yes` on success (`Conflict` is thrown + /// instead of ever returning `Restored::No` -- see the field's + /// own doc comment in `vote_dto.hpp`). + /// @throws ValidationError if `action.validate()` rejects the input. + /// @throws NotFound if this handler was never attached via `OpenPoll`. + /// @throws Conflict if `action.participantName` has no vote-history entry + /// left to undo for this poll (never voted, or already undone). + /// @throws Conflict if the poll is already finalized. + UndoLastVoteChangeResult execute(const UndoLastVoteChange& action); + + /// @brief Lists every `PollEvent` recorded for this handler's attached + /// poll strictly after @p action.lastEventId -- the Zulip-pattern + /// event log's read side. `action.lastEventId == PollEventId{}` + /// (its default) means "from the beginning": `poll_events.id` is a + /// SQLite `ServerSideAutoIncrement` primary key, which starts at 1, + /// so `WHERE id > 0` already matches every row with no special + /// case needed. Oldest-first, ascending by id -- the opposite + /// direction and full-result-set counterpart of `buildState()`'s + /// own `lastEvent` lookup (`.OrderBy(id, DESCENDING).First()`), + /// which this method mirrors for its query shape + /// (`Where(poll=...).Where(id > ...)`) but not its ordering or + /// cardinality. + /// + /// Durable persistence alone closes the Zulip-pattern gap this + /// rung's README documents as design decision 2: the event log + /// survives this handler's own destruction/rebirth (a fresh + /// `PollModel` reading the same `poll_events` table sees every row + /// a now-gone instance wrote), and a stale cursor simply gets + /// every real event since it -- no epoch token needed, because the + /// table-wide autoincrement `id` never resets or repeats across + /// instance lifetimes. + /// @param action Carries `lastEventId`, the caller's cursor. + /// @return Every event with `id > action.lastEventId`, oldest first. + /// @throws ValidationError if `action.validate()` rejects the input. + /// @throws NotFound if this handler was never attached via `OpenPoll`. + GetEventsSinceResult execute(const GetEventsSince& action); + + private: + /// @brief Throws `Forbidden` unless `session::current()->token` equals + /// @p adminToken. Takes the already-decoded token rather than a + /// `db::PollRecord&` deliberately: the entity is an + /// implementation detail of this TU (this header exposes only + /// DTOs -- see `pastebin::PasteModel`'s identical `paste_model.hpp` + /// precedent), so callers in `poll_model.cpp` pass + /// `AdminToken{textOf(poll.adminToken.Value())}`. + /// @param adminToken The poll's stored admin token, decoded to text and + /// wrapped in its own opaque newtype (`dto/poll_dto.hpp`) so a + /// `ParticipantToken` can never be passed here by mistake. + void requireAdmin(const AdminToken& adminToken) const; + + /// @brief Whether `applyVotes()` should append a `VoteHistoryRecord` + /// capturing the pre-change vote set it is about to replace. + /// + /// A strong type instead of a bare `bool` so call sites read as + /// intent (`WriteHistory::No`) rather than an unexplained `false` + /// -- same convention as `morph::model::Loggable` + /// (`morph/core/registry.hpp`). + /// + /// `SubmitVotes`/`UpdateVotes` pass `WriteHistory::Yes`: their + /// history row is `UndoLastVoteChange`'s normal data source. + /// `execute(UndoLastVoteChange)`'s own restore call passes + /// `WriteHistory::No` -- writing a history row for a restore + /// would let a second undo call "undo the undo", turning a + /// one-shot compensating action into an unbounded ping-pong. + enum class WriteHistory : std::uint8_t { No, Yes }; + + /// @brief Shared body of `execute(SubmitVotes)`/`execute(UpdateVotes)`/ + /// `execute(UndoLastVoteChange)`: loads this handler's attached + /// poll, throws `Conflict` if it is finalized, then -- inside one + /// transaction -- deletes @p participantName's prior vote rows + /// for this poll (if any), writes one fresh `VoteRecord` per + /// @p votes entry, appends a `VoteHistoryRecord` capturing the + /// pre-change vote set if @p writeHistory is `WriteHistory::Yes`, + /// deletes the `VoteHistoryRecord` row named by + /// @p historyRowIdToDelete if set, and appends a + /// `PollEventRecord` whose summary embeds @p summaryVerb -- + /// all inside that same one transaction, which is exactly why + /// @p historyRowIdToDelete exists as a parameter here rather than + /// being deleted by the caller afterward: it lets + /// `execute(UndoLastVoteChange)` fold its own history-row cleanup + /// into this same commit instead of opening a second transaction + /// that could fail independently, after the restore has already + /// landed. Takes only DTO-shaped/primitive parameters, never a + /// `db::PollRecord&`/`db::VoteHistoryRecord&` -- this header + /// exposes only DTOs (see the file comment). + /// @param participantName The (unauthenticated) participant's display name. + /// @param votes The participant's full new vote set -- replaces, never merges. + /// @param summaryVerb Event-summary verb distinguishing the callers: + /// `"submitted votes"` for `SubmitVotes`, `"updated votes"` for + /// `UpdateVotes`, `"undid their last vote change"` for + /// `UndoLastVoteChange`. + /// @param writeHistory Whether to append a fresh `VoteHistoryRecord` for + /// this write. See `WriteHistory`'s own doc comment above. + /// @param historyRowIdToDelete If set, the primary-key id of one + /// `VoteHistoryRecord` row to delete inside this same transaction + /// -- `execute(UndoLastVoteChange)` passes the id of the history + /// row it just consumed, so the restore write and that row's + /// deletion commit together or not at all. + /// @return The freshly-rebuilt state of this handler's attached poll. + /// @throws Conflict if the poll is already finalized. + GetPollStateResult applyVotes(const std::string& participantName, const std::vector& votes, + const std::string& summaryVerb, WriteHistory writeHistory, + std::optional historyRowIdToDelete = std::nullopt); + + /// @brief The poll this handler is attached to, cached on the first + /// successful `execute(OpenPoll)`. Unset until then -- reading it + /// from `execute(GetPollState)` before any `OpenPoll` attach is a + /// caller error (see that method's `.cpp` doc comment). + std::optional _pollId; +}; + +} // namespace polls + +BRIDGE_REGISTER_MODEL(polls::PollModel, "PollModel") +BRIDGE_REGISTER_ACTION(polls::PollModel, polls::CreatePoll, "CreatePoll") +BRIDGE_REGISTER_ACTION(polls::PollModel, polls::OpenPoll, "OpenPoll", ::morph::model::Loggable::No) +BRIDGE_REGISTER_ACTION(polls::PollModel, polls::GetPollState, "GetPollState", ::morph::model::Loggable::No) +BRIDGE_REGISTER_ACTION(polls::PollModel, polls::SubmitVotes, "SubmitVotes") +BRIDGE_REGISTER_ACTION(polls::PollModel, polls::UpdateVotes, "UpdateVotes") +BRIDGE_REGISTER_ACTION(polls::PollModel, polls::AddComment, "AddComment") +BRIDGE_REGISTER_ACTION(polls::PollModel, polls::FinalizePoll, "FinalizePoll") +BRIDGE_REGISTER_ACTION(polls::PollModel, polls::UndoLastVoteChange, "UndoLastVoteChange") +BRIDGE_REGISTER_ACTION(polls::PollModel, polls::GetEventsSince, "GetEventsSince", ::morph::model::Loggable::No) + +// PollModel is keyed by OpenPoll::pollId -- deferred from Task 3 to here per +// that task's own review (matching docs/spec/core/shared_instances.md's +// worked example and examples/bank's two keyed-model precedents, +// account_model.hpp/customer_model.hpp, both placing this macro immediately +// after the model's own BRIDGE_REGISTER_MODEL/BRIDGE_REGISTER_ACTION block). +BRIDGE_MODEL_KEY(polls::PollModel, polls::OpenPoll, &polls::OpenPoll::pollId); diff --git a/examples/polls/include/polls/units.hpp b/examples/polls/include/polls/units.hpp new file mode 100644 index 00000000..c0fd1773 --- /dev/null +++ b/examples/polls/include/polls/units.hpp @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +/// @file +/// Polls' one-unit system: a dimensionless count, reused for every +/// vote tally in a poll (yes/no/ifNeedBe counts per option). +/// Modeled on `bookmarks/units.hpp` — see that file for the full +/// UnitTraits/consteval-algebra contract this mirrors; this rung needs no +/// unit algebra either, for the same reason. + +namespace polls { + +/// @brief Units polls works in. +enum class Unit { + count, ///< dimensionless whole-number count +}; + +} // namespace polls + +/// @brief Static unit metadata: schema id, display text, default decimals. +template <> +struct morph::units::UnitTraits { + static constexpr morph::units::UnitMeta meta(polls::Unit unit) noexcept { + switch (unit) { + case polls::Unit::count: + return {"count", "", 1}; + default: + return {"?", "?", 1}; + } + } +}; + +namespace polls { + +/// @brief A whole-number count (vote tallies in poll results). +/// +/// `morph::units::Quantity` requires `DeclaredDecimals +/// >= 1` (zero is not legal); every value that ever appears is a whole +/// number by construction. See `bookmarks::Count`'s identical pattern. +using Count = ::morph::units::Quantity; + +} // namespace polls diff --git a/examples/polls/src/app/app.cpp b/examples/polls/src/app/app.cpp new file mode 100644 index 00000000..c30f8752 --- /dev/null +++ b/examples/polls/src/app/app.cpp @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "polls/app/app.hpp" + +// This rung registers exactly one model type. `BRIDGE_REGISTER_MODEL`/ +// `BRIDGE_REGISTER_ACTION` (poll_model.hpp) place their registrars in the +// *header*, so a translation unit that includes it both registers +// "PollModel" with the process-wide registry/dispatcher and emits a +// reference to `PollModel::execute`'s bodies -- which is what pulls +// PollModel's object file out of a static library for a binary (a server +// `main()`) whose own code names nothing but `App`. Without this include, +// such a binary would either fail to link or come up serving no models at +// all -- identical rationale to `bookmarks::app::App`'s own model includes +// (`examples/bookmarks/src/app/app.cpp`), just for one model instead of +// four. +#include "polls/models/poll_model.hpp" + +#include + +namespace polls::app { + +namespace { + +/// @brief Live-instance cap this server installs. +/// +/// This rung's `authorizeRegister` is unconditionally permissive by design +/// (the framework can now gate registration on identity -- register/attach +/// envelopes carry the caller's session -- but polls' attach-by-id model +/// deliberately doesn't use it), so an unauthenticated client *can* make the +/// server create model instances even though `PollModel::execute()`'s own +/// admin/participant checks still gate every state-changing call on them -- +/// `auth::PollsAuthorizer` leaves both `authorize()` and its two +/// instance-lifecycle hooks permissive by design (see that file's own +/// `@file` comment). `maxLiveModels` is the framework's own answer to that +/// shape of churn: past the cap a +/// `register`/keyed-attach is answered `err "too many models"` and no +/// instance is constructed. +/// +/// This rung registers exactly one model type, `PollModel`, shared/keyed by +/// `pollId` (`BRIDGE_MODEL_KEY`, `poll_model.hpp`) -- unlike bookmarks' +/// per-client-owned instances, one live `PollModel` instance is shared by +/// every participant currently viewing that poll, so the relevant count +/// here is concurrent *polls with at least one attached viewer*, not +/// concurrent clients. `256` is generous relative to that: it matches +/// rung 2's own cap (`bookmarks::app::kMaxLiveModels`, +/// `examples/bookmarks/src/app/app.cpp`) chosen for a comparable +/// single-server-instance shape, and is far beyond the concurrency this +/// rung's own harness (a handful of simulated participants converging on +/// one shared poll, `examples/polls/README.md`) ever exercises at once. +constexpr std::size_t kMaxLiveModels = 256; + +} // namespace + +App::App(std::filesystem::path actionLogPath, std::size_t workers) + : _actionLog{std::make_shared<::morph::journal::FileActionLog>(std::move(actionLogPath))}, + _pool{workers}, + _server{std::make_shared<::morph::backend::RemoteServer>(_pool, std::make_shared())} { + ::morph::journal::setActionLog(_actionLog); + + ::morph::backend::LimitPolicy limits; + limits.maxLiveModels = kMaxLiveModels; + _server->setLimitPolicy(limits); +} + +App::~App() { + // Matches setActionLog's own clear-on-destruction discipline + // (`bookmarks::app::App`/`pastebin::app::App`'s identical `~App`): a + // later test (or a second App in the same process) must see the action + // log cleared rather than a previous App's still-live instance. + ::morph::journal::setActionLog(nullptr); +} + +} // namespace polls::app diff --git a/examples/polls/src/auth/polls_authorizer.cpp b/examples/polls/src/auth/polls_authorizer.cpp new file mode 100644 index 00000000..de1669aa --- /dev/null +++ b/examples/polls/src/auth/polls_authorizer.cpp @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "polls/auth/polls_authorizer.hpp" + +namespace polls::auth { + +bool PollsAuthorizer::authorizeRegister(const ::morph::session::Context& /*ctx*/, + std::string_view /*modelType*/) const { + return true; +} + +bool PollsAuthorizer::authorizeInstance(const ::morph::session::Context& /*ctx*/, std::string_view /*modelType*/, + std::string_view /*actionType*/, std::uint64_t /*modelId*/, + std::string_view /*ownerPrincipal*/) const { + return true; +} + +} // namespace polls::auth diff --git a/examples/polls/src/db/schema.cpp b/examples/polls/src/db/schema.cpp new file mode 100644 index 00000000..33f945b1 --- /dev/null +++ b/examples/polls/src/db/schema.cpp @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "polls/db/database.hpp" + +#include +#include +#include + +namespace polls::db { + +void setup(const std::string& connectionString) { + Lightweight::SqlConnection::SetDefaultConnectionString(Lightweight::SqlConnectionString{connectionString}); + Lightweight::SqlMigration::MigrationManager::GetInstance().CreateMigrationHistory(); + Lightweight::SqlMigration::MigrationManager::GetInstance().ApplyPendingMigrations(); +} + +} // namespace polls::db + +// ─── Schema migration ──────────────────────────────────────────────────────── +// LIGHTWEIGHT_SQL_MIGRATION auto-registers with the MigrationManager at +// static-init time; linking this TU into the binary makes the schema known. +// All six tables (`poll_entity.hpp`) are created in one migration, in +// dependency order, matching bookmarks' own single-migration schema.cpp. + +using namespace Lightweight::SqlColumnTypeDefinitions; + +LIGHTWEIGHT_SQL_MIGRATION(20260808000001, "Create polls tables") { + plan.CreateTableIfNotExists("polls") + .PrimaryKeyWithAutoIncrement("id", Bigint()) + .RequiredColumn("poll_id", Varchar(22)) + .RequiredColumn("admin_token", Varchar(22)) + .RequiredColumn("participant_token", Varchar(22)) + .RequiredColumn("title", Varchar(200)) + .RequiredColumn("finalized", Bool()) + .RequiredColumn("finalized_option_id", Bigint()) + .RequiredColumn("created_at_ms", Bigint()); + // pollId is the shareable link id and both tokens gate admin/participant + // actions (Task 5+) -- all three must be looked up by exact value alone. + plan.CreateUniqueIndex("idx_polls_poll_id", "polls", {"poll_id"}); + plan.CreateUniqueIndex("idx_polls_admin_token", "polls", {"admin_token"}); + plan.CreateUniqueIndex("idx_polls_participant_token", "polls", {"participant_token"}); + + const auto pollsRef = Lightweight::SqlForeignKeyReferenceDefinition{.tableName = "polls", .columnName = "id"}; + + plan.CreateTableIfNotExists("poll_options") + .PrimaryKeyWithAutoIncrement("id", Bigint()) + .RequiredForeignKey("poll_id", Bigint(), pollsRef) + .RequiredColumn("label", Varchar(100)) + .RequiredColumn("sort_order", Bigint()); + // GetPollState (Task 5) lists every option for a poll. + plan.CreateIndex("idx_poll_options_poll", "poll_options", {"poll_id"}); + + const auto optionsRef = Lightweight::SqlForeignKeyReferenceDefinition{.tableName = "poll_options", .columnName = "id"}; + + plan.CreateTableIfNotExists("votes") + .PrimaryKeyWithAutoIncrement("id", Bigint()) + .RequiredForeignKey("poll_id", Bigint(), pollsRef) + .RequiredForeignKey("option_id", Bigint(), optionsRef) + .RequiredColumn("participant_name", Varchar(80)) + .RequiredColumn("choice", Tinyint()); + // A participant may cast exactly one current vote per option -- this is + // what makes a retried SubmitVotes (Task 6) idempotent rather than a + // duplicate row. + plan.CreateUniqueIndex("idx_votes_poll_participant_option", "votes", {"poll_id", "participant_name", "option_id"}); + + plan.CreateTableIfNotExists("comments") + .PrimaryKeyWithAutoIncrement("id", Bigint()) + .RequiredForeignKey("poll_id", Bigint(), pollsRef) + .RequiredColumn("participant_name", Varchar(80)) + .RequiredColumn("body", Varchar(500)) + .RequiredColumn("created_at_ms", Bigint()); + plan.CreateIndex("idx_comments_poll", "comments", {"poll_id"}); + + plan.CreateTableIfNotExists("vote_history") + .PrimaryKeyWithAutoIncrement("id", Bigint()) + .RequiredForeignKey("poll_id", Bigint(), pollsRef) + .RequiredColumn("participant_name", Varchar(80)) + .RequiredColumn("previous_votes_json", NVarchar(0)) + .RequiredColumn("created_at_ms", Bigint()); + // UndoLastVoteChange (Task 8) looks up the calling participant's most + // recent row for this poll. + plan.CreateIndex("idx_vote_history_poll", "vote_history", {"poll_id"}); + + plan.CreateTableIfNotExists("poll_events") + .PrimaryKeyWithAutoIncrement("id", Bigint()) + .RequiredForeignKey("poll_id", Bigint(), pollsRef) + .RequiredColumn("kind", Varchar(32)) + .RequiredColumn("summary", NVarchar(0)) + .RequiredColumn("created_at_ms", Bigint()); + // GetEventsSince (Task 9) lists every event for a poll after a cursor. + plan.CreateIndex("idx_poll_events_poll", "poll_events", {"poll_id"}); +} diff --git a/examples/polls/src/models/poll_model.cpp b/examples/polls/src/models/poll_model.cpp new file mode 100644 index 00000000..f08e9cb5 --- /dev/null +++ b/examples/polls/src/models/poll_model.cpp @@ -0,0 +1,740 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "polls/models/poll_model.hpp" + +// The entity is an implementation detail of this TU: `poll_model.hpp` exposes +// only DTOs, so nothing outside this file ever sees `db::PollRecord` -- see +// `pastebin::PasteModel`'s identical `paste_model.cpp` precedent. +#include "polls/db/poll_entity.hpp" + +// examples/common is on the include path as a root (see +// examples/common/CMakeLists.txt's target_include_directories), so the ladder +// clock is "clock.hpp" -- the same spelling testkit/test_clock.cpp uses. +#include "clock.hpp" + +#include + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace polls { + +// The one place each free-form text field's DTO-level bound and its real +// storage-column capacity are checked against each other -- see +// `pastebin::kMaxSyntaxBytes`'s identical `static_assert` (`paste_model.cpp`) +// for the two harms this guards against (a widened column silently +// outrunning the DTO's own reject-if-too-long check, or the reverse: +// `CreatePoll`/`SubmitVotes`/`AddComment`'s `validate()` rejecting input that +// would actually have fit). `PollEventRecord::kind`/`summary` have no DTO-level +// constant to pin against (see poll_entity.hpp's doc comments on both) and so +// are not asserted here. +static_assert(decltype(db::PollRecord::title)::ValueType{}.capacity() == kMaxTitleBytes, + "polls::kMaxTitleBytes must equal PollRecord::title's SqlAnsiString capacity -- otherwise " + "CreatePoll either rejects a title that would have fit, or accepts one that gets silently " + "truncated on the way into the row."); +static_assert(decltype(db::OptionRecord::label)::ValueType{}.capacity() == kMaxOptionLabelBytes, + "polls::kMaxOptionLabelBytes must equal OptionRecord::label's SqlAnsiString capacity -- otherwise " + "CreatePoll either rejects an option label that would have fit, or accepts one that gets silently " + "truncated on the way into the row."); +static_assert(decltype(db::VoteRecord::participantName)::ValueType{}.capacity() == kMaxParticipantNameBytes, + "polls::kMaxParticipantNameBytes must equal VoteRecord::participantName's SqlAnsiString capacity -- " + "otherwise SubmitVotes/UpdateVotes either reject a participantName that would have fit, or accept " + "one that gets silently truncated on the way into the row."); +static_assert(decltype(db::CommentRecord::participantName)::ValueType{}.capacity() == kMaxParticipantNameBytes, + "polls::kMaxParticipantNameBytes must equal CommentRecord::participantName's SqlAnsiString capacity " + "-- otherwise AddComment either rejects a participantName that would have fit, or accepts one that " + "gets silently truncated on the way into the row."); +static_assert(decltype(db::VoteHistoryRecord::participantName)::ValueType{}.capacity() == kMaxParticipantNameBytes, + "polls::kMaxParticipantNameBytes must equal VoteHistoryRecord::participantName's SqlAnsiString " + "capacity -- otherwise applyVotes() either rejects a participantName that would have fit, or " + "accepts one that gets silently truncated on the way into the row."); +static_assert(decltype(db::CommentRecord::body)::ValueType{}.capacity() == kMaxCommentBytes, + "polls::kMaxCommentBytes must equal CommentRecord::body's SqlAnsiString capacity -- otherwise " + "AddComment either rejects a body that would have fit, or accepts one that gets silently truncated " + "on the way into the row."); + +namespace { + +// --------------------------------------------------------------------------- +// SqlAnsiString <-> std::string conversion, mirroring +// pastebin::textOf() (examples/pastebin/src/models/paste_model.cpp) exactly: +// `pollId`/`adminToken`/`participantToken` are `Light::SqlAnsiString`- +// typed columns (fixed after Task 4's own review found no sibling entity +// justification for plain std::string on an id/token-shaped field), so every +// read of one of these three fields goes through this helper and every write +// goes through the equivalent `Light::SqlAnsiString{...}` +// construction at the call site. +// --------------------------------------------------------------------------- +[[nodiscard]] std::string textOf(const Light::SqlAnsiString& stored) { + return std::string{stored.str()}; +} + +// --------------------------------------------------------------------------- +// Free-form Unicode text field conversions (title/label/participantName/body): +// every one of these entity columns is `Light::SqlAnsiString`, +// bounded to the same constant `CreatePoll`/`SubmitVotes`/`AddComment` +// (`polls/dto/poll_dto.hpp`, `polls/dto/vote_dto.hpp`) already validate +// against at the DTO boundary -- the static_asserts right below pin entity +// capacity and DTO bound together so a future change to one without the +// other fails the build (see pastebin::PasteModel's `kMaxSyntaxBytes` +// static_assert, `paste_model.cpp`, for the identical pattern). All four +// share this one conversion pair since all four are the same +// `SqlAnsiString`-shaped case, just with different `N`. +// --------------------------------------------------------------------------- +template +[[nodiscard]] std::string textOf(const Light::SqlAnsiString& stored) { + return std::string{stored.str()}; +} + +// `previousVotesJson`/`summary` are `Light::SqlMaxDynamicAnsiString` (no +// fixed bound -- see poll_entity.hpp's doc comments on each), so they get +// their own conversion pair rather than the templated `textOf()` above. +[[nodiscard]] std::string textOf(const Light::SqlMaxDynamicAnsiString& stored) { + return stored.ToString(); +} + +/// @brief The injectable-time convention rung 1/2 established +/// (`examples/bookmarks/src/models/bookmark_model.cpp`, +/// `examples/pastebin/src/models/paste_model.cpp`): a private, +/// per-TU helper reading `morph::ladder::now()`, never exported. +[[nodiscard]] std::int64_t nowMs() noexcept { + return (*::morph::ladder::now().value).value.time_since_epoch().count(); +} + +/// @brief Number of raw random bytes base64url-encoded (without padding) +/// into a `kTokenBytes`-long token. See `kTokenBytes`'s own doc +/// comment (`polls/core/types.hpp`) for why 16 bytes -> 22 chars. +constexpr std::size_t kRandomTokenBytes = 16; +static_assert((kRandomTokenBytes * 8 + 5) / 6 == kTokenBytes, + "polls::kTokenBytes must equal the base64url-without-padding length of " + "kRandomTokenBytes random bytes -- keeps CreatePoll's generated pollId/" + "adminToken/participantToken length matching the documented contract in " + "core/types.hpp."); + +/// @brief A cryptographically-unguessable `pollId`/admin-or-participant +/// token: `kRandomTokenBytes` bytes drawn directly from +/// `std::random_device` (never used merely to seed a deterministic +/// PRNG, and never `std::rand()`/a time-seeded generator) and +/// base64url-encoded without padding. Unlike pastebin's +/// `randomPasteId()` (a deliberately small, collidable, human-typo- +/// tolerant keyspace) or bank's card-number generator, these three +/// tokens ARE the entire security boundary for admin/participant +/// identity in this rung (see the plan's Global Constraints and the +/// rung README's resolved design decision 1) -- there is no signed +/// `SigningAuthorizer` token backing them up, only a bare secret +/// compared directly against the poll row's own stored columns, so +/// the byte source itself must be a real entropy source, not a +/// seeded-once convenience PRNG. +[[nodiscard]] std::string randomToken() { + static constexpr char kAlphabet[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + + std::random_device rd; + std::uniform_int_distribution byteDist{0, 255}; + std::array bytes{}; + for (auto& b : bytes) { + b = static_cast(byteDist(rd)); + } + + std::string out; + out.reserve(kTokenBytes); + for (std::size_t i = 0; i < bytes.size(); i += 3) { + std::uint32_t chunk = static_cast(bytes[i]) << 16; + int chunkBytes = 1; + if (i + 1 < bytes.size()) { + chunk |= static_cast(bytes[i + 1]) << 8; + chunkBytes = 2; + } + if (i + 2 < bytes.size()) { + chunk |= static_cast(bytes[i + 2]); + chunkBytes = 3; + } + out.push_back(kAlphabet[(chunk >> 18) & 0x3FU]); + out.push_back(kAlphabet[(chunk >> 12) & 0x3FU]); + if (chunkBytes >= 2) { + out.push_back(kAlphabet[(chunk >> 6) & 0x3FU]); + } + if (chunkBytes >= 3) { + out.push_back(kAlphabet[chunk & 0x3FU]); + } + } + return out; +} + +/// @brief Loads the poll named by @p pollId, or throws `NotFound`. +[[nodiscard]] db::PollRecord loadPollByPollId(::Lightweight::DataMapper& mapper, const std::string& pollId) { + auto rows = + mapper.Query().Where(::Lightweight::FieldNameOf<&db::PollRecord::pollId>, "=", pollId).All(); + if (rows.empty()) { + throw NotFound{"poll not found"}; + } + return std::move(rows.front()); +} + +/// @brief Confirms @p optionId names a real option row belonging to @p poll +/// -- not merely a row that exists *somewhere* in `poll_options`. +/// +/// `VoteRecord::option`/`PollRecord::finalizedOptionId` are FK-shaped but not +/// FK-enforced (SQLite; see `poll_entity.hpp`'s own note on this), so the +/// database alone never rejects an option id that belongs to a *different* +/// poll. Without this check, `FinalizePoll` could finalize with an option +/// nothing in this poll's own option list matches, and a vote naming another +/// poll's option would be written but never counted by `buildState()`'s +/// per-option tally loop (which only matches votes against options loaded +/// for `pollDbId`) -- silently discarding the participant's vote instead of +/// rejecting it. +/// @param mapper The active `DataMapper`. +/// @param poll The poll @p optionId is claimed to belong to. +/// @param optionId The option id to verify. +/// @throws NotFound if no option row with that id exists under this poll. +void requireOptionBelongsToPoll(::Lightweight::DataMapper& mapper, const db::PollRecord& poll, OptionId optionId) { + if (optionId.value < 0) { + throw NotFound{"option does not belong to this poll"}; + } + auto rows = mapper.Query() + .Where(::Lightweight::FieldNameOf<&db::OptionRecord::id>, "=", + static_cast(optionId.value)) + .Where(::Lightweight::FieldNameOf<&db::OptionRecord::poll>, "=", poll.id.Value()) + .All(); + if (rows.empty()) { + throw NotFound{"option does not belong to this poll"}; + } +} + +/// @brief Builds the full state view sent back to a client from a loaded +/// `PollRecord`: its options (with tallies), every vote, every +/// comment, and the id of the most recent event (a fresh client's +/// starting cursor for `GetEventsSince`). +[[nodiscard]] GetPollStateResult buildState(::Lightweight::DataMapper& mapper, const db::PollRecord& poll) { + GetPollStateResult result; + result.pollId = textOf(poll.pollId.Value()); + result.title = textOf(poll.title.Value()); + result.finalized = poll.finalized.Value() ? Finalized::Yes : Finalized::No; + if (result.finalized == Finalized::Yes) { + result.finalizedOptionId = OptionId{.value = poll.finalizedOptionId.Value()}; + } + + const std::uint64_t pollDbId = poll.id.Value(); + auto options = mapper.Query() + .Where(::Lightweight::FieldNameOf<&db::OptionRecord::poll>, "=", pollDbId) + .OrderBy(::Lightweight::FieldNameOf<&db::OptionRecord::sortOrder>) + .All(); + auto votes = mapper.Query() + .Where(::Lightweight::FieldNameOf<&db::VoteRecord::poll>, "=", pollDbId) + .All(); + for (const auto& opt : options) { + PollOptionView view; + view.id = OptionId{.value = static_cast(opt.id.Value())}; + view.label = textOf(opt.label.Value()); + // Explicit zero, not default-constructed: a default `Count{}` is + // Quantity's *empty* state (no payload), and Quantity arithmetic + // propagates empty (empty + fromDouble(1.0) == empty, forever) -- + // see morph/util/quantity.hpp's own "Arithmetic. Empty propagates" + // doc comment. Without this, no option's tally could ever leave + // empty no matter how many votes matched below. Task 5 never caught + // this because its own tests never exercised a poll with actual + // votes; Task 6's SubmitVotes/UpdateVotes tests are what surfaced it. + view.yesCount = Count::fromDouble(0.0); + view.ifNeedBeCount = Count::fromDouble(0.0); + view.noCount = Count::fromDouble(0.0); + for (const auto& vote : votes) { + if (vote.option.Value() != opt.id.Value()) { + continue; + } + switch (static_cast(vote.choice.Value())) { + case VoteChoice::Yes: + view.yesCount = view.yesCount + Count::fromDouble(1.0); + break; + case VoteChoice::IfNeedBe: + view.ifNeedBeCount = view.ifNeedBeCount + Count::fromDouble(1.0); + break; + case VoteChoice::No: + view.noCount = view.noCount + Count::fromDouble(1.0); + break; + default: + break; + } + result.votes.push_back({.participantName = textOf(vote.participantName.Value()), + .optionId = view.id, + .choice = static_cast(vote.choice.Value())}); + } + result.options.push_back(std::move(view)); + } + + auto comments = mapper.Query() + .Where(::Lightweight::FieldNameOf<&db::CommentRecord::poll>, "=", pollDbId) + .All(); + for (const auto& c : comments) { + result.comments.push_back({.participantName = textOf(c.participantName.Value()), .body = textOf(c.body.Value())}); + } + + auto lastEvent = mapper.Query() + .Where(::Lightweight::FieldNameOf<&db::PollEventRecord::poll>, "=", pollDbId) + .OrderBy(::Lightweight::FieldNameOf<&db::PollEventRecord::id>, + ::Lightweight::SqlResultOrdering::DESCENDING) + .First(); + result.lastEventId = + lastEvent ? PollEventId{.value = static_cast(lastEvent->id.Value())} : PollEventId{}; + return result; +} + +/// @brief Encodes @p votes as JSON for `VoteHistoryRecord::previousVotesJson`. +/// `std::vector` is a plain aggregate of plain aggregates +/// (`OptionId` already has its own `glz::meta`), so Glaze reflects it +/// with no `glz::meta` specialization of its own -- the same +/// automatic reflection `BRIDGE_REGISTER_ACTION` relies on for user +/// action structs. +/// @throws PollsError on encode failure (structurally unreachable for this +/// flat a shape -- see `morph::journal::detail::throwOnGlazeError`'s +/// identical rationale for `LogEntry`, `morph/journal/action_log.hpp`). +[[nodiscard]] std::string encodeVotesJson(const std::vector& votes) { + std::string out; + if (auto errCode = glz::write_json(votes, out); errCode) { + throw PollsError{glz::format_error(errCode, out)}; + } + return out; +} + +/// @brief The symmetric decode of `encodeVotesJson()` above, for +/// `UndoLastVoteChange` (Task 8) to reconstitute a +/// `VoteHistoryRecord::previousVotesJson` payload back into the vote +/// set `applyVotes()` can restore. +/// @throws PollsError on decode failure -- structurally unreachable in +/// practice (the only writer of this column is `encodeVotesJson()` +/// itself, in this same TU), but a stored value must still be +/// handled like any other fallible parse, not blindly trusted. +[[nodiscard]] std::vector decodeVotesJson(const std::string& json) { + std::vector votes; + if (auto errCode = glz::read_json(votes, json); errCode) { + throw PollsError{glz::format_error(errCode, json)}; + } + return votes; +} + +/// @brief Whether @p a and @p b are equal, comparing every byte regardless +/// of an early mismatch -- unlike `std::string::operator==`/`!=`, +/// which short-circuits at the first differing byte and so leaks how +/// many leading bytes matched through response timing. +/// +/// This is example/demo code whose whole security boundary is already just +/// the bare admin token (see this rung's README, resolved design decision +/// 1), so the practical bar for exploiting a timing side channel here is +/// low -- but every comparison against a secret token should still not be +/// the one place in the codebase that makes that side channel easy. +/// @param a One string to compare. +/// @param b The other string to compare. +/// @return `true` if @p a and @p b hold the same bytes. +[[nodiscard]] bool constantTimeEquals(const std::string& a, const std::string& b) { + if (a.size() != b.size()) { + // The length itself is not treated as secret here (an admin token's + // length is fixed and public -- kTokenBytes -- so this branch never + // executes for a real token of the right length; a caller who sends + // the wrong length learns nothing more than "wrong length", already + // implied by kTokenBytes being a known, documented constant). + return false; + } + unsigned char diff = 0; + for (std::size_t i = 0; i < a.size(); ++i) { + diff |= static_cast(a[i]) ^ static_cast(b[i]); + } + return diff == 0; +} + +} // namespace + +void PollModel::requireAdmin(const AdminToken& adminToken) const { + const auto* ctx = ::morph::session::current(); + if (ctx == nullptr || ctx->token.empty() || !adminToken.hasValue() || + !constantTimeEquals(ctx->token, *adminToken)) { + throw Forbidden{"admin token required"}; + } +} + +CreatePollResult PollModel::execute(const CreatePoll& action) { + if (!action.validate()) { + throw ValidationError{"CreatePoll: a bounded title and 2-20 bounded-label options are required"}; + } + + db::PollRecord poll; + poll.pollId = Light::SqlAnsiString{randomToken()}; + poll.adminToken = Light::SqlAnsiString{randomToken()}; + poll.participantToken = Light::SqlAnsiString{randomToken()}; + poll.title = action.title; + poll.createdAtMs = nowMs(); + + auto mapper = ::Lightweight::GlobalDataMapperPool().Acquire(); + ::Lightweight::SqlTransaction transaction{mapper->Connection(), ::Lightweight::SqlTransactionMode::ROLLBACK}; + mapper->Create(poll); + std::int64_t order = 0; + for (const auto& opt : action.options) { + db::OptionRecord rec; + rec.poll = poll; + rec.label = opt.label; + rec.sortOrder = order++; + mapper->Create(rec); + } + transaction.Commit(); + + return CreatePollResult{.pollId = textOf(poll.pollId.Value()), + .adminToken = AdminToken{textOf(poll.adminToken.Value())}, + .participantToken = ParticipantToken{textOf(poll.participantToken.Value())}}; +} + +GetPollStateResult PollModel::execute(const OpenPoll& action) { + if (!action.validate()) { + throw ValidationError{"OpenPoll: pollId is required"}; + } + auto mapper = ::Lightweight::GlobalDataMapperPool().Acquire(); + db::PollRecord poll = loadPollByPollId(mapper.Get(), action.pollId); + // Cache the pollId once this handler has proven it names a real poll, + // before dispatching to buildState() -- execute(GetPollState) below + // reads this cache to re-derive which poll it is, since GetPollState + // itself carries no pollId of its own (it is dispatched against an + // already-OpenPoll-attached handler). + _pollId = action.pollId; + return buildState(mapper.Get(), poll); +} + +GetPollStateResult PollModel::execute(const GetPollState& /*action*/) { + // GetPollState carries no pollId of its own -- it is dispatched against + // an already-attached handler (attach happens via OpenPoll, the keyed + // action). If this handler was never attached via OpenPoll first, that + // is a caller error: there is no poll to report state for. + if (!_pollId.has_value()) { + throw NotFound{"GetPollState: handler was never attached via OpenPoll"}; + } + auto mapper = ::Lightweight::GlobalDataMapperPool().Acquire(); + return buildState(mapper.Get(), loadPollByPollId(mapper.Get(), *_pollId)); +} + +GetPollStateResult PollModel::applyVotes(const std::string& participantName, const std::vector& votes, + const std::string& summaryVerb, WriteHistory writeHistory, + std::optional historyRowIdToDelete) { + // Both callers (execute(SubmitVotes)/execute(UpdateVotes)) act against + // this handler's attached poll, exactly like execute(GetPollState) -- + // never attached via OpenPoll is a caller error, not a NotFound-worthy + // poll lookup failure. + if (!_pollId.has_value()) { + throw NotFound{"applyVotes: handler was never attached via OpenPoll"}; + } + // One connection for this whole call: the pre-transaction reads below + // inform the transaction's own writes (the prior-votes read in + // particular must see the same data the delete-then-recreate loop + // deletes), so everything here runs against a single acquisition. + auto mapper = ::Lightweight::GlobalDataMapperPool().Acquire(); + db::PollRecord poll = loadPollByPollId(mapper.Get(), *_pollId); + if (poll.finalized.Value()) { + // A vote in flight when FinalizePoll lands must dead-letter with a + // user-visible outcome, not vanish -- Conflict IS that outcome, + // delivered through the caller's .onError(...). + throw Conflict{"poll is finalized"}; + } + + // Validated before any row is touched, not interleaved with the + // delete-then-recreate loop below: a vote naming another poll's option + // must reject the *whole* submission, not delete the participant's prior + // votes and then partially apply the new ones before hitting a bad + // entry. See requireOptionBelongsToPoll's own doc comment for why this + // check exists at all (the DB's own FK is not enforced here). + for (const auto& ov : votes) { + requireOptionBelongsToPoll(mapper.Get(), poll, ov.optionId); + } + + const std::uint64_t pollDbId = poll.id.Value(); + auto priorVotes = mapper + ->Query() + .Where(::Lightweight::FieldNameOf<&db::VoteRecord::poll>, "=", pollDbId) + .Where(::Lightweight::FieldNameOf<&db::VoteRecord::participantName>, "=", participantName) + .All(); + + // Captured before any row is deleted: the *pre-change* vote set is what + // UndoLastVoteChange (Task 8) needs to restore. + std::vector previousVotes; + previousVotes.reserve(priorVotes.size()); + for (const auto& v : priorVotes) { + previousVotes.push_back({.optionId = OptionId{.value = static_cast(v.option.Value())}, + .choice = static_cast(v.choice.Value())}); + } + const std::string previousVotesJson = encodeVotesJson(previousVotes); + + ::Lightweight::SqlTransaction transaction{mapper->Connection(), ::Lightweight::SqlTransactionMode::ROLLBACK}; + + // Delete-then-recreate: replaces the participant's votes wholesale + // rather than diffing old vs. new, so a retried SubmitVotes for the same + // participant (the DoD's own retry scenario) converges on one row per + // option instead of ever risking a duplicate -- backed by + // idx_votes_poll_participant_option's unique index as the last line of + // defense, not the primary mechanism. + for (auto& prior : priorVotes) { + mapper->Delete(prior); + } + for (const auto& ov : votes) { + db::VoteRecord rec; + rec.poll = poll; + rec.option = static_cast(ov.optionId.value); + rec.participantName = participantName; + rec.choice = static_cast(ov.choice); + mapper->Create(rec); + } + + if (writeHistory == WriteHistory::Yes) { + db::VoteHistoryRecord history; + history.poll = poll; + history.participantName = participantName; + history.previousVotesJson = previousVotesJson; + history.createdAtMs = nowMs(); + mapper->Create(history); + } + + // Folded into this same transaction (not deleted by the caller + // afterward) so the restore write and the consumed history row's + // deletion commit together or not at all -- see this method's own doc + // comment (poll_model.hpp) and execute(UndoLastVoteChange)'s call site. + if (historyRowIdToDelete.has_value()) { + auto rowsToDelete = mapper + ->Query() + .Where(::Lightweight::FieldNameOf<&db::VoteHistoryRecord::id>, "=", + *historyRowIdToDelete) + .All(); + for (auto& row : rowsToDelete) { + mapper->Delete(row); + } + } + + db::PollEventRecord event; + event.poll = poll; + event.kind = "vote"; + event.summary = participantName + " " + summaryVerb; + event.createdAtMs = nowMs(); + mapper->Create(event); + + transaction.Commit(); + + return buildState(mapper.Get(), poll); +} + +GetPollStateResult PollModel::execute(const SubmitVotes& action) { + if (!action.validate()) { + throw ValidationError{ + "SubmitVotes: a bounded participantName and at least one vote (with no repeated optionId) are required"}; + } + return applyVotes(action.participantName, action.votes, "submitted votes", WriteHistory::Yes); +} + +GetPollStateResult PollModel::execute(const UpdateVotes& action) { + if (!action.validate()) { + throw ValidationError{ + "UpdateVotes: a bounded participantName and at least one vote (with no repeated optionId) are required"}; + } + return applyVotes(action.participantName, action.votes, "updated votes", WriteHistory::Yes); +} + +GetPollStateResult PollModel::execute(const AddComment& action) { + if (!action.validate()) { + throw ValidationError{"AddComment: a bounded participantName and body are required"}; + } + if (!_pollId.has_value()) { + throw NotFound{"AddComment: handler was never attached via OpenPoll"}; + } + auto mapper = ::Lightweight::GlobalDataMapperPool().Acquire(); + db::PollRecord poll = loadPollByPollId(mapper.Get(), *_pollId); + if (poll.finalized.Value()) { + // FinalizePoll's own doc comment: finalizing makes the poll + // read-only -- that applies to every write, not only votes. + throw Conflict{"poll is finalized"}; + } + + ::Lightweight::SqlTransaction transaction{mapper->Connection(), ::Lightweight::SqlTransactionMode::ROLLBACK}; + + db::CommentRecord comment; + comment.poll = poll; + comment.participantName = action.participantName; + comment.body = action.body; + comment.createdAtMs = nowMs(); + mapper->Create(comment); + + db::PollEventRecord event; + event.poll = poll; + event.kind = "comment"; + event.summary = action.participantName + " commented"; + event.createdAtMs = nowMs(); + mapper->Create(event); + + transaction.Commit(); + + return buildState(mapper.Get(), poll); +} + +GetPollStateResult PollModel::execute(const FinalizePoll& action) { + if (!action.validate()) { + throw ValidationError{"FinalizePoll: a real optionId is required"}; + } + if (!_pollId.has_value()) { + throw NotFound{"FinalizePoll: handler was never attached via OpenPoll"}; + } + auto mapper = ::Lightweight::GlobalDataMapperPool().Acquire(); + db::PollRecord poll = loadPollByPollId(mapper.Get(), *_pollId); + + // Token check strictly before the already-finalized check: a caller who + // does not hold the admin token must get the same Forbidden regardless + // of the poll's current state, never a Conflict that would leak "this + // poll is already finalized" to someone who has not proven they may act + // on it at all. See this rung's README design decision 1 and this + // method's own header doc comment. + requireAdmin(AdminToken{textOf(poll.adminToken.Value())}); + + if (poll.finalized.Value()) { + throw Conflict{"poll is already finalized"}; + } + requireOptionBelongsToPoll(mapper.Get(), poll, action.optionId); + + ::Lightweight::SqlTransaction transaction{mapper->Connection(), ::Lightweight::SqlTransactionMode::ROLLBACK}; + poll.finalized = true; + poll.finalizedOptionId = *action.optionId; + mapper->Update(poll); + + db::PollEventRecord event; + event.poll = poll; + event.kind = "finalize"; + event.summary = "poll finalized"; + event.createdAtMs = nowMs(); + mapper->Create(event); + + transaction.Commit(); + + return buildState(mapper.Get(), poll); +} + +// --------------------------------------------------------------------------- +// UndoLastVoteChange -- this rung's headline design record (Task 8). See +// the README's resolved design decision 3: `SessionLog::undoLast()` +// (docs/spec/journal/journal.md) pops the newest journal entry regardless +// of which principal made it, and hands back a fresh, detached model +// holder no API can install into a live shared instance -- neither +// property this action needs is available from the framework journal, so +// `PollModel` owns its own small `vote_history` table (Task 4) and this +// method reads/reverses it directly, entirely at the app level. +// --------------------------------------------------------------------------- + +UndoLastVoteChangeResult PollModel::execute(const UndoLastVoteChange& action) { + if (!action.validate()) { + throw ValidationError{"UndoLastVoteChange: participantName is required"}; + } + if (!_pollId.has_value()) { + throw NotFound{"UndoLastVoteChange: handler was never attached via OpenPoll"}; + } + // Read-only lookup, its own single acquisition: only historyRowId (a + // plain integer) crosses into applyVotes() below, which does its own + // separate acquisition for the actual restore transaction -- nothing + // here depends on being on the same physical connection as that write. + auto mapper = ::Lightweight::GlobalDataMapperPool().Acquire(); + db::PollRecord poll = loadPollByPollId(mapper.Get(), *_pollId); + const std::uint64_t pollDbId = poll.id.Value(); + + // "Most recent row for this participant" -- same OrderBy(...DESCENDING) + // + First() shape buildState()'s own lastEvent lookup above uses for + // "most recent PollEventRecord", the established precedent in this TU + // for this exact query pattern. + auto history = mapper + ->Query() + .Where(::Lightweight::FieldNameOf<&db::VoteHistoryRecord::poll>, "=", pollDbId) + .Where(::Lightweight::FieldNameOf<&db::VoteHistoryRecord::participantName>, "=", + action.participantName) + .OrderBy(::Lightweight::FieldNameOf<&db::VoteHistoryRecord::id>, + ::Lightweight::SqlResultOrdering::DESCENDING) + .First(); + if (!history.has_value()) { + // Nothing to undo: this participant never changed their vote on this + // poll, or a prior UndoLastVoteChange already consumed the one entry + // that existed -- either way, a Conflict, not a silent no-op. + throw Conflict{"nothing to undo for this participant"}; + } + + const std::vector previousVotes = decodeVotesJson(textOf(history->previousVotesJson.Value())); + const std::uint64_t historyRowId = history->id.Value(); + + // Reuse applyVotes() (Task 6) directly for the restore itself, exactly + // like SubmitVotes/UpdateVotes: same delete-then-recreate write, same + // fresh PollEventRecord as this call's own audit entry (its own summary + // verb naming the undo, per the brief) -- not a duplicated write path. + // + // WriteHistory::No: restoring must not itself append a new + // VoteHistoryRecord -- left in place, a fresh row capturing "what the + // participant had immediately before the undo" would let a second + // UndoLastVoteChange silently undo the undo, turning a one-shot + // compensating action into an unbounded ping-pong. + // + // historyRowId: the one row this call itself just read above (the + // consumed history entry) is deleted by applyVotes() inside its own + // transaction, alongside the restore write -- so the restore and the + // one-shot cleanup commit together, atomically, never in two separate + // transactions with a window between them where the vote set is + // restored but the consumed row (or a spurious new one) still exists. + // This is what makes "undo is one-shot, not a redo stack" (the brief's + // own words) true, and it is exactly what the "undoing twice in a row" + // test below verifies. + (void) applyVotes(action.participantName, previousVotes, "undid their last vote change", WriteHistory::No, + historyRowId); + + return UndoLastVoteChangeResult{.restored = Restored::Yes}; +} + +// --------------------------------------------------------------------------- +// GetEventsSince (Task 9) -- the Zulip-pattern event log's read side. Every +// mutating action above (applyVotes()'s SubmitVotes/UpdateVotes/ +// UndoLastVoteChange callers, execute(AddComment), execute(FinalizePoll)) +// already appends a PollEventRecord inside its own write transaction; this is +// the last piece, reading that log back out from a cursor. +// --------------------------------------------------------------------------- + +GetEventsSinceResult PollModel::execute(const GetEventsSince& action) { + if (!action.validate()) { + throw ValidationError{"GetEventsSince: malformed request"}; + } + // Carries no pollId of its own -- dispatched against an already-attached + // handler, exactly like execute(GetPollState)/execute(FinalizePoll)/ + // execute(UndoLastVoteChange) above. + if (!_pollId.has_value()) { + throw NotFound{"GetEventsSince: handler was never attached via OpenPoll"}; + } + auto mapper = ::Lightweight::GlobalDataMapperPool().Acquire(); + db::PollRecord poll = loadPollByPollId(mapper.Get(), *_pollId); + const std::uint64_t pollDbId = poll.id.Value(); + + // Opposite direction and full-result-set counterpart of buildState()'s + // own lastEvent lookup above (Where(poll=...).OrderBy(id, DESCENDING) + // .First()): ascending by id, every row, not just the newest one. + // action.lastEventId defaults to PollEventId{} (value 0); poll_events.id + // is a ServerSideAutoIncrement primary key starting at 1, so + // `id > 0` already matches every row -- "from the beginning" falls out of + // this same query with no special-case branch. + auto rows = mapper + ->Query() + .Where(::Lightweight::FieldNameOf<&db::PollEventRecord::poll>, "=", pollDbId) + .Where(::Lightweight::FieldNameOf<&db::PollEventRecord::id>, ">", + static_cast(*action.lastEventId)) + .OrderBy(::Lightweight::FieldNameOf<&db::PollEventRecord::id>) + .All(); + + GetEventsSinceResult result; + result.events.reserve(rows.size()); + for (const auto& row : rows) { + result.events.push_back({.id = PollEventId{.value = static_cast(row.id.Value())}, + .kind = textOf(row.kind.Value()), + .summary = textOf(row.summary.Value())}); + } + return result; +} + +} // namespace polls diff --git a/examples/polls/src/server/main.cpp b/examples/polls/src/server/main.cpp new file mode 100644 index 00000000..9520a13c --- /dev/null +++ b/examples/polls/src/server/main.cpp @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: Apache-2.0 + +/// @file +/// polls' standalone server process: `polls::db::setup()` once, one +/// `polls::app::App` (worker pool + `RemoteServer` with a real +/// `auth::PollsAuthorizer` + durable action log), and one +/// `morph::qt::QtWebSocketServer` in front of it. Mirrors +/// `bookmarks::src::server::main.cpp` closely, minus everything that server +/// owns and this rung has no equivalent for: there is no +/// `POLLS_TOKEN_SECRET` (this rung mints no process-wide signed tokens at +/// all -- `CreatePoll` generates bare admin/participant tokens per poll, +/// directly inside `PollModel::execute()`, see +/// `polls/auth/polls_authorizer.hpp`'s own `@file` comment), and there is no +/// background worker to drain on shutdown (`polls::app::App` is plain C++ +/// with no timer at all -- see that header's own `@file` comment). +/// +/// Usage: +/// @code +/// POLLS_DB=... POLLS_PORT=8767 ladder_polls_server +/// @endcode + +#include "polls/app/app.hpp" +#include "polls/db/database.hpp" + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +/// @brief Set from the `SIGINT`/`SIGTERM` handler, polled by a `QTimer`. +/// +/// A signal handler may not call into Qt (nothing in `QCoreApplication` is +/// async-signal-safe), so it does the one thing it is allowed to do — assign +/// to a `volatile std::sig_atomic_t` — and a timer on the Qt thread turns that +/// into a real `quit()`. This exists so the shutdown path below is actually +/// *reachable*: a demo server is stopped with Ctrl-C, and the default `SIGINT` +/// disposition would terminate the process outright, so `exec()` would never +/// return and `App`'s destructor would never run at all. Identical in shape to +/// `bookmarks`' and `pastebin`'s own server mains. +volatile std::sig_atomic_t gStopRequested = 0; + +extern "C" void onStopSignal(int /*signum*/) { gStopRequested = 1; } + +} // namespace + +int main(int argc, char** argv) { + QCoreApplication qtApp{argc, argv}; + + for (int i = 1; i < argc; ++i) { + std::cerr << "polls-server: unknown argument '" << argv[i] << "' (usage: ladder_polls_server)\n"; + return 2; + } + + const char* connectionString = std::getenv("POLLS_DB"); + polls::db::setup(connectionString != nullptr ? connectionString + : "DRIVER=SQLite3;Database=polls.db;Timeout=5000"); + + // `std::from_chars`, not `std::atoi`: `atoi` has no error channel at all, + // so `POLLS_PORT=abc` would silently bind port 0 (a kernel-assigned + // ephemeral port — the server comes up on an address no client was told + // about) and `POLLS_PORT=99999` would silently wrap to a different port + // on the cast to `quint16`. Both are worse than not starting: an + // operator who mistyped the port gets a server that *looks* healthy. + // Parsed before `App` is constructed so a bad value costs nothing. + quint16 port = 8767; + if (const char* portEnv = std::getenv("POLLS_PORT"); portEnv != nullptr) { + const std::string_view text{portEnv}; + std::uint16_t parsed = 0; + const auto [end, ec] = std::from_chars(text.data(), text.data() + text.size(), parsed); + if (ec != std::errc{} || end != text.data() + text.size()) { + std::cerr << "polls-server: POLLS_PORT='" << portEnv << "' is not a valid port number (0-65535)\n"; + return 2; + } + port = parsed; + } + + int exitCode = 0; + { + polls::app::App app{std::filesystem::current_path() / "polls_actions.jsonl"}; + + ::morph::qt::QtWebSocketServer wsServer{*app.server(), port}; + if (!wsServer.listen()) { + std::cerr << "polls-server: failed to listen on port " << port << "\n"; + return 1; + } + std::cout << "polls-server: listening on ws://127.0.0.1:" << wsServer.port() << std::endl; + + std::signal(SIGINT, onStopSignal); + std::signal(SIGTERM, onStopSignal); + QTimer stopPoll; + QObject::connect(&stopPoll, &QTimer::timeout, &qtApp, [] { + if (gStopRequested != 0) { + QCoreApplication::quit(); + } + }); + stopPoll.start(std::chrono::milliseconds{200}); + + exitCode = QCoreApplication::exec(); + + // Let connected clients' in-flight executes reply and close cleanly + // before `app` leaves this scope. Unlike bookmarks' server, there is + // no background worker to drain afterward: `polls::app::App` is + // plain C++ with no timer at all (see its own `@file` comment) — + // every mutation this rung's `PollModel` performs is synchronous, + // inside the calling `execute()`, so there is nothing left in flight + // once every client connection has closed. + static_cast(wsServer.closeGracefully(std::chrono::seconds{2})); + } + + std::cout << "polls-server: stopped\n"; + return exitCode; +} diff --git a/examples/polls/tests/test_app.cpp b/examples/polls/tests/test_app.cpp new file mode 100644 index 00000000..494374ec --- /dev/null +++ b/examples/polls/tests/test_app.cpp @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// App's own suite: booting the server side (RemoteServer + PollsAuthorizer + +// FileActionLog) and confirming a real client -- not a direct +// `PollModel::execute()` call -- can dispatch `CreatePoll`/`OpenPoll` through +// it end to end. Mirrors `bookmarks::app::App`'s own `[bookmarks][app]` suite +// in spirit (one App-boot smoke test dispatched over the real +// RemoteServer/SimulatedRemoteBackend path), scaled down to this rung's +// single model and its lack of a background worker: there is no +// fetchMetadataOnce()/relayOutboxOnce() equivalent to test here, so this +// file has exactly the one case the brief calls for. + +#include "polls/app/app.hpp" + +#include "polls/dto/poll_dto.hpp" +#include "polls/models/poll_model.hpp" +#include "testkit/db_fixture.hpp" +#include "testkit/pump.hpp" + +#include +#include +#include +#include + +#include +#include +#include + +using morph::bridge::AllowShared; +using morph::bridge::Bridge; +using morph::bridge::BridgeHandler; +using morph::ladder::testkit::awaitQt; +using morph::ladder::testkit::DbFixture; + +namespace { + +/// @brief A fresh, empty action-log path per test. Same convention as +/// `bookmarks::app`'s own `test_app.cpp` -- `FileActionLog` rebuilds +/// its idempotency-dedup set from whatever is already on disk, so a +/// leftover file from an earlier run would silently suppress a +/// re-logged entry. +[[nodiscard]] std::filesystem::path freshLogPath(const std::string& name) { + auto path = std::filesystem::temp_directory_path() / ("polls_" + name + ".jsonl"); + std::filesystem::remove(path); + return path; +} + +} // namespace + +TEST_CASE("App boots, registers PollModel, and a real client can CreatePoll/OpenPoll over it", "[polls][app]") { + DbFixture fixture; + const auto logPath = freshLogPath("app_boot"); + { + polls::app::App app{logPath}; + + // A real client of app.server(): SimulatedRemoteBackend routes + // through RemoteServer::handle() -- the identical dispatch path a + // real socket client's QtWebSocketBackend would use -- so this + // proves the server genuinely registered "PollModel" (via + // poll_model.hpp's BRIDGE_REGISTER_MODEL/BRIDGE_REGISTER_ACTION + // static-init registrars, pulled into this binary through app.cpp's + // own include) and that auth::PollsAuthorizer's permissive + // authorizeRegister/authorizeInstance hooks genuinely admit an + // unauthenticated caller's register and keyed attach, exactly as + // the rung's own design intends (polls_authorizer.hpp's @file + // comment). + morph::qt::QtExecutor exec; + Bridge bridge{std::make_unique(*app.server())}; + + // Plain (NoSharing) handler for CreatePoll -- CreatePoll carries no + // key, so nothing about it is shared/keyed. Mirrors + // test_poll_model.cpp's own instance-rebirth test's "creator" handler. + BridgeHandler creator{bridge, &exec}; + const auto created = awaitQt( + creator.execute(polls::CreatePoll{.title = "Team offsite", .options = {{"2026-09-01"}, {"2026-09-02"}}})); + CHECK_FALSE(created.pollId.empty()); + REQUIRE(created.adminToken.hasValue()); + REQUIRE(created.participantToken.hasValue()); + CHECK_FALSE((*created.adminToken).empty()); + CHECK_FALSE((*created.participantToken).empty()); + CHECK(*created.adminToken != *created.participantToken); + + // AllowShared handler for OpenPoll -- the keyed attach path + // (BRIDGE_MODEL_KEY(PollModel, OpenPoll, &OpenPoll::pollId)) a real + // participant screen uses to join the poll `creator` just made. + BridgeHandler viewer{bridge, &exec}; + const auto state = awaitQt(viewer.execute(polls::OpenPoll{.pollId = created.pollId})); + CHECK(state.pollId == created.pollId); + CHECK(state.title == "Team offsite"); + REQUIRE(state.options.size() == 2); + CHECK(state.options[0].label == "2026-09-01"); + CHECK(state.options[1].label == "2026-09-02"); + CHECK(state.finalized == polls::Finalized::No); + CHECK(state.votes.empty()); + CHECK(state.comments.empty()); + } + std::filesystem::remove(logPath); +} diff --git a/examples/polls/tests/test_gui_qml_smoke.cpp b/examples/polls/tests/test_gui_qml_smoke.cpp new file mode 100644 index 00000000..5e9e3749 --- /dev/null +++ b/examples/polls/tests/test_gui_qml_smoke.cpp @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// The one QML test examples/TESTING.md presenter rule 6 asks each rung for: +// "one offscreen engine-load smoke test (engine creates root object, no +// errors) registered in ctest — not Qt Quick Test, and no synthesized-mouse- +// event flows." Mirrors examples/bookmarks/tests/test_gui_qml_smoke.cpp +// (rung 2's Task 18) exactly in shape and in what it does/does not prove — +// see that file's own header comment for the full explanation, restated only +// where this rung's own structure differs below. +// +// This rung ships three QML files (Main, CreatePollView, VoteView), and +// Main.qml's StackView starts on its inline landing screen: nothing pushes +// CreatePollView or VoteView without a live `pollBridge` (both require a +// non-null controller to do anything, and Main's own "Create a new poll" +// button is additionally gated on `pollBridge !== null`). So, exactly as +// rung 2's BookmarkListView needed its own standalone load, both are loaded +// here as root objects in their own right — every controller property +// defaults to null, exactly as when the desktop client has not finished +// connecting yet (and exactly what tests/test_poll_qml_bridges.cpp's own +// suite proves *with* a live controller, at the adapter layer rather than +// through the QML engine). +// +// MORPH_LADDER_QML_URI is defined by morph_add_rung() only when the rung's QML +// module was actually built (MORPH_BUILD_FORMS_QML=ON). Without it this file +// is an empty translation unit, so a configure that legitimately has no Qt +// Quick still builds. + +#ifdef MORPH_LADDER_QML_URI + +#include + +#include +#include +#include +#include + +#include + +namespace { + +/// @brief Loads @p typeName from this rung's QML module and returns the first +/// warning the engine emitted, or an empty string. +/// @param typeName Unqualified QML type name within `MORPH_LADDER_QML_URI`. +/// @param created Set to whether a root object was produced. +/// @return The first warning's text, or an empty string if there was none. +[[nodiscard]] std::string firstWarningLoading(const char* typeName, bool& created) { + QQmlApplicationEngine engine; + + QString firstWarning; + QObject::connect(&engine, &QQmlApplicationEngine::warnings, [&firstWarning](const QList& warnings) { + if (firstWarning.isEmpty() && !warnings.isEmpty()) { + firstWarning = warnings.front().toString(); + } + }); + + engine.loadFromModule(MORPH_LADDER_QML_URI, typeName); + created = !engine.rootObjects().isEmpty(); + return firstWarning.toStdString(); +} + +} // namespace + +TEST_CASE("polls' QML engine loads Main.qml and creates a root object with no errors", "[polls][gui][qml-smoke]") { + bool created = false; + // Reported through the message, not a bare boolean: a QML warning is + // otherwise a failing assertion with nothing to act on. + CHECK(firstWarningLoading("Main", created) == std::string{}); + REQUIRE(created); +} + +TEST_CASE("polls' create-poll screen loads standalone with no errors", "[polls][gui][qml-smoke]") { + // Main.qml's StackView never reaches CreatePollView without a live + // pollBridge and a click on the (also pollBridge-gated) "Create a new + // poll" button — see this file's header comment. + bool created = false; + CHECK(firstWarningLoading("CreatePollView", created) == std::string{}); + REQUIRE(created); +} + +TEST_CASE("polls' vote screen loads standalone with no errors", "[polls][gui][qml-smoke]") { + // Same reasoning as CreatePollView above; VoteView's own + // Component.onCompleted also guards its one side effect (calling + // pollBridge.openPoll) on pollBridge being non-null, so loading it here + // with the default null controller triggers no dispatch at all. + bool created = false; + CHECK(firstWarningLoading("VoteView", created) == std::string{}); + REQUIRE(created); +} + +#endif // MORPH_LADDER_QML_URI diff --git a/examples/polls/tests/test_poll_dto.cpp b/examples/polls/tests/test_poll_dto.cpp new file mode 100644 index 00000000..0b22f9e8 --- /dev/null +++ b/examples/polls/tests/test_poll_dto.cpp @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include + +TEST_CASE("CreatePoll requires a bounded title and 2-20 bounded-label options", "[polls][dto]") { + polls::CreatePoll action; + CHECK_FALSE(action.validate()); // no title, no options + action.title = "Team offsite"; + CHECK_FALSE(action.validate()); // still no options + action.options = {{"2026-09-01"}}; + CHECK_FALSE(action.validate()); // only one option + action.options.push_back({"2026-09-02"}); + CHECK(action.validate()); + action.options.push_back({""}); + CHECK_FALSE(action.validate()); // empty label + action.title = std::string(polls::kMaxTitleBytes + 1, 't'); + action.options = {{"a"}, {"b"}}; + CHECK_FALSE(action.validate()); // title too long +} + +TEST_CASE("OpenPoll requires a non-empty pollId", "[polls][dto]") { + CHECK_FALSE(polls::OpenPoll{}.validate()); + CHECK(polls::OpenPoll{.pollId = "abc"}.validate()); +} + +TEST_CASE("GetPollStateResult contains all nested views with correct field values", "[polls][dto]") { + polls::GetPollStateResult result; + result.pollId = "abc"; + result.title = "Team offsite"; + result.options.push_back({.id = polls::OptionId{.value = 1}, .label = "2026-09-01", + .yesCount = polls::Count::fromDouble(2.0)}); + result.votes.push_back({.participantName = "alice", .optionId = polls::OptionId{.value = 1}, + .choice = polls::VoteChoice::Yes}); + result.comments.push_back({.participantName = "alice", .body = "works for me"}); + + // Verify field values directly; JSON round-trip via ActionTraits::resultToJson/resultFromJson + // will be added in Task 3's reflection registration. + CHECK(result.pollId == "abc"); + CHECK(result.title == "Team offsite"); + CHECK(result.finalized == polls::Finalized::No); + CHECK(!result.finalizedOptionId.hasValue()); + CHECK(result.options.size() == 1); + CHECK(result.options[0].id == polls::OptionId{.value = 1}); + CHECK(result.options[0].label == "2026-09-01"); + CHECK(result.options[0].yesCount == polls::Count::fromDouble(2.0)); + CHECK(result.votes.size() == 1); + CHECK(result.votes[0].participantName == "alice"); + CHECK(result.votes[0].optionId == polls::OptionId{.value = 1}); + CHECK(result.votes[0].choice == polls::VoteChoice::Yes); + CHECK(result.comments.size() == 1); + CHECK(result.comments[0].participantName == "alice"); + CHECK(result.comments[0].body == "works for me"); +} diff --git a/examples/polls/tests/test_poll_model.cpp b/examples/polls/tests/test_poll_model.cpp new file mode 100644 index 00000000..4b70ebe8 --- /dev/null +++ b/examples/polls/tests/test_poll_model.cpp @@ -0,0 +1,604 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// PollModel's model-level suite. Task 5's cases: CreatePoll's generated +// tokens, OpenPoll finding the poll it created (and the keyed-attach +// pollId cache GetPollState later reads -- exercised indirectly via +// OpenPoll's returned state), and NotFound on an unknown pollId. Task 6 +// appends SubmitVotes/UpdateVotes/AddComment: one-vote-per-option tallying, +// retry-idempotency (the DoD's "participant-token + option uniqueness is a +// model invariant, tested under retry" requirement), wholesale replacement, +// and the finalized-poll Conflict dead-letter both vote-writing actions and +// AddComment share. +#include "testkit/db_fixture.hpp" + +#include "polls/core/errors.hpp" +#include "polls/core/types.hpp" +#include "polls/dto/event_dto.hpp" +#include "polls/dto/poll_dto.hpp" +#include "polls/dto/vote_dto.hpp" +#include "polls/models/poll_model.hpp" + +// Task 9's own instance-rebirth test drives PollModel through real +// BridgeHandlers over a real Bridge/backend (BackendRig), not direct +// PollModel::execute() calls -- the only way to make one PollModel instance +// genuinely die (last handler naming its key destructed) and a fresh one take +// its place, per this rung's shared-instance design (BRIDGE_MODEL_KEY( +// polls::PollModel, polls::OpenPoll, &polls::OpenPoll::pollId) in +// poll_model.hpp). +#include "testkit/backend_rig.hpp" +#include "testkit/pump.hpp" + +#include + +// Test-only: FinalizePoll (Task 7) does not exist yet, so the two +// finalized-poll Conflict cases below reach into the entity directly to put +// a poll into the finalized state -- the same untransacted single-row +// mapper.Update() pattern test_bookmarks_schema.cpp/test_polls_schema.cpp +// already use for a direct DataMapper write (not test_bookmark_model.cpp, +// which only ever reads entities directly, never writes them). Production +// model code never does this (poll_model.cpp's own file comment: the entity +// is a poll_model.cpp-only implementation detail) -- this is the test +// harness reaching past that boundary on purpose, not a precedent for +// application code. +#include "polls/db/poll_entity.hpp" + +#include + +#include +#include + +#include + +using morph::bridge::AllowShared; +using morph::bridge::BridgeHandler; +using morph::ladder::testkit::awaitQt; +using morph::ladder::testkit::BackendRig; +using morph::ladder::testkit::DbFixture; +using morph::ladder::testkit::Mode; +using polls::AddComment; +using polls::Conflict; +using polls::CreatePoll; +using polls::FinalizePoll; +using polls::Forbidden; +using polls::GetEventsSince; +using polls::NotFound; +using polls::OpenPoll; +using polls::PollModel; +using polls::SubmitVotes; +using polls::UndoLastVoteChange; +using polls::UpdateVotes; +using polls::VoteChoice; + +namespace { + +/// @brief A `Context` carrying only @p token. Built field-by-field rather +/// than a designated initializer, for the identical +/// `-Wmissing-designated-field-initializers` reason +/// `test_bookmark_model.cpp`'s `contextFor` exists. +[[nodiscard]] morph::session::Context contextForToken(std::string token) { + morph::session::Context ctx; + ctx.token = std::move(token); + return ctx; +} + +/// @brief Installs a `Context` carrying only a bearer token, thread-locally, +/// for its scope. Same shape as `test_bookmark_model.cpp`'s +/// `ScopedPrincipal`, adapted to this rung's bearer-token-not-principal +/// design (README design decision 1): `PollModel::requireAdmin()` +/// reads `session::current()->token`, never `->principal`. +class ScopedToken { + public: + explicit ScopedToken(std::string token) : _ctx{contextForToken(std::move(token))}, _scope{_ctx} {} + + private: + morph::session::Context _ctx; + morph::session::detail::ScopedContext _scope; +}; + +/// @brief Marks the poll named by @p pollId finalized, bypassing +/// `FinalizePoll` (not implemented until Task 7) -- see the file +/// comment above. +void finalizePollDirectly(const std::string& pollId) { + Lightweight::DataMapper mapper; + auto rows = mapper.Query() + .Where(Lightweight::FieldNameOf<&polls::db::PollRecord::pollId>, "=", pollId) + .All(); + REQUIRE_FALSE(rows.empty()); + auto& poll = rows.front(); + poll.finalized = true; + mapper.Update(poll); +} + +} // namespace + +TEST_CASE("CreatePoll returns three distinct tokens and OpenPoll finds the same poll", "[polls][model]") { + DbFixture fixture; + PollModel model; + auto created = model.execute(CreatePoll{.title = "Team offsite", .options = {{"2026-09-01"}, {"2026-09-02"}}}); + CHECK_FALSE(created.pollId.empty()); + REQUIRE(created.adminToken.hasValue()); + REQUIRE(created.participantToken.hasValue()); + CHECK_FALSE((*created.adminToken).empty()); + CHECK_FALSE((*created.participantToken).empty()); + CHECK(created.pollId != *created.adminToken); + CHECK(created.pollId != *created.participantToken); + // Compared through the payloads: the two newtypes are deliberately + // different C++ types, so there is no cross-type `!=` to reach for. + CHECK(*created.adminToken != *created.participantToken); + + auto state = model.execute(OpenPoll{.pollId = created.pollId}); + CHECK(state.pollId == created.pollId); + CHECK(state.title == "Team offsite"); + CHECK(state.options.size() == 2); + CHECK(state.options[0].label == "2026-09-01"); + CHECK(state.options[1].label == "2026-09-02"); + CHECK(state.finalized == polls::Finalized::No); + CHECK(state.votes.empty()); + CHECK(state.comments.empty()); +} + +TEST_CASE("OpenPoll against an unknown pollId throws NotFound", "[polls][model]") { + DbFixture fixture; + PollModel model; + CHECK_THROWS_AS(model.execute(OpenPoll{.pollId = "no-such-poll"}), NotFound); +} + +TEST_CASE("Two CreatePoll calls never collide on pollId/adminToken/participantToken", "[polls][model]") { + DbFixture fixture; + PollModel model; + auto a = model.execute(CreatePoll{.title = "A", .options = {{"1"}, {"2"}}}); + auto b = model.execute(CreatePoll{.title = "B", .options = {{"1"}, {"2"}}}); + CHECK(a.pollId != b.pollId); + CHECK(a.adminToken != b.adminToken); + CHECK(a.participantToken != b.participantToken); +} + +TEST_CASE("GetPollState after OpenPoll returns the same poll's state", "[polls][model]") { + DbFixture fixture; + PollModel model; + auto created = model.execute(CreatePoll{.title = "Lunch spot", .options = {{"Cafe"}, {"Diner"}}}); + (void) model.execute(OpenPoll{.pollId = created.pollId}); + + auto state = model.execute(polls::GetPollState{}); + CHECK(state.pollId == created.pollId); + CHECK(state.title == "Lunch spot"); + CHECK(state.options.size() == 2); +} + +TEST_CASE("GetPollState on a fresh handler never attached via OpenPoll throws NotFound", "[polls][model]") { + DbFixture fixture; + PollModel model; + CHECK_THROWS_AS(model.execute(polls::GetPollState{}), NotFound); +} + +TEST_CASE("CreatePoll's validate() rejects an empty title and out-of-range option counts", "[polls][model]") { + DbFixture fixture; + PollModel model; + CHECK_THROWS_AS(model.execute(CreatePoll{.title = "", .options = {{"1"}, {"2"}}}), polls::ValidationError); + CHECK_THROWS_AS(model.execute(CreatePoll{.title = "T", .options = {{"1"}}}), polls::ValidationError); + CHECK_THROWS_AS(model.execute(CreatePoll{.title = "T", .options = {}}), polls::ValidationError); +} + +TEST_CASE("SubmitVotes writes one vote per option, visible in the next GetPollState", "[polls][model]") { + DbFixture fixture; + PollModel model; + auto created = model.execute(CreatePoll{.title = "T", .options = {{"1"}, {"2"}}}); + model.execute(OpenPoll{.pollId = created.pollId}); + auto opts = model.execute(polls::GetPollState{}).options; + + auto state = model.execute(SubmitVotes{.participantName = "alice", + .votes = {{.optionId = opts[0].id, .choice = VoteChoice::Yes}, + {.optionId = opts[1].id, .choice = VoteChoice::No}}}); + CHECK(state.options[0].yesCount == polls::Count::fromDouble(1.0)); + CHECK(state.options[1].noCount == polls::Count::fromDouble(1.0)); + REQUIRE(state.votes.size() == 2); +} + +TEST_CASE("A retried SubmitVotes for the same participant does not double-count", "[polls][model]") { + DbFixture fixture; + PollModel model; + auto created = model.execute(CreatePoll{.title = "T", .options = {{"1"}, {"2"}}}); + model.execute(OpenPoll{.pollId = created.pollId}); + auto opts = model.execute(polls::GetPollState{}).options; + SubmitVotes action{.participantName = "alice", .votes = {{.optionId = opts[0].id, .choice = VoteChoice::Yes}}}; + model.execute(action); + // The DoD names this as a retry scenario: the strand serializes but does + // not dedup by itself, so the model's own unique constraint (backed by + // applyVotes()'s delete-then-recreate) is what actually prevents + // double-counting -- assert on the real outcome, not the mechanism. + auto state = model.execute(action); // retried identically + CHECK(state.options[0].yesCount == polls::Count::fromDouble(1.0)); // still 1, not 2 + REQUIRE(state.votes.size() == 1); +} + +TEST_CASE("UpdateVotes replaces a participant's prior votes wholesale", "[polls][model]") { + DbFixture fixture; + PollModel model; + auto created = model.execute(CreatePoll{.title = "T", .options = {{"1"}, {"2"}}}); + model.execute(OpenPoll{.pollId = created.pollId}); + auto opts = model.execute(polls::GetPollState{}).options; + model.execute( + SubmitVotes{.participantName = "alice", .votes = {{.optionId = opts[0].id, .choice = VoteChoice::Yes}}}); + auto state = model.execute( + UpdateVotes{.participantName = "alice", .votes = {{.optionId = opts[1].id, .choice = VoteChoice::Yes}}}); + CHECK(state.options[0].yesCount == polls::Count::fromDouble(0.0)); // alice's old vote is gone + CHECK(state.options[1].yesCount == polls::Count::fromDouble(1.0)); + REQUIRE(state.votes.size() == 1); +} + +TEST_CASE("SubmitVotes against a finalized poll throws Conflict, a visible dead-letter outcome", "[polls][model]") { + DbFixture fixture; + PollModel model; + auto created = model.execute(CreatePoll{.title = "T", .options = {{"1"}, {"2"}}}); + model.execute(OpenPoll{.pollId = created.pollId}); + auto opts = model.execute(polls::GetPollState{}).options; + finalizePollDirectly(created.pollId); + CHECK_THROWS_AS(model.execute(SubmitVotes{.participantName = "bob", + .votes = {{.optionId = opts[0].id, .choice = VoteChoice::Yes}}}), + Conflict); +} + +TEST_CASE("AddComment writes a comment visible in the next GetPollState", "[polls][model]") { + DbFixture fixture; + PollModel model; + auto created = model.execute(CreatePoll{.title = "T", .options = {{"1"}, {"2"}}}); + model.execute(OpenPoll{.pollId = created.pollId}); + auto state = model.execute(AddComment{.participantName = "alice", .body = "works for me"}); + REQUIRE(state.comments.size() == 1); + CHECK(state.comments.front().body == "works for me"); +} + +TEST_CASE("AddComment against a finalized poll throws Conflict -- finalizing makes the poll read-only", + "[polls][model]") { + DbFixture fixture; + PollModel model; + auto created = model.execute(CreatePoll{.title = "T", .options = {{"1"}, {"2"}}}); + model.execute(OpenPoll{.pollId = created.pollId}); + finalizePollDirectly(created.pollId); + CHECK_THROWS_AS(model.execute(AddComment{.participantName = "alice", .body = "too late"}), Conflict); +} + +TEST_CASE("FinalizePoll requires the admin token in Context::token", "[polls][model]") { + DbFixture fixture; + PollModel model; + auto created = model.execute(CreatePoll{.title = "T", .options = {{"1"}, {"2"}}}); + model.execute(OpenPoll{.pollId = created.pollId}); + auto opts = model.execute(polls::GetPollState{}).options; + + // No token at all: + CHECK_THROWS_AS(model.execute(FinalizePoll{.optionId = opts[0].id}), Forbidden); + + // Wrong token (the participant token, not the admin token): still + // Forbidden, not a silent success -- a participant may never finalize. + { + const ScopedToken scoped{*created.participantToken}; + CHECK_THROWS_AS(model.execute(FinalizePoll{.optionId = opts[0].id}), Forbidden); + } + + // Right token: + { + const ScopedToken scoped{*created.adminToken}; + auto state = model.execute(FinalizePoll{.optionId = opts[0].id}); + CHECK(state.finalized == polls::Finalized::Yes); + CHECK(state.finalizedOptionId == opts[0].id); + } +} + +TEST_CASE("Finalizing an already-finalized poll throws Conflict", "[polls][model]") { + DbFixture fixture; + PollModel model; + auto created = model.execute(CreatePoll{.title = "T", .options = {{"1"}, {"2"}}}); + model.execute(OpenPoll{.pollId = created.pollId}); + auto opts = model.execute(polls::GetPollState{}).options; + const ScopedToken scoped{*created.adminToken}; + model.execute(FinalizePoll{.optionId = opts[0].id}); + CHECK_THROWS_AS(model.execute(FinalizePoll{.optionId = opts[1].id}), Conflict); +} + +TEST_CASE("FinalizePoll's admin-token check runs before the already-finalized check", "[polls][model]") { + // A wrong-token caller against an *already-finalized* poll must still see + // Forbidden, never Conflict -- Conflict would leak "this poll is already + // finalized" to a caller who has not proven they may act on it at all. + // See poll_model.cpp's own comment on this ordering. + DbFixture fixture; + PollModel model; + auto created = model.execute(CreatePoll{.title = "T", .options = {{"1"}, {"2"}}}); + model.execute(OpenPoll{.pollId = created.pollId}); + auto opts = model.execute(polls::GetPollState{}).options; + { + const ScopedToken scoped{*created.adminToken}; + model.execute(FinalizePoll{.optionId = opts[0].id}); + } + { + const ScopedToken scoped{*created.participantToken}; + CHECK_THROWS_AS(model.execute(FinalizePoll{.optionId = opts[1].id}), Forbidden); + } +} + +TEST_CASE("FinalizePoll rejects an optionId belonging to a different poll", "[polls][model]") { + // /code-review max finding: finalizedOptionId is FK-shaped but not + // FK-enforced (SQLite), so without an explicit membership check a poll + // could finalize with an option id that exists, but belongs to some + // *other* poll entirely. + DbFixture fixture; + PollModel modelA; + auto createdA = modelA.execute(CreatePoll{.title = "Poll A", .options = {{"1"}, {"2"}}}); + modelA.execute(OpenPoll{.pollId = createdA.pollId}); + + PollModel modelB; + auto createdB = modelB.execute(CreatePoll{.title = "Poll B", .options = {{"3"}, {"4"}}}); + modelB.execute(OpenPoll{.pollId = createdB.pollId}); + auto optsB = modelB.execute(polls::GetPollState{}).options; + + const ScopedToken scoped{*createdA.adminToken}; + CHECK_THROWS_AS(modelA.execute(FinalizePoll{.optionId = optsB[0].id}), NotFound); + + // Poll A must still be genuinely unfinalized -- the rejected attempt left + // no partial state behind. + auto stateA = modelA.execute(polls::GetPollState{}); + CHECK(stateA.finalized == polls::Finalized::No); +} + +TEST_CASE("SubmitVotes rejects a vote naming an optionId from a different poll, atomically", + "[polls][model]") { + // /code-review max finding: without this check, a cross-poll vote would + // be written but never counted by buildState()'s per-poll tally loop -- + // the participant is told they voted, and the vote silently vanishes. + DbFixture fixture; + PollModel modelA; + auto createdA = modelA.execute(CreatePoll{.title = "Poll A", .options = {{"1"}, {"2"}}}); + modelA.execute(OpenPoll{.pollId = createdA.pollId}); + auto optsA = modelA.execute(polls::GetPollState{}).options; + + PollModel modelB; + auto createdB = modelB.execute(CreatePoll{.title = "Poll B", .options = {{"3"}, {"4"}}}); + modelB.execute(OpenPoll{.pollId = createdB.pollId}); + auto optsB = modelB.execute(polls::GetPollState{}).options; + + // One valid vote (poll A's own option) plus one cross-poll vote (poll + // B's option) in the same submission -- the whole call must be rejected, + // not partially applied. + CHECK_THROWS_AS(modelA.execute(SubmitVotes{.participantName = "alice", + .votes = {{.optionId = optsA[0].id, .choice = VoteChoice::Yes}, + {.optionId = optsB[0].id, .choice = VoteChoice::No}}}), + NotFound); + + // Nothing was written -- not even the valid first vote. + auto stateA = modelA.execute(polls::GetPollState{}); + CHECK(stateA.votes.empty()); +} + +TEST_CASE("SubmitVotes rejects two votes naming the same optionId with ValidationError, not a raw SQL error", + "[polls][model]") { + DbFixture fixture; + PollModel model; + auto created = model.execute(CreatePoll{.title = "T", .options = {{"1"}, {"2"}}}); + model.execute(OpenPoll{.pollId = created.pollId}); + auto opts = model.execute(polls::GetPollState{}).options; + + CHECK_THROWS_AS(model.execute(SubmitVotes{.participantName = "alice", + .votes = {{.optionId = opts[0].id, .choice = VoteChoice::Yes}, + {.optionId = opts[0].id, .choice = VoteChoice::No}}}), + polls::ValidationError); +} + +TEST_CASE("GetEventsSince rejects a negative lastEventId with ValidationError", "[polls][model]") { + DbFixture fixture; + PollModel model; + auto created = model.execute(CreatePoll{.title = "T", .options = {{"1"}, {"2"}}}); + model.execute(OpenPoll{.pollId = created.pollId}); + + CHECK_THROWS_AS(model.execute(GetEventsSince{.lastEventId = polls::PollEventId{.value = -1}}), + polls::ValidationError); +} + +// --------------------------------------------------------------------------- +// Task 8: UndoLastVoteChange. Per this task's own brief, the interleaving +// test below is written and run FIRST, before execute(UndoLastVoteChange) +// has a body -- its outcome is this rung's headline design record: proof +// that a principal-scoped compensating action can do what +// SessionLog::undoLast() (docs/spec/journal/journal.md) structurally +// cannot, since that API pops the newest journal entry regardless of which +// principal made it, and hands back a detached model holder no API can +// install into a live shared instance. +// --------------------------------------------------------------------------- + +TEST_CASE("Principal-scoped undo: A votes, B votes, A undoes -> only A's vote dies (the rung's headline design record)", + "[polls][model]") { + DbFixture fixture; + PollModel model; + auto created = model.execute(CreatePoll{.title = "T", .options = {{"1"}, {"2"}}}); + model.execute(OpenPoll{.pollId = created.pollId}); + auto opts = model.execute(polls::GetPollState{}).options; + + model.execute(SubmitVotes{.participantName = "alice", .votes = {{.optionId = opts[0].id, .choice = VoteChoice::Yes}}}); + model.execute(SubmitVotes{.participantName = "bob", .votes = {{.optionId = opts[0].id, .choice = VoteChoice::Yes}}}); + // Both voted yes on option 0: count should be 2. + auto before = model.execute(polls::GetPollState{}); + REQUIRE(before.options[0].yesCount == polls::Count::fromDouble(2.0)); + + auto undoResult = model.execute(UndoLastVoteChange{.participantName = "alice"}); + CHECK(undoResult.restored == polls::Restored::Yes); + + auto after = model.execute(polls::GetPollState{}); + // Alice's vote is gone; Bob's survives. This is the assertion that + // SessionLog::undoLast() could never make true: it pops the newest + // entry regardless of principal, which would have killed Bob's vote + // (the more recent of the two), not Alice's own. + CHECK(after.options[0].yesCount == polls::Count::fromDouble(1.0)); + const bool bobStillVotes = + std::ranges::any_of(after.votes, [](const auto& v) { return v.participantName == "bob"; }); + const bool aliceStillVotes = + std::ranges::any_of(after.votes, [](const auto& v) { return v.participantName == "alice"; }); + CHECK(bobStillVotes); + CHECK_FALSE(aliceStillVotes); +} + +TEST_CASE("UndoLastVoteChange with nothing to undo throws Conflict", "[polls][model]") { + DbFixture fixture; + PollModel model; + auto created = model.execute(CreatePoll{.title = "T", .options = {{"1"}, {"2"}}}); + model.execute(OpenPoll{.pollId = created.pollId}); + CHECK_THROWS_AS(model.execute(UndoLastVoteChange{.participantName = "nobody-voted"}), Conflict); +} + +TEST_CASE("Undo is one-shot: undoing twice in a row throws Conflict the second time", "[polls][model]") { + DbFixture fixture; + PollModel model; + auto created = model.execute(CreatePoll{.title = "T", .options = {{"1"}, {"2"}}}); + model.execute(OpenPoll{.pollId = created.pollId}); + auto opts = model.execute(polls::GetPollState{}).options; + model.execute(SubmitVotes{.participantName = "alice", .votes = {{.optionId = opts[0].id, .choice = VoteChoice::Yes}}}); + model.execute(UndoLastVoteChange{.participantName = "alice"}); + CHECK_THROWS_AS(model.execute(UndoLastVoteChange{.participantName = "alice"}), Conflict); +} + +TEST_CASE("UndoLastVoteChange restores a genuinely non-empty prior vote set, not just \"no vote\"", + "[polls][model]") { + DbFixture fixture; + PollModel model; + auto created = model.execute(CreatePoll{.title = "T", .options = {{"1"}, {"2"}}}); + model.execute(OpenPoll{.pollId = created.pollId}); + auto opts = model.execute(polls::GetPollState{}).options; + + model.execute(SubmitVotes{.participantName = "alice", + .votes = {{.optionId = opts[0].id, .choice = VoteChoice::Yes}, + {.optionId = opts[1].id, .choice = VoteChoice::No}}}); + model.execute(UpdateVotes{.participantName = "alice", + .votes = {{.optionId = opts[1].id, .choice = VoteChoice::IfNeedBe}}}); + model.execute(UndoLastVoteChange{.participantName = "alice"}); + + auto after = model.execute(polls::GetPollState{}); + CHECK(after.votes.size() == 2); + CHECK(after.options[0].yesCount == polls::Count::fromDouble(1.0)); + CHECK(after.options[1].noCount == polls::Count::fromDouble(1.0)); + CHECK(after.options[1].ifNeedBeCount == polls::Count::fromDouble(0.0)); +} + +// --------------------------------------------------------------------------- +// Task 9: GetEventsSince -- the Zulip-pattern event log's read side. Every +// mutating action above already appends a PollEventRecord (SubmitVotes/ +// UpdateVotes/AddComment/FinalizePoll/UndoLastVoteChange, exercised by the +// tests above); these cases read that log back out. +// --------------------------------------------------------------------------- + +TEST_CASE("GetEventsSince{} (from the beginning) returns every event in order", "[polls][model]") { + DbFixture fixture; + PollModel model; + auto created = model.execute(CreatePoll{.title = "T", .options = {{"1"}, {"2"}}}); + model.execute(OpenPoll{.pollId = created.pollId}); + auto opts = model.execute(polls::GetPollState{}).options; + model.execute(SubmitVotes{.participantName = "alice", .votes = {{.optionId = opts[0].id, .choice = VoteChoice::Yes}}}); + model.execute(AddComment{.participantName = "alice", .body = "hi"}); + + auto events = model.execute(GetEventsSince{}).events; + REQUIRE(events.size() == 2); + CHECK(events[0].kind == "vote"); + CHECK(events[1].kind == "comment"); + CHECK(events[0].id.value < events[1].id.value); // strictly increasing +} + +TEST_CASE("GetEventsSince{lastEventId} returns only strictly-newer events", "[polls][model]") { + DbFixture fixture; + PollModel model; + auto created = model.execute(CreatePoll{.title = "T", .options = {{"1"}, {"2"}}}); + model.execute(OpenPoll{.pollId = created.pollId}); + auto opts = model.execute(polls::GetPollState{}).options; + model.execute(SubmitVotes{.participantName = "alice", .votes = {{.optionId = opts[0].id, .choice = VoteChoice::Yes}}}); + auto firstEvents = model.execute(GetEventsSince{}).events; + REQUIRE(firstEvents.size() == 1); + + model.execute(AddComment{.participantName = "alice", .body = "hi"}); + auto newEvents = model.execute(GetEventsSince{.lastEventId = firstEvents.front().id}).events; + REQUIRE(newEvents.size() == 1); + CHECK(newEvents.front().kind == "comment"); +} + +TEST_CASE("GetEventsSince throws NotFound against a handler never attached via OpenPoll", "[polls][model]") { + DbFixture fixture; + PollModel model; + CHECK_THROWS_AS(model.execute(GetEventsSince{}), NotFound); +} + +TEST_CASE("The event log survives full detach/reattach (instance rebirth), and a stale cursor " + "gets everything after it -- no epoch token needed", + "[polls][model]") { + // This is the DoD's own required test: "Event log survives full + // detach/reattach (instance rebirth) and a stale cursor triggers a clean + // full resync, verified by test." Per this rung's resolved design + // decision (durable persistence alone closes the Zulip-pattern gap, no + // epoch token needed): "clean full resync" here means the stale cursor + // simply gets every real event since it, correctly, because poll_events' + // autoincrement id survived the instance's death regardless of which + // in-memory PollModel wrote which row. + // + // Goes through real BridgeHandlers over a real Bridge/backend + // (BackendRig{Mode::Local, ...}), not direct PollModel::execute() calls + // -- direct calls construct their own private PollModel per test-local + // variable and never touch the shared instance directory at all, so + // there would be no instance to kill. Two AllowShared handlers attach to + // the same pollId (proving one shared instance, not two -- instances() + // reports exactly one live key), both then go out of scope, and + // BridgeHandler::instances() confirms the + // directory is genuinely empty afterward -- not merely "the test didn't + // crash". A fresh handler then reattaches and GetEventsSince with the + // pre-death cursor gets exactly the events written after it. + DbFixture fixture; + BackendRig rig{Mode::Local, 1}; + + std::string pollId; + polls::PollEventId lastEventId; + { + // A plain (NoSharing) handler for CreatePoll: an AllowShared handler + // that has never attached refuses every keyless action ("handler not + // bound" -- see BridgeHandler's own doc comment, + // morph/core/bridge.hpp), and CreatePoll carries no BRIDGE_KEY_FROM + // of its own to attach by. + BridgeHandler creator{rig.bridge(0), rig.executor()}; + auto created = awaitQt(creator.execute(CreatePoll{.title = "T", .options = {{"1"}, {"2"}}})); + pollId = created.pollId; + + // Two shared handlers naming the same key -- both land on one + // instance (mirrors bank's "two shared handlers on one account reach + // one instance", test_stateful_account.cpp). + BridgeHandler handlerA{rig.bridge(0), rig.executor()}; + BridgeHandler handlerB{rig.bridge(0), rig.executor()}; + auto state = awaitQt(handlerA.execute(OpenPoll{.pollId = pollId})); + (void) awaitQt(handlerB.execute(OpenPoll{.pollId = pollId})); + REQUIRE(awaitQt(handlerA.instances()) == std::vector{pollId}); + + awaitQt(handlerA.execute( + SubmitVotes{.participantName = "alice", .votes = {{.optionId = state.options[0].id, .choice = VoteChoice::Yes}}})); + auto events = awaitQt(handlerB.execute(GetEventsSince{})).events; + REQUIRE(events.size() == 1); + lastEventId = events.back().id; + + // handlerA/handlerB (the only two handlers naming this poll's key) + // and creator (never in the directory to begin with) all go out of + // scope at the end of this block -- releasing the shared instance, + // which destructs. This is the "instance rebirth" this test proves: + // there is now no live PollModel instance for this poll anywhere. + } + + // Real destruction, not assumed: a fresh AllowShared handler's own + // instances() call shows an empty directory, not merely "no crash". + { + BridgeHandler prober{rig.bridge(0), rig.executor()}; + REQUIRE(awaitQt(prober.instances()).empty()); + } + + // Fresh handler -> a brand-new PollModel instance, re-attached from + // scratch via OpenPoll (its own _pollId cache starts unset, exactly like + // any other freshly-constructed PollModel). The event log itself lives in + // SQLite, not in that now-dead instance's memory, so it is untouched. + BridgeHandler handlerC{rig.bridge(0), rig.executor()}; + auto reopened = awaitQt(handlerC.execute(OpenPoll{.pollId = pollId})); + REQUIRE(reopened.lastEventId == lastEventId); // durable across the instance's death + + awaitQt(handlerC.execute(AddComment{.participantName = "bob", .body = "welcome back"})); + + auto sinceStale = awaitQt(handlerC.execute(GetEventsSince{.lastEventId = lastEventId})).events; + REQUIRE(sinceStale.size() == 1); + CHECK(sinceStale.front().kind == "comment"); + CHECK(sinceStale.front().id.value > lastEventId.value); +} diff --git a/examples/polls/tests/test_poll_presenter.cpp b/examples/polls/tests/test_poll_presenter.cpp new file mode 100644 index 00000000..966df225 --- /dev/null +++ b/examples/polls/tests/test_poll_presenter.cpp @@ -0,0 +1,560 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// PollPresenter's own suite (Task 14, mirroring rung 2's Task 17 +// test_bookmark_presenter.cpp): each of its nine actions +// (createPoll/openPoll/getPollState/submitVotes/updateVotes/addComment/ +// finalizePoll/undoLastVoteChange/getEventsSince) round-trips through the +// presenter's own signals -- not the model directly -- across the full +// BackendRig mode matrix (Local/LocalSingleThread/Socket, +// examples/TESTING.md "The dual-mode fixture"), plus a +// validation-failure-routing case and two "emits failed, not a crash" +// cases. Domain rules (vote tallying, undo's principal-scoping, the +// event log's ordering/cursor semantics, admin-token gating, ...) already +// have a dedicated suite at the model level (test_poll_model.cpp); this +// file only proves the presenter wires each action to the right signal, +// sets busy()/idle() correctly, and neither crashes nor hangs -- the +// "translates and routes only" contract poll_presenter.hpp's own doc +// comment states (examples/IMPLEMENTATION.md rule 2). +// +// Unlike bookmarks/pastebin, this rung needs no signed token at all for +// most actions -- PollsAuthorizer permits every register/instance hook +// unconditionally (polls_authorizer.hpp's own @file comment), and +// PollModel calls no requirePrincipal() anywhere. The one real per-call +// check this rung has is FinalizePoll's requireAdmin(), comparing +// session::current()->token against the poll's own stored admin token -- +// exercised below by setting a bare (unsigned) Context::token to the +// admin token CreatePoll returned, exactly test_poll_model.cpp's/ +// test_shared_instance_lifecycle.cpp's own pattern. + +#include "poll_presenter.hpp" +#include "testkit/backend_rig.hpp" +#include "testkit/db_fixture.hpp" +#include "testkit/pump.hpp" + +#include +#include + +#include +#include + +#include +#include +#include + +namespace { + +using morph::ladder::testkit::BackendRig; +using morph::ladder::testkit::DbFixture; +using morph::ladder::testkit::Mode; +using morph::ladder::testkit::pumpUntil; + +/// @brief Builds a rig with a fresh `PollsAuthorizer`, for @p mode. Every +/// polls test file that touches `Mode::Socket` passes an explicit +/// authorizer (test_shared_instance_lifecycle.cpp's own +/// `makeRig`-shaped call sites) -- this mirrors that, even though +/// `PollsAuthorizer` behaves identically to the default for every +/// action this suite exercises (see this file's own top comment). +[[nodiscard]] std::unique_ptr makeRig(Mode mode, std::size_t nClients = 1) { + return std::make_unique(mode, nClients, std::make_shared()); +} + +} // namespace + +TEST_CASE("PollPresenter::createPoll then openPoll round-trips a poll, all three backend modes", + "[polls][presenter]") { + const auto mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + auto rig = makeRig(mode); + polls::gui::PollPresenter presenter{rig->bridge(0), rig->executor()}; + + polls::CreatePollResult createdResult; + bool created = false; + QObject::connect(&presenter, &polls::gui::PollPresenter::created, [&](polls::CreatePollResult result) { + createdResult = std::move(result); + created = true; + }); + presenter.createPoll(polls::CreatePoll{.title = "Team offsite", .options = {{"2026-09-01"}, {"2026-09-02"}}}); + REQUIRE(pumpUntil([&] { return created; })); + REQUIRE_FALSE(presenter.busy()); + CHECK_FALSE(createdResult.pollId.empty()); + REQUIRE(createdResult.adminToken.hasValue()); + REQUIRE(createdResult.participantToken.hasValue()); + CHECK_FALSE((*createdResult.adminToken).empty()); + CHECK_FALSE((*createdResult.participantToken).empty()); + + polls::GetPollStateResult opened; + bool gotOpened = false; + QObject::connect(&presenter, &polls::gui::PollPresenter::opened, [&](polls::GetPollStateResult result) { + opened = std::move(result); + gotOpened = true; + }); + presenter.openPoll(createdResult.pollId); + REQUIRE(pumpUntil([&] { return gotOpened; })); + REQUIRE_FALSE(presenter.busy()); + CHECK(opened.pollId == createdResult.pollId); + CHECK(opened.title == "Team offsite"); + REQUIRE(opened.options.size() == 2); + CHECK(opened.options[0].label == "2026-09-01"); + CHECK(opened.options[1].label == "2026-09-02"); + CHECK(opened.finalized == polls::Finalized::No); +} + +TEST_CASE("PollPresenter::getPollState after openPoll returns the same poll's state, all three backend modes", + "[polls][presenter]") { + const auto mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + auto rig = makeRig(mode); + polls::gui::PollPresenter presenter{rig->bridge(0), rig->executor()}; + + polls::CreatePollResult createdResult; + bool created = false; + QObject::connect(&presenter, &polls::gui::PollPresenter::created, + [&](polls::CreatePollResult result) { + createdResult = std::move(result); + created = true; + }); + presenter.createPoll(polls::CreatePoll{.title = "Lunch spot", .options = {{"Cafe"}, {"Diner"}}}); + REQUIRE(pumpUntil([&] { return created; })); + + bool gotOpened = false; + QObject::connect(&presenter, &polls::gui::PollPresenter::opened, + [&](polls::GetPollStateResult) { gotOpened = true; }); + presenter.openPoll(createdResult.pollId); + REQUIRE(pumpUntil([&] { return gotOpened; })); + + polls::GetPollStateResult state; + bool gotState = false; + QObject::connect(&presenter, &polls::gui::PollPresenter::stateLoaded, [&](polls::GetPollStateResult result) { + state = std::move(result); + gotState = true; + }); + presenter.getPollState(polls::GetPollState{}); + REQUIRE(pumpUntil([&] { return gotState; })); + REQUIRE_FALSE(presenter.busy()); + CHECK(state.pollId == createdResult.pollId); + CHECK(state.title == "Lunch spot"); + REQUIRE(state.options.size() == 2); +} + +TEST_CASE("PollPresenter::submitVotes tallies a participant's vote, all three backend modes", + "[polls][presenter]") { + const auto mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + auto rig = makeRig(mode); + polls::gui::PollPresenter presenter{rig->bridge(0), rig->executor()}; + + polls::CreatePollResult createdResult; + bool created = false; + QObject::connect(&presenter, &polls::gui::PollPresenter::created, + [&](polls::CreatePollResult result) { + createdResult = std::move(result); + created = true; + }); + presenter.createPoll(polls::CreatePoll{.title = "T", .options = {{"1"}, {"2"}}}); + REQUIRE(pumpUntil([&] { return created; })); + + polls::GetPollStateResult opened; + bool gotOpened = false; + QObject::connect(&presenter, &polls::gui::PollPresenter::opened, [&](polls::GetPollStateResult result) { + opened = std::move(result); + gotOpened = true; + }); + presenter.openPoll(createdResult.pollId); + REQUIRE(pumpUntil([&] { return gotOpened; })); + REQUIRE(opened.options.size() == 2); + + polls::GetPollStateResult afterVote; + bool gotVote = false; + QObject::connect(&presenter, &polls::gui::PollPresenter::votesSubmitted, + [&](polls::GetPollStateResult result) { + afterVote = std::move(result); + gotVote = true; + }); + presenter.submitVotes(polls::SubmitVotes{ + .participantName = "alice", .votes = {{.optionId = opened.options[0].id, .choice = polls::VoteChoice::Yes}}}); + REQUIRE(pumpUntil([&] { return gotVote; })); + REQUIRE_FALSE(presenter.busy()); + REQUIRE(afterVote.votes.size() == 1); + CHECK(afterVote.votes.front().participantName == "alice"); + CHECK(afterVote.options[0].yesCount == polls::Count::fromDouble(1.0)); +} + +TEST_CASE("PollPresenter::updateVotes replaces a participant's votes wholesale, all three backend modes", + "[polls][presenter]") { + const auto mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + auto rig = makeRig(mode); + polls::gui::PollPresenter presenter{rig->bridge(0), rig->executor()}; + + polls::CreatePollResult createdResult; + bool created = false; + QObject::connect(&presenter, &polls::gui::PollPresenter::created, + [&](polls::CreatePollResult result) { + createdResult = std::move(result); + created = true; + }); + presenter.createPoll(polls::CreatePoll{.title = "T", .options = {{"1"}, {"2"}}}); + REQUIRE(pumpUntil([&] { return created; })); + + polls::GetPollStateResult opened; + bool gotOpened = false; + QObject::connect(&presenter, &polls::gui::PollPresenter::opened, [&](polls::GetPollStateResult result) { + opened = std::move(result); + gotOpened = true; + }); + presenter.openPoll(createdResult.pollId); + REQUIRE(pumpUntil([&] { return gotOpened; })); + + bool submitted = false; + QObject::connect(&presenter, &polls::gui::PollPresenter::votesSubmitted, + [&](polls::GetPollStateResult) { submitted = true; }); + presenter.submitVotes(polls::SubmitVotes{ + .participantName = "alice", .votes = {{.optionId = opened.options[0].id, .choice = polls::VoteChoice::Yes}}}); + REQUIRE(pumpUntil([&] { return submitted; })); + + polls::GetPollStateResult afterUpdate; + bool updated = false; + QObject::connect(&presenter, &polls::gui::PollPresenter::votesUpdated, [&](polls::GetPollStateResult result) { + afterUpdate = std::move(result); + updated = true; + }); + presenter.updateVotes(polls::UpdateVotes{ + .participantName = "alice", .votes = {{.optionId = opened.options[1].id, .choice = polls::VoteChoice::Yes}}}); + REQUIRE(pumpUntil([&] { return updated; })); + REQUIRE_FALSE(presenter.busy()); + REQUIRE(afterUpdate.votes.size() == 1); + CHECK(afterUpdate.options[0].yesCount == polls::Count::fromDouble(0.0)); // alice's old vote is gone + CHECK(afterUpdate.options[1].yesCount == polls::Count::fromDouble(1.0)); +} + +TEST_CASE("PollPresenter::addComment writes a comment visible in the next getPollState, all three backend modes", + "[polls][presenter]") { + const auto mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + auto rig = makeRig(mode); + polls::gui::PollPresenter presenter{rig->bridge(0), rig->executor()}; + + polls::CreatePollResult createdResult; + bool created = false; + QObject::connect(&presenter, &polls::gui::PollPresenter::created, + [&](polls::CreatePollResult result) { + createdResult = std::move(result); + created = true; + }); + presenter.createPoll(polls::CreatePoll{.title = "T", .options = {{"1"}, {"2"}}}); + REQUIRE(pumpUntil([&] { return created; })); + + bool gotOpened = false; + QObject::connect(&presenter, &polls::gui::PollPresenter::opened, + [&](polls::GetPollStateResult) { gotOpened = true; }); + presenter.openPoll(createdResult.pollId); + REQUIRE(pumpUntil([&] { return gotOpened; })); + + polls::GetPollStateResult afterComment; + bool commented = false; + QObject::connect(&presenter, &polls::gui::PollPresenter::commentAdded, [&](polls::GetPollStateResult result) { + afterComment = std::move(result); + commented = true; + }); + presenter.addComment(polls::AddComment{.participantName = "alice", .body = "works for me"}); + REQUIRE(pumpUntil([&] { return commented; })); + REQUIRE_FALSE(presenter.busy()); + REQUIRE(afterComment.comments.size() == 1); + CHECK(afterComment.comments.front().body == "works for me"); + CHECK(afterComment.comments.front().participantName == "alice"); +} + +TEST_CASE("PollPresenter::finalizePoll marks the poll finalized given the admin token, all three backend modes", + "[polls][presenter]") { + const auto mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + auto rig = makeRig(mode); + polls::gui::PollPresenter presenter{rig->bridge(0), rig->executor()}; + + polls::CreatePollResult createdResult; + bool created = false; + QObject::connect(&presenter, &polls::gui::PollPresenter::created, + [&](polls::CreatePollResult result) { + createdResult = std::move(result); + created = true; + }); + presenter.createPoll(polls::CreatePoll{.title = "T", .options = {{"1"}, {"2"}}}); + REQUIRE(pumpUntil([&] { return created; })); + + polls::GetPollStateResult opened; + bool gotOpened = false; + QObject::connect(&presenter, &polls::gui::PollPresenter::opened, [&](polls::GetPollStateResult result) { + opened = std::move(result); + gotOpened = true; + }); + presenter.openPoll(createdResult.pollId); + REQUIRE(pumpUntil([&] { return gotOpened; })); + + // The bare (unsigned) admin token in Context::token is this rung's whole + // admin identity -- see this file's own top comment. + morph::session::Context ctx; + ctx.token = *createdResult.adminToken; + rig->bridge(0).setDefaultSession(ctx); + + polls::GetPollStateResult finalizedResult; + bool finalizedFired = false; + QObject::connect(&presenter, &polls::gui::PollPresenter::finalized, [&](polls::GetPollStateResult result) { + finalizedResult = std::move(result); + finalizedFired = true; + }); + presenter.finalizePoll(polls::FinalizePoll{.optionId = opened.options[0].id}); + REQUIRE(pumpUntil([&] { return finalizedFired; })); + REQUIRE_FALSE(presenter.busy()); + CHECK(finalizedResult.finalized == polls::Finalized::Yes); + CHECK(finalizedResult.finalizedOptionId == opened.options[0].id); +} + +TEST_CASE("PollPresenter::undoLastVoteChange reverses a participant's own last vote, all three backend modes", + "[polls][presenter]") { + const auto mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + auto rig = makeRig(mode); + polls::gui::PollPresenter presenter{rig->bridge(0), rig->executor()}; + + polls::CreatePollResult createdResult; + bool created = false; + QObject::connect(&presenter, &polls::gui::PollPresenter::created, + [&](polls::CreatePollResult result) { + createdResult = std::move(result); + created = true; + }); + presenter.createPoll(polls::CreatePoll{.title = "T", .options = {{"1"}, {"2"}}}); + REQUIRE(pumpUntil([&] { return created; })); + + polls::GetPollStateResult opened; + bool gotOpened = false; + QObject::connect(&presenter, &polls::gui::PollPresenter::opened, [&](polls::GetPollStateResult result) { + opened = std::move(result); + gotOpened = true; + }); + presenter.openPoll(createdResult.pollId); + REQUIRE(pumpUntil([&] { return gotOpened; })); + + bool submitted = false; + QObject::connect(&presenter, &polls::gui::PollPresenter::votesSubmitted, + [&](polls::GetPollStateResult) { submitted = true; }); + presenter.submitVotes(polls::SubmitVotes{ + .participantName = "alice", .votes = {{.optionId = opened.options[0].id, .choice = polls::VoteChoice::Yes}}}); + REQUIRE(pumpUntil([&] { return submitted; })); + + polls::UndoLastVoteChangeResult undoResult; + bool undone = false; + QObject::connect(&presenter, &polls::gui::PollPresenter::voteChangeUndone, + [&](polls::UndoLastVoteChangeResult result) { + undoResult = result; + undone = true; + }); + presenter.undoLastVoteChange(polls::UndoLastVoteChange{.participantName = "alice"}); + REQUIRE(pumpUntil([&] { return undone; })); + REQUIRE_FALSE(presenter.busy()); + CHECK(undoResult.restored == polls::Restored::Yes); + + polls::GetPollStateResult afterUndo; + bool gotState = false; + QObject::connect(&presenter, &polls::gui::PollPresenter::stateLoaded, [&](polls::GetPollStateResult result) { + afterUndo = std::move(result); + gotState = true; + }); + presenter.getPollState(polls::GetPollState{}); + REQUIRE(pumpUntil([&] { return gotState; })); + CHECK(afterUndo.votes.empty()); + CHECK(afterUndo.options[0].yesCount == polls::Count::fromDouble(0.0)); +} + +TEST_CASE("PollPresenter::getEventsSince returns every event recorded on this handler's attached poll, " + "all three backend modes", + "[polls][presenter]") { + const auto mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + auto rig = makeRig(mode); + polls::gui::PollPresenter presenter{rig->bridge(0), rig->executor()}; + + polls::CreatePollResult createdResult; + bool created = false; + QObject::connect(&presenter, &polls::gui::PollPresenter::created, + [&](polls::CreatePollResult result) { + createdResult = std::move(result); + created = true; + }); + presenter.createPoll(polls::CreatePoll{.title = "T", .options = {{"1"}, {"2"}}}); + REQUIRE(pumpUntil([&] { return created; })); + + polls::GetPollStateResult opened; + bool gotOpened = false; + QObject::connect(&presenter, &polls::gui::PollPresenter::opened, [&](polls::GetPollStateResult result) { + opened = std::move(result); + gotOpened = true; + }); + presenter.openPoll(createdResult.pollId); + REQUIRE(pumpUntil([&] { return gotOpened; })); + + bool submitted = false; + QObject::connect(&presenter, &polls::gui::PollPresenter::votesSubmitted, + [&](polls::GetPollStateResult) { submitted = true; }); + presenter.submitVotes(polls::SubmitVotes{ + .participantName = "alice", .votes = {{.optionId = opened.options[0].id, .choice = polls::VoteChoice::Yes}}}); + REQUIRE(pumpUntil([&] { return submitted; })); + + bool commented = false; + QObject::connect(&presenter, &polls::gui::PollPresenter::commentAdded, + [&](polls::GetPollStateResult) { commented = true; }); + presenter.addComment(polls::AddComment{.participantName = "alice", .body = "hi"}); + REQUIRE(pumpUntil([&] { return commented; })); + + polls::GetEventsSinceResult events; + bool gotEvents = false; + QObject::connect(&presenter, &polls::gui::PollPresenter::eventsReceived, + [&](polls::GetEventsSinceResult result) { + events = std::move(result); + gotEvents = true; + }); + presenter.getEventsSince(polls::GetEventsSince{}); + REQUIRE(pumpUntil([&] { return gotEvents; })); + REQUIRE_FALSE(presenter.busy()); + REQUIRE(events.events.size() == 2); + CHECK(events.events[0].kind == "vote"); + CHECK(events.events[1].kind == "comment"); + CHECK(events.events[0].id.value < events.events[1].id.value); +} + +TEST_CASE("Every PollPresenter validation-driven action routes its failure to failed(), not just createPoll()", + "[polls][presenter]") { + // Not a completeness ritual: each action's `reportError` is wired + // independently at its own `track()` call site (`poll_presenter.cpp`), + // so a passing test for one action says nothing about whether another + // action's wiring is correct. See test_bookmark_presenter.cpp's + // identical test for the same rationale. + // getPollState/getEventsSince are excluded here (both have + // `validate() { return true; }` unconditionally -- their only reachable + // failure is the genuine "never attached via openPoll" NotFound covered + // by the dedicated case below. + DbFixture fixture; + auto rig = makeRig(Mode::Local); + polls::gui::PollPresenter presenter{rig->bridge(0), rig->executor()}; + + QString failure; + int failures = 0; + QObject::connect(&presenter, &polls::gui::PollPresenter::failed, [&](QString message) { + failure = message; + ++failures; + }); + + // createPoll: empty title and no options both fail CreatePoll::validate(). + presenter.createPoll(polls::CreatePoll{}); + REQUIRE(pumpUntil([&] { return failures == 1; })); + REQUIRE_FALSE(presenter.busy()); + + // openPoll: an empty pollId fails OpenPoll::validate(). + presenter.openPoll(""); + REQUIRE(pumpUntil([&] { return failures == 2; })); + REQUIRE_FALSE(presenter.busy()); + + // submitVotes/updateVotes: empty participantName and empty votes both fail validate(). + presenter.submitVotes(polls::SubmitVotes{}); + REQUIRE(pumpUntil([&] { return failures == 3; })); + presenter.updateVotes(polls::UpdateVotes{}); + REQUIRE(pumpUntil([&] { return failures == 4; })); + REQUIRE_FALSE(presenter.busy()); + + // addComment: empty participantName/body fails validate(). + presenter.addComment(polls::AddComment{}); + REQUIRE(pumpUntil([&] { return failures == 5; })); + REQUIRE_FALSE(presenter.busy()); + + // finalizePoll: a disengaged optionId fails validate(). + presenter.finalizePoll(polls::FinalizePoll{}); + REQUIRE(pumpUntil([&] { return failures == 6; })); + REQUIRE_FALSE(presenter.busy()); + + // undoLastVoteChange: an empty participantName fails validate(). + presenter.undoLastVoteChange(polls::UndoLastVoteChange{}); + REQUIRE(pumpUntil([&] { return failures == 7; })); + REQUIRE_FALSE(presenter.busy()); + CHECK_FALSE(failure.isEmpty()); +} + +TEST_CASE("PollPresenter::getPollState and getEventsSince against a handler never attached via openPoll " + "emit failed, not a crash", + "[polls][presenter]") { + // PollModel::execute(GetPollState)/execute(GetEventsSince) both throw + // NotFound when this handler's own _pollId was never populated by a + // prior execute(OpenPoll) (poll_model.cpp) -- proves the presenter + // surfaces that as failed() rather than crashing, using a handler that + // never called openPoll() at all. + DbFixture fixture; + auto rig = makeRig(Mode::Local); + polls::gui::PollPresenter presenter{rig->bridge(0), rig->executor()}; + + QString failure; + int failures = 0; + QObject::connect(&presenter, &polls::gui::PollPresenter::failed, [&](QString message) { + failure = message; + ++failures; + }); + + presenter.getPollState(polls::GetPollState{}); + REQUIRE(pumpUntil([&] { return failures == 1; })); + REQUIRE_FALSE(presenter.busy()); + + presenter.getEventsSince(polls::GetEventsSince{}); + REQUIRE(pumpUntil([&] { return failures == 2; })); + CHECK_FALSE(failure.isEmpty()); + REQUIRE_FALSE(presenter.busy()); +} + +TEST_CASE("PollPresenter::finalizePoll with no session at all emits failed, not a crash", "[polls][presenter]") { + // Mirrors test_bookmark_presenter.cpp's own "no session at all" case, + // adapted to this rung's actual auth shape -- see this file's own top + // comment. createPoll/openPoll need no session at all (PollsAuthorizer + // permits everything, and neither action's model code checks + // session::current()); only finalizePoll's requireAdmin() genuinely + // checks Context::token, so a bridge that never had setDefaultSession + // called on it reaches that check with an empty token, which can never + // equal a real admin token. + DbFixture fixture; + auto rig = makeRig(Mode::Local); + polls::gui::PollPresenter presenter{rig->bridge(0), rig->executor()}; + + polls::CreatePollResult createdResult; + bool created = false; + QObject::connect(&presenter, &polls::gui::PollPresenter::created, + [&](polls::CreatePollResult result) { + createdResult = std::move(result); + created = true; + }); + presenter.createPoll(polls::CreatePoll{.title = "T", .options = {{"1"}, {"2"}}}); + REQUIRE(pumpUntil([&] { return created; })); + + polls::GetPollStateResult opened; + bool gotOpened = false; + QObject::connect(&presenter, &polls::gui::PollPresenter::opened, [&](polls::GetPollStateResult result) { + opened = std::move(result); + gotOpened = true; + }); + presenter.openPoll(createdResult.pollId); + REQUIRE(pumpUntil([&] { return gotOpened; })); + + QString failure; + bool failed = false; + QObject::connect(&presenter, &polls::gui::PollPresenter::failed, [&](QString message) { + failure = message; + failed = true; + }); + presenter.finalizePoll(polls::FinalizePoll{.optionId = opened.options[0].id}); + REQUIRE(pumpUntil([&] { return failed; })); + CHECK_FALSE(failure.isEmpty()); + REQUIRE_FALSE(presenter.busy()); +} diff --git a/examples/polls/tests/test_poll_qml_bridges.cpp b/examples/polls/tests/test_poll_qml_bridges.cpp new file mode 100644 index 00000000..6eaeea01 --- /dev/null +++ b/examples/polls/tests/test_poll_qml_bridges.cpp @@ -0,0 +1,501 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// The QML-adapter layer's own suite: `PollBridge` (`gui_lib/poll_qml_bridges.hpp`) +// and the `PollFormsController` it wraps (`gui_lib/poll_forms_controller.hpp`) +// — everything that stands between `PollPresenter`/`PollModel` and +// `gui/qml/{Main,CreatePollView,VoteView}.qml`. Mirrors +// examples/bookmarks/tests/test_bookmark_qml_bridges.cpp's shape and +// rationale (rung 2's Task 18) — read that file's own header comment for why +// this layer needs its own suite distinct from test_poll_presenter.cpp; the +// same reasoning applies verbatim here (QML binds by *string*, so a renamed +// key, a mistyped action id or a changed signal signature is not a compile +// error anywhere). +// +// One thing this suite proves that has no rung-2 analogue at all: that every +// already-open-poll action really does share PollFormsController's one +// `BridgeHandler` correctly. PollModel is this +// rung's shared/keyed model (rung 2's three models are all plain); a second, +// independently-attached handler for e.g. AddComment would fail "handler not +// bound" until it separately attached — the "openPoll then AddComment/ +// FinalizePoll/UndoLastVoteChange/submitVotes/updateVotes/refresh/ +// getEventsSince all succeed" cases below are the direct proof that never +// happens here (see poll_forms_controller.hpp's own doc comment for the full +// design rationale). + +#include "poll_qml_bridges.hpp" +#include "poll_schemas.hpp" +#include "polls/auth/polls_authorizer.hpp" +#include "testkit/backend_rig.hpp" +#include "testkit/db_fixture.hpp" +#include "testkit/pump.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace { + +using morph::ladder::testkit::BackendRig; +using morph::ladder::testkit::DbFixture; +using morph::ladder::testkit::Mode; +using morph::ladder::testkit::pumpUntil; + +/// @brief Builds a rig with a fresh `PollsAuthorizer` — every polls test +/// file that touches `Mode::Socket` passes an explicit authorizer; +/// this suite stays on `Mode::Local` throughout (the presenter suite +/// already covers the full backend-mode matrix per action), but +/// matches the same construction shape for consistency. +[[nodiscard]] std::unique_ptr makeRig() { + return std::make_unique(Mode::Local, 1, std::make_shared()); +} + +/// @brief How many methods a class declares itself (signals + `Q_INVOKABLE`s), +/// i.e. excluding everything it inherits from `QObject`. +[[nodiscard]] int ownMethodCount(const QMetaObject* meta) { return meta->methodCount() - meta->methodOffset(); } + +/// @brief One `pollBridge.createPoll(title, optionLabels)` round trip. +/// @param bridge The bridge to create through. +/// @param title The poll's title. +/// @param optionLabels Candidate option labels. +/// @return `{ok, bag-or-message}` from the single `created`/`failed` signal. +[[nodiscard]] std::pair createVia(polls::gui::PollBridge& bridge, const QString& title, + const QVariantList& optionLabels) { + QVariantMap bag; + QString failure; + bool settled = false; + bool ok = false; + const auto onCreated = QObject::connect(&bridge, &polls::gui::PollBridge::created, [&](const QVariantMap& result) { + bag = result; + ok = true; + settled = true; + }); + const auto onFailed = QObject::connect(&bridge, &polls::gui::PollBridge::failed, [&](const QString& message) { + failure = message; + ok = false; + settled = true; + }); + bridge.createPoll(title, optionLabels); + REQUIRE(pumpUntil([&] { return settled; })); + QObject::disconnect(onCreated); + QObject::disconnect(onFailed); + if (!ok) { + bag.insert(QStringLiteral("__error"), failure); + } + return {ok, bag}; +} + +/// @brief One `pollBridge.openPoll(pollId)` round trip. +/// @param bridge The bridge to open through. +/// @param pollId The poll to attach to. +/// @return `{ok, state-bag-or-message}` from the single `opened`/`failed` signal. +[[nodiscard]] std::pair openVia(polls::gui::PollBridge& bridge, const QString& pollId) { + QVariantMap bag; + QString failure; + bool settled = false; + bool ok = false; + const auto onOpened = QObject::connect(&bridge, &polls::gui::PollBridge::opened, [&](const QVariantMap& state) { + bag = state; + ok = true; + settled = true; + }); + const auto onFailed = QObject::connect(&bridge, &polls::gui::PollBridge::failed, [&](const QString& message) { + failure = message; + ok = false; + settled = true; + }); + bridge.openPoll(pollId); + REQUIRE(pumpUntil([&] { return settled; })); + QObject::disconnect(onOpened); + QObject::disconnect(onFailed); + if (!ok) { + bag.insert(QStringLiteral("__error"), failure); + } + return {ok, bag}; +} + +/// @brief One `stateChanged`/`failed` round trip driven by @p act (e.g. +/// `refresh`, `submitVotes`, `updateVotes`). +/// @param bridge The bridge the action runs against. +/// @param act Callable that triggers exactly one such round trip. +/// @return `{ok, state-bag-or-message}`. +template +[[nodiscard]] std::pair stateChangeVia(polls::gui::PollBridge& bridge, Act act) { + QVariantMap bag; + QString failure; + bool settled = false; + bool ok = false; + const auto onChanged = + QObject::connect(&bridge, &polls::gui::PollBridge::stateChanged, [&](const QVariantMap& state) { + bag = state; + ok = true; + settled = true; + }); + const auto onFailed = QObject::connect(&bridge, &polls::gui::PollBridge::failed, [&](const QString& message) { + failure = message; + ok = false; + settled = true; + }); + act(); + REQUIRE(pumpUntil([&] { return settled; })); + QObject::disconnect(onChanged); + QObject::disconnect(onFailed); + if (!ok) { + bag.insert(QStringLiteral("__error"), failure); + } + return {ok, bag}; +} + +/// @brief One `pollBridge.submitIfValid(actionType, bodyJson)` round trip. +/// @param bridge The bridge to submit through. +/// @param actionType The schema-driven action id. +/// @param bodyJson The `DynamicForm`-shaped JSON body. +/// @return `{ok, payload}` from the single `replyReceived`. +[[nodiscard]] std::pair submitVia(polls::gui::PollBridge& bridge, const QString& actionType, + const QString& bodyJson) { + bool ok = false; + QString payload; + QString echoedType; + bool replied = false; + const auto connection = QObject::connect(&bridge, &polls::gui::PollBridge::replyReceived, + [&](const QString& type, bool succeeded, const QString& body) { + echoedType = type; + ok = succeeded; + payload = body; + replied = true; + }); + bridge.submitIfValid(actionType, bodyJson); + REQUIRE(pumpUntil([&] { return replied; })); + QObject::disconnect(connection); + // VoteView.qml:98-106 dispatches on the echoed type, so a normalised or + // empty echo would misroute every outcome on that screen. + REQUIRE(echoedType == actionType); + return {ok, payload}; +} + +/// @brief Finds the first option's `id` in a `GetPollStateResult` bag's +/// `options` list. +/// @param stateBag A bag as `opened`/`stateChanged` carries it. +/// @return The first option's numeric id. +[[nodiscard]] qlonglong firstOptionId(const QVariantMap& stateBag) { + const QVariantList options = stateBag.value(QStringLiteral("options")).toList(); + REQUIRE_FALSE(options.isEmpty()); + return options.front().toMap().value(QStringLiteral("id")).toLongLong(); +} + +} // namespace + +// ═════════════════════════════════════════════════════════════════════════ +// The QML-visible surface: names and signatures QML binds by string +// ═════════════════════════════════════════════════════════════════════════ + +TEST_CASE("PollBridge exposes exactly the surface Main.qml/CreatePollView.qml/VoteView.qml bind against", + "[polls][gui][qml-bridges]") { + DbFixture fixture; + auto rig = makeRig(); + polls::gui::PollBridge bridge{rig->bridge(0), rig->executor()}; + + const QMetaObject* meta = bridge.metaObject(); + + // `root.pollBridge.schemasJson` — Main.qml. + REQUIRE(meta->indexOfProperty("schemasJson") >= 0); + CHECK(meta->property(meta->indexOfProperty("schemasJson")).isConstant()); + CHECK(meta->propertyCount() - meta->propertyOffset() == 1); + + // `page.pollBridge.createPoll(...)` — CreatePollView.qml. + REQUIRE(meta->indexOfMethod("createPoll(QString,QVariantList)") >= 0); + // `page.pollBridge.openPoll(...)` — VoteView.qml (Component.onCompleted) + // and Main.qml's landing screen. + REQUIRE(meta->indexOfMethod("openPoll(QString)") >= 0); + REQUIRE(meta->indexOfMethod("refresh()") >= 0); + REQUIRE(meta->indexOfMethod("submitVotes(QString,QVariantList)") >= 0); + REQUIRE(meta->indexOfMethod("updateVotes(QString,QVariantList)") >= 0); + REQUIRE(meta->indexOfMethod("setAdminToken(QString)") >= 0); + REQUIRE(meta->indexOfMethod("submitIfValid(QString,QString)") >= 0); + REQUIRE(meta->indexOfMethod("stopPolling()") >= 0); + + REQUIRE(meta->indexOfSignal("created(QVariantMap)") >= 0); + REQUIRE(meta->indexOfSignal("opened(QVariantMap)") >= 0); + REQUIRE(meta->indexOfSignal("stateChanged(QVariantMap)") >= 0); + REQUIRE(meta->indexOfSignal("eventReceived(QVariantMap)") >= 0); + REQUIRE(meta->indexOfSignal("replyReceived(QString,bool,QString)") >= 0); + REQUIRE(meta->indexOfSignal("pollingStopped(QString)") >= 0); + REQUIRE(meta->indexOfSignal("failed(QString)") >= 0); + + // Nothing else: an adapter method with no binding site is a stub, and one + // removed from under a binding is a silent runtime gap. + CHECK(ownMethodCount(meta) == 15); + + // The property's value is the shared schema document, verbatim — the + // same one every shell builds (poll_schemas.hpp exists so they cannot + // diverge), and `JSON.parse`-able, since Main.qml does exactly that. + CHECK(bridge.schemasJson().toStdString() == polls::gui::pollSchemasJson()); + const QJsonDocument schemas = QJsonDocument::fromJson(bridge.schemasJson().toUtf8()); + REQUIRE(schemas.isObject()); + for (const char* actionType : {"AddComment", "FinalizePoll", "UndoLastVoteChange"}) { + INFO("missing schema: " << actionType); + CHECK(schemas.object().contains(QString::fromLatin1(actionType))); + } + CHECK(schemas.object().size() == 3); +} + +// ═════════════════════════════════════════════════════════════════════════ +// createPoll / openPoll +// ═════════════════════════════════════════════════════════════════════════ + +TEST_CASE("PollBridge::createPoll emits a {pollId, adminToken, participantToken} bag with no leaked field", + "[polls][gui][qml-bridges]") { + DbFixture fixture; + auto rig = makeRig(); + polls::gui::PollBridge bridge{rig->bridge(0), rig->executor()}; + + const auto [ok, bag] = + createVia(bridge, QStringLiteral("Team offsite"), QVariantList{QStringLiteral("2026-09-01"), QStringLiteral("2026-09-02")}); + REQUIRE(ok); + for (const char* key : {"pollId", "adminToken", "participantToken"}) { + INFO("missing key: " << key); + REQUIRE(bag.contains(QString::fromLatin1(key))); + } + CHECK(bag.size() == 3); + CHECK_FALSE(bag.value(QStringLiteral("pollId")).toString().isEmpty()); + CHECK_FALSE(bag.value(QStringLiteral("adminToken")).toString().isEmpty()); + CHECK_FALSE(bag.value(QStringLiteral("participantToken")).toString().isEmpty()); +} + +TEST_CASE("PollBridge::createPoll with fewer than two options emits failed, not a crash", + "[polls][gui][qml-bridges]") { + DbFixture fixture; + auto rig = makeRig(); + polls::gui::PollBridge bridge{rig->bridge(0), rig->executor()}; + + const auto [ok, bag] = createVia(bridge, QStringLiteral("T"), QVariantList{QStringLiteral("only one")}); + CHECK_FALSE(ok); + CHECK_FALSE(bag.value(QStringLiteral("__error")).toString().isEmpty()); +} + +TEST_CASE("PollBridge::openPoll emits the poll's full state, and a bad pollId emits failed", + "[polls][gui][qml-bridges]") { + DbFixture fixture; + auto rig = makeRig(); + polls::gui::PollBridge bridge{rig->bridge(0), rig->executor()}; + + const auto [created, createdBag] = + createVia(bridge, QStringLiteral("Lunch spot"), QVariantList{QStringLiteral("Cafe"), QStringLiteral("Diner")}); + REQUIRE(created); + const QString pollId = createdBag.value(QStringLiteral("pollId")).toString(); + + const auto [ok, state] = openVia(bridge, pollId); + REQUIRE(ok); + for (const char* key : {"pollId", "title", "finalized", "finalizedOptionId", "options", "votes", "comments", + "lastEventId"}) { + INFO("missing key: " << key); + REQUIRE(state.contains(QString::fromLatin1(key))); + } + CHECK(state.size() == 8); + CHECK(state.value(QStringLiteral("pollId")).toString() == pollId); + CHECK(state.value(QStringLiteral("title")).toString() == QStringLiteral("Lunch spot")); + CHECK_FALSE(state.value(QStringLiteral("finalized")).toBool()); + // Unengaged (no finalize yet, freshly opened -- lastEventId not yet + // advanced): both render as -1, this rung's "not entered" sentinel. + CHECK(state.value(QStringLiteral("finalizedOptionId")).toLongLong() == -1); + CHECK(state.value(QStringLiteral("lastEventId")).toLongLong() == -1); + const QVariantList options = state.value(QStringLiteral("options")).toList(); + REQUIRE(options.size() == 2); + CHECK(options[0].toMap().value(QStringLiteral("label")).toString() == QStringLiteral("Cafe")); + CHECK(options[0].toMap().value(QStringLiteral("yesCount")).toString() == QStringLiteral("0")); + + const auto [badOk, badBag] = openVia(bridge, QStringLiteral("no-such-poll-id")); + CHECK_FALSE(badOk); + CHECK_FALSE(badBag.value(QStringLiteral("__error")).toString().isEmpty()); +} + +// ═════════════════════════════════════════════════════════════════════════ +// The shared-handler proof: openPoll, then every other action on the same +// poll, all through PollFormsController's one BridgeHandler +// ═════════════════════════════════════════════════════════════════════════ + +TEST_CASE("PollBridge threads openPoll's attach through every later action on the same poll", + "[polls][gui][qml-bridges]") { + DbFixture fixture; + auto rig = makeRig(); + polls::gui::PollBridge bridge{rig->bridge(0), rig->executor()}; + + const auto [created, createdBag] = + createVia(bridge, QStringLiteral("T"), QVariantList{QStringLiteral("1"), QStringLiteral("2")}); + REQUIRE(created); + const QString pollId = createdBag.value(QStringLiteral("pollId")).toString(); + const QString adminToken = createdBag.value(QStringLiteral("adminToken")).toString(); + + const auto [opened, openedState] = openVia(bridge, pollId); + REQUIRE(opened); + const qlonglong optionId = firstOptionId(openedState); + + // submitVotes -- would fail "handler not bound" if PollFormsController + // used a second, independently-attached handler instead of reusing the + // one openPoll() just attached. + const auto [votedOk, votedState] = stateChangeVia(bridge, [&] { + bridge.submitVotes(QStringLiteral("alice"), + QVariantList{QVariantMap{{"optionId", optionId}, {"choice", QStringLiteral("Yes")}}}); + }); + REQUIRE(votedOk); + const QVariantList votedOptions = votedState.value(QStringLiteral("options")).toList(); + CHECK(votedOptions.front().toMap().value(QStringLiteral("yesCount")).toString() == QStringLiteral("1")); + + // updateVotes -- same handler, different action. + const auto [updatedOk, updatedState] = stateChangeVia(bridge, [&] { + bridge.updateVotes(QStringLiteral("alice"), + QVariantList{QVariantMap{{"optionId", optionId}, {"choice", QStringLiteral("No")}}}); + }); + REQUIRE(updatedOk); + const QVariantList updatedOptions = updatedState.value(QStringLiteral("options")).toList(); + CHECK(updatedOptions.front().toMap().value(QStringLiteral("yesCount")).toString() == QStringLiteral("0")); + CHECK(updatedOptions.front().toMap().value(QStringLiteral("noCount")).toString() == QStringLiteral("1")); + + // AddComment -- schema-driven, via submitIfValid. + const auto [commentOk, commentPayload] = + submitVia(bridge, QStringLiteral("AddComment"), + QStringLiteral(R"({"participantName":"alice","body":"works for me"})")); + REQUIRE(commentOk); + CHECK(commentPayload.contains(QStringLiteral("works for me"))); + + // refresh -- a plain GetPollState against the same attached handler. + const auto [refreshedOk, refreshedState] = stateChangeVia(bridge, [&] { bridge.refresh(); }); + REQUIRE(refreshedOk); + CHECK(refreshedState.value(QStringLiteral("comments")).toList().size() == 1); + + // UndoLastVoteChange -- schema-driven; its result is UndoLastVoteChangeResult, + // not GetPollStateResult, so the payload shape differs from the others. + const auto [undoOk, undoPayload] = + submitVia(bridge, QStringLiteral("UndoLastVoteChange"), QStringLiteral(R"({"participantName":"alice"})")); + REQUIRE(undoOk); + // `"Yes"`, not `true`: `UndoLastVoteChangeResult::restored` is the + // two-enumerator `polls::Restored`, reflected by its own `glz::meta` + // as the enumerator name (IMPLEMENTATION.md rule 3 -- no bare bools + // in DTO fields, on the wire or off it). + CHECK(undoPayload.contains(QStringLiteral("\"restored\":\"Yes\""))); + + // FinalizePoll -- admin-token-gated; fails without the token, succeeds + // once PollBridge::setAdminToken installs it, and both dispatch through + // the same attached handler as everything above. + const auto [deniedOk, deniedPayload] = + submitVia(bridge, QStringLiteral("FinalizePoll"), QStringLiteral(R"({"optionId":%1})").arg(optionId)); + CHECK_FALSE(deniedOk); + CHECK_FALSE(deniedPayload.isEmpty()); + + bridge.setAdminToken(adminToken); + const auto [finalizedOk, finalizedPayload] = + submitVia(bridge, QStringLiteral("FinalizePoll"), QStringLiteral(R"({"optionId":%1})").arg(optionId)); + REQUIRE(finalizedOk); + CHECK(finalizedPayload.contains(QStringLiteral("\"finalized\":\"Yes\""))); +} + +// ═════════════════════════════════════════════════════════════════════════ +// submitIfValid's allow-list +// ═════════════════════════════════════════════════════════════════════════ + +TEST_CASE("PollBridge::submitIfValid refuses an action outside the schema document instead of mis-dispatching it", + "[polls][gui][qml-bridges]") { + // OpenPoll in particular: PollFormsController::submitIfValid refuses it + // by name rather than routing it through executeJson -- + // ActionExecuteRegistry::registerAction now builds a Sharing-aware + // executor, so executeJson itself no longer mis-dispatches OpenPoll's + // payload-keyed attach the way it once did, but this rung's own + // PollFormsController was never migrated to rely on that fix; OpenPoll + // still goes through openPoll()'s own execute() call, and this + // guard is what keeps a caller from reaching submitIfValid's generic + // path for it instead. + DbFixture fixture; + auto rig = makeRig(); + polls::gui::PollBridge bridge{rig->bridge(0), rig->executor()}; + + for (const auto& actionType : {QStringLiteral("OpenPoll"), QStringLiteral("SubmitVotes"), + QStringLiteral("CreatePoll"), QStringLiteral("NotEvenReal")}) { + const auto [ok, payload] = submitVia(bridge, actionType, QStringLiteral("{}")); + INFO(actionType.toStdString()); + CHECK_FALSE(ok); + CHECK(payload.contains(QStringLiteral("not a schema-driven action"))); + } +} + +// ═════════════════════════════════════════════════════════════════════════ +// The live, event-driven results display -- EventPoller wired to a real view +// ═════════════════════════════════════════════════════════════════════════ + +TEST_CASE("PollBridge's EventPoller applies a live event and refreshes state, end to end", + "[polls][gui][qml-bridges][event-poller]") { + // The one genuinely slow case in this suite, deliberately: it proves the + // *real* production wiring (PollBridge's Dispatch closure over + // PollFormsController::getEventsSince, ticking on EventPoller's real + // default 3s interval -- see event_poller.hpp's own "Default poll + // interval" section) rather than a manually-driven pollOnce(), which + // PollBridge does not expose (it owns the poller privately, matching a + // real view). test_event_poller.cpp already covers the class's own + // mechanics exhaustively with an artificial long interval + manual + // ticks; this is the one place in the whole ladder that proves the + // *wiring* to a real screen's adapter actually ticks on its own. + DbFixture fixture; + auto rig = makeRig(); + polls::gui::PollBridge bridge{rig->bridge(0), rig->executor()}; + + const auto [created, createdBag] = + createVia(bridge, QStringLiteral("T"), QVariantList{QStringLiteral("1"), QStringLiteral("2")}); + REQUIRE(created); + const QString pollId = createdBag.value(QStringLiteral("pollId")).toString(); + + const auto [opened, openedState] = openVia(bridge, pollId); + REQUIRE(opened); + const qlonglong optionId = firstOptionId(openedState); + + // A vote after openPoll writes one PollEvent (kind "vote") -- the + // increment the next tick should pick up. + const auto [votedOk, votedState] = stateChangeVia(bridge, [&] { + bridge.submitVotes(QStringLiteral("alice"), + QVariantList{QVariantMap{{"optionId", optionId}, {"choice", QStringLiteral("Yes")}}}); + }); + REQUIRE(votedOk); + static_cast(votedState); + + QVariantMap event; + bool eventSeen = false; + QVariantMap resynced; + bool resyncSeen = false; + const auto onEvent = + QObject::connect(&bridge, &polls::gui::PollBridge::eventReceived, [&](const QVariantMap& e) { + event = e; + eventSeen = true; + }); + const auto onResync = + QObject::connect(&bridge, &polls::gui::PollBridge::stateChanged, [&](const QVariantMap& s) { + resynced = s; + resyncSeen = true; + }); + + // kDefaultInterval is 3000ms; a 6s budget comfortably covers one real + // tick plus dispatch/round-trip overhead without hardcoding a tighter + // margin that would make this test flaky on a loaded CI runner. + REQUIRE(pumpUntil([&] { return eventSeen; }, std::chrono::milliseconds{6000})); + QObject::disconnect(onEvent); + + CHECK(event.value(QStringLiteral("kind")).toString() == QStringLiteral("vote")); + CHECK_FALSE(event.value(QStringLiteral("summary")).toString().isEmpty()); + CHECK(event.value(QStringLiteral("id")).toLongLong() > 0); + + // onEventApplied schedules a debounced refresh() right after -- give it + // a further short budget on the same event loop. + REQUIRE(pumpUntil([&] { return resyncSeen; }, std::chrono::milliseconds{2000})); + QObject::disconnect(onResync); + CHECK(resynced.value(QStringLiteral("pollId")).toString() == pollId); +} + diff --git a/examples/polls/tests/test_polls_authorizer.cpp b/examples/polls/tests/test_polls_authorizer.cpp new file mode 100644 index 00000000..7165d3a0 --- /dev/null +++ b/examples/polls/tests/test_polls_authorizer.cpp @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// PollsAuthorizer's own suite (Task 7). Unlike bookmarks' authorizer, there +// is no signed-token verification to exercise here (see the header's own +// @file comment) -- every hook is unconditionally permissive, so these +// tests confirm exactly that against the real morph::session::IAuthorizer +// signatures, not against a guessed shape. +#include "polls/auth/polls_authorizer.hpp" + +#include +#include + +using morph::session::Context; +using polls::auth::PollsAuthorizer; + +TEST_CASE("PollsAuthorizer::authorize admits every call -- there is no signed token to verify in this rung", + "[polls][auth]") { + const PollsAuthorizer authorizer; + const Context anonymous; // no token at all + CHECK(authorizer.authorize(anonymous, "PollModel", "FinalizePoll")); + CHECK(authorizer.authorize(anonymous, "PollModel", "SubmitVotes")); + + Context withToken; + withToken.token = "not-a-signed-anything"; + CHECK(authorizer.authorize(withToken, "PollModel", "FinalizePoll")); +} + +TEST_CASE("PollsAuthorizer::authorizeRegister admits every register, by this rung's own design", + "[polls][auth]") { + const PollsAuthorizer authorizer; + + // `anonymous` is one real input among others now that + // wire::makeRegister/wire::makeRegisterShared both carry the caller's + // session; authorizeRegister here stays permissive by choice, not + // because there is no identity to check (see the header's own @file + // comment and the rung README's design decision 2). + const Context anonymous; + CHECK(authorizer.authorizeRegister(anonymous, "PollModel")); + + // A stamped principal changes nothing -- the decision does not key on it. + Context authenticated; + authenticated.principal = "alice"; + CHECK(authorizer.authorizeRegister(authenticated, "PollModel")); +} + +TEST_CASE("PollsAuthorizer::authorizeInstance admits every instance operation -- no owner concept in this rung", + "[polls][auth]") { + const PollsAuthorizer authorizer; + const Context asAlice = [] { + Context ctx; + ctx.principal = "alice"; + return ctx; + }(); + + // No recorded owner -- the only case reachable here, since PollModel is + // always shared/keyed by pollId, which the framework records ownerless + // by design (unrelated to whether register envelopes carry a session)... + CHECK(authorizer.authorizeInstance(asAlice, "PollModel", "FinalizePoll", 1, "")); + // ...and even a non-empty ownerPrincipal (hypothetical -- see the header's + // own doc comment: PollModel has no per-caller ownership concept at all, + // only the admin/participant token check FinalizePoll performs itself). + CHECK(authorizer.authorizeInstance(asAlice, "PollModel", "FinalizePoll", 1, "someone-else")); +} diff --git a/examples/polls/tests/test_polls_schema.cpp b/examples/polls/tests/test_polls_schema.cpp new file mode 100644 index 00000000..a94fafcd --- /dev/null +++ b/examples/polls/tests/test_polls_schema.cpp @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "polls/db/poll_entity.hpp" +#include "testkit/db_fixture.hpp" + +#include +#include + +using morph::ladder::testkit::DbFixture; + +TEST_CASE("The polls schema creates all six tables and a poll round-trips", "[polls][db]") { + DbFixture fixture; + Lightweight::DataMapper mapper; + + polls::db::PollRecord poll; + poll.pollId = Light::SqlAnsiString{"poll-abc"}; + poll.adminToken = Light::SqlAnsiString{"admin-xyz"}; + poll.participantToken = Light::SqlAnsiString{"part-xyz"}; + poll.title = "Team offsite"; + poll.createdAtMs = 1000; + mapper.Create(poll); + REQUIRE(poll.id.Value() != 0); + + polls::db::OptionRecord opt; + opt.poll = poll; + opt.label = "2026-09-01"; + opt.sortOrder = 0; + mapper.Create(opt); + REQUIRE(opt.id.Value() != 0); + + auto loadedOptions = mapper.Query() + .Where(::Lightweight::FieldNameOf<&polls::db::OptionRecord::poll>, "=", poll.id.Value()) + .All(); + REQUIRE(loadedOptions.size() == 1); + CHECK(loadedOptions.front().label.Value() == "2026-09-01"); + + // The remaining four tables are read via a plain Query().Where(...) + // on the poll's own id, never through an embedded relation field on + // PollRecord -- see this rung's Global Constraints, and poll_entity.hpp's + // file comment. + polls::db::VoteRecord vote; + vote.poll = poll; + vote.option = opt; + vote.participantName = "alice"; + vote.choice = std::uint8_t{0}; + mapper.Create(vote); + REQUIRE(vote.id.Value() != 0); + + polls::db::CommentRecord comment; + comment.poll = poll; + comment.participantName = "alice"; + comment.body = "See you there!"; + comment.createdAtMs = 1001; + mapper.Create(comment); + REQUIRE(comment.id.Value() != 0); + + polls::db::VoteHistoryRecord history; + history.poll = poll; + history.participantName = "alice"; + history.previousVotesJson = "[]"; + history.createdAtMs = 1002; + mapper.Create(history); + REQUIRE(history.id.Value() != 0); + + polls::db::PollEventRecord event; + event.poll = poll; + event.kind = "vote"; + event.summary = "alice voted"; + event.createdAtMs = 1003; + mapper.Create(event); + REQUIRE(event.id.Value() != 0); + + auto loadedVotes = mapper.Query() + .Where(::Lightweight::FieldNameOf<&polls::db::VoteRecord::poll>, "=", poll.id.Value()) + .All(); + REQUIRE(loadedVotes.size() == 1); + CHECK(loadedVotes.front().participantName.Value() == "alice"); +} + +TEST_CASE("Duplicate (pollId, participantName, optionId) votes are rejected by the unique index", + "[polls][db]") { + DbFixture fixture; + Lightweight::DataMapper mapper; + + polls::db::PollRecord poll; + poll.pollId = Light::SqlAnsiString{"poll-dup"}; + poll.adminToken = Light::SqlAnsiString{"admin-dup"}; + poll.participantToken = Light::SqlAnsiString{"part-dup"}; + poll.title = "Dup test"; + poll.createdAtMs = 1000; + mapper.Create(poll); + + polls::db::OptionRecord opt; + opt.poll = poll; + opt.label = "2026-09-02"; + opt.sortOrder = 0; + mapper.Create(opt); + + polls::db::VoteRecord first; + first.poll = poll; + first.option = opt; + first.participantName = "bob"; + first.choice = std::uint8_t{0}; + mapper.Create(first); + + // A retried SubmitVotes (Task 6) must not double-count -- this is the + // exact index the VoteRecord doc comment names. + polls::db::VoteRecord second; + second.poll = poll; + second.option = opt; + second.participantName = "bob"; + second.choice = std::uint8_t{1}; + CHECK_THROWS_AS(mapper.Create(second), Lightweight::SqlException); +} + +TEST_CASE("PollRecord has no relation-typed member -- Update() must compile", "[polls][db]") { + // A compile-time proof, not a runtime assertion: if PollRecord ever grows + // an embedded HasMany/HasManyThrough field, this line stops compiling + // with the exact "no member IsModified" error the Global Constraints + // section documents. + DbFixture fixture; + Lightweight::DataMapper mapper; + + polls::db::PollRecord poll; + poll.pollId = Light::SqlAnsiString{"poll-upd"}; + poll.adminToken = Light::SqlAnsiString{"admin-upd"}; + poll.participantToken = Light::SqlAnsiString{"part-upd"}; + poll.title = "Before"; + poll.createdAtMs = 1; + mapper.Create(poll); + poll.title = "After"; + CHECK_NOTHROW(mapper.Update(poll)); +} diff --git a/examples/polls/tests/test_polls_types.cpp b/examples/polls/tests/test_polls_types.cpp new file mode 100644 index 00000000..be3f0333 --- /dev/null +++ b/examples/polls/tests/test_polls_types.cpp @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "polls/core/errors.hpp" +#include "polls/core/types.hpp" + +#include +#include + +TEST_CASE("OptionId/PollEventId are independently hasValue()-capable", "[polls][types]") { + CHECK_FALSE(polls::OptionId{}.hasValue()); + CHECK(polls::OptionId{.value = 1}.hasValue()); + CHECK_FALSE(polls::PollEventId{}.hasValue()); + CHECK(polls::PollEventId{.value = 1}.hasValue()); +} + +TEST_CASE("OptionId equality follows the payload", "[polls][types]") { + CHECK(polls::OptionId{.value = 5} == polls::OptionId{.value = 5}); + CHECK_FALSE(polls::OptionId{.value = 5} == polls::OptionId{.value = 6}); +} + +TEST_CASE("kTokenBytes is a plausible unguessable-token length", "[polls][types]") { + STATIC_REQUIRE(polls::kTokenBytes >= 16); // enough entropy to resist guessing +} + +TEST_CASE("PollsError hierarchy: each derived type carries its own message", "[polls][types]") { + CHECK(std::string_view{polls::NotFound{"poll not found"}.what()} == "poll not found"); + CHECK(std::string_view{polls::Forbidden{"not the admin"}.what()} == "not the admin"); + CHECK(std::string_view{polls::Conflict{"already finalized"}.what()} == "already finalized"); +} diff --git a/examples/polls/tests/test_shared_instance_lifecycle.cpp b/examples/polls/tests/test_shared_instance_lifecycle.cpp new file mode 100644 index 00000000..d497644e --- /dev/null +++ b/examples/polls/tests/test_shared_instance_lifecycle.cpp @@ -0,0 +1,361 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Task 12: three genuinely new pieces of coverage this rung's README names as +// "Expected strain points" that no task above already covers. +// +// 1. The backend-mode matrix for the *keyed* attach path: CreatePoll (a +// direct, non-keyed call over a plain BridgeHandler, exactly like +// test_poll_model.cpp's own instance-rebirth test's "creator" handler and +// test_app.cpp's own "creator") -> handler.execute(OpenPoll{pollId}) to +// attach -> SubmitVotes -> GetPollState, across Mode::Local, +// Mode::LocalSingleThread, Mode::Socket. Mirrors rung 2's Task 14 +// (examples/bookmarks/tests/test_bookmark_model.cpp's own +// GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket) matrix), +// but rung 2's matrix only ever proved the *plain-registration* path; +// this proves the *keyed* attach path (registerModelShared/attachModel, +// docs/spec/core/shared_instances.md) works identically across all three +// modes. +// 2. Shared-instance lifetime: N BridgeHandler +// instances attach to the same pollId, observe each other's writes, and +// handler.instances() reflects the instance's real lifetime (present +// while attached, absent once every attacher has released it) -- the +// DoD's own "handler.instances() for an organizer dashboard" requirement. +// 3. Poisoned-instance attach: docs/spec/core/shared_instances.md's +// "Failure modes" section documents that an instance whose very first +// action's outcome fails is marked and evicted from the directory "the +// next time anyone else attaches to that key -- not immediately", and +// that "the handler that hit the failure does not self-heal: its primary +// is already set to the poisoned key, so retrying the same keyed action +// re-points nowhere (attachHandler's no-op-on-same-primary guard skips +// the backend entirely) -- it keeps its broken instance". This test +// attaches to a bad pollId twice from the *same* handler: the second +// execute() never re-attaches (same primary, no-op guard), it just +// re-dispatches OpenPoll against the same broken instance, and +// PollModel::execute(OpenPoll) re-runs loadPollByPollId() on every call +// (poll_model.cpp) -- so both attempts fail identically with NotFound, +// proving there is no silently half-hydrated success on retry. +// +// Task 13: the last model-layer test task before the rung moves to +// presenters/GUI. +// +// 1. Cross-poll admin-token isolation: PollModel is keyed per-poll (each +// poll is its own shared instance), so a participant token from poll A +// must not let its holder finalize poll B. Written explicitly (rather +// than assumed from the per-instance keying alone) because a bug in +// requireAdmin()'s poll-row lookup could silently pass. +// 2. Bridge::setExecuteDeadline recovers a call the real rate limiter +// (QtWebSocketServerConfig::messagesPerSecond) silently drops -- the +// DoD's "run this rung's harness with messagesPerSecond configured ON" +// requirement, proven end to end (not merely at the framework-prereqs +// plan's own unit-test level) for the first time in this rung. +// 3. The cross-model rename-race analogue (rung 2's TagModel-renames-while- +// BookmarkModel-writes race): this rung's README does not name an exact +// analogue -- there is only one model type here (PollModel), so that +// whole test class does not apply. Considered and explicitly skipped, +// not silently omitted; see this task's commit message. + +#include "testkit/backend_rig.hpp" +#include "testkit/db_fixture.hpp" +#include "testkit/pump.hpp" + +#include "polls/auth/polls_authorizer.hpp" +#include "polls/dto/poll_dto.hpp" +#include "polls/dto/vote_dto.hpp" +#include "polls/models/poll_model.hpp" + +#include +#include + +#include +#include +#include + +using morph::bridge::AllowShared; +using morph::bridge::BridgeHandler; +using morph::ladder::testkit::awaitQt; +using morph::ladder::testkit::BackendRig; +using morph::ladder::testkit::DbFixture; +using morph::ladder::testkit::Mode; +using morph::ladder::testkit::pumpUntil; +using polls::CreatePoll; +using polls::FinalizePoll; +using polls::GetPollState; +using polls::OpenPoll; +using polls::PollModel; +using polls::SubmitVotes; +using polls::VoteChoice; + +TEST_CASE("PollModel over the full backend-mode matrix: create -> keyed-attach -> submit-vote round trip", + "[polls][model]") { + const auto mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + CAPTURE(mode); + DbFixture fixture; + BackendRig rig{mode, 1, std::make_shared()}; + + // Plain (NoSharing) handler for CreatePoll: CreatePoll carries no key, so + // nothing about it is shared/keyed -- the direct, non-keyed call Task 5's + // own tests (and test_app.cpp's "creator") already use. + auto creator = rig.client(0); + const auto created = + awaitQt(creator.execute(CreatePoll{.title = "Matrix poll", .options = {{"opt-a"}, {"opt-b"}}})); + REQUIRE_FALSE(created.pollId.empty()); + + // A fresh, AllowShared handler attaches via the *keyed* path -- + // handler.execute(OpenPoll{pollId}) -- proving keyed attach (not just + // plain registration) works identically in every mode. + BridgeHandler handler{rig.bridge(0), rig.executor()}; + const auto opened = awaitQt(handler.execute(OpenPoll{.pollId = created.pollId})); + REQUIRE(opened.pollId == created.pollId); + REQUIRE(opened.options.size() == 2); + + const auto afterVote = awaitQt(handler.execute( + SubmitVotes{.participantName = "alice", + .votes = {{.optionId = opened.options[0].id, .choice = VoteChoice::Yes}}})); + REQUIRE(afterVote.votes.size() == 1); + CHECK(afterVote.votes.front().participantName == "alice"); + CHECK(afterVote.votes.front().choice == VoteChoice::Yes); + CHECK(afterVote.options[0].yesCount == polls::Count::fromDouble(1.0)); + + const auto state = awaitQt(handler.execute(GetPollState{})); + REQUIRE(state.votes.size() == 1); + CHECK(state.votes.front().participantName == "alice"); +} + +TEST_CASE("N shared handlers on one pollId observe each other's writes, and instances() reflects " + "the instance's real lifetime", + "[polls][model][shared-instances]") { + DbFixture fixture; + // 5 clients, not 4: the fifth connection is reserved for the fresh + // "prober" handler below. Reusing one of the four attached connections + // for it would race a fire-and-forget deregister's unsolicited (callId + // 0) "ok" reply -- sent by BridgeHandler::~BridgeHandler on connection + // teardown, per QtWebSocketBackend::deregisterModel's own doc comment -- + // against the prober's own synchronous instances() call on that same + // connection: QtWebSocketBackend::onTextMessage matches *any* callId-0 + // reply to whichever sendSync happens to be parked, so a still-in-flight + // deregister ack can be misdelivered as the instances() reply, corrupting + // it. A genuinely fresh connection never had a deregister in flight, so + // it cannot race one. This is the exact mechanism a since-fixed finding + // was filed against -- a sync *register* racing a deregister; a sync + // *instances()* call is the identical hazard, since both are ordinary + // sendSync callers competing for the same callId-0 bucket -- a third + // independent reproduction site, after rung 2's own Task 17 discovery + // and QtWebSocketBackend::attachModel's empty-key path hitting it too. + // QtWebSocketBackend::deregisterModel now assigns a real, tracked callId + // rather than sharing the zero sentinel, closing the race framework-side; + // this test's own connection-isolation setup (5 clients, not 4) is kept + // regardless, since it costs nothing and this test still exercises the + // same call shape. + BackendRig rig{Mode::Socket, 5, std::make_shared()}; + + // Client 0's plain handler creates the poll -- CreatePoll carries no key. + auto creator = rig.client(0); + const auto created = + awaitQt(creator.execute(CreatePoll{.title = "Team lunch", .options = {{"mon"}, {"tue"}, {"wed"}}})); + + // Four independent AllowShared handlers, each its own socket client, all + // attach to the same pollId -- exercising cross-connection sharing, not + // merely cross-handler sharing within one connection. + std::vector>> handlers; + polls::OptionId firstOptionId; + for (std::size_t i = 0; i < 4; ++i) { + handlers.push_back(std::make_unique>(rig.bridge(i), rig.executor())); + const auto opened = awaitQt(handlers.back()->execute(OpenPoll{.pollId = created.pollId})); + REQUIRE(opened.pollId == created.pollId); + if (i == 0) { + firstOptionId = opened.options[0].id; + } + } + + // All four attached to one shared instance -- instances() reports + // exactly one live key while at least one handler holds it. + REQUIRE(awaitQt(handlers[0]->instances()) == std::vector{created.pollId}); + + // One handler submits a vote; the other three see it on their next + // GetPollState, proving they share one instance's state, not four + // divergent copies. + (void) awaitQt(handlers[0]->execute( + SubmitVotes{.participantName = "carol", .votes = {{.optionId = firstOptionId, .choice = VoteChoice::Yes}}})); + for (std::size_t i = 1; i < handlers.size(); ++i) { + const auto state = awaitQt(handlers[i]->execute(GetPollState{})); + REQUIRE(state.votes.size() == 1); + CHECK(state.votes.front().participantName == "carol"); + } + + // Detach all four -- releasing the shared instance, which destructs. + // ~BridgeHandler's deregister is deliberately fire-and-forget over a + // socket (QtWebSocketBackend::deregisterModel's own doc comment: no + // nested QEventLoop in a destructor), so this call returns before the + // server has necessarily *processed* all four -- there is no + // synchronous handshake to wait on here, only the directory eventually + // reflecting the release. + handlers.clear(); + + // A fifth, fresh handler -- on its own never-before-used connection, see + // this test's opening comment -- probes the directory: the key must be + // gone now that every prior attacher has released it, not merely "the + // test didn't crash". Polled, not a single snapshot: per the comment + // above, the four deregisters above are still in flight the instant + // handlers.clear() returns, so the first instances() reply can + // legitimately still list the key -- pumpUntil retries the (synchronous, + // round-tripping) instances() call until the directory catches up or the + // deadline elapses. + BridgeHandler prober{rig.bridge(4), rig.executor()}; + std::vector remaining; + REQUIRE(pumpUntil([&] { + remaining = awaitQt(prober.instances()); + return remaining.empty(); + })); + CHECK(remaining.empty()); +} + +TEST_CASE("Opening a stale pollId is NotFound through .onError(), not a crash, and a second attempt " + "to the same bad key gets a fresh (still-failing) instance, not stale poisoned state", + "[polls][model][shared-instances]") { + // Per docs/spec/core/shared_instances.md's "Failure modes" section: this + // handler's primary is set to the poisoned key on the very first + // execute() (attachHandler records the primary before dispatch), so its + // own second execute() re-points nowhere -- the no-op-on-same-primary + // guard skips the backend attach round trip entirely, and the action + // simply re-dispatches against the same (still-broken) instance. Both + // attempts fail identically -- NotFound, via .onError(), never a crash + // and never a silently half-hydrated success -- because + // PollModel::execute(OpenPoll) re-runs loadPollByPollId() on every call, + // not only the first. + DbFixture fixture; + BackendRig rig{Mode::Socket, 1, std::make_shared()}; + auto handler = rig.client(0); + + bool firstFailed = false; + handler.execute(OpenPoll{.pollId = "not-a-real-poll"}).onError([&firstFailed](auto) { firstFailed = true; }); + REQUIRE(pumpUntil([&firstFailed] { return firstFailed; })); + + bool secondFailed = false; + handler.execute(OpenPoll{.pollId = "not-a-real-poll"}).onError([&secondFailed](auto) { secondFailed = true; }); + REQUIRE(pumpUntil([&secondFailed] { return secondFailed; })); + + // Both attempts are genuinely NotFound (loadPollByPollId's own message), + // not merely "something failed" -- confirmed directly rather than only + // inferred from the onError firing. Checked by message, not by C++ + // exception type: over Mode::Socket the server-side polls::NotFound does + // not survive the wire -- RemoteServer's dispatchExecute catches it and + // replies "err" with only exc.what(), and QtWebSocketBackend::onTextMessage + // reconstructs that as a generic std::runtime_error carrying the same + // message (morph/qt/qt_websocket_backend.cpp's execute-reply handling). + // rung 2's own matrix test (test_bookmark_model.cpp) sidesteps this + // entirely by only asserting a concrete exception type over Local/ + // LocalSingleThread, never Socket -- this is that same constraint made + // explicit rather than silently avoided. + try { + (void) awaitQt(handler.execute(OpenPoll{.pollId = "not-a-real-poll"})); + FAIL("expected a third attempt against the same poisoned handler to fail identically"); + } catch (const std::exception& exc) { + CHECK(std::string{exc.what()}.find("poll not found") != std::string::npos); + } +} + +TEST_CASE("A poll's admin token does not finalize a different poll", "[polls][model][shared-instances]") { + // PollModel is keyed per-poll (each poll is its own shared instance), so + // this ought to be implied by the per-instance keying alone -- but a bug + // in requireAdmin()'s poll-row lookup (poll_model.cpp: it compares + // ctx->token against *this instance's own* `poll.adminToken` column, + // loaded via loadPollByPollId() against whichever pollId this handler is + // attached to) could silently let a stale/wrong cached _pollId slip + // through. Written explicitly rather than assumed. + DbFixture fixture; + BackendRig rig{Mode::Socket, 2, std::make_shared()}; + auto handlerA = rig.client(0); + auto handlerB = rig.client(1); + auto createdA = awaitQt(handlerA.execute(CreatePoll{.title = "A", .options = {{"1"}, {"2"}}})); + auto createdB = awaitQt(handlerB.execute(CreatePoll{.title = "B", .options = {{"1"}, {"2"}}})); + awaitQt(handlerB.execute(OpenPoll{.pollId = createdB.pollId})); + auto optsB = awaitQt(handlerB.execute(GetPollState{})).options; + + morph::session::Context ctx; + ctx.token = *createdA.adminToken; // poll A's admin token, used against poll B + rig.bridge(1).setDefaultSession(ctx); + bool failed = false; + handlerB.execute(FinalizePoll{.optionId = optsB[0].id}).onError([&failed](auto) { failed = true; }); + REQUIRE(pumpUntil([&failed] { return failed; })); +} + +TEST_CASE("Bridge::setExecuteDeadline recovers a call the real rate limiter silently drops", + "[polls][model][shared-instances]") { + // BackendRig's Mode::Socket constructor takes an optional + // QtWebSocketServerConfig (Task 11's own README-named + // "Expected strain point": pastebin's own maxMessageBytes case is the + // precedent for configuring it via the rig rather than hand-building a + // second server) -- messagesPerSecond set here is the real per-connection + // token bucket documented in qt_websocket_server.hpp: capacity equals + // messagesPerSecond, one token per incoming frame of any kind, refilling + // continuously; a frame that finds an empty bucket is dropped silently, + // no reply of any kind (mirrors tests/qt/test_qt_websocket.cpp's own + // "messagesPerSecond throttles a burst on one connection" construction + // pattern -- ThreadPoolExecutor -> RemoteServer -> QtWebSocketServer with + // a low-messagesPerSecond cfg -- except BackendRig already threads that + // cfg straight through, so no hand-built server is needed here). + DbFixture fixture; + ::morph::qt::QtWebSocketServerConfig cfg; + cfg.messagesPerSecond = 5; // bucket capacity 5, refills at 5/s -- same + // value test_qt_websocket.cpp's own + // messagesPerSecond test uses. + BackendRig rig{Mode::Socket, 1, std::make_shared(), cfg}; + + // Set the deadline before any traffic: setExecuteDeadline races every + // executeVia() call from this point on, so a genuinely dropped setup + // frame (unlikely at this low a burst rate, but not impossible) fails + // fast with ClientTimeoutError instead of hanging the test up to + // awaitQt's own 5s internal pump deadline. + rig.bridge(0).setExecuteDeadline(std::chrono::milliseconds{500}); + + auto creator = rig.client(0); + const auto created = + awaitQt(creator.execute(CreatePoll{.title = "Rate-limited poll", .options = {{"a"}, {"b"}}})); + BridgeHandler handler{rig.bridge(0), rig.executor()}; + const auto opened = awaitQt(handler.execute(OpenPoll{.pollId = created.pollId})); + + // Burst 20 SubmitVotes calls back-to-back, no pumping/awaiting in + // between -- mirrors test_qt_websocket.cpp's own 20-frame burst. The + // bucket's capacity is hard-capped at 5 regardless of any refill that + // happened during setup above (state.tokens = std::min(capacity, ...)), + // and this loop issues all 20 sends in a single native call stack with no + // real wall-clock time between them, so refill-during-the-burst is + // negligible: at least 15 of these 20 frames are guaranteed to find an + // empty bucket and be dropped at the transport, never reaching + // RemoteServer, with no reply of any kind. Distinct participant names so + // any call that *does* get through always succeeds -- never a business + // -logic Conflict -- keeping "no real reply" the only way a call can end + // up in `errors` without also being a ClientTimeoutError. + constexpr int kBurstSize = 20; + int successes = 0; + int errors = 0; + int clientTimeouts = 0; + for (int i = 0; i < kBurstSize; ++i) { + handler + .execute(SubmitVotes{.participantName = "voter-" + std::to_string(i), + .votes = {{.optionId = opened.options[0].id, .choice = VoteChoice::Yes}}}) + .then([&successes](polls::GetPollStateResult) { ++successes; }) + .onError([&errors, &clientTimeouts](const std::exception_ptr& err) { + ++errors; + try { + std::rethrow_exception(err); + } catch (const morph::backend::ClientTimeoutError&) { + ++clientTimeouts; + } catch (...) { + } + }); + } + + // Every one of the 20 completions must settle -- some via a real reply, + // the rest recovered by the deadline -- never left hanging. + REQUIRE(pumpUntil([&] { return successes + errors >= kBurstSize; }, std::chrono::milliseconds{3000})); + CHECK(successes + errors == kBurstSize); + + // Proof the drop was real, not merely that the deadline fired for some + // unrelated reason: strictly fewer real replies than calls sent (the + // "observing more calls than replies" confirmation the brief calls for), + // and at least one of the shortfall was specifically recovered via + // ClientTimeoutError rather than some other error. + CHECK(successes < kBurstSize); + CHECK(clientTimeouts >= 1); +} diff --git a/examples/polls/tests/test_vote_event_dto.cpp b/examples/polls/tests/test_vote_event_dto.cpp new file mode 100644 index 00000000..faeafdaa --- /dev/null +++ b/examples/polls/tests/test_vote_event_dto.cpp @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include +#include + +TEST_CASE("SubmitVotes/UpdateVotes require a bounded participantName and at least one vote", "[polls][dto]") { + polls::SubmitVotes action; + CHECK_FALSE(action.validate()); + action.participantName = "alice"; + CHECK_FALSE(action.validate()); // no votes yet + action.votes.push_back({.optionId = polls::OptionId{.value = 1}, .choice = polls::VoteChoice::Yes}); + CHECK(action.validate()); +} + +TEST_CASE("AddComment requires a bounded body", "[polls][dto]") { + polls::AddComment action{.participantName = "alice", .body = ""}; + CHECK_FALSE(action.validate()); + action.body = std::string(polls::kMaxCommentBytes + 1, 'x'); + CHECK_FALSE(action.validate()); + action.body = "works for me"; + CHECK(action.validate()); +} + +TEST_CASE("FinalizePoll requires a real optionId", "[polls][dto]") { + CHECK_FALSE(polls::FinalizePoll{}.validate()); + CHECK(polls::FinalizePoll{.optionId = polls::OptionId{.value = 1}}.validate()); +} + +TEST_CASE("GetEventsSince{} (lastEventId unset) validates -- it means \"from the beginning\"", "[polls][dto]") { + CHECK(polls::GetEventsSince{}.validate()); +}