From 21af5dd9a727ce935c82a4715b674c2a67d84a18 Mon Sep 17 00:00:00 2001 From: Rafal Maciag Date: Sun, 18 Oct 2026 15:29:25 +0000 Subject: [PATCH 1/4] =?UTF-8?q?test:=20HowToSubscribeRealModel=20counted?= =?UTF-8?q?=20earlier=20runs'=20reservations=20=E2=80=94=20LiteDbFactory?= =?UTF-8?q?=20left=20LiteDB's=20journal=20(test=5FN-log.db)=20behind=20and?= =?UTF-8?q?=20the=20next=20process=20replayed=20it;=20the=20assertion=20db?= =?UTF-8?q?=20is=20now=20in-memory,=20and=20both=20HowTo=20real-model=20ro?= =?UTF-8?q?ws=20count=20their=20own=20reservation=20after=20a=20bounded=20?= =?UTF-8?q?wait?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../Infrastructure/InMemoryAssertionDb.cs | 16 ++++---- .../HowTo/PlumberMethodsTests.cs | 27 +++++++++++--- .../Unit/LiteDbFactoryTests.cs | 37 +++++++++++++++++++ 3 files changed, 65 insertions(+), 15 deletions(-) create mode 100644 src/MicroPlumberd.Tests/Unit/LiteDbFactoryTests.cs diff --git a/src/MicroPlumberd.Tests.App/Infrastructure/InMemoryAssertionDb.cs b/src/MicroPlumberd.Tests.App/Infrastructure/InMemoryAssertionDb.cs index eb307fe..5a87081 100644 --- a/src/MicroPlumberd.Tests.App/Infrastructure/InMemoryAssertionDb.cs +++ b/src/MicroPlumberd.Tests.App/Infrastructure/InMemoryAssertionDb.cs @@ -37,13 +37,11 @@ public void Add(Metadata m, object evt) public class LiteDbFactory { - private static int _id = 0; - - public static LiteDatabase Get() - { - string fileName = $"./test_{Interlocked.Increment(ref _id)}.db"; - if(File.Exists(fileName)) - File.Delete(fileName); - return new LiteDatabase(fileName); - } + /// + /// A fresh, empty, IN-MEMORY LiteDB per call. It was a file, ./test_{n}.db, "fresh" by deleting that file — but LiteDB + /// also writes a journal, ./test_{n}-log.db, which survived and was REPLAYED on the next open: a later process's + /// HowToSubscribeRealModel counted earlier runs' reservations ("found 2", "found 6", 10 — growing run on run, and + /// passing only where the journal had been checkpointed away). Memory leaves nothing behind for the next run. + /// + public static LiteDatabase Get() => new LiteDatabase(new MemoryStream()); } \ No newline at end of file diff --git a/src/MicroPlumberd.Tests/HowTo/PlumberMethodsTests.cs b/src/MicroPlumberd.Tests/HowTo/PlumberMethodsTests.cs index 938ea0a..955342b 100644 --- a/src/MicroPlumberd.Tests/HowTo/PlumberMethodsTests.cs +++ b/src/MicroPlumberd.Tests/HowTo/PlumberMethodsTests.cs @@ -82,12 +82,14 @@ public async Task HowToSubscribeRealModel() await plumber.SubscribeEventHandler(dbModel); var suffixOfStreamWhereOurEventWillBeAppend = Guid.NewGuid(); - var ourLovelyEvent = new TicketReserved() { MovieName = "Golden Eye", RoomName = "101"}; + // THIS row's own reservation: the class-fixture store also holds other rows' TicketReserved, and the model subscribes + // from the beginning — an absolute count over it is order-dependent ("found 2", "found 6"). Count ours, by its own name. + var movie = $"Golden Eye {Guid.NewGuid():N}"; + var ourLovelyEvent = new TicketReserved() { MovieName = movie, RoomName = "101"}; await plumber.AppendEvent(ourLovelyEvent, suffixOfStreamWhereOurEventWillBeAppend); - await Task.Delay(1000); - dbModel.Reservations.Query().Count().Should().Be(1); + (await OwnReservationsFolded(dbModel.Reservations, movie)).Should().Be(1, "our event is folded exactly once"); } [Fact] @@ -102,12 +104,12 @@ public async Task HowToSubscribeRealModelWithHost() .StartAsync(); var suffixOfStreamWhereOurEventWillBeAppend = Guid.NewGuid(); - var ourLovelyEvent = new TicketReserved() { MovieName = "Golden Eye", RoomName = "101" }; + var movie = $"Golden Eye {Guid.NewGuid():N}"; // this row's own reservation (see HowToSubscribeRealModel) + var ourLovelyEvent = new TicketReserved() { MovieName = movie, RoomName = "101" }; await sp.GetRequiredService().AppendEvent(ourLovelyEvent, suffixOfStreamWhereOurEventWillBeAppend); - await Task.Delay(1000); - sp.GetRequiredService().Reservations().Query().Count().Should().Be(1); + (await OwnReservationsFolded(sp.GetRequiredService().Reservations(), movie)).Should().Be(1, "our event is folded exactly once"); } @@ -265,6 +267,19 @@ await plumber.Rehydrate(model, $"ReservationStream-{Guid.NewGuid()}", } + /// + /// Waits — bounded, for the condition the assertion needs: THIS row's reservation folded — instead of a fixed delay, then + /// gives a short settle so a duplicate delivery of the same event would still be counted. Returns how many are folded. + /// + private static async Task OwnReservationsFolded(LiteDB.ILiteCollection reservations, string movie) + { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(15); + while (reservations.Count(x => x.MovieName == movie) == 0 && DateTime.UtcNow < deadline) + await Task.Delay(50); + await Task.Delay(250); + return reservations.Count(x => x.MovieName == movie); + } + public void Dispose() { _host?.Dispose(); diff --git a/src/MicroPlumberd.Tests/Unit/LiteDbFactoryTests.cs b/src/MicroPlumberd.Tests/Unit/LiteDbFactoryTests.cs new file mode 100644 index 0000000..f629428 --- /dev/null +++ b/src/MicroPlumberd.Tests/Unit/LiteDbFactoryTests.cs @@ -0,0 +1,37 @@ +using FluentAssertions; +using LiteDB; +using MicroPlumberd.Tests.App.CinemaDomain; +using MicroPlumberd.Tests.App.Infrastructure; + +namespace MicroPlumberd.Tests.Unit; + +/// +/// HowToSubscribeRealModel counted reservations of EARLIER test runs: LiteDbFactory "freshened" ./test_{n}.db by deleting +/// it, but LiteDB's journal ./test_{n}-log.db survived and was replayed on the next open ("found 2", "found 6", 10 — growing +/// run on run). The property that matters is that an assertion db leaves NOTHING on disk for a later process to pick up. +/// +public class LiteDbFactoryTests +{ + [Fact] + public void AnAssertionDb_LeavesNoFileBehind_ForALaterRunToReplay() + { + var dir = Directory.GetCurrentDirectory(); + var before = Directory.GetFiles(dir, "*.db").ToHashSet(); + + using (var db = LiteDbFactory.Get()) + db.Reservations().Insert(new Reservation { MovieName = "written", RoomName = "1" }); + + Directory.GetFiles(dir, "*.db").Where(f => !before.Contains(f)) + .Should().BeEmpty("a file — or its journal — outlives this process and is replayed into the next run's model"); + } + + [Fact] + public void EachAssertionDb_StartsEmpty() + { + using (var first = LiteDbFactory.Get()) + first.Reservations().Insert(new Reservation { MovieName = "written", RoomName = "1" }); + + using var next = LiteDbFactory.Get(); + next.Reservations().Count().Should().Be(0); + } +} From bde5f229f29c82c5b25104f9f4509052be100daa Mon Sep 17 00:00:00 2001 From: Rafal Maciag Date: Sun, 18 Oct 2026 15:47:15 +0000 Subject: [PATCH 2/4] test: drop the blind 'no new *.db' row (the old factory reused names); say why the own-row count stays (rev-reach 6646ada65) Co-Authored-By: Claude Opus 5.5 --- .../HowTo/PlumberMethodsTests.cs | 6 ++++-- .../Unit/LiteDbFactoryTests.cs | 18 ++++-------------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/src/MicroPlumberd.Tests/HowTo/PlumberMethodsTests.cs b/src/MicroPlumberd.Tests/HowTo/PlumberMethodsTests.cs index 955342b..93cf3fb 100644 --- a/src/MicroPlumberd.Tests/HowTo/PlumberMethodsTests.cs +++ b/src/MicroPlumberd.Tests/HowTo/PlumberMethodsTests.cs @@ -82,8 +82,10 @@ public async Task HowToSubscribeRealModel() await plumber.SubscribeEventHandler(dbModel); var suffixOfStreamWhereOurEventWillBeAppend = Guid.NewGuid(); - // THIS row's own reservation: the class-fixture store also holds other rows' TicketReserved, and the model subscribes - // from the beginning — an absolute count over it is order-dependent ("found 2", "found 6"). Count ours, by its own name. + // THIS row's own reservation, by its own name. The "found 2/6" reds were a LiteDB journal replayed from earlier runs + // (fixed in LiteDbFactory); the class-fixture store contributes nothing TODAY (the absolute-count mutant survives) — + // but the model subscribes from the beginning, so the next row in this class that reserves under a shared name would + // bring an absolute count's order-dependence back. Count ours. var movie = $"Golden Eye {Guid.NewGuid():N}"; var ourLovelyEvent = new TicketReserved() { MovieName = movie, RoomName = "101"}; diff --git a/src/MicroPlumberd.Tests/Unit/LiteDbFactoryTests.cs b/src/MicroPlumberd.Tests/Unit/LiteDbFactoryTests.cs index f629428..36e3a5c 100644 --- a/src/MicroPlumberd.Tests/Unit/LiteDbFactoryTests.cs +++ b/src/MicroPlumberd.Tests/Unit/LiteDbFactoryTests.cs @@ -8,23 +8,13 @@ namespace MicroPlumberd.Tests.Unit; /// /// HowToSubscribeRealModel counted reservations of EARLIER test runs: LiteDbFactory "freshened" ./test_{n}.db by deleting /// it, but LiteDB's journal ./test_{n}-log.db survived and was replayed on the next open ("found 2", "found 6", 10 — growing -/// run on run). The property that matters is that an assertion db leaves NOTHING on disk for a later process to pick up. +/// run on run). The guard is that a new assertion db starts EMPTY whatever an earlier one held: red on the shipped file +/// factory ("found 11" — a leftover journal replayed). (A "no new *.db file" row was dropped, rev-reach 6646ada65: the old +/// factory REUSED test_{n} names, so on any bin that had run once it created no NEW path — blind exactly where the defect +/// lived. The in-memory seam makes "no file" true by construction.) /// public class LiteDbFactoryTests { - [Fact] - public void AnAssertionDb_LeavesNoFileBehind_ForALaterRunToReplay() - { - var dir = Directory.GetCurrentDirectory(); - var before = Directory.GetFiles(dir, "*.db").ToHashSet(); - - using (var db = LiteDbFactory.Get()) - db.Reservations().Insert(new Reservation { MovieName = "written", RoomName = "1" }); - - Directory.GetFiles(dir, "*.db").Where(f => !before.Contains(f)) - .Should().BeEmpty("a file — or its journal — outlives this process and is replayed into the next run's model"); - } - [Fact] public void EachAssertionDb_StartsEmpty() { From 10268cc5febfda96a60f0c046400958ea65eac49 Mon Sep 17 00:00:00 2001 From: Rafal Maciag Date: Sun, 18 Oct 2026 16:20:03 +0000 Subject: [PATCH 3/4] =?UTF-8?q?test:=20restore=20the=20no-file=20row=20in?= =?UTF-8?q?=20created-OR-modified=20form=20=E2=80=94=20red=20on=20a=20CLEA?= =?UTF-8?q?N=20bin=20(CI:=20the=20file=20factory=20creates=20test=5F1.db)?= =?UTF-8?q?=20and=20a=20DIRTY=20one=20(reused=20names,=20modified)=20(rev-?= =?UTF-8?q?reach=205d04e884e)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../Unit/LiteDbFactoryTests.cs | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/MicroPlumberd.Tests/Unit/LiteDbFactoryTests.cs b/src/MicroPlumberd.Tests/Unit/LiteDbFactoryTests.cs index 36e3a5c..d096ff4 100644 --- a/src/MicroPlumberd.Tests/Unit/LiteDbFactoryTests.cs +++ b/src/MicroPlumberd.Tests/Unit/LiteDbFactoryTests.cs @@ -8,13 +8,33 @@ namespace MicroPlumberd.Tests.Unit; /// /// HowToSubscribeRealModel counted reservations of EARLIER test runs: LiteDbFactory "freshened" ./test_{n}.db by deleting /// it, but LiteDB's journal ./test_{n}-log.db survived and was replayed on the next open ("found 2", "found 6", 10 — growing -/// run on run). The guard is that a new assertion db starts EMPTY whatever an earlier one held: red on the shipped file -/// factory ("found 11" — a leftover journal replayed). (A "no new *.db file" row was dropped, rev-reach 6646ada65: the old -/// factory REUSED test_{n} names, so on any bin that had run once it created no NEW path — blind exactly where the defect -/// lived. The in-memory seam makes "no file" true by construction.) +/// run on run). Two rows, because a file factory shows differently on the two kinds of bin (rev-reach): +/// - CLEAN bin (CI, always): the file factory CREATES test_1.db — the no-file row is red; StartsEmpty is green (nothing to replay). +/// - DIRTY bin (a dev's second run): it re-opens existing names — no new path, but the files are MODIFIED (the no-file row, +/// by length + write time, is red) and a leftover journal is replayed (StartsEmpty is red, "found 11"). +/// A path-set-only form of the no-file row was blind on the dirty bin (names are reused); this one sees creation AND change. /// public class LiteDbFactoryTests { + [Fact] + public void AnAssertionDb_CreatesOrTouchesNoFile_ForALaterRunToReplay() + { + var dir = Directory.GetCurrentDirectory(); + var before = Snapshot(dir); + + using (var db = LiteDbFactory.Get()) + db.Reservations().Insert(new Reservation { MovieName = "written", RoomName = "1" }); + + var after = Snapshot(dir); + var created = after.Keys.Except(before.Keys).ToList(); + var modified = after.Where(f => before.TryGetValue(f.Key, out var was) && was != f.Value).Select(f => f.Key).ToList(); + created.Concat(modified).Should().BeEmpty( + "an assertion db must leave nothing on disk — a file, or its journal, is replayed into the next run's model"); + } + + private static Dictionary Snapshot(string dir) => + Directory.GetFiles(dir, "*.db").ToDictionary(f => f, f => { var i = new FileInfo(f); return (i.Length, i.LastWriteTimeUtc); }); + [Fact] public void EachAssertionDb_StartsEmpty() { From c00e3c1c7425b229fd9181aad6ca00656f0588a4 Mon Sep 17 00:00:00 2001 From: Rafal Maciag Date: Sun, 18 Oct 2026 16:20:41 +0000 Subject: [PATCH 4/4] test: say when StartsEmpty reds (an un-checkpointed journal only); the no-file row guards both bins Co-Authored-By: Claude Opus 5.5 --- src/MicroPlumberd.Tests/Unit/LiteDbFactoryTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/MicroPlumberd.Tests/Unit/LiteDbFactoryTests.cs b/src/MicroPlumberd.Tests/Unit/LiteDbFactoryTests.cs index d096ff4..1ae1d91 100644 --- a/src/MicroPlumberd.Tests/Unit/LiteDbFactoryTests.cs +++ b/src/MicroPlumberd.Tests/Unit/LiteDbFactoryTests.cs @@ -11,7 +11,8 @@ namespace MicroPlumberd.Tests.Unit; /// run on run). Two rows, because a file factory shows differently on the two kinds of bin (rev-reach): /// - CLEAN bin (CI, always): the file factory CREATES test_1.db — the no-file row is red; StartsEmpty is green (nothing to replay). /// - DIRTY bin (a dev's second run): it re-opens existing names — no new path, but the files are MODIFIED (the no-file row, -/// by length + write time, is red) and a leftover journal is replayed (StartsEmpty is red, "found 11"). +/// by length + write time, is red). StartsEmpty is red only where a NOT-yet-checkpointed journal was left behind (as the +/// HowTo rows left one: "found 11"); in this class's own runs it stayed green. The no-file row is the guard on both bins. /// A path-set-only form of the no-file row was blind on the dirty bin (names are reused); this one sees creation AND change. /// public class LiteDbFactoryTests