From fb343a66de6a3ad855a879141bb3367d12e4e240 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Tue, 15 Sep 2026 13:51:40 +0100 Subject: [PATCH] Initial: AI code exploration exercise --- .gitignore | 14 + README.md | 57 +++ ai-code-exploration.sln | 31 ++ src/Before.Console/Before.Console.csproj | 14 + src/Before.Console/Program.cs | 83 ++++ src/Before/Before.csproj | 9 + src/Before/Client.cs | 89 +++++ src/Before/DbBase.cs | 128 +++++++ src/Before/DbContext.cs | 131 +++++++ src/Before/ObjectNotFoundException.cs | 17 + src/Before/Order.cs | 20 + src/Before/WebApp.cs | 40 ++ tests/Before.Tests/Before.Tests.csproj | 24 ++ tests/Before.Tests/BeforeTests.cs | 466 +++++++++++++++++++++++ 14 files changed, 1123 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 ai-code-exploration.sln create mode 100644 src/Before.Console/Before.Console.csproj create mode 100644 src/Before.Console/Program.cs create mode 100644 src/Before/Before.csproj create mode 100644 src/Before/Client.cs create mode 100644 src/Before/DbBase.cs create mode 100644 src/Before/DbContext.cs create mode 100644 src/Before/ObjectNotFoundException.cs create mode 100644 src/Before/Order.cs create mode 100644 src/Before/WebApp.cs create mode 100644 tests/Before.Tests/Before.Tests.csproj create mode 100644 tests/Before.Tests/BeforeTests.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..66db31d --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +## .NET +bin/ +obj/ + +## IDE +.vs/ +.vscode/ +*.user +*.suo + +## Dotnet CLI +.dotnet/ +.local/ +Library/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..b7e0387 --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# AI Code Exploration Exercise + +A small C# application in the Active Record style, for practicing using coding agents to explore unfamiliar codebases. + +## What this is + +The `src/Before/` folder contains a small domain model (Client, Order) backed by a fake EF Core `DbContext`. The `src/Before.Console/` project is a demo console app that seeds data and queries it. + +**You do not need to read or understand the code before starting.** The point is to use a coding agent to produce diagrams that reveal the structure and behavior. + +## Prerequisites + +- A coding agent with access to this repo (Claude Code, Cursor, Copilot, etc.) +- .NET SDK 10.0 (to build and run) +- `mmdc` CLI for Mermaid rendering, or IDE Mermaid preview + +## Setup + +1. Clone this repo +2. Open `ai-code-exploration.sln` in your IDE +3. Build to verify everything compiles: + ``` + dotnet build + ``` + +## Tasks + +Use a coding agent to create diagrams of this codebase. Start broad, then iterate with follow-up prompts to refine. + +### Task 1: Class diagram (sanity check) + +Prompt your agent to create a **class diagram** of the classes in `src/Before/` using Mermaid. Iterate until you're satisfied it captures the structure accurately. + +**What to check:** Are the inheritance relationships correct? Are the associations (Client → Orders, Order → Client) visible? Is the DbContext leak (domain objects reaching persistence) shown? + +### Task 2: Sequence diagram — happy path + +Prompt your agent to create a **sequence diagram** for the flow when `Client.FindByName("Jane Doe")` is called. Show the interactions from the call site, through `DbBase.Find()`, to the DbContext lookup, and the result returning. + +### Task 3: Sequence diagram — exception path + +Prompt your agent to create a **sequence diagram** for `Client.FindByNameRequired("Nobody")` — when the client is not found. What interactions differ from the happy path? Where is `ObjectNotFoundException` thrown? + +### Task 4 (stretch): Full initialization flow + +From `Program.cs` (the Console entry point), trace how the database gets initialized and what interactions happen on `Client` and `Order` entities. Include the exception flow from Task 3. This will be a larger diagram — iterate and simplify as needed. + +## Tips + +- **Start broad, then refine:** "Draw me a class diagram of src/Before/" is enough to start. +- **Iterate on the prompt:** If the first diagram is missing something, ask the agent to add it. You don't need to write Mermaid syntax. +- **Validate:** Use `mmdc` to check syntax, visually check the diagram against the code. +- **The skill is directing the agent,** not writing diagrams by hand. + +## Background + +This is the "before" code from a refactoring exercise (db-subclass-to-dto) where the Active Record pattern is extracted into a DTO + POCO design. The Active Record structure — with its DbContext leak through entity inheritance — is the kind of thing you want to understand before refactoring. diff --git a/ai-code-exploration.sln b/ai-code-exploration.sln new file mode 100644 index 0000000..a4c22d3 --- /dev/null +++ b/ai-code-exploration.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Before", "src\Before\Before.csproj", "{2FF256B4-B085-449E-95AD-B9E6202DA94A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Before.Console", "src\Before.Console\Before.Console.csproj", "{AC39E693-E83D-4B50-A120-0DE259108AF2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Before.Tests", "tests\Before.Tests\Before.Tests.csproj", "{F5E0C182-CBC3-4440-AF8F-32887F9B5223}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2FF256B4-B085-449E-95AD-B9E6202DA94A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2FF256B4-B085-449E-95AD-B9E6202DA94A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2FF256B4-B085-449E-95AD-B9E6202DA94A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2FF256B4-B085-449E-95AD-B9E6202DA94A}.Release|Any CPU.Build.0 = Release|Any CPU + {AC39E693-E83D-4B50-A120-0DE259108AF2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AC39E693-E83D-4B50-A120-0DE259108AF2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AC39E693-E83D-4B50-A120-0DE259108AF2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AC39E693-E83D-4B50-A120-0DE259108AF2}.Release|Any CPU.Build.0 = Release|Any CPU + {F5E0C182-CBC3-4440-AF8F-32887F9B5223}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F5E0C182-CBC3-4440-AF8F-32887F9B5223}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F5E0C182-CBC3-4440-AF8F-32887F9B5223}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F5E0C182-CBC3-4440-AF8F-32887F9B5223}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/src/Before.Console/Before.Console.csproj b/src/Before.Console/Before.Console.csproj new file mode 100644 index 0000000..c936346 --- /dev/null +++ b/src/Before.Console/Before.Console.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + diff --git a/src/Before.Console/Program.cs b/src/Before.Console/Program.cs new file mode 100644 index 0000000..b828ed1 --- /dev/null +++ b/src/Before.Console/Program.cs @@ -0,0 +1,83 @@ +using Before; + +// --------------------------------------------------------------------------- +// Before.Console — a straight-line demo of the ActiveRecord-style pattern. +// +// The console app sets DbBase.Context (the shared singleton) once at startup, +// then creates client/order entities, saves them through the context, and +// queries back using the ActiveRecord entry points: +// • Client.FindByName(name) — no context argument: rides the +// DbBase.Context singleton +// • listing via DbBase.Context!.Tracked +// --------------------------------------------------------------------------- + +var ctx = new DbContext(); +DbBase.Context = ctx; + +// ----- seed data ---------------------------------------------------------- +var jane = new Client { Name = "Jane Doe" }; +var bob = new Client { Name = "Bob Smith" }; + +jane.Orders.Add(new Order { Description = "Design consultation" }); +jane.Orders.Add(new Order { Description = "Website redesign" }); + +bob.Orders.Add(new Order { Description = "Monthly retainer" }); + +// Orders need to be attached too so they get IDs: +ctx.Attach(jane.Orders[0]); +ctx.Attach(jane.Orders[1]); +ctx.Attach(bob.Orders[0]); + +ctx.Attach(jane); +ctx.Attach(bob); +ctx.Save(); + +// ----- list all clients --------------------------------------------------- +Console.WriteLine("=== All Clients ==="); +foreach (var client in DbBase.Context!.Tracked.OfType()) +{ + Console.WriteLine($"{client.Name} [{client.Id}]"); + foreach (var order in client.Orders) + Console.WriteLine($" - order [{order.Id}]: {order.Description}"); +} + +// ----- find by name ------------------------------------------------------- +Console.WriteLine("\n=== Find by Name (null-returning) ==="); +// No context argument: FindByName falls back to the DbBase.Context singleton. +var found = Client.FindByName("Jane Doe"); +if (found is not null) +{ + Console.WriteLine($"Found: {found.Name} [{found.Id}]"); + Console.WriteLine($" Orders: {found.Orders.Count}"); +} +else +{ + Console.WriteLine("Not found."); +} + +// ----- find by name (throwing variant) ------------------------------------ +Console.WriteLine("\n=== Find by Name Required (throws instead of returning null) ==="); +try +{ + var required = Client.FindByNameRequired("Jane Doe"); + Console.WriteLine($"Found: {required.Name} [{required.Id}]"); + Console.WriteLine($" Orders: {required.Orders.Count}"); +} +catch (ObjectNotFoundException ex) +{ + Console.WriteLine($"Not found: {ex.Message}"); +} + +// Show the contrast with a non-existent name: +Console.WriteLine("\n=== Miss: null-returning vs throwing ==="); +var missNull = Client.FindByName("Nobody Here"); +Console.WriteLine($"FindByName(\"Nobody Here\"): {(missNull == null ? "null" : missNull.Name)}"); +try +{ + Client.FindByNameRequired("Nobody Here"); +} +catch (ObjectNotFoundException ex) +{ + var msg = ex.Message.ReplaceLineEndings(" ").Trim(); + Console.WriteLine($"FindByNameRequired(\"Nobody Here\"): throws - {msg}"); +} diff --git a/src/Before/Before.csproj b/src/Before/Before.csproj new file mode 100644 index 0000000..b760144 --- /dev/null +++ b/src/Before/Before.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/src/Before/Client.cs b/src/Before/Client.cs new file mode 100644 index 0000000..37a4ef0 --- /dev/null +++ b/src/Before/Client.cs @@ -0,0 +1,89 @@ +using System.Collections.ObjectModel; + +namespace Before; + +/// +/// A client, backed by the database: it derives from and +/// holds its orders as a navigation collection. In the "after" situation this +/// becomes a plain POCO (no ) with a ClientDto +/// carrying its data to the WebApp. +/// +public class Client : DbBase +{ + /// Display name of the client. + public string Name { get; set; } = string.Empty; + + /// + /// Navigation collection of this client's orders. Adding to it performs + /// EF-style navigation fix-up (see ). + /// + public ClientOrders Orders { get; } + + public Client() + { + Orders = new ClientOrders(this); + } + + /// + /// Convenience lookup: finds the first whose + /// equals . Returns + /// null when no match. + /// + /// This mirrors how an ActiveRecord-style ORM might surface a static + /// finder on the domain class itself — it rides the DbContext + /// leak from the entity's back-reference. Delegates to + /// for the common traversal logic. + /// + /// The context is optional: when omitted, the + /// singleton is used, so call sites read like + /// Client.FindByName("Jane Doe") — no context threading required. + /// + public static Client? FindByName(string name, DbContext? db = null) + => DbBase.Find(c => c.Name == name, db); + + /// + /// Throwing variant of . Returns + /// the matching or throws + /// with a message that includes the searched . + /// Throws instead when no context at all + /// is configured (a configuration error, not a lookup miss). + /// + /// Delegates to . + /// + /// The client name to search for. + /// The context whose tracked entities to search. When + /// null, falls back to the singleton. + /// The first client whose equals . + /// + /// Thrown when no matching client is found. + /// + /// + /// Thrown when no context is configured (neither passed explicitly nor set as the singleton). + /// + public static Client FindByNameRequired(string name, DbContext? db = null) + => DbBase.FindRequired(c => c.Name == name, db, $"name == \"{name}\""); +} + +/// +/// An collection that fakes EF's navigation fix-up: when an +/// order is added, its back-reference is set to the +/// owning client, exactly as EF would wire up the two ends of the relation. +/// +public sealed class ClientOrders : Collection +{ + private readonly Client _owner; + + public ClientOrders(Client owner) + { + _owner = owner; + } + + // Both Add(...) and Insert(...) funnel through InsertItem, so overriding + // it covers every way an order can be added to the collection. + protected override void InsertItem(int index, Order item) + { + ArgumentNullException.ThrowIfNull(item); + item.Client = _owner; // navigation fix-up: order now knows its client + base.InsertItem(index, item); + } +} diff --git a/src/Before/DbBase.cs b/src/Before/DbBase.cs new file mode 100644 index 0000000..4466f39 --- /dev/null +++ b/src/Before/DbBase.cs @@ -0,0 +1,128 @@ +namespace Before; + +/// +/// Base class for every DB-backed domain object. Fakes the active-record part +/// of EF: each entity carries its own and, once it has been +/// created/saved through a context, a back-reference to that context. +/// +/// is the dependency this base class leaks. The +/// "after" situation (Yak 02) removes this inheritance entirely — domain +/// objects become plain POCOs that know nothing about a DbContext. +/// +public class DbBase +{ + // ----------------------------------------------------------------- + // Static singleton: an ActiveRecord-style shared context that any + // entity can reach without passing it through method parameters. + // ----------------------------------------------------------------- + /// + /// Shared (singleton) accessible from every + /// entity via its base type. Set once at application startup so that + /// entity methods can call DbBase.Context! instead of carrying + /// a context reference. + /// + /// This is yet another leak: domain objects depend on the persistence + /// layer at the *type* level, not just the instance level. + /// + public static DbContext? Context { get; set; } + + /// + /// Primary key. Fresh (unsaved) entities have ; + /// assigns a real id, faking EF's identity + /// generation. + /// + public Guid Id { get; set; } + + /// + /// Active-record back-reference: "I know which context created me". This + /// is the leak the exercise exposes — from a plain domain object you can + /// reach straight into the persistence layer. internal set because + /// only the owning may (re)assign it; consumers + /// (including the WebApp, and the tests) can only read it. + /// + public DbContext? DbContext { get; internal set; } + + /// + /// Generic ActiveRecord-style finder: walks the 's tracked entities, looks for the first whose runtime type matches + /// and satisfies + ///. + /// + /// This static generic rides the DbContext leak just like + /// , but works for any subtype without each entity needing its own hand-written finder. + /// + /// Entity type to find (must derive from ). + /// + /// Filter applied to candidates of type . + /// + /// The context whose tracked entities to search. + /// When null, falls back to the singleton — + /// so a caller only passes a context explicitly when it must differ from + /// the ambient one. + /// + /// The first matching entity, or null when no match. + /// + /// + /// Note the (deliberate, ActiveRecord-style) ambiguity this still leaves: + /// a null result means "no match — or no context configured at + /// all". The caller cannot distinguish the two; that is part of the cost + /// of the static-singleton leak this exercise illustrates. + /// + public static T? Find(Predicate predicate, DbContext? db) where T : DbBase + { + db ??= Context; + + if (db is null) + return default; + + foreach (var e in db.Tracked) + if (e is T candidate && predicate(candidate)) + return candidate; + + return default; + } + + /// + /// Throwing variant of . Finds the first entity + /// whose runtime type matches and satisfies + /// . Throws + /// when no match is found, and when no + /// singleton is configured (a configuration error, not a lookup miss). + /// + /// The exception message includes so + /// callers can debug which lookup failed. + /// + /// Filter applied to candidates of type . + /// The context whose tracked entities to search. When null, falls back + /// to the singleton. + /// A human-readable description of the predicate, used in the + /// exception message when the search fails. + /// The first matching entity. + /// + /// Thrown when no entity matches . + /// + /// + /// Thrown when no context is available (neither passed explicitly nor set as the singleton). + /// + public static T FindRequired(Predicate predicate, DbContext? db, string predicateToString) + where T : DbBase + { + db ??= Context; + + if (db is null) + throw new InvalidOperationException( + $"Cannot perform FindRequired<{typeof(T).Name}>: " + + $"no DbContext configured (neither passed explicitly nor set as " + + $"DbBase.Context singleton). Set DbBase.Context before calling " + + $"FindRequired<{typeof(T).Name}>."); + + foreach (var e in db.Tracked) + if (e is T candidate && predicate(candidate)) + return candidate; + + throw new ObjectNotFoundException( + $"FindRequired<{typeof(T).Name}>({predicateToString}) — " + + $"no matching {typeof(T).Name} found in context."); + } +} \ No newline at end of file diff --git a/src/Before/DbContext.cs b/src/Before/DbContext.cs new file mode 100644 index 0000000..225ad42 --- /dev/null +++ b/src/Before/DbContext.cs @@ -0,0 +1,131 @@ +using System.Collections.ObjectModel; + +namespace Before; + +/// +/// A tiny hand-rolled fake of EF's DbContext: it keeps per-type +/// change-tracker tables (mirroring EF's pattern) and +/// a that fakes SaveChangesAsync. +/// +/// This is deliberately NOT real EF Core — no providers, no SQLite, no NuGet +/// beyond xUnit. The point of the exercise is the *shape of the dependencies* +/// (domain object -> DbContext), not EF's behaviour. +/// +public class DbContext +{ + // Per-type tables: each entity type has its own typed collection, + // mimicking EF Core's model where the DbContext + // maintains a separate set per entity type. + private readonly Dictionary> _tables = new(); + + /// + /// Get or create the per-type table for . + /// This mirrors EF's / Set<T> accessor. + /// + private Collection Table() where T : DbBase + { + var type = typeof(T); + if (!_tables.TryGetValue(type, out var table)) + { + table = new Collection(); + _tables[type] = table; + } + return table; + } + + /// + /// Get or create the per-type table for the given runtime . + /// Called from where we only know the type at runtime. + /// + private Collection TableFor(Type type) + { + if (!_tables.TryGetValue(type, out var table)) + { + table = new Collection(); + _tables[type] = table; + } + return table; + } + + /// + /// Attach an entity to this context (EF's Add). Sets the + /// active-record back-reference so the entity knows its owner. + /// The entity is placed into the per-type table that matches its + /// runtime type — just as EF writes rows to the correct table. + /// + public void Attach(DbBase entity) + { + ArgumentNullException.ThrowIfNull(entity); + entity.DbContext = this; + var table = TableFor(entity.GetType()); + if (!table.Contains(entity)) + table.Add(entity); + } + + /// + /// Fake SaveChangesAsync: walk all per-type tables and assign a + /// fresh to any whose id is still . + /// Returns the number of entities that were (re)saved — i.e. newly + /// identified — mirroring EF's "rows written" return value. + /// + public int Save() + { + var saved = 0; + foreach (var table in _tables.Values) + foreach (var entity in table) + if (entity.Id == Guid.Empty) + { + entity.Id = Guid.NewGuid(); + saved++; + } + return saved; + } + + /// + /// Find the first entity of type in this + /// context's per-type table that satisfies . + /// Only checks entities whose runtime type exactly matches + /// — just as EF's DbSet{T}.Find operates on a single table. + /// + /// This instance-level finder complements the static + /// . + /// + /// Entity type to find (must derive from ). + /// Filter applied to candidates of type . + /// The first matching entity, or null when no match. + public T? Find(Predicate predicate) where T : DbBase + { + var table = Table(); + foreach (var e in table) + if (e is T candidate && predicate(candidate)) + return candidate; + return default; + } + + /// + /// True if is in this context's tracker + /// (walks all per-type tables). + /// + public bool IsTracked(DbBase entity) + { + foreach (var table in _tables.Values) + if (table.Contains(entity)) + return true; + return false; + } + + /// + /// Read-only view of all entities this context currently tracks + /// (flattened across all per-type tables). + /// + public IReadOnlyList Tracked + { + get + { + var all = new List(); + foreach (var table in _tables.Values) + all.AddRange(table); + return all.AsReadOnly(); + } + } +} diff --git a/src/Before/ObjectNotFoundException.cs b/src/Before/ObjectNotFoundException.cs new file mode 100644 index 0000000..b4bb35e --- /dev/null +++ b/src/Before/ObjectNotFoundException.cs @@ -0,0 +1,17 @@ +namespace Before; + +/// +/// Thrown when a "required" ActiveRecord-style lookup — +/// / — +/// finds no matching entity in the context. +/// +/// Named after NHibernate's ObjectNotFoundException (Rails' ActiveRecord +/// raises ActiveRecord::RecordNotFound for the same situation), so a +/// lookup miss is distinguishable from unrelated +/// s such as EF's Single() +/// "Sequence contains no elements". +/// +public class ObjectNotFoundException : Exception +{ + public ObjectNotFoundException(string message) : base(message) { } +} diff --git a/src/Before/Order.cs b/src/Before/Order.cs new file mode 100644 index 0000000..721d994 --- /dev/null +++ b/src/Before/Order.cs @@ -0,0 +1,20 @@ +namespace Before; + +/// +/// An order, backed by the database: it derives from and +/// holds a back-reference to its (the other end of the +/// relation). In the "after" situation this becomes a plain POCO with an +/// OrderDto carrying its data to the WebApp. +/// +public class Order : DbBase +{ + /// Free-text description of what this order is for. + public string Description { get; set; } = string.Empty; + + /// + /// The client that owns this order. Set automatically by + /// when the order is added to + /// (navigation fix-up). + /// + public Client? Client { get; set; } +} diff --git a/src/Before/WebApp.cs b/src/Before/WebApp.cs new file mode 100644 index 0000000..a325724 --- /dev/null +++ b/src/Before/WebApp.cs @@ -0,0 +1,40 @@ +namespace Before; + +/// +/// The consuming application. In the "before" situation it talks to the +/// DB-backed domain model directly: its methods take and +/// (which inherit ) as parameters. +/// +/// One of its methods reaches the *through* a domain +/// object — the leak. In the "after" situation (Yak 02) the WebApp consumes +/// DTOs instead, and the domain objects no longer expose a DbContext. +/// +public class WebApp +{ + /// + /// Render a client together with all of its orders, consuming the + /// / domain objects directly. + /// + public string ShowClient(Client client) + { + ArgumentNullException.ThrowIfNull(client); + + var lines = new List { $"{client.Name} [{client.Id}]" }; + foreach (var order in client.Orders) + lines.Add($" - order [{order.Id}]: {order.Description}"); + return string.Join(Environment.NewLine, lines); + } + + /// + /// The leak, made concrete: from a plain domain object the WebApp can reach + /// the () and thus + /// touch the persistence layer — here just to ask whether the client has + /// been saved. The "after" situation removes client.DbContext + /// entirely, so no DTO-consumer can do this. + /// + public bool IsPersisted(Client client) + { + ArgumentNullException.ThrowIfNull(client); + return client.DbContext is not null; + } +} diff --git a/tests/Before.Tests/Before.Tests.csproj b/tests/Before.Tests/Before.Tests.csproj new file mode 100644 index 0000000..18130df --- /dev/null +++ b/tests/Before.Tests/Before.Tests.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + diff --git a/tests/Before.Tests/BeforeTests.cs b/tests/Before.Tests/BeforeTests.cs new file mode 100644 index 0000000..c41f2c5 --- /dev/null +++ b/tests/Before.Tests/BeforeTests.cs @@ -0,0 +1,466 @@ +using Before; + +namespace BeforeAfter.Tests; + +/// +/// Tests for the BEFORE situation (Yak 01): DB-backed domain classes that +/// inherit and leak their . +/// +public class BeforeTests +{ + // --------------------------------------------------------------------- + // (1) Inheritance: the domain classes ARE DB-backed (they derive from + // DbBase). This is the "before" shape Yak 02 will undo. + // --------------------------------------------------------------------- + [Fact] + public void Client_and_Order_derive_from_DbBase() + { + Assert.True(typeof(DbBase).IsAssignableFrom(typeof(Client))); + Assert.True(typeof(DbBase).IsAssignableFrom(typeof(Order))); + } + + // --------------------------------------------------------------------- + // (2) Fresh entities are unsaved: their Id is still Guid.Empty. + // --------------------------------------------------------------------- + [Theory] + [InlineData(typeof(Client))] + [InlineData(typeof(Order))] + public void New_entities_have_an_empty_id(Type type) + { + var entity = (DbBase)Activator.CreateInstance(type)!; + Assert.Equal(Guid.Empty, entity.Id); + } + + // --------------------------------------------------------------------- + // (3) Save() fakes EF's SaveChanges: it assigns fresh, distinct Guids to + // entities that do not have one yet. + // --------------------------------------------------------------------- + [Fact] + public void Save_assigns_fresh_distinct_ids() + { + var db = new DbContext(); + var acme = new Client { Name = "Acme" }; + var globex = new Client { Name = "Globex" }; + db.Attach(acme); + db.Attach(globex); + + Assert.Equal(2, db.Save()); + + Assert.NotEqual(Guid.Empty, acme.Id); + Assert.NotEqual(Guid.Empty, globex.Id); + Assert.NotEqual(acme.Id, globex.Id); + } + + // --------------------------------------------------------------------- + // (4) A saved entity is registered with its context, reachable via the + // active-record back-reference. + // --------------------------------------------------------------------- + [Fact] + public void Saved_entity_is_registered_with_its_context() + { + var db = new DbContext(); + var client = new Client { Name = "Acme" }; + db.Attach(client); + db.Save(); + + Assert.Same(db, client.DbContext); + Assert.True(db.IsTracked(client)); + } + + // --------------------------------------------------------------------- + // (5) Navigation fix-up: adding an order to client.Orders wires the + // order.Client back-reference. + // --------------------------------------------------------------------- + [Fact] + public void Adding_an_order_sets_the_client_back_reference() + { + var client = new Client { Name = "Acme" }; + var order = new Order { Description = "first order" }; + + client.Orders.Add(order); + + Assert.Same(client, order.Client); + Assert.Same(order, client.Orders[0]); + Assert.Single(client.Orders); + } + + // --------------------------------------------------------------------- + // (6) THE LEAK: from a plain Client you can reach its DbContext. + // This is exactly what the "after" situation removes: a ClientDto has + // no DbContext to reach, so a consumer can never touch the persistence + // layer through it. + // --------------------------------------------------------------------- + [Fact] + public void A_client_can_reach_its_DbContext__the_leak() + { + var db = new DbContext(); + var client = new Client { Name = "Acme" }; + db.Attach(client); + db.Save(); + + // Reaching the persistence layer *through* the domain object: + Assert.NotNull(client.DbContext); + Assert.Same(db, client.DbContext); + Assert.True(client.DbContext!.IsTracked(client)); + } + + // --------------------------------------------------------------------- + // (7) The WebApp consumes the domain classes directly — including a method + // that rides the leak. + // --------------------------------------------------------------------- + [Fact] + public void WebApp_works_on_the_domain_classes_directly() + { + var app = new WebApp(); + var db = new DbContext(); + + var client = new Client { Name = "Acme" }; + var order = new Order { Description = "order one" }; + client.Orders.Add(order); + db.Attach(client); + db.Attach(order); + db.Save(); + + // ShowClient consumes Client and its Orders directly: + var view = app.ShowClient(client); + Assert.Contains("Acme", view); + Assert.Contains("order one", view); + + // IsPersisted rides the leak (client.DbContext) — and it is true here + // because the client was saved through the context: + Assert.True(app.IsPersisted(client)); + } + + // A companion check: a bare client has no context until saved. + [Fact] + public void A_bare_client_has_no_context_until_saved() + { + var app = new WebApp(); + var client = new Client { Name = "Nobody" }; + + Assert.Null(client.DbContext); + Assert.False(app.IsPersisted(client)); + } + + // ----------------------------------------------------------------- + // (8) Static Context (Singleton): DbBase exposes a class-level + // shared DbContext. Setting it makes the context accessible + // from any entity via its base type. + // ----------------------------------------------------------------- + [Fact] + public void DbBase_has_a_static_Context_singleton() + { + Assert.Null(DbBase.Context); // fresh — not set yet + + var db = new DbContext(); + DbBase.Context = db; + + Assert.Same(db, DbBase.Context); + + DbBase.Context = null; // cleanup + } + + // --------------------------------------------------------------------- + // (9) ActiveRecord finder: DbBase.Find walks the context's tracked + // entities, looks for the first whose runtime type matches T, + // and returns it when matches. + // --------------------------------------------------------------------- + [Fact] + public void DbBase_Find_finds_matching_entity() + { + var db = new DbContext(); + var acme = new Client { Name = "Acme" }; + var globex = new Client { Name = "Globex" }; + db.Attach(acme); + db.Attach(globex); + + var result = DbBase.Find(c => c.Name == "Globex", db); + + Assert.NotNull(result); + Assert.Same(globex, result); + } + + // --------------------------------------------------------------------- + // (10) DbBase.Find returns null when no entity of type T satisfies + // the predicate. + // --------------------------------------------------------------------- + [Fact] + public void DbBase_Find_returns_null_when_no_match() + { + var db = new DbContext(); + db.Attach(new Client { Name = "Acme" }); + + var result = DbBase.Find(c => c.Name == "Nobody", db); + + Assert.Null(result); + } + + // --------------------------------------------------------------------- + // (11) DbBase.Find filters by runtime type — an Order attached to the + // context does NOT match a Client predicate. + // --------------------------------------------------------------------- + [Fact] + public void DbBase_Find_ignores_non_matching_types() + { + var db = new DbContext(); + db.Attach(new Order { Description = "test" }); + + var result = DbBase.Find(_ => true, db); + + Assert.Null(result); + } + + // --------------------------------------------------------------------- + // (12) DbBase.Find returns the FIRST matching entity only. + // --------------------------------------------------------------------- + [Fact] + public void DbBase_Find_returns_first_match() + { + var db = new DbContext(); + var first = new Client { Name = "SameName" }; + var second = new Client { Name = "SameName" }; + db.Attach(first); + db.Attach(second); + + var result = DbBase.Find(c => c.Name == "SameName", db); + + Assert.Same(first, result); // first one inserted wins + Assert.NotSame(second, result); + } + + // --------------------------------------------------------------------- + // (13) DbBase.Find handles a null context gracefully — with no explicit + // context AND no singleton configured, it returns default (null). + // --------------------------------------------------------------------- + [Fact] + public void DbBase_Find_with_null_context_returns_default() + { + var result = DbBase.Find(_ => true, null); + Assert.Null(result); + } + + // (13b) When no explicit context is passed, Find falls back to the + // DbBase.Context singleton instead of returning null. + [Fact] + public void DbBase_Find_falls_back_to_Context_singleton_when_db_is_null() + { + var db = new DbContext(); + var acme = new Client { Name = "SingletonCo" }; + db.Attach(acme); + + DbBase.Context = db; + try + { + var result = DbBase.Find(c => c.Name == "SingletonCo", null); + + Assert.NotNull(result); + Assert.Same(acme, result); + } + finally + { + DbBase.Context = null; // cleanup + } + } + + // --------------------------------------------------------------------- + // (14) Client.FindByName delegates to DbBase.Find and works correctly. + // --------------------------------------------------------------------- + [Fact] + public void Client_FindByName_finds_by_name() + { + var db = new DbContext(); + var acme = new Client { Name = "Acme Corp" }; + db.Attach(acme); + + var result = Client.FindByName("Acme Corp", db); + + Assert.NotNull(result); + Assert.Same(acme, result); + } + + // --------------------------------------------------------------------- + // (15) Client.FindByName returns null when the name does not match. + // --------------------------------------------------------------------- + [Fact] + public void Client_FindByName_returns_null_when_not_found() + { + var db = new DbContext(); + db.Attach(new Client { Name = "Acme Corp" }); + + var result = Client.FindByName("Nobody", db); + + Assert.Null(result); + } + + // (15b) FindByName with no context argument uses the Context singleton. + [Fact] + public void Client_FindByName_uses_Context_singleton_when_db_omitted() + { + var db = new DbContext(); + var acme = new Client { Name = "Acme Corp" }; + db.Attach(acme); + + DbBase.Context = db; + try + { + var result = Client.FindByName("Acme Corp"); + + Assert.NotNull(result); + Assert.Same(acme, result); + } + finally + { + DbBase.Context = null; // cleanup + } + } + + // --------------------------------------------------------------------- + // (16) The class-level DbBase.Context singleton can be used as the + // implicit context source for Find operations — the classic + // ActiveRecord pattern where any entity method reaches the shared + // context without parameter passing. + // --------------------------------------------------------------------- + [Fact] + public void DbBase_Context_singleton_is_used_in_Find() + { + var db = new DbContext(); + var targeted = new Client { Name = "TargetCo" }; + db.Attach(targeted); + + DbBase.Context = db; + + // Query using the singleton instead of passing the context: + // (In practice, callers often do this to avoid threading db through + // every call — the whole point of the ActiveRecord leak.) + var queryResult = DbBase.Find(c => c.Name == "TargetCo", DbBase.Context); + + Assert.NotNull(queryResult); + Assert.Same(targeted, queryResult); + + DbBase.Context = null; // cleanup + } + + // --------------------------------------------------------------------- + // (17) DbBase.FindRequired finds the matching entity — same result as Find. + // --------------------------------------------------------------------- + [Fact] + public void DbBase_FindRequired_T_finds_matching_entity() + { + var db = new DbContext(); + var acme = new Client { Name = "Acme" }; + var globex = new Client { Name = "Globex" }; + db.Attach(acme); + db.Attach(globex); + + var result = DbBase.FindRequired(c => c.Name == "Globex", db, "name == Globex"); + + Assert.Same(globex, result); + } + + // --------------------------------------------------------------------- + // (18) DbBase.FindRequired throws ObjectNotFoundException when no match + // is found (the domain-specific lookup miss, not a BCL exception). + // --------------------------------------------------------------------- + [Fact] + public void DbBase_FindRequired_T_throws_when_no_match() + { + var db = new DbContext(); + db.Attach(new Client { Name = "Acme" }); + + var ex = Assert.Throws(() => + DbBase.FindRequired(c => c.Name == "Nobody", db, "name == Nobody")); + + Assert.Contains("Nobody", ex.Message); + Assert.Contains("no matching Client found", ex.Message); + } + + // --------------------------------------------------------------------- + // (19) DbBase.FindRequired throws with a useful message when no context is + // configured (both explicit null and singleton null). + // + // Deliberately InvalidOperationException, not ObjectNotFoundException: + // a missing context is a configuration error, not a lookup miss. + // --------------------------------------------------------------------- + [Fact] + public void DbBase_FindRequired_T_throws_when_no_context_configured() + { + DbBase.Context = null; + + var ex = Assert.Throws(() => + DbBase.FindRequired(_ => true, null, "true")); + + Assert.Contains("no DbContext configured", ex.Message); + Assert.Contains("DbBase.Context", ex.Message); + } + + // --------------------------------------------------------------------- + // (20) Client.FindByNameRequired finds the matching client by name. + // --------------------------------------------------------------------- + [Fact] + public void Client_FindByNameRequired_finds_by_name() + { + var db = new DbContext(); + var acme = new Client { Name = "Acme Corp" }; + db.Attach(acme); + + var result = Client.FindByNameRequired("Acme Corp", db); + + Assert.Same(acme, result); + } + + // --------------------------------------------------------------------- + // (21) Client.FindByNameRequired throws ObjectNotFoundException when no + // match — message includes name. + // --------------------------------------------------------------------- + [Fact] + public void Client_FindByNameRequired_throws_with_name_when_not_found() + { + var db = new DbContext(); + db.Attach(new Client { Name = "Acme Corp" }); + + var ex = Assert.Throws(() => + Client.FindByNameRequired("Nobody", db)); + + Assert.Contains("Nobody", ex.Message); + Assert.Contains("no matching Client found", ex.Message); + } + + // --------------------------------------------------------------------- + // (22) Client.FindByNameRequired uses Context singleton when no context arg. + // --------------------------------------------------------------------- + [Fact] + public void Client_FindByNameRequired_uses_Context_singleton() + { + var db = new DbContext(); + var acme = new Client { Name = "SingletonCo" }; + db.Attach(acme); + + DbBase.Context = db; + try + { + var result = Client.FindByNameRequired("SingletonCo"); + Assert.Same(acme, result); + } + finally + { + DbBase.Context = null; + } + } + + // --------------------------------------------------------------------- + // (23) Client.FindByNameRequired throws with a useful message when the + // singleton is not configured (no arg, no singleton). + // Stays InvalidOperationException: configuration error, not a miss. + // --------------------------------------------------------------------- + [Fact] + public void Client_FindByNameRequired_throws_with_context_hint_when_singleton_null() + { + DbBase.Context = null; + + var ex = Assert.Throws(() => + Client.FindByNameRequired("SomeBody")); + + Assert.Contains("no DbContext configured", ex.Message); + Assert.Contains("DbBase.Context", ex.Message); + } +} \ No newline at end of file