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..93cf3fb 100644 --- a/src/MicroPlumberd.Tests/HowTo/PlumberMethodsTests.cs +++ b/src/MicroPlumberd.Tests/HowTo/PlumberMethodsTests.cs @@ -82,12 +82,16 @@ 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, 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"}; 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 +106,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 +269,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..1ae1d91 --- /dev/null +++ b/src/MicroPlumberd.Tests/Unit/LiteDbFactoryTests.cs @@ -0,0 +1,48 @@ +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). 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). 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 +{ + [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() + { + 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); + } +}