diff --git a/Directory.Packages.props b/Directory.Packages.props index 26c8128..3e1dc8b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,10 +4,10 @@ - - - - + + + + @@ -19,23 +19,25 @@ + PINNED AT 6.16.0 to match the CritterWatch line installed below. CritterWatch 1.0.0-beta.1 is + compiled against WolverineFx(.Marten) 6.16.0; running a HIGHER WolverineFx than the version + CritterWatch targets throws a startup TypeLoadException in Wolverine.Marten's projection-distribution + internals (EventSubscriptionAgentFamily.TryRebuildRegisteredProjectionAsync). There is only one + Wolverine.Marten per process, so the console and the four services must share the exact version + CritterWatch was built against. Bump these ONLY in lockstep with a CritterWatch release + (CritterWatch + Wolverine.CritterWatch + WolverineFx.* move together, never apart) — diagnosed + 2026-06-24 (CW alpha.3 / 6.12.0), updated 2026-06-30 (CW beta.1 / 6.16.0); see the dependency-update + runbook. This supersedes the prior "held at 6.12.0, no 6.13.x" pin: beta.1 ships against GA Wolverine + 6.16.0, so Dependabot's stranded 6.13.x bumps (#94/#99) are now obsolete. --> - - - - - - - - + + + + + + + + + - - + + - + bump CritterWatch has not yet taken. Re-verified unaddressed on 1.0.0-beta.1 (2026-06-30; was + 2.5.301 on alpha.3). Suppress until an upstream CritterWatch release moves to MessagePack 3.x. --> diff --git a/docs/retrospectives/chore/004-critterwatch-next-release-upgrade.md b/docs/retrospectives/chore/004-critterwatch-next-release-upgrade.md new file mode 100644 index 0000000..cf2effd --- /dev/null +++ b/docs/retrospectives/chore/004-critterwatch-next-release-upgrade.md @@ -0,0 +1,54 @@ +--- +retrospective: 004 +kind: chore +prompt: docs/prompts/chore/004-critterwatch-next-release-upgrade.md +deliverable: Directory.Packages.props (CritterWatch + Wolverine.CritterWatch alpha.3 → beta.1; WolverineFx.* 6.12.0 → 6.16.0; coupling + MessagePack-CVE comment rewrites); src/CritterMart.Orders/Ordering/OrdersAwaitingPayment.cs + Shopping/CartsAwaitingActivity.cs (stateless projections + read-shaped row records); src/CritterMart.Orders/Features/PlaceOrder.cs + AddToCart.cs (read-time deadline endpoints); src/CritterMart.Orders/Program.cs (generic projection registration + comments); tests/CritterMart.Orders.Tests/{OrdersAwaitingPaymentProjectionTests,CartsAwaitingActivityProjectionTests,PaymentTimeoutTests,CartAbandonmentTests}.cs; docs/retrospectives/chore/004-critterwatch-next-release-upgrade.md (this file) +date: 2026-06-30 +mode: solo maintenance; CritterWatch coupling per Directory.Packages.props + the dependency-update runbook; Marten-9.12 compat fix added when the bump surfaced a projection regression +session-runner: Claude (Opus 4.8) +--- + +# Retrospective — Chore 004: CritterWatch next-release upgrade (→ beta.1 / Wolverine 6.16.0) + +## Outcome summary + +CritterWatch moved **`1.0.0-alpha.3` → `1.0.0-beta.1`** and, in lockstep, the whole Critter Stack line moved **WolverineFx.* `6.12.0` → `6.16.0`** (Marten `9.12.0` + JasperFx.Events `2.18.1` pulled transitively). beta.1's nuspec was read first-hand — it targets **GA WolverineFx 6.16.0**, not a prerelease alpha line, which is exactly the condition Erik set for taking the upgrade ("revisit when a GA-Wolverine CW ships"; alpha.4's prerelease-6.14 line was the reason alpha.4 was skipped). This **resolves the standing "DO NOT merge Wolverine 6.13.x" warning** the sanctioned way — past 6.13.x straight to 6.16.0, atomically with CritterWatch — so the stranded Dependabot 6.13.x bumps are obsolete. The MessagePack CVE (`GHSA-hv8m-jj95-wg3x`) is **still unresolved on beta.1** (transitive MessagePack `2.5.302` < the `3.0.214` fix), so the audit suppression stays, comment re-verified-dated. + +**The bump was not zero-code.** Marten 9.12 surfaced a projection regression: an inline `SingleStreamProjection` registered as an *instance* (`Projections.Add(new T(arg), …)`) is re-materialized by the runtime, so **constructor-injected state is dropped** — the two "awaiting-list" Bruun todo-list projections read their captured timeout back as `default(TimeSpan)`, so the view's visible `Deadline` silently became "now". Fixed by making both projections **stateless fact-recorders** (store `PlacedAt` / `LastActivityAt`) and moving the deadline policy to the **read side** (the GET endpoints inject the existing `PaymentDeadline` / `CartActivityDeadline` singletons and compute `Deadline = timestamp + duration`). Impact was cosmetic-only — the cancellation/abandonment decisions run off the scheduled messages and the Order/Cart streams, never this view. + +Verified three ways: full solution build (0 warnings / 0 errors); **149/149 tests green** (the two integration failures the bump introduced are fixed, nothing else regressed); and a **full live boot** on the real Aspire stack — all three services + the **CritterWatch console came up clean** (PID confirmed; the coupling crash is a startup `TypeLoadException`, so a healthy boot is the proof), and the refactored `GET /orders/awaiting-payment` returned a deadline ~7 min in the future (the `Orders__PaymentTimeout` knob), not "now". + +## What worked + +- **Read the nuspec, don't guess the floor.** `curl`-ing `wolverine.critterwatch/1.0.0-beta.1/*.nuspec` showed `WolverineFx 6.16.0` (and `WolverineFx.Marten/Http.Marten/SignalR 6.16.0`, `Marten 9.12.0`) directly — turning "is beta.1 safe to take?" from a judgment call into a fact (GA vs prerelease), which is the whole gating question. +- **Empirical root-causing beat speculation.** The failure (`Deadline ≈ now` despite a 10-min config) had two plausible stories (TimeProvider-sourced `e.Timestamp` vs dropped timeout). The unit test passing (direct `new Projection(10min)`) while the integration test failed, plus a one-line hardcode probe (`Add(10min)` → green), isolated it to "Marten doesn't preserve the instance's ctor state" without needing the changelog (ctx7 was quota-blocked all session). +- **The fix is a better design, not just a workaround.** Stateless projection + read-time policy is the more idiomatic CQRS split (the view stores facts; the endpoint applies config). The bump forced the cleaner shape. +- **Live boot earned its keep.** Both the CW coupling (startup `TypeLoadException`) and the projection fix (a live deadline value) are runtime-only truths a green test summary wouldn't fully prove. + +## What was harder than expected + +- **The Marten 9.12 instance-projection-state change is undocumented-to-us and subtle.** It only manifests through the runtime projection path, so it slipped past the build and the unit tests — only the two integration tests that drive the projection through Marten caught it. Diagnosis took several iterations of arithmetic before the hardcode probe settled it. +- **ctx7 monthly quota was exhausted** (as the prior session flagged). Fell back to the official Aspire/Marten release notes via WebFetch and to first-hand nuspec + empirical probes — no training-data guessing, per the docs rule. + +## Methodology refinements that emerged + +1. **A Critter Stack dependency upgrade needs BOTH a full `dotnet test` and a live boot — they catch different failure classes.** The CW coupling is a startup crash (live boot only); the Marten projection-state change is a runtime behavior shift (integration tests only). A build + unit tests would have shipped both bugs. +2. **Prefer stateless projections; put per-projection config on the read side.** Constructor-injected projection state is fragile across Marten versions (it re-materializes projections). When a projection needs config, store the raw fact and apply the policy where DI lives (the endpoint), not in the projection instance. +3. **A "version bump" chore can legitimately grow a code fix when the bump breaks behavior** — bundling the fix is correct (the PR can't go green without it); the retro records the divergence from the prompt's "Directory.Packages.props only" plan. + +## Outstanding items / next-session inputs + +1. **MessagePack CVE still suppressed** — beta.1 transitively pulls MessagePack `2.5.302` (< `3.0.214`). Re-check on the next CritterWatch release; drop the `` when it moves to MessagePack 3.x. +2. **Consider reporting the Marten 9.12 instance-projection-state behavior upstream** to JasperFx (instance-registered inline projections losing constructor state). The CritterMart fix doesn't need it, but other Critter Stack apps using `Projections.Add(new T(arg), …)` would hit the same. +3. **Dependabot cleanup:** #104 (critter-stack group), #108 (CritterWatch alpha.4), #110 (Wolverine.CritterWatch alpha.4) are superseded by this PR — closed. #105 (aspire group) is superseded by the separate Aspire PR #116. #106 (otel), #107 (test-stack), #109 (Swashbuckle) remain as separate concerns. +4. **Post-talk:** the four AppHost demo knobs still want deleting after the talk (unchanged by this PR). + +## Spec-delta — landed? + +**None named, and none shipped** — this chore changes no domain behavior. The `Directory.Packages.props` coupling comment is the durable operational record of the new CW↔Wolverine pin. The read-time deadline refactor is an internal restructuring that **preserves the HTTP contract** (the awaiting-list responses still carry `Deadline`); no narrative, workshop, or OpenSpec capability changed, and the unchanged integration assertions (deadline in the future; row vanishes on terminal events) are the regression guard. The retro forward-confirms the named-none. + +## Process notes + +- One PR bundles `chore:` (the version coupling) and the Marten-9.12 compatibility fix it necessitated. +- Branch: `chore/critterwatch-beta1`. +- The upgrade crosses the previously-held 6.12.0 Wolverine pin to 6.16.0 — Erik's merge is the conscious blessing of that line move; the coupling comment + this retro record why it is now safe (CW beta.1 ships against GA 6.16.0). diff --git a/src/CritterMart.AppHost/CritterMart.AppHost.csproj b/src/CritterMart.AppHost/CritterMart.AppHost.csproj index 9def52f..c2973cd 100644 --- a/src/CritterMart.AppHost/CritterMart.AppHost.csproj +++ b/src/CritterMart.AppHost/CritterMart.AppHost.csproj @@ -1,6 +1,6 @@ - + Exe diff --git a/src/CritterMart.AppHost/Program.cs b/src/CritterMart.AppHost/Program.cs index 885d899..b8dd9c1 100644 --- a/src/CritterMart.AppHost/Program.cs +++ b/src/CritterMart.AppHost/Program.cs @@ -8,7 +8,7 @@ // Only Postgres + RabbitMQ are containers here — the three services and the seeder are dotnet // processes and the storefront is a node process, so they never appear in Docker Desktop's // container list. WithContainerRuntimeArgs passes the --label straight through to the runtime. -// Mirrors CritterBids' AppHost (same Aspire 13.4.3). +// Mirrors CritterBids' AppHost (both on the Aspire 13.4 line). const string dockerProject = "crittermart"; // Shared PostgreSQL with one database; each service uses its own schema (ADR 002). diff --git a/src/CritterMart.Orders/Features/AddToCart.cs b/src/CritterMart.Orders/Features/AddToCart.cs index f77432f..c559177 100644 --- a/src/CritterMart.Orders/Features/AddToCart.cs +++ b/src/CritterMart.Orders/Features/AddToCart.cs @@ -131,11 +131,16 @@ public static async Task Get(string cartId, IQuerySession session) // conditional deletes). A literal route segment, so it wins over /carts/{cartId} by ASP.NET // Core route precedence. [WolverineGet("/carts/awaiting-activity")] - public static async Task GetAwaitingActivity(IQuerySession session) + public static async Task GetAwaitingActivity(IQuerySession session, CartActivityDeadline deadline) { + // The view stores LastActivityAt; the visible Deadline is LastActivityAt + the configured window, + // applied here on read (the projection is stateless — see CartsAwaitingActivity remarks). Ordering + // by LastActivityAt equals ordering by Deadline since the window is constant across rows. var rows = await session.Query() - .OrderBy(x => x.Deadline) + .OrderBy(x => x.LastActivityAt) .ToListAsync(); - return Results.Ok(rows); + var result = rows.Select(r => + new CartAwaitingActivityRow(r.Id, r.CustomerId, r.LastActivityAt.Add(deadline.Duration))); + return Results.Ok(result); } } diff --git a/src/CritterMart.Orders/Features/PlaceOrder.cs b/src/CritterMart.Orders/Features/PlaceOrder.cs index e9fe068..7e330d5 100644 --- a/src/CritterMart.Orders/Features/PlaceOrder.cs +++ b/src/CritterMart.Orders/Features/PlaceOrder.cs @@ -117,11 +117,16 @@ public static async Task Get(string orderId, IQuerySession session) // live set of orders the payment-timeout automation is watching. A literal route segment, so // it wins over /orders/{orderId} by ASP.NET Core route precedence. [WolverineGet("/orders/awaiting-payment")] - public static async Task GetAwaitingPayment(IQuerySession session) + public static async Task GetAwaitingPayment(IQuerySession session, PaymentDeadline deadline) { + // The view stores PlacedAt; the visible Deadline is PlacedAt + the configured timeout, applied + // here on read (the projection is stateless — see OrdersAwaitingPayment remarks). Ordering by + // PlacedAt equals ordering by Deadline since the timeout is constant across rows. var rows = await session.Query() - .OrderBy(x => x.Deadline) + .OrderBy(x => x.PlacedAt) .ToListAsync(); - return Results.Ok(rows); + var result = rows.Select(r => + new OrderAwaitingPaymentRow(r.Id, r.CustomerId, r.Total, r.PlacedAt.Add(deadline.Duration))); + return Results.Ok(result); } } diff --git a/src/CritterMart.Orders/Ordering/OrdersAwaitingPayment.cs b/src/CritterMart.Orders/Ordering/OrdersAwaitingPayment.cs index c07bbaa..0b60336 100644 --- a/src/CritterMart.Orders/Ordering/OrdersAwaitingPayment.cs +++ b/src/CritterMart.Orders/Ordering/OrdersAwaitingPayment.cs @@ -8,32 +8,40 @@ namespace CritterMart.Orders.Ordering; // when OrderPlaced folds and DELETED when any terminal event folds — a Marten conditional delete. // The timeout handler does NOT read this view (the Order stream is the single source of truth for // the cancellation decision; design.md Decision 3) — this is the observable face of the automation. +// +// The view stores PlacedAt (a fact: the OrderPlaced append time); the visible Deadline is computed at +// READ time in the endpoint as PlacedAt + the configured payment timeout. The deadline policy lives on +// the read side, NOT in the projection, on purpose: under Marten 9.x an inline projection registered as +// an instance is re-materialized by the runtime, so constructor-injected state (a captured TimeSpan) is +// lost and reads back as default(TimeSpan). Keeping the projection STATELESS sidesteps that — the +// timeout is config/DI, which the endpoint has and the projection does not. (Surfaced by the CW/Marten +// 9.12 upgrade, chore/004: the prior ctor-injected deadline silently became "now".) public class OrderAwaitingPayment { public string Id { get; set; } = string.Empty; // the orderId (stream key) public string CustomerId { get; set; } = string.Empty; public decimal Total { get; set; } - public DateTimeOffset Deadline { get; set; } // when OrderPaymentTimeout will fire + public DateTimeOffset PlacedAt { get; set; } // OrderPlaced append time; Deadline = PlacedAt + timeout (on read) } +// The read-shaped row returned by GET /orders/awaiting-payment: the stored PlacedAt projected forward by +// the configured payment timeout into the visible Deadline. Distinct from the stored view so the +// projection stays a pure fact-recorder and the policy (the timeout) is applied once, on read. +public record OrderAwaitingPaymentRow(string Id, string CustomerId, decimal Total, DateTimeOffset Deadline); + // Inline single-stream projection (ADR 008) over the same Order stream that OrderStatusView folds — // two projections, one stream: projection shape is a per-projection choice, and this one's is -// "a row exists only while the order is non-terminal". The configured PaymentDeadline duration is -// constructor-injected (instance registration in Program.cs) so the row's visible deadline matches -// the schedule PlaceOrder actually set. +// "a row exists only while the order is non-terminal". STATELESS by design (see the view remarks) — +// it records the placement timestamp; the endpoint adds the configured timeout on read. public partial class OrdersAwaitingPaymentProjection : SingleStreamProjection { - private readonly TimeSpan _paymentTimeout; - - public OrdersAwaitingPaymentProjection(TimeSpan paymentTimeout) => _paymentTimeout = paymentTimeout; - // The IEvent wrapper exposes the event's append timestamp (Marten's using-metadata convention): - // the deadline is placement time plus the configured timeout. + // store it raw; the deadline is computed on read from the configured timeout. public void Apply(IEvent e, OrderAwaitingPayment view) { view.CustomerId = e.Data.CustomerId; view.Total = e.Data.Total; - view.Deadline = e.Timestamp.Add(_paymentTimeout); + view.PlacedAt = e.Timestamp; } // Conditional deletes (Marten 9 ShouldDelete method convention): any terminal event removes the diff --git a/src/CritterMart.Orders/Program.cs b/src/CritterMart.Orders/Program.cs index 2676ea3..e08733d 100644 --- a/src/CritterMart.Orders/Program.cs +++ b/src/CritterMart.Orders/Program.cs @@ -22,7 +22,7 @@ // The payment deadline (slice 4.7): how long a placed order may sit non-terminal before the // scheduled OrderPaymentTimeout cancels it. Config-driven so the Aspire demo can shorten it // (e.g. 30s) without recompiling; one value feeds both the PlaceOrder schedule and the -// OrdersAwaitingPayment projection's visible deadline. +// awaiting-payment endpoint's visible deadline (computed on read — the projection is stateless). var paymentTimeout = builder.Configuration.GetValue("Orders:PaymentTimeout") ?? PaymentDeadline.Default; builder.Services.AddSingleton(new PaymentDeadline(paymentTimeout)); @@ -30,7 +30,7 @@ // The cart inactivity window (slice 3.4): how long a cart may sit without activity before the // scheduled CartActivityTimeout abandons it. Config-driven like the payment deadline above; one // value feeds the AddToCart schedule, the abandonment handler's fire-and-check decision, and the -// CartsAwaitingActivity projection's visible deadline. +// awaiting-activity endpoint's visible deadline (computed on read — the projection is stateless). var cartActivityTimeout = builder.Configuration.GetValue("Orders:CartActivityTimeout") ?? CartActivityDeadline.Default; builder.Services.AddSingleton(new CartActivityDeadline(cartActivityTimeout)); @@ -71,13 +71,14 @@ opts.Projections.Snapshot(SnapshotLifecycle.Inline); // The Bruun todo-list (slice 4.7): a second inline projection over the same Order stream. - // Instance-registered (not generic) because the projection carries the configured payment - // timeout — the row's visible deadline must match the schedule PlaceOrder actually sets. - opts.Projections.Add(new OrdersAwaitingPaymentProjection(paymentTimeout), ProjectionLifecycle.Inline); - - // The cart-side Bruun todo-list (slice 3.4): the same shape over the Cart stream — - // instance-registered with the configured inactivity window. - opts.Projections.Add(new CartsAwaitingActivityProjection(cartActivityTimeout), ProjectionLifecycle.Inline); + // STATELESS + generic-registered: it records the placement timestamp only; the awaiting-payment + // endpoint adds the configured timeout on read. (Marten 9.x re-materializes instance-registered + // projections, dropping constructor-injected state — chore/004; see OrdersAwaitingPayment remarks.) + opts.Projections.Add(ProjectionLifecycle.Inline); + + // The cart-side Bruun todo-list (slice 3.4): the same stateless shape over the Cart stream — + // records the latest activity timestamp; the awaiting-activity endpoint adds the window on read. + opts.Projections.Add(ProjectionLifecycle.Inline); // The round-one ASYNC projection teaser (ADR 008, slice 3.4): registered with the async // lifecycle but NO AddAsyncDaemon anywhere — the daily abandonment report stays empty diff --git a/src/CritterMart.Orders/Shopping/CartsAwaitingActivity.cs b/src/CritterMart.Orders/Shopping/CartsAwaitingActivity.cs index c3be13d..f625b8f 100644 --- a/src/CritterMart.Orders/Shopping/CartsAwaitingActivity.cs +++ b/src/CritterMart.Orders/Shopping/CartsAwaitingActivity.cs @@ -5,46 +5,52 @@ namespace CritterMart.Orders.Shopping; // The cart-side Bruun todo-list (Workshop 001 § 5 / § 7: CartsAwaitingActivity*): one row per // open cart, readable at GET /carts/awaiting-activity. The row is created when CartCreated folds, -// its deadline advances as activity events fold, and it is DELETED when either terminal event -// folds — a Marten conditional delete. The abandonment handler does NOT read this view (the Cart -// stream is the single source of truth for the abandonment decision; design.md Decision 2) — +// its activity timestamp advances as activity events fold, and it is DELETED when either terminal +// event folds — a Marten conditional delete. The abandonment handler does NOT read this view (the +// Cart stream is the single source of truth for the abandonment decision; design.md Decision 2) — // this is the observable face of the automation, mirroring Order/OrdersAwaitingPayment. +// +// The view stores LastActivityAt (a fact: the most recent activity event's append time); the visible +// Deadline is computed at READ time as LastActivityAt + the configured inactivity window. The policy +// lives on the read side so the projection stays STATELESS — under Marten 9.x an instance-registered +// inline projection is re-materialized and constructor-injected state (a captured TimeSpan) is lost +// (chore/004, the CW/Marten 9.12 upgrade). Mirrors OrdersAwaitingPayment. public class CartAwaitingActivity { public string Id { get; set; } = string.Empty; // the cartId (stream key) public string CustomerId { get; set; } = string.Empty; - public DateTimeOffset Deadline { get; set; } // when the cart becomes abandonable + public DateTimeOffset LastActivityAt { get; set; } // most recent activity time; Deadline = LastActivityAt + window (on read) } +// The read-shaped row returned by GET /carts/awaiting-activity: the stored LastActivityAt projected +// forward by the configured inactivity window into the visible Deadline. Distinct from the stored view +// so the projection stays a pure fact-recorder and the window is applied once, on read. +public record CartAwaitingActivityRow(string Id, string CustomerId, DateTimeOffset Deadline); + // Inline single-stream projection (ADR 008) over the same Cart stream that CartView folds — two // inline projections plus one async projection (CartAbandonmentReport), all over the same events: -// projection shape and lifecycle are per-projection choices, not per-event ones. The configured -// window is constructor-injected (instance registration in Program.cs) so the row's visible -// deadline matches what the abandonment handler will actually decide. +// projection shape and lifecycle are per-projection choices, not per-event ones. STATELESS by design +// (see the view remarks) — it records the latest activity timestamp; the endpoint adds the window on read. public partial class CartsAwaitingActivityProjection : SingleStreamProjection { - private readonly TimeSpan _activityWindow; - - public CartsAwaitingActivityProjection(TimeSpan activityWindow) => _activityWindow = activityWindow; - // The IEvent wrapper exposes the event's append timestamp (Marten's using-metadata convention): - // the deadline is the activity time plus the configured window. + // store it raw; the deadline is computed on read from the configured inactivity window. public void Apply(IEvent e, CartAwaitingActivity view) { view.CustomerId = e.Data.CustomerId; - view.Deadline = e.Timestamp.Add(_activityWindow); + view.LastActivityAt = e.Timestamp; } - // Every activity event pushes the deadline out — the visible mirror of fire-and-check: the + // Every activity event pushes the activity timestamp out — the visible mirror of fire-and-check: the // pending scheduled message is NOT moved (Wolverine can't); the fired handler re-aims instead. public void Apply(IEvent e, CartAwaitingActivity view) => - view.Deadline = e.Timestamp.Add(_activityWindow); + view.LastActivityAt = e.Timestamp; public void Apply(IEvent e, CartAwaitingActivity view) => - view.Deadline = e.Timestamp.Add(_activityWindow); + view.LastActivityAt = e.Timestamp; public void Apply(IEvent e, CartAwaitingActivity view) => - view.Deadline = e.Timestamp.Add(_activityWindow); + view.LastActivityAt = e.Timestamp; // Conditional deletes (Marten 9 ShouldDelete method convention): either terminal event — // checkout (slice 4.1) or abandonment (slice 3.4) — removes the row from the todo-list. diff --git a/tests/CritterMart.Orders.Tests/CartAbandonmentTests.cs b/tests/CritterMart.Orders.Tests/CartAbandonmentTests.cs index b8aff5e..8ede53d 100644 --- a/tests/CritterMart.Orders.Tests/CartAbandonmentTests.cs +++ b/tests/CritterMart.Orders.Tests/CartAbandonmentTests.cs @@ -201,7 +201,7 @@ public async Task the_awaiting_activity_list_shows_open_carts_and_drops_closed_o _.Get.Url("/carts/awaiting-activity"); _.StatusCodeShouldBe(200); }); - var rows = listed.ReadAsJson>()!; + var rows = listed.ReadAsJson>()!; var row = rows.ShouldHaveSingleItem(); row.Id.ShouldBe(cartId); row.CustomerId.ShouldBe("customer-V"); @@ -216,7 +216,7 @@ public async Task the_awaiting_activity_list_shows_open_carts_and_drops_closed_o _.Get.Url("/carts/awaiting-activity"); _.StatusCodeShouldBe(200); }); - afterAbandon.ReadAsJson>()!.ShouldBeEmpty(); + afterAbandon.ReadAsJson>()!.ShouldBeEmpty(); } // ADR 008's teaching beat, proven: the async CartAbandonmentReport is EMPTY until an diff --git a/tests/CritterMart.Orders.Tests/CartsAwaitingActivityProjectionTests.cs b/tests/CritterMart.Orders.Tests/CartsAwaitingActivityProjectionTests.cs index 55c69a0..3543634 100644 --- a/tests/CritterMart.Orders.Tests/CartsAwaitingActivityProjectionTests.cs +++ b/tests/CritterMart.Orders.Tests/CartsAwaitingActivityProjectionTests.cs @@ -6,49 +6,50 @@ namespace CritterMart.Orders.Tests; // Pure-function unit tests for the CartsAwaitingActivity todo-list fold (slice 3.4) — no database, -// no mocks, no container. Untagged, so they run in the CI `Category!=Integration` job alongside -// the other projection fold tests. Mirrors OrdersAwaitingPaymentProjectionTests: Apply methods -// take IEvent wrappers (Marten's using-metadata convention) so the visible deadline is computed -// from each activity event's append timestamp; the tests construct the wrappers with Event. +// no mocks, no container. Untagged, so they run in the CI `Category!=Integration` job alongside the +// other projection fold tests. Mirrors OrdersAwaitingPaymentProjectionTests: Apply methods take +// IEvent wrappers (Marten's using-metadata convention) so each activity event's append timestamp is +// recorded; the tests construct the wrappers with Event. The projection is STATELESS — the visible +// deadline (LastActivityAt + window) is applied on read in the endpoint (chore/004). public class CartsAwaitingActivityProjectionTests { private static readonly ProductSnapshot CosmicCritterPlush = new("Cosmic Critter Plush", 24.99m); - private static readonly TimeSpan Window = TimeSpan.FromHours(2); private static readonly DateTimeOffset T0 = new(2026, 6, 2, 12, 0, 0, TimeSpan.Zero); - private readonly CartsAwaitingActivityProjection _projection = new(Window); + private readonly CartsAwaitingActivityProjection _projection = new(); private static Event At(T data, DateTimeOffset timestamp) where T : notnull => new(data) { Timestamp = timestamp }; - // CartCreated creates the row: the cart joins the todo-list with a deadline of creation - // time plus the configured inactivity window. + // CartCreated creates the row: the cart joins the todo-list recording its creation timestamp + // (the read endpoint adds the configured inactivity window to produce the visible deadline). [Fact] - public void cart_created_creates_a_row_with_the_deadline() + public void cart_created_records_the_activity_timestamp() { var view = new CartAwaitingActivity(); _projection.Apply(At(new CartCreated("cart-1", "customer-X"), T0), view); view.CustomerId.ShouldBe("customer-X"); - view.Deadline.ShouldBe(T0.Add(Window)); + view.LastActivityAt.ShouldBe(T0); } - // Every activity event pushes the deadline out — the visible mirror of fire-and-check. + // Every activity event advances the recorded activity timestamp — the visible mirror of + // fire-and-check (the read endpoint projects it forward by the window). [Fact] - public void cart_activity_advances_the_deadline() + public void cart_activity_advances_the_activity_timestamp() { var view = new CartAwaitingActivity(); _projection.Apply(At(new CartCreated("cart-1", "customer-X"), T0), view); _projection.Apply(At(new CartItemAdded("crit-001", 1, CosmicCritterPlush), T0.AddMinutes(10)), view); - view.Deadline.ShouldBe(T0.AddMinutes(10).Add(Window)); + view.LastActivityAt.ShouldBe(T0.AddMinutes(10)); _projection.Apply(At(new CartItemQuantityChanged("crit-001", 3), T0.AddMinutes(30)), view); - view.Deadline.ShouldBe(T0.AddMinutes(30).Add(Window)); + view.LastActivityAt.ShouldBe(T0.AddMinutes(30)); _projection.Apply(At(new CartItemRemoved("crit-001"), T0.AddMinutes(55)), view); - view.Deadline.ShouldBe(T0.AddMinutes(55).Add(Window)); + view.LastActivityAt.ShouldBe(T0.AddMinutes(55)); } // The conditional delete (Marten ShouldDelete convention): a checked-out cart leaves the list. diff --git a/tests/CritterMart.Orders.Tests/OrdersAwaitingPaymentProjectionTests.cs b/tests/CritterMart.Orders.Tests/OrdersAwaitingPaymentProjectionTests.cs index 412a947..d4ff61e 100644 --- a/tests/CritterMart.Orders.Tests/OrdersAwaitingPaymentProjectionTests.cs +++ b/tests/CritterMart.Orders.Tests/OrdersAwaitingPaymentProjectionTests.cs @@ -6,21 +6,21 @@ namespace CritterMart.Orders.Tests; // Pure-function unit tests for the OrdersAwaitingPayment todo-list fold (slice 4.7) — no database, -// no mocks, no container. Untagged, so they run in the CI `Category!=Integration` job alongside -// the other projection fold tests. The Apply takes an IEvent wrapper (Marten's -// using-metadata convention) so the deadline can be computed from the event's append timestamp; -// the test constructs the wrapper directly with Event. +// no mocks, no container. Untagged, so they run in the CI `Category!=Integration` job alongside the +// other projection fold tests. The Apply takes an IEvent wrapper (Marten's using-metadata +// convention) so it can record the event's append timestamp; the test constructs the wrapper directly +// with Event. The projection is STATELESS — the visible deadline (PlacedAt + timeout) is applied on +// read in the endpoint, not here (chore/004; see OrdersAwaitingPayment remarks). public class OrdersAwaitingPaymentProjectionTests { private static readonly OrderLine Plush = new("crit-001", 2, "Cosmic Critter Plush", 24.99m); - private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(10); - private readonly OrdersAwaitingPaymentProjection _projection = new(Timeout); + private readonly OrdersAwaitingPaymentProjection _projection = new(); - // OrderPlaced creates the row: the order joins the todo-list with a deadline of placement - // time plus the configured payment timeout. + // OrderPlaced creates the row: the order joins the todo-list recording its placement timestamp + // (the read endpoint adds the configured timeout to produce the visible deadline). [Fact] - public void order_placed_creates_a_row_with_the_deadline() + public void order_placed_creates_a_row_recording_placed_at() { var view = new OrderAwaitingPayment(); var placedAt = new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero); @@ -33,7 +33,7 @@ public void order_placed_creates_a_row_with_the_deadline() view.CustomerId.ShouldBe("customer-X"); view.Total.ShouldBe(49.98m); - view.Deadline.ShouldBe(placedAt.Add(Timeout)); + view.PlacedAt.ShouldBe(placedAt); } // The conditional delete (Marten ShouldDelete convention): a confirmed order leaves the list. diff --git a/tests/CritterMart.Orders.Tests/PaymentTimeoutTests.cs b/tests/CritterMart.Orders.Tests/PaymentTimeoutTests.cs index 7e52520..2634752 100644 --- a/tests/CritterMart.Orders.Tests/PaymentTimeoutTests.cs +++ b/tests/CritterMart.Orders.Tests/PaymentTimeoutTests.cs @@ -200,7 +200,7 @@ await _fixture.Host.Scenario(_ => _.Get.Url("/orders/awaiting-payment"); _.StatusCodeShouldBe(200); }); - var rows = listed.ReadAsJson>()!; + var rows = listed.ReadAsJson>()!; var row = rows.ShouldHaveSingleItem(); row.Id.ShouldBe(orderId); row.Total.ShouldBe(Total); @@ -214,6 +214,6 @@ await _fixture.Host.Scenario(_ => _.Get.Url("/orders/awaiting-payment"); _.StatusCodeShouldBe(200); }); - afterSettle.ReadAsJson>()!.ShouldBeEmpty(); + afterSettle.ReadAsJson>()!.ShouldBeEmpty(); } }