Exercise baseline
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
||||
.yaks
|
||||
artifacts/
|
||||
bin/
|
||||
obj/
|
||||
.dotnet-cli/
|
||||
.npm-cache/
|
||||
node_modules/
|
||||
*.user
|
||||
# NuGet restore scratch dir (DOTNET_CLI_HOME fallout)
|
||||
.local/
|
||||
Library/
|
||||
.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, // plain int all the way through
|
||||
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 = row.TotalInCents, // plain int all the way through
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 in cents (non-negative by convention); the DTO and storage row carry the same plain int.</summary>
|
||||
public int TotalInCents { get; set; }
|
||||
|
||||
/// <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,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, // plain int all the way through
|
||||
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 = dto.TotalInCents, // plain int all the way through
|
||||
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 = 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);
|
||||
|
||||
// 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,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).</summary>
|
||||
public OrderBuilder WithTotal(int totalInCents)
|
||||
{
|
||||
_order.TotalInCents = 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);
|
||||
}
|
||||
|
||||
// (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