This commit is contained in:
+11
@@ -0,0 +1,11 @@
|
|||||||
|
.yaks
|
||||||
|
artifacts/
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
.dotnet-cli/
|
||||||
|
.npm-cache/
|
||||||
|
node_modules/
|
||||||
|
*.user
|
||||||
|
# NuGet restore scratch dir (DOTNET_CLI_HOME fallout)
|
||||||
|
.local/
|
||||||
|
.idea
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
Exercise repo: illustrating the transition from DB-backed domain classes
|
||||||
|
(`DbBase` inheritance) to plain domain objects + DTOs + mappers.
|
||||||
|
|
||||||
|
**Working Set (for now): the split projects — `src/Domain`,
|
||||||
|
`src/Db`, `src/Web`** — focus changes there; leave other
|
||||||
|
projects untouched unless a yak explicitly says otherwise.
|
||||||
|
|
||||||
|
## Rendering diagrams (mermaid → svg)
|
||||||
|
|
||||||
|
Mermaid diagrams are rendered to SVG with mermaid-cli (`mmdc`), pinned as a
|
||||||
|
devDependency in `package.json`. Install it once per clone:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install # installs @mermaid-js/mermaid-cli into node_modules/
|
||||||
|
```
|
||||||
|
|
||||||
|
Then render with `npx mmdc`, which resolves the local install — the same
|
||||||
|
command on Linux, macOS and **Windows** (PowerShell/cmd):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npx mmdc -p puppeteer.config.json -i <diagram>.mmd # -> <diagram>.mmd.svg
|
||||||
|
```
|
||||||
|
|
||||||
|
`-p puppeteer.config.json` passes the headless-Chrome flags (`--no-sandbox`
|
||||||
|
et al.) — without it mmdc fails to launch the browser in agent/sandbox
|
||||||
|
shells. On desktop shells it is not needed: plain `npx mmdc -i <input>`
|
||||||
|
works. On first run puppeteer downloads its own Chrome. `node_modules/` is
|
||||||
|
gitignored.
|
||||||
|
|
||||||
|
## Running dotnet
|
||||||
|
|
||||||
|
dotnet SDK 10 is installed **via mise** (see `mise.toml`). It is NOT on PATH in
|
||||||
|
agent/sandbox shells, and SDK 10 additionally fails without
|
||||||
|
`DOTNET_CLI_HOME` in some shells. Always use the wrappers:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/dotnet.sh build db-subclass-to-dto.sln
|
||||||
|
scripts/dotnet.sh test db-subclass-to-dto.sln
|
||||||
|
scripts/run-tests.sh # full test suite, from repo root
|
||||||
|
```
|
||||||
|
|
||||||
|
The wrappers cd to the repo root (the test runner must run from the parent
|
||||||
|
directory of the test project), export `DOTNET_CLI_HOME`, and invoke
|
||||||
|
`mise exec -- dotnet ...`. Do not call bare `dotnet`.
|
||||||
|
|
||||||
|
If the first-time-use setup fails with `The file ...
|
||||||
|
'.dotnet-cli/.dotnet' already exists` or `Operation not permitted` (some
|
||||||
|
agent/sandbox shells block `$HOME/.dotnet-cli`), point the wrapper at a
|
||||||
|
repo-local home: `DOTNET_CLI_HOME="$PWD/.dotnet-cli" scripts/dotnet.sh ...`
|
||||||
|
(add `.dotnet-cli/` to `.gitignore`).
|
||||||
|
|
||||||
|
## Yaks (task management with yx)
|
||||||
|
|
||||||
|
Tasks are managed with the `yx` CLI; yaks live in git (event store), snapshot
|
||||||
|
in `.yaks/` (gitignored). Work one yak at a time:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yx list # all yaks and states
|
||||||
|
yx show "<yak name>" # full yak context (the task instructions)
|
||||||
|
yx start "<yak name>" # set state to wip
|
||||||
|
yx done "<yak name>" # set state to done (only after acceptance
|
||||||
|
# criteria pass and changes are committed)
|
||||||
|
```
|
||||||
|
|
||||||
|
- The yak context in `yx show` is the authoritative task description; follow
|
||||||
|
its acceptance criteria before `yx done`.
|
||||||
|
- Children block their parent: do prerequisites (nested under a yak) first.
|
||||||
|
- Add new tasks: `yx add "<name>"` (nest with `--under "<parent>"`).
|
||||||
|
- If yx complains `.yaks is not gitignored`, keep `.yaks` in `.gitignore`.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- Target framework: net10.0 (SDK pinned by mise).
|
||||||
|
- Tests: xUnit, mirroring the src split: `tests/Domain.Tests`,
|
||||||
|
`tests/Db.Tests`, `tests/Web.Tests`, with the shared test-data builders in
|
||||||
|
`tests/TestData`.
|
||||||
|
- No EF Core / SQLite / AutoMapper — the DB layer is a small hand-rolled fake
|
||||||
|
(pure managed code, cross-platform).
|
||||||
|
- Commit per yak with a message referencing the yak.
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) Living Software LTD (UK)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.0.31903.59
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Domain", "src\Domain\Domain.csproj", "{A103FFE5-4F20-4DE1-A086-38012E701F9E}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Db", "src\Db\Db.csproj", "{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Web", "src\Web\Web.csproj", "{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}"
|
||||||
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestData", "tests\TestData\TestData.csproj", "{A9EA20AD-2B2D-456B-858A-1575C4B99E11}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Domain.Tests", "tests\Domain.Tests\Domain.Tests.csproj", "{824867CB-A83B-4968-A29D-D39FC17BCB85}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Db.Tests", "tests\Db.Tests\Db.Tests.csproj", "{7DA9971D-306A-4997-A585-D956F41C7A08}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Web.Tests", "tests\Web.Tests\Web.Tests.csproj", "{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Debug|x64 = Debug|x64
|
||||||
|
Debug|x86 = Debug|x86
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
Release|x64 = Release|x64
|
||||||
|
Release|x86 = Release|x86
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{7DA9971D-306A-4997-A585-D956F41C7A08}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{7DA9971D-306A-4997-A585-D956F41C7A08}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{7DA9971D-306A-4997-A585-D956F41C7A08}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{7DA9971D-306A-4997-A585-D956F41C7A08}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{7DA9971D-306A-4997-A585-D956F41C7A08}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{7DA9971D-306A-4997-A585-D956F41C7A08}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{7DA9971D-306A-4997-A585-D956F41C7A08}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{7DA9971D-306A-4997-A585-D956F41C7A08}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{7DA9971D-306A-4997-A585-D956F41C7A08}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{7DA9971D-306A-4997-A585-D956F41C7A08}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{7DA9971D-306A-4997-A585-D956F41C7A08}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{7DA9971D-306A-4997-A585-D956F41C7A08}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(NestedProjects) = preSolution
|
||||||
|
{A103FFE5-4F20-4DE1-A086-38012E701F9E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{A9EA20AD-2B2D-456B-858A-1575C4B99E11} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
|
{824867CB-A83B-4968-A29D-D39FC17BCB85} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
|
{7DA9971D-306A-4997-A585-D956F41C7A08} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
|
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
Generated
+2521
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"@mermaid-js/mermaid-cli": "^11.17.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"executablePath": "/home/willem/.cache/puppeteer/chrome/linux-152.0.7977.75/chrome-linux64/chrome",
|
||||||
|
"headless": true,
|
||||||
|
"args": [
|
||||||
|
"--no-sandbox",
|
||||||
|
"--disable-setuid-sandbox",
|
||||||
|
"--disable-gpu",
|
||||||
|
"--disable-gpu-compositing",
|
||||||
|
"--disable-software-rasterizer",
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
"--disable-features=VizDisplayCompositor",
|
||||||
|
"--use-gl=swiftshader",
|
||||||
|
"--disable-gpu-driver-bug-workaround"
|
||||||
|
]
|
||||||
|
}
|
||||||
Executable
+29
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run any dotnet command for this repo.
|
||||||
|
#
|
||||||
|
# Why a wrapper:
|
||||||
|
# - dotnet is installed via mise (see mise.toml, dotnet = "10"); it is NOT on
|
||||||
|
# PATH in agent/sandbox shells, so plain `dotnet` fails with 127.
|
||||||
|
# - dotnet SDK 10 fails with "The user's home directory could not be
|
||||||
|
# determined" unless DOTNET_CLI_HOME is set.
|
||||||
|
# - The test runner must be invoked from the repo root (the parent directory
|
||||||
|
# of the test project), so we always cd here first.
|
||||||
|
#
|
||||||
|
# Usage: scripts/dotnet.sh <dotnet command and args>
|
||||||
|
# scripts/dotnet.sh test db-subclass-to-dto.sln
|
||||||
|
# scripts/dotnet.sh build
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
export DOTNET_CLI_HOME="${DOTNET_CLI_HOME:-$HOME/.dotnet-cli}"
|
||||||
|
export DOTNET_NOLOGO=1
|
||||||
|
export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1
|
||||||
|
|
||||||
|
if ! command -v mise >/dev/null 2>&1; then
|
||||||
|
echo "error: mise not found on PATH; install mise or put dotnet on PATH" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$#" -eq 0 ]; then
|
||||||
|
exec mise exec -- dotnet --version
|
||||||
|
fi
|
||||||
|
exec mise exec -- dotnet "$@"
|
||||||
Executable
+31
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Render a mermaid diagram to SVG using mmdc with sandbox/GPU workarounds
|
||||||
|
# for Ubuntu 24.04+ (AppArmor restrictions).
|
||||||
|
#
|
||||||
|
# Usage: scripts/mermaid.sh input.mmd [output.svg]
|
||||||
|
# scripts/mermaid.sh input.mmd - # output to stdout
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
CONFIG="$REPO_ROOT/puppeteer.config.json"
|
||||||
|
|
||||||
|
if [[ $# -lt 1 ]]; then
|
||||||
|
echo "Usage: $0 <input.mmd> [output.svg|-]" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
INPUT="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
ARGS=("-i" "$INPUT" "-p" "$CONFIG")
|
||||||
|
|
||||||
|
if [[ $# -gt 0 ]]; then
|
||||||
|
OUTPUT="$1"
|
||||||
|
shift
|
||||||
|
ARGS+=("-o" "$OUTPUT")
|
||||||
|
fi
|
||||||
|
|
||||||
|
ARGS+=("$@")
|
||||||
|
|
||||||
|
exec mmdc "${ARGS[@]}"
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run the full test suite from the repo root (see scripts/dotnet.sh for why).
|
||||||
|
# Usage: scripts/run-tests.sh [extra dotnet test args]
|
||||||
|
set -euo pipefail
|
||||||
|
exec "$(cd "$(dirname "$0")" && pwd)/dotnet.sh" test db-subclass-to-dto.sln "$@"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using Domain;
|
||||||
|
|
||||||
|
namespace Db;
|
||||||
|
|
||||||
|
/// <summary>Persisted shape of a <see cref="Client"/>: data only.</summary>
|
||||||
|
public sealed class ClientData : DbBase
|
||||||
|
{
|
||||||
|
/// <summary>Display name of the client.</summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using Domain;
|
||||||
|
|
||||||
|
namespace Db;
|
||||||
|
|
||||||
|
/// <summary>Hand-written mappers between domain objects and persistence records; each method maps one direction and never mutates its input.</summary>
|
||||||
|
public static class DataMappers
|
||||||
|
{
|
||||||
|
/// <summary>Map a <see cref="Client"/> to its persistence record <see cref="ClientData"/>.</summary>
|
||||||
|
public static ClientData ToClientData(Client client)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(client);
|
||||||
|
return new ClientData { Id = client.Id, Name = client.Name };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Map an <see cref="Order"/> to its persistence record <see cref="OrderData"/>; the client back-reference becomes a foreign key.</summary>
|
||||||
|
public static OrderData ToOrderData(Order order)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(order);
|
||||||
|
return new OrderData
|
||||||
|
{
|
||||||
|
Id = order.Id,
|
||||||
|
Description = order.Description,
|
||||||
|
TotalInCents = order.TotalInCents.Cents, // value object → plain int for the row
|
||||||
|
ClientId = order.Client?.Id ?? Guid.Empty,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Map a <see cref="ClientData"/> record back to a <see cref="Client"/>.</summary>
|
||||||
|
public static Client FromClientData(ClientData row)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(row);
|
||||||
|
return new Client { Id = row.Id, Name = row.Name };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Map an <see cref="OrderData"/> record back to an <see cref="Order"/>; the context reassembles the graph.</summary>
|
||||||
|
public static Order FromOrderData(OrderData row)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(row);
|
||||||
|
return new Order
|
||||||
|
{
|
||||||
|
Id = row.Id,
|
||||||
|
Description = row.Description,
|
||||||
|
TotalInCents = OrderMoney.From(row.TotalInCents), // plain int from the row → value object
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Domain\Domain.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using Domain;
|
||||||
|
|
||||||
|
namespace Db;
|
||||||
|
|
||||||
|
/// <summary>Persistence framework base class: only the data records derive from it, never the domain objects.</summary>
|
||||||
|
public class DbBase
|
||||||
|
{
|
||||||
|
/// <summary>Primary key; <see cref="Guid.Empty"/> until the context assigns one on save.</summary>
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Back-reference to the owning <see cref="DbContext"/>; lives on persistence records only.</summary>
|
||||||
|
public DbContext? DbContext { get; internal set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using Domain;
|
||||||
|
|
||||||
|
namespace Db;
|
||||||
|
|
||||||
|
/// <summary>The fake DbContext: per-type tables of data-only records (never domain objects); Save inserts-or-refreshes rows, Load reassembles domain objects.</summary>
|
||||||
|
public class DbContext
|
||||||
|
{
|
||||||
|
private readonly List<ClientData> _clients = new();
|
||||||
|
private readonly List<OrderData> _orders = new();
|
||||||
|
|
||||||
|
// domain object reference → its persisted record
|
||||||
|
private readonly Dictionary<object, DbBase> _byRef = new();
|
||||||
|
|
||||||
|
public IReadOnlyList<ClientData> Clients => _clients.AsReadOnly();
|
||||||
|
public IReadOnlyList<OrderData> Orders => _orders.AsReadOnly();
|
||||||
|
|
||||||
|
/// <summary>The strongly-typed "table" for <typeparamref name="T"/>.</summary>
|
||||||
|
public IReadOnlyList<T> Set<T>() where T : DbBase
|
||||||
|
=> typeof(T) switch
|
||||||
|
{
|
||||||
|
var t when t == typeof(ClientData) => (IReadOnlyList<T>)_clients.AsReadOnly(),
|
||||||
|
var t when t == typeof(OrderData) => (IReadOnlyList<T>)_orders.AsReadOnly(),
|
||||||
|
_ => throw new NotSupportedException($"No per-type table for {typeof(T)}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Persist a <see cref="Client"/>: insert on first save (assigning its id), refresh in place on later ones.</summary>
|
||||||
|
public void Save(Client client)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(client);
|
||||||
|
var fresh = DataMappers.ToClientData(client);
|
||||||
|
if (_byRef.TryGetValue(client, out var existing))
|
||||||
|
{
|
||||||
|
var row = (ClientData)existing; // refresh, keep row identity
|
||||||
|
row.Name = fresh.Name;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
fresh.Id = client.Id = id; // write-back identity
|
||||||
|
fresh.DbContext = this;
|
||||||
|
_clients.Add(fresh);
|
||||||
|
_byRef[client] = fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Persist an <see cref="Order"/> (same insert-or-refresh rule; save its client first so the foreign key can be filled).</summary>
|
||||||
|
public void Save(Order order)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(order);
|
||||||
|
var fresh = DataMappers.ToOrderData(order);
|
||||||
|
if (_byRef.TryGetValue(order, out var existing))
|
||||||
|
{
|
||||||
|
var row = (OrderData)existing; // refresh, keep row identity
|
||||||
|
row.Description = fresh.Description;
|
||||||
|
row.TotalInCents = fresh.TotalInCents;
|
||||||
|
row.ClientId = fresh.ClientId;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
fresh.Id = order.Id = id; // write-back identity
|
||||||
|
fresh.DbContext = this;
|
||||||
|
_orders.Add(fresh);
|
||||||
|
_byRef[order] = fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Load the <see cref="Client"/> stored under <paramref name="id"/> with all of its orders, or null.</summary>
|
||||||
|
public Client? LoadClient(Guid id)
|
||||||
|
{
|
||||||
|
var row = Find<ClientData>(r => r.Id == id);
|
||||||
|
if (row is null) return null;
|
||||||
|
var client = DataMappers.FromClientData(row);
|
||||||
|
foreach (var orderRow in _orders.Where(o => o.ClientId == id))
|
||||||
|
client.Orders.Add(DataMappers.FromOrderData(orderRow)); // fix-up wires Order.Client
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Load the <see cref="Order"/> stored under <paramref name="id"/> with its client, or null; use <see cref="LoadClient"/> for a client with all its orders.</summary>
|
||||||
|
public Order? LoadOrder(Guid id)
|
||||||
|
{
|
||||||
|
var row = Find<OrderData>(r => r.Id == id);
|
||||||
|
if (row is null) return null;
|
||||||
|
var order = DataMappers.FromOrderData(row);
|
||||||
|
if (row.ClientId != Guid.Empty)
|
||||||
|
{
|
||||||
|
var clientRow = Find<ClientData>(c => c.Id == row.ClientId);
|
||||||
|
if (clientRow is not null)
|
||||||
|
{
|
||||||
|
var client = DataMappers.FromClientData(clientRow);
|
||||||
|
client.Orders.Add(order); // fix-up wires Order.Client
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Search the per-type table for a record whose predicate matches.</summary>
|
||||||
|
public T? Find<T>(Func<T, bool> predicate) where T : DbBase
|
||||||
|
=> Set<T>().FirstOrDefault(predicate);
|
||||||
|
|
||||||
|
/// <summary>True if <paramref name="entity"/> has been saved through this context.</summary>
|
||||||
|
public bool IsTracked(object entity) => _byRef.ContainsKey(entity);
|
||||||
|
|
||||||
|
/// <summary>All data records this context currently manages.</summary>
|
||||||
|
public IReadOnlyList<DbBase> Tracked => _byRef.Values.ToList();
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using Domain;
|
||||||
|
|
||||||
|
namespace Db;
|
||||||
|
|
||||||
|
/// <summary>Persisted shape of an <see cref="Order"/>: data only, object graph flattened to ids.</summary>
|
||||||
|
public sealed class OrderData : DbBase
|
||||||
|
{
|
||||||
|
/// <summary>Free-text description of what this order is for.</summary>
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Order total in cents of the stored order.</summary>
|
||||||
|
public int TotalInCents { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Foreign key to the owning client; empty when the order has no client.</summary>
|
||||||
|
public Guid ClientId { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
|
namespace Domain;
|
||||||
|
|
||||||
|
/// <summary>A plain domain object: no persistence base class, no persistence context knowledge.</summary>
|
||||||
|
public class Client
|
||||||
|
{
|
||||||
|
/// <summary>Identity: <see cref="Guid.Empty"/> until the persistence layer assigns one on save.</summary>
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Display name of the client.</summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>The client's orders; adding one wires its <see cref="Order.Client"/> back-reference.</summary>
|
||||||
|
public ClientOrders Orders { get; }
|
||||||
|
|
||||||
|
public Client()
|
||||||
|
{
|
||||||
|
Orders = new ClientOrders(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An <see cref="Order"/> collection that fakes EF's navigation fix-up.</summary>
|
||||||
|
public sealed class ClientOrders : Collection<Order>
|
||||||
|
{
|
||||||
|
private readonly Client _owner;
|
||||||
|
|
||||||
|
public ClientOrders(Client owner)
|
||||||
|
{
|
||||||
|
_owner = owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add(...) and Insert(...) both funnel through InsertItem.
|
||||||
|
protected override void InsertItem(int index, Order item)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(item);
|
||||||
|
item.Client = _owner; // navigation fix-up
|
||||||
|
base.InsertItem(index, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace Domain;
|
||||||
|
|
||||||
|
/// <summary>A plain domain order: no persistence base class, no persistence context knowledge.</summary>
|
||||||
|
public class Order
|
||||||
|
{
|
||||||
|
/// <summary>Identity: <see cref="Guid.Empty"/> until the persistence layer assigns one on save.</summary>
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Free-text description of what this order is for.</summary>
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Order total as a value object; the DTO and storage row stay plain ints — the mappers convert.</summary>
|
||||||
|
public OrderMoney TotalInCents { get; set; } = OrderMoney.Default;
|
||||||
|
|
||||||
|
/// <summary>The owning client; wired by navigation fix-up when the order is added to <see cref="Client.Orders"/>.</summary>
|
||||||
|
public Client? Client { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
namespace Domain;
|
||||||
|
|
||||||
|
/// <summary>Money value object: non-negative whole cents; invalid amounts are unrepresentable. Record: value equality.</summary>
|
||||||
|
public sealed record OrderMoney
|
||||||
|
{
|
||||||
|
private OrderMoney(int cents) => Cents = cents;
|
||||||
|
|
||||||
|
/// <summary>The only validating construction path.</summary>
|
||||||
|
public static OrderMoney From(int cents)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegative(cents);
|
||||||
|
return new OrderMoney(cents);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Zero sentinel for member initializers (like <see cref="Guid.Empty"/>); internal to this assembly.</summary>
|
||||||
|
internal static OrderMoney Default { get; } = new(0);
|
||||||
|
|
||||||
|
/// <summary>The raw amount in cents.</summary>
|
||||||
|
public int Cents { get; }
|
||||||
|
|
||||||
|
/// <summary>Explicit conversions only; int → OrderMoney validates exactly like <see cref="From"/>.</summary>
|
||||||
|
public static explicit operator OrderMoney(int cents) => From(cents);
|
||||||
|
|
||||||
|
public static explicit operator int(OrderMoney money)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(money);
|
||||||
|
return money.Cents;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => Cents.ToString();
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using Domain;
|
||||||
|
|
||||||
|
namespace Web;
|
||||||
|
|
||||||
|
/// <summary>DTO carrying a <see cref="Client"/> — with its orders — to the WebApp; record: value equality keeps round-trip comparisons trivial.</summary>
|
||||||
|
public sealed record ClientDto
|
||||||
|
{
|
||||||
|
/// <summary>Primary key of the underlying client.</summary>
|
||||||
|
public Guid Id { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Display name of the client.</summary>
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>The client's orders, each carrying a back-reference to this DTO; settable so the mappers can fill it in a second pass.</summary>
|
||||||
|
public IReadOnlyList<OrderDto> Orders { get; set; } = Array.Empty<OrderDto>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using Domain;
|
||||||
|
|
||||||
|
namespace Web;
|
||||||
|
|
||||||
|
/// <summary>Hand-written mappers between domain objects and DTOs; each method maps one direction and never mutates its input. The client/order cycle is resolved with a reference-identity cache.</summary>
|
||||||
|
public static class DtoMappers
|
||||||
|
{
|
||||||
|
/// <summary>Map a <see cref="Client"/> (with its orders) to a <see cref="ClientDto"/>.</summary>
|
||||||
|
public static ClientDto ToClientDto(Client client)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(client);
|
||||||
|
return MapClient(client, new Dictionary<object, object>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Map an <see cref="Order"/> (with its client back-reference) to an <see cref="OrderDto"/>.</summary>
|
||||||
|
public static OrderDto ToOrderDto(Order order)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(order);
|
||||||
|
return MapOrder(order, new Dictionary<object, object>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Map a <see cref="ClientDto"/> (with its orders) back to a <see cref="Client"/>.</summary>
|
||||||
|
public static Client FromClientDto(ClientDto dto)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(dto);
|
||||||
|
return UnmapClient(dto, new Dictionary<object, object>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Map an <see cref="OrderDto"/> (with its client back-reference) back to an <see cref="Order"/>.</summary>
|
||||||
|
public static Order FromOrderDto(OrderDto dto)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(dto);
|
||||||
|
return UnmapOrder(dto, new Dictionary<object, object>());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClientDto MapClient(Client client, Dictionary<object, object> seen)
|
||||||
|
{
|
||||||
|
if (seen.TryGetValue(client, out var cached))
|
||||||
|
return (ClientDto)cached;
|
||||||
|
|
||||||
|
var dto = new ClientDto { Id = client.Id, Name = client.Name };
|
||||||
|
seen[client] = dto; // cache first: breaks the cycle
|
||||||
|
dto.Orders = client.Orders
|
||||||
|
.Select(o => MapOrder(o, seen))
|
||||||
|
.ToList(); // fill second, through the cache
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OrderDto MapOrder(Order order, Dictionary<object, object> seen)
|
||||||
|
{
|
||||||
|
if (seen.TryGetValue(order, out var cached))
|
||||||
|
return (OrderDto)cached;
|
||||||
|
|
||||||
|
var dto = new OrderDto
|
||||||
|
{
|
||||||
|
Id = order.Id,
|
||||||
|
Description = order.Description,
|
||||||
|
TotalInCents = order.TotalInCents.Cents, // value object → plain int for the DTO
|
||||||
|
ClientDto = order.Client is null ? null : MapClient(order.Client, seen),
|
||||||
|
};
|
||||||
|
seen[order] = dto;
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Client UnmapClient(ClientDto dto, Dictionary<object, object> seen)
|
||||||
|
{
|
||||||
|
if (seen.TryGetValue(dto, out var cached))
|
||||||
|
return (Client)cached;
|
||||||
|
|
||||||
|
var client = new Client { Id = dto.Id, Name = dto.Name };
|
||||||
|
seen[dto] = client; // cache first: breaks the cycle
|
||||||
|
foreach (var orderDto in dto.Orders)
|
||||||
|
client.Orders.Add(UnmapOrder(orderDto, seen)); // fix-up wires order.Client
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Order UnmapOrder(OrderDto dto, Dictionary<object, object> seen)
|
||||||
|
{
|
||||||
|
if (seen.TryGetValue(dto, out var cached))
|
||||||
|
return (Order)cached;
|
||||||
|
|
||||||
|
var order = new Order
|
||||||
|
{
|
||||||
|
Id = dto.Id,
|
||||||
|
Description = dto.Description,
|
||||||
|
TotalInCents = OrderMoney.From(dto.TotalInCents), // plain int from the DTO → value object
|
||||||
|
Client = dto.ClientDto is null ? null : UnmapClient(dto.ClientDto, seen),
|
||||||
|
};
|
||||||
|
seen[dto] = order;
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Domain;
|
||||||
|
|
||||||
|
namespace Web;
|
||||||
|
|
||||||
|
/// <summary>DTO carrying an <see cref="Order"/> — including its client back-reference — to the WebApp; record: value equality.</summary>
|
||||||
|
public sealed record OrderDto
|
||||||
|
{
|
||||||
|
/// <summary>Primary key of the underlying order.</summary>
|
||||||
|
public Guid Id { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Free-text description of what this order is for.</summary>
|
||||||
|
public string Description { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Order total in cents of the underlying order.</summary>
|
||||||
|
public int TotalInCents { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Back-reference to the owning client, or null for a bare order.</summary>
|
||||||
|
public ClientDto? ClientDto { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Deliberately NO reference to Db: the Web layer consumes DTOs
|
||||||
|
only, so it cannot reach the persistence layer — the leak the
|
||||||
|
exercise illustrates is now enforced by the compiler. -->
|
||||||
|
<ProjectReference Include="..\Domain\Domain.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace Web;
|
||||||
|
|
||||||
|
/// <summary>The consuming application: it sees only DTOs, never domain or persistence types.</summary>
|
||||||
|
public class WebApp
|
||||||
|
{
|
||||||
|
/// <summary>Render a client together with all of its orders, consuming DTOs only.</summary>
|
||||||
|
public string ShowClient(ClientDto client)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(client);
|
||||||
|
|
||||||
|
var lines = new List<string> { $"{client.Name} [{client.Id}]" };
|
||||||
|
foreach (var order in client.Orders)
|
||||||
|
lines.Add($" - order [{order.Id}]: {order.Description}");
|
||||||
|
return string.Join(Environment.NewLine, lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
using Domain;
|
||||||
|
using static TestData.ClientBuilder;
|
||||||
|
using static TestData.OrderBuilder;
|
||||||
|
|
||||||
|
namespace Db.Tests;
|
||||||
|
|
||||||
|
/// <summary>The fake <see cref="DbContext"/> stores data-only records, never domain objects; Save maps state into rows (insert or refresh), the graph flattened to ids.</summary>
|
||||||
|
public class AfterPersistenceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Save_maps_domain_state_into_data_records()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var order = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
|
||||||
|
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||||
|
|
||||||
|
db.Save(client);
|
||||||
|
db.Save(order);
|
||||||
|
|
||||||
|
var clientRow = Assert.Single(db.Clients);
|
||||||
|
Assert.Equal(client.Id, clientRow.Id);
|
||||||
|
Assert.Equal("Acme", clientRow.Name);
|
||||||
|
|
||||||
|
var orderRow = Assert.Single(db.Orders);
|
||||||
|
Assert.Equal(order.Id, orderRow.Id);
|
||||||
|
Assert.Equal("one", orderRow.Description);
|
||||||
|
Assert.Equal(150_00, orderRow.TotalInCents);
|
||||||
|
Assert.Equal(client.Id, orderRow.ClientId); // the graph is flattened to a key
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_order_without_client_stores_an_empty_client_id()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var order = ValidOrder().WithDescription("orphan").Build();
|
||||||
|
|
||||||
|
db.Save(order);
|
||||||
|
|
||||||
|
Assert.Equal(Guid.Empty, Assert.Single(db.Orders).ClientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Saving_again_refreshes_the_row_instead_of_duplicating_it()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var order = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
|
||||||
|
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||||
|
db.Save(client);
|
||||||
|
db.Save(order);
|
||||||
|
|
||||||
|
order.Description = "one (edited)";
|
||||||
|
order.TotalInCents = OrderMoney.From(99_00);
|
||||||
|
client.Name = "Acme GmbH";
|
||||||
|
db.Save(order);
|
||||||
|
db.Save(client);
|
||||||
|
|
||||||
|
// One row per saved object, refreshed with the *current* state:
|
||||||
|
var orderRow = Assert.Single(db.Orders);
|
||||||
|
Assert.Equal(order.Id, orderRow.Id);
|
||||||
|
Assert.Equal("one (edited)", orderRow.Description);
|
||||||
|
Assert.Equal(99_00, orderRow.TotalInCents);
|
||||||
|
Assert.Equal(client.Id, orderRow.ClientId);
|
||||||
|
|
||||||
|
var clientRow = Assert.Single(db.Clients);
|
||||||
|
Assert.Equal("Acme GmbH", clientRow.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rows_are_reachable_through_Set_and_Find()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var order = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
|
||||||
|
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||||
|
db.Save(client);
|
||||||
|
db.Save(order);
|
||||||
|
|
||||||
|
Assert.Single(db.Set<ClientData>());
|
||||||
|
Assert.Single(db.Set<OrderData>());
|
||||||
|
|
||||||
|
var found = db.Find<OrderData>(row => row.Description == "one");
|
||||||
|
Assert.Equal(order.Id, found!.Id);
|
||||||
|
Assert.Null(db.Find<OrderData>(row => row.Description == "no such order"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read-back: LoadOrder / LoadClient reassemble domain objects from the stored rows.
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Loading_an_order_restores_its_state_and_its_client()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var order = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
|
||||||
|
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||||
|
db.Save(client);
|
||||||
|
db.Save(order);
|
||||||
|
|
||||||
|
var loaded = db.LoadOrder(order.Id);
|
||||||
|
|
||||||
|
Assert.NotNull(loaded);
|
||||||
|
Assert.Equal(order.Id, loaded!.Id);
|
||||||
|
Assert.Equal("one", loaded.Description);
|
||||||
|
Assert.Equal(150_00, loaded.TotalInCents.Cents);
|
||||||
|
|
||||||
|
// The graph is reassembled: the loaded order knows its client again,
|
||||||
|
// and the client's collection holds the loaded order.
|
||||||
|
Assert.NotNull(loaded.Client);
|
||||||
|
Assert.Equal("Acme", loaded.Client!.Name);
|
||||||
|
Assert.Same(loaded, Assert.Single(loaded.Client.Orders));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Loading_a_client_restores_it_with_all_of_its_orders()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var first = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
|
||||||
|
var second = ValidOrder().WithDescription("two").WithTotal(250_00).Build();
|
||||||
|
var client = ValidClient().WithName("Acme").WithOrder(first).WithOrder(second).Build();
|
||||||
|
db.Save(client);
|
||||||
|
db.Save(first);
|
||||||
|
db.Save(second);
|
||||||
|
|
||||||
|
var loaded = db.LoadClient(client.Id);
|
||||||
|
|
||||||
|
Assert.NotNull(loaded);
|
||||||
|
Assert.Equal("Acme", loaded!.Name);
|
||||||
|
Assert.Equal(2, loaded.Orders.Count);
|
||||||
|
Assert.All(loaded.Orders, o => Assert.Same(loaded, o.Client));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Loading_an_unknown_id_returns_null()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
|
||||||
|
Assert.Null(db.LoadOrder(Guid.NewGuid()));
|
||||||
|
Assert.Null(db.LoadClient(Guid.NewGuid()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tracking: explicit Save, distinct ids; a saved object keeps no reference to the context.
|
||||||
|
[Fact]
|
||||||
|
public void DbContext_saves_plain_domain_objects_and_tracks_them()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var order = ValidOrder().WithDescription("one").Build();
|
||||||
|
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||||
|
|
||||||
|
Assert.Equal(Guid.Empty, client.Id);
|
||||||
|
db.Save(client);
|
||||||
|
db.Save(order);
|
||||||
|
|
||||||
|
Assert.NotEqual(Guid.Empty, client.Id);
|
||||||
|
Assert.NotEqual(Guid.Empty, order.Id);
|
||||||
|
Assert.NotEqual(client.Id, order.Id);
|
||||||
|
Assert.True(db.IsTracked(client));
|
||||||
|
Assert.True(db.IsTracked(order));
|
||||||
|
Assert.Equal(2, db.Tracked.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Saving_twice_does_not_reassign_the_id()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var client = ValidClient().WithName("Acme").Build();
|
||||||
|
db.Save(client);
|
||||||
|
var id = client.Id;
|
||||||
|
|
||||||
|
db.Save(client);
|
||||||
|
|
||||||
|
Assert.Equal(id, client.Id);
|
||||||
|
Assert.Single(db.Tracked);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_saved_domain_object_keeps_no_reference_to_the_context()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var client = ValidClient().WithName("Acme").Build();
|
||||||
|
db.Save(client);
|
||||||
|
|
||||||
|
// Saved (the id was assigned by the context) ...
|
||||||
|
Assert.NotEqual(Guid.Empty, client.Id);
|
||||||
|
Assert.True(db.IsTracked(client));
|
||||||
|
|
||||||
|
// No way back to the context through the domain object (see DomainStaysPlainTests): the only "receipt" is the plain Id value.
|
||||||
|
Assert.Equal(db.Tracked.Single(t => t.Id == client.Id).Id, client.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Domain is referenced explicitly because these tests use domain
|
||||||
|
types directly (it also flows in transitively through Db). -->
|
||||||
|
<ProjectReference Include="..\..\src\Domain\Domain.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\Db\Db.csproj" />
|
||||||
|
<ProjectReference Include="..\TestData\TestData.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using Domain;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace Db.Tests;
|
||||||
|
|
||||||
|
/// <summary>The domain stayed clean: Client/Order do NOT derive from <see cref="DbBase"/> and expose no <see cref="DbContext"/>-typed member; asserted from the persistence side, where both are visible.</summary>
|
||||||
|
public class DomainStaysPlainTests
|
||||||
|
{
|
||||||
|
private const BindingFlags AllMembers =
|
||||||
|
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;
|
||||||
|
|
||||||
|
// The leak is gone: no DbBase in the base chain, no DbContext-typed member (reflection).
|
||||||
|
[Theory]
|
||||||
|
[InlineData(typeof(Client))]
|
||||||
|
[InlineData(typeof(Order))]
|
||||||
|
public void Domain_objects_are_plain__no_DbBase_no_DbContext_member(Type type)
|
||||||
|
{
|
||||||
|
// Not DbBase, and not a derived type either (walk the base chain):
|
||||||
|
for (var t = type; t is not null && t != typeof(object); t = t.BaseType)
|
||||||
|
Assert.NotSame(typeof(DbBase), t);
|
||||||
|
|
||||||
|
// No DbContext-typed member, public or private:
|
||||||
|
foreach (var p in type.GetProperties(AllMembers))
|
||||||
|
Assert.True(p.PropertyType != typeof(DbContext),
|
||||||
|
$"{type.Name}.{p.Name} leaks a DbContext-typed property");
|
||||||
|
foreach (var f in type.GetFields(AllMembers))
|
||||||
|
Assert.True(f.FieldType != typeof(DbContext),
|
||||||
|
$"{type.Name}.{f.Name} leaks a DbContext-typed field");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\Domain\Domain.csproj" />
|
||||||
|
<ProjectReference Include="..\TestData\TestData.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
namespace Domain.Tests;
|
||||||
|
|
||||||
|
/// <summary>Unit tests for the <see cref="OrderMoney"/> value object.</summary>
|
||||||
|
public class OrderMoneyTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void From_keeps_the_cents()
|
||||||
|
{
|
||||||
|
Assert.Equal(150_00, OrderMoney.From(150_00).Cents);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Zero_is_valid()
|
||||||
|
{
|
||||||
|
Assert.Equal(0, OrderMoney.From(0).Cents);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Negative_cents_are_rejected()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => OrderMoney.From(-1));
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => _ = (OrderMoney)(-1)); // conversion validates too
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Money_compares_by_value_not_reference()
|
||||||
|
{
|
||||||
|
Assert.Equal(OrderMoney.From(150_00), OrderMoney.From(150_00));
|
||||||
|
Assert.NotEqual(OrderMoney.From(150_00), OrderMoney.From(151_00));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Converting_back_to_cents_is_explicit()
|
||||||
|
{
|
||||||
|
var money = OrderMoney.From(150_00);
|
||||||
|
|
||||||
|
Assert.Equal(150_00, (int)money);
|
||||||
|
Assert.Equal(150_00, money.Cents);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToString_shows_the_cents()
|
||||||
|
{
|
||||||
|
Assert.Equal("15000", OrderMoney.From(150_00).ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using Domain;
|
||||||
|
|
||||||
|
namespace TestData;
|
||||||
|
|
||||||
|
/// <summary>Test data builder for a <see cref="Client"/> (TestDataBuilder pattern); <see cref="ValidClient"/> starts fresh — exactly what <c>new Client()</c> gives.</summary>
|
||||||
|
public sealed class ClientBuilder
|
||||||
|
{
|
||||||
|
private readonly Client _client = new();
|
||||||
|
|
||||||
|
/// <summary>A valid, fresh client — the neutral starting point for tests.</summary>
|
||||||
|
public static ClientBuilder ValidClient() => new();
|
||||||
|
|
||||||
|
public ClientBuilder WithId(Guid id)
|
||||||
|
{
|
||||||
|
_client.Id = id;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ClientBuilder WithName(string name)
|
||||||
|
{
|
||||||
|
_client.Name = name;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Adds an order to the client (navigation fix-up wires the back-reference).</summary>
|
||||||
|
public ClientBuilder WithOrder(Order order)
|
||||||
|
{
|
||||||
|
_client.Orders.Add(order);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds the given builder and adds its order — chaining sugar for <c>.WithOrder(builder.Build())</c>.</summary>
|
||||||
|
public ClientBuilder WithOrder(OrderBuilder order) => WithOrder(order.Build());
|
||||||
|
|
||||||
|
public Client Build() => _client;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using Domain;
|
||||||
|
|
||||||
|
namespace TestData;
|
||||||
|
|
||||||
|
/// <summary>Test data builder for an <see cref="Order"/> (TestDataBuilder pattern); the only place tests touch an order's construction, so tests survive domain evolution.</summary>
|
||||||
|
public sealed class OrderBuilder
|
||||||
|
{
|
||||||
|
private readonly Order _order = new();
|
||||||
|
|
||||||
|
/// <summary>A valid, fresh order — the neutral starting point for tests.</summary>
|
||||||
|
public static OrderBuilder ValidOrder() => new();
|
||||||
|
|
||||||
|
public OrderBuilder WithId(Guid id)
|
||||||
|
{
|
||||||
|
_order.Id = id;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OrderBuilder WithDescription(string description)
|
||||||
|
{
|
||||||
|
_order.Description = description;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sets the total in cents (<c>150_00</c> = €150.00), wrapped in <see cref="OrderMoney"/>.</summary>
|
||||||
|
public OrderBuilder WithTotal(int totalInCents)
|
||||||
|
{
|
||||||
|
_order.TotalInCents = OrderMoney.From(totalInCents);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Order Build() => _order;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\Domain\Domain.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
using Domain;
|
||||||
|
using static TestData.ClientBuilder;
|
||||||
|
using static TestData.OrderBuilder;
|
||||||
|
|
||||||
|
namespace Web.Tests;
|
||||||
|
|
||||||
|
/// <summary>The hand-written <see cref="DtoMappers"/> map both ways, never mutate their input, and round-trip losslessly; domain objects come from the test-data builders, DTO initializers stay literal.</summary>
|
||||||
|
public class DtoMapperTests
|
||||||
|
{
|
||||||
|
// (1) Mappers map all scalar fields, in both directions.
|
||||||
|
[Fact]
|
||||||
|
public void ToClientDto_maps_all_scalar_fields()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var client = ValidClient().WithId(id).WithName("Acme").Build();
|
||||||
|
|
||||||
|
var dto = DtoMappers.ToClientDto(client);
|
||||||
|
|
||||||
|
Assert.Equal(id, dto.Id);
|
||||||
|
Assert.Equal("Acme", dto.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToOrderDto_maps_all_scalar_fields()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var order = ValidOrder().WithId(id).WithDescription("one").WithTotal(150_00).Build();
|
||||||
|
|
||||||
|
var dto = DtoMappers.ToOrderDto(order);
|
||||||
|
|
||||||
|
Assert.Equal(id, dto.Id);
|
||||||
|
Assert.Equal("one", dto.Description);
|
||||||
|
Assert.Equal(150_00, dto.TotalInCents);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromClientDto_maps_all_scalar_fields()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var dto = new ClientDto { Id = id, Name = "Acme" };
|
||||||
|
|
||||||
|
var client = DtoMappers.FromClientDto(dto);
|
||||||
|
|
||||||
|
Assert.Equal(id, client.Id);
|
||||||
|
Assert.Equal("Acme", client.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromOrderDto_maps_all_scalar_fields()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var dto = new OrderDto { Id = id, Description = "one", TotalInCents = 150_00 };
|
||||||
|
|
||||||
|
var order = DtoMappers.FromOrderDto(dto);
|
||||||
|
|
||||||
|
Assert.Equal(id, order.Id);
|
||||||
|
Assert.Equal("one", order.Description);
|
||||||
|
Assert.Equal(150_00, order.TotalInCents.Cents);
|
||||||
|
}
|
||||||
|
|
||||||
|
// (2) Mappers map the relations, and the back-references stay consistent.
|
||||||
|
[Fact]
|
||||||
|
public void ToClientDto_maps_orders_and_keeps_the_back_reference_consistent()
|
||||||
|
{
|
||||||
|
var order = ValidOrder().WithDescription("one").Build();
|
||||||
|
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||||
|
|
||||||
|
var dto = DtoMappers.ToClientDto(client);
|
||||||
|
var orderDto = Assert.Single(dto.Orders);
|
||||||
|
|
||||||
|
// The order is mapped with the client's data:
|
||||||
|
Assert.Equal(order.Id, orderDto.Id);
|
||||||
|
Assert.Equal(order.Description, orderDto.Description);
|
||||||
|
|
||||||
|
// The back-reference cycle is resolved to the SAME dto reference:
|
||||||
|
Assert.Same(dto, orderDto.ClientDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromClientDto_wires_the_order_client_back_reference()
|
||||||
|
{
|
||||||
|
var orderDto = new OrderDto { Description = "one" };
|
||||||
|
var dto = new ClientDto { Name = "Acme", Orders = new[] { orderDto } };
|
||||||
|
|
||||||
|
var client = DtoMappers.FromClientDto(dto);
|
||||||
|
var order = Assert.Single(client.Orders);
|
||||||
|
|
||||||
|
Assert.Equal(orderDto.Description, order.Description);
|
||||||
|
// Navigation fix-up: the reconstructed order points at the client:
|
||||||
|
Assert.Same(client, order.Client);
|
||||||
|
}
|
||||||
|
|
||||||
|
// (3) Round-trips preserve all state (compared by values, not reference).
|
||||||
|
[Fact]
|
||||||
|
public void Domain_round_trip_preserves_all_state()
|
||||||
|
{
|
||||||
|
var order = ValidOrder().WithId(Guid.NewGuid()).WithDescription("one").WithTotal(150_00).Build();
|
||||||
|
var client = ValidClient().WithId(Guid.NewGuid()).WithName("Acme").WithOrder(order).Build();
|
||||||
|
|
||||||
|
var roundTrip = DtoMappers.FromClientDto(DtoMappers.ToClientDto(client));
|
||||||
|
|
||||||
|
Assert.Equal(client.Id, roundTrip.Id);
|
||||||
|
Assert.Equal(client.Name, roundTrip.Name);
|
||||||
|
var roundTripOrder = Assert.Single(roundTrip.Orders);
|
||||||
|
Assert.Equal(order.Id, roundTripOrder.Id);
|
||||||
|
Assert.Equal(order.Description, roundTripOrder.Description);
|
||||||
|
Assert.Equal(order.TotalInCents, roundTripOrder.TotalInCents);
|
||||||
|
// The back-reference is still consistent on the reconstructed graph:
|
||||||
|
Assert.Same(roundTrip, roundTripOrder.Client);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dto_round_trip_preserves_all_state()
|
||||||
|
{
|
||||||
|
var order = ValidOrder().WithId(Guid.NewGuid()).WithDescription("one").WithTotal(150_00).Build();
|
||||||
|
var client = ValidClient().WithId(Guid.NewGuid()).WithName("Acme").WithOrder(order).Build();
|
||||||
|
var dto = DtoMappers.ToClientDto(client);
|
||||||
|
var dtoOrder = dto.Orders[0];
|
||||||
|
|
||||||
|
var roundTrip = DtoMappers.ToClientDto(DtoMappers.FromClientDto(dto));
|
||||||
|
|
||||||
|
Assert.Equal(dto.Id, roundTrip.Id);
|
||||||
|
Assert.Equal(dto.Name, roundTrip.Name);
|
||||||
|
var roundTripOrder = Assert.Single(roundTrip.Orders);
|
||||||
|
Assert.Equal(dtoOrder.Id, roundTripOrder.Id);
|
||||||
|
Assert.Equal(dtoOrder.Description, roundTripOrder.Description);
|
||||||
|
Assert.Equal(dtoOrder.TotalInCents, roundTripOrder.TotalInCents);
|
||||||
|
Assert.Same(roundTrip, roundTripOrder.ClientDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// (4) Mappers do not mutate their inputs (snapshot fields, map, compare).
|
||||||
|
[Fact]
|
||||||
|
public void ToClientDto_does_not_mutate_the_domain()
|
||||||
|
{
|
||||||
|
var order = ValidOrder().WithId(Guid.NewGuid()).WithDescription("one").Build();
|
||||||
|
var client = ValidClient().WithId(Guid.NewGuid()).WithName("Acme").WithOrder(order).Build();
|
||||||
|
var ordersCount = client.Orders.Count;
|
||||||
|
|
||||||
|
DtoMappers.ToClientDto(client);
|
||||||
|
DtoMappers.ToOrderDto(order);
|
||||||
|
|
||||||
|
Assert.Equal("Acme", client.Name);
|
||||||
|
Assert.Equal(ordersCount, client.Orders.Count);
|
||||||
|
Assert.Same(client, order.Client); // back-reference untouched
|
||||||
|
Assert.Equal("one", order.Description);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromClientDto_does_not_mutate_the_dto()
|
||||||
|
{
|
||||||
|
var orderDto = new OrderDto { Description = "one" };
|
||||||
|
var dto = new ClientDto { Id = Guid.NewGuid(), Name = "Acme", Orders = new[] { orderDto } };
|
||||||
|
|
||||||
|
DtoMappers.FromClientDto(dto);
|
||||||
|
DtoMappers.FromOrderDto(orderDto);
|
||||||
|
|
||||||
|
Assert.Equal("Acme", dto.Name);
|
||||||
|
Assert.Same(orderDto, Assert.Single(dto.Orders)); // collection untouched
|
||||||
|
Assert.Null(orderDto.ClientDto); // back-reference untouched
|
||||||
|
Assert.Equal("one", orderDto.Description);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Domain is referenced explicitly because these tests use domain
|
||||||
|
types directly (it also flows in transitively through Web).
|
||||||
|
Deliberately NO reference to Db — mirroring the Web layer. -->
|
||||||
|
<ProjectReference Include="..\..\src\Domain\Domain.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\Web\Web.csproj" />
|
||||||
|
<ProjectReference Include="..\TestData\TestData.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using Domain;
|
||||||
|
using System.Reflection;
|
||||||
|
using static TestData.ClientBuilder;
|
||||||
|
using static TestData.OrderBuilder;
|
||||||
|
|
||||||
|
namespace Web.Tests;
|
||||||
|
|
||||||
|
/// <summary>The WebApp consumes only DTOs; persistence types are excluded at compile time (no Web → Db project reference).</summary>
|
||||||
|
public class WebAppTests
|
||||||
|
{
|
||||||
|
// The WebApp's public API references no domain type (reflection).
|
||||||
|
private static readonly Type[] BannedTypes = [typeof(Client), typeof(Order)];
|
||||||
|
|
||||||
|
private static IEnumerable<Type> TypeAndArguments(Type t)
|
||||||
|
{
|
||||||
|
yield return t;
|
||||||
|
if (t.IsGenericType)
|
||||||
|
foreach (var arg in t.GetGenericArguments())
|
||||||
|
foreach (var nested in TypeAndArguments(arg))
|
||||||
|
yield return nested;
|
||||||
|
if (t.IsArray && t.GetElementType() is { } elem)
|
||||||
|
foreach (var nested in TypeAndArguments(elem))
|
||||||
|
yield return nested;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WebApp_public_api_references_no_domain_types()
|
||||||
|
{
|
||||||
|
var app = typeof(WebApp);
|
||||||
|
var banned = BannedTypes.ToList();
|
||||||
|
|
||||||
|
foreach (var m in app.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static))
|
||||||
|
{
|
||||||
|
var signature = m.GetParameters()
|
||||||
|
.Select(p => p.ParameterType)
|
||||||
|
.Append(m.ReturnType)
|
||||||
|
.ToList();
|
||||||
|
foreach (var t in signature.SelectMany(TypeAndArguments))
|
||||||
|
Assert.False(banned.Contains(t),
|
||||||
|
$"WebApp.{m.Name}({string.Join(", ", signature)}) references banned type {t}");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var p in app.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static))
|
||||||
|
foreach (var t in TypeAndArguments(p.PropertyType))
|
||||||
|
Assert.False(banned.Contains(t),
|
||||||
|
$"WebApp.{p.Name} references banned type {t}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WebApp_consumes_dtos()
|
||||||
|
{
|
||||||
|
var app = new WebApp();
|
||||||
|
var order = ValidOrder().WithId(Guid.NewGuid()).WithDescription("order one").WithTotal(150_00).Build();
|
||||||
|
var client = ValidClient().WithId(Guid.NewGuid()).WithName("Acme").WithOrder(order).Build();
|
||||||
|
|
||||||
|
var view = app.ShowClient(DtoMappers.ToClientDto(client));
|
||||||
|
|
||||||
|
Assert.Contains("Acme", view);
|
||||||
|
Assert.Contains("order one", view);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user