diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
index 9ba354c7..06328b33 100644
--- a/.github/workflows/dotnet.yml
+++ b/.github/workflows/dotnet.yml
@@ -34,6 +34,21 @@ jobs:
path: ${{ env.Workspace }}/bin/x64/Release/net8.0
if: ${{ github.event_name == 'push'}}
+ test:
+
+ runs-on: windows-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.0.x
+ - name: Restore dependencies
+ run: dotnet restore
+ - name: Run unit tests
+ run: dotnet test src/Perpetuum.Tests/Perpetuum.Tests.csproj --no-restore --configuration Release -p:Platform=x64
+
build-admintool-installer:
runs-on: windows-latest
diff --git a/CLAUDE.md b/CLAUDE.md
index 17abeaf3..36dbd9a1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -62,7 +62,14 @@ CI:
Output:
- `bin/x64/Release/net8.0`
-There are currently no automated tests.
+Tests:
+
+```bash
+dotnet test src/Perpetuum.Tests/Perpetuum.Tests.csproj -c Release -p:Platform=x64
+```
+
+This is the unit tier and needs no setup. The integration tier and the smoke script need a configured
+`GameRoot` and a live database — see `docs/codebase/TESTING.md`.
---
@@ -280,14 +287,22 @@ Prefer:
# Testing & Validation
-There is currently no automated test suite.
+An automated test suite exists in three tiers — unit, integration and smoke. It does not cover the
+whole codebase. `docs/codebase/TESTING.md` states what is covered, what is not, and how to run each
+tier.
Claude MUST:
-- propose manual validation steps
+- run the unit tier after changing code it covers
+- propose tests first, and manual validation for what tests cannot reach
+- propose manual validation steps for gameplay behaviour, which no tier covers
- identify affected gameplay systems
- identify affected DB state
- identify likely regression areas
+Claude MUST NOT:
+- restructure production code to make a test possible without saying so explicitly
+- add a regression test without observing it fail against the unfixed code
+
---
# Response Expectations
diff --git a/PerpetuumServer2.sln b/PerpetuumServer2.sln
index d9d55f4a..9a70b043 100644
--- a/PerpetuumServer2.sln
+++ b/PerpetuumServer2.sln
@@ -23,6 +23,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Perpetuum.ServerService2",
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Perpetuum.AdminTool", "src\Perpetuum.AdminTool\Perpetuum.AdminTool.csproj", "{A7D1E3C5-9F4B-42E8-8A6C-B5D7F1E9C2A0}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Perpetuum.Tests", "src\Perpetuum.Tests\Perpetuum.Tests.csproj", "{C8C45427-6E5C-496C-9446-0817EADC05DD}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Perpetuum.Tests.Integration", "src\Perpetuum.Tests.Integration\Perpetuum.Tests.Integration.csproj", "{ABE8E004-66D6-4D40-AEBF-3CFBD041E29B}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|x64 = Debug|x64
@@ -65,6 +69,14 @@ Global
{A7D1E3C5-9F4B-42E8-8A6C-B5D7F1E9C2A0}.Debug|x64.Build.0 = Debug|x64
{A7D1E3C5-9F4B-42E8-8A6C-B5D7F1E9C2A0}.Release|x64.ActiveCfg = Release|x64
{A7D1E3C5-9F4B-42E8-8A6C-B5D7F1E9C2A0}.Release|x64.Build.0 = Release|x64
+ {C8C45427-6E5C-496C-9446-0817EADC05DD}.Debug|x64.ActiveCfg = Debug|x64
+ {C8C45427-6E5C-496C-9446-0817EADC05DD}.Debug|x64.Build.0 = Debug|x64
+ {C8C45427-6E5C-496C-9446-0817EADC05DD}.Release|x64.ActiveCfg = Release|x64
+ {C8C45427-6E5C-496C-9446-0817EADC05DD}.Release|x64.Build.0 = Release|x64
+ {ABE8E004-66D6-4D40-AEBF-3CFBD041E29B}.Debug|x64.ActiveCfg = Debug|x64
+ {ABE8E004-66D6-4D40-AEBF-3CFBD041E29B}.Debug|x64.Build.0 = Debug|x64
+ {ABE8E004-66D6-4D40-AEBF-3CFBD041E29B}.Release|x64.ActiveCfg = Release|x64
+ {ABE8E004-66D6-4D40-AEBF-3CFBD041E29B}.Release|x64.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -79,6 +91,8 @@ Global
{D3F8A2B1-6C9E-4D7F-A5B8-2E0C4F9A3D6B} = {0BC991A1-133C-49ED-A141-80E2A906898B}
{A2FCE930-E013-4731-B354-F7DA00322D38} = {0BC991A1-133C-49ED-A141-80E2A906898B}
{A7D1E3C5-9F4B-42E8-8A6C-B5D7F1E9C2A0} = {0BC991A1-133C-49ED-A141-80E2A906898B}
+ {C8C45427-6E5C-496C-9446-0817EADC05DD} = {0BC991A1-133C-49ED-A141-80E2A906898B}
+ {ABE8E004-66D6-4D40-AEBF-3CFBD041E29B} = {0BC991A1-133C-49ED-A141-80E2A906898B}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {91874B60-30B0-4B48-9155-370E40BC70E6}
diff --git a/docs/backlog/improvements.md b/docs/backlog/improvements.md
index 1c4ea21e..ea5cd43e 100644
--- a/docs/backlog/improvements.md
+++ b/docs/backlog/improvements.md
@@ -1,6 +1,84 @@
# Last ID used
-044
+045
+
+## IMPROVEMENT-045 - Automated test suite
+
+Status: IN_PROGRESS
+Priority: MEDIUM
+Area: Testing / Infrastructure
+
+### Description
+
+Introduce an automated test suite covering the whole project, in three tiers: a smoke script that runs
+the real server end to end, a unit tier behind fakes for the four static service locators, and an
+integration tier that runs against the real database.
+
+Coverage is partial by design and grows in stages. Stages 0-4 are implemented; stages 5-10 are listed
+under Proposed Implementation and are not started.
+
+### Impact
+
+Every change to core systems is currently validated by starting a server and watching the log. That
+answer does not survive the next change, and it is re-derived by hand every time.
+
+Two defects found in this repository in the last week — [[ISSUE-033]] and [[ISSUE-039]] — are now
+regression tests, each observed failing with its fix reverted. Neither would have been caught by
+inspection: ISSUE-039 left a running server quoting stale insurance prices with no error in the log at
+all.
+
+[[ISSUE-038]] already asks in writing for "an automated test client" looping session
+connect/disconnect while sampling `dotnet-gcdump`. That is a soak harness rather than a test suite, but
+the demand for automation is already on record.
+
+There is a second reason, and it is the stronger one. The share of AI-authored code in this repository
+is rising. The value of an automated suite is that it answers "did this break something" without a
+human re-deriving the answer for every contribution, from any author.
+
+### Proposed Implementation
+
+Three tiers, because no single tier catches what this repository actually breaks. ISSUE-039 only
+manifests when a real `SqlConnection.Open()` reads `Transaction.Current` and finds a completed scope —
+a faked connection passes it.
+
+| Tier | Project | Needs |
+|---|---|---|
+| 1 — smoke | `tools/smoke-test.ps1` | A configured `GameRoot` and a live database |
+| 2 — unit | `src/Perpetuum.Tests` | Nothing; runs in CI |
+| 3 — integration | `src/Perpetuum.Tests.Integration` | A configured `GameRoot` and a live database |
+
+Delivered (stages 0-4): infrastructure and fakes, the smoke script, `Guard.cs` and
+`ValueTypeExtensions.cs`, the data layer against both the fake and the real schema, and the two
+regression tests.
+
+Remaining stages, in order:
+
+| # | Stage | Tier |
+|---|---|---|
+| 5 | Entity system — `Entity`, `EntityDefault`, `EntityDynamicProperties` in isolation | 2 |
+| 6 | Module state machines — transitions in `ActiveModule.States.cs` | 2 |
+| 7 | Season service — tier grant, objective completion, leaderboard delivery, intro-mail idempotency, end-of-season processing | 2+3 |
+| 8 | Request handlers — fake session/request infrastructure plus one handler per dispatch category | 2 |
+| 9 | Mission engine — deterministic resolve against fixed data | 2+3 |
+| 10 | Concurrency — only what can be made deterministic: `ProcessManager`, `MessageSender` | 2 |
+
+### Notes
+
+- **No production code changes.** The four existing static service locators (`Logger.Current`,
+ `Db.DbQueryFactory`, `EntityDefault.Reader`, `Entity.Services`) turned out to be sufficient seams for
+ everything in stages 0-4. If a later stage genuinely cannot be tested without a new seam, that is
+ raised in the pull request rather than slipped in — `CLAUDE.md` forbids speculative refactors.
+- **No synthetic schema.** Tier 3 runs against the real `perpetuumsa`. Duplicating the DDL would drift
+ from production, and every developer who touches this code already has the standard environment.
+ `PERPETUUM_GAMEROOT` is the only machine-specific input and its absence makes tests skip, not fail.
+- **Coverage is not the goal.** Covering all 585 files of `Perpetuum.RequestHandlers` is explicitly a
+ non-goal. Stage 8 is one handler per dispatch category, not 200.
+- Stage 10 is scoped to what can be made deterministic. A flaky concurrency test is worse than no test:
+ it trains the team to ignore red.
+- Before stages 7 and 9, where the number of stubs multiplies, two hardening items: make the data fake
+ fail loudly when no registered pattern matches a command instead of returning an empty result set,
+ and give the assembly-wide recording logger an automatic reset instead of relying on each test class
+ to clear it.
## IMPROVEMENT-044 - Disable NPC flee behavior (player complaints)
diff --git a/docs/codebase/ARCHITECTURE.md b/docs/codebase/ARCHITECTURE.md
index e2d491c8..8a563de8 100644
--- a/docs/codebase/ARCHITECTURE.md
+++ b/docs/codebase/ARCHITECTURE.md
@@ -289,8 +289,10 @@ consumed by zones to clean up zone sessions.
These are write-once at startup, read-only thereafter.
- **Platform:** `[SupportedOSPlatform("windows")]` on both `Perpetuum.Server` and
`Perpetuum.Bootstrapper` assemblies — Windows-only due to native dependencies.
-- **No automated tests:** The project has no test projects. Validation is manual or via
- the `Perpetuum.AdminTool` WPF application.
+- **Partial test coverage:** `src/Perpetuum.Tests` (unit) and `src/Perpetuum.Tests.Integration`
+ (against the real database) cover the data layer, validation helpers and two regression paths.
+ Gameplay behaviour is still validated manually or via the `Perpetuum.AdminTool` WPF application.
+ See `docs/codebase/TESTING.md`.
- **SQL Server only:** `Microsoft.Data.SqlClient` is hard-wired; no abstraction layer.
## Anti-Patterns
diff --git a/docs/codebase/CONCERNS.md b/docs/codebase/CONCERNS.md
index fbbe8c50..37bc3ed1 100644
--- a/docs/codebase/CONCERNS.md
+++ b/docs/codebase/CONCERNS.md
@@ -170,13 +170,13 @@ The `MissionHandler`, `MissionInProgress`, `MissionProcessorDeliverMission`, `Pr
## Missing Infrastructure
-### No Automated Tests
+### Partial Test Coverage
-The repository has zero automated tests (unit, integration, or functional). CLAUDE.md states this explicitly.
+A test suite exists (`src/Perpetuum.Tests`, `src/Perpetuum.Tests.Integration`, `tools/smoke-test.ps1`) but covers only the data layer, validation helpers and two regression paths. The subsystems this document calls high-risk are still untested.
-**Impact:** Every change to core systems — combat calculations, mission rewards, market transactions, season point accrual — must be manually validated in a running server with a connected database. Regressions are invisible until they reach production or are found by players.
+**Impact:** Changes to combat calculations, mission rewards, market transactions and season point accrual must still be manually validated in a running server with a connected database. Regressions in those areas remain invisible until they reach production or are found by players.
-**Fix approach:** Start with pure-logic unit tests for `FastRandom`, economy formulas, mission reward calculations, and season point math. These have no external dependencies and provide immediate value.
+**Fix approach:** Continue along the coverage map in `IMPROVEMENT-045` — entity system, module state machines, season service, request handlers, mission engine, concurrency. See `docs/codebase/TESTING.md` for what is covered today.
---
diff --git a/docs/codebase/STACK.md b/docs/codebase/STACK.md
index b2500cbd..b2cb324d 100644
--- a/docs/codebase/STACK.md
+++ b/docs/codebase/STACK.md
@@ -70,9 +70,15 @@
- Runner: `windows-latest`
- Trigger: push/PR to `develop` branch
- Publishes build artifact `Perpetuum-Server-v2-{sha}` on push
-- Only `Perpetuum.ServerService2` project is built in CI
-
-**No automated tests** — no test project, no test framework configured.
+- Only `Perpetuum.ServerService2` project is built in the `build` job
+- A `test` job runs the unit tier; the integration tier is not referenced in CI
+
+**Testing:**
+- Framework: xUnit v3, with NSubstitute for interface doubles
+- `src/Perpetuum.Tests` — unit tier, no external dependencies, runs in CI
+- `src/Perpetuum.Tests.Integration` — runs against the real database, skipped when `PERPETUUM_GAMEROOT` is unset
+- `tools/smoke-test.ps1` — builds, starts the server, asserts on the startup and shutdown log
+- Coverage is partial by design; see `docs/codebase/TESTING.md`
**Unsafe code:**
- `true` in `src/Perpetuum/Perpetuum.csproj`
diff --git a/docs/codebase/TESTING.md b/docs/codebase/TESTING.md
index 9b8f2829..40d0456a 100644
--- a/docs/codebase/TESTING.md
+++ b/docs/codebase/TESTING.md
@@ -1,18 +1,139 @@
# Testing
-**Analysis Date:** 2026-05-11
+**Analysis Date:** 2026-08-14
## Current State
-**No automated test suite exists in this repository.**
+The repository has an automated test suite in three tiers. It does not cover the whole codebase — the
+coverage map below states what is covered and what is not.
-There is no xUnit, NUnit, MSTest, or any other test framework. The CI pipeline (`.github/workflows/dotnet.yml`) runs only `dotnet build` and `dotnet restore` — no `dotnet test` step exists. There are no `*.Tests` projects, no test discovery configuration, and no code coverage tooling.
+| Tier | Project | Count | Needs |
+|------|---------|-------|-------|
+| 1 — smoke | `tools/smoke-test.ps1` | 1 end-to-end run | A configured `GameRoot` and a live database |
+| 2 — unit | `src/Perpetuum.Tests` | 58 tests | Nothing. Runs anywhere the solution builds |
+| 3 — integration | `src/Perpetuum.Tests.Integration` | 8 tests | A configured `GameRoot` and a live database |
+
+Tier 2 is the tier that runs in CI. Tiers 1 and 3 run on a developer machine that already has the
+standard server environment, and skip rather than fail when it is absent.
+
+## Running the tests
+
+Tier 2, no setup required:
+
+```bash
+dotnet test src/Perpetuum.Tests/Perpetuum.Tests.csproj -c Release -p:Platform=x64
+```
+
+Tier 3, against the real database:
+
+```bash
+set PERPETUUM_GAMEROOT=C:\PerpetuumServer\data
+dotnet test src/Perpetuum.Tests.Integration/Perpetuum.Tests.Integration.csproj -c Release -p:Platform=x64
+```
+
+Tier 1, a full server run:
+
+```bash
+pwsh tools/smoke-test.ps1 -GameRoot C:\PerpetuumServer\data
+```
+
+### Environment variables
+
+| Variable | Read by | Effect |
+|----------|---------|--------|
+| `PERPETUUM_GAMEROOT` | Tiers 1 and 3 | Directory holding `perpetuum.ini`. Unset, every tier-3 test that touches the database is **skipped**, not failed |
+| `PERPETUUM_TESTDB_ALLOW_WRITE` | Tier 3 | Set to `1` to opt in to tests that write. Unset, only read-only tests run |
+
+Tier 3 does not carry its own connection string. It deserializes the same `perpetuum.ini` into the same
+`GlobalConfiguration` type the bootstrapper uses, so the connection string cannot drift from the one
+the server runs with.
## Test Infrastructure
-None. There is no test runner, no assertion library, no mock framework, and no test helpers.
+### Tier 2 — unit
+
+Framework: xUnit v3, with NSubstitute for interface doubles.
+
+The four static service locators the previous version of this document called "the main obstacle" are
+the seams the suite uses. Each is a settable `public static` property assigned in exactly one place, so
+a fixture assigns a double before exercising code:
+
+| Seam | Type | Production assignment |
+|------|------|-----------------------|
+| `Logger.Current` | `ILogger` | `PerpetuumBootstrapper.cs:137` |
+| `Db.DbQueryFactory` | `Func` | `PerpetuumBootstrapper.cs:150` |
+| `EntityDefault.Reader` | `IEntityDefaultReader` | `PerpetuumBootstrapper.cs:156` |
+| `Entity.Services` | `IEntityServices` | `PerpetuumBootstrapper.cs:157` |
+
+The data layer is the important one. `Db.Query()` funnels 755 call sites in `Perpetuum` and
+`Perpetuum.RequestHandlers` through `Db.DbQueryFactory`, and `DbQuery` takes a
+`DbConnectionFactory` delegate, so
+`Db.DbQueryFactory = () => new DbQuery(() => fakeConnection)` intercepts all of them without any
+production change.
-Files with "Test" in their name are not unit tests — they are **in-game admin commands** that exercise game logic against a live database while the server is running:
+`Fakes/Data/` implements the ADO.NET interfaces as a recording fake: a test registers a result set
+against a command pattern, then asserts on the SQL and parameters the code under test actually
+produced. `Fakes/RecordingLogger.cs` does the same for log output.
+
+Because these seams are process-wide static state, the fixtures live in xUnit collections
+(`PerpetuumStaticsCollection`) so classes touching them do not run in parallel with each other.
+
+### Tier 3 — integration
+
+Runs against the real `perpetuumsa`. No synthetic schema is built and no database is created,
+restored or snapshotted: every developer who touches this code already has the standard environment,
+and a second copy of the DDL would drift from production.
+
+Two things are covered:
+
+- **Schema conformance** — every stored procedure and function documented under `docs/db_structure/`
+ is checked to exist in the live database with the documented parameter signature.
+- **Query anchoring** — the queries that tier 2 stubs are executed against the real schema, so the
+ fake stays an assertion about how the database actually behaves rather than about how it was
+ imagined to behave.
+
+Isolation is by read-only default: writes require `PERPETUUM_TESTDB_ALLOW_WRITE=1`. Tests use a single
+connection, because a second concurrent connection inside a `TransactionScope` escalates to MSDTC.
+
+### Tier 1 — smoke
+
+`tools/smoke-test.ps1` builds the solution, starts the real server, waits for `State : [Online]`, waits
+for the log to quiesce, sends Ctrl+C through `GenerateConsoleCtrlEvent`, and asserts the process
+reaches `State : [Off]` and exits 0. Force-killing a server that will not stop is reported as a
+failure, not a pass.
+
+Assertions are in three categories, declared in arrays at the top of the script:
+
+| Category | Behaviour |
+|----------|-----------|
+| Required | Absence fails the run — `State : [Online]`, `State : [Off]` |
+| Forbidden | Presence fails the run — exception signatures in the startup path |
+| Reported | Printed, never asserted — flock count, members spawned, time to online |
+
+The third category is deliberate. Across recorded runs the spawned member count was 6406, 6423 and
+6425; it changes with every content patch, so asserting on it would build a test that fails when the
+game works.
+
+Exit codes: `0` pass, `2` build failed, `3` GameRoot not found, `4` timed out waiting for online,
+`5` forbidden pattern in the log, `6` shutdown was not graceful, `7` unexpected error.
+
+## Regression tests
+
+Two tests in `src/Perpetuum.Tests/Regression/` exist because a specific bug reached production:
+
+| Test | Guards |
+|------|--------|
+| `Issue033EmptyFlockTests` | `FreeRoamingPathFinder` throwing on a presence with no flocks |
+| `Issue039InsuranceTransactionTests` | `LoadInsurancePrices()` running inside an already-completed `TransactionScope`, which left the price cache stale |
+
+Both were **observed failing with their fixes reverted** before being accepted. A regression test that
+has never been seen red is a statement about nothing.
+
+## Files that are not tests
+
+Files with "Test" in their name in the production projects are **in-game admin commands**, not
+automated tests. They are dispatched like player commands via `IRequestHandler` with
+`AccessLevel.admin` and need a live server:
| File | What it does |
|------|-------------|
@@ -20,14 +141,13 @@ Files with "Test" in their name are not unit tests — they are **in-game admin
| `src/Perpetuum.RequestHandlers/Zone/ZonePBSTest.cs` | Resets PBS highway bits on a live zone terrain |
| `src/Perpetuum.RequestHandlers/Zone/ZoneTerraformTest.cs` | In-game terraform operation test |
| `src/Perpetuum.RequestHandlers/Missions/MissionResolveTest.cs` | Admin command that invokes `MissionResolveTester` against live DB |
-| `src/Perpetuum/Services/MissionEngine/MissionResolveTester.cs` | Parallel mission resolve batch-runner; writes results to `missiontolocation` and `missiontargetslog` tables |
+| `src/Perpetuum/Services/MissionEngine/MissionResolveTester.cs` | Parallel mission resolve batch-runner; writes results to `missiontolocation` and `missiontargetslog` |
| `src/Perpetuum/Services/MissionEngine/OneLocationTest.cs` | Single-location mission resolve helper used by `MissionResolveTester` |
-These are dispatched exactly like player commands (via `IRequestHandler`) with `AccessLevel.admin` and require a live game server with a connected SQL Server database. They are not isolated or repeatable without the full runtime.
-
## Manual Testing
-The team tests by running the server locally against a configured `GameRoot`:
+Automated tiers do not replace running the server. For anything touching gameplay, the team still tests
+by running locally against a configured `GameRoot`:
```bash
cd src/Perpetuum.Server
@@ -41,42 +161,51 @@ Manual verification involves:
- Inspecting server console log output (`Logger.Info/Warning/Error`) and file logs under `logs/`
- Querying the SQL Server database directly to verify data state
-The `Perpetuum.AdminTool` project (`src/Perpetuum.AdminTool/`) provides a GUI tool for administrative operations including the Seasons Admin Tool.
+The `Perpetuum.AdminTool` project (`src/Perpetuum.AdminTool/`) provides a GUI tool for administrative
+operations including the Seasons Admin Tool.
## CI Pipeline
-`.github/workflows/dotnet.yml` runs on pushes and pull requests to `develop` branch only:
+`.github/workflows/dotnet.yml` runs on pushes and pull requests to `develop` only. It has four jobs:
-```yaml
-- dotnet restore
-- dotnet build src/Perpetuum.ServerService2/... --configuration Release -p:Platform=x64
-```
+| Job | What it runs |
+|-----|--------------|
+| `build` | `dotnet build src/Perpetuum.ServerService2/...` and uploads the artifact on push |
+| `test` | `dotnet test src/Perpetuum.Tests/Perpetuum.Tests.csproj` — tier 2 only |
+| `build-admintool-installer` | Builds the AdminTool MSI |
+| `publish-wiki` | Publishes the graphify codebase report |
-A build artifact is uploaded on successful push. No tests are executed in CI.
+The `test` job does not reference the integration project, and `[RequiresGameRoot]` would skip it even
+if something did. Two independent barriers, because one alone is a convention.
## Gaps
-**Everything is untested at the automated level.** Specific high-risk gaps:
+Covered so far: `Guard.cs`, `ValueTypeExtensions.cs`, the database query layer, and the two regression
+paths above. Still untested at the automated level:
- **Entity system** (`src/Perpetuum/EntityFramework/`) — `Entity`, `EntityDefault`, `EntityDynamicProperties` have no isolation tests. These underpin all game objects.
- **Module state machines** (`src/Perpetuum/Modules/ActiveModule.States.cs`) — state transitions for robot equipment are tested only by playing the game.
-- **Guard/validation extensions** (`src/Perpetuum/Guard.cs`) — the `ThrowIf*` extension methods are used pervasively but never unit-tested.
-- **Database query layer** (`src/Perpetuum/Data/DbQuery.cs`, `Db.cs`) — no integration test harness for SQL query correctness.
- **Request handlers** (`src/Perpetuum.RequestHandlers/`) — 200+ handler classes have no test doubles or mock session/request infrastructure.
-- **Concurrent/threading code** — `ProcessManager`, `MessageSender`, `TcpConnection` use `ThreadPool` and `Task.Run` patterns that are notoriously difficult to test without a harness.
+- **Concurrent/threading code** — `ProcessManager`, `MessageSender`, `TcpConnection` use `ThreadPool` and `Task.Run` patterns that need a harness before they can be tested deterministically.
- **Season service logic** (`src/Perpetuum/Services/Seasons/SeasonService.cs`) — tier grant, objective completion, leaderboard delivery, intro mail idempotency, and end-of-season processing are exercised only via live play.
-- **Mission engine** (`src/Perpetuum/Services/MissionEngine/`) — the most complex subsystem; the existing `MissionResolveTester` exercises resolve logic but requires a live DB and has no assertions — it logs results to tables for human inspection.
+- **Mission engine** (`src/Perpetuum/Services/MissionEngine/`) — the most complex subsystem; `MissionResolveTester` exercises resolve logic but requires a live DB and has no assertions.
-## Adding Tests (If Introduced)
+Covering every file is not the goal. See `IMPROVEMENT-045` in `docs/backlog/improvements.md` for the
+planned order of attack.
-To add automated tests, the recommended path would be:
+## Adding Tests
-1. Create a `Perpetuum.Tests` project (xUnit recommended for .NET 8)
-2. Add project reference to `Perpetuum` core library
-3. The main obstacle is the pervasive use of static service locators (`Entity.Services`, `EntityDefault.Reader`, `Db.DbQueryFactory`, `Logger.Current`) — these would need to be initialized or replaced with test doubles before any entity or database code can run in isolation
-4. `Guard.cs` extension methods and `ValueTypeExtensions.cs` are pure functions with no dependencies — good first candidates for unit tests
-5. `SeasonRepository` methods are testable with an in-memory or test SQL database since they only use `Db.Query()` which is factory-injected
+1. Pure functions and validation helpers go in `src/Perpetuum.Tests/Unit/`.
+2. Anything that reaches the database goes through the fake in `Fakes/Data/`. Register the result set,
+ exercise the code, assert on the SQL and parameters it produced.
+3. Anything that needs the real schema goes in `src/Perpetuum.Tests.Integration` behind
+ `[RequiresGameRootFact]`, so it skips on a machine without the environment instead of failing.
+4. A test written for a bug must be observed failing before the fix is applied, or reverted against
+ afterwards. Otherwise it proves nothing.
+5. Production code is not restructured to make a test possible. The four seams above have been enough
+ so far; if a test genuinely cannot be written without a new seam, that is a discussion to have in
+ the pull request, not a refactor to slip in.
---
-*Testing analysis: 2026-05-11*
+*Testing analysis: 2026-08-14*
diff --git a/src/Perpetuum.Tests.Integration/AssemblyInfo.cs b/src/Perpetuum.Tests.Integration/AssemblyInfo.cs
new file mode 100644
index 00000000..37fefc24
--- /dev/null
+++ b/src/Perpetuum.Tests.Integration/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.Versioning;
+
+[assembly: SupportedOSPlatform("windows")]
diff --git a/src/Perpetuum.Tests.Integration/Data/InsuranceQueryTests.cs b/src/Perpetuum.Tests.Integration/Data/InsuranceQueryTests.cs
new file mode 100644
index 00000000..4ef7aa66
--- /dev/null
+++ b/src/Perpetuum.Tests.Integration/Data/InsuranceQueryTests.cs
@@ -0,0 +1,89 @@
+using System.Data;
+using Microsoft.Data.SqlClient;
+using Perpetuum.Tests.Integration.Infrastructure;
+using Xunit;
+
+namespace Perpetuum.Tests.Integration.Data
+{
+ ///
+ /// The unit tests stub this exact query text against a fake connection. Running it for real
+ /// is what keeps the fake honest: if a column is renamed or the table is dropped, the unit
+ /// tests keep passing against their stub and this test is what fails.
+ ///
+ [Collection(DatabaseCollection.Name)]
+ public class InsuranceQueryTests
+ {
+ // Kept in sync by hand with two other copies of this literal: the stub in
+ // Perpetuum.Tests/Unit/DbQueryTests.cs and the production query in
+ // InsuranceHelper.LoadInsurancePrices. Nothing links the three at compile time — they are in
+ // two assemblies plus production — so if this one drifts, this test keeps passing while
+ // verifying a query production no longer runs, and the anchor silently stops anchoring.
+ private const string InsurancePricesQuery = "select definition,fee,payout from insuranceprices";
+
+ [RequiresGameRootFact]
+ public void The_insurance_prices_query_runs_and_returns_the_expected_columns()
+ {
+ DatabaseFixture fixture = new();
+ using SqlConnection connection = fixture.OpenConnection();
+
+ using SqlCommand command = connection.CreateCommand();
+ command.CommandText = InsurancePricesQuery;
+
+ using SqlDataReader reader = command.ExecuteReader();
+
+ Assert.Equal(3, reader.FieldCount);
+ Assert.Equal("definition", reader.GetName(0), ignoreCase: true);
+ Assert.Equal("fee", reader.GetName(1), ignoreCase: true);
+ Assert.Equal("payout", reader.GetName(2), ignoreCase: true);
+ }
+
+ [RequiresGameRootFact]
+ public void The_insurance_prices_columns_have_the_types_the_code_reads_them_as()
+ {
+ // InsuranceHelper.LoadInsurancePrices reads column 0 as int and columns 1 and 2 as
+ // double. A type change in the database would break that silently at runtime.
+ DatabaseFixture fixture = new();
+ using SqlConnection connection = fixture.OpenConnection();
+
+ using SqlCommand command = connection.CreateCommand();
+ command.CommandText = InsurancePricesQuery;
+
+ using SqlDataReader reader = command.ExecuteReader(CommandBehavior.SchemaOnly);
+ DataTable? schema = reader.GetSchemaTable();
+
+ Assert.NotNull(schema);
+ Assert.Equal(typeof(int), schema!.Rows[0]["DataType"]);
+ Assert.Equal(typeof(double), schema.Rows[1]["DataType"]);
+ Assert.Equal(typeof(double), schema.Rows[2]["DataType"]);
+ }
+
+ [RequiresGameRootFact]
+ public void The_insurance_price_recalculation_procedure_exists()
+ {
+ // Task 8 already asserts every documented procedure exists, so this looks redundant.
+ // It is not, for two reasons worth stating rather than leaving a reader to reconstruct.
+ // It anchors the unit tier's `WhenNonQuery("exec usp_RecalculateInsurancePrices", 1)`
+ // stub exactly as the two tests above anchor the price-query stub. And Task 8's check is
+ // driven from the contents of docs/db_structure/, so deleting the procedure together
+ // with its documentation file would pass there and fail here.
+ //
+ // The name says only "exists": this asserts a catalog row, it never invokes the
+ // procedure. Invoking it would write, and nothing in stages 0-4 writes.
+ DatabaseFixture fixture = new();
+ using SqlConnection connection = fixture.OpenConnection();
+
+ using SqlCommand command = connection.CreateCommand();
+
+ // Schema-qualified deliberately: this database already carries one same-named pair
+ // across schemas (dbo.extensionSubscriptionStart and opp.extensionSubscriptionStart),
+ // so a bare name match is not a safe assumption here.
+ command.CommandText =
+ "select count(*) from sys.objects o "
+ + "join sys.schemas s on s.schema_id = o.schema_id "
+ + "where o.type in ('P','PC') and s.name = 'dbo' "
+ + "and o.name = 'usp_RecalculateInsurancePrices'";
+
+ Assert.Equal(1, (int)command.ExecuteScalar());
+ }
+ }
+}
diff --git a/src/Perpetuum.Tests.Integration/Infrastructure/DatabaseCollection.cs b/src/Perpetuum.Tests.Integration/Infrastructure/DatabaseCollection.cs
new file mode 100644
index 00000000..7fd8f66a
--- /dev/null
+++ b/src/Perpetuum.Tests.Integration/Infrastructure/DatabaseCollection.cs
@@ -0,0 +1,16 @@
+using Xunit;
+
+namespace Perpetuum.Tests.Integration.Infrastructure
+{
+ ///
+ /// Every test class that opens a connection to the operator's real perpetuumsa carries
+ /// [Collection(DatabaseCollection.Name)]. xUnit runs the classes in one collection serially,
+ /// which holds concurrent connections to a developer's own database at one and keeps failures
+ /// deterministic. It also means the write opt-in a later stage may use cannot race with a read.
+ ///
+ [CollectionDefinition(Name)]
+ public class DatabaseCollection
+ {
+ public const string Name = "Perpetuum database";
+ }
+}
diff --git a/src/Perpetuum.Tests.Integration/Infrastructure/DatabaseFixture.cs b/src/Perpetuum.Tests.Integration/Infrastructure/DatabaseFixture.cs
new file mode 100644
index 00000000..7c19fe05
--- /dev/null
+++ b/src/Perpetuum.Tests.Integration/Infrastructure/DatabaseFixture.cs
@@ -0,0 +1,35 @@
+using Microsoft.Data.SqlClient;
+
+namespace Perpetuum.Tests.Integration.Infrastructure
+{
+ ///
+ /// Opens connections to the developer's real perpetuumsa database. Read-only by default:
+ /// nothing in this project writes unless PERPETUUM_TESTDB_ALLOW_WRITE=1, and stages 0-4
+ /// contain no write test at all.
+ ///
+ public sealed class DatabaseFixture
+ {
+ public GameRootEnvironment? LocalEnvironment { get; }
+ public string? UnavailableReason { get; }
+
+ public DatabaseFixture()
+ {
+ _ = GameRootEnvironment.TryLoad(out GameRootEnvironment? env, out string? reason);
+ LocalEnvironment = env;
+ UnavailableReason = reason;
+ }
+
+ public SqlConnection OpenConnection()
+ {
+ if (LocalEnvironment is null)
+ {
+ throw new InvalidOperationException(
+ $"Database unavailable: {UnavailableReason}. Tests must use [RequiresGameRootFact].");
+ }
+
+ SqlConnection connection = new(LocalEnvironment.ConnectionString);
+ connection.Open();
+ return connection;
+ }
+ }
+}
diff --git a/src/Perpetuum.Tests.Integration/Infrastructure/EnvironmentDiscoveryTests.cs b/src/Perpetuum.Tests.Integration/Infrastructure/EnvironmentDiscoveryTests.cs
new file mode 100644
index 00000000..1318ee8c
--- /dev/null
+++ b/src/Perpetuum.Tests.Integration/Infrastructure/EnvironmentDiscoveryTests.cs
@@ -0,0 +1,50 @@
+using Xunit;
+
+namespace Perpetuum.Tests.Integration.Infrastructure
+{
+ [Collection(DatabaseCollection.Name)]
+ public class EnvironmentDiscoveryTests
+ {
+ [RequiresGameRootFact]
+ public void The_connection_string_comes_from_perpetuum_ini()
+ {
+ Assert.True(GameRootEnvironment.TryLoad(out GameRootEnvironment? env, out string? reason), reason);
+ Assert.NotNull(env);
+ Assert.False(string.IsNullOrWhiteSpace(env!.ConnectionString));
+ }
+
+ [RequiresGameRootFact]
+ public void The_database_accepts_a_connection()
+ {
+ DatabaseFixture fixture = new();
+ using Microsoft.Data.SqlClient.SqlConnection connection = fixture.OpenConnection();
+
+ Assert.Equal(System.Data.ConnectionState.Open, connection.State);
+ }
+
+ [Fact]
+ public void Writes_are_disabled_unless_explicitly_allowed()
+ {
+ // Controls the variable rather than reading whatever the shell happens to carry. Read
+ // ambiently, this test asserts nothing at all whenever the variable is already set —
+ // which is precisely the case for anyone working on a later write-enabled stage — and
+ // it is the only test guarding the opt-in that protects the operator's real database.
+ string? saved = Environment.GetEnvironmentVariable(GameRootEnvironment.AllowWriteVariable);
+ try
+ {
+ Environment.SetEnvironmentVariable(GameRootEnvironment.AllowWriteVariable, null);
+ Assert.False(GameRootEnvironment.WritesAllowed);
+
+ Environment.SetEnvironmentVariable(GameRootEnvironment.AllowWriteVariable, "0");
+ Assert.False(GameRootEnvironment.WritesAllowed);
+
+ Environment.SetEnvironmentVariable(GameRootEnvironment.AllowWriteVariable, "1");
+ Assert.True(GameRootEnvironment.WritesAllowed);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(GameRootEnvironment.AllowWriteVariable, saved);
+ }
+ }
+ }
+}
diff --git a/src/Perpetuum.Tests.Integration/Infrastructure/GameRootEnvironment.cs b/src/Perpetuum.Tests.Integration/Infrastructure/GameRootEnvironment.cs
new file mode 100644
index 00000000..3b99b294
--- /dev/null
+++ b/src/Perpetuum.Tests.Integration/Infrastructure/GameRootEnvironment.cs
@@ -0,0 +1,72 @@
+using Newtonsoft.Json;
+
+namespace Perpetuum.Tests.Integration.Infrastructure
+{
+ ///
+ /// Reads the same perpetuum.ini the server reads, from the directory named by
+ /// PERPETUUM_GAMEROOT. The file is JSON deserialized into GlobalConfiguration by
+ /// PerpetuumBootstrapper; this does the same, so the connection string can never drift
+ /// from the one the server uses.
+ ///
+ public sealed class GameRootEnvironment
+ {
+ public const string GameRootVariable = "PERPETUUM_GAMEROOT";
+ public const string AllowWriteVariable = "PERPETUUM_TESTDB_ALLOW_WRITE";
+
+ public required string GameRoot { get; init; }
+ public required string ConnectionString { get; init; }
+
+ public static bool WritesAllowed
+ => Environment.GetEnvironmentVariable(AllowWriteVariable) == "1";
+
+ public static bool TryLoad(out GameRootEnvironment? environment, out string? reason)
+ {
+ environment = null;
+
+ string? gameRoot = Environment.GetEnvironmentVariable(GameRootVariable);
+ if (string.IsNullOrWhiteSpace(gameRoot))
+ {
+ reason = $"{GameRootVariable} is not set.";
+ return false;
+ }
+
+ if (!Directory.Exists(gameRoot))
+ {
+ reason = $"{GameRootVariable} points at a directory that does not exist: {gameRoot}";
+ return false;
+ }
+
+ string iniPath = Path.Combine(gameRoot, "perpetuum.ini");
+ if (!File.Exists(iniPath))
+ {
+ reason = $"perpetuum.ini not found under {gameRoot}";
+ return false;
+ }
+
+ GlobalConfiguration? configuration;
+ try
+ {
+ configuration = JsonConvert.DeserializeObject(File.ReadAllText(iniPath));
+ }
+ catch (Exception ex)
+ {
+ reason = $"perpetuum.ini could not be parsed: {ex.Message}";
+ return false;
+ }
+
+ if (string.IsNullOrWhiteSpace(configuration?.ConnectionString))
+ {
+ reason = "perpetuum.ini carries no ConnectionString.";
+ return false;
+ }
+
+ environment = new GameRootEnvironment
+ {
+ GameRoot = gameRoot,
+ ConnectionString = configuration.ConnectionString,
+ };
+ reason = null;
+ return true;
+ }
+ }
+}
diff --git a/src/Perpetuum.Tests.Integration/Infrastructure/RequiresGameRootFactAttribute.cs b/src/Perpetuum.Tests.Integration/Infrastructure/RequiresGameRootFactAttribute.cs
new file mode 100644
index 00000000..e557f533
--- /dev/null
+++ b/src/Perpetuum.Tests.Integration/Infrastructure/RequiresGameRootFactAttribute.cs
@@ -0,0 +1,20 @@
+using Xunit;
+
+namespace Perpetuum.Tests.Integration.Infrastructure
+{
+ ///
+ /// A test marked with this attribute is skipped, not failed, when the local environment is
+ /// absent. The check is per test rather than per collection so a missing environment reports
+ /// once per affected test instead of failing a fixture during construction.
+ ///
+ public sealed class RequiresGameRootFactAttribute : FactAttribute
+ {
+ public RequiresGameRootFactAttribute()
+ {
+ if (!GameRootEnvironment.TryLoad(out _, out string? reason))
+ {
+ Skip = $"Local game environment unavailable: {reason}";
+ }
+ }
+ }
+}
diff --git a/src/Perpetuum.Tests.Integration/Perpetuum.Tests.Integration.csproj b/src/Perpetuum.Tests.Integration/Perpetuum.Tests.Integration.csproj
new file mode 100644
index 00000000..b7496cdb
--- /dev/null
+++ b/src/Perpetuum.Tests.Integration/Perpetuum.Tests.Integration.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net8.0
+ enable
+ annotations
+ x64
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Perpetuum.Tests.Integration/Schema/StoredProcedureConformanceTests.cs b/src/Perpetuum.Tests.Integration/Schema/StoredProcedureConformanceTests.cs
new file mode 100644
index 00000000..268c26c2
--- /dev/null
+++ b/src/Perpetuum.Tests.Integration/Schema/StoredProcedureConformanceTests.cs
@@ -0,0 +1,131 @@
+using System.Data;
+using Microsoft.Data.SqlClient;
+using Perpetuum.Tests.Integration.Infrastructure;
+using Xunit;
+
+namespace Perpetuum.Tests.Integration.Schema
+{
+ ///
+ /// docs/db_structure is described by CLAUDE.md as the authoritative source of truth for the
+ /// database. These tests make that claim checkable: every documented procedure and function
+ /// must exist in the real database.
+ ///
+ [Collection(DatabaseCollection.Name)]
+ public class StoredProcedureConformanceTests
+ {
+ private static string RepositoryRoot
+ {
+ get
+ {
+ DirectoryInfo? dir = new(AppContext.BaseDirectory);
+ while (dir != null && !File.Exists(Path.Combine(dir.FullName, "PerpetuumServer2.sln")))
+ {
+ dir = dir.Parent;
+ }
+
+ return dir?.FullName
+ ?? throw new InvalidOperationException("PerpetuumServer2.sln not found above the test output directory.");
+ }
+ }
+
+ ///
+ /// The two directories name their files by different conventions, both real and both
+ /// measured: stored_procedures/ uses "schema.Name.StoredProcedure.sql" — with one outlier,
+ /// dbo.usp_RecalculateInsurancePrices.sql, that omits the .StoredProcedure segment — while
+ /// functions/ uses a bare "Name.sql". Taking the filename as the object name would compare
+ /// "dbo.X.StoredProcedure" against "X" and report almost every documented procedure as
+ /// missing, which looks like a large finding about the repository and is not one.
+ /// Procedures keep their schema; functions cannot. Two documented procedures differ only by
+ /// schema — dbo.extensionSubscriptionStart and opp.extensionSubscriptionStart — so comparing
+ /// bare names would let one of them disappear from the database while this test stayed green.
+ /// Function filenames carry no schema at all, and at least one real function lives outside
+ /// dbo (opp.ToolTestAccount_GetDefinitionID, which is itself undocumented), so assuming dbo
+ /// there would produce a false failure. The asymmetry is forced by the data, and the
+ /// remaining gap on the function side is stated rather than left to be discovered.
+ ///
+ private static string ProcedureNameFromDocumentedFileName(string fileNameWithoutExtension)
+ {
+ return fileNameWithoutExtension.EndsWith(".StoredProcedure", StringComparison.OrdinalIgnoreCase)
+ ? fileNameWithoutExtension[..^".StoredProcedure".Length]
+ : fileNameWithoutExtension;
+ }
+
+ private static IReadOnlyList DocumentedNames(string subdirectory, Func derive)
+ {
+ string path = Path.Combine(RepositoryRoot, "docs", "db_structure", subdirectory);
+ return [.. Directory.EnumerateFiles(path, "*.sql")
+ .Select(Path.GetFileNameWithoutExtension)
+ .Where(n => !string.IsNullOrWhiteSpace(n))
+ .Select(n => derive(n!))
+ .OrderBy(n => n, StringComparer.OrdinalIgnoreCase)];
+ }
+
+ private static HashSet RoutineNames(
+ SqlConnection connection,
+ bool qualifyWithSchema,
+ params string[] typeCodes)
+ {
+ HashSet names = new(StringComparer.OrdinalIgnoreCase);
+
+ using SqlCommand command = connection.CreateCommand();
+
+ // The schema and object names are selected as separate columns and joined in C#.
+ // Concatenating them in T-SQL fails on this database with "Cannot resolve collation
+ // conflict between Latin1_General_CI_AS_KS_WS and SQL_Latin1_General_CP1_CI_AS".
+ command.CommandText =
+ "select s.name, o.name from sys.objects o " +
+ "join sys.schemas s on s.schema_id = o.schema_id where o.type in (" +
+ string.Join(",", typeCodes.Select((_, i) => $"@t{i}")) + ")";
+
+ for (int i = 0; i < typeCodes.Length; i++)
+ {
+ _ = command.Parameters.AddWithValue($"@t{i}", typeCodes[i]);
+ }
+
+ using IDataReader reader = command.ExecuteReader();
+ while (reader.Read())
+ {
+ string schema = reader.GetString(0);
+ string name = reader.GetString(1);
+ _ = names.Add(qualifyWithSchema ? $"{schema}.{name}" : name);
+ }
+
+ return names;
+ }
+
+ [RequiresGameRootFact]
+ public void Every_documented_stored_procedure_exists()
+ {
+ DatabaseFixture fixture = new();
+ using SqlConnection connection = fixture.OpenConnection();
+
+ HashSet actual = RoutineNames(connection, qualifyWithSchema: true, "P", "PC");
+ List missing =
+ [
+ .. DocumentedNames("stored_procedures", ProcedureNameFromDocumentedFileName)
+ .Where(n => !actual.Contains(n))
+ ];
+
+ Assert.True(
+ missing.Count == 0,
+ $"Documented under docs/db_structure/stored_procedures but absent from the database: {string.Join(", ", missing)}");
+ }
+
+ [RequiresGameRootFact]
+ public void Every_documented_function_exists()
+ {
+ DatabaseFixture fixture = new();
+ using SqlConnection connection = fixture.OpenConnection();
+
+ HashSet actual = RoutineNames(connection, qualifyWithSchema: false, "FN", "IF", "TF", "FS", "FT");
+ List missing =
+ [
+ .. DocumentedNames("functions", n => n).Where(n => !actual.Contains(n))
+ ];
+
+ Assert.True(
+ missing.Count == 0,
+ $"Documented under docs/db_structure/functions but absent from the database: {string.Join(", ", missing)}");
+ }
+ }
+}
diff --git a/src/Perpetuum.Tests/AssemblyInfo.cs b/src/Perpetuum.Tests/AssemblyInfo.cs
new file mode 100644
index 00000000..d04ca728
--- /dev/null
+++ b/src/Perpetuum.Tests/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.Versioning;
+
+[assembly: SupportedOSPlatform("windows")]
diff --git a/src/Perpetuum.Tests/Fakes/Data/FakeDataReader.cs b/src/Perpetuum.Tests/Fakes/Data/FakeDataReader.cs
new file mode 100644
index 00000000..4db7ebde
--- /dev/null
+++ b/src/Perpetuum.Tests/Fakes/Data/FakeDataReader.cs
@@ -0,0 +1,81 @@
+using System.Data;
+
+namespace Perpetuum.Tests.Fakes.Data
+{
+ public sealed class FakeDataReader(FakeResultSet resultSet) : IDataReader
+ {
+ private readonly FakeResultSet _resultSet = resultSet;
+ private int _index = -1;
+
+ // System.Data.Common.DbEnumerator (behind DataReaderExtensions.ToEnumerable, which
+ // DbQuery.Execute()/ExecuteSingleRow() use) calls GetFieldType for every column once,
+ // before the first Read(), to build its schema info — while _index is still -1. Treat
+ // that unpositioned state as an all-null row instead of indexing Rows[-1]; a real reader
+ // has no per-row values to report at that point either.
+ private object?[] Current => _index < 0
+ ? new object?[_resultSet.ColumnNames.Count]
+ : _resultSet.Rows[_index];
+
+ public bool Read()
+ {
+ _index++;
+ return _index < _resultSet.Rows.Count;
+ }
+
+ public int FieldCount => _resultSet.ColumnNames.Count;
+ public string GetName(int i) => _resultSet.ColumnNames[i];
+ public int GetOrdinal(string name)
+ {
+ for (int i = 0; i < _resultSet.ColumnNames.Count; i++)
+ {
+ if (string.Equals(_resultSet.ColumnNames[i], name, StringComparison.OrdinalIgnoreCase))
+ {
+ return i;
+ }
+ }
+
+ throw new IndexOutOfRangeException(name);
+ }
+
+ public object GetValue(int i) => Current[i] ?? DBNull.Value;
+ public bool IsDBNull(int i) => Current[i] is null;
+ public object this[int i] => GetValue(i);
+ public object this[string name] => GetValue(GetOrdinal(name));
+
+ public bool GetBoolean(int i) => (bool)GetValue(i);
+ public byte GetByte(int i) => (byte)GetValue(i);
+ public char GetChar(int i) => (char)GetValue(i);
+ public DateTime GetDateTime(int i) => (DateTime)GetValue(i);
+ public decimal GetDecimal(int i) => (decimal)GetValue(i);
+ public double GetDouble(int i) => (double)GetValue(i);
+ public float GetFloat(int i) => (float)GetValue(i);
+ public Guid GetGuid(int i) => (Guid)GetValue(i);
+ public short GetInt16(int i) => (short)GetValue(i);
+ public int GetInt32(int i) => (int)GetValue(i);
+ public long GetInt64(int i) => (long)GetValue(i);
+ public string GetString(int i) => (string)GetValue(i);
+ public Type GetFieldType(int i) => Current[i]?.GetType() ?? typeof(object);
+ public string GetDataTypeName(int i) => GetFieldType(i).Name;
+
+ public int GetValues(object[] values)
+ {
+ int count = Math.Min(values.Length, FieldCount);
+ for (int i = 0; i < count; i++) { values[i] = GetValue(i); }
+ return count;
+ }
+
+ public long GetBytes(int i, long fieldOffset, byte[]? buffer, int bufferoffset, int length)
+ => throw new NotSupportedException();
+ public long GetChars(int i, long fieldoffset, char[]? buffer, int bufferoffset, int length)
+ => throw new NotSupportedException();
+ public IDataReader GetData(int i) => throw new NotSupportedException();
+
+ public int Depth => 0;
+ public bool IsClosed { get; private set; }
+ public int RecordsAffected => -1;
+ public void Close() => IsClosed = true;
+ public DataTable? GetSchemaTable() => null;
+ public bool NextResult() => false;
+ public void Dispose() => Close();
+ }
+}
diff --git a/src/Perpetuum.Tests/Fakes/Data/FakeDb.cs b/src/Perpetuum.Tests/Fakes/Data/FakeDb.cs
new file mode 100644
index 00000000..3faf9c95
--- /dev/null
+++ b/src/Perpetuum.Tests/Fakes/Data/FakeDb.cs
@@ -0,0 +1,100 @@
+using System.Data;
+using System.Transactions;
+using Perpetuum.Data;
+
+namespace Perpetuum.Tests.Fakes.Data
+{
+ public sealed record RecordedCommand
+ {
+ public required string CommandText { get; init; }
+ public required CommandType CommandType { get; init; }
+ public required IReadOnlyDictionary Parameters { get; init; }
+ public required int CommandTimeout { get; init; }
+ public required bool HadAmbientTransaction { get; init; }
+ public required TransactionStatus? AmbientTransactionStatus { get; init; }
+ }
+
+ ///
+ /// Installs a fake data layer into Db.DbQueryFactory, which funnels every Db.Query() call
+ /// site in the codebase. Results are matched by substring against the command text.
+ ///
+ public sealed class FakeDb
+ {
+ // _commands is lock-protected because production code logs and queries from timer and task
+ // threads. _results and _nonQueries are deliberately not: they are configured by the test
+ // before the code under test runs, and never mutated afterwards. Registering a stub from
+ // inside the code under test would race, and nothing here prevents it.
+ private readonly List<(string Match, FakeResultSet Result)> _results = [];
+ private readonly List<(string Match, int RowsAffected)> _nonQueries = [];
+ private readonly List _commands = [];
+ private readonly object _gate = new();
+
+ public static FakeDb Install()
+ {
+ FakeDb fake = new();
+ Db.DbQueryFactory = () => new DbQuery(() => new FakeDbConnection(fake));
+ return fake;
+ }
+
+ ///
+ /// Registers a result for any command whose text contains .
+ /// The first registration that matches wins, so register the most specific pattern first —
+ /// a broad pattern registered earlier silently shadows a narrower one registered later.
+ ///
+ public void When(string commandTextContains, FakeResultSet result)
+ => _results.Add((commandTextContains, result));
+
+ public void WhenNonQuery(string commandTextContains, int rowsAffected)
+ => _nonQueries.Add((commandTextContains, rowsAffected));
+
+ public IReadOnlyList Commands
+ {
+ get { lock (_gate) { return [.. _commands]; } }
+ }
+
+ public RecordedCommand? LastCommandMatching(string commandTextContains)
+ => Commands.LastOrDefault(c => c.CommandText.Contains(commandTextContains, StringComparison.OrdinalIgnoreCase));
+
+ internal int RowsAffectedFor(string commandText)
+ {
+ foreach ((string match, int rows) in _nonQueries)
+ {
+ if (commandText.Contains(match, StringComparison.OrdinalIgnoreCase)) { return rows; }
+ }
+
+ return 0;
+ }
+
+ internal FakeResultSet Record(FakeDbCommand command)
+ {
+ Dictionary parameters = [];
+ foreach (object p in command.Parameters)
+ {
+ FakeParameter parameter = (FakeParameter)p;
+ parameters[parameter.ParameterName] = parameter.Value is DBNull ? null : parameter.Value;
+ }
+
+ Transaction? ambient = command.OwnerConnection.AmbientTransactionAtOpen;
+
+ lock (_gate)
+ {
+ _commands.Add(new RecordedCommand
+ {
+ CommandText = command.CommandText,
+ CommandType = command.CommandType,
+ Parameters = parameters,
+ CommandTimeout = command.CommandTimeout,
+ HadAmbientTransaction = ambient != null,
+ AmbientTransactionStatus = ambient?.TransactionInformation.Status,
+ });
+ }
+
+ foreach ((string match, FakeResultSet result) in _results)
+ {
+ if (command.CommandText.Contains(match, StringComparison.OrdinalIgnoreCase)) { return result; }
+ }
+
+ return FakeResultSet.Empty();
+ }
+ }
+}
diff --git a/src/Perpetuum.Tests/Fakes/Data/FakeDbCommand.cs b/src/Perpetuum.Tests/Fakes/Data/FakeDbCommand.cs
new file mode 100644
index 00000000..5b420316
--- /dev/null
+++ b/src/Perpetuum.Tests/Fakes/Data/FakeDbCommand.cs
@@ -0,0 +1,75 @@
+using System.Data;
+
+namespace Perpetuum.Tests.Fakes.Data
+{
+ public sealed class FakeDbCommand(FakeDbConnection connection) : IDbCommand
+ {
+ private readonly FakeDbConnection _connection = connection;
+
+ ///
+ /// The connection that created this command. DbQuery.ExecuteHelper never assigns the
+ /// IDbCommand.Connection property, so the fake carries its own back-reference rather
+ /// than reading one that is always null.
+ ///
+ internal FakeDbConnection OwnerConnection => _connection;
+
+ public string CommandText { get; set; } = string.Empty;
+ public int CommandTimeout { get; set; }
+ public CommandType CommandType { get; set; }
+ public IDbConnection? Connection { get; set; }
+ public IDataParameterCollection Parameters { get; } = new FakeParameterCollection();
+ public IDbTransaction? Transaction { get; set; }
+ public UpdateRowSource UpdatedRowSource { get; set; }
+
+ public void Cancel() { }
+ public IDbDataParameter CreateParameter() => new FakeParameter();
+ public void Prepare() { }
+ public void Dispose() { }
+
+ public IDataReader ExecuteReader() => new FakeDataReader(_connection.Owner.Record(this));
+ public IDataReader ExecuteReader(CommandBehavior behavior) => ExecuteReader();
+
+ public int ExecuteNonQuery()
+ {
+ _ = _connection.Owner.Record(this);
+ return _connection.Owner.RowsAffectedFor(CommandText);
+ }
+
+ public object? ExecuteScalar()
+ {
+ FakeResultSet result = _connection.Owner.Record(this);
+ return result.Rows.Count == 0 ? null : result.Rows[0][0];
+ }
+ }
+
+ public sealed class FakeParameter : IDbDataParameter
+ {
+ public byte Precision { get; set; }
+ public byte Scale { get; set; }
+ public int Size { get; set; }
+ public DbType DbType { get; set; }
+ public ParameterDirection Direction { get; set; }
+ public bool IsNullable => true;
+ public string ParameterName { get; set; } = string.Empty;
+ public string SourceColumn { get; set; } = string.Empty;
+ public DataRowVersion SourceVersion { get; set; }
+ public object? Value { get; set; }
+ }
+
+ public sealed class FakeParameterCollection : List