From e4aae8f9c7bbf725694eac134ec8be613aaab2a0 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Fri, 14 Aug 2026 15:47:12 +0300 Subject: [PATCH 1/2] ladder: rung 1 -- pastebin Split out of application-ladder (originally bundled with bookmarks/polls and the shared foundation in #41) into its own PR against the rung-0 foundation (#88). Includes the pool-migration and Unicode-content fixes folded in during review of the combined branch: - PasteModel acquires connections from Lightweight::GlobalDataMapperPool() per execute() call rather than holding one for its own lifetime (WithMapper removed). - content is Light::SqlMaxDynamicWideString, not std::string/SqlText -- both of those are char-based and would render as VARCHAR(MAX) (a single-byte-collation column) on the SQL Server backend this same test suite can target via ODBC_CONNECTION_STRING; SqlMaxDynamicWideString's wchar_t-based storage self-declares NVarchar, giving real Unicode columns on every backend. Verified standalone against the ladder-foundation base: configures and builds with -DMORPH_LADDER_RUNGS=pastebin and no other rung present. Full suite passes: 834 assertions in 51 test cases (SQLite default). Spec-citation and test-type-name lints clean. Co-Authored-By: Claude Sonnet 5 --- examples/pastebin/CMakeLists.txt | 29 + examples/pastebin/README.md | 377 +++++ examples/pastebin/gui/main.cpp | 109 ++ examples/pastebin/gui/qml/Main.qml | 201 +++ examples/pastebin/gui/qml/PasteView.qml | 82 + .../gui_lib/paste_forms_controller.cpp | 16 + .../gui_lib/paste_forms_controller.hpp | 77 + examples/pastebin/gui_lib/paste_presenter.cpp | 49 + examples/pastebin/gui_lib/paste_presenter.hpp | 93 ++ .../pastebin/gui_lib/paste_qml_bridges.cpp | 128 ++ .../pastebin/gui_lib/paste_qml_bridges.hpp | 170 ++ examples/pastebin/gui_lib/paste_schemas.hpp | 35 + examples/pastebin/gui_wasm/main_wasm.cpp | 94 ++ .../pastebin/include/pastebin/app/app.hpp | 123 ++ .../pastebin/include/pastebin/core/errors.hpp | 56 + .../pastebin/include/pastebin/core/types.hpp | 135 ++ .../pastebin/include/pastebin/db/database.hpp | 30 + .../include/pastebin/db/paste_entity.hpp | 66 + .../include/pastebin/dto/paste_dto.hpp | 200 +++ .../include/pastebin/models/paste_model.hpp | 90 + examples/pastebin/include/pastebin/units.hpp | 48 + examples/pastebin/src/app/app.cpp | 114 ++ examples/pastebin/src/db/schema.cpp | 41 + examples/pastebin/src/models/paste_model.cpp | 486 ++++++ examples/pastebin/src/server/main.cpp | 188 +++ .../pastebin/tests/test_gui_qml_smoke.cpp | 51 + examples/pastebin/tests/test_paste_model.cpp | 1447 +++++++++++++++++ .../pastebin/tests/test_paste_presenter.cpp | 269 +++ .../pastebin/tests/test_paste_qml_bridges.cpp | 479 ++++++ 29 files changed, 5283 insertions(+) create mode 100644 examples/pastebin/CMakeLists.txt create mode 100644 examples/pastebin/README.md create mode 100644 examples/pastebin/gui/main.cpp create mode 100644 examples/pastebin/gui/qml/Main.qml create mode 100644 examples/pastebin/gui/qml/PasteView.qml create mode 100644 examples/pastebin/gui_lib/paste_forms_controller.cpp create mode 100644 examples/pastebin/gui_lib/paste_forms_controller.hpp create mode 100644 examples/pastebin/gui_lib/paste_presenter.cpp create mode 100644 examples/pastebin/gui_lib/paste_presenter.hpp create mode 100644 examples/pastebin/gui_lib/paste_qml_bridges.cpp create mode 100644 examples/pastebin/gui_lib/paste_qml_bridges.hpp create mode 100644 examples/pastebin/gui_lib/paste_schemas.hpp create mode 100644 examples/pastebin/gui_wasm/main_wasm.cpp create mode 100644 examples/pastebin/include/pastebin/app/app.hpp create mode 100644 examples/pastebin/include/pastebin/core/errors.hpp create mode 100644 examples/pastebin/include/pastebin/core/types.hpp create mode 100644 examples/pastebin/include/pastebin/db/database.hpp create mode 100644 examples/pastebin/include/pastebin/db/paste_entity.hpp create mode 100644 examples/pastebin/include/pastebin/dto/paste_dto.hpp create mode 100644 examples/pastebin/include/pastebin/models/paste_model.hpp create mode 100644 examples/pastebin/include/pastebin/units.hpp create mode 100644 examples/pastebin/src/app/app.cpp create mode 100644 examples/pastebin/src/db/schema.cpp create mode 100644 examples/pastebin/src/models/paste_model.cpp create mode 100644 examples/pastebin/src/server/main.cpp create mode 100644 examples/pastebin/tests/test_gui_qml_smoke.cpp create mode 100644 examples/pastebin/tests/test_paste_model.cpp create mode 100644 examples/pastebin/tests/test_paste_presenter.cpp create mode 100644 examples/pastebin/tests/test_paste_qml_bridges.cpp diff --git a/examples/pastebin/CMakeLists.txt b/examples/pastebin/CMakeLists.txt new file mode 100644 index 00000000..5ff6bf78 --- /dev/null +++ b/examples/pastebin/CMakeLists.txt @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# pastebin — rung 1 of the application ladder (examples/pastebin/README.md). +# All target wiring lives in morph_add_rung() (cmake/morph_add_rung.cmake); +# this file only pulls in pastebin-specific dependencies morph_add_rung() +# itself doesn't know about, then calls it. + +cmake_minimum_required(VERSION 3.25) + +morph_add_rung(NAME pastebin) + +# ── The WASM client's server url ──────────────────────────────────────────── +# A page served from a static bundle has no argv to read a --server flag from, +# so the url the browser client connects to is a build-time constant. Same +# mechanism and same shape as the rung-0 spike's own +# MORPH_LADDER_WASM_SPIKE_SERVER_URL (examples/common/wasm_spike/CMakeLists.txt), +# under a per-rung name so several rungs' WASM clients can point at their own +# servers in one Emscripten configure. Guarded on the target rather than on +# EMSCRIPTEN directly: morph_add_rung() creates it only under Emscripten, and +# only when its prerequisites are met (it announces every skip). +if(TARGET ladder_pastebin_gui_wasm) + if(NOT DEFINED MORPH_LADDER_PASTEBIN_WASM_SERVER_URL) + set(MORPH_LADDER_PASTEBIN_WASM_SERVER_URL "ws://127.0.0.1:8765" CACHE STRING + "URL pastebin's WASM client connects to; must be a reachable ladder_pastebin_server.") + endif() + target_compile_definitions(ladder_pastebin_gui_wasm PRIVATE + MORPH_LADDER_PASTEBIN_WASM_SERVER_URL="${MORPH_LADDER_PASTEBIN_WASM_SERVER_URL}" + ) +endif() diff --git a/examples/pastebin/README.md b/examples/pastebin/README.md new file mode 100644 index 00000000..3c8c9953 --- /dev/null +++ b/examples/pastebin/README.md @@ -0,0 +1,377 @@ +# pastebin — rung 1 of the [application ladder](../LADDER.md) + +**Status: shipped** — every rung-1 task is complete; see +[Definition of done](#definition-of-done) for what that does and does not +mean (the native stack is verified end to end; the WASM client is written and +CI-gated but has never been compiled here). A minimal pastebin: create a text +snippet, share its URL, let it expire or burn after N reads. The smallest +complete morph application — one entity, one model, SQLite, Qt WASM client. + +## Running it + +```bash +# One-time configure (Qt 6.5+, an ODBC SQLite3 driver, MORPH_BUILD_FORMS_QML +# for the schema-driven create form): +cmake -S . -B build -G Ninja \ + -DMORPH_BUILD_QT=ON -DMORPH_BUILD_FORMS_QML=ON \ + -DMORPH_BUILD_LADDER=ON -DMORPH_LADDER_RUNGS=pastebin + +# Server (owns the database, the action journal and the expiry sweep): +PASTEBIN_DB="DRIVER=SQLite3;Database=pastebin.db;Timeout=5000" \ +PASTEBIN_PORT=8765 ./build/examples/pastebin/ladder_pastebin_server + +# Desktop client, either deployment mode: +./build/examples/pastebin/ladder_pastebin_gui # in-process +./build/examples/pastebin/ladder_pastebin_gui --server ws://127.0.0.1:8765 +``` + +The browser client is the same program with a different `main()` +(`gui_wasm/main_wasm.cpp`), built only in an Emscripten configure — which +additionally needs `-DMORPH_CLIENT_ONLY=ON`, since a WASM client names its +model type but must not link the model's ODBC-backed bodies +(`docs/spec/core/registry.md`; `morph_add_rung()` fails the configure with that +explanation if the option is missing). Its server url is baked in at build time +via `-DMORPH_LADDER_PASTEBIN_WASM_SERVER_URL=ws://host:port`. The exact +configure line CI uses is `.github/workflows/wasm-ladder.yml`. + +**Scope note (delivery + verification reviews):** review rounds had piled +ladder-wide infrastructure onto this rung until it stopped being small. That +infrastructure is now **rung 0**, delivered *before* the pastebin app: the +testkit subset (`pump.hpp`, `backend_rig.hpp`, `db_fixture.hpp`, Qt-owning +test main), `examples/common/gui` (AppContext + Presenter base), the +`ladder-tests` CI job, and the **WASM-remote spike** — the first-ever +WASM + `QtWebSocketBackend` run, which requires `asyncRegistrationEnabled = +true` (opt-in, off by default) and the `setConnectHandler` pattern instead +of `waitForConnected()` (which hangs the page on WASM), with a written +fallback plan if it bounces off framework work. Rung 1 proper is the app +below plus its design records. Deferred from rung 1: the convergence +assertion (its `poll()`/`lastEventId()` hooks exist only from rung 3) and +the full hostile-content corpus suite (start with a representative subset). + +## Reference implementations + +- **[MicroBin](https://github.com/szabodanika/microbin)** (Rust, Actix, + BSD-3-Clause, ~4k LOC) — the anchor. Small enough to read end-to-end in an + afternoon; its single `Pasta` struct *is* the data model. Supports SQLite or + a flat JSON file behind a two-backend storage abstraction — directly + analogous to morph's in-memory vs. SQLite-persisted split. +- [PrivateBin](https://github.com/PrivateBin/PrivateBin) — studied and + rejected as anchor: its zero-knowledge design makes the server a dumb + ciphertext store, exercising none of the typed-model machinery. Worth a look + only for its burn-after-read UX. + +## What to implement + +One model, `PasteModel`, keyed by paste id (animal-name ids like MicroBin's +are a nice touch), with actions: + +1. `CreatePaste { content, syntax, expiresAt, burnAfterReads, isPrivate }` + → `PasteId` +2. `GetPaste { id }` → `PasteView` — **this is the interesting one**: reading + increments `read_count` and may delete the paste (burn-after-reads), so a + read is a *write*. +3. `EditPaste`, `DeletePaste` — plain mutations for editable pastes. +4. `ListPastes {}` → recent public pastes (pagination via cursor field). + +Persistence: one Lightweight entity (`PasteRecord`) and one +`LIGHTWEIGHT_SQL_MIGRATION`, per [`../IMPLEMENTATION.md`](../IMPLEMENTATION.md) +— fields modeled on MicroBin's `Pasta` (id, content, extension, private, +editable, created, expiration, last_read, read_count, burn_after_reads). +DTO fields follow the strong-type rule: `PasteId`, `Timestamp`, `enum +class` visibility, a reads `Quantity` — `std::string` only for content and +extension. + +Clients: Qt Widgets desktop client and the same code compiled to WASM +(follow [`../bank/gui_wasm`](../bank/gui_wasm)). Local and remote backends +must both work unchanged. + +## morph subsystems exercised + +- The full local/remote loop end-to-end on a fresh codebase (registration, + strands, wire protocol, WASM build). +- **Journal**: install `FileActionLog` from day one. Design questions, + **resolved** below (ladder discipline rule): + + - *Is a state-mutating read an action?* **Resolved: yes — `GetPaste` + stays the one client-visible, journaled action (default + `Loggable::Yes`), not split.** The recommended split (a pure, unlogged + `GetPaste` plus an internally-journaled `RecordRead` mutation) turned + out to be structurally unavailable: `IModelHolder::recordIfAttached` + (`include/morph/core/model.hpp:145`) is called only by the two + built-in dispatch runners, for the one action actually dispatched — + there is no seam for a model to author a second, independent + `LogEntry` from inside its own `execute()`. `Bridge::modelFactory` + constructor injection only reaches `Local`-mode registration; morph + has since grown `ModelRegistryFactory::registerModel(modelId, + factory)` (`include/morph/core/registry.hpp`) as the equivalent seam + for `Socket`-mode's registry-constructed models, but this rung + predates it and has not adopted it. **Consequence, accepted + and documented, not worked around:** replaying `GetPaste`'s entry + re-runs the real burn/read-count logic against whatever row state + exists at replay time — for a burn-after-read paste this can + resurrect content the user was told was destroyed. This is the + concrete, privacy-shaped example the journal-honesty position below + generalizes from; it is not unique to `GetPaste` in kind (replaying + *any* DB-backed mutating action re-touches the live database — see + that position) but it is the sharpest instance of it, so pastebin's + UI must never expose a raw "undo"/"replay" affordance over the + journal, only read-only history rendering. + - *How does expiry replay?* **Resolved: an explicit, journaled + `ExpirePaste{id}` action, dispatched by a periodic sweep that is a + genuinely separate top-level call — not nested inside `GetPaste`'s own + `execute()`.** `GetPaste`'s own atomic update (the burn-atomicity + decision, below) already excludes an expired row from its `WHERE` + clause defensively, so correctness never depends on sweep timing — a + client asking for an expired paste gets `Expired` regardless of + whether the sweep has reached that row yet. This is what makes a + **periodic** sweep (a timer in the app-layer server bootstrap, + `src/app/`, not model code — it is orchestration, not domain logic; + typically every few seconds) both simpler than a per-request hook + (`RemoteServer` has no confirmed pre-dispatch interception seam to + hang one on) and *more* complete than "on access" alone — it also + reclaims pastes nobody ever requests again, which an on-access-only + sweep would leave orphaned forever. The sweep queries + `expires_at_ms <= now()` directly (a plain, unlogged read — not an + action) and dispatches `ExpirePaste{id}` for each match through an + **internal client** — a `Bridge` over `SimulatedRemoteBackend{*server}` + wrapping the app's own live `RemoteServer` — a first-class client of + the same server, not a bypass: `SimulatedRemoteBackend::execute()` + calls `RemoteServer::handle()`, the exact path a real socket client's + call takes (`dispatchMessage` → `dispatchExecute` → + `ActionDispatcher::dispatch`), so `ExpirePaste` is authorized, + dispatched, and auto-journaled exactly like any client-issued action + — no framework gap, no finding needed for this part. `ExpirePaste`'s + payload is just `{id}` (never `now()`), so replaying its entry is + trivially deterministic regardless of when replay runs. Under this + rung's fail-open default (no authorizer configured), + `RemoteServer::dispatchExecute` clears any claimed principal before + the model sees it (`authenticate()` returns `nullopt` by default), so + `ExpirePaste`'s `LogEntry.principal` reads empty — consistent with + every other unauthenticated call this rung makes, not a gap. + - *The ladder-wide journal position paper.* **Resolved:** + `morph::journal` is an **audit trail** — install it to answer "what + happened, and when" (render read-only history; `entries()` + + `LogEntry.timestampMs`/`.principal`/`.outcome`). It is **not** + event-sourcing and **not** a safe reconstruction mechanism for any + DB-backed model, pastebin's `PasteModel` included: + `journal::replay()`/`SessionLog::undoLast()` re-run the recorded + action's real `execute()` against a freshly created model instance — + for an in-memory-only model that's an isolated sandbox, but for a + model whose real state lives in Lightweight/SQLite (every ladder + model to date), "fresh instance" only isolates the *model object*, + not the database it immediately reopens and mutates again. Do not + invoke `replay()`/`undoLast()` against a live install's database; + they exist for offline forensic reconstruction (a copied-aside + database file) or for models that are provably pure/in-memory, which + no ladder rung has shipped yet. Framework growth this rung proposes + instead of assuming: (1) a documented, opt-in "replay-safe" trait or + marker distinguishing pure/in-memory models from DB-backed ones, so + `replay()` can refuse (or clearly warn) against the latter; (2) + adopting the DI seam noted above, which would let `GetPaste` be split + as originally hoped — not done in this rung. +- **Shared vs. unshared instance — the burn-atomicity decision. Resolved: + SQL-atomicity, not a shared keyed instance.** `PasteModel` is registered + plain (no `BRIDGE_MODEL_KEY`/`AllowShared`), matching bank's + `NotificationModel` shape, not `AccountModel`'s. Burn-after-read + atomicity comes from a conditional `UPDATE … WHERE read_count < + burn_after_reads` issued via Lightweight's raw-query facility + (`SqlStatement::Prepare`/`Execute`) — the pre-enumerated + sanctioned-escape-tier answer named in + [`../IMPLEMENTATION.md`](../IMPLEMENTATION.md) § sanctioned escape tier. + **As shipped this is the transaction-wrapped two-statement form, not the + single-statement `… RETURNING …` one originally written here.** The + sqliteodbc driver accepts `UPDATE … RETURNING`, applies it, and reports + the returned column count, but the first `FetchRow()` throws SQLSTATE + 24000 "Invalid cursor state"; it never opens a cursor over the returned + rows — filed upstream as + [`LASTRADA-Software/Lightweight#545`](https://github.com/LASTRADA-Software/Lightweight/issues/545), + tracked morph-side as + [`LASTRADA-Software/morph#58`](https://github.com/LASTRADA-Software/morph/issues/58). + `PasteModel::execute(const GetPaste&)` therefore runs a + `SqlTransaction` around (1) the identical conditional `UPDATE` minus its + `RETURNING` clause, dispatched on `NumRowsAffected()`, and (2) an ordinary + `DataMapper` read-back by primary key. **The atomicity argument is + unchanged**: it never rested on `RETURNING`, only on the guard living + inside the `UPDATE`'s own `WHERE`, which SQLite evaluates and applies + indivisibly under a write lock — of N clients racing for the last allowed + read, exactly one gets a non-zero affected-row count. The transaction only + keeps the read-back consistent with the write it reads back, and folds the + burn-delete into the same commit. This also avoids the + shared-instance option's WASM coupling: a shared keyed instance's first + `GetPaste` would drive the *synchronous* shared-attach path that aborts + the page, pulling the async-shared-attach framework prerequisite forward + from rung 3. Revisit sharing at rung 3, per the original recommendation. +- **Lightweight behind a model** at the smallest possible scale — the + DTO ⇄ entity ⇄ `DataMapper` loop of [`../IMPLEMENTATION.md`](../IMPLEMENTATION.md) + proven on a one-entity schema before the bigger rungs depend on it. + +**Custom-GUI-element justification (`../IMPLEMENTATION.md` rule 2):** at the +time this rung was built, the shipped `morph::qt::forms::FormsControllerCore +` hardcoded its own `Bridge`/`LocalBackend`/executor internally, with +no way to compose it over `AppContext`'s `Bridge&`/`IExecutor*` — a direct +conflict with [`../TESTING.md`](../TESTING.md)'s "never construct executors +or backends themselves" presenter rule, and silently untestable in `Socket` +mode. The shipped core's own `(Bridge&, IExecutor*, schemasJson)` constructor +now supports this composition directly, closing the gap framework-side; +`gui_lib/paste_forms_controller.hpp` still owns a thin controller of its own +(this rung predates that constructor). +Pastebin's GUI still renders exclusively from `morph::forms::schemaJson()` +through the real `MorphForms` QML module (justification (b): pure glue, no +domain logic, no hand-rolled widget) — only the backend-wiring seam is +rung-owned: a thin controller exposing the same +`schemaJson()`/`submitIfValid()`/`fetchOptions()` surface, constructed over +the `BridgeHandler` `AppContext::onReady()` hands it. + +## Required tests (from review) + +- **Hostile content round-trip**: replay every input in `tests/fuzz/findings/` + *as paste content* (control bytes, broken UTF-8), both directions, both + backends — the exact bug class fuzzing already caught once in the wire + layer. +- **Size-limit UX**: `CreatePaste` bouncing off the server's message-size + bound; the client renders a typed error. Typed error rendering debuts + here, not rung 4. +- **Duplicate create on retry**: a resent `CreatePaste` must not mint two + pastes — first appearance of the idempotency-key discipline (rung 4 + formalizes it). Until the fault-injection proxy exists (rung 4), this is + explicitly the **weaker approximation** — double-execute with the same op + id — not true reply-frame loss. Plus id-collision handling in the tiny + animal-name keyspace. +- **Expiry edges**: `expiresAt` in the past / at epoch / malformed + (wire error, not clamped); `GetPaste` against an already-past-`expiresAt` + row before the periodic sweep has reached it (must still throw `Expired` + — this is exactly what proves correctness doesn't depend on sweep + timing); the periodic sweep firing between two pages of a `ListPastes` + cursor walk. +- **Security posture (per the LADDER matrix)**: this rung deliberately runs + the *unhardened* fail-open default, with one test that asserts the delta + (any client can register / execute against a learned id) as executable + documentation of `docs/spec/security.md`; it also owns the `hello` + protocol-version-negotiation test — no example exercises negotiation + today. +- **Store-error branch coverage, per failure class, through the real + schema — not through one failing driver.** `db_fault_fixture.hpp`'s + `SqlScopedLock`-based contention cannot fault an ordinary `DataMapper` + call or the raw conditional update above (there is no injectable seam + between `DataMapper` and the ODBC driver — see `examples/TESTING.md`'s + testkit section). This rung provokes two of the three failure classes for + real instead: `db_busy_fixture.hpp` holds a competing write transaction + open on a second connection to force a genuine `SQLITE_BUSY`, and (for the + raw conditional update specifically) a row already at + `read_count == burn_after_reads` forces the zero-rows-affected branch. + **Constraint violations are not covered this way**: no fixture forces a + genuine `UNIQUE`/FK violation through the schema yet. + `IMPLEMENTATION.md` rule 5's per-line exclusion tag is reserved for + whatever, after this, still provably can't be reached this way. + +## Expected strain points + +- Expiry sweeps are a **time-driven background job** — no client action + triggers them. Keep the rung-1 answer primitive (a plain periodic timer + in the app-layer bootstrap, dispatching through an internal client — see + the journal design decision above); the real background-job pattern + arrives in [`bookmarks`](../bookmarks). +- File attachments (MicroBin supports uploads) are **out of scope** — blobs + through a JSON protocol are rung 4/8's problem. + +## Definition of done + +- [x] **Desktop client against local and remote backends.** + `ladder_pastebin_gui` in both modes, driven manually against a real + `ladder_pastebin_server` (create → list → open → burn → delete) and by the + offscreen QML engine-load smoke test in the suite. +- [~] **WASM client, same client code.** `gui_wasm/main_wasm.cpp` is the only + file that differs from the desktop client: the presenters, the forms + controller, the QML adapters (`gui_lib/paste_qml_bridges.hpp`), the schema + document and `gui/qml/Main.qml` are all shared verbatim — no shadow headers, + no WASM variant of any model/DTO/QML file + ([`../TESTING.md`](../TESTING.md)'s hard requirement). **It has never been + compiled.** No Emscripten toolchain existed in the environment it was + authored in (`emcmake: command not found`), exactly as rung 0's own + [`../common/wasm_spike`](../common/wasm_spike) records for the spike it rides + on. What *was* verified locally: every shared translation unit plus + `main_wasm.cpp` compiles with `__EMSCRIPTEN__` and `MORPH_CLIENT_ONLY` + defined and the Lightweight/ODBC include paths removed — the client's include + graph is genuinely persistence-free. What was not: the Qt for WebAssembly + toolchain, the link, and the browser. + `.github/workflows/wasm-ladder.yml` is the compile gate that will settle it. +- [x] **`examples/common/testkit` used throughout.** `BackendRig`'s + Local/Simulated/Socket matrix, `pump`/`pumpUntil` discipline, `DbFixture` + per test case, `DbBusyFixture` for the `SQLITE_BUSY` branches. +- [x] **Presenter-shaped GUI.** `ladder_pastebin_gui_lib` links `Qt6::Core` + only (presenter rule 1); `PastePresenter` is tested in all three backend + modes. +- [x] **Burn-after-read and expiry work**, with the atomicity mechanism, its + `RETURNING` limitation and the ladder-wide journal position documented above. +- [x] **Model unit tests**, following [`../bank/tests`](../bank/tests) + conventions: 33 cases covering the burn/expiry edges, the hostile-content + corpus replay, size limits, duplicate create, id collisions, the fail-open + security delta and `hello` version negotiation. +- [x] **Findings filed rather than worked around** — this rung's actual + product: ten in total, spanning rung 0 through this rung. Eight have since + been fixed framework-side; their gaps and fixes are described inline + throughout this README and this rung's own source comments, not + re-listed here. Two genuine, still-current limitations remain: + `db_fault_fixture.hpp`'s `SqlScopedLock`-based contention cannot fault an + ordinary `DataMapper` call (see "Store-error branch coverage" above), and + the SQLite ODBC driver's `UPDATE ... RETURNING`/`SQLFetch` combination — + see "Burn-atomicity" above and this rung's `Lightweight` issue tracking it + upstream. + Three framework/testkit bugs found on the way were *fixed*, not merely + filed: JSON control-byte escaping in the action/result codecs, an + executor-lifetime bug in the shared testkit, and — found by this rung's own + QML-adapter suite — `QtDrivenMainThreadExecutor::post()`'s zero-delay drain + timer capturing a bare `this`, which fired into freed storage one + `GENERATE` iteration later and aborted the process + (`examples/common/testkit/backend_rig.hpp`, with a regression case in + `test_backend_rig.cpp`). A fourth bug, `Completion::onError`'s single-slot + overwrite (finding 023), was *worked around* at the time rather than fixed: + `gui/presenter.hpp`'s `track()` folded a subclass's error-display callback + and the busy-counter decrement into the one `.onError()` slot `Completion` + then kept, instead of composing two separate calls. `Completion`'s + `onOk`/`onErr` are now vectors of handlers (multiple `.then()`/`.onError()` + attaches fan out instead of overwriting), so `track()`'s fold is no longer + load-bearing — kept as-is since it still works and nothing forces the + change. + Finding 026, the sibling-writer half of the first bug above, is also fixed: + the same missing control-byte escaping in `journal/action_log.hpp`, + `offline/file_offline_queue.hpp` and `session/session_auth.hpp` was closed + framework-side. + +### Known gaps, stated rather than smoothed over + +- **Findings triage complete.** [`../FINDINGS.md`](../FINDINGS.md)'s "Rung + exit criteria" makes a rung done when (1) its README's design questions are + resolved in writing, (2) every named strain test exists — passing or filed + as a finding, and (3) its findings are triaged (no `open` dispositions + left). All three are now met: of the ten findings this rung owned or + inherited, eight have since been fixed framework-side (the framework fixes + are described inline throughout this README and this rung's own source + comments, not re-listed here). `db_fault_fixture.hpp`'s store-error + coverage gap (see "Store-error branch coverage" above) is a genuine, + still-current limitation, documented there and in + `examples/TESTING.md`/`IMPLEMENTATION.md` directly rather than as a + standalone finding. The sqliteodbc `RETURNING`/`SQLFetch` gap (see + "Burn-atomicity" above) is filed upstream against + [`Lightweight`](https://github.com/LASTRADA-Software/Lightweight/issues/545) + and tracked morph-side as + [`morph#58`](https://github.com/LASTRADA-Software/morph/issues/58) — a + genuine third-party ODBC driver limitation, not fixable in morph source. +- The WASM client's verification status, above. +- **`ladder-tests` still builds no GUI.** That job's distro Qt is 6.4.2, below + the 6.5 floor `MORPH_BUILD_FORMS_QML` requires, so it configures without the + QML module, the desktop client or the smoke test — `morph_add_rung()` + announces each skip rather than letting them vanish silently. The + `linux-all-features` job now enables `MORPH_BUILD_LADDER` alongside + `MORPH_BUILD_FORMS_QML` (it already installs Qt 6.8), so that is where those + targets are built and that test runs. +- **Registration timing.** `PasteBridge` exposes a `bound` signal + (`Presenter::trackBound()`, backed by `Bridge::whenBound()`) that settles + once the registration round trip lands; both clients' `Main.qml` gates its + bootstrap `refresh()` on it instead of retrying on a timer. `Remote` mode + still has no connect timeout, so a server that never answers leaves `bound` + simply never firing and the list pane empty with no terminal error. +- Deferred by design: the convergence assertion (needs rung 3's + `poll()`/`lastEventId()`), the full hostile-content corpus (a representative + subset ships), true reply-frame loss (rung 4's fault-injection proxy), file + attachments. diff --git a/examples/pastebin/gui/main.cpp b/examples/pastebin/gui/main.cpp new file mode 100644 index 00000000..91297929 --- /dev/null +++ b/examples/pastebin/gui/main.cpp @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: Apache-2.0 + +/// @file +/// pastebin's desktop client shell: one `AppContext` (deployment mode from +/// `--server`), the two QML adapters `gui_lib/paste_qml_bridges.hpp` defines +/// built inside `ctx.onReady()`, and a `QQmlApplicationEngine` loading this +/// rung's own QML module (`Pastebin`, see `cmake/morph_add_rung.cmake`). +/// +/// Usage: +/// @code +/// ladder_pastebin_gui # in-process backend +/// ladder_pastebin_gui --server ws://127.0.0.1:8765 # standalone server +/// @endcode +/// +/// Everything below the deployment-mode choice is shared verbatim with +/// `gui_wasm/main_wasm.cpp` — the adapters, the schema document and the QML +/// module all live outside this file precisely so the two clients are one +/// program with two `main()`s (`examples/TESTING.md`, "same client code"). + +#include +#include +#include +#include +#include +#include + +#include "gui/app_context.hpp" +#include "paste_qml_bridges.hpp" +#include "pastebin/db/database.hpp" + +#include +#include +#include + +namespace { + +/// @brief `--server ` if present, otherwise no url (in-process mode). +[[nodiscard]] std::optional serverUrlFromArgs(const QStringList& args) { + const auto index = args.indexOf(QStringLiteral("--server")); + if (index < 0 || index + 1 >= args.size()) { + return std::nullopt; + } + return QUrl{args.at(index + 1)}; +} + +} // namespace + +int main(int argc, char** argv) { + QGuiApplication qtApp{argc, argv}; + + const auto serverUrl = serverUrlFromArgs(QCoreApplication::arguments()); + + // Local mode hosts `PasteModel` in this very process, so this process is + // also the one that has to point Lightweight at a database and apply the + // migrations — the same bootstrap `src/server/main.cpp` performs, for the + // same reason. `Remote` mode must *not* do it: the server owns the store, + // and a client opening the same SQLite file behind the server's back is + // exactly the second writer this rung's SQLITE_BUSY work exists to avoid. + // + // Local mode is deliberately the *smaller* deployment, not an equivalent + // one: `pastebin::app::App` (the durable action log and the periodic + // expiry sweep) lives only in the server binary. A Local-mode client + // therefore journals nothing, and an expired paste keeps appearing in the + // listing until something sweeps it — `ListPastes` filters on visibility + // only, and it is `ExpirePaste` that reclaims the row + // (`src/models/paste_model.cpp`). Opening one still fails correctly with + // "paste has expired", because `GetPaste`'s own atomic guard never depends + // on the sweep having run. + if (!serverUrl) { + const char* connectionString = std::getenv("PASTEBIN_DB"); + pastebin::db::setup(connectionString != nullptr ? connectionString + : "DRIVER=SQLite3;Database=pastebin.db;Timeout=5000"); + } + + // Mirrors AppContext's own doc-comment construction pattern: pick the + // mode, then build every handler from inside onReady() — a Remote context + // is *not* usable the line after its constructor returns + // (docs/findings/017). + ::morph::ladder::gui::AppContext ctx{ + serverUrl ? ::morph::ladder::gui::AppContext::Mode{::morph::ladder::gui::Remote{.url = *serverUrl}} + : ::morph::ladder::gui::AppContext::Mode{::morph::ladder::gui::Local{.workers = 4}}}; + + QQmlApplicationEngine engine; + std::unique_ptr formsBridge; + std::unique_ptr pasteBridge; + + ctx.onReady([&] { + formsBridge = std::make_unique(ctx.bridge(), ctx.executor()); + pasteBridge = std::make_unique(ctx.bridge(), ctx.executor()); + // Initial properties rather than context properties: the root object + // then declares what it needs, so the same Main.qml also loads with + // nothing wired up — which is exactly what the offscreen engine-load + // smoke test (tests/test_gui_qml_smoke.cpp) does. + engine.setInitialProperties({ + {QStringLiteral("formsController"), QVariant::fromValue(formsBridge.get())}, + {QStringLiteral("pasteController"), QVariant::fromValue(pasteBridge.get())}, + }); + engine.loadFromModule(MORPH_LADDER_QML_URI, "Main"); + if (engine.rootObjects().isEmpty()) { + qWarning("ladder_pastebin_gui: QML engine produced no root object"); + QCoreApplication::exit(1); + } + }); + + if (serverUrl) { + qInfo("ladder_pastebin_gui: connecting to %s ...", qUtf8Printable(serverUrl->toString())); + } + return QGuiApplication::exec(); +} diff --git a/examples/pastebin/gui/qml/Main.qml b/examples/pastebin/gui/qml/Main.qml new file mode 100644 index 00000000..bdda2607 --- /dev/null +++ b/examples/pastebin/gui/qml/Main.qml @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// pastebin's desktop shell. Three panes' worth of behavior, none of it +// domain logic (examples/TESTING.md presenter rule 6, "QML is bindings-only"): +// +// * the create form is the shipped MorphForms renderer (DynamicForm) driven +// entirely by schemaJson() — nothing here knows CreatePaste +// has a `syntax` field, a burn budget, or an expiry; +// * the list and the detail pane are read-only displays of server-computed +// state relayed by PastePresenter (via gui/main.cpp's PasteBridge); +// * every error string shown is the model's own `what()`. +// +// `formsController` / `pasteController` are supplied by gui/main.cpp through +// QQmlApplicationEngine::setInitialProperties. They default 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 +import MorphForms + +ApplicationWindow { + id: root + width: 980 + height: 720 + visible: true + title: "pastebin — morph application ladder, rung 1" + + property var formsController: null + property var pasteController: null + + property var schemas: root.formsController ? JSON.parse(root.formsController.schemasJson) : ({}) + property var rows: [] + property var currentPaste: null + property string status: "" + property bool statusIsError: false + + function report(message, isError) { + root.status = message + root.statusIsError = isError + } + + // The first listing cannot simply be requested from Component.onCompleted. + // In Remote mode AppContext::onReady() fires when the *socket* connects, + // which is when gui/main.cpp builds the presenters — but a BridgeHandler's + // registration is a round trip, and until its reply lands every dispatch + // through it fails fast with "handler not bound" (morph/core/bridge.hpp). + // Verified, not theorised: an unconditional refresh() on completion + // reliably reported exactly that error and left the list empty on every + // launch against a real server. `PasteBridge::bound` (backed by + // `Bridge::whenBound()`) is that round trip's settlement signal — Local + // mode's handler is already bound by construction, so this fires + // synchronously there. + Connections { + target: root.pasteController + + function onBound() { + root.pasteController.refresh() + } + + function onListed(rows) { + root.rows = rows + root.report("", false) + } + + function onLoaded(paste) { + root.currentPaste = paste + root.report("opened " + paste.id + " — read " + paste.readCount + " time(s)", false) + // A read is a mutation in this rung: GetPaste consumes one unit of + // burn budget, and the read that spends the last unit destroys the + // paste server-side (README, "burn-after-read atomicity"). Re-listing + // is what makes that visible instead of leaving a stale row on screen. + root.pasteController.refresh() + } + + function onRemoved() { + root.currentPaste = null + root.report("deleted", false) + root.pasteController.refresh() + } + + function onFailed(message) { + root.report(message, true) + } + } + + Connections { + target: root.formsController + + // The create form submits through PasteFormsController, not through + // PastePresenter, so this — not `pasteController.created` — is where a + // create's outcome arrives. + function onReplyReceived(actionType, ok, payload) { + if (!ok) { + root.report(payload, true) + return + } + root.report(actionType + " ok: " + payload, false) + createForm.resetFields() + if (root.pasteController) + root.pasteController.refresh() + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 8 + + Label { + Layout.fillWidth: true + visible: root.status !== "" + wrapMode: Text.Wrap + color: root.statusIsError ? "#d33" : palette.text + text: root.status + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 8 + + ColumnLayout { + Layout.preferredWidth: 430 + Layout.fillHeight: true + spacing: 8 + + DynamicForm { + id: createForm + Layout.fillWidth: true + actionType: "CreatePaste" + schema: root.schemas["CreatePaste"] || ({}) + // Deliberately *not* `controller: root.formsController`. + // DynamicForm auto-submits the moment its required fields + // are engaged and on every keystroke after that — right for + // the calculator-shaped actions it was written against, + // catastrophic for CreatePaste, which would store one paste + // per typed character. Left unbound, the form is a pure + // renderer/validator: `ready` is its submit gate and + // `previewLine` is the exact JSON body it assembled, which + // the button below hands to the controller on demand. + controller: null + } + + Button { + Layout.fillWidth: true + text: "Create paste" + enabled: root.formsController !== null && createForm.ready + onClicked: root.formsController.submitIfValid("CreatePaste", createForm.previewLine) + } + + RowLayout { + Layout.fillWidth: true + + Button { + text: "Refresh list" + enabled: root.pasteController !== null + onClicked: root.pasteController.refresh() + } + + Label { + Layout.fillWidth: true + opacity: 0.7 + text: root.rows.length + " public paste(s)" + } + } + + ListView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: root.rows + + delegate: ItemDelegate { + required property var modelData + width: ListView.view.width + text: modelData.id + " · " + modelData.syntax + " · " + modelData.visibility + + " · " + modelData.createdAt + onClicked: { + if (root.pasteController) + root.pasteController.open(modelData.id) + } + } + } + } + + PasteView { + Layout.fillWidth: true + Layout.fillHeight: true + paste: root.currentPaste + onDeleteRequested: pasteId => { + if (root.pasteController) + root.pasteController.remove(pasteId) + } + } + } + } +} diff --git a/examples/pastebin/gui/qml/PasteView.qml b/examples/pastebin/gui/qml/PasteView.qml new file mode 100644 index 00000000..72d1f1b4 --- /dev/null +++ b/examples/pastebin/gui/qml/PasteView.qml @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Read-only display of one fetched paste. Every value shown is server-computed +// and arrives already rendered as text from gui/main.cpp's PasteBridge — this +// file formats nothing and decides nothing (examples/IMPLEMENTATION.md rule 2's +// "pure glue" allowance for read-only displays; there is no hand-rolled input +// widget here, only a Delete button that relays an id). +// +// Zero styling effort by rule: default Qt Quick controls, default fonts, no +// theming. + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Frame { + id: pane + + /// The property bag PasteBridge emits with `loaded`, or null when nothing + /// is open yet. + property var paste: null + + /// Emitted when the user asks for the currently displayed paste to go. + signal deleteRequested(string pasteId) + + property var facts: pane.paste ? [ + { key: "syntax", value: pane.paste.syntax }, + { key: "visibility", value: pane.paste.visibility }, + { key: "editability", value: pane.paste.editability }, + { key: "created", value: pane.paste.createdAt }, + { key: "expires", value: pane.paste.expiresAt === "" ? "never" : pane.paste.expiresAt }, + { key: "reads", value: pane.paste.readCount }, + { key: "burn after", value: pane.paste.burnAfterReads === "N/A" ? "no limit" : pane.paste.burnAfterReads } + ] : [] + + ColumnLayout { + anchors.fill: parent + spacing: 6 + + Label { + Layout.fillWidth: true + font.bold: true + elide: Text.ElideRight + text: pane.paste ? pane.paste.id : "no paste open — pick one from the list" + } + + // One "key: value" line per fact rather than a two-column grid: a + // Repeater contributes one item per model entry, so a grid would need + // either two Repeaters (which can desynchronise) or a per-row wrapper — + // neither of which buys anything at this rung's styling budget. + Repeater { + model: pane.facts + + delegate: Label { + required property var modelData + Layout.fillWidth: true + elide: Text.ElideRight + text: modelData.key + ": " + modelData.value + } + } + + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + + TextArea { + readOnly: true + wrapMode: TextArea.Wrap + text: pane.paste ? pane.paste.content : "" + } + } + + Button { + text: "Delete this paste" + enabled: pane.paste !== null + onClicked: pane.deleteRequested(pane.paste.id) + } + } +} diff --git a/examples/pastebin/gui_lib/paste_forms_controller.cpp b/examples/pastebin/gui_lib/paste_forms_controller.cpp new file mode 100644 index 00000000..f17a7d6f --- /dev/null +++ b/examples/pastebin/gui_lib/paste_forms_controller.cpp @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "paste_forms_controller.hpp" + +// submitIfValid() is a template (OnReply/OnError deduced per call site, +// exactly like FormsControllerCore's own) and so stays fully defined in the +// header, alongside everything else here — this translation unit exists +// only to give the constructor (and this class generally) exactly one +// non-inline definition, matching every other gui_lib/*.cpp in this rung. + +namespace pastebin::gui { + +PasteFormsController::PasteFormsController(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor* executor, + std::string schemasJson) + : _handler{bridge, executor}, _schemasJson{std::move(schemasJson)} {} + +} // namespace pastebin::gui diff --git a/examples/pastebin/gui_lib/paste_forms_controller.hpp b/examples/pastebin/gui_lib/paste_forms_controller.hpp new file mode 100644 index 00000000..d1b2a730 --- /dev/null +++ b/examples/pastebin/gui_lib/paste_forms_controller.hpp @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include "pastebin/models/paste_model.hpp" + +#include +#include + +#include +#include +#include + +namespace pastebin::gui { + +/// @brief Same schema-driven surface as the shipped +/// `morph::qt::forms::FormsControllerCore` +/// (`schemasJson()`/`submitIfValid()`), composed over an injected +/// `Bridge&`/`IExecutor*` instead of constructing its own +/// `LocalBackend`. The shipped core's own `(Bridge&, IExecutor*, +/// schemasJson)` constructor now supports this directly (finding +/// 021), but this rung still owns a thin controller of its own — +/// `TESTING.md`'s presenter rule 2 forbids GUI code from +/// constructing its own backend/executor regardless, and this +/// controller predates the shipped core gaining that overload. Pure +/// glue, no domain logic (`IMPLEMENTATION.md` rule 2 justification +/// (b)) — the schema/validation/rendering machinery is untouched; +/// only the backend-wiring seam differs. +/// +/// `fetchOptions()` is deliberately not present: it exists on the shipped +/// `FormsControllerCore` to serve a `morph::forms::Choice` field's +/// combo-box options, and none of pastebin's DTOs +/// (`pastebin/dto/paste_dto.hpp`) declare a `Choice` field — `CreatePaste`'s +/// `Visibility`/`Editability` enums render as plain enum widgets, not a +/// server-fetched `Choice`. Adding an unused `fetchOptions()` here would be +/// a stub with nothing to call it; omitted rather than speculatively +/// implemented, per this task's own instruction. +class PasteFormsController { + public: + /// @param bridge The shared `Bridge` `AppContext` owns. + /// @param executor The executor `Completion` callbacks land on. + /// @param schemasJson Pre-assembled `{actionType: schemaJson()}` map, + /// matching `FormsControllerCore`'s own constructor contract. + /// Built by whatever composes this controller (Task 12's GUI + /// shell), not by this class. + PasteFormsController(::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 the generic + /// `executeJson` path, invoking @p onReply / @p onError on the GUI + /// thread once the reply arrives. Verbatim copy of + /// `FormsControllerCore::submitIfValid`'s logic + /// (`include/morph/qt/forms/forms_controller_core.hpp:53-58`): + /// `_handler` is the only thing that differs, since it is built + /// from the injected `Bridge&`/`IExecutor*` instead of a + /// hardcoded `LocalBackend`. + /// @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 Registered action type id. + /// @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) { + _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); }); + } + + private: + ::morph::bridge::BridgeHandler _handler; + std::string _schemasJson; +}; + +} // namespace pastebin::gui diff --git a/examples/pastebin/gui_lib/paste_presenter.cpp b/examples/pastebin/gui_lib/paste_presenter.cpp new file mode 100644 index 00000000..928e44b2 --- /dev/null +++ b/examples/pastebin/gui_lib/paste_presenter.cpp @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "paste_presenter.hpp" + +namespace pastebin::gui { + +PastePresenter::PastePresenter(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor* executor, QObject* parent) + : Presenter{parent}, _handler{bridge, executor} { + trackBound(_handler.whenBound()); +} + +void PastePresenter::reportError(const std::exception_ptr& err) { + try { + std::rethrow_exception(err); + } catch (const std::exception& e) { + emit failed(QString::fromStdString(e.what())); + } +} + +void PastePresenter::create(CreatePaste action) { + track( + _handler.execute(std::move(action)), [this](CreatePasteResult result) { emit created(std::move(result)); }, + [this](const std::exception_ptr& err) { reportError(err); }); +} + +void PastePresenter::get(GetPaste action) { + track( + _handler.execute(std::move(action)), [this](PasteView view) { emit loaded(std::move(view)); }, + [this](const std::exception_ptr& err) { reportError(err); }); +} + +void PastePresenter::edit(EditPaste action) { + track( + _handler.execute(std::move(action)), [this](PasteView view) { emit edited(std::move(view)); }, + [this](const std::exception_ptr& err) { reportError(err); }); +} + +void PastePresenter::remove(DeletePaste action) { + track( + _handler.execute(std::move(action)), [this](Ack) { emit removed(); }, + [this](const std::exception_ptr& err) { reportError(err); }); +} + +void PastePresenter::list(ListPastes action) { + track( + _handler.execute(std::move(action)), [this](ListPastesResult result) { emit listed(std::move(result)); }, + [this](const std::exception_ptr& err) { reportError(err); }); +} + +} // namespace pastebin::gui diff --git a/examples/pastebin/gui_lib/paste_presenter.hpp b/examples/pastebin/gui_lib/paste_presenter.hpp new file mode 100644 index 00000000..bbb4d6c9 --- /dev/null +++ b/examples/pastebin/gui_lib/paste_presenter.hpp @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include "gui/presenter.hpp" +#include "pastebin/dto/paste_dto.hpp" + +#include + +// Guarded like examples/bank/gui/controllers/AccountController.hpp and +// examples/forms/gui_qml/FormsController.hpp: moc only needs the +// Q_OBJECT/signals declarations below (and the DTO types above, which are +// lightweight — no Lightweight/ODBC dependency); it must not be pointed at +// morph's template-heavy bridge.hpp (not a real C++ front end, and +// bridge.hpp's template machinery produces bogus moc output the same way +// paste_model.hpp historically did when it transitively pulled in +// Lightweight's DataMapper machinery through the since-removed +// pastebin/db/db_model.hpp -- paste_model.hpp itself no longer has any +// Lightweight/ODBC dependency at all, now that PasteModel 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 "pastebin/models/paste_model.hpp" + +#include +#include +#endif + +namespace pastebin::gui { + +/// @brief Routes CreatePaste/GetPaste/EditPaste/DeletePaste/ListPastes +/// through a `BridgeHandler`, surfacing typed errors to +/// whatever view composes this (QML properties/signals, Task 12). +/// Translates and routes only — no domain logic +/// (`IMPLEMENTATION.md` rule 2). +class PastePresenter : 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. + PastePresenter(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor* executor, QObject* parent = nullptr); + + /// @brief Stores a new paste. Emits `created` on success, `failed` on error. + /// @param action The paste to store. + void create(CreatePaste action); + + /// @brief Reads (and consumes one read of) a paste. Emits `loaded` on + /// success, `failed` on error. + /// @param action The paste to read. + void get(GetPaste action); + + /// @brief Replaces an editable paste's content and syntax. Emits + /// `edited` on success, `failed` on error. + /// @param action The edit to apply. + void edit(EditPaste action); + + /// @brief Deletes a paste. Emits `removed` on success, `failed` on error. + /// @param action The paste to delete. + void remove(DeletePaste action); + + /// @brief Fetches one page of public pastes. Emits `listed` on success, + /// `failed` on error. + /// @param action The page request. + void list(ListPastes action); + + signals: + void created(CreatePasteResult result); + void loaded(PasteView view); + void edited(PasteView view); + void removed(); + void listed(ListPastesResult 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: rethrows @p err to recover the concrete + /// message and emits `failed`. Passed as `track`'s `onErr` + /// parameter — see `Presenter::track()`'s doc comment + /// (`examples/common/gui/presenter.hpp`) for why that, rather + /// than a separate `.onError(...)` attached directly on the + /// `Completion` beforehand, is where this belongs. Factored + /// out (rather than duplicated per action) since it does not + /// depend on the action's result type `T` — only on the + /// `std::exception_ptr` every `onErr` callback receives — so it + /// stays a plain member function, not a template. + void reportError(const std::exception_ptr& err); + + ::morph::bridge::BridgeHandler _handler; +}; + +} // namespace pastebin::gui diff --git a/examples/pastebin/gui_lib/paste_qml_bridges.cpp b/examples/pastebin/gui_lib/paste_qml_bridges.cpp new file mode 100644 index 00000000..a1e0af79 --- /dev/null +++ b/examples/pastebin/gui_lib/paste_qml_bridges.cpp @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "paste_qml_bridges.hpp" + +#include "paste_schemas.hpp" + +#include + +#include +#include +#include + +namespace pastebin::gui { + +namespace { + +/// @brief Renders an optional instant as ISO-8601, or an empty string. +[[nodiscard]] QString isoOrEmpty(const ::morph::time::Timestamp& instant) { + return instant.hasValue() ? QString::fromStdString((*instant).toIso8601()) : QString{}; +} + +/// @brief Renders a read count via `morph::units::toString` (`"N/A"` when the +/// quantity is empty, i.e. "no burn limit"). +/// +/// `morph::units::toString`, not `std::format("{}", reads)`: the two produce +/// identical text (the `std::formatter` specialization delegates to +/// the same function), but Emscripten's bundled libc++ fails to compile the +/// `std::format` call outright — see `toString`'s own doc comment +/// (`include/morph/util/quantity.hpp`) for why. +[[nodiscard]] QString readsText(const pastebin::Reads& reads) { + return QString::fromStdString(morph::units::toString(reads)); +} + +/// @brief `PasteId` as plain text (empty when unengaged). +[[nodiscard]] QString idText(const pastebin::PasteId& id) { + return id.hasValue() ? QString::fromStdString(*id) : QString{}; +} + +/// @brief A `PasteView` as the property bag `PasteView.qml` binds against. +[[nodiscard]] QVariantMap toVariantMap(const pastebin::PasteView& view) { + return QVariantMap{ + {"id", idText(view.id)}, + {"content", QString::fromStdString(view.content)}, + {"syntax", QString::fromStdString(view.syntax)}, + {"createdAt", isoOrEmpty(view.createdAt)}, + {"expiresAt", isoOrEmpty(view.expiresAt)}, + {"burnAfterReads", readsText(view.burnAfterReads)}, + {"readCount", readsText(view.readCount)}, + {"visibility", + view.visibility == pastebin::Visibility::Private ? QStringLiteral("Private") : QStringLiteral("Public")}, + {"editability", view.editability == pastebin::Editability::Editable ? QStringLiteral("Editable") + : QStringLiteral("Immutable")}, + }; +} + +/// @brief One `ListPastes` row as the property bag the list delegate binds +/// against. Narrower than `toVariantMap` because `PasteSummary` is +/// narrower than `PasteView` on purpose — a listing must not leak +/// paste content (`pastebin/dto/paste_dto.hpp`). +[[nodiscard]] QVariantMap toVariantMap(const pastebin::PasteSummary& summary) { + return QVariantMap{ + {"id", idText(summary.id)}, + {"syntax", QString::fromStdString(summary.syntax)}, + {"createdAt", isoOrEmpty(summary.createdAt)}, + {"visibility", + summary.visibility == pastebin::Visibility::Private ? QStringLiteral("Private") : QStringLiteral("Public")}, + }; +} + +} // namespace + +FormsBridge::FormsBridge(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor* executor, QObject* parent) + : QObject{parent}, _controller{bridge, executor, pasteSchemasJson()} {} + +QString FormsBridge::schemasJson() const { + return QString::fromStdString(_controller.schemasJson()); +} + +void FormsBridge::submitIfValid(const QString& actionType, const QString& bodyJson) { + _controller.submitIfValid( + actionType.toStdString(), bodyJson.toStdString(), + [this, actionType](std::string resultJson) { + emit replyReceived(actionType, true, QString::fromStdString(resultJson)); + }, + [this, actionType](const std::exception_ptr& err) { + try { + std::rethrow_exception(err); + } catch (const std::exception& e) { + emit replyReceived(actionType, false, QString::fromUtf8(e.what())); + } + }); +} + +PasteBridge::PasteBridge(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor* executor, QObject* parent) + : QObject{parent}, _presenter{bridge, executor} { + // Direct (same-thread) connections throughout — see this header's + // "Threading" note for why no meta-type registration is involved. + connect(&_presenter, &PastePresenter::bound, this, &PasteBridge::bound); + connect(&_presenter, &PastePresenter::listed, this, [this](const pastebin::ListPastesResult& result) { + QVariantList rows; + rows.reserve(static_cast(result.pastes.size())); + for (const auto& summary : result.pastes) { + rows.append(toVariantMap(summary)); + } + emit listed(rows); + }); + connect(&_presenter, &PastePresenter::loaded, this, + [this](const pastebin::PasteView& view) { emit loaded(toVariantMap(view)); }); + // `PastePresenter::created`/`edited` are deliberately not relayed: + // creating goes through the schema-driven form (FormsBridge above), so + // its reply arrives on `replyReceived`, and this rung's shell ships no + // edit screen. Relaying a signal nothing binds to would be a stub. + connect(&_presenter, &PastePresenter::removed, this, &PasteBridge::removed); + connect(&_presenter, &PastePresenter::failed, this, &PasteBridge::failed); +} + +void PasteBridge::refresh() { + _presenter.list(pastebin::ListPastes{}); +} + +void PasteBridge::open(const QString& id) { + _presenter.get(pastebin::GetPaste{.id = pastebin::PasteId{id.toStdString()}}); +} + +void PasteBridge::remove(const QString& id) { + _presenter.remove(pastebin::DeletePaste{.id = pastebin::PasteId{id.toStdString()}}); +} + +} // namespace pastebin::gui diff --git a/examples/pastebin/gui_lib/paste_qml_bridges.hpp b/examples/pastebin/gui_lib/paste_qml_bridges.hpp new file mode 100644 index 00000000..d8b72bbf --- /dev/null +++ b/examples/pastebin/gui_lib/paste_qml_bridges.hpp @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include + +// Guarded exactly like paste_presenter.hpp's own includes: AUTOMOC runs moc +// over this header, and moc must not be pointed at morph's template-heavy +// bridge.hpp or at paste_model.hpp, which pulls in Lightweight's DataMapper +// machinery — moc is not a C++ front end and mis-parses it, emitting the rest +// of the file inside a namespace it wrongly believes is still open. moc needs +// nothing from these headers: the macros, signals and `Q_INVOKABLE` +// signatures below are all it reads. +#ifndef Q_MOC_RUN +#include "paste_forms_controller.hpp" +#include "paste_presenter.hpp" + +#include +#include +#endif + +/// @file +/// The two QML-facing adapters pastebin's shells put in front of the Task 10 +/// GUI-layer classes. They live in `gui_lib` — not in a shell's `main.cpp` — +/// because *both* shells need them and must be the same program: +/// `gui/main.cpp` (desktop) and `gui_wasm/main_wasm.cpp` (browser) differ +/// only in how they choose a deployment mode, per `examples/TESTING.md`'s +/// "same client code" requirement and its ban on bank's shadow-header +/// pattern. +/// +/// @par Why these adapters exist at all +/// Neither Task 10 class is directly consumable from QML — deliberately. +/// `PasteFormsController` is a plain class (no `Q_OBJECT`) whose +/// `submitIfValid` takes C++ callbacks, and `PastePresenter`'s signals carry +/// raw C++ DTOs (`PasteView`, `ListPastesResult`) that QML has no reading of. +/// The two classes below are the thinnest possible translation from those +/// surfaces to the `QString`/`QVariantMap` shapes QML binds against. They +/// decide nothing: every conditional and every rule stays in the model, and +/// the only formatting they perform is rendering a `Timestamp`/`Quantity` as +/// the text a `Label` shows (`TESTING.md` presenter rule 6's "QML is +/// bindings-only", `IMPLEMENTATION.md` rule 2's "pure glue"). +/// +/// @par Qt6::Core only +/// Nothing here needs Qt Quick or Qt Qml: a `QVariantMap` is Qt Core, and the +/// engine-facing side is `setInitialProperties` in each shell. That keeps +/// `ladder_pastebin_gui_lib` inside presenter rule 1's Qt6::Core-only bound +/// and keeps these adapters instantiable under a plain `QCoreApplication`. +/// +/// @par Threading, and why no `Q_DECLARE_METATYPE`/`qRegisterMetaType` +/// Everything in a client process lives on the one Qt event-loop thread: the +/// engine, both adapters, and the `PastePresenter` they wrap are all +/// constructed on it, and `AppContext`'s executor is a `QtExecutor`, so every +/// completion callback — and therefore every `PastePresenter` signal emission +/// — is delivered on that same thread too. A same-thread `AutoConnection` is +/// a *direct* connection: the argument is passed straight through as a C++ +/// reference and Qt never asks the meta-type system to copy it. So the DTO +/// signals need no `Q_DECLARE_METATYPE` and no `qRegisterMetaType`, and none +/// is added: an unused registration would be a speculative stub, and the +/// worker pool that does run on other threads is behind the `Bridge`, which +/// never emits a Qt signal. The one thing that *would* break this is moving a +/// presenter to another thread or connecting one to a QML object across +/// contexts — neither of which either shell does, and both of which would +/// fail loudly ("Cannot queue arguments of type 'pastebin::PasteView'") +/// rather than silently. + +namespace pastebin::gui { + +/// @brief QML-facing face of `pastebin::gui::PasteFormsController`. +/// +/// Same surface `DynamicForm.qml` expects of a controller — a `schemasJson` +/// property, `submitIfValid(actionType, bodyJson)`, and a `replyReceived` +/// signal — so the shipped renderer needs no pastebin-specific knowledge. +class FormsBridge : public QObject { + Q_OBJECT + + /// @brief `{actionType: schema}` JSON — 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. + FormsBridge(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor* executor, QObject* parent = nullptr); + + /// @brief The schema document supplied to the wrapped controller + /// (`paste_schemas.hpp`). + /// @return `{actionType: schema}` JSON. + [[nodiscard]] QString schemasJson() const; + + /// @brief Dispatches @p bodyJson as @p actionType's body, emitting + /// `replyReceived` when the reply (or the error) arrives. + /// @param actionType Registered action type id. + /// @param bodyJson Fully-assembled JSON body, as `DynamicForm` builds it. + Q_INVOKABLE void submitIfValid(const QString& actionType, const QString& bodyJson); + +signals: + /// @brief Emitted once per `submitIfValid`. @p payload is the result JSON + /// when @p ok, otherwise the error message. + /// @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); + +private: +#ifndef Q_MOC_RUN + PasteFormsController _controller; +#endif +}; + +/// @brief QML-facing face of `pastebin::gui::PastePresenter`. +/// +/// Turns the presenter's DTO-carrying signals into `QVariantMap`/`QVariantList` +/// property bags and its typed `create`/`get`/`list`/`remove` calls into +/// id-string invokables. No decisions: burn/expiry, visibility and pagination +/// are all the model's, and this only relays what the server computed. +class PasteBridge : public QObject { + Q_OBJECT + +public: + /// @param bridge The shared `Bridge` `AppContext` owns. + /// @param executor The executor `Completion` callbacks land on. + /// @param parent Optional `QObject` parent. + PasteBridge(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor* executor, QObject* parent = nullptr); + + /// @brief Fetches the first page of public pastes. + Q_INVOKABLE void refresh(); + + /// @brief Reads @p id — which consumes one read, so a burn-after-N paste + /// moves one step closer to being burned. Emits `loaded`, or + /// `failed` with the model's own message for a burned/expired/absent + /// paste. + /// @param id The paste to open. + Q_INVOKABLE void open(const QString& id); + + /// @brief Deletes @p id. + /// @param id The paste to delete. + Q_INVOKABLE void remove(const QString& id); + +signals: + /// @brief Emitted once the wrapped presenter's registration round trip + /// settles — successfully or not (`Presenter::bound()`, + /// `morph/core/bridge.hpp`'s `whenBound()`). `Remote` mode's first + /// dispatch attempt fails fast with "handler not bound" until this + /// fires; `Local` mode fires it synchronously from this + /// constructor, since its handler is already bound by + /// construction. QML's `Main.qml` gates its bootstrap `refresh()` + /// on this instead of retrying on a `Timer`. + void bound(); + + /// @brief One page of `ListPastes` rows, each a `{id, syntax, createdAt, visibility}` map. + /// @param rows The page's rows. + void listed(const QVariantList& rows); + /// @brief A fetched paste, as a property bag. + /// @param paste The paste's fields, rendered as display strings. + void loaded(const QVariantMap& paste); + /// @brief A `DeletePaste` succeeded. + void removed(); + /// @brief Any action's typed error, already rendered as a message. + /// @param message The model's own `what()`. + void failed(const QString& message); + +private: +#ifndef Q_MOC_RUN + PastePresenter _presenter; +#endif +}; + +} // namespace pastebin::gui diff --git a/examples/pastebin/gui_lib/paste_schemas.hpp b/examples/pastebin/gui_lib/paste_schemas.hpp new file mode 100644 index 00000000..457a4e25 --- /dev/null +++ b/examples/pastebin/gui_lib/paste_schemas.hpp @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include + +#include "pastebin/dto/paste_dto.hpp" + +/// @file +/// The one schema document pastebin's create form renders from, in one place +/// so every shell that builds a `PasteFormsController` — the desktop client +/// (`gui/main.cpp`), the WASM client (`gui_wasm/main_wasm.cpp`) and the +/// presenter tests — builds the *identical* map instead of each assembling +/// its own (`examples/TESTING.md`'s "same client code" requirement: the two +/// clients must differ only in their `main()`). + +namespace pastebin::gui { + +/// @brief The `{actionType: schema}` document the create form renders from. +/// +/// Only `CreatePaste` is schema-driven: it is the one action a user *enters*. +/// Reading, listing and deleting are parameterised by a paste id the user +/// picks from the list, never typed, so they route through `PastePresenter` +/// and need no form. Assembled here rather than in `PasteFormsController` +/// because that class takes the document as a constructor argument by design +/// (whatever composes it decides which actions it serves) — the same split +/// `morph::qt::forms::FormsControllerCore` and `lab::schemasJson()` use. +/// +/// @return `{"CreatePaste": ()>}`. +[[nodiscard]] inline std::string pasteSchemasJson() { + return std::string{"{\"CreatePaste\":"} + ::morph::forms::schemaJson() + "}"; +} + +} // namespace pastebin::gui diff --git a/examples/pastebin/gui_wasm/main_wasm.cpp b/examples/pastebin/gui_wasm/main_wasm.cpp new file mode 100644 index 00000000..46afaaed --- /dev/null +++ b/examples/pastebin/gui_wasm/main_wasm.cpp @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: Apache-2.0 + +/// @file +/// pastebin's WebAssembly client shell — rung 1's payoff on rung 0's +/// WASM-remote spike (`examples/common/wasm_spike/`). +/// +/// This file is the *only* difference between the browser client and the +/// desktop client (`gui/main.cpp`). Everything with behaviour in it — the +/// presenters (`gui_lib/paste_presenter.hpp`), the forms controller +/// (`gui_lib/paste_forms_controller.hpp`), the QML adapters +/// (`gui_lib/paste_qml_bridges.hpp`), the schema document +/// (`gui_lib/paste_schemas.hpp`) and the QML itself (`gui/qml/Main.qml`, built +/// into the `Pastebin` module both binaries link) — is shared verbatim. 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. +/// +/// Two things are genuinely WASM-specific, and both are one line each: +/// +/// * **Mode.** There is no `--server` flag and no `Local` alternative. A +/// browser has no ODBC and no in-process server to be `Local` against, so a +/// ladder WASM client is always `Remote` (`examples/IMPLEMENTATION.md` rule +/// 4's WASM clause: "Lightweight (ODBC) cannot run in the browser… the +/// ladder's WASM clients are **remote clients** — persistence lives +/// server-side, behind the model"). The url is baked in at build time via +/// `MORPH_LADDER_PASTEBIN_WASM_SERVER_URL` (`../CMakeLists.txt`), following +/// the spike's own `MORPH_LADDER_WASM_SPIKE_SERVER_URL` convention — a page +/// served from a static bundle has no argv to read one from. +/// * **No database bootstrap.** `gui/main.cpp` calls `pastebin::db::setup()` +/// in `Local` mode; there is nothing to set up here. +/// +/// Note what is *not* here: no `asyncRegistrationEnabled` flag, no +/// `setConnectHandler`, no hand-rolled wait-for-binding timer. The spike had +/// to hand-roll all three; `AppContext` (`examples/common/gui/app_context.hpp`) +/// now owns the first two generically for every client, native or browser, and +/// `PasteBridge::bound` — backed by `Bridge::whenBound()`, shared like the +/// rest of the QML adapters — covers the third (the "handler not bound" +/// window that opens on connect and closes when registration settles; it is +/// a *remote* mode gap, so this client hits exactly the same one the desktop +/// client does in `--server` mode, and `Main.qml` gates its bootstrap +/// `refresh()` on the same signal in both). +/// +/// @par Verification status +/// Structurally complete and reviewed, **never compiled**: no Emscripten +/// toolchain was available in the environment this was authored in, exactly as +/// `examples/common/wasm_spike/README.md` records for the spike. The +/// `ladder-wasm` compile gate added to `.github/workflows/wasm-ladder.yml` is +/// what will actually prove it, on the first push that runs it. + +#include +#include +#include +#include +#include + +#include "gui/app_context.hpp" +#include "paste_qml_bridges.hpp" + +#include + +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_PASTEBIN_WASM_SERVER_URL)}}}; + + QQmlApplicationEngine engine; + std::unique_ptr formsBridge; + std::unique_ptr pasteBridge; + + // Every handler is built from inside onReady(), never before it: a Remote + // context is not usable the line after its constructor returns, and a + // registration issued before the socket is up fails permanently with no + // retry (docs/findings/017). Identical to gui/main.cpp's --server path. + ctx.onReady([&] { + formsBridge = std::make_unique(ctx.bridge(), ctx.executor()); + pasteBridge = std::make_unique(ctx.bridge(), ctx.executor()); + engine.setInitialProperties({ + {QStringLiteral("formsController"), QVariant::fromValue(formsBridge.get())}, + {QStringLiteral("pasteController"), QVariant::fromValue(pasteBridge.get())}, + }); + engine.loadFromModule(MORPH_LADDER_QML_URI, "Main"); + if (engine.rootObjects().isEmpty()) { + qWarning("ladder_pastebin_gui_wasm: QML engine produced no root object"); + } + }); + + qInfo("ladder_pastebin_gui_wasm: connecting to %s ...", MORPH_LADDER_PASTEBIN_WASM_SERVER_URL); + return QGuiApplication::exec(); +} diff --git a/examples/pastebin/include/pastebin/app/app.hpp b/examples/pastebin/include/pastebin/app/app.hpp new file mode 100644 index 00000000..6f20561f --- /dev/null +++ b/examples/pastebin/include/pastebin/app/app.hpp @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +namespace pastebin::app { + +/// @brief Owns the server-side pieces every pastebin deployment shares: the +/// worker pool, the `RemoteServer`, the durable `FileActionLog` (installed +/// process-wide via `morph::journal::setActionLog`, so every `PasteModel` +/// instance auto-attaches — see its own doc comment), and the periodic +/// expiry sweep. Nothing here decides deployment mode (`Local`/`Remote`) — +/// that stays `examples/common/gui::AppContext`'s job on the client side; +/// this is exclusively the server side. +/// +/// The expiry sweep dispatches `ExpirePaste{id}` through an **internal +/// client** — a `Bridge` over `SimulatedRemoteBackend{*server()}` — a +/// first-class client of the same `RemoteServer` a real socket client +/// talks to (`SimulatedRemoteBackend::execute()` calls +/// `RemoteServer::handle()`, the identical dispatch path), so every swept +/// expiry is authorized, dispatched, and auto-journaled exactly like a +/// client-issued action. See `examples/pastebin/README.md`'s "How does +/// expiry replay?" for the full rationale, including why sweep *timing* +/// does not affect correctness (`PasteModel::execute(GetPaste)`'s own +/// atomic update already excludes an expired row on its own). +class App : public QObject { + Q_OBJECT + public: + /// @param actionLogPath Where `FileActionLog` persists entries. + /// @param sweepInterval How often the expiry sweep runs. Tests pass a + /// long interval (effectively disabling the timer) and call + /// `sweepExpiredOnce()` directly instead, for determinism. + /// @param workers Size of the model worker pool. + /// @param parent Optional `QObject` parent. + explicit App(std::filesystem::path actionLogPath, std::chrono::milliseconds sweepInterval = std::chrono::seconds{5}, + std::size_t workers = 4, QObject* parent = nullptr); + + /// @brief Detaches the process-wide default action log. + ~App() override; + + 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 + /// `BackendRig`) wraps or dispatches against. + [[nodiscard]] std::shared_ptr<::morph::backend::RemoteServer> server() const noexcept { return _server; } + + /// @brief Runs one expiry sweep pass right now: finds every paste whose + /// `expires_at_ms` has passed and fire-and-forget dispatches + /// `ExpirePaste` for each through the internal client. Does not + /// block on the dispatched calls settling — callers that need + /// to observe completion (tests) pump the Qt event loop + /// afterward (`morph::ladder::testkit::pumpUntil`). + /// + /// The internal client used to issue this pass's dispatches stays alive + /// (via a lifetime extended past this call) until every dispatched + /// `ExpirePaste` has actually settled, success or failure — see the + /// implementation's doc comment for why deregistering it any earlier + /// would race `RemoteServer`'s still-pending dispatch and silently drop + /// the reclaim for this pass. + void sweepExpiredOnce(); + + /// @brief Whether any `ExpirePaste` dispatched by a previous + /// `sweepExpiredOnce()` has not settled yet. + /// + /// The settle seam a test needs before letting an `App` go, mirroring + /// `Presenter::busy()`. Observing the *effect* of a sweep (the rows are + /// gone) is not the same as the dispatches having settled: the reclaim + /// happens on a worker thread, while each call's completion callback is + /// delivered later, on the Qt event loop. Destroying the `App` in that + /// window leaves those callbacks queued against objects it owned, and + /// they detonate whenever some later `processEvents()` gets to them — + /// which is nowhere near the code that caused it. Pump on this until it + /// is `false`, then destroy. + /// @return `true` while at least one dispatched `ExpirePaste` is + /// outstanding. + [[nodiscard]] bool sweepInFlight() const noexcept { return _sweepInFlight->load() != 0; } + + private: + // Declaration order is load-bearing, and `_sweepExecutor` comes first on + // purpose: members are destroyed in reverse, so this is the *last* thing + // to go. A sweep's `ExpirePaste` runs on `_pool`, and the worker thread + // that finishes it resolves the completion by calling `post()` on the + // executor the call was issued with. With the executor declared after the + // pool (its natural reading order), `~App` destroyed it while pool + // threads were still finishing dispatched sweeps, and the next completion + // to resolve posted through a dangling `IExecutor*` — an intermittent + // segfault, reproduced by this rung's sweep tests, in whichever test + // happened to be running when the late completion landed. Destroying + // `_pool` (which joins its threads, so every in-flight completion has + // resolved) before the executor closes that window. `QtExecutor` itself + // holds no state and queues onto `QCoreApplication`, so the callbacks it + // has already posted stay safe after `App` is gone. + ::morph::qt::QtExecutor _sweepExecutor; + /// Outstanding dispatches from `sweepExpiredOnce()`. A `shared_ptr` so the + /// completion callbacks that decrement it hold it by value rather than + /// through `this` — a callback delivered after the `App` is gone (the very + /// case `sweepInFlight()` exists to let callers avoid) must not touch a + /// destroyed member. + std::shared_ptr> _sweepInFlight{std::make_shared>(0)}; + std::shared_ptr<::morph::journal::FileActionLog> _actionLog; + ::morph::exec::ThreadPoolExecutor _pool; + std::shared_ptr<::morph::backend::RemoteServer> _server; + ::morph::bridge::Bridge _sweepBridge; + QTimer _sweepTimer; +}; + +} // namespace pastebin::app diff --git a/examples/pastebin/include/pastebin/core/errors.hpp b/examples/pastebin/include/pastebin/core/errors.hpp new file mode 100644 index 00000000..5961c249 --- /dev/null +++ b/examples/pastebin/include/pastebin/core/errors.hpp @@ -0,0 +1,56 @@ +// 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 on the GUI executor. On a remote backend the +/// `what()` string travels back in the error envelope. + +namespace pastebin { + +/// @brief Base of every pastebin-specific error a model throws. +struct PastebinError : std::runtime_error { + using std::runtime_error::runtime_error; +}; + +/// @brief No paste exists at the given id (never existed, deleted, or +/// already expired/burned). +struct NotFound : PastebinError { + using PastebinError::PastebinError; +}; + +/// @brief The paste existed but its `expiresAt` has passed. +struct Expired : PastebinError { + using PastebinError::PastebinError; +}; + +/// @brief The paste existed but its burn-after-reads budget was already +/// exhausted before this read. +struct Burned : PastebinError { + using PastebinError::PastebinError; +}; + +/// @brief An action's `validate()` rejected its input. +struct ValidationError : PastebinError { + using PastebinError::PastebinError; +}; + +/// @brief `CreatePaste`'s content exceeded the server's message-size bound. +struct TooLarge : PastebinError { + using PastebinError::PastebinError; +}; + +/// @brief `EditPaste` lost a race: the paste's content/syntax changed +/// between this client's read and its write. Distinct from +/// `ValidationError` — the request was well-formed and the paste +/// exists and is editable, but the specific edit could not be applied +/// because it was no longer editing what it thought it was editing. +struct Conflict : PastebinError { + using PastebinError::PastebinError; +}; + +} // namespace pastebin diff --git a/examples/pastebin/include/pastebin/core/types.hpp b/examples/pastebin/include/pastebin/core/types.hpp new file mode 100644 index 00000000..9cbf783e --- /dev/null +++ b/examples/pastebin/include/pastebin/core/types.hpp @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include + +/// @file +/// PasteId: a hasValue()-capable strong id wrapping the animal-name paste +/// key. Modeled on morph::forms::Ranged's shape +/// (include/morph/forms/widget_hints.hpp) — the closest existing +/// hasValue()-capable newtype template — but wraps std::optional, +/// not a bounded arithmetic value, so it carries its own glz::meta rather than +/// reusing Ranged's. morph has since grown Tagged +/// (include/morph/util/tagged.hpp), a generic newtype helper this type +/// predates; not migrated onto it here, since nothing forces the change and +/// this file's own glz::meta already does the same job. + +namespace pastebin { + +/// @brief Strong id for a paste (the animal-name key, e.g. "swift-otter"). +/// +/// Wire form: a plain JSON string (via the `glz::meta` specialisation below), +/// exactly like an unwrapped `std::string` member — see the `glz::meta` +/// specialisation for the exact convention this follows. +struct PasteId { + /// @brief The payload; `std::nullopt` means "not entered". + std::optional value; + + /// @brief Constructs the empty state. + constexpr PasteId() noexcept = default; + + /// @brief Engages with @p id. + explicit PasteId(std::string id) noexcept : value{std::move(id)} {} + + /// @brief Adopts an optional payload as-is. + /// + /// A named factory rather than a second same-arity constructor: a + /// `std::string`-taking constructor and an + /// `std::optional`-taking constructor are both viable, + /// equal-rank user-defined-conversion candidates for a string literal + /// (`const char*`) argument, so `PasteId{"swift-otter"}` would be + /// ambiguous if both were constructors. Keeping only the `std::string` + /// overload as a constructor avoids that entirely. + /// @param payload The optional payload to adopt as-is. + /// @return A `PasteId` wrapping @p payload directly. + [[nodiscard]] static PasteId fromOptional(std::optional payload) noexcept { + PasteId 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 PasteId&) const noexcept = default; +}; + +/// @brief Opaque pagination cursor for `ListPastes`. +/// +/// Same `hasValue()`-capable opaque-string shape as `PasteId` — a distinct +/// concrete type following the identical pattern (`IMPLEMENTATION.md` rule +/// 3's protocol-scalars row: pagination cursors get a named opaque newtype +/// per role, never a loose `std::string`), not the same helper reused a +/// third time, so the promotion rule does not apply here. +struct PasteCursor { + /// @brief The payload; `std::nullopt` means "not entered". + std::optional value; + + /// @brief Constructs the empty state. + constexpr PasteCursor() noexcept = default; + + /// @brief Engages with @p token. + explicit PasteCursor(std::string token) noexcept : value{std::move(token)} {} + + /// @brief Adopts an optional payload as-is. + /// + /// A named factory rather than a second same-arity constructor — see + /// `PasteId::fromOptional` for why: a `std::string`-taking constructor + /// and an `std::optional`-taking constructor would be + /// equal-rank candidates for a string literal argument, making + /// `PasteCursor{"..."}` ambiguous. + /// @param payload The optional payload to adopt as-is. + /// @return A `PasteCursor` wrapping @p payload directly. + [[nodiscard]] static PasteCursor fromOptional(std::optional payload) noexcept { + PasteCursor 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 PasteCursor&) const noexcept = default; +}; + +/// @brief Trivial, fieldless acknowledgement result for actions with nothing +/// else to return (`DeletePaste`, `ExpirePaste`). +struct Ack {}; + +} // namespace pastebin + +/// @brief On the wire a PasteId is its nullable underlying string — the +/// strong-typing lives in the C++ type only. +template <> +struct glz::meta { + static constexpr auto value = &pastebin::PasteId::value; + static constexpr std::string_view name = "PasteId"; +}; + +/// @brief On the wire a PasteCursor is its nullable underlying string — the +/// strong-typing lives in the C++ type only. +template <> +struct glz::meta { + static constexpr auto value = &pastebin::PasteCursor::value; + static constexpr std::string_view name = "PasteCursor"; +}; diff --git a/examples/pastebin/include/pastebin/db/database.hpp b/examples/pastebin/include/pastebin/db/database.hpp new file mode 100644 index 00000000..15505a63 --- /dev/null +++ b/examples/pastebin/include/pastebin/db/database.hpp @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +/// @file +/// pastebin::db::setup — mirrors bank::db::setup's bootstrap shape +/// (examples/bank/include/bank/db/database.hpp): set the default connection +/// string, then apply every pending LIGHTWEIGHT_SQL_MIGRATION. The +/// migration itself lives in schema.cpp so linking that one TU registers it +/// against MigrationManager's process-wide singleton at static-init time. + +namespace pastebin::db { + +/// @brief Points Lightweight's default connection at @p connectionString and +/// applies every pending migration. +/// +/// Production-bootstrap-only: Task 6's server app calls this once, at +/// process start. Tests never call it — rung 0's `DbFixture` already sets +/// the default connection string exactly once per process and applies every +/// pending migration on each fixture construction; the +/// `LIGHTWEIGHT_SQL_MIGRATION` this module registers is picked up +/// automatically the moment the pastebin library is linked in, `setup()` or +/// not. +/// +/// @param connectionString ODBC connection string (SQLite via sqliteodbc in +/// every ladder test/demo context). +void setup(const std::string& connectionString); + +} // namespace pastebin::db diff --git a/examples/pastebin/include/pastebin/db/paste_entity.hpp b/examples/pastebin/include/pastebin/db/paste_entity.hpp new file mode 100644 index 00000000..3ef6806c --- /dev/null +++ b/examples/pastebin/include/pastebin/db/paste_entity.hpp @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include +#include + +/// @file +/// PasteRecord: the one Lightweight entity this rung needs, kept strictly +/// separate from the wire DTOs (pastebin/dto/paste_dto.hpp) per +/// IMPLEMENTATION.md rule 4's two-type-layer architecture. `id` is the +/// animal-name key itself (the primary key IS the public id — no separate +/// surrogate integer key), so it is a plain string primary key, not +/// auto-incremented: `Light::PrimaryKey::AutoAssign` is Lightweight's +/// enumerator for "primary key, caller supplies the value" (its doc comment: +/// "If the field is neither auto-incrementable nor a GUID, it must be +/// manually set" — exactly this column). There is no `ManualAssign` +/// enumerator; `Light::PrimaryKey` has exactly three values: `No`, +/// `AutoAssign`, `ServerSideAutoIncrement` (the latter is what bank's +/// surrogate integer keys use). +/// +/// `content` is `Light::SqlMaxDynamicWideString`, not `std::string`: a paste +/// body is unbounded free-form Unicode text, and `db_fixture.hpp`'s +/// `computeConnectionString()` lets `ODBC_CONNECTION_STRING` point this same +/// suite at a SQL Server backend instead of its SQLite default (per +/// `examples/LADDER.md`'s security matrix, which expects rungs to eventually +/// gain non-SQLite CI legs). On that backend, `Light::SqlText`/bare +/// `std::string` — both `char`-based — render as `VARCHAR(MAX)`, a +/// single-byte-collation column: non-ASCII paste content would not +/// round-trip correctly there. `SqlMaxDynamicWideString` is `wchar_t`-based, +/// so its `SqlBasicStringOperations` specialization self-declares `NVarchar` +/// as its column type instead of `Varchar`/`Text`, which every dialect's +/// formatter renders as an unbounded Unicode column (`NVARCHAR(MAX)` on SQL +/// Server once size exceeds `SqlOptimalMaxColumnSize`; SQLite ignores +/// declared length as pure type-affinity and stores UTF-8 natively either +/// way). The model converts at the DTO boundary +/// (`Lightweight::ToStdWideString`/`Lightweight::ToUtf8`), since the wire +/// DTOs stay UTF-8 `std::string` per IMPLEMENTATION.md rule 4 — only this +/// entity field's storage representation is wide. `id`/`syntax` use +/// `Light::SqlAnsiString<32>` instead, matching bank's convention for +/// fixed-width/ASCII/token-shaped columns, where the ASCII assumption is +/// actually true. + +namespace pastebin::db { + +/// @brief One row of the `pastes` table. +struct PasteRecord { + static constexpr std::string_view TableName = "pastes"; + + /// The animal-name id; caller-assigned, not auto-incremented. + Light::Field, Light::PrimaryKey::AutoAssign, Light::SqlRealName{"id"}> id; // 0 + Light::Field content; // 1 + Light::Field, Light::SqlRealName{"syntax"}> syntax; // 2 + Light::Field createdAtMs{0}; // 3 + /// `std::nullopt` = never expires. + Light::Field, Light::SqlRealName{"expires_at_ms"}> expiresAtMs; // 4 + /// `std::nullopt` = no burn limit. + Light::Field, Light::SqlRealName{"burn_after_reads"}> burnAfterReads; // 5 + Light::Field readCount{0}; // 6 + Light::Field isPrivate{false}; // 7 + Light::Field isEditable{false}; // 8 +}; + +} // namespace pastebin::db diff --git a/examples/pastebin/include/pastebin/dto/paste_dto.hpp b/examples/pastebin/include/pastebin/dto/paste_dto.hpp new file mode 100644 index 00000000..36e08944 --- /dev/null +++ b/examples/pastebin/include/pastebin/dto/paste_dto.hpp @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include "pastebin/core/types.hpp" +#include "pastebin/units.hpp" + +#include + +#include +#include +#include +#include +#include + +/// @file +/// Pastebin's one entity's wire DTOs. GetPaste is the one client-visible, +/// journaled mutation (README "Journal" design decision — not split into an +/// unlogged read + RecordRead). ExpirePaste is dispatched only by the +/// app-layer sweep's internal client (Task 6), never by a GUI client. + +namespace pastebin { + +enum class Visibility { Public, Private }; +enum class Editability { Immutable, Editable }; + +/// @brief Longest `syntax` label, in bytes, that `CreatePaste`/`EditPaste` +/// accept. +/// +/// This is the storage column's exact width, not a policy number pulled from +/// the air: `PasteRecord::syntax` is a +/// `Light::SqlAnsiString<32>` (`pastebin/db/paste_entity.hpp`), and +/// Lightweight's `SqlFixedString` constructor is +/// `_size{std::min(N, s.size())}` with **no throw and no diagnostic** — a +/// 33-byte label is silently cut to 32 on the way into the row, and the +/// client is told the create succeeded. Two concrete harms follow, which is +/// why this is validated rather than tolerated: +/// +/// 1. **Silent data loss.** `GetPaste` returns the truncated label, so the +/// round trip is lossy without anything reporting it. +/// 2. **Ill-formed UTF-8.** The cut is at a byte offset, not a codepoint +/// boundary, so a multi-byte label can be severed mid-sequence — putting +/// invalid UTF-8 into the `TEXT` column *and* into the JSON text frame +/// that carries the resulting `PasteView` back to the client. That is the +/// same class of wire-level hostile-content bug this rung already found +/// and fixed in the action/result codec (commit `f2ad662`, +/// `morph::model::detail::EscapingWriteOpts`), arriving by a different +/// door. +/// +/// The bound is the column width **exactly**, with no safety margin +/// deliberately: any margin would be an arbitrary second number to keep in +/// sync, and the invariant that matters is simply "everything accepted is +/// stored whole". `src/models/paste_model.cpp` carries a `static_assert` +/// tying this constant to the entity's real capacity, so widening the column +/// without widening this (or vice versa) fails the build rather than +/// silently reopening the gap. +/// +/// `content` needs no equivalent bound: it is a `Light::Field`, +/// a variable-length column with no fixed capacity to overflow. The +/// server's own message-size limit is what bounds it, and this rung already +/// tests that path ("An oversized CreatePaste is refused by the transport +/// with a typed, readable error"). +inline constexpr std::size_t kMaxSyntaxBytes = 32; + +struct CreatePaste { + std::string content; + std::string syntax; // free-form label, e.g. "plaintext", "cpp" + ::morph::time::Timestamp expiresAt; // empty = never expires + Reads burnAfterReads; // empty = no burn limit + Visibility visibility = Visibility::Public; + Editability editability = Editability::Immutable; + + /// @brief Members `schemaJson()` must leave out of the derived + /// `required` array (`morph::forms`' `optionalFields` convention — + /// see `include/morph/forms/forms.hpp`). + /// + /// `schemaJson()` marks *every* reflected member required unless it is a + /// `std::optional` or is named here, and the schema-driven create form + /// (`gui/qml/Main.qml`) gates submission on exactly that array. Without + /// this list no paste could be created without both an expiry instant and + /// a burn budget — contradicting the two members' own documented "empty = + /// never expires" / "empty = no burn limit" semantics above — and the two + /// enums, which already carry defaults here, would have to be typed out by + /// hand on every create. Discovered by this rung's first schema-driven + /// consumer (the desktop GUI shell), not by the model tests, which + /// construct `CreatePaste` in C++ and never see the schema. + static constexpr std::array optionalFields{"expiresAt", "burnAfterReads", "visibility", + "editability"}; + + [[nodiscard]] bool validate() const noexcept { + if (content.empty() || syntax.empty() || syntax.size() > kMaxSyntaxBytes) { + return false; + } + // Reads' own doc comment (units.hpp) puts the whole-number constraint + // on this DTO to enforce, not on the type. A budget of 0 (or + // negative) is the same problem in a different guise: it is a whole + // number, but PasteModel::execute(GetPaste)'s burn check + // (`readCount >= *burnAfterReads`) is already true before the first + // read ever happens, so the paste is born unreadable — accepted by + // `validate()`, then permanently `Burned` on the very first `GetPaste`. + if (burnAfterReads.hasValue() && + (burnAfterReads.value()->isZero() || burnAfterReads.value()->isNegative())) { + return false; + } + return true; + } +}; + +struct CreatePasteResult { + PasteId id; +}; + +struct GetPaste { + PasteId id; + + [[nodiscard]] bool validate() const noexcept { return id.hasValue(); } +}; + +struct PasteView { + PasteId id; + std::string content; + std::string syntax; + ::morph::time::Timestamp createdAt; + ::morph::time::Timestamp expiresAt; + Reads burnAfterReads; + Reads readCount; + Visibility visibility = Visibility::Public; + Editability editability = Editability::Immutable; +}; + +struct EditPaste { + PasteId id; + std::string content; + std::string syntax; + + [[nodiscard]] bool validate() const noexcept { + return id.hasValue() && !content.empty() && !syntax.empty() && syntax.size() <= kMaxSyntaxBytes; + } +}; + +struct DeletePaste { + PasteId id; + + [[nodiscard]] bool validate() const noexcept { return id.hasValue(); } +}; + +/// @brief One row of `ListPastes`' result — deliberately narrower than +/// `PasteView`: a listing must not leak full paste content. +struct PasteSummary { + PasteId id; + std::string syntax; + ::morph::time::Timestamp createdAt; + Visibility visibility = Visibility::Public; +}; + +struct ListPastes { + PasteCursor cursor; // empty = first page +}; + +struct ListPastesResult { + std::vector pastes; + PasteCursor nextCursor; // empty = no further page +}; + +/// @brief Internal-only: dispatched exclusively by the app-layer expiry +/// sweep's internal client (Task 6), never by a GUI client. Payload +/// is just the id — never `now()` — so replaying this entry is +/// trivially deterministic (README "How does expiry replay?"). +struct ExpirePaste { + PasteId id; + + [[nodiscard]] bool validate() const noexcept { return id.hasValue(); } +}; + +} // namespace pastebin + +/// @brief Reflects `Visibility` as the strings `"Public"`/`"Private"` rather +/// than its underlying `0`/`1`. +/// +/// Same rationale (and same `glz::enumerate` shape) as +/// `glz::meta`: a journal line, a wire envelope, and +/// the JSON body a schema-driven form assembles all stay readable and +/// hand-writable without cross-referencing the enum. Without a `glz::meta` +/// glaze emits the bare ordinal *and* the schema writer degrades the field's +/// `$defs` entry to the any-type union `{"type":["number","string",...]}`, +/// which tells a renderer nothing at all. Persistence is unaffected: the +/// `pastes` table stores visibility as the boolean `is_private` column +/// (`src/models/paste_model.cpp`), never as this JSON form. +template <> +struct glz::meta { + using enum pastebin::Visibility; + static constexpr auto value = glz::enumerate(Public, Private); +}; + +/// @brief Reflects `Editability` as the strings `"Immutable"`/`"Editable"` — +/// see `glz::meta` for the full rationale. +template <> +struct glz::meta { + using enum pastebin::Editability; + static constexpr auto value = glz::enumerate(Immutable, Editable); +}; diff --git a/examples/pastebin/include/pastebin/models/paste_model.hpp b/examples/pastebin/include/pastebin/models/paste_model.hpp new file mode 100644 index 00000000..7e09bbc3 --- /dev/null +++ b/examples/pastebin/include/pastebin/models/paste_model.hpp @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#include "pastebin/core/errors.hpp" +#include "pastebin/dto/paste_dto.hpp" + +/// @file +/// The one model this rung ships. `examples/IMPLEMENTATION.md` rule 1 — +/// models *are* the application: every pastebin business rule (id allocation, +/// expiry, burn-after-read, editability, listing/pagination) lives here and +/// nowhere else. The app bootstrap, presenters, and GUI carry no domain logic. + +namespace pastebin { + +/// @brief Create/read/edit/delete/list/expire over the `pastes` table. +/// +/// Registered **plain** — no `BRIDGE_MODEL_KEY`, no `AllowShared` (the +/// README's resolved burn-atomicity decision): every action dispatch gets a +/// fresh instance and all real state lives in `pastes`. This model 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. Burn-after-read atomicity therefore comes from SQL, not from a +/// shared C++ instance — see `execute(const GetPaste&)` in +/// `src/models/paste_model.cpp` for the exact mechanism and why it is safe +/// against two clients racing on the last allowed read. +class PasteModel { +public: + /// @brief Stores a new paste under a freshly allocated animal-name id. + /// @param action The paste to store. + /// @return The allocated id. + /// @throws ValidationError if the action fails `validate()`, or if no free + /// id could be allocated within the bounded retry budget. + CreatePasteResult execute(const CreatePaste& action); + + /// @brief Consumes one read of a paste and returns it. + /// @param action The paste to read. + /// @return The paste, with its post-read `readCount`. + /// @throws ValidationError if the action fails `validate()`. + /// @throws NotFound if no such paste exists (or it was burned away). + /// @throws Expired if the paste's `expiresAt` has passed. + /// @throws Burned if the paste's burn-after-reads budget was already spent. + PasteView execute(const GetPaste& action); + + /// @brief Replaces an editable paste's content and syntax. + /// @param action The edit to apply. + /// @return The paste as it now stands. + /// @throws ValidationError if the action fails `validate()` or the paste is + /// immutable. + /// @throws NotFound if no such paste exists. + PasteView execute(const EditPaste& action); + + /// @brief Deletes a paste, whether or not it exists. + /// @param action The paste to delete. + /// @return An acknowledgement. + /// @throws ValidationError if the action fails `validate()`. + Ack execute(const DeletePaste& action); + + /// @brief Returns one page of public pastes, newest id first. + /// @param action The page request (empty cursor = first page). + /// @return The page, plus the cursor for the next one (empty when exhausted). + ListPastesResult execute(const ListPastes& action); + + /// @brief Reclaims one paste whose `expiresAt` has passed. + /// + /// Dispatched only by the app-layer expiry sweep's internal client + /// (Task 6) — never by a GUI client. Deliberately a no-op (still `Ack`) + /// when the paste is absent or not actually expired yet, so a replayed or + /// late-arriving sweep entry can never destroy a live paste. + /// @param action The paste to reclaim. + /// @return An acknowledgement. + /// @throws ValidationError if the action fails `validate()`. + Ack execute(const ExpirePaste& action); +}; + +} // namespace pastebin + +BRIDGE_REGISTER_MODEL(pastebin::PasteModel, "PasteModel") +BRIDGE_REGISTER_ACTION(pastebin::PasteModel, pastebin::CreatePaste, "CreatePaste") +// GetPaste stays the one client-visible, journaled *mutation* (default +// Loggable::Yes) — the README's resolved journal decision; it is deliberately +// not split into an unlogged read plus a RecordRead, and must not opt out. +BRIDGE_REGISTER_ACTION(pastebin::PasteModel, pastebin::GetPaste, "GetPaste") +BRIDGE_REGISTER_ACTION(pastebin::PasteModel, pastebin::EditPaste, "EditPaste") +BRIDGE_REGISTER_ACTION(pastebin::PasteModel, pastebin::DeletePaste, "DeletePaste") +BRIDGE_REGISTER_ACTION(pastebin::PasteModel, pastebin::ListPastes, "ListPastes", ::morph::model::Loggable::No) +BRIDGE_REGISTER_ACTION(pastebin::PasteModel, pastebin::ExpirePaste, "ExpirePaste") diff --git a/examples/pastebin/include/pastebin/units.hpp b/examples/pastebin/include/pastebin/units.hpp new file mode 100644 index 00000000..6f37dd4a --- /dev/null +++ b/examples/pastebin/include/pastebin/units.hpp @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +/// @file +/// Pastebin's one-unit system: a dimensionless read count. Modeled on +/// examples/forms/lab_units.hpp's shape — see that file for the full +/// UnitTraits/consteval-algebra contract this mirrors. Rung 1 needs no unit +/// algebra (no products/quotients, no within-dimension conversions), so this +/// file skips `operator*`/`operator/` and `UnitTraits::relations` — both are +/// optional per `morph::units::UnitEnum`/`HasUnitRelations` and only apply +/// once a second unit exists to combine or convert with. + +namespace pastebin { + +/// @brief Units pastebin works in. +enum class Unit { + count, ///< dimensionless read count +}; + +} // namespace pastebin + +/// @brief Static unit metadata: schema id, display text, default decimals. +template <> +struct morph::units::UnitTraits { + static constexpr morph::units::UnitMeta meta(pastebin::Unit unit) noexcept { + switch (unit) { + case pastebin::Unit::count: + return {"count", "", 1}; + default: + return {"?", "?", 1}; + } + } +}; + +namespace pastebin { + +/// @brief A whole-number read count (burn-after-N-reads, read_count). +/// +/// `morph::units::Quantity` requires `DeclaredDecimals +/// >= 1` (zero is not legal), so this alias declares `1` even though every +/// value that ever appears is a whole number by construction — the DTOs that +/// use `Reads` (Task 3) enforce the whole-number constraint explicitly in +/// their `validate()`; the type alone cannot. +using Reads = ::morph::units::Quantity; + +} // namespace pastebin diff --git a/examples/pastebin/src/app/app.cpp b/examples/pastebin/src/app/app.cpp new file mode 100644 index 00000000..be869bb0 --- /dev/null +++ b/examples/pastebin/src/app/app.cpp @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "pastebin/app/app.hpp" + +// examples/common is on every ladder target's include path as a root (see +// examples/common/CMakeLists.txt's target_include_directories), so the +// ladder clock is "clock.hpp" -- the same spelling paste_model.cpp and +// testkit/test_clock.cpp use. +#include "clock.hpp" +#include "pastebin/dto/paste_dto.hpp" +#include "pastebin/models/paste_model.hpp" + +#include + +#include + +#include +#include + +namespace pastebin::app { + +namespace { + +/// @brief The current instant, in epoch milliseconds. Mirrors +/// `paste_model.cpp`'s private `nowMs()` helper exactly (same +/// `morph::ladder::now().value` dereference this session's earlier +/// research confirmed against `examples/common/testkit/test_clock.cpp` +/// and `paste_model.cpp`'s own usage) -- duplicated rather than +/// shared because that helper is `paste_model.cpp`'s own anonymous- +/// namespace implementation detail, not part of any public header. +[[nodiscard]] std::int64_t nowMs() noexcept { + return (*::morph::ladder::now().value).value.time_since_epoch().count(); +} + +} // namespace + +App::App(std::filesystem::path actionLogPath, std::chrono::milliseconds sweepInterval, std::size_t workers, + QObject* parent) + // Initialiser order follows the declaration order in app.hpp, which is + // itself chosen for teardown safety — see that header's comment. + : QObject{parent}, + _actionLog{std::make_shared<::morph::journal::FileActionLog>(std::move(actionLogPath))}, + _pool{workers}, + _server{std::make_shared<::morph::backend::RemoteServer>(_pool)}, + _sweepBridge{std::make_unique<::morph::backend::SimulatedRemoteBackend>(*_server)} { + ::morph::journal::setActionLog(_actionLog); + connect(&_sweepTimer, &QTimer::timeout, this, &App::sweepExpiredOnce); + _sweepTimer.start(sweepInterval); +} + +App::~App() { + // Stop first: a tick landing while the members below are being torn down + // would dispatch a sweep into a half-destroyed App. + _sweepTimer.stop(); + ::morph::journal::setActionLog(nullptr); +} + +void App::sweepExpiredOnce() { + std::vector expiredIds; + { + ::Lightweight::SqlStatement stmt; + stmt.Prepare("SELECT id FROM pastes WHERE expires_at_ms IS NOT NULL AND expires_at_ms <= ?"); + auto cursor = stmt.Execute(nowMs()); + while (cursor.FetchRow()) { + expiredIds.push_back(cursor.GetColumn(1)); + } + } + if (expiredIds.empty()) { + return; + } + + // `handler` is kept alive by every dispatched call's own completion, not + // by this function's stack frame. `BridgeHandler::execute()` posts to the + // worker pool (`SimulatedRemoteBackend::execute()` -> `RemoteServer::handle()` + // -> `_pool.post(...)`) and returns immediately, so this loop -- and this + // function -- routinely returns before RemoteServer has so much as looked + // up the model instance for the *first* dispatched ExpirePaste, let alone + // run it. A `handler` destroyed synchronously right here (e.g. as a plain + // local, going out of scope at the end of this function) would deregister + // its model instance -- via a synchronous `RemoteServer::handleInline` + // "deregister" call in `~BridgeHandler` -- and race those still-pending + // dispatches: `RemoteServer::dispatchExecute` would then find the + // (already-erased) instance missing and reply "model not found" instead of + // ever running `PasteModel::execute(ExpirePaste)`, silently dropping that + // sweep pass's reclaim. `RemoteServer`'s own "safe to deregister while an + // execute is in flight" guarantee (docs/spec/concurrency_and_lifetimes.md) + // protects an execute already admitted to the model's strand -- not one + // still sitting in the worker pool's queue, which is exactly the state + // every one of this loop's dispatches is in immediately after `execute()` + // returns. Nothing is corrupted or leaked either way -- a dropped pass + // just means the paste stays expired-but-unreclaimed until the next timer + // tick tries again (`PasteModel::execute(GetPaste)` already excludes an + // expired row on its own) -- but every dropped pass is a spurious "expiry + // sweep: ExpirePaste failed" log line and a wasted round trip. Capturing + // `handler` in every completion below closes the window: the handler -- + // and the model instance it registered -- is deregistered only once every + // dispatch issued by this pass has actually settled, whichever of + // `.then()`/`.onError()` that turns out to be for each one. + auto handler = std::make_shared<::morph::bridge::BridgeHandler>(_sweepBridge, &_sweepExecutor); + // `inFlight` is captured by value, never through `this`: the callbacks + // below can outlive this App (see sweepInFlight()'s doc comment), and a + // late one must still be able to decrement the counter safely. + auto inFlight = _sweepInFlight; + for (const auto& id : expiredIds) { + inFlight->fetch_add(1); + handler->execute(ExpirePaste{.id = PasteId{id}}) + .then([handler, inFlight](Ack) { inFlight->fetch_sub(1); }) + .onError([handler, inFlight, id](const std::exception_ptr&) { + inFlight->fetch_sub(1); + ::morph::log::logError("[pastebin::App] expiry sweep: ExpirePaste failed for " + id); + }); + } +} + +} // namespace pastebin::app diff --git a/examples/pastebin/src/db/schema.cpp b/examples/pastebin/src/db/schema.cpp new file mode 100644 index 00000000..b906400b --- /dev/null +++ b/examples/pastebin/src/db/schema.cpp @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "pastebin/db/database.hpp" + +#include +#include +#include + +namespace pastebin::db { + +void setup(const std::string& connectionString) { + Lightweight::SqlConnection::SetDefaultConnectionString(Lightweight::SqlConnectionString{connectionString}); + Lightweight::SqlMigration::MigrationManager::GetInstance().CreateMigrationHistory(); + Lightweight::SqlMigration::MigrationManager::GetInstance().ApplyPendingMigrations(); +} + +} // namespace pastebin::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. +// +// `.PrimaryKey("id", Varchar(32))` (as opposed to `.PrimaryKeyWithAutoIncrement`) +// is the manual/caller-assigned primary key column — confirmed against +// `Lightweight/SqlQuery/Migrate.hpp`'s `SqlCreateTableQueryBuilder::PrimaryKey` +// overload, which is exactly what a `Field<..., Light::PrimaryKey::AutoAssign, ...>` +// member (see `paste_entity.hpp`) needs. + +using namespace Lightweight::SqlColumnTypeDefinitions; + +LIGHTWEIGHT_SQL_MIGRATION(20260806000001, "Create pastes table") { + plan.CreateTableIfNotExists("pastes") + .PrimaryKey("id", Varchar(32)) + .RequiredColumn("content", NVarchar(0)) + .RequiredColumn("syntax", Varchar(32)) + .RequiredColumn("created_at_ms", Bigint()) + .Column("expires_at_ms", Bigint()) + .Column("burn_after_reads", Bigint()) + .RequiredColumn("read_count", Bigint()) + .RequiredColumn("is_private", Bool()) + .RequiredColumn("is_editable", Bool()); +} diff --git a/examples/pastebin/src/models/paste_model.cpp b/examples/pastebin/src/models/paste_model.cpp new file mode 100644 index 00000000..dc310032 --- /dev/null +++ b/examples/pastebin/src/models/paste_model.cpp @@ -0,0 +1,486 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "pastebin/models/paste_model.hpp" + +// The entity is an implementation detail of this TU: `paste_model.hpp` exposes +// only DTOs, so nothing outside this file ever sees `db::PasteRecord`. +#include "pastebin/db/paste_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 +#include +#include +#include + +namespace pastebin { + +// The one place the DTO layer's `syntax` bound and the storage layer's real +// column capacity are checked against each other. `kMaxSyntaxBytes` exists so +// `CreatePaste::validate()`/`EditPaste::validate()` can reject an over-long +// label instead of letting `SqlFixedString`'s `_size{std::min(N, s.size())}` +// truncate it silently (see that constant's own doc comment for the two harms +// that follow); this assertion is what keeps the number honest. Widening the +// column without widening the constant — or the reverse — fails the build +// here rather than silently reopening the gap in production. +static_assert(decltype(db::PasteRecord::syntax)::ValueType{}.capacity() == kMaxSyntaxBytes, + "pastebin::kMaxSyntaxBytes must equal PasteRecord::syntax's SqlAnsiString capacity — otherwise " + "CreatePaste/EditPaste either reject labels that would have fit, or accept ones that get " + "silently truncated on the way into the row."); + +namespace { + +// --------------------------------------------------------------------------- +// DTO <-> entity conversions (IMPLEMENTATION.md rule 4's DTO<->entity mapping +// layer). Both directions are exact: an instant is a whole number of +// milliseconds, and every `Reads` value that ever reaches the database is a +// whole-number count, so the conversions go through `std::int64_t` and an +// exact `math::Rational` rather than through `double`. `Reads::fromDouble` / +// `math::Rational::toDouble` do exist and would work for the magnitudes +// involved, but they round-trip through binary floating point for values that +// are integers by construction — there is nothing to gain and a rounding step +// to lose. +// --------------------------------------------------------------------------- + +[[nodiscard]] std::int64_t toEpochMs(const ::morph::time::DateTime& instant) noexcept { + return instant.value.time_since_epoch().count(); +} + +[[nodiscard]] std::int64_t nowMs() noexcept { + return toEpochMs(*::morph::ladder::now().value); +} + +[[nodiscard]] ::morph::time::Timestamp fromEpochMs(const std::optional& epochMs) noexcept { + if (!epochMs) { + return ::morph::time::Timestamp{}; + } + return ::morph::time::Timestamp{::morph::time::DateTime{ + std::chrono::sys_time{std::chrono::milliseconds{*epochMs}}}}; +} + +/// @brief An exact whole-number read count as a `Reads` quantity. +[[nodiscard]] Reads readsOf(std::int64_t count) { + return Reads{::morph::math::Rational{count, Reads::declaredPrecision()}}; +} + +/// @brief An engaged `Reads` back as a whole-number count. +/// +/// `math::floor` is exact on a `Rational` (integer division on the stored +/// numerator/denominator) — no floating-point step. `Reads` only ever carries +/// whole numbers here, so flooring and truncating agree. +[[nodiscard]] std::int64_t countOf(const Reads& reads) noexcept { + return ::morph::math::floor(*reads); +} + +[[nodiscard]] std::string textOf(const Light::SqlAnsiString<32>& stored) { + return std::string{stored.str()}; +} + +// `content` is stored wide (Light::SqlMaxDynamicWideString — see +// paste_entity.hpp's file comment for why); the DTO layer stays UTF-8 +// std::string per IMPLEMENTATION.md rule 4, so every read/write of `content` +// converts here, at the model boundary, rather than leaking the storage +// representation into the DTO or the caller. +[[nodiscard]] std::string utf8Of(const Light::SqlMaxDynamicWideString& stored) { + return std::string{reinterpret_cast(Lightweight::ToUtf8(stored.ToStringView()).c_str())}; +} + +[[nodiscard]] Light::SqlMaxDynamicWideString wideOf(const std::string& utf8) { + return Light::SqlMaxDynamicWideString{ + Lightweight::ToStdWideString(std::u8string_view{reinterpret_cast(utf8.data()), utf8.size()})}; +} + +/// @brief Builds the read-only view sent back to a client from a fully loaded +/// `PasteRecord`. +[[nodiscard]] PasteView toView(const db::PasteRecord& rec) { + PasteView view; + view.id = PasteId{textOf(rec.id.Value())}; + view.content = utf8Of(rec.content.Value()); + view.syntax = textOf(rec.syntax.Value()); + view.createdAt = fromEpochMs(rec.createdAtMs.Value()); + view.expiresAt = fromEpochMs(rec.expiresAtMs.Value()); + view.burnAfterReads = rec.burnAfterReads.Value() ? readsOf(*rec.burnAfterReads.Value()) : Reads{}; + view.readCount = readsOf(rec.readCount.Value()); + view.visibility = rec.isPrivate.Value() ? Visibility::Private : Visibility::Public; + view.editability = rec.isEditable.Value() ? Editability::Editable : Editability::Immutable; + return view; +} + +/// @brief The tiny animal-name id keyspace (MicroBin-style). Deliberately +/// small — the required tests exercise the id-collision retry path, +/// which needs collisions to be reachable in a bounded number of +/// `CreatePaste` calls, not astronomically unlikely. +constexpr std::array kAnimals = { + "cat", "dog", "fox", "owl", "bee", "ant", "elk", "ram", + "yak", "cod", "eel", "hen", "pig", "cow", "bat", "jay", +}; +constexpr std::array kAdjectives = { + "red", "blue", "gold", "dark", "swift", "calm", "bold", "wild", + "keen", "grey", "warm", "cool", "sharp", "quiet", "loud", "soft", +}; + +[[nodiscard]] std::string randomPasteId() { + static thread_local std::mt19937_64 rng{std::random_device{}()}; + std::uniform_int_distribution adjIdx{0, kAdjectives.size() - 1}; + std::uniform_int_distribution animalIdx{0, kAnimals.size() - 1}; + std::uniform_int_distribution suffix{0, 999}; + return std::string{kAdjectives[adjIdx(rng)]} + "-" + std::string{kAnimals[animalIdx(rng)]} + "-" + + std::to_string(suffix(rng)); +} + +/// @brief Bounded retry budget for allocating a free animal-name id. +constexpr int kMaxIdAttempts = 8; + +/// @brief `ListPastes` page size (rows per page, excluding the has-more probe). +constexpr std::size_t kPageSize = 20; + +/// @brief The one conditional statement burn-after-read atomicity rests on. +/// +/// Every guard a read must respect lives in this single `WHERE`: the row must +/// exist, must not have expired, and must still have burn budget left. The +/// increment and the guard are therefore evaluated by the database in one +/// statement — no read-then-write window exists for a second client to slip +/// through. See `PasteModel::execute(const GetPaste&)` for the full argument. +/// +/// **Not** `... RETURNING`: the sqliteodbc driver this rung runs against +/// reports the RETURNING column count but then fails `SQLFetch` with SQLSTATE +/// 24000 ("Invalid cursor state") — filed upstream as +/// `LASTRADA-Software/Lightweight#545`. The row is read back by a second +/// statement inside the same transaction instead; the atomicity argument is +/// unchanged because the guard still lives in the `UPDATE` itself. +constexpr std::string_view kConsumeReadSql = R"(UPDATE pastes + SET read_count = read_count + 1 + WHERE id = ? + AND (expires_at_ms IS NULL OR expires_at_ms > ?) + AND (burn_after_reads IS NULL OR read_count < burn_after_reads))"; + +/// @brief `EditPaste`'s compare-and-swap guard: the write only applies if the +/// row's content/syntax still equal what this client last read. Same +/// shape and same argument as `kConsumeReadSql` above — the guard and +/// the write are one indivisible statement, so there is no +/// read-then-write window a second concurrent edit can land in. See +/// `PasteModel::execute(const EditPaste&)` for the full argument. +constexpr std::string_view kEditPasteSql = R"(UPDATE pastes + SET content = ?, syntax = ? + WHERE id = ? + AND is_editable = 1 + AND content = ? + AND syntax = ?)"; + +} // namespace + +CreatePasteResult PasteModel::execute(const CreatePaste& action) { + if (!action.validate()) { + throw ValidationError{std::format("CreatePaste: content and syntax are required, syntax must be at most {} " + "bytes, and burnAfterReads (if given) must be a positive count", + kMaxSyntaxBytes)}; + } + + // One connection for this call, acquired from the pool and returned when + // it goes out of scope at the end of this function — not a member this + // model instance holds for its own lifetime (see paste_model.hpp's file + // comment for why the model must not own database state). + auto mapper = ::Lightweight::GlobalDataMapperPool().Acquire(); + + // Bounded retry on the (small, deliberately-collidable) animal-name + // keyspace. The insert itself is the collision test — a pre-check would be + // a time-of-check/time-of-use window between two model instances on two + // connections; the primary key is the only authority. + for (int attempt = 0; attempt < kMaxIdAttempts; ++attempt) { + db::PasteRecord rec; + rec.id = Light::SqlAnsiString<32>{randomPasteId()}; + rec.content = wideOf(action.content); + rec.syntax = Light::SqlAnsiString<32>{action.syntax}; + rec.createdAtMs = nowMs(); + rec.expiresAtMs = action.expiresAt.hasValue() ? std::optional{toEpochMs(*action.expiresAt)} : std::nullopt; + rec.burnAfterReads = + action.burnAfterReads.hasValue() ? std::optional{countOf(action.burnAfterReads)} : std::nullopt; + rec.readCount = std::int64_t{0}; + rec.isPrivate = action.visibility == Visibility::Private; + rec.isEditable = action.editability == Editability::Editable; + + try { + mapper->Create(rec); + } catch (const ::Lightweight::SqlException& error) { + // Only a primary-key collision on the animal-name id is retryable. + // Every other store error (a lock, a dropped connection, a broken + // schema) must reach the client as itself — swallowing it here + // would mis-report an outage as "keyspace exhausted", and the + // required store-error branch tests distinguish the two. + // sqliteodbc reports both under SQLSTATE HY000, so the message-based + // classifier Lightweight ships is the only discriminator available. + if (!::Lightweight::IsUniqueConstraintViolation(error.info(), mapper->Connection().ServerType())) { + throw; + } + continue; + } + return CreatePasteResult{.id = PasteId{textOf(rec.id.Value())}}; + } + throw ValidationError{"CreatePaste: could not allocate a unique paste id"}; +} + +PasteView PasteModel::execute(const GetPaste& action) { + if (!action.validate()) { + throw ValidationError{"GetPaste: id is required"}; + } + const std::string& id = *action.id; + const std::int64_t readAtMs = nowMs(); + + // One connection for this whole call — the transaction below and the + // fallback classification read after it must run on the same connection. + auto mapper = ::Lightweight::GlobalDataMapperPool().Acquire(); + + // ── The atomic read-consumption ───────────────────────────────────────── + // The conditional UPDATE is the whole race-safety argument: SQLite + // evaluates its WHERE and applies its increment as one indivisible + // statement under a write lock, so of two clients racing for the last + // allowed read of a burn-after-N paste exactly one gets a non-zero + // affected-row count. The loser's UPDATE finds `read_count < burn_after_reads` + // already false and touches nothing. + // + // The transaction exists for the *read-back*, not for the guard: it holds + // the write lock the UPDATE took until the SELECT has seen the row the + // UPDATE produced, so no other connection can delete or re-read it in + // between. It also makes the burn-delete below part of the same commit. + std::optional view; + { + ::Lightweight::SqlTransaction transaction{mapper->Connection(), + ::Lightweight::SqlTransactionMode::ROLLBACK}; + + std::size_t consumed = 0; + { + ::Lightweight::SqlStatement consume{mapper->Connection()}; + consume.Prepare(kConsumeReadSql); + auto cursor = consume.Execute(id, readAtMs); + consumed = cursor.NumRowsAffected(); + } + + // `== 1`, not `!= 0`: `id` is the primary key, so the UPDATE's + // `WHERE id = ?` can affect at most one row — 1 is the only possible + // non-zero outcome. Testing for it exactly also closes the one + // theoretical hole in this gate: `NumRowsAffected()` casts ODBC's + // signed `SQLLEN` to `size_t` unguarded, and `SQLRowCount` may report + // -1 when the count is unavailable, which would arrive here as + // SIZE_MAX — non-zero, and so would disclose content without a read + // having actually been consumed. This one comparison is the sole gate + // on the burn-atomicity guarantee; it must not admit a sentinel. + if (consumed == 1) { + auto rows = mapper + ->Query() + .Where(::Lightweight::FieldNameOf<&db::PasteRecord::id>, "=", id) + .All(); + if (rows.empty()) { + // Unreachable in practice: the UPDATE just matched this row and + // holds the write lock. Treated as "gone" rather than asserted. + throw NotFound{"GetPaste: no such paste"}; + } + const db::PasteRecord& rec = rows.front(); + view = toView(rec); + + // Burn-after-read destroys the paste *on* the Nth read, not before: + // the read that just consumed the last unit of budget still returns + // its content, and only then removes the row. + const std::optional& budget = rec.burnAfterReads.Value(); + if (budget && rec.readCount.Value() >= *budget) { + ::Lightweight::SqlStatement burn{mapper->Connection()}; + burn.Prepare("DELETE FROM pastes WHERE id = ?"); + (void) burn.Execute(id); + } + transaction.Commit(); + } + } + if (view) { + return *view; + } + + // ── Zero rows matched: classify why ───────────────────────────────────── + // A plain, unprotected read. This does not reopen the window the atomic + // UPDATE closed: it decides only *which* error to throw and mutates + // nothing. A row that changes underneath it can at worst turn one + // truthful-a-moment-ago error into another. + auto existing = mapper + ->Query() + .Where(::Lightweight::FieldNameOf<&db::PasteRecord::id>, "=", id) + .All(); + if (existing.empty()) { + throw NotFound{"GetPaste: no such paste"}; + } + const db::PasteRecord& row = existing.front(); + if (row.expiresAtMs.Value() && *row.expiresAtMs.Value() <= readAtMs) { + throw Expired{"GetPaste: paste has expired"}; + } + if (row.burnAfterReads.Value() && row.readCount.Value() >= *row.burnAfterReads.Value()) { + throw Burned{"GetPaste: paste's burn-after-reads budget is exhausted"}; + } + throw NotFound{"GetPaste: no such paste"}; +} + +PasteView PasteModel::execute(const EditPaste& action) { + if (!action.validate()) { + throw ValidationError{std::format("EditPaste: id, content, and syntax are required, and syntax must be at " + "most {} bytes", + kMaxSyntaxBytes)}; + } + const std::string& id = *action.id; + + // One connection for this whole call — the CAS transaction below and the + // reads before/after it must run on the same connection. + auto mapper = ::Lightweight::GlobalDataMapperPool().Acquire(); + + // A first, unprotected read: it decides the common-case NotFound / + // not-editable errors, and supplies the compare-and-swap guard's expected + // "before" values for the atomic write below. A stale read here does not + // reopen a race — it just means the guarded UPDATE below affects 0 rows, + // which is classified as `Conflict`, never silently applied. + auto before = mapper + ->Query() + .Where(::Lightweight::FieldNameOf<&db::PasteRecord::id>, "=", id) + .All(); + if (before.empty()) { + throw NotFound{"EditPaste: no such paste"}; + } + if (!before.front().isEditable.Value()) { + throw ValidationError{"EditPaste: paste is not editable"}; + } + const Light::SqlMaxDynamicWideString previousContent = before.front().content.Value(); + const std::string previousSyntax = textOf(before.front().syntax.Value()); + + // ── The atomic compare-and-swap write ─────────────────────────────────── + // Same structure as `PasteModel::execute(const GetPaste&)`'s burn + // consumption: the guard (content/syntax still equal what was just read) + // and the write are one indivisible statement, so a second concurrent + // `EditPaste` racing against this one cannot land in a read-then-write + // window — it either wins the CAS or is told `Conflict`, never silently + // discarded. + std::optional view; + { + ::Lightweight::SqlTransaction transaction{mapper->Connection(), + ::Lightweight::SqlTransactionMode::ROLLBACK}; + + std::size_t consumed = 0; + { + ::Lightweight::SqlStatement stmt{mapper->Connection()}; + stmt.Prepare(kEditPasteSql); + auto cursor = stmt.Execute(wideOf(action.content), action.syntax, id, previousContent, previousSyntax); + consumed = cursor.NumRowsAffected(); + } + + // `== 1`, not `!= 0` — same rationale as GetPaste's burn-consumption + // gate: `id` is the primary key, so at most one row can ever match, + // and testing for exactly 1 closes the `NumRowsAffected()` + // signed-to-unsigned `-1` -> `SIZE_MAX` hole. + if (consumed == 1) { + auto rows = mapper + ->Query() + .Where(::Lightweight::FieldNameOf<&db::PasteRecord::id>, "=", id) + .All(); + if (rows.empty()) { + // Unreachable in practice: the UPDATE just matched this row + // and holds the write lock. Treated as "gone" rather than + // asserted, matching GetPaste's equivalent branch. + throw NotFound{"EditPaste: no such paste"}; + } + view = toView(rows.front()); + transaction.Commit(); + } + } + if (view) { + return *view; + } + + // ── Zero rows matched: classify why ───────────────────────────────────── + auto existing = mapper + ->Query() + .Where(::Lightweight::FieldNameOf<&db::PasteRecord::id>, "=", id) + .All(); + if (existing.empty()) { + throw NotFound{"EditPaste: no such paste"}; + } + if (!existing.front().isEditable.Value()) { + throw ValidationError{"EditPaste: paste is not editable"}; + } + // Still exists, still editable, but the CAS guard didn't match: some + // other write landed between the read above and this one. + throw Conflict{"EditPaste: paste was modified by another edit since it was last read"}; +} + +Ack PasteModel::execute(const DeletePaste& action) { + if (!action.validate()) { + throw ValidationError{"DeletePaste: id is required"}; + } + auto mapper = ::Lightweight::GlobalDataMapperPool().Acquire(); + ::Lightweight::SqlStatement stmt{mapper->Connection()}; + stmt.Prepare("DELETE FROM pastes WHERE id = ?"); + (void) stmt.Execute(*action.id); + return Ack{}; +} + +ListPastesResult PasteModel::execute(const ListPastes& action) { + // One connection for this call: the query is built up across several + // statements below and must run against the same connection throughout. + auto mapper = ::Lightweight::GlobalDataMapperPool().Acquire(); + + // Keyset pagination on the primary key, descending: the cursor is the last + // id of the previous page, so a row created or reclaimed mid-walk can never + // shift a later page's offset (the required "sweep fires between two pages" + // test depends on exactly this). + auto query = mapper->Query(); + (void) query.Where(::Lightweight::FieldNameOf<&db::PasteRecord::isPrivate>, "=", false); + if (action.cursor.hasValue()) { + (void) query.Where(::Lightweight::FieldNameOf<&db::PasteRecord::id>, "<", *action.cursor); + } + // One row beyond the page is the has-more probe; it is never returned. + auto rows = query + .OrderBy(::Lightweight::FieldNameOf<&db::PasteRecord::id>, + ::Lightweight::SqlResultOrdering::DESCENDING) + .First(kPageSize + 1); + + const bool hasMore = rows.size() > kPageSize; + if (hasMore) { + rows.resize(kPageSize); + } + + ListPastesResult result; + result.pastes.reserve(rows.size()); + for (const db::PasteRecord& row : rows) { + result.pastes.push_back(PasteSummary{ + .id = PasteId{textOf(row.id.Value())}, + .syntax = textOf(row.syntax.Value()), + .createdAt = fromEpochMs(row.createdAtMs.Value()), + .visibility = row.isPrivate.Value() ? Visibility::Private : Visibility::Public, + }); + } + result.nextCursor = hasMore ? PasteCursor{textOf(rows.back().id.Value())} : PasteCursor{}; + return result; +} + +Ack PasteModel::execute(const ExpirePaste& action) { + if (!action.validate()) { + throw ValidationError{"ExpirePaste: id is required"}; + } + // The `expires_at_ms <= ?` guard is what makes this replay-safe: the action + // payload carries only the id, so re-running a journaled entry against a + // paste that is not (or no longer) expired deletes nothing. + auto mapper = ::Lightweight::GlobalDataMapperPool().Acquire(); + ::Lightweight::SqlStatement stmt{mapper->Connection()}; + stmt.Prepare("DELETE FROM pastes WHERE id = ? AND expires_at_ms IS NOT NULL AND expires_at_ms <= ?"); + (void) stmt.Execute(*action.id, nowMs()); + return Ack{}; +} + +} // namespace pastebin diff --git a/examples/pastebin/src/server/main.cpp b/examples/pastebin/src/server/main.cpp new file mode 100644 index 00000000..ee7a1145 --- /dev/null +++ b/examples/pastebin/src/server/main.cpp @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: Apache-2.0 + +/// @file +/// pastebin's standalone server process: `pastebin::db::setup()` once, one +/// `pastebin::app::App` (worker pool + `RemoteServer` + durable action log + +/// expiry sweep), and one `morph::qt::QtWebSocketServer` in front of it. The +/// desktop client (`examples/pastebin/gui/`) talks to this over +/// `ws://127.0.0.1:`; nothing here knows anything about pastes beyond +/// the `--seed` demo data below, which is deliberately a handful of literal +/// `CreatePaste` values (`LADDER.md`'s "every rung ships a `--seed` path"). +/// The generator machinery in `action_driver.hpp` is rung 4's deliverable +/// (`TESTING.md`'s component table) and is not pulled forward for it. +/// +/// Usage: +/// @code +/// PASTEBIN_DB=... PASTEBIN_PORT=8765 ladder_pastebin_server [--seed] +/// @endcode + +// examples/common is on every ladder target's include path as a root, so the +// ladder clock is "clock.hpp" — the same spelling paste_model.cpp and app.cpp +// use. Seeding reads the *same* injectable clock the model does, so a seeded +// expiry and the model's own expiry check can never disagree. +#include "clock.hpp" +#include "pastebin/app/app.hpp" +#include "pastebin/db/database.hpp" +#include "pastebin/dto/paste_dto.hpp" +#include "pastebin/models/paste_model.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. +volatile std::sig_atomic_t gStopRequested = 0; + +extern "C" void onStopSignal(int /*signum*/) { gStopRequested = 1; } + +/// @brief Pumps the Qt event loop until no sweep dispatch is outstanding. +/// +/// `pastebin::app::App::sweepInFlight()` is observe-only: `~App` does *not* +/// wait for the `ExpirePaste` calls a sweep dispatched to settle before +/// destroying the bridge they complete against, so a callback delivered after +/// `~App` is a use-after-free. The header states the contract — "pump on this +/// until it is `false`, then destroy" — and this is the production consumer +/// honouring it. Bounded by @p budget so a wedged dispatch cannot hang +/// shutdown forever; overrunning it is strictly better than the alternative of +/// not draining at all, and is reported. +/// +/// @param app The app whose sweep dispatches must settle. +/// @param budget Maximum time to wait. +/// @return `true` if everything settled within @p budget. +[[nodiscard]] bool drainSweeps(const pastebin::app::App& app, std::chrono::milliseconds budget) { + const auto deadline = std::chrono::steady_clock::now() + budget; + while (app.sweepInFlight()) { + if (std::chrono::steady_clock::now() >= deadline) { + return false; + } + QCoreApplication::processEvents(QEventLoop::AllEvents, 20); + } + return true; +} + +/// @brief Creates the demo corpus, in-process and synchronously. +/// +/// Calls `PasteModel::execute()` directly rather than going through a +/// `Bridge`/`BridgeHandler`: seeding happens before the listener starts, on +/// the Qt thread, with nothing to dispatch to and nobody to be concurrent +/// with. The model is the application (`IMPLEMENTATION.md` rule 1), so a +/// direct call runs exactly the same id allocation, clamping and persistence +/// a client-issued `CreatePaste` would — only the transport is skipped. +void seedDemoPastes() { + using namespace std::chrono_literals; + pastebin::PasteModel model; + + const auto create = [&model](pastebin::CreatePaste action, const char* what) { + try { + const auto result = model.execute(action); + std::cout << "pastebin-server: seeded " << what << " as " + << (result.id.hasValue() ? *result.id : std::string{""}) << '\n'; + } catch (const std::exception& e) { + std::cerr << "pastebin-server: failed to seed " << what << ": " << e.what() << '\n'; + } + }; + + create({.content = "Hello from the morph application ladder, rung 1.", .syntax = "plaintext"}, + "a plain public paste"); + create({.content = "int main() { return 0; }", .syntax = "cpp", .editability = pastebin::Editability::Editable}, + "an editable C++ snippet"); + create({.content = "SELECT id, syntax FROM pastes ORDER BY created_at_ms DESC;", .syntax = "sql"}, + "a SQL snippet"); + create({.content = "This paste is private; it never shows up in ListPastes.", + .syntax = "plaintext", + .visibility = pastebin::Visibility::Private}, + "a private paste"); + create({.content = "One read and this is gone. Open it twice to see the burn.", + .syntax = "plaintext", + .burnAfterReads = pastebin::Reads::fromDouble(1.0)}, + "a burn-after-1 paste"); + create({.content = "This one expires two minutes after the server started.", + .syntax = "plaintext", + .expiresAt = ::morph::time::Timestamp{*::morph::ladder::now() + 2min}}, + "a paste expiring in two minutes"); +} + +} // namespace + +int main(int argc, char** argv) { + QCoreApplication qtApp{argc, argv}; + + bool seed = false; + for (int i = 1; i < argc; ++i) { + const std::string arg{argv[i]}; + if (arg == "--seed") { + seed = true; + } else { + std::cerr << "pastebin-server: unknown argument '" << arg + << "' (usage: ladder_pastebin_server [--seed])\n"; + return 2; + } + } + + const char* connectionString = std::getenv("PASTEBIN_DB"); + pastebin::db::setup(connectionString != nullptr ? connectionString + : "DRIVER=SQLite3;Database=pastebin.db;Timeout=5000"); + + if (seed) { + seedDemoPastes(); + } + + int exitCode = 0; + { + pastebin::app::App app{std::filesystem::current_path() / "pastebin_actions.jsonl"}; + + const char* portEnv = std::getenv("PASTEBIN_PORT"); + const int port = portEnv != nullptr ? std::atoi(portEnv) : 0; + ::morph::qt::QtWebSocketServer wsServer{*app.server(), static_cast(port)}; + if (!wsServer.listen()) { + std::cerr << "pastebin-server: failed to listen\n"; + return 1; + } + std::cout << "pastebin-server: listening on port " << 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(); + + // Order matters: let connected clients' in-flight executes reply and + // close cleanly first, *then* drain the expiry sweep's own dispatches + // (see drainSweeps) before `app` leaves this scope. + static_cast(wsServer.closeGracefully(std::chrono::seconds{2})); + if (!drainSweeps(app, std::chrono::seconds{5})) { + std::cerr << "pastebin-server: expiry-sweep dispatches did not settle within 5s; " + "shutting down anyway\n"; + } + } + + std::cout << "pastebin-server: stopped\n"; + return exitCode; +} diff --git a/examples/pastebin/tests/test_gui_qml_smoke.cpp b/examples/pastebin/tests/test_gui_qml_smoke.cpp new file mode 100644 index 00000000..882d158d --- /dev/null +++ b/examples/pastebin/tests/test_gui_qml_smoke.cpp @@ -0,0 +1,51 @@ +// 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." It loads the *same* Pastebin/Main.qml the desktop client +// ships (both link the ladder_pastebin_qml module), with no controllers +// attached — which is why Main.qml's `formsController`/`pasteController` +// default to null. +// +// 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 — the shipped MorphForms +// renderer Main.qml imports). Without it this file is an empty translation +// unit, so a configure that legitimately has no Qt Quick still builds. +// +// Runs under QT_QPA_PLATFORM=offscreen (already set for the ladder-tests and +// clang-coverage CI legs) against the QGuiApplication testkit_main.cpp owns +// when this rung's test binary is built — Qt Quick cannot instantiate a window +// under a plain QCoreApplication. + +#ifdef MORPH_LADDER_QML_URI + +#include + +#include +#include +#include +#include + +#include + +TEST_CASE("pastebin's QML engine loads Main.qml and creates a root object with no errors", + "[pastebin][gui][qml-smoke]") { + 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, "Main"); + + // Reported through the message, not a bare boolean: a QML warning is + // otherwise a failing assertion with nothing to act on. + CHECK(firstWarning.toStdString() == std::string{}); + REQUIRE_FALSE(engine.rootObjects().isEmpty()); +} + +#endif // MORPH_LADDER_QML_URI diff --git a/examples/pastebin/tests/test_paste_model.cpp b/examples/pastebin/tests/test_paste_model.cpp new file mode 100644 index 00000000..5dca18a6 --- /dev/null +++ b/examples/pastebin/tests/test_paste_model.cpp @@ -0,0 +1,1447 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// PasteModel's model-level suite: ordinary CRUD, the burn-after-read +// semantics (including the atomicity guarantee under genuine socket +// concurrency), expiry through the injectable clock, the store-error +// classification branches, and the security/protocol cases +// `examples/pastebin/README.md`'s "Required tests" section assigns to this +// rung. Every case builds its own `DbFixture` (rung 0's convention) so it +// starts from a freshly migrated, real on-disk schema. + +// Lightweight::DataMapper::CreateInternal's own if-constexpr chain +// (DataMapper.hpp) has a trailing `return {};` that MSVC's flow analysis +// proves unreachable for PasteModel's specific Record instantiation -- +// entirely inside that third-party header, not any call site in this file. +// /external:W0 (this file's own target already demotes Lightweight's +// headers to SYSTEM, per morph_add_rung.cmake) does not suppress it here: +// the diagnosis is instantiation-driven and MSVC ties it to the template's +// first instantiation point in the TU, not merely "reported at a line +// inside the external header" -- a known MSVC limitation with templates in +// headers marked external. File-scoped instead of scoped to one call site, +// since several call sites in this file instantiate the same template. +#if defined(_MSC_VER) +#pragma warning(disable : 4702) +#endif + +#include +#include +#include + +#include "clock.hpp" +#include "testkit/backend_rig.hpp" +#include "testkit/db_busy_fixture.hpp" +#include "testkit/db_fixture.hpp" +#include "testkit/db_pool_drain.hpp" +#include "testkit/pump.hpp" + +#include "pastebin/app/app.hpp" +#include "pastebin/core/errors.hpp" +#include "pastebin/db/database.hpp" +#include "pastebin/db/paste_entity.hpp" +#include "pastebin/models/paste_model.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#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::awaitQt; +using morph::ladder::testkit::drainPoolIdleMappers; +using morph::ladder::testkit::pumpUntil; + +// ───────────────────────────────────────────────────────────────────────── +// Small assertion helpers +// ───────────────────────────────────────────────────────────────────────── + +/// @brief An engaged `Reads` as a plain whole number; `-1` when disengaged, +/// so an unexpectedly-empty quantity fails an assertion loudly rather +/// than dereferencing an empty optional. +[[nodiscard]] std::int64_t countOf(const pastebin::Reads& reads) { + return reads.hasValue() ? ::morph::math::floor(*reads) : -1; +} + +[[nodiscard]] pastebin::CreatePaste makeCreate(std::string content, std::string syntax = "text") { + pastebin::CreatePaste create; + create.content = std::move(content); + create.syntax = std::move(syntax); + return create; +} + +/// @brief The instant `morph::ladder::now()` currently reads, shifted by +/// @p delta — the standard way this suite moves time without sleeping. +[[nodiscard]] ::morph::time::DateTime nowPlus(std::chrono::milliseconds delta) { + return *morph::ladder::now() + delta; +} + +// ───────────────────────────────────────────────────────────────────────── +// The animal-name keyspace, mirrored from `src/models/paste_model.cpp` +// ───────────────────────────────────────────────────────────────────────── +// +// Deliberately duplicated rather than exported: those arrays are the model +// TU's own anonymous-namespace implementation detail, and making them public +// API purely for a test would widen the model's surface for no other caller. +// The duplication cannot silently rot, because the keyspace-exhaustion case +// below fills *every* id these arrays can spell and then requires +// `CreatePaste` to fail — if the real arrays ever gain an entry this copy +// lacks, that create finds a free id and the test fails loudly. + +constexpr std::array kAnimals = { + "cat", "dog", "fox", "owl", "bee", "ant", "elk", "ram", + "yak", "cod", "eel", "hen", "pig", "cow", "bat", "jay", +}; +constexpr std::array kAdjectives = { + "red", "blue", "gold", "dark", "swift", "calm", "bold", "wild", + "keen", "grey", "warm", "cool", "sharp", "quiet", "loud", "soft", +}; +constexpr int kSuffixes = 1000; // paste_model.cpp's uniform_int_distribution{0, 999} +constexpr std::size_t kCombos = kAdjectives.size() * kAnimals.size(); + +/// @brief Inserts every `--<0..999>` id for the first +/// @p comboCount adjective/animal pairs, occupying that share of the +/// keyspace so `CreatePaste`'s allocation genuinely collides. +/// +/// One `INSERT ... SELECT` over a recursive CTE rather than @p comboCount +/// x 1000 `DataMapper::Create` round trips: occupying a quarter of the +/// keyspace is 64,000 rows, which is seconds of ODBC round trips and +/// milliseconds of SQLite. +void occupyKeyspace(std::size_t comboCount) { + std::string combos; + std::size_t emitted = 0; + for (const auto& adjective : kAdjectives) { + for (const auto& animal : kAnimals) { + if (emitted >= comboCount) { + break; + } + if (emitted > 0) { + combos += " UNION ALL "; + } + combos += "SELECT '"; + combos += adjective; + combos += '-'; + combos += animal; + combos += "' AS prefix"; + ++emitted; + } + } + REQUIRE(emitted == comboCount); + + ::Lightweight::SqlStatement stmt; + (void) stmt.ExecuteDirect("WITH RECURSIVE suffix(x) AS (SELECT 0 UNION ALL SELECT x + 1 FROM suffix WHERE x < " + + std::to_string(kSuffixes - 1) + + ") INSERT INTO pastes (id, content, syntax, created_at_ms, expires_at_ms, burn_after_reads, " + "read_count, is_private, is_editable) SELECT c.prefix || '-' || suffix.x, 'occupied', 'text', " + "0, NULL, NULL, 0, 0, 0 FROM suffix, (" + + combos + ") c"); +} + +// ───────────────────────────────────────────────────────────────────────── +// Fuzz-corpus replay support (Step 8 / README "Hostile content round-trip") +// ───────────────────────────────────────────────────────────────────────── + +/// @brief Every committed fuzz finding, as raw bytes. +/// +/// `MORPH_LADDER_SOURCE_ROOT` is compiled in by `morph_add_rung()` — ctest +/// runs this binary from its own build directory, so a repo-relative path +/// would not resolve. The directory is walked at runtime (not a hard-coded +/// file list) for the same reason `tests/fuzz/CMakeLists.txt` globs it: +/// a newly committed reproducer must start being replayed without anyone +/// remembering to edit a list here. +[[nodiscard]] std::vector> fuzzFindings() { + const std::filesystem::path root = std::filesystem::path{MORPH_LADDER_SOURCE_ROOT} / "tests" / "fuzz" / "findings"; + std::vector> inputs; + for (const auto& entry : std::filesystem::recursive_directory_iterator{root}) { + if (!entry.is_regular_file()) { + continue; + } + std::ifstream in{entry.path(), std::ios::binary}; + REQUIRE(in.good()); + inputs.emplace_back(entry.path().filename().string(), + std::string{std::istreambuf_iterator{in}, std::istreambuf_iterator{}}); + } + std::ranges::sort(inputs); // stable order across filesystems, for reproducible failures + return inputs; +} + +/// @brief Whether @p text is well-formed UTF-8. +/// +/// The wire protocol is JSON in a WebSocket *text* frame, and the storage +/// column is `TEXT`: bytes that are not valid UTF-8 have no faithful +/// representation anywhere along that path. Which half of the corpus a given +/// finding falls into decides which guarantee the round-trip case below can +/// honestly assert — see it for the split. +[[nodiscard]] bool isValidUtf8(std::string_view text) { + std::size_t i = 0; + while (i < text.size()) { + const auto lead = static_cast(text[i]); + std::size_t extra = 0; + if (lead < 0x80) { + extra = 0; + } else if ((lead & 0xE0) == 0xC0 && lead >= 0xC2) { + extra = 1; + } else if ((lead & 0xF0) == 0xE0) { + extra = 2; + } else if ((lead & 0xF8) == 0xF0 && lead <= 0xF4) { + extra = 3; + } else { + return false; + } + if (i + extra >= text.size()) { + return false; + } + for (std::size_t k = 1; k <= extra; ++k) { + if ((static_cast(text[i + k]) & 0xC0) != 0x80) { + return false; + } + } + i += extra + 1; + } + return true; +} + +/// @brief How many rows the `pastes` table currently holds. +/// +/// Read straight from SQL rather than through `ListPastes`, so it counts +/// private pastes too and is unaffected by paging. +[[nodiscard]] std::int64_t pasteRowCount() { + ::Lightweight::SqlStatement stmt; + return stmt.ExecuteDirectScalar("SELECT COUNT(*) FROM pastes").value_or(-1); +} + +/// @brief Installs a short SQLite `busy_timeout` on every connection opened +/// while it is alive, and restores the default afterwards. +/// +/// `Lightweight::SqlConnection::PostConnect()` unconditionally issues +/// `PRAGMA busy_timeout = 60000` on every new SQLite connection, so a write +/// that collides with `DbBusyFixture`'s held lock blocks for a real minute +/// before SQLite gives up. `test_db_busy_fixture.cpp` re-issues the PRAGMA on +/// the connection it owns — that is not available here, because the +/// connection `PasteModel` uses is acquired from +/// `Lightweight::GlobalDataMapperPool()` inside `execute(...)`, which no test +/// can reach directly. The post-connected hook is the seam that works from +/// the outside: it runs immediately after `PostConnect()` on every +/// newly-created connection. See `db_busy_fixture.hpp`'s +/// "`SetPostConnectedHook` and `GlobalDataMapperPool()`" note: this is only +/// guaranteed to fire if the pool actually creates a fresh connection for the +/// model under test's acquisition, not if it hands back an already-connected +/// idle one — the two call sites below accept that as a documented, +/// not-fully-deterministic tradeoff rather than a hard guarantee. +class ScopedShortBusyTimeout { + public: + explicit ScopedShortBusyTimeout(int milliseconds) { + ::Lightweight::SqlConnection::SetPostConnectedHook([milliseconds](::Lightweight::SqlConnection& connection) { + ::Lightweight::SqlStatement stmt{connection}; + (void) stmt.ExecuteDirect("PRAGMA busy_timeout = " + std::to_string(milliseconds)); + }); + } + ~ScopedShortBusyTimeout() { ::Lightweight::SqlConnection::ResetPostConnectedHook(); } + + ScopedShortBusyTimeout(const ScopedShortBusyTimeout&) = delete; + ScopedShortBusyTimeout& operator=(const ScopedShortBusyTimeout&) = delete; + ScopedShortBusyTimeout(ScopedShortBusyTimeout&&) = delete; + ScopedShortBusyTimeout& operator=(ScopedShortBusyTimeout&&) = delete; +}; + +} // namespace + +// ═════════════════════════════════════════════════════════════════════════ +// Step 1 — ordinary CRUD and validation +// ═════════════════════════════════════════════════════════════════════════ + +TEST_CASE("CreatePaste stores a paste under a freshly allocated animal-name id", "[pastebin][model]") { + DbFixture fixture; + pastebin::PasteModel model; + + const auto id = model.execute(makeCreate("hello", "cpp")).id; + REQUIRE(id.hasValue()); + CHECK_FALSE((*id).empty()); + + const auto view = model.execute(pastebin::GetPaste{.id = id}); + CHECK(view.id == id); + CHECK(view.content == "hello"); + CHECK(view.syntax == "cpp"); + CHECK(view.visibility == pastebin::Visibility::Public); + CHECK(view.editability == pastebin::Editability::Immutable); + CHECK_FALSE(view.expiresAt.hasValue()); + CHECK_FALSE(view.burnAfterReads.hasValue()); +} + +TEST_CASE("CreatePaste and EditPaste round-trip non-ASCII content losslessly", "[pastebin][model]") { + // `content` is stored as Light::SqlMaxDynamicWideString (paste_entity.hpp's + // file comment explains why: SqlText/std::string are char-based and render + // as VARCHAR(MAX) on the SQL Server backend, a single-byte-collation + // column). This exercises both the DataMapper-bound write path + // (CreatePaste) and the raw-prepared-statement write path (EditPaste's + // compare-and-swap, paste_model.cpp's kEditPasteSql) that binds a + // Light::SqlMaxDynamicWideString parameter by hand rather than through a + // Field<>. + DbFixture fixture; + pastebin::PasteModel model; + + const std::string original = "héllo wörld — \xE4\xB8\xAD\xE6\x96\x87 \xF0\x9F\x8E\x89"; // Latin-1 + CJK + emoji + auto create = makeCreate(original, "text"); + create.editability = pastebin::Editability::Editable; + const auto id = model.execute(create).id; + CHECK(model.execute(pastebin::GetPaste{.id = id}).content == original); + + const std::string edited = "édité — \xE6\x97\xA5\xE6\x9C\xAC\xE8\xAA\x9E"; // Japanese + model.execute(pastebin::EditPaste{.id = id, .content = edited, .syntax = "text"}); + CHECK(model.execute(pastebin::GetPaste{.id = id}).content == edited); +} + +TEST_CASE("CreatePaste's validate() rejects empty content and empty syntax", "[pastebin][model]") { + DbFixture fixture; + pastebin::PasteModel model; + + REQUIRE_THROWS_AS(model.execute(makeCreate("", "text")), pastebin::ValidationError); + REQUIRE_THROWS_AS(model.execute(makeCreate("body", "")), pastebin::ValidationError); + REQUIRE_THROWS_AS(model.execute(makeCreate("", "")), pastebin::ValidationError); + + // Nothing was stored by any of the three rejections. + CHECK(model.execute(pastebin::ListPastes{}).pastes.empty()); +} + +TEST_CASE("CreatePaste's validate() rejects a zero or negative burnAfterReads", "[pastebin][model]") { + // A budget of 0 is a whole number, so it passes Reads' own whole-number + // constraint, but PasteModel::execute(GetPaste)'s burn check + // (`readCount >= *burnAfterReads`) is already true before the first read + // ever happens — a paste born with burnAfterReads=0 would be permanently + // Burned on its very first GetPaste, having never been read once. + DbFixture fixture; + pastebin::PasteModel model; + + auto zero = makeCreate("body", "text"); + zero.burnAfterReads = pastebin::Reads::fromDouble(0.0); + REQUIRE_THROWS_AS(model.execute(zero), pastebin::ValidationError); + + auto negative = makeCreate("body", "text"); + negative.burnAfterReads = pastebin::Reads::fromDouble(-1.0); + REQUIRE_THROWS_AS(model.execute(negative), pastebin::ValidationError); + + // A positive budget is unaffected by the new check. + auto positive = makeCreate("body", "text"); + positive.burnAfterReads = pastebin::Reads::fromDouble(1.0); + REQUIRE_NOTHROW(model.execute(positive)); + + // Nothing was stored by either rejection — only the positive create. + CHECK(model.execute(pastebin::ListPastes{}).pastes.size() == 1); +} + +TEST_CASE("An over-length syntax is rejected, not silently truncated into the column", "[pastebin][model]") { + // `PasteRecord::syntax` is a `Light::SqlAnsiString<32>`, whose constructor + // is `_size{std::min(N, s.size())}` — no throw, no diagnostic. Before + // `kMaxSyntaxBytes` was validated, a 33-byte label was cut to 32 on the way + // into the row and the client was told the create succeeded, and a cut + // landing mid-UTF-8-sequence put ill-formed UTF-8 into both the TEXT column + // and the JSON frame carrying the resulting PasteView back. Both halves are + // asserted here: the boundary still fits, one byte past it is refused, and + // nothing was stored by any refusal. + DbFixture fixture; + pastebin::PasteModel model; + + static constexpr std::size_t kMax = pastebin::kMaxSyntaxBytes; + const std::string atLimit(kMax, 'x'); + const std::string overLimit(kMax + 1, 'x'); + + // The boundary itself is accepted and round-trips whole — the bound is + // "<= capacity", not an off-by-one that rejects a label that would fit. + // Editable, so the EditPaste assertion below is genuinely about the syntax + // bound and not about `EditPaste: paste is not editable`. + auto create = makeCreate("at the limit", atLimit); + create.editability = pastebin::Editability::Editable; + const auto id = model.execute(create).id; + CHECK(model.execute(pastebin::GetPaste{.id = id}).syntax == atLimit); + + // One byte past it is a typed rejection, on both actions that write the + // column. + REQUIRE_THROWS_AS(model.execute(makeCreate("one too many", overLimit)), pastebin::ValidationError); + REQUIRE_THROWS_AS(model.execute(pastebin::EditPaste{.id = id, .content = "body", .syntax = overLimit}), + pastebin::ValidationError); + // ... and the still-valid boundary length is accepted by EditPaste too, so + // the rejection above is the length rule, not a blanket refusal. + REQUIRE_NOTHROW(model.execute(pastebin::EditPaste{.id = id, .content = "body", .syntax = atLimit})); + + // A multi-byte label whose truncation point falls *inside* a codepoint — + // the ill-formed-UTF-8 case specifically. Thirty-three 2-byte characters is + // 66 bytes, so a 32-byte cut would sever the 17th one. + std::string multiByte; + for (int i = 0; i < 33; ++i) { + multiByte += "é"; // U+00E9, two bytes in UTF-8 + } + REQUIRE(multiByte.size() > kMax); + REQUIRE_THROWS_AS(model.execute(makeCreate("mid-codepoint", multiByte)), pastebin::ValidationError); + + // Exactly one paste exists: the at-limit one. No refusal wrote a row, and + // no refused edit changed the one that did. + const auto listed = model.execute(pastebin::ListPastes{}); + REQUIRE(listed.pastes.size() == 1); + CHECK(listed.pastes.front().syntax == atLimit); +} + +TEST_CASE("CreatePaste round-trips visibility and editability", "[pastebin][model]") { + DbFixture fixture; + pastebin::PasteModel model; + + auto create = makeCreate("private and editable"); + create.visibility = pastebin::Visibility::Private; + create.editability = pastebin::Editability::Editable; + const auto id = model.execute(create).id; + + const auto view = model.execute(pastebin::GetPaste{.id = id}); + CHECK(view.visibility == pastebin::Visibility::Private); + CHECK(view.editability == pastebin::Editability::Editable); +} + +TEST_CASE("GetPaste returns a freshly created paste and counts the read", "[pastebin][model]") { + DbFixture fixture; + pastebin::PasteModel model; + const auto id = model.execute(makeCreate("secret")).id; + + const auto first = model.execute(pastebin::GetPaste{.id = id}); + CHECK(first.content == "secret"); + CHECK(countOf(first.readCount) == 1); + + const auto second = model.execute(pastebin::GetPaste{.id = id}); + CHECK(second.content == "secret"); + CHECK(countOf(second.readCount) == 2); // the count is real state, not a per-call constant +} + +TEST_CASE("GetPaste against an unknown id throws NotFound, and an empty id is a ValidationError", + "[pastebin][model]") { + DbFixture fixture; + pastebin::PasteModel model; + + REQUIRE_THROWS_AS(model.execute(pastebin::GetPaste{.id = pastebin::PasteId{"no-such-paste"}}), + pastebin::NotFound); + REQUIRE_THROWS_AS(model.execute(pastebin::GetPaste{}), pastebin::ValidationError); +} + +TEST_CASE("EditPaste replaces an editable paste's content and syntax", "[pastebin][model]") { + DbFixture fixture; + pastebin::PasteModel model; + + auto create = makeCreate("before", "text"); + create.editability = pastebin::Editability::Editable; + const auto id = model.execute(create).id; + + const auto edited = model.execute(pastebin::EditPaste{.id = id, .content = "after", .syntax = "cpp"}); + CHECK(edited.content == "after"); + CHECK(edited.syntax == "cpp"); + + // Persisted, not merely reflected back from the action. + const auto refetched = model.execute(pastebin::GetPaste{.id = id}); + CHECK(refetched.content == "after"); + CHECK(refetched.syntax == "cpp"); +} + +TEST_CASE("EditPaste refuses an immutable paste, an unknown id, and an incomplete action", + "[pastebin][model]") { + DbFixture fixture; + pastebin::PasteModel model; + const auto id = model.execute(makeCreate("immutable")).id; // Editability::Immutable by default + + REQUIRE_THROWS_AS(model.execute(pastebin::EditPaste{.id = id, .content = "nope", .syntax = "text"}), + pastebin::ValidationError); + REQUIRE_THROWS_AS( + model.execute(pastebin::EditPaste{.id = pastebin::PasteId{"ghost"}, .content = "nope", .syntax = "text"}), + pastebin::NotFound); + REQUIRE_THROWS_AS(model.execute(pastebin::EditPaste{.id = id, .content = "", .syntax = "text"}), + pastebin::ValidationError); + REQUIRE_THROWS_AS(model.execute(pastebin::EditPaste{.id = id, .content = "body", .syntax = ""}), + pastebin::ValidationError); + + // The refused edits left the stored paste untouched. + CHECK(model.execute(pastebin::GetPaste{.id = id}).content == "immutable"); +} + +TEST_CASE("A concurrent write between EditPaste's read and its write is a Conflict, not a lost update", + "[pastebin][model]") { + // EditPaste used to be a plain read-then-write: whichever caller's + // UPDATE landed last would silently discard whatever an earlier caller + // had just written, with no error to either side. The fix makes the + // write a compare-and-swap (`kEditPasteSql`'s `content = ? AND syntax + // = ?` guard): the write only applies if the row still holds what this + // call read. + // + // Provoked deterministically — no `sleep_for` (examples/TESTING.md) + // and no guessing at thread-scheduling order. `WaitForGuardedUpdate` + // is a `Lightweight::SqlLogger` that fires `OnExecute()` on whatever + // thread runs a statement, strictly before that statement's actual + // (and here, blocking) ODBC call — a real hook Lightweight already + // exposes, not new instrumentation added to PasteModel. It lets the + // main thread wait on a condition variable for the precise moment + // `contendedModel`'s guarded UPDATE is about to run — which can only + // happen after its own `before` SELECT has already completed — before + // committing a *different* write through a lock held open on a second + // connection. `contendedModel`'s guarded UPDATE then blocks on that + // lock; when it is finally released, the guard compares against + // content that is no longer there. + class WaitForGuardedUpdate : public ::Lightweight::SqlLogger::Null { + public: + void OnExecute(std::string_view const& query) override { + if (query.find("SET content = ?, syntax = ?") == std::string_view::npos) { + return; + } + { + const std::lock_guard lock{_mutex}; + _reached = true; + } + _cv.notify_all(); + } + + void wait() { + std::unique_lock lock{_mutex}; + _cv.wait(lock, [this] { return _reached; }); + } + + private: + std::mutex _mutex; + std::condition_variable _cv; + bool _reached = false; + }; + + DbFixture fixture; + pastebin::PasteModel seedModel; + + auto create = makeCreate("seed", "text"); + create.editability = pastebin::Editability::Editable; + const auto id = seedModel.execute(create).id; + + // `contendedModel`'s execute() below must acquire a genuinely new pooled + // connection *while* the short busy-timeout hook is installed for this + // hook to actually apply to it (see db_busy_fixture.hpp's + // GlobalDataMapperPool() note above `ScopedShortBusyTimeout`'s own doc + // comment) — same requirement as the SQLITE_BUSY cases below. Draining + // the pool's idle mappers first (see drainPoolIdleMappers's own doc + // comment) turns that into a hard guarantee rather than the "correct in + // practice, not guaranteed" caveat a shared pool would otherwise leave: + // held alive across the hook install and the racy execute() below, then + // released once this test no longer needs a forced-fresh acquisition. + const ScopedShortBusyTimeout shortTimeout{5000}; + auto drained = drainPoolIdleMappers(); + pastebin::PasteModel contendedModel; + + ::Lightweight::SqlConnection lockingConnection; + { + ::Lightweight::SqlStatement stmt{lockingConnection}; + (void) stmt.ExecuteDirect("BEGIN IMMEDIATE"); + (void) stmt.ExecuteDirect("UPDATE pastes SET id = id WHERE id = '" + *id + "'"); + } + + WaitForGuardedUpdate probe; + ::Lightweight::SqlLogger& previousLogger = ::Lightweight::SqlLogger::GetLogger(); + ::Lightweight::SqlLogger::SetLogger(probe); + + std::optional succeeded; + std::exception_ptr failure; + std::thread editor{[&] { + try { + succeeded = contendedModel.execute(pastebin::EditPaste{.id = id, .content = "mine", .syntax = "text"}); + } catch (...) { + failure = std::current_exception(); + } + }}; + + // Blocks until `contendedModel`'s guarded UPDATE is about to execute — + // which is only reachable after its own `before` SELECT has already + // returned "seed". Only past this point is it safe to commit a + // different write through the lock: the SELECT is guaranteed done. + probe.wait(); + + { + ::Lightweight::SqlStatement stmt{lockingConnection}; + (void) stmt.ExecuteDirect("UPDATE pastes SET content = 'concurrent writer' WHERE id = '" + *id + "'"); + (void) stmt.ExecuteDirect("COMMIT"); + } + + editor.join(); + // Safe to stop forcing fresh acquisitions now: contendedModel's one and + // only execute() call (and so its one pool acquisition) already + // happened, inside the joined editor thread above. + drained.clear(); + // Restored only after the editor thread is done issuing statements — + // `probe` must not be touched by another thread once it goes out of + // scope below. + ::Lightweight::SqlLogger::SetLogger(previousLogger); + + REQUIRE_FALSE(succeeded.has_value()); + REQUIRE(failure); + bool sawConflict = false; + try { + std::rethrow_exception(failure); + } catch (const pastebin::Conflict&) { + sawConflict = true; + } catch (...) { + // Falls through to the REQUIRE below with sawConflict still false. + } + REQUIRE(sawConflict); + + // Not a lost update: the concurrent writer's content survived, untouched + // by the rejected edit. + CHECK(seedModel.execute(pastebin::GetPaste{.id = id}).content == "concurrent writer"); +} + +TEST_CASE("DeletePaste removes the paste, and a follow-up GetPaste throws NotFound", "[pastebin][model]") { + DbFixture fixture; + pastebin::PasteModel model; + const auto id = model.execute(makeCreate("doomed")).id; + + REQUIRE_NOTHROW(model.execute(pastebin::DeletePaste{.id = id})); + REQUIRE_THROWS_AS(model.execute(pastebin::GetPaste{.id = id}), pastebin::NotFound); + + // Deleting an absent paste is a no-op acknowledgement, not an error — + // the operation is idempotent by design. + REQUIRE_NOTHROW(model.execute(pastebin::DeletePaste{.id = id})); + REQUIRE_THROWS_AS(model.execute(pastebin::DeletePaste{}), pastebin::ValidationError); +} + +TEST_CASE("ListPastes returns only public pastes, one page at a time, and its cursor round-trips", + "[pastebin][model]") { + DbFixture fixture; + pastebin::PasteModel model; + + constexpr int kPublic = 25; // one full 20-row page plus a partial second one + constexpr int kPrivate = 3; + std::vector publicIds; + for (int i = 0; i < kPublic; ++i) { + publicIds.push_back(model.execute(makeCreate("public " + std::to_string(i))).id); + } + std::vector privateIds; + for (int i = 0; i < kPrivate; ++i) { + auto create = makeCreate("private " + std::to_string(i)); + create.visibility = pastebin::Visibility::Private; + privateIds.push_back(model.execute(create).id); + } + + const auto page1 = model.execute(pastebin::ListPastes{}); + REQUIRE(page1.pastes.size() == 20); + REQUIRE(page1.nextCursor.hasValue()); + + const auto page2 = model.execute(pastebin::ListPastes{.cursor = page1.nextCursor}); + REQUIRE(page2.pastes.size() == static_cast(kPublic - 20)); + CHECK_FALSE(page2.nextCursor.hasValue()); // exhausted — no third page + + std::vector walked; + for (const auto& summary : page1.pastes) { + walked.push_back(summary.id); + } + for (const auto& summary : page2.pastes) { + walked.push_back(summary.id); + } + + // Every public paste exactly once, no private paste at all. + std::ranges::sort(walked); + CHECK(std::ranges::adjacent_find(walked) == walked.end()); // no overlap between the two pages + CHECK(walked.size() == static_cast(kPublic)); + for (const auto& id : publicIds) { + CHECK(std::ranges::find(walked, id) != walked.end()); + } + for (const auto& id : privateIds) { + CHECK(std::ranges::find(walked, id) == walked.end()); + } + + // A summary is deliberately narrower than a view: it carries no content. + CHECK(page1.pastes.front().syntax == "text"); + CHECK(page1.pastes.front().visibility == pastebin::Visibility::Public); +} + +TEST_CASE("ListPastes does not consume a read budget — listing is not reading", "[pastebin][model]") { + DbFixture fixture; + pastebin::PasteModel model; + + auto create = makeCreate("listed but unread"); + create.burnAfterReads = pastebin::Reads::fromDouble(1.0); + const auto id = model.execute(create).id; + + REQUIRE(model.execute(pastebin::ListPastes{}).pastes.size() == 1); + REQUIRE(model.execute(pastebin::ListPastes{}).pastes.size() == 1); + + // The one allowed read is still available. + CHECK(model.execute(pastebin::GetPaste{.id = id}).content == "listed but unread"); +} + +// ═════════════════════════════════════════════════════════════════════════ +// Step 2 — burn-after-read semantics, single client +// ═════════════════════════════════════════════════════════════════════════ + +TEST_CASE("GetPaste spends the burn budget and deletes the paste on the last allowed read", + "[pastebin][model]") { + DbFixture fixture; + pastebin::PasteModel model; + + auto create = makeCreate("secret"); + create.burnAfterReads = pastebin::Reads::fromDouble(2.0); + const auto id = model.execute(create).id; + + const auto first = model.execute(pastebin::GetPaste{.id = id}); + CHECK(first.content == "secret"); + CHECK(countOf(first.readCount) == 1); + + // Read 2 of 2 still returns the content: burn-after-read destroys the + // paste *on* the Nth read, after building the result — not before it. + const auto second = model.execute(pastebin::GetPaste{.id = id}); + CHECK(second.content == "secret"); + CHECK(countOf(second.readCount) == 2); + + REQUIRE_THROWS_AS(model.execute(pastebin::GetPaste{.id = id}), pastebin::NotFound); +} + +TEST_CASE("GetPaste against a row already at its burn budget throws Burned, not NotFound", + "[pastebin][model]") { + // Seeds the row directly at the storage layer with read_count already at + // burn_after_reads, bypassing the delete-on-last-read step that would + // normally have removed it. This is the "conditional UPDATE matched zero + // rows, and the row still exists" classification branch — reachable no + // other way from the model's own API. + // + // It is also the *only* case in this suite that pins the burn clause of + // `kConsumeReadSql`'s `WHERE` on its own: with that clause deleted, this + // read matches the row, increments past the budget, and hands back + // content that was already spent. Verified by doing exactly that. See the + // concurrent case below for why the socket race does not catch it on + // SQLite, and why the two belong together. + DbFixture fixture; + { + Lightweight::DataMapper mapper; + pastebin::db::PasteRecord rec; + rec.id = Light::SqlAnsiString<32>{"test-burned-paste"}; + rec.content = Light::SqlMaxDynamicWideString{L"gone"}; + rec.syntax = Light::SqlAnsiString<32>{"text"}; + rec.createdAtMs = std::int64_t{0}; + rec.burnAfterReads = std::optional{1}; + rec.readCount = std::int64_t{1}; // already at budget + mapper.Create(rec); + } + + pastebin::PasteModel model; + REQUIRE_THROWS_AS(model.execute(pastebin::GetPaste{.id = pastebin::PasteId{"test-burned-paste"}}), + pastebin::Burned); +} + +// ═════════════════════════════════════════════════════════════════════════ +// Step 3 — burn atomicity under genuine socket concurrency +// ═════════════════════════════════════════════════════════════════════════ + +TEST_CASE("BackendRig::Socket: concurrent GetPaste against a burn-after-N paste — exactly N clients win", + "[pastebin][model][socket-only]") { + // The end-to-end regression test for the burn-after-read guarantee under + // genuine concurrency: N clients, each on its own socket, its own model + // instance, its own strand and its own database connection, all reading + // one burn-after-N paste with nothing awaited until every call is issued. + // Exactly N of them may ever see the content, no matter how the four + // dispatches interleave. Budgets 1..3 are all exercised, because the + // interesting boundary (the last allowed read, which both returns content + // *and* destroys the paste) sits at a different client each time. + // + // Two honesty notes, both established empirically by rebuilding the model + // with its guard deliberately broken and re-running this case: + // + // * This case does *not*, on SQLite, discriminate the conditional + // `UPDATE`'s `read_count < burn_after_reads` clause. Deleting that + // clause outright leaves this case passing, because SQLite serializes + // writers: a losing client's `UPDATE` cannot interleave with the + // winner's transaction, and by the time it runs the winner has already + // committed the burn-delete, so it matches no row and the client gets + // `NotFound` anyway. The clause is what keeps that true on a store with + // row-level locking or MVCC, and the case that pins it directly is + // "GetPaste against a row already at its burn budget throws Burned" — + // deleting the clause fails *that* case immediately. Read the two + // together; neither alone covers the guarantee. + // + // The residual gap that leaves, stated plainly for whoever next touches + // `execute(GetPaste)`: **nothing in this suite would catch the atomic + // `UPDATE ... WHERE read_count < burn_after_reads` being refactored into + // a separate check-then-act (a `SELECT` of the budget, then an + // unguarded `UPDATE`).** That refactor keeps *both* cases green on + // SQLite — the `Burned` case because the pre-check rejects the read just + // as the `WHERE` clause did, and this case because losing clients still + // find the row already deleted, whether the winner's check-then-act was + // genuinely atomic or merely got lucky with SQLite's write + // serialization. It only becomes observably wrong under a store with + // real row-level locking/MVCC contention windows (Postgres), which this + // rung does not test against. So: keep the check inside the `UPDATE`. + // The tests will not tell you if you move it out. + // + // * What this case genuinely does cover is everything above the SQL: that + // the whole stack — four sockets, four strands, four connections, the + // transaction, the read-back and the burn-delete — composes into the + // invariant the README promises, with no client ever handed content + // belonging to a spent budget, and no client left hanging. + DbFixture fixture; + pastebin::PasteModel seedModel; + + constexpr std::size_t kClients = 4; + constexpr int kRounds = 12; + BackendRig rig{Mode::Socket, kClients}; + + // BridgeHandler is neither copyable nor movable, so the handlers are + // named locals rather than a vector. Held for the whole case: registering + // once per client (not once per round) keeps each client's model instance + // — and therefore its database connection — alive across the rounds, + // which is what makes the rounds cheap enough to run many of. + auto handler0 = rig.client(0); + auto handler1 = rig.client(1); + auto handler2 = rig.client(2); + auto handler3 = rig.client(3); + const std::array*, kClients> handlers{&handler0, &handler1, + &handler2, &handler3}; + + struct Tally { + std::atomic successes{0}; + std::atomic failures{0}; + std::atomic wrongContent{0}; + }; + + for (int budget = 1; budget <= 3; ++budget) { + CAPTURE(budget); + for (int round = 0; round < kRounds; ++round) { + CAPTURE(round); + const std::string content = "budget " + std::to_string(budget) + ", round " + std::to_string(round); + auto create = makeCreate(content); + create.burnAfterReads = pastebin::Reads::fromDouble(static_cast(budget)); + const auto id = seedModel.execute(create).id; + + // Heap-allocated (and captured by value) rather than a stack + // local: if the pump below ever timed out, a late callback would + // otherwise write through a dangling reference — the same + // reasoning `pump.hpp`'s `awaitQt` documents. + auto tally = std::make_shared(); + + // Every call is issued before any of them is awaited — that is + // the race-provoking property this case exists for. + for (auto* handler : handlers) { + handler->execute(pastebin::GetPaste{.id = id}) + .then([tally, content](pastebin::PasteView view) { + if (view.content != content) { + tally->wrongContent.fetch_add(1); + } + tally->successes.fetch_add(1); + }) + .onError([tally](const std::exception_ptr&) { tally->failures.fetch_add(1); }); + } + + REQUIRE(pumpUntil([tally] { + return tally->successes.load() + tally->failures.load() == static_cast(kClients); + })); + // Exactly `budget` clients get the content — never one more, no + // matter how the four dispatches interleave. + REQUIRE(tally->successes.load() == budget); + REQUIRE(tally->failures.load() == static_cast(kClients) - budget); + REQUIRE(tally->wrongContent.load() == 0); + + // And the paste really is gone afterwards, for everyone. + REQUIRE_THROWS_AS(seedModel.execute(pastebin::GetPaste{.id = id}), pastebin::NotFound); + } + } +} + +// ═════════════════════════════════════════════════════════════════════════ +// Step 4 — expiry, driven by the injectable clock rather than by sleeping +// ═════════════════════════════════════════════════════════════════════════ + +TEST_CASE("A paste past its expiresAt throws Expired from GetPaste, before any sweep runs", + "[pastebin][model]") { + DbFixture fixture; + pastebin::PasteModel model; + + auto create = makeCreate("expiring"); + create.expiresAt = morph::ladder::now(); + const auto id = model.execute(create).id; + + // No sweep is involved: `GetPaste`'s own conditional UPDATE excludes the + // expired row, which is exactly what makes correctness independent of + // sweep timing (README, "How does expiry replay?"). + const morph::ladder::ScopedClockOverride later{nowPlus(std::chrono::hours{1})}; + REQUIRE_THROWS_AS(model.execute(pastebin::GetPaste{.id = id}), pastebin::Expired); + + // Repeatable: a failed read consumes nothing, so the same error comes back. + REQUIRE_THROWS_AS(model.execute(pastebin::GetPaste{.id = id}), pastebin::Expired); +} + +TEST_CASE("Expiry edges: an expiresAt at the epoch, and one already in the past at creation time", + "[pastebin][model]") { + DbFixture fixture; + pastebin::PasteModel model; + + // The epoch is a legal instant, not a sentinel for "no expiry" — that is + // what a disengaged `Timestamp` means. A paste stamped with it is simply + // long expired. + auto atEpoch = makeCreate("epoch"); + atEpoch.expiresAt = ::morph::time::Timestamp{::morph::time::DateTime{ + std::chrono::sys_time{std::chrono::milliseconds{0}}}}; + const auto epochId = model.execute(atEpoch).id; + REQUIRE_THROWS_AS(model.execute(pastebin::GetPaste{.id = epochId}), pastebin::Expired); + + auto inThePast = makeCreate("already stale"); + inThePast.expiresAt = ::morph::time::Timestamp{nowPlus(-std::chrono::hours{1})}; + const auto staleId = model.execute(inThePast).id; + REQUIRE_THROWS_AS(model.execute(pastebin::GetPaste{.id = staleId}), pastebin::Expired); + + // A still-future expiry is untouched by any of this. + auto live = makeCreate("still live"); + live.expiresAt = ::morph::time::Timestamp{nowPlus(std::chrono::hours{1})}; + const auto liveId = model.execute(live).id; + CHECK(model.execute(pastebin::GetPaste{.id = liveId}).content == "still live"); +} + +TEST_CASE("A malformed expiresAt on the wire is a decode error, never a clamped value", + "[pastebin][model]") { + // The third expiry edge the README requires, and the one the two cases + // above cannot reach: past and epoch are *values*, but "malformed" is not + // representable as a `Timestamp` at all, so it can only be exercised where + // the wire text is still text — the action codec + // (`ActionTraits::fromJson`, which is what + // `Bridge`/`RemoteServer` call on an execute envelope's `body`). No + // `DbFixture` is needed: a malformed action must be rejected before any + // model, transaction or row is involved. + using Traits = ::morph::model::ActionTraits; + + // Positive control first, in exactly the wire shape the negatives use, so + // none of them can pass for an unrelated reason (a rejected sibling field, + // a changed key name). A well-formed instant decodes, and `null` is the + // legal "never expires" encoding of a disengaged `Timestamp`. + const auto wellFormed = + Traits::fromJson(R"({"content":"x","syntax":"text","expiresAt":"2026-08-06T12:30:15.000Z"})"); + REQUIRE(wellFormed.expiresAt.hasValue()); + CHECK((*wellFormed.expiresAt).toIso8601() == "2026-08-06T12:30:15.000Z"); + CHECK_FALSE(Traits::fromJson(R"({"content":"x","syntax":"text","expiresAt":null})").expiresAt.hasValue()); + + // Every one of these must throw rather than yield a `CreatePaste` at all. + // The failure mode being pinned is silent coercion: a decoder that shrugged + // and left `expiresAt` disengaged would turn "expires at a time I got + // wrong" into "never expires" — a paste that outlives its author's intent + // with no error anywhere — and one that rounded 2026-02-30 forward to + // March 2nd, or read "T-5:30:15" as a negative hour, would shift the + // instant to a *different valid* one just as silently. + const auto malformed = GENERATE(as{}, + R"("garbage")", // not a date in any format + R"("")", // empty string + R"("2026-08-06")", // date with no clock part + R"("2026-02-30T00:00:00.000Z")", // date that does not exist + R"("2026-08-06T-5:30:15Z")", // sign injection into the hour + R"("2026-08-06t12:30:15Z")", // lowercase separator + R"(1754483415000)", // epoch millis, not an ISO string + R"(true)"); // wrong JSON type entirely + CAPTURE(malformed); + const auto body = std::string{R"({"content":"x","syntax":"text","expiresAt":)"} + std::string{malformed} + "}"; + CHECK_THROWS_AS(Traits::fromJson(body), ::morph::model::detail::ParseError); +} + +TEST_CASE("ExpirePaste reclaims only a genuinely expired paste, so replaying it is safe", + "[pastebin][model]") { + DbFixture fixture; + pastebin::PasteModel model; + + auto live = makeCreate("not expired"); + live.expiresAt = ::morph::time::Timestamp{nowPlus(std::chrono::hours{1})}; + const auto liveId = model.execute(live).id; + + auto neverExpires = makeCreate("no expiry at all"); + const auto eternalId = model.execute(neverExpires).id; + + // Replaying the journaled entry against pastes that are not (or not yet) + // expired must delete nothing — the payload carries only the id, so the + // guard has to live in the statement. + REQUIRE_NOTHROW(model.execute(pastebin::ExpirePaste{.id = liveId})); + REQUIRE_NOTHROW(model.execute(pastebin::ExpirePaste{.id = eternalId})); + CHECK(model.execute(pastebin::GetPaste{.id = liveId}).content == "not expired"); + CHECK(model.execute(pastebin::GetPaste{.id = eternalId}).content == "no expiry at all"); + + REQUIRE_THROWS_AS(model.execute(pastebin::ExpirePaste{}), pastebin::ValidationError); + + { + const morph::ladder::ScopedClockOverride later{nowPlus(std::chrono::hours{2})}; + REQUIRE_NOTHROW(model.execute(pastebin::ExpirePaste{.id = liveId})); + REQUIRE_THROWS_AS(model.execute(pastebin::GetPaste{.id = liveId}), pastebin::NotFound); + // Still nothing to reclaim for the paste that never expires. + REQUIRE_NOTHROW(model.execute(pastebin::ExpirePaste{.id = eternalId})); + CHECK(model.execute(pastebin::GetPaste{.id = eternalId}).content == "no expiry at all"); + } + + // Replaying the entry a second time, after the paste is already gone, is + // still an acknowledgement rather than an error. + REQUIRE_NOTHROW(model.execute(pastebin::ExpirePaste{.id = liveId})); +} + +TEST_CASE("App's periodic sweep dispatches ExpirePaste for a past-expiry paste, and it is gone afterward", + "[pastebin][app]") { + DbFixture fixture; + pastebin::PasteModel model; + + auto create = makeCreate("to be swept"); + create.expiresAt = morph::ladder::now(); + const auto sweptId = model.execute(create).id; + const auto survivorId = model.execute(makeCreate("no expiry")).id; + + const morph::ladder::ScopedClockOverride later{nowPlus(std::chrono::hours{1})}; + + const auto logPath = std::filesystem::temp_directory_path() / "pastebin_sweep_test.jsonl"; + std::filesystem::remove(logPath); + { + // A one-hour interval effectively disables the timer; the pass is + // driven directly instead, so nothing here depends on wall-clock + // timing. `App` reaches the same database this test does because both + // go through Lightweight's process-global default connection string, + // which `DbFixture` (constructed above, before `App`) already set. + pastebin::app::App app{logPath, std::chrono::hours{1}}; + app.sweepExpiredOnce(); + + // The sweep dispatches fire-and-forget through its internal client, so + // the effect is observed by pumping rather than by the call returning. + REQUIRE(pumpUntil([&] { + try { + (void) model.execute(pastebin::GetPaste{.id = sweptId}); + return false; // still there + } catch (const pastebin::NotFound&) { + return true; // reclaimed + } catch (const pastebin::PastebinError&) { + return false; // Expired: found but not yet swept + } + })); + // The rows being gone is not the same as the dispatches having + // settled — see App::sweepInFlight(). Settle before letting the App + // go, or its completion callbacks outlive it. + REQUIRE(pumpUntil([&] { return !app.sweepInFlight(); })); + } + std::filesystem::remove(logPath); + + // The sweep is targeted: an unexpiring paste is untouched by it. + CHECK(model.execute(pastebin::GetPaste{.id = survivorId}).content == "no expiry"); +} + +TEST_CASE("A sweep firing between two pages of a ListPastes cursor walk skips no surviving paste", + "[pastebin][app]") { + // Keyset pagination on the primary key is what makes this safe: the + // cursor is the previous page's last id, so rows reclaimed mid-walk + // cannot shift a later page's offset the way LIMIT/OFFSET would. + DbFixture fixture; + pastebin::PasteModel model; + + constexpr int kSurvivors = 25; + constexpr int kDoomed = 10; + std::vector survivors; + for (int i = 0; i < kSurvivors; ++i) { + survivors.push_back(model.execute(makeCreate("survivor " + std::to_string(i))).id); + } + // Scattered among them (ids are random, so their ranks interleave), the + // pastes the sweep will reclaim halfway through the walk. + for (int i = 0; i < kDoomed; ++i) { + auto doomed = makeCreate("doomed " + std::to_string(i)); + doomed.expiresAt = morph::ladder::now(); + (void) model.execute(doomed); + } + REQUIRE(pasteRowCount() == kSurvivors + kDoomed); + + const morph::ladder::ScopedClockOverride later{nowPlus(std::chrono::hours{1})}; + + const auto page1 = model.execute(pastebin::ListPastes{}); + REQUIRE(page1.pastes.size() == 20); + REQUIRE(page1.nextCursor.hasValue()); + + const auto logPath = std::filesystem::temp_directory_path() / "pastebin_sweep_paging_test.jsonl"; + std::filesystem::remove(logPath); + std::vector walked; + { + pastebin::app::App app{logPath, std::chrono::hours{1}}; + app.sweepExpiredOnce(); + // The whole sweep lands between the two pages — the most disruptive + // moment it could possibly fire. + REQUIRE(pumpUntil([&] { return pasteRowCount() == kSurvivors; })); + REQUIRE(pumpUntil([&] { return !app.sweepInFlight(); })); + + for (const auto& summary : page1.pastes) { + walked.push_back(summary.id); + } + const auto page2 = model.execute(pastebin::ListPastes{.cursor = page1.nextCursor}); + for (const auto& summary : page2.pastes) { + walked.push_back(summary.id); + } + } + std::filesystem::remove(logPath); + + // Every survivor appears exactly once across the two pages: none was + // skipped by rows vanishing underneath the walk, and none was served + // twice. (Page 1 may still name reclaimed pastes — it was read before the + // sweep — which is staleness, not a paging defect.) + std::ranges::sort(walked); + CHECK(std::ranges::adjacent_find(walked) == walked.end()); + for (const auto& id : survivors) { + CHECK(std::ranges::find(walked, id) != walked.end()); + } +} + +// ═════════════════════════════════════════════════════════════════════════ +// Step 5 — duplicate create on retry (this rung's honest, weaker behavior) +// ═════════════════════════════════════════════════════════════════════════ + +TEST_CASE("Two CreatePaste calls with identical content mint two distinct pastes at this rung", + "[pastebin][model]") { + // Documents a known limitation rather than a guarantee. The README's + // "duplicate create on retry" bullet points at idempotency-key discipline, + // but rung 1's `CreatePaste` has no such key — LADDER.md scopes + // exactly-once delivery to rung 4, and the fault-injection proxy that + // could stage a genuine lost reply frame does not exist yet either. So + // today two identical creates really are two pastes, and this asserts + // that plainly: the day rung 4's idempotency discipline lands here, this + // case fails loudly and gets updated alongside the comment, instead of + // silently drifting into a guarantee nobody implemented. + DbFixture fixture; + pastebin::PasteModel model; + + const auto create = makeCreate("resent"); + const auto first = model.execute(create).id; + const auto second = model.execute(create).id; + + CHECK(first != second); + CHECK(model.execute(pastebin::ListPastes{}).pastes.size() == 2); +} + +// ═════════════════════════════════════════════════════════════════════════ +// Step 6 — id-collision handling in the tiny animal-name keyspace +// ═════════════════════════════════════════════════════════════════════════ + +TEST_CASE("CreatePaste retries past colliding animal-name ids instead of failing the call", + "[pastebin][model]") { + DbFixture fixture; + + // A quarter of the keyspace is occupied up front, so roughly one + // allocation attempt in four collides on a real primary-key violation and + // has to be retried. Across the creates below a collision is effectively + // certain (P(none) = 0.75^40 ~= 1e-5), while exhausting the eight-attempt + // budget for any single create is not (P = 0.25^8 ~= 1.5e-5 per create) — + // the retry path is genuinely exercised without the case becoming flaky. + occupyKeyspace(kCombos / 4); + + pastebin::PasteModel model; + std::vector minted; + for (int i = 0; i < 40; ++i) { + pastebin::CreatePasteResult result; + REQUIRE_NOTHROW(result = model.execute(makeCreate("attempt " + std::to_string(i)))); + REQUIRE(result.id.hasValue()); + minted.push_back(result.id); + } + + // Every id is distinct, and none of them landed on an occupied row (which + // would mean an allocation overwrote a stored paste rather than retrying). + std::ranges::sort(minted); + CHECK(std::ranges::adjacent_find(minted) == minted.end()); + for (const auto& id : minted) { + CHECK(model.execute(pastebin::GetPaste{.id = id}).content.starts_with("attempt ")); + } +} + +TEST_CASE("CreatePaste gives up with a ValidationError once the whole keyspace is occupied", + "[pastebin][model]") { + // The other side of the retry budget, and the guard that keeps the + // keyspace mirrored at the top of this file honest: with every id the + // model can spell already taken, all eight attempts must collide and the + // call must surface a plain ValidationError rather than leaking the + // driver's constraint-violation exception. + DbFixture fixture; + occupyKeyspace(kCombos); + + pastebin::PasteModel model; + REQUIRE_THROWS_AS(model.execute(makeCreate("no room left")), pastebin::ValidationError); +} + +// ═════════════════════════════════════════════════════════════════════════ +// Step 7 — size-limit UX +// ═════════════════════════════════════════════════════════════════════════ + +TEST_CASE("An oversized CreatePaste is refused by the transport with a typed, readable error", + "[pastebin][model][socket-only]") { + // The bound is a transport concern, not a model one: `QtWebSocketServer` + // rejects the frame before `RemoteServer::handle()` ever decodes it, so + // no `PasteModel` runs and nothing is stored. The client still gets an + // error addressed to its own call, which is what makes the failure + // renderable rather than a silent hang. + DbFixture fixture; + + morph::qt::QtWebSocketServerConfig serverConfig; + serverConfig.maxMessageBytes = 4096; + BackendRig rig{Mode::Socket, 1, /*authorizer=*/nullptr, serverConfig}; + auto handler = rig.client(0); + + // A comfortably-under-the-cap paste still works, so the case below is + // about the size and nothing else. + const auto smallId = awaitQt(handler.execute(makeCreate(std::string(64, 'a')))).id; + CHECK(awaitQt(handler.execute(pastebin::GetPaste{.id = smallId})).content == std::string(64, 'a')); + + REQUIRE_THROWS_WITH(awaitQt(handler.execute(makeCreate(std::string(64 * 1024, 'a')))), + Catch::Matchers::ContainsSubstring("message exceeds maxMessageBytes")); + + // Refused at the transport: exactly one paste exists, the small one. + pastebin::PasteModel model; + CHECK(model.execute(pastebin::ListPastes{}).pastes.size() == 1); +} + +// ═════════════════════════════════════════════════════════════════════════ +// Step 8 — hostile content round-trip +// ═════════════════════════════════════════════════════════════════════════ + +TEST_CASE("Fuzz-corpus findings survive CreatePaste/GetPaste as paste content, both backends", + "[pastebin][model]") { + const auto mode = GENERATE(Mode::Local, Mode::Socket); + DbFixture fixture; + BackendRig rig{mode, 1}; + auto handler = rig.client(0); + + const auto findings = fuzzFindings(); + REQUIRE_FALSE(findings.empty()); + + for (const auto& [name, content] : findings) { + CAPTURE(name); + if (isValidUtf8(content)) { + // Control bytes, embedded quotes, JSON-looking payloads: all of + // these must survive the JSON envelope, the socket, and the TEXT + // column byte for byte. This is the bug class fuzzing already + // caught once in the wire layer. + const auto id = awaitQt(handler.execute(makeCreate(content))).id; + const auto fetched = awaitQt(handler.execute(pastebin::GetPaste{.id = id})); + CHECK(fetched.content == content); + } else { + // Ill-formed UTF-8 has no faithful representation in a JSON text + // frame or a `TEXT` column, and does not come back byte for byte + // (observed: the ill-formed sequences are re-encoded, so the + // stored content is longer than what was sent). That loss is + // inherent to a text protocol over a text column, not a defect — + // but it has to be *stable and convergent*, which is what this + // asserts: the paste reads back identically every time, and + // re-pasting what came back round-trips byte for byte. A stack + // that mangled a little more on every hop, or handed out a + // different string on the second read, would fail here. + pastebin::PasteId id; + try { + id = awaitQt(handler.execute(makeCreate(content))).id; + } catch (const std::exception&) { + continue; // refused outright: an acceptable, well-behaved outcome + } + const auto first = awaitQt(handler.execute(pastebin::GetPaste{.id = id})); + const auto second = awaitQt(handler.execute(pastebin::GetPaste{.id = id})); + CHECK(first.content == second.content); + + const auto reId = awaitQt(handler.execute(makeCreate(first.content))).id; + CHECK(awaitQt(handler.execute(pastebin::GetPaste{.id = reId})).content == first.content); + } + } +} + +// ═════════════════════════════════════════════════════════════════════════ +// Step 9 — security posture: the fail-open delta +// ═════════════════════════════════════════════════════════════════════════ + +TEST_CASE("Fail-open default: an unauthenticated client registers and reads a paste it knows the id of", + "[pastebin][security][socket-only]") { + // Executable documentation of `docs/spec/security.md`'s fail-open + // default. Rung 1 deliberately configures no authorizer, so this asserts + // the *documented* posture, not a bug: knowing an id is the entire access + // control story at this rung. LADDER.md's security matrix is where that + // changes; when it does, this case is the one that fails first and gets + // rewritten alongside the rung that hardens it. + DbFixture fixture; + pastebin::PasteModel seedModel; + auto create = makeCreate("no auth configured"); + create.visibility = pastebin::Visibility::Private; // not even "private" gates a direct read + const auto id = seedModel.execute(create).id; + + BackendRig rig{Mode::Socket, 1}; // no authorizer -> RemoteServer's allow-all default + auto handler = rig.client(0); + + const auto fetched = awaitQt(handler.execute(pastebin::GetPaste{.id = id})); + CHECK(fetched.content == "no auth configured"); + + // And the same session-less client can mutate, not merely read. + REQUIRE_NOTHROW(awaitQt(handler.execute(pastebin::DeletePaste{.id = id}))); + REQUIRE_THROWS_AS(seedModel.execute(pastebin::GetPaste{.id = id}), pastebin::NotFound); +} + +// ═════════════════════════════════════════════════════════════════════════ +// Step 10 — `hello` protocol-version negotiation +// ═════════════════════════════════════════════════════════════════════════ + +TEST_CASE("hello negotiates the protocol version the server is built against", + "[pastebin][security][socket-only]") { + // No example exercised the `hello` handshake before this rung (README's + // "Required tests"). `negotiateProtocolVersion()` is transport-level and + // blocks on a nested QEventLoop, which is exactly what a native Catch2 + // test wants; `BackendRig::socketBackend()` exists to reach it. + DbFixture fixture; + BackendRig rig{Mode::Socket, 1}; + + REQUIRE(rig.socketBackend(0).negotiateProtocolVersion() == morph::wire::ProtocolNegotiationResult::Negotiated); + + // Negotiation is not a one-way door: the same connection goes on to serve + // ordinary traffic. + pastebin::PasteModel seedModel; + const auto id = seedModel.execute(makeCreate("after negotiation")).id; + auto handler = rig.client(0); + CHECK(awaitQt(handler.execute(pastebin::GetPaste{.id = id})).content == "after negotiation"); + + // Idempotent — a second handshake over a live connection negotiates the + // same version rather than failing. + REQUIRE(rig.socketBackend(0).negotiateProtocolVersion() == morph::wire::ProtocolNegotiationResult::Negotiated); +} + +// ═════════════════════════════════════════════════════════════════════════ +// Step 11 — store-error branch coverage +// ═════════════════════════════════════════════════════════════════════════ + +TEST_CASE("GetPaste surfaces a real SQLITE_BUSY as a thrown error, not as silent data loss", + "[pastebin][model]") { + // Finding 018's designated resolution for the busy class: a genuine + // competing write transaction on a second connection, not a mock. The + // model must let that failure reach the client as itself — treating a + // contended update as "zero rows matched" would silently downgrade an + // outage into a NotFound, and a burn budget could be spent (or not) with + // nobody able to tell. + DbFixture fixture; + pastebin::PasteModel seedModel; + const auto id = seedModel.execute(makeCreate("contended")).id; + + // Same requirement as the EditPaste contention test above: + // contendedModel's execute() below must acquire its connection while + // this hook is installed for the hook to actually apply — draining the + // pool's idle mappers first (drainPoolIdleMappers's own doc comment) + // makes that a hard guarantee rather than a "usually true" assumption. + const ScopedShortBusyTimeout shortTimeout{200}; + auto drained = drainPoolIdleMappers(); + pastebin::PasteModel contendedModel; + + const morph::ladder::testkit::DbBusyFixture busy{"pastes"}; + const auto start = std::chrono::steady_clock::now(); + REQUIRE_THROWS(contendedModel.execute(pastebin::GetPaste{.id = id})); + // contendedModel's one and only execute() call (and so its one pool + // acquisition) already happened on this thread, synchronously, above. + drained.clear(); + // Fast, not a sixty-second block: without the hook above, Lightweight's + // own `PRAGMA busy_timeout = 60000` would make this "pass" by waiting out + // a real minute. + CHECK(std::chrono::steady_clock::now() - start < std::chrono::seconds{30}); +} + +TEST_CASE("CreatePaste surfaces a real SQLITE_BUSY rather than mistaking it for an id collision", + "[pastebin][model]") { + // The other half of the classifier in `CreatePaste`'s retry loop: only a + // unique-constraint violation is retryable. A busy database must not be + // swallowed into "could not allocate a unique paste id" — that would + // report an outage as keyspace exhaustion. + DbFixture fixture; + { + pastebin::PasteModel warmup; + (void) warmup.execute(makeCreate("seed")); + } + + // Same requirement as the other two SQLITE_BUSY tests in this file: + // contendedModel's execute() below must acquire its connection while + // this hook is installed for the hook to actually apply -- draining the + // pool's idle mappers first (drainPoolIdleMappers's own doc comment) + // makes that a hard guarantee. Without this, `warmup`'s own earlier + // acquisition above can leave an idle, already-connected mapper in the + // pool for contendedModel to receive instead of a fresh one, silently + // skipping the short busy-timeout PRAGMA and blocking on the real 60s + // default -- observed as a 120s CTest timeout on CI, not a local + // failure, since it depends on the pool's prior state. + const ScopedShortBusyTimeout shortTimeout{200}; + auto drained = drainPoolIdleMappers(); + pastebin::PasteModel contendedModel; + + const morph::ladder::testkit::DbBusyFixture busy{"pastes"}; + REQUIRE_THROWS_AS(contendedModel.execute(makeCreate("cannot be written")), Lightweight::SqlException); + drained.clear(); +} + +// ═════════════════════════════════════════════════════════════════════════ +// Coverage completeness (examples/IMPLEMENTATION.md rule 5) +// ═════════════════════════════════════════════════════════════════════════ +// +// Small surfaces the behavioural cases above never happen to reach, pinned +// directly rather than left as coverage holes: each is real, shipped API +// another rung (or this rung's own server binary) calls. + +TEST_CASE("PasteId and PasteCursor adopt an optional payload as-is", "[pastebin][model]") { + // The named factory that exists because a second same-arity constructor + // would make `PasteId{"literal"}` ambiguous — see core/types.hpp. + CHECK_FALSE(pastebin::PasteId::fromOptional(std::nullopt).hasValue()); + const auto engaged = pastebin::PasteId::fromOptional(std::optional{"swift-otter"}); + REQUIRE(engaged.hasValue()); + CHECK(*engaged == "swift-otter"); + CHECK(engaged == pastebin::PasteId{"swift-otter"}); + + CHECK_FALSE(pastebin::PasteCursor::fromOptional(std::nullopt).hasValue()); + const auto cursor = pastebin::PasteCursor::fromOptional(std::optional{"page-2"}); + REQUIRE(cursor.hasValue()); + CHECK(*cursor == "page-2"); + CHECK(cursor == pastebin::PasteCursor{"page-2"}); +} + +TEST_CASE("The read-count unit carries its schema id, display text and precision", "[pastebin][model]") { + const auto meta = morph::units::UnitTraits::meta(pastebin::Unit::count); + CHECK(meta.id == "count"); + CHECK(meta.display.empty()); // a read count is dimensionless — no unit symbol to render + CHECK(meta.defaultDecimals == 1U); +} + +TEST_CASE("db::setup points the default connection at a database and applies the schema", + "[pastebin][model]") { + // The entry point the server/GUI binaries call at startup, in place of a + // DbFixture. Pointed at the same database this suite already uses, so it + // is idempotent here: both of its migration calls are no-ops against an + // already-migrated schema. + DbFixture fixture; + REQUIRE_NOTHROW(pastebin::db::setup(DbFixture::computeConnectionString(std::getenv("ODBC_CONNECTION_STRING")))); + + pastebin::PasteModel model; + const auto id = model.execute(makeCreate("after setup")).id; + CHECK(model.execute(pastebin::GetPaste{.id = id}).content == "after setup"); +} + +TEST_CASE("A sweep with nothing expired dispatches nothing at all", "[pastebin][app]") { + DbFixture fixture; + pastebin::PasteModel model; + const auto id = model.execute(makeCreate("nothing to reclaim")).id; + + const auto logPath = std::filesystem::temp_directory_path() / "pastebin_empty_sweep_test.jsonl"; + std::filesystem::remove(logPath); + { + pastebin::app::App app{logPath, std::chrono::hours{1}}; + // The server every transport wraps — what a real deployment reaches + // for right after construction. + CHECK(app.server() != nullptr); + + app.sweepExpiredOnce(); + // The early return, not merely "no rows were deleted": a pass that + // found nothing must not stand up an internal client and dispatch. + CHECK_FALSE(app.sweepInFlight()); + } + std::filesystem::remove(logPath); + + CHECK(model.execute(pastebin::GetPaste{.id = id}).content == "nothing to reclaim"); +} diff --git a/examples/pastebin/tests/test_paste_presenter.cpp b/examples/pastebin/tests/test_paste_presenter.cpp new file mode 100644 index 00000000..9ad7ba87 --- /dev/null +++ b/examples/pastebin/tests/test_paste_presenter.cpp @@ -0,0 +1,269 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// PastePresenter's own suite (Task 11): each of the five actions +// (create/get/edit/remove/list) 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 the `failed` signal path for an unknown id. Domain rules +// (validation, burn-after-read, expiry, keyspace collisions, ...) already +// have a dedicated suite at the model level (test_paste_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 paste_presenter.hpp's own doc +// comment states (examples/IMPLEMENTATION.md rule 2). +// +// Step 2 of Task 11 (one offscreen QML engine-load smoke test, TESTING.md +// presenter rule 6) is deliberately not attempted here: it needs Task 12's +// Main.qml to exist first, per the plan. + +#include +#include + +#include "paste_presenter.hpp" +#include "testkit/backend_rig.hpp" +#include "testkit/db_fixture.hpp" +#include "testkit/pump.hpp" + +#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; + +[[nodiscard]] pastebin::CreatePaste makeCreate(std::string content, std::string syntax = "text") { + pastebin::CreatePaste create; + create.content = std::move(content); + create.syntax = std::move(syntax); + return create; +} + +} // namespace + +TEST_CASE("PastePresenter::create then get round-trips a paste, all three backend modes", + "[pastebin][presenter]") { + auto mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + DbFixture fixture; + BackendRig rig{mode, 1}; + pastebin::gui::PastePresenter presenter{rig.bridge(0), rig.executor()}; + + pastebin::PasteId createdId; + bool created = false; + QObject::connect(&presenter, &pastebin::gui::PastePresenter::created, + [&](pastebin::CreatePasteResult result) { + createdId = result.id; + created = true; + }); + presenter.create(makeCreate("presenter round-trip")); + REQUIRE(pumpUntil([&] { return created; })); + REQUIRE_FALSE(presenter.busy()); + REQUIRE(createdId.hasValue()); + + pastebin::PasteView loaded; + bool gotLoaded = false; + QObject::connect(&presenter, &pastebin::gui::PastePresenter::loaded, [&](pastebin::PasteView view) { + loaded = view; + gotLoaded = true; + }); + presenter.get(pastebin::GetPaste{.id = createdId}); + REQUIRE(pumpUntil([&] { return gotLoaded; })); + REQUIRE_FALSE(presenter.busy()); + CHECK(loaded.id == createdId); + CHECK(loaded.content == "presenter round-trip"); + CHECK(loaded.syntax == "text"); +} + +TEST_CASE("PastePresenter::edit replaces an editable paste's content and syntax, all three backend modes", + "[pastebin][presenter]") { + auto mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + DbFixture fixture; + BackendRig rig{mode, 1}; + pastebin::gui::PastePresenter presenter{rig.bridge(0), rig.executor()}; + + pastebin::PasteId createdId; + bool created = false; + QObject::connect(&presenter, &pastebin::gui::PastePresenter::created, + [&](pastebin::CreatePasteResult result) { + createdId = result.id; + created = true; + }); + auto create = makeCreate("before edit"); + create.editability = pastebin::Editability::Editable; + presenter.create(create); + REQUIRE(pumpUntil([&] { return created; })); + + pastebin::PasteView edited; + bool gotEdited = false; + QObject::connect(&presenter, &pastebin::gui::PastePresenter::edited, [&](pastebin::PasteView view) { + edited = view; + gotEdited = true; + }); + presenter.edit(pastebin::EditPaste{.id = createdId, .content = "after edit", .syntax = "cpp"}); + REQUIRE(pumpUntil([&] { return gotEdited; })); + REQUIRE_FALSE(presenter.busy()); + CHECK(edited.id == createdId); + CHECK(edited.content == "after edit"); + CHECK(edited.syntax == "cpp"); + + // Persisted, not merely reflected back from the action. + pastebin::PasteView reloaded; + bool gotReloaded = false; + QObject::connect(&presenter, &pastebin::gui::PastePresenter::loaded, [&](pastebin::PasteView view) { + reloaded = view; + gotReloaded = true; + }); + presenter.get(pastebin::GetPaste{.id = createdId}); + REQUIRE(pumpUntil([&] { return gotReloaded; })); + CHECK(reloaded.content == "after edit"); + CHECK(reloaded.syntax == "cpp"); +} + +TEST_CASE("PastePresenter::remove deletes a paste, and a follow-up get fails, all three backend modes", + "[pastebin][presenter]") { + auto mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + DbFixture fixture; + BackendRig rig{mode, 1}; + pastebin::gui::PastePresenter presenter{rig.bridge(0), rig.executor()}; + + pastebin::PasteId createdId; + bool created = false; + QObject::connect(&presenter, &pastebin::gui::PastePresenter::created, + [&](pastebin::CreatePasteResult result) { + createdId = result.id; + created = true; + }); + presenter.create(makeCreate("doomed")); + REQUIRE(pumpUntil([&] { return created; })); + + bool removed = false; + QObject::connect(&presenter, &pastebin::gui::PastePresenter::removed, [&] { removed = true; }); + presenter.remove(pastebin::DeletePaste{.id = createdId}); + REQUIRE(pumpUntil([&] { return removed; })); + REQUIRE_FALSE(presenter.busy()); + + QString failure; + bool failed = false; + QObject::connect(&presenter, &pastebin::gui::PastePresenter::failed, [&](QString message) { + failure = message; + failed = true; + }); + presenter.get(pastebin::GetPaste{.id = createdId}); + REQUIRE(pumpUntil([&] { return failed; })); + CHECK_FALSE(failure.isEmpty()); + REQUIRE_FALSE(presenter.busy()); +} + +TEST_CASE("PastePresenter::list returns the pastes just created, all three backend modes", + "[pastebin][presenter]") { + auto mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + DbFixture fixture; + BackendRig rig{mode, 1}; + pastebin::gui::PastePresenter presenter{rig.bridge(0), rig.executor()}; + + std::vector createdIds; + QObject::connect(&presenter, &pastebin::gui::PastePresenter::created, + [&](pastebin::CreatePasteResult result) { createdIds.push_back(result.id); }); + + constexpr int kCount = 3; + for (int i = 0; i < kCount; ++i) { + presenter.create(makeCreate("listed " + std::to_string(i))); + REQUIRE(pumpUntil([&] { return static_cast(createdIds.size()) == i + 1; })); + } + REQUIRE(createdIds.size() == static_cast(kCount)); + + pastebin::ListPastesResult listed; + bool gotListed = false; + QObject::connect(&presenter, &pastebin::gui::PastePresenter::listed, [&](pastebin::ListPastesResult result) { + listed = std::move(result); + gotListed = true; + }); + presenter.list(pastebin::ListPastes{}); + REQUIRE(pumpUntil([&] { return gotListed; })); + REQUIRE_FALSE(presenter.busy()); + + REQUIRE(listed.pastes.size() == static_cast(kCount)); + for (const auto& id : createdIds) { + CHECK(std::ranges::find_if(listed.pastes, [&](const pastebin::PasteSummary& summary) { + return summary.id == id; + }) != listed.pastes.end()); + } +} + +TEST_CASE("Every PastePresenter action routes its failure to failed(), not just get()", + "[pastebin][presenter]") { + // `get`'s error path has its own case below; this covers the other four. + // Not a completeness ritual: each action's `reportError` is wired + // independently at its own `track()` call site (`paste_presenter.cpp`), + // so a mis-wired one action's `onErr` argument is a mistake only that + // action's own test can catch — a passing test for one action says + // nothing about whether another action's wiring is correct. + DbFixture fixture; + BackendRig rig{Mode::Local, 1}; + pastebin::gui::PastePresenter presenter{rig.bridge(0), rig.executor()}; + + QString failure; + int failures = 0; + QObject::connect(&presenter, &pastebin::gui::PastePresenter::failed, [&](QString message) { + failure = message; + ++failures; + }); + + // create: empty content fails CreatePaste::validate(). + presenter.create(makeCreate("")); + REQUIRE(pumpUntil([&] { return failures == 1; })); + CHECK(failure.contains("CreatePaste")); + REQUIRE_FALSE(presenter.busy()); + + // edit: an id nothing was ever stored under. + presenter.edit(pastebin::EditPaste{.id = pastebin::PasteId{"no-such-paste"}, .content = "x", .syntax = "text"}); + REQUIRE(pumpUntil([&] { return failures == 2; })); + CHECK(failure.contains("EditPaste")); + REQUIRE_FALSE(presenter.busy()); + + // remove: a disengaged id fails DeletePaste::validate(). (An id that + // merely does not exist is deliberately *not* an error — deleting is + // idempotent by design, see test_paste_model.cpp.) + presenter.remove(pastebin::DeletePaste{}); + REQUIRE(pumpUntil([&] { return failures == 3; })); + CHECK(failure.contains("DeletePaste")); + REQUIRE_FALSE(presenter.busy()); + + // list: the one action with no validation failure at all — every + // `ListPastes` is well-formed. Its error path is reachable only through a + // genuine store error, so provoke one for real, through the schema, not + // through a mock (there is no injectable seam between DataMapper and the + // ODBC driver — see examples/TESTING.md's testkit section): drop the + // table out from under the query. `DbFixture` re-creates the schema for + // the next test case, so this is contained. + { + ::Lightweight::SqlStatement stmt; + (void) stmt.ExecuteDirect("DROP TABLE pastes"); + } + presenter.list(pastebin::ListPastes{}); + REQUIRE(pumpUntil([&] { return failures == 4; })); + CHECK_FALSE(failure.isEmpty()); + REQUIRE_FALSE(presenter.busy()); +} + +TEST_CASE("PastePresenter::get against an unknown id emits failed, not a crash", "[pastebin][presenter]") { + DbFixture fixture; + BackendRig rig{Mode::Local, 1}; + pastebin::gui::PastePresenter presenter{rig.bridge(0), rig.executor()}; + + QString failure; + bool failed = false; + QObject::connect(&presenter, &pastebin::gui::PastePresenter::failed, [&](QString message) { + failure = message; + failed = true; + }); + presenter.get(pastebin::GetPaste{.id = pastebin::PasteId{"no-such-paste"}}); + REQUIRE(pumpUntil([&] { return failed; })); + CHECK_FALSE(failure.isEmpty()); + REQUIRE_FALSE(presenter.busy()); +} diff --git a/examples/pastebin/tests/test_paste_qml_bridges.cpp b/examples/pastebin/tests/test_paste_qml_bridges.cpp new file mode 100644 index 00000000..86ede4a0 --- /dev/null +++ b/examples/pastebin/tests/test_paste_qml_bridges.cpp @@ -0,0 +1,479 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// The QML-adapter layer's own suite: `PasteBridge` and `FormsBridge` +// (`gui_lib/paste_qml_bridges.hpp`), the two classes that stand between the +// Task 10 GUI classes and the QML shell. +// +// Why this file exists as a *separate* suite from test_paste_presenter.cpp: +// those adapters are the only place in the rung where a `PasteView` becomes a +// `QVariantMap` and a signal acquires the exact name and signature +// `gui/qml/Main.qml` and `gui/qml/PasteView.qml` bind against. QML binds by +// *string*, so a renamed key or a changed signal signature is not a compile +// error anywhere — it is a silently empty label at run time, and the offscreen +// engine-load smoke test (test_gui_qml_smoke.cpp) deliberately loads Main.qml +// with both controllers null, so it cannot catch it either. Every assertion +// below that names a string key or a signal signature is therefore a +// cross-check against a real binding site in those two QML files, cited +// inline. +// +// Both classes are Qt-Core-only (`QVariantMap` is Qt Core; the engine-facing +// side is `setInitialProperties` in each shell), so they instantiate under the +// testkit's owned application object exactly like `PastePresenter` does — no +// QML engine, no window. Domain rules (burn/expiry/visibility/pagination) are +// the model's and are covered in test_paste_model.cpp; routing and busy/idle +// are the presenter's and are covered in test_paste_presenter.cpp. This file +// only proves the translation. + +#include +#include + +#include "clock.hpp" +#include "paste_qml_bridges.hpp" +#include "paste_schemas.hpp" +#include "pastebin/models/paste_model.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 + +namespace { + +using morph::ladder::testkit::BackendRig; +using morph::ladder::testkit::DbFixture; +using morph::ladder::testkit::Mode; +using morph::ladder::testkit::pumpUntil; + +/// @brief A `CreatePaste` body in the shape `DynamicForm.previewLine` hands +/// `FormsBridge::submitIfValid` — a fully-assembled JSON object, with +/// the optional members (`expiresAt`, `burnAfterReads`, `visibility`, +/// `editability`, per `CreatePaste::optionalFields`) left out exactly +/// as the form leaves them out when the user engages neither. +[[nodiscard]] QString createBody(const QString& content, const QString& syntax = QStringLiteral("text")) { + return QStringLiteral(R"({"content":"%1","syntax":"%2"})").arg(content, syntax); +} + +/// @brief Creates one paste through `FormsBridge` and returns its id, so the +/// `PasteBridge` cases below have a real row to act on without reaching +/// past the adapters into the model. +/// +/// This is the composition the shell actually performs: `Main.qml` creates +/// through `formsController.submitIfValid` and reads the outcome in +/// `onReplyReceived`, never through `pasteController` — `PasteBridge` relays no +/// `created` signal at all (see paste_qml_bridges.cpp's comment on why that is +/// deliberate). The id comes out of the reply payload, which is a +/// `CreatePasteResult` (`{"id": ...}`) — not out of a follow-up listing, whose +/// order is descending by id and so identifies "the paste just created" only +/// by accident when exactly one exists. +/// @param forms The bridge to submit through. +/// @param content Paste body. +/// @param syntax Syntax label. +/// @return The new paste's id. +[[nodiscard]] QString createPasteVia(pastebin::gui::FormsBridge& forms, const QString& content, + const QString& syntax = QStringLiteral("text")) { + bool replied = false; + bool ok = false; + QString payload; + QObject::connect(&forms, &pastebin::gui::FormsBridge::replyReceived, + [&](const QString&, bool succeeded, const QString& body) { + ok = succeeded; + payload = body; + replied = true; + }); + forms.submitIfValid(QStringLiteral("CreatePaste"), createBody(content, syntax)); + REQUIRE(pumpUntil([&] { return replied; })); + REQUIRE(ok); + QObject::disconnect(&forms, &pastebin::gui::FormsBridge::replyReceived, nullptr, nullptr); + + const QJsonDocument reply = QJsonDocument::fromJson(payload.toUtf8()); + REQUIRE(reply.isObject()); + const QString id = reply.object().value(QStringLiteral("id")).toString(); + REQUIRE_FALSE(id.isEmpty()); + return id; +} + +} // namespace + +// ═════════════════════════════════════════════════════════════════════════ +// The QML-visible surface: names and signatures QML binds by string +// ═════════════════════════════════════════════════════════════════════════ + +TEST_CASE("FormsBridge exposes exactly the surface DynamicForm and Main.qml bind against", + "[pastebin][gui][qml-bridges]") { + DbFixture fixture; + BackendRig rig{Mode::Local, 1}; + pastebin::gui::FormsBridge forms{rig.bridge(0), rig.executor()}; + + const QMetaObject* meta = forms.metaObject(); + + // `root.formsController.schemasJson` — Main.qml:35. + REQUIRE(meta->indexOfProperty("schemasJson") >= 0); + CHECK(meta->property(meta->indexOfProperty("schemasJson")).isConstant()); + + // `root.formsController.submitIfValid("CreatePaste", createForm.previewLine)` + // — Main.qml:169. Two QString arguments, invokable from QML. + REQUIRE(meta->indexOfMethod("submitIfValid(QString,QString)") >= 0); + + // `function onReplyReceived(actionType, ok, payload)` — Main.qml:113. + REQUIRE(meta->indexOfSignal("replyReceived(QString,bool,QString)") >= 0); + + // The property's value is the shared schema document, verbatim — the same + // one both shells build (paste_schemas.hpp exists so they cannot diverge), + // and `JSON.parse`-able, since Main.qml does exactly that to it. + CHECK(forms.schemasJson().toStdString() == pastebin::gui::pasteSchemasJson()); + CHECK(forms.schemasJson().contains(QStringLiteral("\"CreatePaste\""))); +} + +TEST_CASE("PasteBridge exposes exactly the surface Main.qml and PasteView.qml bind against", + "[pastebin][gui][qml-bridges]") { + DbFixture fixture; + BackendRig rig{Mode::Local, 1}; + pastebin::gui::PasteBridge pastes{rig.bridge(0), rig.executor()}; + + const QMetaObject* meta = pastes.metaObject(); + + // `root.pasteController.refresh()` (Main.qml:69, :93, :99, :121, :178), + // `.open(modelData.id)` (Main.qml:201), `.remove(pasteId)` (Main.qml:213). + REQUIRE(meta->indexOfMethod("refresh()") >= 0); + REQUIRE(meta->indexOfMethod("open(QString)") >= 0); + REQUIRE(meta->indexOfMethod("remove(QString)") >= 0); + + // `function onListed(rows)` / `onLoaded(paste)` / `onRemoved()` / + // `onFailed(message)` — Main.qml:75, :86, :96, :102. + REQUIRE(meta->indexOfSignal("listed(QVariantList)") >= 0); + REQUIRE(meta->indexOfSignal("loaded(QVariantMap)") >= 0); + REQUIRE(meta->indexOfSignal("removed()") >= 0); + REQUIRE(meta->indexOfSignal("failed(QString)") >= 0); +} + +// ═════════════════════════════════════════════════════════════════════════ +// FormsBridge: both arms of its one reply signal +// ═════════════════════════════════════════════════════════════════════════ + +TEST_CASE("FormsBridge::submitIfValid relays a successful create as replyReceived(type, true, resultJson), " + "all three backend modes", + "[pastebin][gui][qml-bridges]") { + auto mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + DbFixture fixture; + BackendRig rig{mode, 1}; + pastebin::gui::FormsBridge forms{rig.bridge(0), rig.executor()}; + + QString actionType; + bool ok = false; + QString payload; + bool replied = false; + QObject::connect(&forms, &pastebin::gui::FormsBridge::replyReceived, + [&](const QString& type, bool succeeded, const QString& body) { + actionType = type; + ok = succeeded; + payload = body; + replied = true; + }); + + forms.submitIfValid(QStringLiteral("CreatePaste"), createBody(QStringLiteral("through the form"))); + REQUIRE(pumpUntil([&] { return replied; })); + + // Main.qml:118 renders `actionType + " ok: " + payload`, so the echoed type + // must be the one submitted, not a normalised or empty string. + CHECK(actionType == QStringLiteral("CreatePaste")); + CHECK(ok); + // `CreatePasteResult` is `{id}`; the shell displays the JSON verbatim. + CHECK(payload.contains(QStringLiteral("\"id\""))); +} + +TEST_CASE("FormsBridge::submitIfValid relays a rejected create as replyReceived(type, false, message)", + "[pastebin][gui][qml-bridges]") { + DbFixture fixture; + BackendRig rig{Mode::Local, 1}; + pastebin::gui::FormsBridge forms{rig.bridge(0), rig.executor()}; + + QString actionType; + bool ok = true; + QString payload; + bool replied = false; + QObject::connect(&forms, &pastebin::gui::FormsBridge::replyReceived, + [&](const QString& type, bool succeeded, const QString& body) { + actionType = type; + ok = succeeded; + payload = body; + replied = true; + }); + + // Empty content fails `CreatePaste::validate()` — the model's own rule, + // reached through the generic executeJson path the form uses. + forms.submitIfValid(QStringLiteral("CreatePaste"), createBody(QString{})); + REQUIRE(pumpUntil([&] { return replied; })); + + CHECK(actionType == QStringLiteral("CreatePaste")); + CHECK_FALSE(ok); + // Main.qml:116 shows `payload` as the error text, so it must be the + // exception's own `what()`, not an empty string or a generic placeholder. + CHECK_FALSE(payload.isEmpty()); + CHECK(payload.contains(QStringLiteral("CreatePaste"))); + + // Nothing was stored by the rejected submit. + pastebin::gui::PasteBridge pastes{rig.bridge(0), rig.executor()}; + QVariantList rows; + bool listed = false; + QObject::connect(&pastes, &pastebin::gui::PasteBridge::listed, [&](const QVariantList& page) { + rows = page; + listed = true; + }); + pastes.refresh(); + REQUIRE(pumpUntil([&] { return listed; })); + CHECK(rows.isEmpty()); +} + +// ═════════════════════════════════════════════════════════════════════════ +// PasteBridge: the property-bag shapes +// ═════════════════════════════════════════════════════════════════════════ + +TEST_CASE("PasteBridge::open emits a paste bag carrying every key PasteView.qml reads, " + "all three backend modes", + "[pastebin][gui][qml-bridges]") { + auto mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + DbFixture fixture; + BackendRig rig{mode, 1}; + pastebin::gui::FormsBridge forms{rig.bridge(0), rig.executor()}; + pastebin::gui::PasteBridge pastes{rig.bridge(0), rig.executor()}; + + const QString id = createPasteVia(forms, QStringLiteral("bag contents"), QStringLiteral("cpp")); + + QVariantMap bag; + bool loaded = false; + QObject::connect(&pastes, &pastebin::gui::PasteBridge::loaded, [&](const QVariantMap& paste) { + bag = paste; + loaded = true; + }); + pastes.open(id); + REQUIRE(pumpUntil([&] { return loaded; })); + + // Every key below is read by name in QML. `id`/`readCount` from + // Main.qml:88; `content` from PasteView.qml:72; `syntax`, `visibility`, + // `editability`, `createdAt`, `expiresAt`, `readCount`, `burnAfterReads` + // from PasteView.qml:29-35; `id` again from PasteView.qml:46, :79. + for (const char* key : {"id", "content", "syntax", "createdAt", "expiresAt", "burnAfterReads", "readCount", + "visibility", "editability"}) { + INFO("missing key: " << key); + REQUIRE(bag.contains(QString::fromLatin1(key))); + } + // Nothing extra: the bag is exactly these nine, so a key added here without + // a QML binding (or removed from under one) shows up as a failure rather + // than as dead weight. + CHECK(bag.size() == 9); + + CHECK(bag.value(QStringLiteral("id")).toString() == id); + CHECK(bag.value(QStringLiteral("content")).toString() == QStringLiteral("bag contents")); + CHECK(bag.value(QStringLiteral("syntax")).toString() == QStringLiteral("cpp")); + // Every value is already a display *string* — PasteView.qml concatenates + // them straight into a Label with no formatting of its own (rule 2's + // "pure glue" allowance depends on this being true here). + for (auto it = bag.cbegin(); it != bag.cend(); ++it) { + INFO("non-string value for key: " << it.key().toStdString()); + CHECK(it.value().typeId() == QMetaType::QString); + } + + // The two enums render as the words PasteView.qml displays verbatim. + CHECK(bag.value(QStringLiteral("visibility")).toString() == QStringLiteral("Public")); + CHECK(bag.value(QStringLiteral("editability")).toString() == QStringLiteral("Immutable")); + + // Two sentinel conventions PasteView.qml compares against *literally* + // (PasteView.qml:33 and :35) — if either renderer ever changed, the pane + // would silently start showing the raw sentinel instead of "never"/"no + // limit". This create engaged neither `expiresAt` nor `burnAfterReads`. + CHECK(bag.value(QStringLiteral("expiresAt")).toString().isEmpty()); + CHECK(bag.value(QStringLiteral("burnAfterReads")).toString() == QStringLiteral("N/A")); + + // A read is a mutation at this rung: the count is real state, rendered as + // text. Main.qml:88 shows it as "read N time(s)". + CHECK(bag.value(QStringLiteral("readCount")).toString().startsWith(QStringLiteral("1"))); + CHECK_FALSE(bag.value(QStringLiteral("createdAt")).toString().isEmpty()); +} + +TEST_CASE("PasteBridge::refresh emits list rows in the narrower summary shape, and only public pastes", + "[pastebin][gui][qml-bridges]") { + DbFixture fixture; + BackendRig rig{Mode::Local, 1}; + pastebin::gui::FormsBridge forms{rig.bridge(0), rig.executor()}; + pastebin::gui::PasteBridge pastes{rig.bridge(0), rig.executor()}; + + (void) createPasteVia(forms, QStringLiteral("first"), QStringLiteral("text")); + (void) createPasteVia(forms, QStringLiteral("second"), QStringLiteral("md")); + + // A private paste, submitted through the same form path with the optional + // `visibility` member engaged — it must not appear in the listing. + { + bool replied = false; + QObject::connect(&forms, &pastebin::gui::FormsBridge::replyReceived, + [&](const QString&, bool ok, const QString&) { + CHECK(ok); + replied = true; + }); + forms.submitIfValid(QStringLiteral("CreatePaste"), + QStringLiteral(R"({"content":"hidden","syntax":"text","visibility":"Private"})")); + REQUIRE(pumpUntil([&] { return replied; })); + QObject::disconnect(&forms, &pastebin::gui::FormsBridge::replyReceived, nullptr, nullptr); + } + + QVariantList rows; + bool listed = false; + QObject::connect(&pastes, &pastebin::gui::PasteBridge::listed, [&](const QVariantList& page) { + rows = page; + listed = true; + }); + pastes.refresh(); + REQUIRE(pumpUntil([&] { return listed; })); + + REQUIRE(rows.size() == 2); + for (const QVariant& row : rows) { + const QVariantMap bag = row.toMap(); + // Main.qml:197 reads exactly these four off `modelData`. + for (const char* key : {"id", "syntax", "createdAt", "visibility"}) { + INFO("missing key: " << key); + REQUIRE(bag.contains(QString::fromLatin1(key))); + } + // Narrower than the `loaded` bag *on purpose*: a listing must not leak + // paste content (`pastebin/dto/paste_dto.hpp`'s `PasteSummary`). This + // assertion is the one that would catch a well-meaning widening of the + // summary bag into a full `PasteView` map. + CHECK(bag.size() == 4); + CHECK_FALSE(bag.contains(QStringLiteral("content"))); + CHECK(bag.value(QStringLiteral("visibility")).toString() == QStringLiteral("Public")); + CHECK_FALSE(bag.value(QStringLiteral("id")).toString().isEmpty()); + } +} + +TEST_CASE("PasteBridge renders the engaged arm of every formatted field, and the second arm of both enums", + "[pastebin][gui][qml-bridges]") { + // The `loaded`-bag case above exercises each renderer's *empty/default* + // arm (`isoOrEmpty` with no instant -> "", `readsText` with no budget -> + // "N/A", Public, Immutable). This one exercises the other arm of all four, + // which is where a formatting regression would actually be visible in the + // pane: an engaged expiry, an engaged burn budget, Private and Editable. + // + // The row is seeded through `PasteModel` directly rather than through + // `FormsBridge`, deliberately: engaging `burnAfterReads` over the wire + // means hand-writing a `Rational`'s `{num,den,dp}` wire object, which + // pins this file to a codec detail it is not about. Seeding in C++ is the + // convention the sibling model suite already uses, and the subject under + // test — the adapter's rendering — is unaffected by how the row got there. + // `Mode::Local`, so the bridge and the seeding model share one process and + // one database. + DbFixture fixture; + BackendRig rig{Mode::Local, 1}; + pastebin::gui::PasteBridge pastes{rig.bridge(0), rig.executor()}; + + pastebin::PasteId seededId; + { + pastebin::PasteModel seed; + pastebin::CreatePaste create; + create.content = "fully engaged"; + create.syntax = "cpp"; + create.expiresAt = ::morph::time::Timestamp{*morph::ladder::now() + std::chrono::hours{24}}; + create.burnAfterReads = pastebin::Reads{::morph::math::Rational{9, pastebin::Reads::declaredPrecision()}}; + create.visibility = pastebin::Visibility::Private; + create.editability = pastebin::Editability::Editable; + seededId = seed.execute(create).id; + } + REQUIRE(seededId.hasValue()); + + QVariantMap bag; + bool loaded = false; + QObject::connect(&pastes, &pastebin::gui::PasteBridge::loaded, [&](const QVariantMap& paste) { + bag = paste; + loaded = true; + }); + pastes.open(QString::fromStdString(*seededId)); + REQUIRE(pumpUntil([&] { return loaded; })); + + // Both enum ternaries' second branch (paste_qml_bridges.cpp's + // `toVariantMap`), rendered as the words PasteView.qml:30-31 display. + CHECK(bag.value(QStringLiteral("visibility")).toString() == QStringLiteral("Private")); + CHECK(bag.value(QStringLiteral("editability")).toString() == QStringLiteral("Editable")); + + // `isoOrEmpty`'s engaged arm. PasteView.qml:33 shows this verbatim unless + // it is exactly "", so it must be a real ISO-8601 instant. + const QString expires = bag.value(QStringLiteral("expiresAt")).toString(); + CHECK(expires.contains(QLatin1Char('T'))); + CHECK(expires.endsWith(QLatin1Char('Z'))); + + // `readsText`'s engaged arm. PasteView.qml:35 shows this verbatim unless + // it is exactly "N/A", so an engaged budget must render as something else. + const QString burn = bag.value(QStringLiteral("burnAfterReads")).toString(); + CHECK(burn != QStringLiteral("N/A")); + CHECK(burn.startsWith(QStringLiteral("9"))); + + // The paste is private, so it is absent from the public listing — the + // `PasteSummary` visibility rule, seen from the adapter's side. + QVariantList rows; + bool listed = false; + QObject::connect(&pastes, &pastebin::gui::PasteBridge::listed, [&](const QVariantList& page) { + rows = page; + listed = true; + }); + pastes.refresh(); + REQUIRE(pumpUntil([&] { return listed; })); + CHECK(rows.isEmpty()); +} + + +TEST_CASE("PasteBridge::remove emits removed(), and a follow-up open emits failed() with the model's message", + "[pastebin][gui][qml-bridges]") { + auto mode = GENERATE(Mode::Local, Mode::LocalSingleThread, Mode::Socket); + DbFixture fixture; + BackendRig rig{mode, 1}; + pastebin::gui::FormsBridge forms{rig.bridge(0), rig.executor()}; + pastebin::gui::PasteBridge pastes{rig.bridge(0), rig.executor()}; + + const QString id = createPasteVia(forms, QStringLiteral("doomed")); + + bool removed = false; + QObject::connect(&pastes, &pastebin::gui::PasteBridge::removed, [&] { removed = true; }); + pastes.remove(id); + REQUIRE(pumpUntil([&] { return removed; })); + + QString message; + bool failed = false; + QObject::connect(&pastes, &pastebin::gui::PasteBridge::failed, [&](const QString& text) { + message = text; + failed = true; + }); + pastes.open(id); + REQUIRE(pumpUntil([&] { return failed; })); + // Main.qml:103 shows this string as the error banner, so it must be the + // model's own `what()`. + CHECK_FALSE(message.isEmpty()); + CHECK(message.contains(QStringLiteral("GetPaste"))); +} + +TEST_CASE("PasteBridge::open against an unknown id emits failed(), not loaded()", + "[pastebin][gui][qml-bridges]") { + DbFixture fixture; + BackendRig rig{Mode::Local, 1}; + pastebin::gui::PasteBridge pastes{rig.bridge(0), rig.executor()}; + + bool loaded = false; + QObject::connect(&pastes, &pastebin::gui::PasteBridge::loaded, [&](const QVariantMap&) { loaded = true; }); + QString message; + bool failed = false; + QObject::connect(&pastes, &pastebin::gui::PasteBridge::failed, [&](const QString& text) { + message = text; + failed = true; + }); + + pastes.open(QStringLiteral("no-such-paste")); + REQUIRE(pumpUntil([&] { return failed; })); + CHECK_FALSE(loaded); + CHECK_FALSE(message.isEmpty()); +} From 1503b6b01f02e64ce48249600bc71a2459097c8a Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Sat, 15 Aug 2026 15:27:02 +0300 Subject: [PATCH 2/2] tests: close two real EditPaste coverage gaps, document a third Investigating PR #89's codecov/patch gap (85.04% vs 97.24% target) found two genuinely reachable, previously-uncovered branches in EditPaste's post-CAS-miss classification (paste_model.cpp's "Zero rows matched: classify why" block) -- distinct from the pre-CAS checks the existing "refuses an immutable paste, an unknown id" test already covers, since those never reach the guarded UPDATE at all: - A concurrent delete between EditPaste's first read and its guarded write throws NotFound, not Conflict -- the row genuinely vanished underneath the pending edit. - A concurrent is_editable flip (simulated directly through the locking connection, since no ordinary action un-edits a paste) throws ValidationError instead. Both forced deterministically via the same WaitForGuardedUpdate SqlLogger hook idiom the existing Conflict test already established -- no sleep_for, no guessing at scheduling. Also investigated (but left undone, with a comment explaining why) a third gap: app.cpp's sweep .onError() path, which decrements sweepInFlight when a swept ExpirePaste fails. Forcing a real SQLITE_BUSY inside sweepExpiredOnce()'s worker-thread-dispatched execute() needs drainPoolIdleMappers()'s "next Acquire() is fresh" guarantee to hold across that async dispatch; confirmed by direct instrumentation that it currently does not (some other Acquire()/Return() pair repopulates the idle pool first), and there is no way to observe which path a given Acquire() took to root-cause that further. Filed LASTRADA-Software/Lightweight#548 requesting SqlLogger::OnConnectionIdle/ OnConnectionReuse (already declared, never called anywhere in the library) actually get wired up, which would answer this directly. Two other apparent gaps (paste_qml_bridges.hpp, app.hpp -- Q_OBJECT/ constructor/destructor declaration lines) are the same llvm-cov per-declaration-line reporting artifact confirmed twice already this session: both classes are thoroughly exercised via their .cpp definitions (0 uncovered lines each), and their constructors are directly instantiated in this file's own App-level tests. Co-Authored-By: Claude Sonnet 5 --- examples/pastebin/src/app/app.cpp | 13 ++ examples/pastebin/tests/test_paste_model.cpp | 187 +++++++++++++++++++ 2 files changed, 200 insertions(+) diff --git a/examples/pastebin/src/app/app.cpp b/examples/pastebin/src/app/app.cpp index be869bb0..fa0264e3 100644 --- a/examples/pastebin/src/app/app.cpp +++ b/examples/pastebin/src/app/app.cpp @@ -104,6 +104,19 @@ void App::sweepExpiredOnce() { inFlight->fetch_add(1); handler->execute(ExpirePaste{.id = PasteId{id}}) .then([handler, inFlight](Ack) { inFlight->fetch_sub(1); }) + // Not covered by this file's own test suite: forcing this path + // needs a real SQLITE_BUSY inside sweepExpiredOnce()'s own + // worker-thread-dispatched execute(), which requires + // drainPoolIdleMappers()'s "next Acquire() is fresh" guarantee to + // hold across that async dispatch -- confirmed by direct + // instrumentation that it currently does not (some other, + // unidentified Acquire()/Return() pair repopulates the idle pool + // in between), and Lightweight has no built-in way to observe + // which path a given Acquire() took to root-cause that further + // (see LASTRADA-Software/Lightweight#548, requesting + // SqlLogger::OnConnectionIdle/OnConnectionReuse actually get + // wired up). Left uncovered rather than shipping a ~60-second + // test or ad hoc printf instrumentation. .onError([handler, inFlight, id](const std::exception_ptr&) { inFlight->fetch_sub(1); ::morph::log::logError("[pastebin::App] expiry sweep: ExpirePaste failed for " + id); diff --git a/examples/pastebin/tests/test_paste_model.cpp b/examples/pastebin/tests/test_paste_model.cpp index 5dca18a6..0e1a261e 100644 --- a/examples/pastebin/tests/test_paste_model.cpp +++ b/examples/pastebin/tests/test_paste_model.cpp @@ -609,6 +609,193 @@ TEST_CASE("A concurrent write between EditPaste's read and its write is a Confli CHECK(seedModel.execute(pastebin::GetPaste{.id = id}).content == "concurrent writer"); } +TEST_CASE("A concurrent delete between EditPaste's read and its guarded write is a NotFound, " + "not a lost update or a Conflict", + "[pastebin][model]") { + // Same forced-interleaving idiom as "A concurrent write between + // EditPaste's read and its write is a Conflict" above, but the + // concurrent writer deletes the row outright instead of editing its + // content. This exercises EditPaste's *post-CAS-miss* classification + // path (paste_model.cpp's "Zero rows matched: classify why" block) -- + // distinct from the earlier, pre-CAS existing.empty() check the "refuses + // an unknown id" test above already covers, since that one never reaches + // the guarded UPDATE at all (the row was never there to begin with). This + // one has the row present and readable at EditPaste's first SELECT, and + // only disappears in the window the CAS UPDATE itself is blocked in. + class WaitForGuardedUpdate : public ::Lightweight::SqlLogger::Null { + public: + void OnExecute(std::string_view const& query) override { + if (query.find("SET content = ?, syntax = ?") == std::string_view::npos) { + return; + } + { + const std::lock_guard lock{_mutex}; + _reached = true; + } + _cv.notify_all(); + } + + void wait() { + std::unique_lock lock{_mutex}; + _cv.wait(lock, [this] { return _reached; }); + } + + private: + std::mutex _mutex; + std::condition_variable _cv; + bool _reached = false; + }; + + DbFixture fixture; + pastebin::PasteModel seedModel; + + auto create = makeCreate("about to vanish", "text"); + create.editability = pastebin::Editability::Editable; + const auto id = seedModel.execute(create).id; + + const ScopedShortBusyTimeout shortTimeout{5000}; + auto drained = drainPoolIdleMappers(); + pastebin::PasteModel contendedModel; + + ::Lightweight::SqlConnection lockingConnection; + { + ::Lightweight::SqlStatement stmt{lockingConnection}; + (void) stmt.ExecuteDirect("BEGIN IMMEDIATE"); + (void) stmt.ExecuteDirect("UPDATE pastes SET id = id WHERE id = '" + *id + "'"); + } + + WaitForGuardedUpdate probe; + ::Lightweight::SqlLogger& previousLogger = ::Lightweight::SqlLogger::GetLogger(); + ::Lightweight::SqlLogger::SetLogger(probe); + + std::optional succeeded; + std::exception_ptr failure; + std::thread editor{[&] { + try { + succeeded = contendedModel.execute(pastebin::EditPaste{.id = id, .content = "mine", .syntax = "text"}); + } catch (...) { + failure = std::current_exception(); + } + }}; + + probe.wait(); + + { + ::Lightweight::SqlStatement stmt{lockingConnection}; + (void) stmt.ExecuteDirect("DELETE FROM pastes WHERE id = '" + *id + "'"); + (void) stmt.ExecuteDirect("COMMIT"); + } + + editor.join(); + drained.clear(); + ::Lightweight::SqlLogger::SetLogger(previousLogger); + + REQUIRE_FALSE(succeeded.has_value()); + REQUIRE(failure); + bool sawNotFound = false; + try { + std::rethrow_exception(failure); + } catch (const pastebin::NotFound&) { + sawNotFound = true; + } catch (...) { + // Falls through to the REQUIRE below with sawNotFound still false. + } + REQUIRE(sawNotFound); +} + +TEST_CASE("A concurrent DeletePaste is not the only way to reach EditPaste's post-CAS \"not editable\" " + "classification, but flipping is_editable underneath a pending edit reaches it too", + "[pastebin][model]") { + // Mirrors the delete case above, but the concurrent writer clears + // is_editable instead of removing the row -- the other branch of the + // same "Zero rows matched: classify why" block (paste_model.cpp). + // is_editable has no ordinary action that flips it after creation (only + // CreatePaste sets it, permanently, in this rung), so this reaches into + // the row directly through the locking connection, the same way the + // Conflict/NotFound tests above simulate "some other write landed" -- + // there is no in-API way to un-edit a paste, which is exactly why this + // classification branch has no other route to it. + class WaitForGuardedUpdate : public ::Lightweight::SqlLogger::Null { + public: + void OnExecute(std::string_view const& query) override { + if (query.find("SET content = ?, syntax = ?") == std::string_view::npos) { + return; + } + { + const std::lock_guard lock{_mutex}; + _reached = true; + } + _cv.notify_all(); + } + + void wait() { + std::unique_lock lock{_mutex}; + _cv.wait(lock, [this] { return _reached; }); + } + + private: + std::mutex _mutex; + std::condition_variable _cv; + bool _reached = false; + }; + + DbFixture fixture; + pastebin::PasteModel seedModel; + + auto create = makeCreate("about to be locked", "text"); + create.editability = pastebin::Editability::Editable; + const auto id = seedModel.execute(create).id; + + const ScopedShortBusyTimeout shortTimeout{5000}; + auto drained = drainPoolIdleMappers(); + pastebin::PasteModel contendedModel; + + ::Lightweight::SqlConnection lockingConnection; + { + ::Lightweight::SqlStatement stmt{lockingConnection}; + (void) stmt.ExecuteDirect("BEGIN IMMEDIATE"); + (void) stmt.ExecuteDirect("UPDATE pastes SET id = id WHERE id = '" + *id + "'"); + } + + WaitForGuardedUpdate probe; + ::Lightweight::SqlLogger& previousLogger = ::Lightweight::SqlLogger::GetLogger(); + ::Lightweight::SqlLogger::SetLogger(probe); + + std::optional succeeded; + std::exception_ptr failure; + std::thread editor{[&] { + try { + succeeded = contendedModel.execute(pastebin::EditPaste{.id = id, .content = "mine", .syntax = "text"}); + } catch (...) { + failure = std::current_exception(); + } + }}; + + probe.wait(); + + { + ::Lightweight::SqlStatement stmt{lockingConnection}; + (void) stmt.ExecuteDirect("UPDATE pastes SET is_editable = 0 WHERE id = '" + *id + "'"); + (void) stmt.ExecuteDirect("COMMIT"); + } + + editor.join(); + drained.clear(); + ::Lightweight::SqlLogger::SetLogger(previousLogger); + + REQUIRE_FALSE(succeeded.has_value()); + REQUIRE(failure); + bool sawValidationError = false; + try { + std::rethrow_exception(failure); + } catch (const pastebin::ValidationError&) { + sawValidationError = true; + } catch (...) { + // Falls through to the REQUIRE below with sawValidationError still false. + } + REQUIRE(sawValidationError); +} + TEST_CASE("DeletePaste removes the paste, and a follow-up GetPaste throws NotFound", "[pastebin][model]") { DbFixture fixture; pastebin::PasteModel model;