Compare commits
426 Commits
00fd8ded6e
...
feat/akte-
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cb39cb180 | |||
| b53b8cf2ff | |||
| e460cd5905 | |||
| b83e96f552 | |||
| bddbaf59d3 | |||
| 3021149ddd | |||
| 46f987422b | |||
| 5d2316d670 | |||
| c6968b9968 | |||
| f3f4534392 | |||
| d189da954f | |||
| 5b32c09076 | |||
| ea760fba06 | |||
| 396d8f05d8 | |||
| 5732398e60 | |||
| 0185446bfe | |||
| 04d189bd2f | |||
| 7d8166c555 | |||
| d5c7b77e7e | |||
| bff25b4683 | |||
| aa1d22cec7 | |||
| f7e97ad7a2 | |||
| 7d552d33cd | |||
| 4d7333b714 | |||
| 62b07597f5 | |||
| 5ce35af813 | |||
| 23b6dad303 | |||
| e9eef5cce6 | |||
| 299c038449 | |||
| ecd7ad2658 | |||
| 6de56ab112 | |||
| ddc343a96b | |||
| a26bc4d75e | |||
| d4dfe6c3ac | |||
| 40f3ce250d | |||
| ffbbc0dfad | |||
| 05cb1af39e | |||
| 78f87f0d44 | |||
| 7f6855be62 | |||
| 29e9c4cd64 | |||
| 8eddb83c99 | |||
| 1a5f3218fb | |||
| 2d54cb901f | |||
| 03f8e4de90 | |||
| 70803efa1f | |||
| 25af10a736 | |||
| ade40eef5e | |||
| c1b215d06f | |||
| 7525970ffc | |||
| bfc90f8581 | |||
| c45bcc99f0 | |||
| c4a661b5b6 | |||
| 30b2fff775 | |||
| c0ecf2022d | |||
| 2198c33898 | |||
| 04971bf3bb | |||
| 08500757d7 | |||
| 05ef3d9567 | |||
| 7c12a65938 | |||
| 5361a292f1 | |||
| cb5acd2005 | |||
| bda5479b93 | |||
| d5c155953b | |||
| 36795bd974 | |||
| ae60b47ad3 | |||
| adb9e93cdb | |||
| 3aa9ac9811 | |||
| b72e0a9d9a | |||
| 12d85d9796 | |||
| 76bdde4649 | |||
| 025bca1840 | |||
| 58dc811574 | |||
| 367304940f | |||
| 7e1acdb5e4 | |||
| 5ee6a4edf6 | |||
| ea527c7ec6 | |||
| c80adcf060 | |||
| a42966ad8e | |||
| 886a6e3aae | |||
| 88e00b3718 | |||
| 3214855989 | |||
| 76e635a122 | |||
| 00886b1c6e | |||
| d5e8946d7d | |||
| e367657254 | |||
| 8c43a8dc03 | |||
| 08732d8348 | |||
| c6edb2e9d0 | |||
| 8e475dff6a | |||
| 54ffb809c4 | |||
| 4403574f3d | |||
| bcdea8011b | |||
| 643f1a6831 | |||
| 27a3eb802c | |||
| 13a887608c | |||
| f74af537e2 | |||
| bb832b7f95 | |||
| 3a75005f43 | |||
| f2a64ad66b | |||
| 3706ba3ecd | |||
| c8ab6bf072 | |||
| 30ee7b6198 | |||
| 29207e41da | |||
| 7fe538dd5d | |||
| c452b69dd6 | |||
| ff42341c47 | |||
| 90f998d189 | |||
| d5f4be76be | |||
| 433f9ee592 | |||
| 5c327cd8d8 | |||
| b12b5d720d | |||
| 92dd614269 | |||
| 097a04cbd8 | |||
| b7ebfd3057 | |||
| d268bbc122 | |||
| a638cf2c52 | |||
| efbf24ca39 | |||
| 22e033b26a | |||
| ee7f0aeab4 | |||
| 454f6e0d60 | |||
| c652afbbc7 | |||
| 545db4132f | |||
| ddc4676f87 | |||
| 083ffac47a | |||
| b6178b630b | |||
| 55d08a5dc3 | |||
| 532cbf3bcf | |||
| d1baba686f | |||
| 8b867b9058 | |||
| ea3596ca0d | |||
| e6b513ed5d | |||
| 52303b90b2 | |||
| 4b6a07544d | |||
| 8f90821081 | |||
| 9dda78b28a | |||
| 865b3831c8 | |||
| 1b0d3286db | |||
| de1d9460b3 | |||
| 259dd96c0e | |||
| 615abfa510 | |||
| 69ffe58d6b | |||
| d7c0698c44 | |||
| db49150206 | |||
| f6a4025d71 | |||
| 8175b150f7 | |||
| 319aca7608 | |||
| 4d9e901139 | |||
| 01781f23d6 | |||
| 55c298ad1f | |||
| e1e088f041 | |||
| 358e30db1f | |||
| 0b9b03eb2d | |||
| d4c9be23c8 | |||
| f6bfc0ebab | |||
| 3160b66697 | |||
| 39767cef0f | |||
| 162a0fafb1 | |||
| c26ac5c6dd | |||
| 2bbdfe2b71 | |||
| 2c3f342905 | |||
| b165916dfb | |||
| 797b38df34 | |||
| c34d63e0fd | |||
| a2bdf9626c | |||
| 0d4b560df8 | |||
| d116b8d8f1 | |||
| 8dfad32918 | |||
| 1ee176a24a | |||
| a2f5e848d1 | |||
| de293904bb | |||
| 68447896ee | |||
| 5f26594a12 | |||
| 6eb82da90c | |||
| 0a1e63bec6 | |||
| 5b30407257 | |||
| c357a18418 | |||
| 791e9f91cc | |||
| 97acaf6b10 | |||
| efce79b3fa | |||
| 868c5f5cd7 | |||
| ddfa3614a4 | |||
| 13da8f2502 | |||
| 13eb17b453 | |||
| 9ed68ba38a | |||
| dfcd296119 | |||
| db85a6e0dc | |||
| 0c94cfcbf1 | |||
| 5eadd89bb6 | |||
| f9a68deb7a | |||
| a693095886 | |||
| d5544412bd | |||
| d93e8d1586 | |||
| 114bbd92c8 | |||
| a8d8ae0dfc | |||
| 522f2eec51 | |||
| 3f71d8e28e | |||
| 0e7ec5ab61 | |||
| 12604fba7c | |||
| 311c5461fd | |||
| 3634d6ef9a | |||
| aa345aa796 | |||
| 0317c5d454 | |||
| ea8c3e9966 | |||
| 4d3383a4f1 | |||
| 19a0a6c79b | |||
| b9033238c9 | |||
| 594923e10f | |||
| aa20d6f809 | |||
| afd321ec9f | |||
| ae825ae80a | |||
| e055061d85 | |||
| df13136955 | |||
| 4aca1d528b | |||
| 4e02133d95 | |||
| f3efd4aa2b | |||
| 461d7feb2d | |||
| 7536aba474 | |||
| 972570b142 | |||
| 029ffb9895 | |||
| 6decd59ce4 | |||
| f015274d28 | |||
| fd48f7fc09 | |||
| 105e63b9c4 | |||
| 0334fce275 | |||
| aaaf5b6fb7 | |||
| c20e6b5426 | |||
| 36454f3747 | |||
| 3335502ef1 | |||
| 0766f8303c | |||
| 6357e77060 | |||
| 5edbd67c96 | |||
| 0651e54343 | |||
| 4dcd416ff6 | |||
| 5fd8cf4395 | |||
| 76fcad6168 | |||
| 91fa9058de | |||
| 6775707387 | |||
| 8125c453d0 | |||
| 8101cc7be5 | |||
| ea79703dbf | |||
| ed576c0339 | |||
| c35a6eca04 | |||
| 2e20df624f | |||
| a57b481a7e | |||
| 27424c574f | |||
| fd28b2a954 | |||
| 8aab9d34f7 | |||
| 6dabb5572c | |||
| 36db013b76 | |||
| 60d034cde4 | |||
| 5ec04984c9 | |||
| e2fd32ea12 | |||
| 2f089a902d | |||
| 7aac59f3e5 | |||
| a0b720df73 | |||
| 1e7dc8f91d | |||
| c93c24f37c | |||
| bd9cc83205 | |||
| 8d0fc43c0a | |||
| 98596b5586 | |||
| 9013ff488e | |||
| 2a1c4a9eb7 | |||
| d79f039b8b | |||
| 8202dfc0e4 | |||
| 68366ad8c9 | |||
| dcf849cfa7 | |||
| 958a2dbac0 | |||
| 5d7cbc66bf | |||
| 84181ed74d | |||
| d510b0f822 | |||
| 3716dcb1f3 | |||
| d6e35f40ec | |||
| 70f7c88419 | |||
| acd7f4c386 | |||
| f73ac0d4d8 | |||
| 94f055f9bf | |||
| cab8c133c8 | |||
| 13ff97a12b | |||
| 681e8c28fb | |||
| ba81f48e0f | |||
| 3afb04f646 | |||
| 5d8bbfa93b | |||
| 5b11780074 | |||
| f20a42a94b | |||
| f858a02f50 | |||
| 09f11cb4e3 | |||
| 45b213e11d | |||
| 38b9717ad2 | |||
| 0ee2b53b24 | |||
| 459c66bc3e | |||
| 0cee3e2710 | |||
| 385cccb352 | |||
| e73b561b77 | |||
| 55a72bfe12 | |||
| e87d0aafea | |||
| db53fad5a4 | |||
| d3f38b9be4 | |||
| f08dbd2219 | |||
| cd795d3831 | |||
| be62e785db | |||
| d8ccb451d9 | |||
| 9c3bcfc9f9 | |||
| 0c05a5acf1 | |||
| b7ee48401a | |||
| ac5ab9d0ba | |||
| a1549266c3 | |||
| 8fa02a5d49 | |||
| e431c50347 | |||
| f86a044143 | |||
| ce1e578bf2 | |||
| 98060b890b | |||
| 78da534c7c | |||
| 42718606a9 | |||
| d6bf6c6fe3 | |||
| 04f9e087aa | |||
| 95ecb1c0ee | |||
| 36c97e8a7a | |||
| d9e5a757e9 | |||
| f6bb13ca2f | |||
| a221e366e6 | |||
| 3ec4f660b0 | |||
| daa6683405 | |||
| 69a0279bb7 | |||
| 2f2e5b1f56 | |||
| adea82a0e7 | |||
| ebd61d73a5 | |||
| ec5e755e72 | |||
| 8d7999c4a5 | |||
| c9723d2fc8 | |||
| 07dd5c9b8e | |||
| aa79f9dbe5 | |||
| 3b3497079b | |||
| dada3af968 | |||
| 6a37f9e46e | |||
| 44f306d18f | |||
| cbe77c927a | |||
| e4b7558abd | |||
| 188b33618e | |||
| f3b1ecb7a3 | |||
| 7627468b82 | |||
| c937b6d5a8 | |||
| 6cc007ba55 | |||
| 7ea2f97e72 | |||
| a9778ad1d2 | |||
| 263ba32e3c | |||
| 180d53b203 | |||
| a47fbef785 | |||
| 7ab463d47e | |||
| 27299e225c | |||
| 3476b71525 | |||
| 34911fdee1 | |||
| 1b776cd994 | |||
| 016b5a1521 | |||
| c6699a9592 | |||
| e76554342b | |||
| 79f260d62e | |||
| 74ac6ddb5a | |||
| d3550bbc16 | |||
| 784d3d3f40 | |||
| cd4328cef8 | |||
| 87250ba007 | |||
| d7be812ad0 | |||
| 1bf94c2040 | |||
| 17879b1cfb | |||
| f772464401 | |||
| e12588c338 | |||
| 9c0a2fec56 | |||
| 61cfab046a | |||
| 01923d1b87 | |||
| caba259a28 | |||
| 5ef27a70d3 | |||
| 16d3cefe68 | |||
| 0c94a6ecd3 | |||
| bca97ed10a | |||
| 92c930c165 | |||
| d8e94f6d05 | |||
| 5ee4b9874b | |||
| 16a0016de9 | |||
| a1d02c0d4e | |||
| 12e787671c | |||
| 5a6326a967 | |||
| f7ba1fa70e | |||
| e077766cc5 | |||
| e9f88b7889 | |||
| 0237f5de5e | |||
| 9b586dd7fa | |||
| f162ef4fed | |||
| e04b69ce49 | |||
| 3629e89e87 | |||
| 3cf6413fcc | |||
| 0408a482cf | |||
| 0553ceda66 | |||
| 3561b435d5 | |||
| 2bee5caaa2 | |||
| d99294e30e | |||
| f641334982 | |||
| ebc61acd18 | |||
| 76d8380ea1 | |||
| 3fc430f1a0 | |||
| 9d5608f46e | |||
| 2b9a0cd648 | |||
| 9b9cf2b35a | |||
| cc589b13d0 | |||
| 84de905469 | |||
| 4d541fe9cd | |||
| 8820cc7751 | |||
| b041d84b18 | |||
| 6ee85bc909 | |||
| 42a43ae24e | |||
| a7157a5a2e | |||
| 74dee4a6e2 | |||
| 47e2117325 | |||
| 6e5f0fb312 | |||
| 7d54a53005 | |||
| c337c3dfd5 | |||
| 08983ba30d | |||
| 5792d2fcf4 | |||
| 6556e17146 | |||
| d3d2b4b989 | |||
|
|
9b8c5412f8 | ||
|
|
dee8de7a15 | ||
|
|
d58f874162 | ||
| 136ce48385 | |||
| 3f538fc34e | |||
| 64a065058a | |||
| 1aa4f0b5f6 |
25
.dockerignore
Normal file
25
.dockerignore
Normal file
@@ -0,0 +1,25 @@
|
||||
**/.classpath
|
||||
**/.dockerignore
|
||||
**/.env
|
||||
**/.git
|
||||
**/.gitignore
|
||||
**/.project
|
||||
**/.settings
|
||||
**/.toolstarget
|
||||
**/.vs
|
||||
**/.vscode
|
||||
**/*.*proj.user
|
||||
**/*.dbmdl
|
||||
**/*.jfm
|
||||
**/bin
|
||||
**/charts
|
||||
**/docker-compose*
|
||||
**/compose*
|
||||
**/Dockerfile*
|
||||
**/node_modules
|
||||
**/npm-debug.log
|
||||
**/obj
|
||||
**/secrets.dev.yaml
|
||||
**/values.dev.yaml
|
||||
LICENSE
|
||||
README.md
|
||||
135
.gitea/workflows/ci.yml
Normal file
135
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,135 @@
|
||||
# GerbilManager CI — Gitea Actions
|
||||
# =================================
|
||||
# STATUS: ENTWURF — inaktiv bis Julian Gitea Actions aktiviert.
|
||||
# Aktivierung: Gitea -> Repository -> Einstellungen -> Actions -> "Actions aktivieren"
|
||||
# Anschliessend: Gitea Actions Runner auf der NAS installieren (siehe docs/ops.md).
|
||||
#
|
||||
# Was dieser Workflow tut:
|
||||
# 1. dotnet test (alle xUnit-Projekte)
|
||||
# 2. npm test + npm run build (Frontend)
|
||||
# 3. Docker-Images bauen und in die Gitea-Registry pushen
|
||||
#
|
||||
# Registry: git.rismer.de (Gitea Container Registry über HTTPS — keine insecure-registry-Konfig nötig)
|
||||
# Images:
|
||||
# git.rismer.de/gulum/gerbilmanager-api:latest
|
||||
# git.rismer.de/gulum/gerbilmanager-frontend:latest
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
REGISTRY: git.rismer.de
|
||||
REGISTRY_OWNER: gulum
|
||||
DOTNET_VERSION: "10.0.x"
|
||||
NODE_VERSION: "22"
|
||||
|
||||
jobs:
|
||||
test-backend:
|
||||
name: Backend Tests (.NET)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: .NET SDK einrichten
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
|
||||
- name: Pakete wiederherstellen
|
||||
run: dotnet restore GerbilManager.slnx
|
||||
|
||||
- name: Build
|
||||
run: dotnet build GerbilManager.slnx --no-restore -c Release
|
||||
|
||||
- name: dotnet-ef Tool installieren
|
||||
# 'tool update' ist idempotent (installiert wenn fehlend, sonst aktualisiert) —
|
||||
# auf dem SELF-HOSTED Runner persistieren --global Tools, daher schlug 'tool install'
|
||||
# ab dem 2. Lauf mit 'already installed' fehl und kippte den Backend-Job.
|
||||
# GITHUB_PATH-Zeile: global tools landen in ~/.dotnet/tools, das auf dem Runner-Container
|
||||
# nicht im PATH ist — einmalig eintragen, damit alle Folge-Steps 'dotnet ef' finden.
|
||||
run: |
|
||||
dotnet tool update --global dotnet-ef --version 10.0.*
|
||||
echo "$HOME/.dotnet/tools" >> $GITHUB_PATH
|
||||
|
||||
- name: DB-3 EF Migrations Drift-Check
|
||||
# Fails CI if the EF model diverges from the snapshot (i.e. a code change touched
|
||||
# entities/OnModelCreating without generating a matching migration). Catches exactly
|
||||
# the class of drift the SQLite/EnsureCreated test host is blind to.
|
||||
run: >
|
||||
dotnet ef migrations has-pending-model-changes
|
||||
--project GerbilManagerWebAPI
|
||||
--startup-project GerbilManagerWebAPI
|
||||
--no-build --configuration Release
|
||||
|
||||
- name: Tests ausfuehren
|
||||
run: dotnet test GerbilManager.slnx --no-build -c Release --logger "console;verbosity=normal"
|
||||
|
||||
test-frontend:
|
||||
name: Frontend Tests (Node/Vite)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Node.js einrichten
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "npm"
|
||||
cache-dependency-path: gerbil-manager-web/package-lock.json
|
||||
|
||||
- name: Abhaengigkeiten installieren
|
||||
run: npm ci
|
||||
working-directory: gerbil-manager-web
|
||||
|
||||
- name: Tests ausfuehren
|
||||
run: npm test
|
||||
working-directory: gerbil-manager-web
|
||||
|
||||
- name: Produktionsbuild pruefen
|
||||
run: npm run build
|
||||
working-directory: gerbil-manager-web
|
||||
|
||||
build-and-push:
|
||||
name: Docker Build & Push
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test-backend, test-frontend]
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Bei Gitea Registry anmelden
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Docker Buildx einrichten
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: API-Image bauen und pushen
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: GerbilManagerWebAPI/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.REGISTRY_OWNER }}/gerbilmanager-api:latest
|
||||
${{ env.REGISTRY }}/${{ env.REGISTRY_OWNER }}/gerbilmanager-api:${{ github.sha }}
|
||||
|
||||
- name: Frontend-Image bauen und pushen
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: gerbil-manager-web/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.REGISTRY_OWNER }}/gerbilmanager-frontend:latest
|
||||
${{ env.REGISTRY }}/${{ env.REGISTRY_OWNER }}/gerbilmanager-frontend:${{ github.sha }}
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -127,3 +127,16 @@ $RECYCLE.BIN/
|
||||
# Mac desktop service store files
|
||||
.DS_Store
|
||||
|
||||
|
||||
# MemPalace per-project files (issue #185)
|
||||
mempalace.yaml
|
||||
entities.json
|
||||
|
||||
# Runtime photo store (uploaded/imported gerbil photos) — never commit
|
||||
GerbilManagerWebAPI/photo-storage/
|
||||
|
||||
# AR-3: Data Protection key ring (dev-only ephemeral keys) — never commit
|
||||
GerbilManagerWebAPI/.data-protection-keys/
|
||||
|
||||
# node_modules — never commit, installed locally per npm install
|
||||
node_modules/
|
||||
|
||||
65
.vscode/launch.json
vendored
65
.vscode/launch.json
vendored
@@ -1,33 +1,32 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": ".NET Core Launch (web)",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build",
|
||||
"program": "${workspaceFolder}/GerbilManagerWebAPI/bin/Debug/net7.0/GerbilManagerWebAPI.dll",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}/GerbilManagerWebAPI",
|
||||
"stopAtEntry": false,
|
||||
"serverReadyAction": {
|
||||
"action": "openExternally",
|
||||
"pattern": "\\bNow listening on:\\s+(https?://\\S+)"
|
||||
},
|
||||
"env": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"sourceFileMap": {
|
||||
"/Views": "${workspaceFolder}/GerbilManagerWebAPI/Views"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": ".NET Core Attach",
|
||||
"type": "coreclr",
|
||||
"request": "attach"
|
||||
}
|
||||
]
|
||||
}
|
||||
{
|
||||
// Play button (F5) starts the WHOLE stack via .NET Aspire:
|
||||
// dashboard + Postgres container + WebAPI + React frontend (Vite).
|
||||
// The Aspire dashboard (with login token) opens automatically.
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "GerbilManager (Aspire — alles starten)",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build",
|
||||
"program": "${workspaceFolder}/GerbilManager.AppHost/bin/Debug/net10.0/GerbilManager.AppHost.dll",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}/GerbilManager.AppHost",
|
||||
"stopAtEntry": false,
|
||||
"env": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
},
|
||||
"serverReadyAction": {
|
||||
"action": "openExternally",
|
||||
"pattern": "Login to the dashboard at\\s+(https?://\\S+)",
|
||||
"uriFormat": "%s"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": ".NET Core Attach",
|
||||
"type": "coreclr",
|
||||
"request": "attach"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
67
.vscode/tasks.json
vendored
67
.vscode/tasks.json
vendored
@@ -7,7 +7,7 @@
|
||||
"type": "process",
|
||||
"args": [
|
||||
"build",
|
||||
"${workspaceFolder}/GerbilManager.sln",
|
||||
"${workspaceFolder}/GerbilManager.slnx",
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary;ForceNoAlign"
|
||||
],
|
||||
@@ -19,7 +19,7 @@
|
||||
"type": "process",
|
||||
"args": [
|
||||
"publish",
|
||||
"${workspaceFolder}/GerbilManager.sln",
|
||||
"${workspaceFolder}/GerbilManager.slnx",
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary;ForceNoAlign"
|
||||
],
|
||||
@@ -33,9 +33,70 @@
|
||||
"watch",
|
||||
"run",
|
||||
"--project",
|
||||
"${workspaceFolder}/GerbilManager.sln"
|
||||
"${workspaceFolder}/GerbilManager.slnx"
|
||||
],
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
"type": "docker-build",
|
||||
"label": "docker-build: debug",
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"dockerBuild": {
|
||||
"tag": "gerbilmanager:dev",
|
||||
"target": "base",
|
||||
"dockerfile": "${workspaceFolder}/GerbilManagerWebAPI/Dockerfile",
|
||||
"context": "${workspaceFolder}",
|
||||
"pull": true
|
||||
},
|
||||
"netCore": {
|
||||
"appProject": "${workspaceFolder}/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "docker-build",
|
||||
"label": "docker-build: release",
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"dockerBuild": {
|
||||
"tag": "gerbilmanager:latest",
|
||||
"dockerfile": "${workspaceFolder}/GerbilManagerWebAPI/Dockerfile",
|
||||
"context": "${workspaceFolder}",
|
||||
"platform": {
|
||||
"os": "linux",
|
||||
"architecture": "amd64"
|
||||
},
|
||||
"pull": true
|
||||
},
|
||||
"netCore": {
|
||||
"appProject": "${workspaceFolder}/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "docker-run",
|
||||
"label": "docker-run: debug",
|
||||
"dependsOn": [
|
||||
"docker-build: debug"
|
||||
],
|
||||
"dockerRun": {},
|
||||
"netCore": {
|
||||
"appProject": "${workspaceFolder}/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj",
|
||||
"enableDebugging": true,
|
||||
"configureSsl": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "docker-run",
|
||||
"label": "docker-run: release",
|
||||
"dependsOn": [
|
||||
"docker-build: release"
|
||||
],
|
||||
"dockerRun": {},
|
||||
"netCore": {
|
||||
"appProject": "${workspaceFolder}/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
73
GerbilManager.AppHost/AppHost.cs
Normal file
73
GerbilManager.AppHost/AppHost.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
|
||||
var builder = DistributedApplication.CreateBuilder(args);
|
||||
|
||||
var postgres = builder.AddPostgres("postgres")
|
||||
.WithDataVolume();
|
||||
|
||||
var gerbilDb = postgres.AddDatabase("gerbilmanager");
|
||||
|
||||
// Stable, LAN-facing HTTP port for the API (matches the SPA's default base URL).
|
||||
const int ApiHttpPort = 5179;
|
||||
|
||||
var webapi = builder.AddProject<Projects.GerbilManagerWebAPI>("webapi")
|
||||
.WithReference(gerbilDb)
|
||||
.WaitFor(gerbilDb)
|
||||
// Pin the port so the phone-facing URL doesn't change between launches.
|
||||
// Proxyless so the app binds the port directly — Program.cs then widens the
|
||||
// host to 0.0.0.0 for LAN access (the DCP proxy binds localhost only).
|
||||
.WithEndpoint("http", e => { e.Port = ApiHttpPort; e.IsProxied = false; }, createIfNotExists: false)
|
||||
.WithExternalHttpEndpoints();
|
||||
|
||||
// The machine's LAN IP — the SPA runs in the phone's browser, so it must reach
|
||||
// the API at this address, not "localhost" (which would be the phone itself).
|
||||
var lanIp = GetLanIpAddress();
|
||||
|
||||
builder.AddViteApp("frontend", "../gerbil-manager-web")
|
||||
.WithReference(webapi)
|
||||
.WaitFor(webapi)
|
||||
// Pin a stable port for the phone bookmark (Vite default 5173). Proxyless so
|
||||
// the Vite dev server binds the port directly; vite.config host:true makes it
|
||||
// bind 0.0.0.0 for LAN access.
|
||||
.WithEndpoint("http", e => { e.Port = 5173; e.IsProxied = false; }, createIfNotExists: false)
|
||||
.WithExternalHttpEndpoints()
|
||||
.WithEnvironment(context =>
|
||||
{
|
||||
// VITE_API_BASE_URL is read by the Vite dev server at launch (see
|
||||
// src/api/client.ts). Point it at the API's LAN-reachable URL.
|
||||
context.EnvironmentVariables["VITE_API_BASE_URL"] = $"http://{lanIp}:{ApiHttpPort}";
|
||||
});
|
||||
|
||||
builder.Build().Run();
|
||||
|
||||
// Picks the IPv4 address of the active physical LAN adapter (Wi-Fi/Ethernet),
|
||||
// skipping loopback and virtual adapters (WSL/Hyper-V). Recomputed each launch
|
||||
// so it follows DHCP changes.
|
||||
static string GetLanIpAddress()
|
||||
{
|
||||
var candidates = NetworkInterface.GetAllNetworkInterfaces()
|
||||
.Where(ni => ni.OperationalStatus == OperationalStatus.Up)
|
||||
.Where(ni => ni.NetworkInterfaceType is NetworkInterfaceType.Wireless80211
|
||||
or NetworkInterfaceType.Ethernet)
|
||||
.Where(ni => !ni.Description.Contains("Hyper-V", StringComparison.OrdinalIgnoreCase)
|
||||
&& !ni.Description.Contains("Virtual", StringComparison.OrdinalIgnoreCase)
|
||||
&& !ni.Name.Contains("WSL", StringComparison.OrdinalIgnoreCase)
|
||||
&& !ni.Name.Contains("vEthernet", StringComparison.OrdinalIgnoreCase))
|
||||
// Prefer adapters that have a default gateway (i.e. a real network path).
|
||||
.OrderByDescending(ni => ni.GetIPProperties().GatewayAddresses.Count > 0);
|
||||
|
||||
foreach (var ni in candidates)
|
||||
{
|
||||
var ip = ni.GetIPProperties().UnicastAddresses
|
||||
.FirstOrDefault(a => a.Address.AddressFamily == AddressFamily.InterNetwork
|
||||
&& !IPAddress.IsLoopback(a.Address));
|
||||
if (ip is not null)
|
||||
{
|
||||
return ip.Address.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
return "localhost";
|
||||
}
|
||||
20
GerbilManager.AppHost/GerbilManager.AppHost.csproj
Normal file
20
GerbilManager.AppHost/GerbilManager.AppHost.csproj
Normal file
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Aspire.AppHost.Sdk/13.4.2">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UserSecretsId>b153f50c-bec1-401e-9893-f9df7cff2ebd</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting.JavaScript" Version="13.4.2" />
|
||||
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.4.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GerbilManagerWebAPI\GerbilManagerWebAPI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
29
GerbilManager.AppHost/Properties/launchSettings.json
Normal file
29
GerbilManager.AppHost/Properties/launchSettings.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:17004;http://localhost:15265",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"DOTNET_ENVIRONMENT": "Development",
|
||||
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21284",
|
||||
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22113"
|
||||
}
|
||||
},
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:15265",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"DOTNET_ENVIRONMENT": "Development",
|
||||
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19171",
|
||||
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20259"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
GerbilManager.AppHost/appsettings.Development.json
Normal file
8
GerbilManager.AppHost/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
9
GerbilManager.AppHost/appsettings.json
Normal file
9
GerbilManager.AppHost/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Aspire.Hosting.Dcp": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
5
GerbilManager.AppHost/aspire.config.json
Normal file
5
GerbilManager.AppHost/aspire.config.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"appHost": {
|
||||
"path": "GerbilManager.AppHost.csproj"
|
||||
}
|
||||
}
|
||||
121
GerbilManager.ServiceDefaults/Extensions.cs
Normal file
121
GerbilManager.ServiceDefaults/Extensions.cs
Normal file
@@ -0,0 +1,121 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.ServiceDiscovery;
|
||||
using OpenTelemetry;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
namespace Microsoft.Extensions.Hosting;
|
||||
|
||||
// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
|
||||
// This project should be referenced by each service project in your solution.
|
||||
// To learn more about using this project, see https://aka.ms/aspire/service-defaults
|
||||
public static class Extensions
|
||||
{
|
||||
private const string HealthEndpointPath = "/health";
|
||||
private const string AlivenessEndpointPath = "/alive";
|
||||
|
||||
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
builder.ConfigureOpenTelemetry();
|
||||
|
||||
builder.AddDefaultHealthChecks();
|
||||
|
||||
builder.Services.AddServiceDiscovery();
|
||||
|
||||
builder.Services.ConfigureHttpClientDefaults(http =>
|
||||
{
|
||||
// Turn on resilience by default
|
||||
http.AddStandardResilienceHandler();
|
||||
|
||||
// Turn on service discovery by default
|
||||
http.AddServiceDiscovery();
|
||||
});
|
||||
|
||||
// Uncomment the following to restrict the allowed schemes for service discovery.
|
||||
// builder.Services.Configure<ServiceDiscoveryOptions>(options =>
|
||||
// {
|
||||
// options.AllowedSchemes = ["https"];
|
||||
// });
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
builder.Logging.AddOpenTelemetry(logging =>
|
||||
{
|
||||
logging.IncludeFormattedMessage = true;
|
||||
logging.IncludeScopes = true;
|
||||
});
|
||||
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.WithMetrics(metrics =>
|
||||
{
|
||||
metrics.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddRuntimeInstrumentation();
|
||||
})
|
||||
.WithTracing(tracing =>
|
||||
{
|
||||
tracing.AddSource(builder.Environment.ApplicationName)
|
||||
.AddAspNetCoreInstrumentation(tracing =>
|
||||
// Exclude health check requests from tracing
|
||||
tracing.Filter = context =>
|
||||
!context.Request.Path.StartsWithSegments(HealthEndpointPath)
|
||||
&& !context.Request.Path.StartsWithSegments(AlivenessEndpointPath)
|
||||
)
|
||||
// Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
|
||||
//.AddGrpcClientInstrumentation()
|
||||
.AddHttpClientInstrumentation();
|
||||
});
|
||||
|
||||
builder.AddOpenTelemetryExporters();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static TBuilder AddOpenTelemetryExporters<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
|
||||
|
||||
if (useOtlpExporter)
|
||||
{
|
||||
builder.Services.AddOpenTelemetry().UseOtlpExporter();
|
||||
}
|
||||
|
||||
// Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package)
|
||||
//if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
|
||||
//{
|
||||
// builder.Services.AddOpenTelemetry()
|
||||
// .UseAzureMonitor();
|
||||
//}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
builder.Services.AddHealthChecks()
|
||||
// Add a default liveness check to ensure app is responsive
|
||||
.AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static WebApplication MapDefaultEndpoints(this WebApplication app)
|
||||
{
|
||||
// Health checks always exposed — compose healthchecks and Aspire both rely on them.
|
||||
// Trusted home LAN only (no public exposure, no auth — see board constraint).
|
||||
app.MapHealthChecks(HealthEndpointPath);
|
||||
app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
|
||||
{
|
||||
Predicate = r => r.Tags.Contains("live")
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsAspireSharedProject>true</IsAspireSharedProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.6.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="10.6.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
70
GerbilManager.Tests/ApiFactory.cs
Normal file
70
GerbilManager.Tests/ApiFactory.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// FEAT-13: In-Memory-Test-Host für Endpoint-Round-Trips. Ersetzt die
|
||||
/// Aspire/Npgsql-Registrierung durch SQLite in-memory (eine offene Verbindung
|
||||
/// hält die DB am Leben), Umgebung "Testing" überspringt Database.Migrate()
|
||||
/// (Npgsql-Migrationen laufen nicht auf SQLite) — Schema via EnsureCreated,
|
||||
/// inklusive der HasData-Seeds (73 Farbschläge + Zuchtprofil-Singleton).
|
||||
/// Vertrags-Dateien landen in einem Temp-Ordner, der mit dem Host stirbt.
|
||||
/// </summary>
|
||||
public sealed class ApiFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly SqliteConnection _connection = new("DataSource=:memory:");
|
||||
|
||||
/// <summary>
|
||||
/// INBOX-2: Test-spezifische DI-Überschreibungen (z. B. AI-Stub-Handler).
|
||||
/// Init-Property statt Konstruktor — xUnit-Klassen-Fixtures erlauben nur
|
||||
/// EINEN öffentlichen (parameterlosen) Konstruktor.
|
||||
/// </summary>
|
||||
public Action<IServiceCollection>? ConfigureTestServices { get; init; }
|
||||
|
||||
public string ContractRoot { get; } =
|
||||
Path.Combine(Path.GetTempPath(), $"gerbil-contract-tests-{Guid.NewGuid():N}");
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
// Dummy, damit Aspires AddNpgsqlDbContext beim Host-Aufbau zufrieden ist;
|
||||
// die eigentliche Registrierung wird unten durch SQLite ersetzt.
|
||||
builder.UseSetting("ConnectionStrings:gerbilmanager",
|
||||
"Host=localhost;Database=test;Username=test;Password=test");
|
||||
builder.UseSetting("Contracts:RootPath", ContractRoot);
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// EF 9+: AddDbContext registriert die Provider-Konfiguration als
|
||||
// IDbContextOptionsConfiguration<T> — ohne deren Entfernung blieben
|
||||
// Npgsql UND SQLite registriert (ein Provider pro ServiceProvider).
|
||||
services.RemoveAll<Microsoft.EntityFrameworkCore.Infrastructure.IDbContextOptionsConfiguration<ApplicationContext>>();
|
||||
services.RemoveAll<DbContextOptions<ApplicationContext>>();
|
||||
services.RemoveAll<ApplicationContext>();
|
||||
|
||||
_connection.Open();
|
||||
services.AddDbContext<ApplicationContext>(o => o.UseSqlite(_connection));
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
using var scope = provider.CreateScope();
|
||||
scope.ServiceProvider.GetRequiredService<ApplicationContext>().Database.EnsureCreated();
|
||||
|
||||
ConfigureTestServices?.Invoke(services);
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
if (disposing)
|
||||
{
|
||||
_connection.Dispose();
|
||||
if (Directory.Exists(ContractRoot)) Directory.Delete(ContractRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
69
GerbilManager.Tests/CmsPreviewTests.cs
Normal file
69
GerbilManager.Tests/CmsPreviewTests.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WEB-3: Round-Trips für die lokale Webseiten-Vorschau (GET /api/preview/…):
|
||||
/// veröffentlichte Seiten werden als HTML ausgeliefert, Entwürfe nicht,
|
||||
/// styles.css kommt mit CSS-Content-Type.
|
||||
/// </summary>
|
||||
public class CmsPreviewTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
|
||||
public CmsPreviewTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
private static object PageInput(string slug, string title, string status) =>
|
||||
new { slug, title, seoDescription = (string?)null, status };
|
||||
|
||||
private sealed record PageRow(Guid Id, string Slug);
|
||||
|
||||
/// <summary>Seite anlegen oder (falls von WEB-0b geseedet) auf den Zielzustand setzen.</summary>
|
||||
private static async Task UpsertPageAsync(HttpClient client, string slug, string title, string status)
|
||||
{
|
||||
var existing = (await client.GetFromJsonAsync<List<PageRow>>("/api/pages"))!
|
||||
.FirstOrDefault(p => p.Slug == slug);
|
||||
if (existing is null)
|
||||
{
|
||||
var created = await client.PostAsJsonAsync("/api/pages", PageInput(slug, title, status));
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
}
|
||||
else
|
||||
{
|
||||
var updated = await client.PutAsJsonAsync($"/api/pages/{existing.Id}", PageInput(slug, title, status));
|
||||
Assert.Equal(HttpStatusCode.NoContent, updated.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Vorschau_LiefertVeroeffentlichteSeiteAlsHtml_UndKeineEntwuerfe()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
await UpsertPageAsync(client, "start", "Willkommen bei der Zucht", "Published");
|
||||
await UpsertPageAsync(client, "e2e-entwurf", "Unsere Zucht", "Draft");
|
||||
|
||||
// Startseite: /api/preview/ → index.html (text/html, enthält den Titel)
|
||||
var index = await client.GetAsync("/api/preview/");
|
||||
Assert.Equal(HttpStatusCode.OK, index.StatusCode);
|
||||
Assert.StartsWith("text/html", index.Content.Headers.ContentType!.ToString());
|
||||
Assert.Contains("Willkommen bei der Zucht", await index.Content.ReadAsStringAsync());
|
||||
|
||||
// Expliziter Pfad funktioniert ebenso
|
||||
var explicitIndex = await client.GetAsync("/api/preview/index.html");
|
||||
Assert.Equal(HttpStatusCode.OK, explicitIndex.StatusCode);
|
||||
|
||||
// CSS mit korrektem Content-Type (Renderer: SiteRenderer.CssPath)
|
||||
var css = await client.GetAsync("/api/preview/assets/site.css");
|
||||
Assert.Equal(HttpStatusCode.OK, css.StatusCode);
|
||||
Assert.StartsWith("text/css", css.Content.Headers.ContentType!.ToString());
|
||||
|
||||
// Entwurf wird NICHT gerendert (Vorschau zeigt nur Veröffentlichtes)
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/preview/e2e-entwurf/index.html")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/preview/e2e-entwurf/")).StatusCode);
|
||||
|
||||
// Unsinnige Pfade → 404
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/preview/gibt-es-nicht.html")).StatusCode);
|
||||
}
|
||||
}
|
||||
103
GerbilManager.Tests/CmsPublishTests.cs
Normal file
103
GerbilManager.Tests/CmsPublishTests.cs
Normal file
@@ -0,0 +1,103 @@
|
||||
using GerbilManagerWebAPI.Endpoints;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>WEB-2: POST /api/publish — atomic swap, file layout, staging cleanup.</summary>
|
||||
public class CmsPublishTests
|
||||
{
|
||||
private static string TempRoot() =>
|
||||
Path.Combine(Path.GetTempPath(), "gm-publish-test-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_erstellt_live_Verzeichnis_mit_allen_Dateien()
|
||||
{
|
||||
var root = TempRoot();
|
||||
try
|
||||
{
|
||||
var files = new Dictionary<string, string>
|
||||
{
|
||||
["index.html"] = "<html>start</html>",
|
||||
["kontakt/index.html"] = "<html>kontakt</html>",
|
||||
["assets/site.css"] = "body {}",
|
||||
};
|
||||
|
||||
await CmsEndpoints.PublishToDirectoryAsync(files, root);
|
||||
|
||||
Assert.True(File.Exists(Path.Combine(root, "live", "index.html")));
|
||||
Assert.True(File.Exists(Path.Combine(root, "live", "kontakt", "index.html")));
|
||||
Assert.True(File.Exists(Path.Combine(root, "live", "assets", "site.css")));
|
||||
Assert.Equal("<html>start</html>",
|
||||
await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html")));
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_atomarer_Swap_ueberschreibt_alte_live_Version()
|
||||
{
|
||||
var root = TempRoot();
|
||||
try
|
||||
{
|
||||
await CmsEndpoints.PublishToDirectoryAsync(
|
||||
new Dictionary<string, string> { ["index.html"] = "version-1" }, root);
|
||||
|
||||
await CmsEndpoints.PublishToDirectoryAsync(
|
||||
new Dictionary<string, string> { ["index.html"] = "version-2" }, root);
|
||||
|
||||
var content = await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html"));
|
||||
Assert.Equal("version-2", content);
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_kein_staging_oder_old_Verzeichnis_nach_Swap()
|
||||
{
|
||||
var root = TempRoot();
|
||||
try
|
||||
{
|
||||
await CmsEndpoints.PublishToDirectoryAsync(
|
||||
new Dictionary<string, string> { ["index.html"] = "x" }, root);
|
||||
|
||||
Assert.False(Directory.Exists(Path.Combine(root, "_staging_new")));
|
||||
Assert.False(Directory.Exists(Path.Combine(root, "_old")));
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_mehrfach_ohne_Fehler()
|
||||
{
|
||||
var root = TempRoot();
|
||||
try
|
||||
{
|
||||
for (int i = 1; i <= 3; i++)
|
||||
{
|
||||
await CmsEndpoints.PublishToDirectoryAsync(
|
||||
new Dictionary<string, string> { ["index.html"] = $"v{i}" }, root);
|
||||
}
|
||||
|
||||
Assert.Equal("v3",
|
||||
await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html")));
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_UTF8_Inhalt_korrekt_gespeichert()
|
||||
{
|
||||
var root = TempRoot();
|
||||
try
|
||||
{
|
||||
const string german = "<html>Züchter — Rennmäuse & mehr</html>";
|
||||
await CmsEndpoints.PublishToDirectoryAsync(
|
||||
new Dictionary<string, string> { ["index.html"] = german }, root);
|
||||
|
||||
var content = await File.ReadAllTextAsync(
|
||||
Path.Combine(root, "live", "index.html"),
|
||||
System.Text.Encoding.UTF8);
|
||||
Assert.Equal(german, content);
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
}
|
||||
87
GerbilManager.Tests/CmsTests.cs
Normal file
87
GerbilManager.Tests/CmsTests.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>WEB-0: CMS snapshot shape, Abgabetiere auto-resolution from ForSale, block reorder.</summary>
|
||||
public class CmsTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
public CmsTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshot_has_site_nav_and_seeded_pages()
|
||||
{
|
||||
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
|
||||
var root = doc.RootElement;
|
||||
|
||||
Assert.Equal("de", root.GetProperty("site").GetProperty("defaultLocale").GetString());
|
||||
var nav = root.GetProperty("site").GetProperty("navOrder").EnumerateArray().Select(x => x.GetString()).ToList();
|
||||
// main nav = 6 core pages (Impressum/Datenschutz are footer-only, not in navOrder)
|
||||
Assert.Equal(new[] { "start", "ueber-die-zucht", "abgabetiere", "abgabebedingungen", "farben-genetik", "kontakt" }, nav);
|
||||
|
||||
var pages = root.GetProperty("pages").EnumerateArray().ToList();
|
||||
// 8 pages: 6 core + impressum + datenschutz (footer-only legal pages)
|
||||
Assert.Equal(8, pages.Count);
|
||||
Assert.Contains(pages, p => p.GetProperty("slug").GetString() == "impressum");
|
||||
Assert.Contains(pages, p => p.GetProperty("slug").GetString() == "datenschutz");
|
||||
// abgabetiere page carries an AbgabetiereList block with a resolved (possibly empty) animals array
|
||||
var abg = pages.Single(p => p.GetProperty("slug").GetString() == "abgabetiere");
|
||||
var listBlock = abg.GetProperty("blocks").EnumerateArray()
|
||||
.Single(b => b.GetProperty("type").GetString() == "AbgabetiereList");
|
||||
Assert.Equal("auto", listBlock.GetProperty("data").GetProperty("mode").GetString());
|
||||
Assert.Equal(JsonValueKind.Array, listBlock.GetProperty("data").GetProperty("animals").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Abgabetiere_resolves_ForSale_animals_with_becken_group()
|
||||
{
|
||||
// a Becken + a ForSale gerbil in it
|
||||
var encResp = await _client.PostAsJsonAsync("/enclosures", new { name = "Verkaufsbecken" });
|
||||
var encId = JsonDocument.Parse(await encResp.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
await _client.PostAsJsonAsync("/gerbils", new { name = "Verkaufsmaus", gender = "female", status = "ForSale", enclosureId = encId });
|
||||
// a non-ForSale gerbil must NOT appear
|
||||
await _client.PostAsJsonAsync("/gerbils", new { name = "Bleibtmaus", gender = "male", status = "Breeding" });
|
||||
|
||||
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
|
||||
var animals = doc.RootElement.GetProperty("pages").EnumerateArray()
|
||||
.Single(p => p.GetProperty("slug").GetString() == "abgabetiere")
|
||||
.GetProperty("blocks").EnumerateArray()
|
||||
.Single(b => b.GetProperty("type").GetString() == "AbgabetiereList")
|
||||
.GetProperty("data").GetProperty("animals").EnumerateArray().ToList();
|
||||
|
||||
var sale = animals.SingleOrDefault(a => a.GetProperty("name").GetString() == "Verkaufsmaus");
|
||||
Assert.Equal(JsonValueKind.Object, sale.ValueKind);
|
||||
Assert.Equal("Verkaufsbecken", sale.GetProperty("group").GetString());
|
||||
Assert.Equal(JsonValueKind.Array, sale.GetProperty("photos").ValueKind);
|
||||
Assert.DoesNotContain(animals, a => a.GetProperty("name").GetString() == "Bleibtmaus");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Block_crud_and_reorder()
|
||||
{
|
||||
// create a page + two blocks
|
||||
var pageResp = await _client.PostAsJsonAsync("/api/pages", new { slug = "test-page", title = "Testseite" });
|
||||
Assert.Equal(HttpStatusCode.Created, pageResp.StatusCode);
|
||||
var pageId = JsonDocument.Parse(await pageResp.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
|
||||
async Task<Guid> AddBlock(string type, object data) =>
|
||||
JsonDocument.Parse(await (await _client.PostAsJsonAsync($"/api/pages/{pageId}/blocks", new { type, data }))
|
||||
.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetGuid();
|
||||
|
||||
var b1 = await AddBlock("Heading", new { text = "Erstes", level = 2 });
|
||||
var b2 = await AddBlock("RichText", new { markdown = "**hallo**" });
|
||||
|
||||
// reorder: b2 before b1
|
||||
var reorder = await _client.PutAsJsonAsync($"/api/pages/{pageId}/blocks/order", new { blockIds = new[] { b2, b1 } });
|
||||
Assert.Equal(HttpStatusCode.NoContent, reorder.StatusCode);
|
||||
|
||||
var blocks = JsonDocument.Parse(await _client.GetStringAsync("/api/pages/test-page"))
|
||||
.RootElement.GetProperty("blocks").EnumerateArray().ToList();
|
||||
Assert.Equal(b2.ToString(), blocks[0].GetProperty("id").GetString());
|
||||
Assert.Equal(b1.ToString(), blocks[1].GetProperty("id").GetString());
|
||||
// RichText markdown round-trips inside data
|
||||
Assert.Equal("**hallo**", blocks[0].GetProperty("data").GetProperty("markdown").GetString());
|
||||
}
|
||||
}
|
||||
198
GerbilManager.Tests/ContractEndpointTests.cs
Normal file
198
GerbilManager.Tests/ContractEndpointTests.cs
Normal file
@@ -0,0 +1,198 @@
|
||||
using System.IO.Compression;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// FEAT-13 phase B: Endpoint-Round-Trips für /contracts + /settings/breeder-profile
|
||||
/// gegen den In-Memory-Host (ApiFactory). Deckt den Abgabe-Abschluss ab:
|
||||
/// Vertrag erzeugen -> Tiere stehen auf Abgegeben -> .docx ist herunterladbar.
|
||||
/// </summary>
|
||||
public class ContractEndpointTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = CreateJsonOptions();
|
||||
|
||||
private static JsonSerializerOptions CreateJsonOptions()
|
||||
{
|
||||
var o = new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
||||
o.Converters.Add(new JsonStringEnumConverter());
|
||||
return o;
|
||||
}
|
||||
|
||||
private readonly ApiFactory _factory;
|
||||
|
||||
public ContractEndpointTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
private sealed record IdDto(Guid Id);
|
||||
|
||||
private sealed record GerbilView(Guid Id, string Status, Guid? ReceiverContactId, DateOnly? GoHomeDate);
|
||||
|
||||
private sealed record ContractView(
|
||||
Guid Id, Guid ContactId, decimal Price, DateOnly HandoverDate, DateOnly ContractDate,
|
||||
string FileName, List<Guid> GerbilIds, string Url);
|
||||
|
||||
private sealed record Paged<T>(List<T> Items, int TotalCount, int Page, int PageSize);
|
||||
|
||||
private async Task<Guid> CreateContactAsync(HttpClient client, string name = "Herr Max Beispiel")
|
||||
{
|
||||
var response = await client.PostAsJsonAsync("/contacts", new
|
||||
{
|
||||
name,
|
||||
email = "max@example.com",
|
||||
phone = "0987 654321",
|
||||
address = "Beispielallee 7, 54321 Beispielstadt",
|
||||
}, Json);
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
return (await response.Content.ReadFromJsonAsync<IdDto>(Json))!.Id;
|
||||
}
|
||||
|
||||
private async Task<Guid> CreateGerbilAsync(HttpClient client, string name)
|
||||
{
|
||||
var response = await client.PostAsJsonAsync("/gerbils", new
|
||||
{
|
||||
name,
|
||||
gender = "female",
|
||||
dateOfBirth = "2025-03-09",
|
||||
}, Json);
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
return (await response.Content.ReadFromJsonAsync<IdDto>(Json))!.Id;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ZuchtprofilRoundTrip_PutDannGet()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var put = await client.PutAsJsonAsync("/settings/breeder-profile", new
|
||||
{
|
||||
zuchtName = "Zucht Testhausen",
|
||||
name = "Frau Erika Muster",
|
||||
address = "Musterweg 1, 12345 Testhausen",
|
||||
phone = "0123 456789",
|
||||
email = "zucht@example.org",
|
||||
homepage = "https://zucht.example.org/",
|
||||
city = "Testhausen",
|
||||
}, Json);
|
||||
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||
|
||||
var profile = await client.GetFromJsonAsync<Dictionary<string, string>>("/settings/breeder-profile", Json);
|
||||
Assert.Equal("Zucht Testhausen", profile!["zuchtName"]);
|
||||
Assert.Equal("Testhausen", profile["city"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VertragErzeugen_SchließtDieAbgabeAb()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
// Zuchtprofil füllen, damit der Verkäufer-Block im Dokument landet.
|
||||
await client.PutAsJsonAsync("/settings/breeder-profile", new
|
||||
{
|
||||
zuchtName = "Zucht Testhausen",
|
||||
name = "Frau Erika Muster",
|
||||
address = "Musterweg 1, 12345 Testhausen",
|
||||
phone = "0123 456789",
|
||||
email = "zucht@example.org",
|
||||
homepage = "https://zucht.example.org/",
|
||||
city = "Testhausen",
|
||||
}, Json);
|
||||
|
||||
var contactId = await CreateContactAsync(client);
|
||||
var krümel = await CreateGerbilAsync(client, "Krümel");
|
||||
var luna = await CreateGerbilAsync(client, "Luna");
|
||||
|
||||
// POST /contracts — der Abschluss.
|
||||
var post = await client.PostAsJsonAsync("/contracts", new
|
||||
{
|
||||
contactId,
|
||||
gerbilIds = new[] { krümel, luna },
|
||||
price = 72.0m,
|
||||
handoverDate = "2026-06-05",
|
||||
}, Json);
|
||||
Assert.Equal(HttpStatusCode.Created, post.StatusCode);
|
||||
var contract = (await post.Content.ReadFromJsonAsync<ContractView>(Json))!;
|
||||
|
||||
Assert.Equal(contactId, contract.ContactId);
|
||||
Assert.Equal(2, contract.GerbilIds.Count);
|
||||
Assert.Equal(new DateOnly(2026, 6, 5), contract.ContractDate); // Standard = Übergabedatum
|
||||
Assert.Equal($"/contracts/{contract.Id}/file", contract.Url);
|
||||
|
||||
// Tiere stehen jetzt auf Abgegeben — mit Abnehmer und Abgabedatum.
|
||||
foreach (var id in new[] { krümel, luna })
|
||||
{
|
||||
var gerbil = await client.GetFromJsonAsync<GerbilView>($"/gerbils/{id}", Json);
|
||||
Assert.Equal("GivenAway", gerbil!.Status);
|
||||
Assert.Equal(contactId, gerbil.ReceiverContactId);
|
||||
Assert.Equal(new DateOnly(2026, 6, 5), gerbil.GoHomeDate);
|
||||
}
|
||||
|
||||
// Download: echtes .docx mit beiden Tieren, Käufer, de-DE-Preis.
|
||||
var file = await client.GetAsync(contract.Url);
|
||||
Assert.Equal(HttpStatusCode.OK, file.StatusCode);
|
||||
Assert.Equal(
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
file.Content.Headers.ContentType!.MediaType);
|
||||
Assert.Contains("Abgabevertrag_2026-06-05_Herr-Max-Beispiel.docx",
|
||||
file.Content.Headers.ContentDisposition!.FileNameStar ?? file.Content.Headers.ContentDisposition.FileName);
|
||||
|
||||
var text = DocumentText(await file.Content.ReadAsByteArrayAsync());
|
||||
Assert.Contains("Krümel", text);
|
||||
Assert.Contains("Luna", text);
|
||||
Assert.Contains("Herr Max Beispiel", text);
|
||||
Assert.Contains("Kaufpreis von 72,00 €", text);
|
||||
Assert.Contains("Testhausen, den 05.06.2026", text);
|
||||
Assert.DoesNotContain("{{", text);
|
||||
|
||||
// Liste: nach Abnehmer filterbar (Gridify).
|
||||
var list = await client.GetFromJsonAsync<Paged<ContractView>>(
|
||||
$"/contracts?filter=contactId=={contactId}", Json);
|
||||
Assert.Equal(1, list!.TotalCount);
|
||||
Assert.Equal(contract.Id, list.Items[0].Id);
|
||||
|
||||
// DELETE räumt Zeile und Datei ab; Tier-Status bleibt unangetastet.
|
||||
var delete = await client.DeleteAsync($"/contracts/{contract.Id}");
|
||||
Assert.Equal(HttpStatusCode.NoContent, delete.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync(contract.Url)).StatusCode);
|
||||
var still = await client.GetFromJsonAsync<GerbilView>($"/gerbils/{krümel}", Json);
|
||||
Assert.Equal("GivenAway", still!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VertragOhneTiereOderMitUnbekanntemTier_Validierungsfehler()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var contactId = await CreateContactAsync(client, "Frau Lisa Test");
|
||||
|
||||
var empty = await client.PostAsJsonAsync("/contracts", new
|
||||
{
|
||||
contactId,
|
||||
gerbilIds = Array.Empty<Guid>(),
|
||||
price = 10m,
|
||||
handoverDate = "2026-06-05",
|
||||
}, Json);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, empty.StatusCode);
|
||||
|
||||
var unknown = await client.PostAsJsonAsync("/contracts", new
|
||||
{
|
||||
contactId,
|
||||
gerbilIds = new[] { Guid.NewGuid() },
|
||||
price = 10m,
|
||||
handoverDate = "2026-06-05",
|
||||
}, Json);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, unknown.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Sichtbarer Text aus word/document.xml (Tags entfernt).</summary>
|
||||
private static string DocumentText(byte[] docx)
|
||||
{
|
||||
using var zip = new ZipArchive(new MemoryStream(docx), ZipArchiveMode.Read);
|
||||
var entry = Assert.Single(zip.Entries, e => e.FullName == "word/document.xml");
|
||||
using var reader = new StreamReader(entry.Open(), Encoding.UTF8);
|
||||
return Regex.Replace(reader.ReadToEnd(), "<[^>]+>", "");
|
||||
}
|
||||
}
|
||||
224
GerbilManager.Tests/ContractGeneratorTests.cs
Normal file
224
GerbilManager.Tests/ContractGeneratorTests.cs
Normal file
@@ -0,0 +1,224 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Validation;
|
||||
using GerbilManagerWebAPI.Contracts;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// FEAT-13: Tests des Abgabevertrag-Generators. Die erzeugte .docx wird als
|
||||
/// Zip geöffnet und auf word/document.xml geprüft — kein Word nötig.
|
||||
/// </summary>
|
||||
public class ContractGeneratorTests
|
||||
{
|
||||
private static BreederProfile Seller() => new()
|
||||
{
|
||||
ZuchtName = "Zucht Testhausen",
|
||||
Name = "Frau Erika Muster",
|
||||
Address = "Musterweg 1, 12345 Testhausen",
|
||||
Phone = "0123 456789",
|
||||
Email = "zucht@example.org",
|
||||
Homepage = "https://zucht.example.org/",
|
||||
City = "Testhausen",
|
||||
};
|
||||
|
||||
private static ContractAnimal Krümel() => new(
|
||||
Name: "Krümel",
|
||||
Geschlecht: "Weiblich",
|
||||
Geburtsdatum: new DateOnly(2025, 3, 9),
|
||||
Farbschlag: "Agouti");
|
||||
|
||||
private static ContractData OneAnimal() => new(
|
||||
Seller: Seller(),
|
||||
Buyer: new ContractBuyer(
|
||||
Name: "Herr Max Beispiel",
|
||||
Address: "Beispielallee 7, 54321 Beispielstadt",
|
||||
Phone: "0987 654321",
|
||||
Email: "max@example.com"),
|
||||
Animals: [Krümel()],
|
||||
Price: 72m,
|
||||
HandoverDate: new DateOnly(2026, 6, 5));
|
||||
|
||||
/// <summary>
|
||||
/// Sichtbarer Text aus word/document.xml der erzeugten Datei. Tags werden
|
||||
/// entfernt, damit Aussagen über Run-Grenzen hinweg möglich sind (Werte
|
||||
/// und Fließtext liegen in getrennten w:t-Runs).
|
||||
/// </summary>
|
||||
private static string DocumentText(byte[] docx)
|
||||
{
|
||||
using var zip = new ZipArchive(new MemoryStream(docx), ZipArchiveMode.Read);
|
||||
var entry = Assert.Single(zip.Entries, e => e.FullName == "word/document.xml");
|
||||
using var reader = new StreamReader(entry.Open(), Encoding.UTF8);
|
||||
return Regex.Replace(reader.ReadToEnd(), "<[^>]+>", "");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EinTier_EnthaeltAlleVertragswerte()
|
||||
{
|
||||
var text = DocumentText(ContractGenerator.Generate(OneAnimal()));
|
||||
|
||||
// Verkäufer-Block (aus dem Zuchtprofil)
|
||||
Assert.Contains("Zucht Testhausen", text);
|
||||
Assert.Contains("Frau Erika Muster", text);
|
||||
Assert.Contains("Musterweg 1, 12345 Testhausen", text);
|
||||
Assert.Contains("0123 456789", text);
|
||||
Assert.Contains("zucht@example.org", text);
|
||||
Assert.Contains("https://zucht.example.org/", text);
|
||||
|
||||
// Käufer-Block
|
||||
Assert.Contains("Herr Max Beispiel", text);
|
||||
Assert.Contains("Beispielallee 7, 54321 Beispielstadt", text);
|
||||
Assert.Contains("0987 654321", text);
|
||||
Assert.Contains("max@example.com", text);
|
||||
|
||||
// Tier
|
||||
Assert.Contains("Krümel", text);
|
||||
Assert.Contains("Weiblich", text);
|
||||
Assert.Contains("09.03.2025", text);
|
||||
Assert.Contains("Agouti", text);
|
||||
|
||||
// Kaufpreis (de-DE) + Übergabedatum (TT.MM.JJJJ)
|
||||
Assert.Contains("Kaufpreis von 72,00 €", text);
|
||||
Assert.Contains("am 05.06.2026 dem Käufer", text);
|
||||
|
||||
// Unterschriftszeile: Ort des Züchters + Vertragsdatum (= Übergabedatum)
|
||||
Assert.Contains("Testhausen, den 05.06.2026", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeineUnersetztenTokens()
|
||||
{
|
||||
var text = DocumentText(ContractGenerator.Generate(OneAnimal()));
|
||||
Assert.DoesNotContain("{{", text);
|
||||
Assert.DoesNotContain("}}", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DreiTiere_KlontDieTierTabelleProTier()
|
||||
{
|
||||
var data = OneAnimal() with
|
||||
{
|
||||
Animals =
|
||||
[
|
||||
Krümel(),
|
||||
new ContractAnimal("Fridolin", "Männlich", new DateOnly(2023, 5, 1), "Schwarz"),
|
||||
new ContractAnimal("Luna", "Weiblich", new DateOnly(2023, 8, 15), "Gold"),
|
||||
],
|
||||
};
|
||||
|
||||
var text = DocumentText(ContractGenerator.Generate(data));
|
||||
|
||||
Assert.Contains("Krümel", text);
|
||||
Assert.Contains("Fridolin", text);
|
||||
Assert.Contains("Luna", text);
|
||||
Assert.Contains("01.05.2023", text);
|
||||
Assert.Contains("15.08.2023", text);
|
||||
|
||||
// Pro Tier eine Tabelle: „Tierart:“ ist das fixe Label jeder Tier-Tabelle.
|
||||
Assert.Equal(3, Regex.Matches(text, "Tierart:").Count);
|
||||
Assert.DoesNotContain("{{", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreisUndDatum_DeutscheFormate()
|
||||
{
|
||||
var data = OneAnimal() with { Price = 1234.5m, HandoverDate = new DateOnly(2026, 1, 3) };
|
||||
var text = DocumentText(ContractGenerator.Generate(data));
|
||||
|
||||
Assert.Contains("Kaufpreis von 1.234,50 €", text);
|
||||
Assert.Contains("am 03.01.2026 dem Käufer", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VertragsDatum_UeberschreibtUebergabedatumInDerUnterschriftszeile()
|
||||
{
|
||||
var data = OneAnimal() with { ContractDate = new DateOnly(2026, 6, 7) };
|
||||
var text = DocumentText(ContractGenerator.Generate(data));
|
||||
|
||||
Assert.Contains("Testhausen, den 07.06.2026", text);
|
||||
Assert.Contains("am 05.06.2026 dem Käufer", text); // Übergabedatum bleibt eigenständig
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OptionaleFelder_LeerStattPlatzhalter()
|
||||
{
|
||||
var data = OneAnimal() with
|
||||
{
|
||||
Buyer = new ContractBuyer("Frau Lisa Test", "Testgasse 2, 11111 Teststadt"),
|
||||
Animals = [new ContractAnimal("Momo", "Unbekannt")],
|
||||
};
|
||||
|
||||
var text = DocumentText(ContractGenerator.Generate(data));
|
||||
|
||||
Assert.Contains("Frau Lisa Test", text);
|
||||
Assert.Contains("Momo", text);
|
||||
Assert.DoesNotContain("{{", text);
|
||||
Assert.DoesNotContain("null", text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OhneTiere_WirftArgumentException()
|
||||
{
|
||||
var data = OneAnimal() with { Animals = [] };
|
||||
var ex = Assert.Throws<ArgumentException>(() => ContractGenerator.Generate(data));
|
||||
Assert.Contains("mindestens ein Tier", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ErzeugteDatei_IstEinGueltigesDocxPaket()
|
||||
{
|
||||
var docx = ContractGenerator.Generate(OneAnimal());
|
||||
|
||||
using var zip = new ZipArchive(new MemoryStream(docx), ZipArchiveMode.Read);
|
||||
Assert.Contains(zip.Entries, e => e.FullName == "[Content_Types].xml");
|
||||
Assert.Contains(zip.Entries, e => e.FullName == "word/document.xml");
|
||||
// Tierfotos wurden beim Vorlagenbau entfernt, das Zucht-Logo bleibt.
|
||||
Assert.Contains(zip.Entries, e => e.FullName == "word/media/image1.jpeg");
|
||||
Assert.DoesNotContain(zip.Entries, e => e.FullName == "word/media/image2.jpeg");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ErzeugtesDokument_ValidiertGegenOpenXmlSchema()
|
||||
{
|
||||
// Office-2013-Schema: die Vorlage nutzt w:tblLook-Attribute der
|
||||
// Word-2010-Form, die der 2007-Default des Validators nicht kennt.
|
||||
var validator = new OpenXmlValidator(DocumentFormat.OpenXml.FileFormatVersions.Office2013);
|
||||
|
||||
var data = OneAnimal() with
|
||||
{
|
||||
Animals = [Krümel(), new ContractAnimal("Fridolin", "Männlich"), new ContractAnimal("Luna", "Weiblich")],
|
||||
};
|
||||
using var generatedDoc = WordprocessingDocument.Open(
|
||||
new MemoryStream(ContractGenerator.Generate(data)), isEditable: false);
|
||||
var errors = validator.Validate(generatedDoc)
|
||||
.Select(e => $"{e.ErrorType}: {e.Description} [{e.Path?.XPath}]")
|
||||
.ToList();
|
||||
|
||||
Assert.True(errors.Count == 0, "OpenXml-Validierungsfehler:\n" + string.Join("\n", errors));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VorlageSelbst_EnthaeltKeineEchtdaten()
|
||||
{
|
||||
// Die Vorlage wird hier über den Generator-Pfad geladen: ein Vertrag
|
||||
// mit leeren Werten darf keinerlei Daten des Mustervertrags enthalten.
|
||||
var data = new ContractData(
|
||||
new BreederProfile(),
|
||||
new ContractBuyer("", ""),
|
||||
[new ContractAnimal("", "")],
|
||||
0m,
|
||||
new DateOnly(2026, 1, 1));
|
||||
|
||||
var text = DocumentText(ContractGenerator.Generate(data));
|
||||
|
||||
// Stichproben der Original-PII (Name/Ort/Mail des Mustervertrags).
|
||||
Assert.DoesNotContain("Nießner", text);
|
||||
Assert.DoesNotContain("Hartengrund", text);
|
||||
Assert.DoesNotContain("Ronneburg", text);
|
||||
Assert.DoesNotContain("Schädtler", text);
|
||||
Assert.DoesNotContain("gmx.de", text);
|
||||
Assert.DoesNotContain("jimdofree", text);
|
||||
}
|
||||
}
|
||||
144
GerbilManager.Tests/ExportTests.cs
Normal file
144
GerbilManager.Tests/ExportTests.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Export;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// EXPORT-1: CSV-Escaping-Units + voller Round-Trip über GET /export
|
||||
/// (seed -> Zip herunterladen -> Einträge parsen -> Zähler + Beispielzeile).
|
||||
/// </summary>
|
||||
public class ExportTests
|
||||
{
|
||||
// ── CSV-Escaping ───────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "")]
|
||||
[InlineData("", "")]
|
||||
[InlineData("Krümel", "Krümel")] // Umlaute bleiben unquotiert erhalten
|
||||
[InlineData("a;b", "\"a;b\"")] // Semikolon = Trennzeichen -> quoten
|
||||
[InlineData("sagt \"hallo\"", "\"sagt \"\"hallo\"\"\"")] // Anführungszeichen verdoppeln
|
||||
[InlineData("Zeile1\nZeile2", "\"Zeile1\nZeile2\"")] // Zeilenumbruch -> quoten
|
||||
[InlineData("Meier, Hans", "Meier, Hans")] // Komma ist KEIN Trennzeichen (Semikolon-CSV)
|
||||
public void Escape_behandelt_Sonderfälle(string? input, string expected)
|
||||
=> Assert.Equal(expected, ExportService.Escape(input));
|
||||
|
||||
[Fact]
|
||||
public void BuildCsv_schreibt_Kopfzeile_und_quotierte_Zeilen()
|
||||
{
|
||||
var csv = ExportService.BuildCsv(
|
||||
["Name", "Notizen"],
|
||||
[["Krümel", "frisst; gerne \"Hirse\""], ["Bo", null]]);
|
||||
|
||||
var lines = csv.Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
|
||||
Assert.Equal("Name;Notizen", lines[0]);
|
||||
Assert.Equal("Krümel;\"frisst; gerne \"\"Hirse\"\"\"", lines[1]);
|
||||
Assert.Equal("Bo;", lines[2]);
|
||||
}
|
||||
|
||||
// ── Round-Trip über die API ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Export_liefert_Zip_mit_allen_Einträgen_und_korrekten_Daten()
|
||||
{
|
||||
using var factory = new ApiFactory();
|
||||
|
||||
// Seed: Kontakt, Becken, Wurf, Tier (mit Bezügen), Gesundheit, Gewicht
|
||||
Guid gerbilId;
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
|
||||
var contact = new Contact { Id = Guid.NewGuid(), Name = "Zoohandlung; \"Meier\"", Email = "meier@example.de" };
|
||||
var enclosure = new Enclosure { Id = Guid.NewGuid(), Name = "Großbecken" };
|
||||
var litter = new Litter { Id = Guid.NewGuid(), Name = "Wurf K", Date = new DateOnly(2025, 3, 12), TotalBorn = 5 };
|
||||
var colorVariety = db.ColorVarieties.First(); // HasData-Seed
|
||||
var gerbil = new Gerbil
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Krümel",
|
||||
Gender = Gender.female,
|
||||
Status = GerbilStatus.Breeding,
|
||||
DateOfBirth = new DateOnly(2025, 3, 12),
|
||||
LitterId = litter.Id,
|
||||
EnclosureId = enclosure.Id,
|
||||
OriginContactId = contact.Id,
|
||||
ColorVarietyId = colorVariety.Id,
|
||||
Genotype = "Aa CC Dd EE GG Pp Spsp rere",
|
||||
ImportSource = "Stammbaum von Akio Kids.xlsx",
|
||||
};
|
||||
gerbilId = gerbil.Id;
|
||||
db.AddRange(contact, enclosure, litter, gerbil,
|
||||
new HealthRecord
|
||||
{
|
||||
Id = Guid.NewGuid(), GerbilId = gerbil.Id, Date = new DateOnly(2026, 1, 15),
|
||||
Type = HealthRecordType.Vaccination, Description = "Jahresimpfung",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
},
|
||||
new WeightRecord
|
||||
{
|
||||
Id = Guid.NewGuid(), GerbilId = gerbil.Id,
|
||||
Date = new DateOnly(2026, 5, 1), WeightGrams = 78,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var client = factory.CreateClient();
|
||||
var response = await client.GetAsync("/export");
|
||||
response.EnsureSuccessStatusCode();
|
||||
Assert.Equal("application/zip", response.Content.Headers.ContentType?.MediaType);
|
||||
Assert.Contains("rennmaus-export-", response.Content.Headers.ContentDisposition?.FileName);
|
||||
|
||||
using var zip = new ZipArchive(await response.Content.ReadAsStreamAsync(), ZipArchiveMode.Read);
|
||||
string[] expectedEntries =
|
||||
["export.json", "tiere.csv", "wuerfe.csv", "kontakte.csv",
|
||||
"gesundheit.csv", "gewichte.csv", "LIESMICH.txt"];
|
||||
foreach (var name in expectedEntries)
|
||||
Assert.NotNull(zip.GetEntry(name));
|
||||
|
||||
// export.json: Zähler + volle Treue (Genotyp, Import-Herkunft)
|
||||
using var json = JsonDocument.Parse(ReadEntry(zip, "export.json", out _));
|
||||
var root = json.RootElement;
|
||||
Assert.Equal(1, root.GetProperty("gerbils").GetArrayLength());
|
||||
Assert.Equal(1, root.GetProperty("litters").GetArrayLength());
|
||||
Assert.Equal(1, root.GetProperty("contacts").GetArrayLength());
|
||||
Assert.True(root.GetProperty("colorVarieties").GetArrayLength() >= 60); // HasData-Seed (61 nach GEN-3f)
|
||||
var g = root.GetProperty("gerbils")[0];
|
||||
Assert.Equal("Aa CC Dd EE GG Pp Spsp rere", g.GetProperty("genotype").GetString());
|
||||
Assert.Equal("Stammbaum von Akio Kids.xlsx", g.GetProperty("importSource").GetString());
|
||||
Assert.Equal(gerbilId.ToString(), g.GetProperty("id").GetString());
|
||||
|
||||
// tiere.csv: BOM, deutsche Kopfzeile, aufgelöste Namen + deutsches Datum
|
||||
var tiere = ReadEntry(zip, "tiere.csv", out var hadBom);
|
||||
Assert.True(hadBom, "tiere.csv braucht ein UTF-8-BOM für Excel");
|
||||
var lines = tiere.Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
|
||||
Assert.StartsWith("Name;Geschlecht;Status;Geburtsdatum", lines[0]);
|
||||
var row = lines[1];
|
||||
Assert.Contains("Krümel", row);
|
||||
Assert.Contains("Weiblich", row);
|
||||
Assert.Contains("Zucht", row);
|
||||
Assert.Contains("12.03.2025", row);
|
||||
Assert.Contains("Großbecken", row);
|
||||
Assert.Contains("Wurf K", row);
|
||||
|
||||
// kontakte.csv: Escaping im Ernstfall (Semikolon + Anführungszeichen im Namen)
|
||||
var kontakte = ReadEntry(zip, "kontakte.csv", out _);
|
||||
Assert.Contains("\"Zoohandlung; \"\"Meier\"\"\"", kontakte);
|
||||
|
||||
// LIESMICH erklärt den Foto-Speicherort
|
||||
Assert.Contains("photo-storage", ReadEntry(zip, "LIESMICH.txt", out _));
|
||||
}
|
||||
|
||||
private static string ReadEntry(ZipArchive zip, string name, out bool hadBom)
|
||||
{
|
||||
using var stream = zip.GetEntry(name)!.Open();
|
||||
using var ms = new MemoryStream();
|
||||
stream.CopyTo(ms);
|
||||
var bytes = ms.ToArray();
|
||||
hadBom = bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF;
|
||||
return Encoding.UTF8.GetString(bytes, hadBom ? 3 : 0, bytes.Length - (hadBom ? 3 : 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
75
GerbilManager.Tests/GerbilCharacterTests.cs
Normal file
75
GerbilManager.Tests/GerbilCharacterTests.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using GerbilManagerWebAPI.SaleAd;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>FEAT-14a: Charakterbogen (traits + note) persists/round-trips, and the
|
||||
/// sale-ad prompt includes the character info.</summary>
|
||||
public class GerbilCharacterTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public GerbilCharacterTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||
|
||||
[Fact]
|
||||
public async Task CharacterTraits_and_note_round_trip()
|
||||
{
|
||||
var create = await _client.PostAsync("/gerbils", JsonContent.Create(new
|
||||
{
|
||||
name = "Charakter Test",
|
||||
gender = "female",
|
||||
characterTraits = new[] { "zutraulich", "handzahm" },
|
||||
characterNote = "sehr aktiv und neugierig",
|
||||
}));
|
||||
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
|
||||
|
||||
var created = await create.Content.ReadAsStringAsync();
|
||||
// POST response is the created GerbilDto — traits serialise straight back
|
||||
Assert.Contains("zutraulich", created);
|
||||
Assert.Contains("handzahm", created);
|
||||
Assert.Contains("sehr aktiv und neugierig", created);
|
||||
|
||||
var id = Regex.Match(created, "\"id\":\"([^\"]+)\"").Groups[1].Value;
|
||||
Assert.NotEqual("", id);
|
||||
|
||||
// re-read from the DB to prove persistence (not just echo)
|
||||
var fetched = await _client.GetStringAsync($"/gerbils/{id}");
|
||||
Assert.Contains("zutraulich", fetched);
|
||||
Assert.Contains("handzahm", fetched);
|
||||
Assert.Contains("sehr aktiv und neugierig", fetched);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Omitting_traits_defaults_to_empty_list()
|
||||
{
|
||||
var create = await _client.PostAsync("/gerbils", JsonContent.Create(new
|
||||
{
|
||||
name = "Ohne Charakter",
|
||||
gender = "male",
|
||||
}));
|
||||
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
|
||||
var body = await create.Content.ReadAsStringAsync();
|
||||
Assert.Contains("\"characterTraits\":[]", body.Replace(" ", ""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaleAd_prompt_includes_traits_and_note()
|
||||
{
|
||||
var request = new SaleAdRequest(
|
||||
Animals: new List<SaleAdAnimal>
|
||||
{
|
||||
new("Bella", "Agouti", "2025-01-15", "verspielt",
|
||||
Traits: new List<string> { "zutraulich", "handzahm" },
|
||||
CharacterNote: "liebt Sonnenblumenkerne"),
|
||||
},
|
||||
StatusLine: "FREI",
|
||||
Hints: "");
|
||||
|
||||
var prompt = SaleAdPromptBuilder.BuildUserPrompt(request);
|
||||
|
||||
Assert.Contains("Charakter: zutraulich, handzahm", prompt);
|
||||
Assert.Contains("liebt Sonnenblumenkerne", prompt);
|
||||
}
|
||||
}
|
||||
28
GerbilManager.Tests/GerbilManager.Tests.csproj
Normal file
28
GerbilManager.Tests/GerbilManager.Tests.csproj
Normal file
@@ -0,0 +1,28 @@
|
||||
<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.EntityFrameworkCore.InMemory" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
|
||||
<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="..\GerbilManagerWebAPI\GerbilManagerWebAPI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
83
GerbilManager.Tests/GerbilProfilePhotoTests.cs
Normal file
83
GerbilManager.Tests/GerbilProfilePhotoTests.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>profilePhotoUrl: computed field on GerbilDto — first photo by sortOrder, or null.</summary>
|
||||
public class GerbilProfilePhotoTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly ApiFactory _factory;
|
||||
|
||||
public GerbilProfilePhotoTests(ApiFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
private static Guid ExtractId(string json) =>
|
||||
Guid.Parse(Regex.Match(json, "\"id\":\"([^\"]+)\"").Groups[1].Value);
|
||||
|
||||
private static string? GetStr(JsonElement el, string prop) =>
|
||||
el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String
|
||||
? v.GetString() : null;
|
||||
|
||||
private async Task<Guid> CreateGerbil(string name)
|
||||
{
|
||||
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new { name, gender = "female" }));
|
||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
return ExtractId(await resp.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
private void InsertPhotos(Guid gerbilId, params (string fileName, int sortOrder)[] photos)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
|
||||
foreach (var (fn, so) in photos)
|
||||
db.GerbilPhotos.Add(new GerbilPhoto
|
||||
{
|
||||
Id = Guid.NewGuid(), GerbilId = gerbilId,
|
||||
FileName = fn, SortOrder = so, CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetById_without_photos_returns_null_profilePhotoUrl()
|
||||
{
|
||||
var id = await CreateGerbil("NoPhoto");
|
||||
var json = await _client.GetStringAsync($"/gerbils/{id}");
|
||||
var el = JsonDocument.Parse(json).RootElement;
|
||||
Assert.True(el.TryGetProperty("profilePhotoUrl", out var v));
|
||||
Assert.Equal(JsonValueKind.Null, v.ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetById_with_photos_returns_lowest_sortOrder_url()
|
||||
{
|
||||
var id = await CreateGerbil("PhotoGerbil");
|
||||
InsertPhotos(id, ("second.jpg", 1), ("first.jpg", 0));
|
||||
|
||||
var json = await _client.GetStringAsync($"/gerbils/{id}");
|
||||
var el = JsonDocument.Parse(json).RootElement;
|
||||
Assert.Equal("/photos/files/first.jpg", GetStr(el, "profilePhotoUrl"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetList_returns_profilePhotoUrl_without_n_plus_1()
|
||||
{
|
||||
var id = await CreateGerbil("ListPhoto");
|
||||
InsertPhotos(id, ("list-photo.jpg", 0));
|
||||
|
||||
var json = await _client.GetStringAsync("/gerbils");
|
||||
var root = JsonDocument.Parse(json).RootElement;
|
||||
var items = root.GetProperty("items").EnumerateArray().ToList();
|
||||
var gerbil = items.FirstOrDefault(x => GetStr(x, "id") == id.ToString());
|
||||
Assert.NotEqual(default, gerbil);
|
||||
Assert.Equal("/photos/files/list-photo.jpg", GetStr(gerbil, "profilePhotoUrl"));
|
||||
}
|
||||
}
|
||||
58
GerbilManager.Tests/GerbilSearchTests.cs
Normal file
58
GerbilManager.Tests/GerbilSearchTests.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>SEARCH-1: separator-insensitive name search + OriginBreeder (Herkunft) filter.</summary>
|
||||
public class GerbilSearchTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public GerbilSearchTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||
|
||||
private static HttpContent Gerbil(string name, string? originBreeder = null) =>
|
||||
JsonContent.Create(new
|
||||
{
|
||||
name,
|
||||
gender = "female",
|
||||
originBreeder,
|
||||
});
|
||||
|
||||
[Theory]
|
||||
[InlineData("Clan-Kleine-Chaoten", "clankleinechaoten")]
|
||||
[InlineData("Clan Kleine Chaoten", "clankleinechaoten")]
|
||||
[InlineData("v.d. Kleinen_Chaoten", "vdkleinenchaoten")]
|
||||
public void Normalize_strips_separators_and_lowercases(string input, string expected) =>
|
||||
Assert.Equal(expected, GerbilSearch.Normalize(input));
|
||||
|
||||
[Fact]
|
||||
public async Task NameSearch_filter_matches_across_separators()
|
||||
{
|
||||
var create = await _client.PostAsync("/gerbils", Gerbil("Clan-Kleine-Chaoten"));
|
||||
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
|
||||
|
||||
// Gridify contains is "=*value" (no trailing '*', per the main hotfix 0ee2b53).
|
||||
// The client normalizes the user's term the same way the column is normalized.
|
||||
var json = await _client.GetStringAsync("/gerbils?filter=nameSearch=*clankleinechaoten");
|
||||
Assert.Contains("Clan-Kleine-Chaoten", json);
|
||||
|
||||
// a term that doesn't normalize-match must NOT return it
|
||||
var miss = await _client.GetStringAsync("/gerbils?filter=nameSearch=*completelyother");
|
||||
Assert.DoesNotContain("Clan-Kleine-Chaoten", miss);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OriginBreeder_is_filterable_and_listed_in_breeders_endpoint()
|
||||
{
|
||||
await _client.PostAsync("/gerbils", Gerbil("Herkunftstier", originBreeder: "Zucht der kleinen Chaoten"));
|
||||
|
||||
// distinct-values dropdown source
|
||||
var breeders = await _client.GetStringAsync("/gerbils/breeders");
|
||||
Assert.Contains("Zucht der kleinen Chaoten", breeders);
|
||||
|
||||
// Gridify filter on the field
|
||||
var filtered = await _client.GetStringAsync("/gerbils?filter=originBreeder==Zucht der kleinen Chaoten");
|
||||
Assert.Contains("Herkunftstier", filtered);
|
||||
}
|
||||
}
|
||||
122
GerbilManager.Tests/GerbilStatusTests.cs
Normal file
122
GerbilManager.Tests/GerbilStatusTests.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>STATUS-MODEL: GerbilStatusService.Derive() precedence rules.</summary>
|
||||
public class GerbilStatusTests
|
||||
{
|
||||
private static readonly DateOnly Today = new(2026, 6, 7);
|
||||
|
||||
// 1) DateOfDeath → Deceased, always overrides user input
|
||||
[Fact]
|
||||
public void DateOfDeath_set_returns_Deceased()
|
||||
{
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.Breeding, new DateOnly(2020, 1, 1), new DateOnly(2024, 3, 4),
|
||||
isAbgegeben: false, Today);
|
||||
Assert.Equal(GerbilStatus.Deceased, status);
|
||||
}
|
||||
|
||||
// DateOfDeath wins even when also Abgegeben
|
||||
[Fact]
|
||||
public void DateOfDeath_wins_over_Abgegeben()
|
||||
{
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.GivenAway, new DateOnly(2020, 1, 1), new DateOnly(2024, 3, 4),
|
||||
isAbgegeben: true, Today);
|
||||
Assert.Equal(GerbilStatus.Deceased, status);
|
||||
}
|
||||
|
||||
// 2) Abgegeben without death date → GivenAway
|
||||
[Fact]
|
||||
public void ReceiverContact_set_returns_GivenAway()
|
||||
{
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.Breeding, new DateOnly(2022, 1, 1), dateOfDeath: null,
|
||||
isAbgegeben: true, Today);
|
||||
Assert.Equal(GerbilStatus.GivenAway, status);
|
||||
}
|
||||
|
||||
// 3) Age > 6y, no death, not abgegeben → Deceased (presumed)
|
||||
[Fact]
|
||||
public void OlderThan6y_without_death_returns_Deceased()
|
||||
{
|
||||
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears).AddDays(-1); // 1 day past threshold
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
|
||||
Assert.Equal(GerbilStatus.Deceased, status);
|
||||
}
|
||||
|
||||
// Exactly 6 years old is NOT presumed dead (threshold is strictly >)
|
||||
[Fact]
|
||||
public void ExactlyMaxAge_is_not_Deceased()
|
||||
{
|
||||
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears); // exactly 6y today
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
|
||||
// today >= dob.AddYears(6) → exactly equal → IS presumed dead
|
||||
// (threshold = "older than" so >= means Deceased)
|
||||
Assert.Equal(GerbilStatus.Deceased, status);
|
||||
}
|
||||
|
||||
// One day before threshold is alive
|
||||
[Fact]
|
||||
public void OneDayBeforeMaxAge_is_alive()
|
||||
{
|
||||
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears).AddDays(1);
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
|
||||
Assert.Equal(GerbilStatus.Breeding, status);
|
||||
}
|
||||
|
||||
// 3a) Age > 6y but Abgegeben → stays GivenAway (not Deceased)
|
||||
[Fact]
|
||||
public void OlderThan6y_but_Abgegeben_stays_GivenAway()
|
||||
{
|
||||
var dob = Today.AddYears(-7);
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.GivenAway, dob, dateOfDeath: null, isAbgegeben: true, Today);
|
||||
Assert.Equal(GerbilStatus.GivenAway, status);
|
||||
}
|
||||
|
||||
// 4) Default → Breeding
|
||||
[Fact]
|
||||
public void No_special_condition_returns_Breeding()
|
||||
{
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.Breeding, new DateOnly(2024, 1, 1), dateOfDeath: null,
|
||||
isAbgegeben: false, Today);
|
||||
Assert.Equal(GerbilStatus.Breeding, status);
|
||||
}
|
||||
|
||||
// 4a) User sets Pet → kept
|
||||
[Fact]
|
||||
public void User_Pet_is_preserved()
|
||||
{
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.Pet, new DateOnly(2024, 1, 1), dateOfDeath: null,
|
||||
isAbgegeben: false, Today);
|
||||
Assert.Equal(GerbilStatus.Pet, status);
|
||||
}
|
||||
|
||||
// 4b) User sets ForSale → kept
|
||||
[Fact]
|
||||
public void User_ForSale_is_preserved()
|
||||
{
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.ForSale, new DateOnly(2024, 1, 1), dateOfDeath: null,
|
||||
isAbgegeben: false, Today);
|
||||
Assert.Equal(GerbilStatus.ForSale, status);
|
||||
}
|
||||
|
||||
// DateOfBirth null → age rule does not fire
|
||||
[Fact]
|
||||
public void Null_DateOfBirth_does_not_trigger_age_rule()
|
||||
{
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.Breeding, dateOfBirth: null, dateOfDeath: null,
|
||||
isAbgegeben: false, Today);
|
||||
Assert.Equal(GerbilStatus.Breeding, status);
|
||||
}
|
||||
}
|
||||
343
GerbilManager.Tests/ImportDocxServiceTests.cs
Normal file
343
GerbilManager.Tests/ImportDocxServiceTests.cs
Normal file
@@ -0,0 +1,343 @@
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-8d-CREATE / FIX-8D-DEDUP: docx importer CREATE path tests.
|
||||
/// Uses SQLite (not InMemory) so FK + unique-index constraints are enforced —
|
||||
/// this is the only reliable way to catch duplicate-ExternalRef crashes.
|
||||
/// </summary>
|
||||
public class ImportDocxServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _dir;
|
||||
|
||||
public ImportDocxServiceTests()
|
||||
{
|
||||
_dir = Path.Combine(Path.GetTempPath(), "feat8d-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_dir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
// One open connection keeps the :memory: SQLite DB alive across calls.
|
||||
private (ApplicationContext db, SqliteConnection conn) NewSqliteDb()
|
||||
{
|
||||
var conn = new SqliteConnection("DataSource=:memory:");
|
||||
conn.Open();
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||
var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated(); // schema WITH unique index on ExternalRef
|
||||
return (db, conn);
|
||||
}
|
||||
|
||||
private void WriteLitters(object litters) =>
|
||||
File.WriteAllText(Path.Combine(_dir, "docx_litters.json"),
|
||||
System.Text.Json.JsonSerializer.Serialize(litters));
|
||||
|
||||
private void WriteAnimals(object animals) =>
|
||||
File.WriteAllText(Path.Combine(_dir, "docx_animals.json"),
|
||||
System.Text.Json.JsonSerializer.Serialize(animals));
|
||||
|
||||
// ── Test 1: dry-run shows correct counts without writing ─────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DryRun_counts_new_animal_without_writing()
|
||||
{
|
||||
var (db, conn) = NewSqliteDb();
|
||||
await using (conn)
|
||||
await using (db)
|
||||
{
|
||||
// Litter in DB — date-only lookup (±5 days, exactly one candidate)
|
||||
var litter = new Litter
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Testwurf",
|
||||
Date = new DateOnly(2023, 5, 1),
|
||||
};
|
||||
db.Litters.Add(litter);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
WriteLitters(Array.Empty<object>());
|
||||
WriteAnimals(new[]
|
||||
{
|
||||
new { wsCode = "4/4", litterDob = "01.05.2023", name = "Pepper", gender = "female",
|
||||
owner = "Max Mustermann", abgabeDate = "01.07.2023",
|
||||
deathDate = "", deathCause = "", farbschlag = "" }
|
||||
});
|
||||
|
||||
var report = await new ImportDocxService(db, _dir).RunAsync(execute: false);
|
||||
|
||||
Assert.False(report.Executed);
|
||||
Assert.Equal(1, report.Created);
|
||||
Assert.Equal(1, report.LitterLinked); // date-only match finds the one litter
|
||||
Assert.Equal(1, report.GoHomeFilled);
|
||||
Assert.Equal(0, await db.Gerbils.CountAsync()); // nothing written
|
||||
Assert.Equal(0, await db.Contacts.CountAsync()); // nothing written
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 2: execute creates animal with all fields + FK constraints ───────
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_creates_animal_with_litter_contact_and_goHomeDate()
|
||||
{
|
||||
var (db, conn) = NewSqliteDb();
|
||||
await using (conn)
|
||||
await using (db)
|
||||
{
|
||||
var litter = new Litter
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Testwurf",
|
||||
Date = new DateOnly(2023, 5, 1),
|
||||
};
|
||||
db.Litters.Add(litter);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
WriteLitters(Array.Empty<object>());
|
||||
WriteAnimals(new[]
|
||||
{
|
||||
new { wsCode = "4/4", litterDob = "01.05.2023", name = "Pepper", gender = "female",
|
||||
owner = "Max Mustermann", abgabeDate = "01.07.2023",
|
||||
deathDate = "", deathCause = "", farbschlag = "" }
|
||||
});
|
||||
|
||||
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||
|
||||
Assert.True(report.Executed);
|
||||
Assert.Equal(1, report.Created);
|
||||
Assert.Equal(1, report.ContactsCreated);
|
||||
|
||||
var gerbil = await db.Gerbils.SingleAsync();
|
||||
Assert.Equal("Pepper", gerbil.Name);
|
||||
Assert.Equal(new DateOnly(2023, 5, 1), gerbil.DateOfBirth);
|
||||
Assert.Equal(Gender.female, gerbil.Gender);
|
||||
Assert.Equal(GerbilStatus.GivenAway, gerbil.Status);
|
||||
Assert.Equal(litter.Id, gerbil.LitterId); // date-only link worked
|
||||
Assert.Equal(new DateOnly(2023, 7, 1), gerbil.GoHomeDate);
|
||||
Assert.Equal("Zucht der kleinen Chaoten", gerbil.OriginBreeder);
|
||||
Assert.False(gerbil.IsResident);
|
||||
Assert.Equal("docx", gerbil.ImportSource);
|
||||
Assert.StartsWith("docx-", gerbil.ExternalRef);
|
||||
|
||||
var contact = await db.Contacts.SingleAsync();
|
||||
Assert.Equal("Max Mustermann", contact.Name);
|
||||
Assert.Equal(contact.Id, gerbil.ReceiverContactId);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 3: idempotency — second run creates zero ────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_is_idempotent_second_run_creates_zero()
|
||||
{
|
||||
var (db, conn) = NewSqliteDb();
|
||||
await using (conn)
|
||||
await using (db)
|
||||
{
|
||||
WriteLitters(Array.Empty<object>());
|
||||
WriteAnimals(new[]
|
||||
{
|
||||
new { wsCode = "2/3", litterDob = "15.03.2023", name = "Flash", gender = "male",
|
||||
owner = "", abgabeDate = "01.05.2023",
|
||||
deathDate = "", deathCause = "", farbschlag = "" }
|
||||
});
|
||||
|
||||
var first = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||
Assert.Equal(1, first.Created);
|
||||
|
||||
var second = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||
Assert.Equal(0, second.Created); // ExternalRef path, no dupe
|
||||
Assert.Equal(1, await db.Gerbils.CountAsync()); // exactly one row
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 4: existing main-import animal → enrich only, no duplicate ──────
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_enriches_existing_animal_does_not_duplicate()
|
||||
{
|
||||
var (db, conn) = NewSqliteDb();
|
||||
await using (conn)
|
||||
await using (db)
|
||||
{
|
||||
// Animal already in DB (from main import, ExternalRef ≠ "docx-…")
|
||||
var existing = new Gerbil
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Eddie",
|
||||
DateOfBirth = new DateOnly(2022, 8, 10),
|
||||
Gender = Gender.male,
|
||||
ExternalRef = "main-eddie-xyz", // NOT a "docx-" ref
|
||||
IsResident = false,
|
||||
};
|
||||
db.Gerbils.Add(existing);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
WriteLitters(Array.Empty<object>());
|
||||
WriteAnimals(new[]
|
||||
{
|
||||
// Same name+dob → enrich, not create
|
||||
new { wsCode = "", litterDob = "10.08.2022", name = "Eddie", gender = "male",
|
||||
owner = "Anna Beispiel", abgabeDate = "01.10.2022",
|
||||
deathDate = "", deathCause = "", farbschlag = "" }
|
||||
});
|
||||
|
||||
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||
|
||||
Assert.Equal(0, report.Created);
|
||||
Assert.Equal(1, await db.Gerbils.CountAsync()); // still exactly one
|
||||
|
||||
// GoHomeDate was enriched
|
||||
var updated = await db.Gerbils.SingleAsync();
|
||||
Assert.Equal(new DateOnly(2022, 10, 1), updated.GoHomeDate);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 5: Deceased status when deathDate set ───────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_sets_Deceased_status_when_deathDate_provided()
|
||||
{
|
||||
var (db, conn) = NewSqliteDb();
|
||||
await using (conn)
|
||||
await using (db)
|
||||
{
|
||||
WriteLitters(Array.Empty<object>());
|
||||
WriteAnimals(new[]
|
||||
{
|
||||
new { wsCode = "1/5", litterDob = "01.01.2022", name = "Ghost", gender = "male",
|
||||
owner = "", abgabeDate = "",
|
||||
deathDate = "15.06.2022", deathCause = "Tumor", farbschlag = "" }
|
||||
});
|
||||
|
||||
await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||
|
||||
var gerbil = await db.Gerbils.SingleAsync();
|
||||
Assert.Equal(GerbilStatus.Deceased, gerbil.Status);
|
||||
Assert.Equal(new DateOnly(2022, 6, 15), gerbil.DateOfDeath);
|
||||
Assert.Equal("Tumor", gerbil.CauseOfDeath);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 7: P0 REGRESSION — execute works under a retrying execution strategy ──
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_works_under_retrying_execution_strategy()
|
||||
{
|
||||
// Regression: NpgsqlRetryingExecutionStrategy (MaxRetryCount>0) calls
|
||||
// OnFirstExecution() at the start of ExecuteAsync, which throws
|
||||
// InvalidOperationException when it detects a user-initiated transaction
|
||||
// that was NOT opened through the strategy. This test wires the same check
|
||||
// (via FakeRetryingStrategy, MaxRetryCount=1) so the bug would surface in CI
|
||||
// without a live Npgsql instance.
|
||||
//
|
||||
// With the BUG (direct BeginTransactionAsync before strategy.ExecuteAsync):
|
||||
// → OnFirstExecution sees active user tx → InvalidOperationException
|
||||
// With the FIX (BeginTransactionAsync inside strategy.ExecuteAsync lambda):
|
||||
// → OnFirstExecution: no tx yet → OK
|
||||
var conn = new SqliteConnection("DataSource=:memory:");
|
||||
conn.Open();
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseSqlite(conn)
|
||||
.ReplaceService<IExecutionStrategyFactory, FakeRetryingStrategyFactory>()
|
||||
.Options;
|
||||
var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated();
|
||||
|
||||
await using (conn)
|
||||
await using (db)
|
||||
{
|
||||
WriteLitters(Array.Empty<object>());
|
||||
WriteAnimals(new[]
|
||||
{
|
||||
new { wsCode = "3/3", litterDob = "01.01.2023", name = "Pixie", gender = "female",
|
||||
owner = "Retry Adopter", abgabeDate = "01.03.2023",
|
||||
deathDate = "", deathCause = "", farbschlag = "" }
|
||||
});
|
||||
|
||||
// Must NOT throw InvalidOperationException (user-initiated tx rejected)
|
||||
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||
|
||||
Assert.True(report.Executed);
|
||||
Assert.Equal(1, report.Created);
|
||||
Assert.Equal(1, await db.Gerbils.CountAsync());
|
||||
Assert.Equal(1, await db.Contacts.CountAsync());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 6: P0 REGRESSION — same-name siblings get distinct ExternalRefs ─
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_same_name_siblings_created_with_distinct_ExternalRefs()
|
||||
{
|
||||
// P0 regression: two animals in the same litter with the same normalized name
|
||||
// previously caused duplicate ExternalRef → Npgsql/SQLite 23505 unique-key crash.
|
||||
// Fix: ordinal disambiguation (-2) ensures uniqueness within the batch.
|
||||
// The unique index on IX_Gerbils_ExternalRef (via EnsureCreated on SQLite) makes
|
||||
// this test an authoritative regression gate.
|
||||
var (db, conn) = NewSqliteDb();
|
||||
await using (conn)
|
||||
await using (db)
|
||||
{
|
||||
WriteLitters(Array.Empty<object>());
|
||||
WriteAnimals(new[]
|
||||
{
|
||||
new { wsCode = "4/4", litterDob = "10.06.2022", name = "Mochi", gender = "female",
|
||||
owner = "Eva Müller", abgabeDate = "10.08.2022",
|
||||
deathDate = "", deathCause = "", farbschlag = "" },
|
||||
// Identical name+wsCode+litterDob → base ExternalRef collision
|
||||
new { wsCode = "4/4", litterDob = "10.06.2022", name = "Mochi", gender = "female",
|
||||
owner = "Lena Braun", abgabeDate = "11.08.2022",
|
||||
deathDate = "", deathCause = "", farbschlag = "" },
|
||||
});
|
||||
|
||||
// Must NOT throw unique-key violation
|
||||
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||
|
||||
Assert.Equal(2, report.Created);
|
||||
Assert.Equal(2, await db.Gerbils.CountAsync());
|
||||
|
||||
var refs = (await db.Gerbils.Select(g => g.ExternalRef!).ToListAsync()).OrderBy(r => r).ToList();
|
||||
// Both start with the docx- prefix
|
||||
Assert.All(refs, r => Assert.StartsWith("docx-", r));
|
||||
// Must be distinct (unique index enforces this in SQLite)
|
||||
Assert.Equal(2, refs.Distinct().Count());
|
||||
// Second occurrence gets the -2 suffix
|
||||
Assert.Single(refs, r => r.EndsWith("-2"));
|
||||
|
||||
// Idempotent re-run: zero new, still 2 in DB
|
||||
var second = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||
Assert.Equal(0, second.Created);
|
||||
Assert.Equal(2, await db.Gerbils.CountAsync());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers for Test 7 ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Execution strategy with MaxRetryCount=1 so that EF Core's base
|
||||
/// OnFirstExecution() throws when it detects a user-initiated transaction
|
||||
/// that was not opened through CreateExecutionStrategy().ExecuteAsync().
|
||||
/// ShouldRetryOn=false → no actual retry; the check alone is what we need.
|
||||
/// </summary>
|
||||
internal sealed class FakeRetryingStrategy(ExecutionStrategyDependencies deps)
|
||||
: ExecutionStrategy(deps, maxRetryCount: 1, maxRetryDelay: TimeSpan.Zero)
|
||||
{
|
||||
protected override bool ShouldRetryOn(Exception exception) => false;
|
||||
}
|
||||
|
||||
internal sealed class FakeRetryingStrategyFactory(ExecutionStrategyDependencies deps)
|
||||
: IExecutionStrategyFactory
|
||||
{
|
||||
public IExecutionStrategy Create() => new FakeRetryingStrategy(deps);
|
||||
}
|
||||
}
|
||||
761
GerbilManager.Tests/ImportServiceTests.cs
Normal file
761
GerbilManager.Tests/ImportServiceTests.cs
Normal file
@@ -0,0 +1,761 @@
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-8c loader tests against a small JSON fixture + an in-memory DB (never Julian's
|
||||
/// instance). Covers categorisation, the quarantine policy, genotype composition,
|
||||
/// Farbschlag matching, high-confidence linking, and idempotency.
|
||||
/// </summary>
|
||||
public class ImportServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _dir;
|
||||
|
||||
public ImportServiceTests()
|
||||
{
|
||||
_dir = Path.Combine(Path.GetTempPath(), "feat8c-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_dir);
|
||||
File.WriteAllText(Path.Combine(_dir, "litters.json"), LittersJson);
|
||||
File.WriteAllText(Path.Combine(_dir, "animals.json"), AnimalsJson);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
private ApplicationContext NewDb()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseInMemoryDatabase("feat8c-" + Guid.NewGuid().ToString("N"))
|
||||
.Options;
|
||||
var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated(); // applies the 73-variety HasData seed
|
||||
return db;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DryRun_categorises_without_writing()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var report = await new ImportService(db, _dir, _dir).RunAsync(execute: false);
|
||||
|
||||
Assert.False(report.Executed);
|
||||
Assert.Equal(2, report.Litters.InSource);
|
||||
Assert.Equal(2, report.Litters.Created);
|
||||
// 4 animals: a1 (hoch) + a4 (date-only) loadable; a2 conflict, a3 stub quarantined
|
||||
Assert.Equal(4, report.Animals.InSource);
|
||||
Assert.Equal(2, report.Animals.Created);
|
||||
Assert.Equal(1, report.Animals.LinkedToLitter); // only a1 (hoch)
|
||||
Assert.Equal(1, report.Animals.FarbschlagMatched); // a1 -> Agouti
|
||||
Assert.Equal(1, report.Animals.Quarantined.Conflicts);
|
||||
Assert.Equal(1, report.Animals.Quarantined.Stubs);
|
||||
Assert.Equal(1, report.Animals.Quarantined.DateOnlyLinks);
|
||||
|
||||
// nothing written in dry-run
|
||||
Assert.Equal(0, await db.Gerbils.CountAsync());
|
||||
Assert.Equal(0, await db.Litters.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_loads_conflict_free_and_links_high_confidence()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var report = await new ImportService(db, _dir, _dir).RunAsync(execute: true);
|
||||
|
||||
Assert.True(report.Executed);
|
||||
Assert.Equal(2, await db.Litters.CountAsync());
|
||||
Assert.Equal(2, await db.Gerbils.CountAsync()); // a1, a4 only
|
||||
|
||||
// conflict + stub never loaded
|
||||
Assert.False(await db.Gerbils.AnyAsync(g => g.ExternalRef == "a2"));
|
||||
Assert.False(await db.Gerbils.AnyAsync(g => g.ExternalRef == "a3"));
|
||||
|
||||
var a1 = await db.Gerbils.SingleAsync(g => g.ExternalRef == "a1");
|
||||
Assert.NotNull(a1.LitterId); // high-confidence link
|
||||
Assert.Equal("FEAT-8 Stammbaum/Wurfchronik", a1.ImportSource);
|
||||
Assert.NotNull(a1.ColorVarietyId); // Agouti matched
|
||||
Assert.Contains("aa CC", a1.Genotype); // composed from mapped8locus
|
||||
Assert.Contains("??", a1.Genotype!); // missing loci -> ??
|
||||
Assert.Contains("eef", a1.Genotype!); // e^f caret stripped
|
||||
Assert.Contains("RawGenotype", a1.RawImportData!); // raw verbatim preserved
|
||||
|
||||
var a4 = await db.Gerbils.SingleAsync(g => g.ExternalRef == "a4");
|
||||
Assert.Null(a4.LitterId); // date-only link quarantined
|
||||
|
||||
// PairingCode carried through
|
||||
Assert.True(await db.Litters.AnyAsync(l => l.PairingCode == "G01/ZdkC"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_is_idempotent()
|
||||
{
|
||||
using var db = NewDb();
|
||||
await new ImportService(db, _dir, _dir).RunAsync(execute: true);
|
||||
var second = await new ImportService(db, _dir, _dir).RunAsync(execute: true);
|
||||
|
||||
Assert.Equal(0, second.Animals.Created);
|
||||
Assert.Equal(0, second.Litters.Created);
|
||||
Assert.Equal(2, await db.Gerbils.CountAsync());
|
||||
Assert.Equal(2, await db.Litters.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DB5_Litter_ExternalRef_set_and_used_for_idempotency()
|
||||
{
|
||||
// DB-5: litters must have ExternalRef set to the source litter id on first import,
|
||||
// and subsequent runs must detect them via ExternalRef (not just Name+Date).
|
||||
using var db = NewDb();
|
||||
await new ImportService(db, _dir, _dir).RunAsync(execute: true);
|
||||
|
||||
// ExternalRef is set on created litters
|
||||
var litters = await db.Litters.ToListAsync();
|
||||
Assert.All(litters, l => Assert.NotNull(l.ExternalRef));
|
||||
Assert.Contains(litters, l => l.ExternalRef == "L1");
|
||||
Assert.Contains(litters, l => l.ExternalRef == "L2");
|
||||
|
||||
// Simulate the "Name+Date lookup would still work, but ExternalRef is now primary":
|
||||
// mutate Name to something different — Name+Date fallback would fail, ExternalRef must catch it.
|
||||
foreach (var l in litters) l.Name = "Geänderter Name";
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Re-import: litters detected as existing via ExternalRef even though Name changed
|
||||
var second = await new ImportService(db, _dir, _dir).RunAsync(execute: true);
|
||||
Assert.Equal(0, second.Litters.Created);
|
||||
Assert.Equal(2, await db.Litters.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_persists_deaf_flag_and_preserves_sls_and_tags()
|
||||
{
|
||||
using var db = NewDb();
|
||||
await new ImportService(db, _dir, _dir).RunAsync(execute: true);
|
||||
|
||||
var a1 = await db.Gerbils.SingleAsync(g => g.ExternalRef == "a1");
|
||||
// GEN-3b: deafness is a persisted phenotype flag (NOT a genotype locus).
|
||||
Assert.True(a1.IsDeaf);
|
||||
// Sls (2nd spotting locus) + provenance tags are preserved in RawImportData
|
||||
// (kept out of the 8-locus compact Genotype contract until GEN-3a adopts them).
|
||||
Assert.Contains("Sls", a1.RawImportData!);
|
||||
Assert.Contains("WFNZ", a1.RawImportData!);
|
||||
// GEN-3a contract: a WP/Sls carrier appends the trailing "Slsl" token (Kevin).
|
||||
Assert.EndsWith("Slsl", a1.Genotype!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComposeGenotype_appends_Slsl_only_for_carriers()
|
||||
{
|
||||
// wild-type sl/sl is omitted -> plain 8-locus string
|
||||
var wild = new SourceGenotype { Mapped8locus = new() { ["A"] = new() { "a", "a" }, ["Sls"] = new() { "sl", "sl" } } };
|
||||
Assert.DoesNotContain("Sl", ImportService.ComposeGenotype(wild));
|
||||
// WP heterozygote -> trailing Slsl
|
||||
var wp = new SourceGenotype { Mapped8locus = new() { ["A"] = new() { "a", "a" }, ["Sls"] = new() { "Sl", "sl" } } };
|
||||
Assert.EndsWith("Slsl", ImportService.ComposeGenotype(wp));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Synthesizes_litter_from_chart_parentRefs_links_offspring_and_parents()
|
||||
{
|
||||
// Offspring 'C' has chart-position parentRefs to father 'Papa' (loaded) and mother
|
||||
// 'Mama' (loaded), but NO Wurfchronik litterRef -> the loader must synthesize a derived
|
||||
// litter, link C to it, and set the litter's Father/Mother (PEDIGREE-LINK structural fix).
|
||||
var dir = Path.Combine(Path.GetTempPath(), "pedlink-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"papa","name":"Papa v.d. Test","dob":"01.01.2022","death":"","farbschlag":"","gender":"male","zuchtCanon":"test",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"mama","name":"Mama v.d. Test","dob":"02.02.2022","death":"","farbschlag":"","gender":"female","zuchtCanon":"test",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"ext","name":"Fremd of Foreign","dob":"03.03.2022","death":"","farbschlag":"","zuchtCanon":"foreign",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"c","name":"C","dob":"29.04.2024","death":"","farbschlag":"","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||
"parentRefs":[
|
||||
{"name":"Papa v.d. Test","dob":"01.01.2022","roleGuess":"father","method":"chart-position","confidence":"medium"},
|
||||
{"name":"Mama v.d. Test","dob":"02.02.2022","roleGuess":"mother","method":"chart-position","confidence":"medium"}
|
||||
]}
|
||||
]
|
||||
""");
|
||||
using var db = NewDb();
|
||||
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
|
||||
Assert.Equal(1, report.Animals.ParentLinksFromChart);
|
||||
Assert.Equal(1, report.Litters.DerivedFromChart);
|
||||
|
||||
var papa = await db.Gerbils.SingleAsync(g => g.ExternalRef == "papa");
|
||||
var mama = await db.Gerbils.SingleAsync(g => g.ExternalRef == "mama");
|
||||
var c = await db.Gerbils.SingleAsync(g => g.ExternalRef == "c");
|
||||
Assert.NotNull(c.LitterId); // C no longer "unbekannt"
|
||||
|
||||
// box-colour sex flows through (blue=male, white=female)
|
||||
Assert.Equal(Gender.male, papa.Gender);
|
||||
Assert.Equal(Gender.female, mama.Gender);
|
||||
|
||||
var litter = await db.Litters.SingleAsync(l => l.Id == c.LitterId);
|
||||
Assert.Equal(papa.Id, litter.FatherId);
|
||||
Assert.Equal(mama.Id, litter.MotherId);
|
||||
Assert.Contains("Diagramm", litter.Notes!); // transparent + reversible
|
||||
|
||||
// OWNERSHIP/RESIDENCY: C is Clan (rule a); its foreign-Zucht parents flip to
|
||||
// resident (rule b); the unrelated foreign animal stays external.
|
||||
Assert.True(c.IsResident); // rule (a)
|
||||
Assert.True(papa.IsResident); // rule (b) parent exception
|
||||
Assert.True(mama.IsResident); // rule (b)
|
||||
Assert.False((await db.Gerbils.SingleAsync(g => g.ExternalRef == "ext")).IsResident);
|
||||
Assert.NotNull(report.Residency);
|
||||
Assert.Equal(3, report.Residency!.Resident);
|
||||
Assert.Equal(1, report.Residency.External);
|
||||
Assert.Equal(2, report.Residency.FlippedByParentRule);
|
||||
}
|
||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_on_relational_db_with_new_chart_parents_does_not_FK_throw()
|
||||
{
|
||||
// Regression for FK_Litters_Gerbils_FatherId: a derived litter references parent gerbils
|
||||
// created in the SAME run, so they must be inserted before the litter. The EF in-memory
|
||||
// provider does NOT enforce FKs (which masked the bug), so this uses SQLite — which does.
|
||||
var dir = Path.Combine(Path.GetTempPath(), "fkfix-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||
conn.Open();
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"papa","name":"Papa v.d. Test","dob":"01.01.2022","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"mama","name":"Mama v.d. Test","dob":"02.02.2022","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"c","name":"C","dob":"29.04.2024","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||
"parentRefs":[
|
||||
{"name":"Papa v.d. Test","dob":"01.01.2022","roleGuess":"father","method":"chart-position","confidence":"medium"},
|
||||
{"name":"Mama v.d. Test","dob":"02.02.2022","roleGuess":"mother","method":"chart-position","confidence":"medium"}
|
||||
]}
|
||||
]
|
||||
""");
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
await db.Database.EnsureCreatedAsync(); // schema WITH enforced FK constraints
|
||||
|
||||
// must not throw FK_Litters_Gerbils_FatherId (parents inserted before the litter)
|
||||
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
|
||||
var c = await db.Gerbils.SingleAsync(g => g.ExternalRef == "c");
|
||||
Assert.NotNull(c.LitterId);
|
||||
var litter = await db.Litters.SingleAsync(l => l.Id == c.LitterId);
|
||||
Assert.Equal((await db.Gerbils.SingleAsync(g => g.ExternalRef == "papa")).Id, litter.FatherId);
|
||||
Assert.Equal(0, report.Litters.ParentFksDropped);
|
||||
Assert.Equal(0, report.Litters.DerivedSkipped);
|
||||
}
|
||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_circular_gerbil_litter_dependency_does_not_throw()
|
||||
{
|
||||
// Regression for "Unable to save changes because a circular dependency was detected":
|
||||
// Gerbil [Added] ← FK{MotherId} Litter [Added] ← FK{LitterId} Gerbil [Added].
|
||||
// Triggered when a gerbil is both parent (in one synth litter) and offspring
|
||||
// (LitterId → another synth litter) in the SAME SaveChanges batch — forms a cycle EF
|
||||
// topo-sort cannot resolve. This test encodes the minimal reproducer: A's mother is B,
|
||||
// B's mother is A (artificial genealogical cycle, but triggers the EF cycle reliably).
|
||||
var dir = Path.Combine(Path.GetTempPath(), "cycle-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||
conn.Open();
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"alpha","name":"Alpha","dob":"01.01.2020","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||
"parentRefs":[{"name":"Beta","dob":"01.01.2019","roleGuess":"mother","method":"chart-position","confidence":"medium"}]},
|
||||
{"id":"beta","name":"Beta","dob":"01.01.2019","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||
"parentRefs":[{"name":"Alpha","dob":"01.01.2020","roleGuess":"mother","method":"chart-position","confidence":"medium"}]}
|
||||
]
|
||||
""");
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
|
||||
// must NOT throw InvalidOperationException (circular dependency)
|
||||
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
|
||||
Assert.Equal(2, await db.Gerbils.CountAsync());
|
||||
var alpha = await db.Gerbils.SingleAsync(g => g.ExternalRef == "alpha");
|
||||
var beta = await db.Gerbils.SingleAsync(g => g.ExternalRef == "beta");
|
||||
// both should be litter-linked
|
||||
Assert.NotNull(alpha.LitterId);
|
||||
Assert.NotNull(beta.LitterId);
|
||||
// deferred FK update must have set the litter parents correctly
|
||||
var alphaLitter = await db.Litters.SingleAsync(l => l.Id == alpha.LitterId);
|
||||
Assert.Equal(beta.Id, alphaLitter.MotherId);
|
||||
var betaLitter = await db.Litters.SingleAsync(l => l.Id == beta.LitterId);
|
||||
Assert.Equal(alpha.Id, betaLitter.MotherId);
|
||||
}
|
||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Derived_litter_with_quarantined_parent_leaves_FK_null_no_throw()
|
||||
{
|
||||
// A chart parentRef pointing to a QUARANTINED (conflict) animal must not become an FK —
|
||||
// the derived litter keeps that side null; if both sides are unresolvable, no litter.
|
||||
var dir = Path.Combine(Path.GetTempPath(), "fkq-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||
conn.Open();
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"badpa","name":"BadPapa v.d. Test","dob":"01.01.2022","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true},
|
||||
{"id":"goodma","name":"GoodMama v.d. Test","dob":"02.02.2022","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"c2","name":"C2","dob":"29.04.2024","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||
"parentRefs":[
|
||||
{"name":"BadPapa v.d. Test","dob":"01.01.2022","roleGuess":"father","method":"chart-position","confidence":"medium"},
|
||||
{"name":"GoodMama v.d. Test","dob":"02.02.2022","roleGuess":"mother","method":"chart-position","confidence":"medium"}
|
||||
]}
|
||||
]
|
||||
""");
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
|
||||
await new ImportService(db, dir, dir).RunAsync(execute: true); // must not throw
|
||||
|
||||
Assert.False(await db.Gerbils.AnyAsync(g => g.ExternalRef == "badpa")); // quarantined
|
||||
var c2 = await db.Gerbils.SingleAsync(g => g.ExternalRef == "c2");
|
||||
Assert.NotNull(c2.LitterId); // still linked (via mother)
|
||||
var litter = await db.Litters.SingleAsync(l => l.Id == c2.LitterId);
|
||||
Assert.Null(litter.FatherId); // quarantined father -> null FK
|
||||
Assert.Equal((await db.Gerbils.SingleAsync(g => g.ExternalRef == "goodma")).Id, litter.MotherId);
|
||||
}
|
||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Decision_resolved_animal_loads_and_is_counted()
|
||||
{
|
||||
// extract.py clears the conflict + sets resolvedByDecision when a human conflict-decision
|
||||
// un-quarantines an animal; the loader must then LOAD it and surface the count.
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dec-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"firefly","name":"Firefly","dob":"18.12.2019","death":"","farbschlag":"Agouti",
|
||||
"genotype":{"mapped8locus":{"A":["A","a"],"D":["D","?"]},"rawGenotype":"Aa D-","unmappedTokens":[]},
|
||||
"conflict":false,"resolvedByDecision":true}
|
||||
]
|
||||
""");
|
||||
using var db = NewDb();
|
||||
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
|
||||
Assert.Equal(1, report.Animals.ConflictsResolvedByDecision);
|
||||
Assert.True(await db.Gerbils.AnyAsync(g => g.ExternalRef == "firefly")); // loaded, not quarantined
|
||||
Assert.Equal(0, report.Animals.Quarantined.Conflicts);
|
||||
}
|
||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComposeGenotype_strips_carets_and_fills_missing_loci()
|
||||
{
|
||||
var g = new SourceGenotype
|
||||
{
|
||||
Mapped8locus = new()
|
||||
{
|
||||
["A"] = new() { "a", "a" },
|
||||
["C"] = new() { "C", "c^chm" },
|
||||
["E"] = new() { "e", "e^f" },
|
||||
},
|
||||
};
|
||||
// order A C D E G P Sp Re ; missing -> ??
|
||||
Assert.Equal("aa Ccchm ?? eef ?? ?? ?? ??", ImportService.ComposeGenotype(g));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParentFkBackfill_fills_null_litter_parent_on_reimport()
|
||||
{
|
||||
// Run 1: litter "Wurf A" has sire "Vater" (conflict=true — not loaded) and dam "Mutter"
|
||||
// (conflict=false — loaded). After run 1: litter.FatherId = null.
|
||||
// Run 2: sire "Vater" now conflict=false → loaded as NEW in run 2. Backfill via
|
||||
// createdAnimalByName sets FatherId. (god steering point 3: run-2 path.)
|
||||
var dir = Path.Combine(Path.GetTempPath(), "backfill-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||
conn.Open();
|
||||
try
|
||||
{
|
||||
var littersJson = """
|
||||
[{"id":"L-A","litterId":"A","date":"01.05.2023","damName":"Mutter [ZdkC]","sireName":"Vater [ZdkC]","totalBorn":3,"zuchtnummer":"","note":""}]
|
||||
""";
|
||||
// Run 1: Vater is in conflict -> not loaded
|
||||
var animals1 = """
|
||||
[
|
||||
{"id":"mutter","name":"Mutter [ZdkC]","dob":"01.01.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"vater","name":"Vater [ZdkC]","dob":"02.02.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true},
|
||||
{"id":"kind","name":"Kind [ZdkC]","dob":"01.05.2023","death":"","farbschlag":"","gender":null,"zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||
"litterRef":{"litterId":"L-A","method":"geburtsdatum+eltern","confidence":"hoch"}}
|
||||
]
|
||||
""";
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), littersJson);
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), animals1);
|
||||
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
|
||||
var report1 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
Assert.Equal(0, report1.Litters.ParentFksBackfilled);
|
||||
var litter1 = await db.Litters.SingleAsync(l => l.Name == "Wurf A");
|
||||
Assert.Null(litter1.FatherId); // Vater was quarantined -> null FK
|
||||
Assert.NotNull(litter1.MotherId); // Mutter was loaded -> set
|
||||
|
||||
// Run 2: Vater now conflict=false -> loaded as NEW animal in this run
|
||||
var animals2 = """
|
||||
[
|
||||
{"id":"mutter","name":"Mutter [ZdkC]","dob":"01.01.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"vater","name":"Vater [ZdkC]","dob":"02.02.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"kind","name":"Kind [ZdkC]","dob":"01.05.2023","death":"","farbschlag":"","gender":null,"zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||
"litterRef":{"litterId":"L-A","method":"geburtsdatum+eltern","confidence":"hoch"}}
|
||||
]
|
||||
""";
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), animals2);
|
||||
|
||||
var report2 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
Assert.Equal(1, report2.Litters.ParentFksBackfilled); // backfill happened
|
||||
var vater = await db.Gerbils.SingleAsync(g => g.ExternalRef == "vater");
|
||||
var litter2 = await db.Litters.SingleAsync(l => l.Name == "Wurf A");
|
||||
Assert.Equal(vater.Id, litter2.FatherId); // FK now set
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParentFkBackfill_uses_allDb_lookup_when_parent_not_in_current_loadable()
|
||||
{
|
||||
// god steering point 3: the main case — parent was loaded in a PREVIOUS run (not in
|
||||
// the current run's animals.json at all). Backfill must find them via allDbNormToGid.
|
||||
//
|
||||
// Run 1: litter "Wurf C" + dam loaded, sire quarantined -> FatherId null.
|
||||
// Run 2: sire loaded (new animal).
|
||||
// Run 3: animals.json has ONLY the kind (sire absent from extract). Sire is in DB
|
||||
// from run 2 but NOT in the current run's loadable/createdAnimalByName.
|
||||
// Backfill must use allDbNormToGid to find him.
|
||||
var dir = Path.Combine(Path.GetTempPath(), "backfill-db-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||
conn.Open();
|
||||
try
|
||||
{
|
||||
var littersJson = """
|
||||
[{"id":"L-C","litterId":"C","date":"10.06.2023","damName":"Dame [ZdkC]","sireName":"Herr [ZdkC]","totalBorn":2,"zuchtnummer":"","note":""}]
|
||||
""";
|
||||
// Run 1: sire quarantined
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), littersJson);
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"dame","name":"Dame [ZdkC]","dob":"05.05.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"herr","name":"Herr [ZdkC]","dob":"06.06.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true}
|
||||
]
|
||||
""");
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
Assert.Null((await db.Litters.SingleAsync(l => l.Name == "Wurf C")).FatherId);
|
||||
|
||||
// Run 2: sire now loaded
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"dame","name":"Dame [ZdkC]","dob":"05.05.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"herr","name":"Herr [ZdkC]","dob":"06.06.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false}
|
||||
]
|
||||
""");
|
||||
await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
var herrId = (await db.Gerbils.SingleAsync(g => g.ExternalRef == "herr")).Id;
|
||||
// Run 2 itself may or may not backfill (depends on name normalization alignment).
|
||||
// For the test we care about run 3.
|
||||
|
||||
// Run 3: sire NOT in animals.json at all (absent from new extract).
|
||||
// litter still has FatherId=null if run 2 didn't backfill; if it did, we simulate
|
||||
// by manually resetting FatherId to null so run 3 must fix it.
|
||||
var litter3 = await db.Litters.SingleAsync(l => l.Name == "Wurf C");
|
||||
litter3.FatherId = null;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"dame","name":"Dame [ZdkC]","dob":"05.05.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false}
|
||||
]
|
||||
""");
|
||||
// Run 3: sire absent from loadable (NOT in createdAnimalByName), but IS in DB.
|
||||
var report3 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
Assert.Equal(1, report3.Litters.ParentFksBackfilled); // allDbNormToGid path
|
||||
var litter3After = await db.Litters.SingleAsync(l => l.Name == "Wurf C");
|
||||
Assert.Equal(herrId, litter3After.FatherId); // FK set from DB lookup
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParentFkBackfill_dry_run_counts_without_writing()
|
||||
{
|
||||
// Dry-run on a DB with an existing null-parent litter should predict the backfill count.
|
||||
var dir = Path.Combine(Path.GetTempPath(), "backfill-dr-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||
conn.Open();
|
||||
try
|
||||
{
|
||||
var littersJson = """
|
||||
[{"id":"L-B","litterId":"B","date":"15.06.2023","damName":"Mami [ZdkC]","sireName":"Papi [ZdkC]","totalBorn":2,"zuchtnummer":"","note":""}]
|
||||
""";
|
||||
var animals1 = """
|
||||
[
|
||||
{"id":"mami","name":"Mami [ZdkC]","dob":"03.03.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"papi","name":"Papi [ZdkC]","dob":"04.04.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true}
|
||||
]
|
||||
""";
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), littersJson);
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), animals1);
|
||||
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
await new ImportService(db, dir, dir).RunAsync(execute: true); // run 1
|
||||
|
||||
// Run 2 dry-run with papi un-quarantined
|
||||
var animals2 = animals1.Replace("\"conflict\":true", "\"conflict\":false");
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), animals2);
|
||||
var dry = await new ImportService(db, dir, dir).RunAsync(execute: false);
|
||||
|
||||
Assert.Equal(1, dry.Litters.ParentFksBackfilled); // predicted but not written
|
||||
var litter = await db.Litters.SingleAsync(l => l.Name == "Wurf B");
|
||||
Assert.Null(litter.FatherId); // not written in dry-run
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UndatedLitters_counted_as_WithoutDate_not_Created()
|
||||
{
|
||||
// COUNTER-BUG regression: litters with no parseable date must go into WithoutDate,
|
||||
// NOT Created. On re-import, Created must be 0 (not 31-phantom-phantom-phantom...).
|
||||
var dir = Path.Combine(Path.GetTempPath(), "undated-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
// One dated litter, one undated litter (blank date field)
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), """
|
||||
[
|
||||
{"id":"L-dated","litterId":"A","date":"01.02.2020","damName":"Mutter","sireName":"Vater","totalBorn":3,"zuchtnummer":"","note":""},
|
||||
{"id":"L-undated","litterId":"B","date":"","damName":"Mutter","sireName":"Vater","totalBorn":0,"zuchtnummer":"","note":""}
|
||||
]
|
||||
""");
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), "[]");
|
||||
|
||||
using var db = NewDb();
|
||||
|
||||
// First run
|
||||
var r1 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
Assert.Equal(2, r1.Litters.InSource);
|
||||
Assert.Equal(1, r1.Litters.Created); // only the dated one
|
||||
Assert.Equal(0, r1.Litters.AlreadyImported);
|
||||
Assert.Equal(1, r1.Litters.WithoutDate); // the undated one
|
||||
Assert.Equal(1, await db.Litters.CountAsync()); // only 1 persisted
|
||||
|
||||
// Second run (re-import): dated litter is now existing, undated still WithoutDate
|
||||
var r2 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
Assert.Equal(0, r2.Litters.Created); // no phantom "created"
|
||||
Assert.Equal(1, r2.Litters.AlreadyImported);
|
||||
Assert.Equal(1, r2.Litters.WithoutDate);
|
||||
Assert.Equal(1, await db.Litters.CountAsync()); // still only 1 row
|
||||
}
|
||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeedGen3g_existing_varieties_preserve_id_name_binding()
|
||||
{
|
||||
// CATALOG-RESEED regression: ID->Name bindings for all 66 existing entries must be
|
||||
// stable. 4 new Dilute Fuchs varieties appended (IDs 67-70). CP-Fuchs (ID 58) and
|
||||
// the CP-*-Hell entries (IDs 62-66) must survive with their original names intact.
|
||||
using var db = NewDb(); // EnsureCreated applies HasData including 70-entry seed
|
||||
|
||||
// ID 58 = CP-Fuchs — must still be CP-Fuchs (no rename-shift from new entries)
|
||||
var cpFuchsId = new Guid("00000000-0000-0000-0000-000000000058");
|
||||
var cpFuchs = db.ColorVarieties.Find(cpFuchsId);
|
||||
Assert.NotNull(cpFuchs);
|
||||
Assert.Equal("CP-Fuchs", cpFuchs!.Name);
|
||||
|
||||
// Existing CP-*-Hell entries (IDs 62-66) unchanged
|
||||
Assert.Equal("CP-Agouti-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000062"))!.Name);
|
||||
Assert.Equal("CP-Silberagouti-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000063"))!.Name);
|
||||
Assert.Equal("CP-Algierfuchs-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000064"))!.Name);
|
||||
Assert.Equal("CP-Polarfuchs-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000065"))!.Name);
|
||||
Assert.Equal("CP-Orangeschimmel-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000066"))!.Name);
|
||||
|
||||
// 4 new Dilute Fuchs varieties appended (IDs 67-70)
|
||||
Assert.Equal("Dilute Algierfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000067"))!.Name);
|
||||
Assert.Equal("Dilute Goldfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000068"))!.Name);
|
||||
Assert.Equal("Dilute Rotfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000069"))!.Name);
|
||||
Assert.Equal("Dilute Polarfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000070"))!.Name);
|
||||
|
||||
// Total count is exactly 70
|
||||
Assert.Equal(70, db.ColorVarieties.Count());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("01.02.2020", 2020, 2, 1)]
|
||||
[InlineData("5.3.21", 2021, 3, 5)]
|
||||
public void ParseDate_handles_german_dates(string s, int y, int m, int d)
|
||||
{
|
||||
var date = ImportService.ParseDate(s);
|
||||
Assert.Equal(new DateOnly(y, m, d), date);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CR9_NameDOB_drift_falls_back_to_ExternalRef_no_throw()
|
||||
{
|
||||
// CR-9: if an already-imported animal's Name or DOB in animals.json no longer matches
|
||||
// what's stored in the DB (e.g. after a correctDob remap or manual UI rename), the
|
||||
// gidByNameDob lookup used to throw KeyNotFoundException. Now it falls back to the
|
||||
// stable ExternalRef without throwing.
|
||||
var dir = Path.Combine(Path.GetTempPath(), "cr9-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[{"id":"drift","name":"Drift Tier","dob":"01.01.2021","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},
|
||||
"conflict":false}]
|
||||
""");
|
||||
using var db = NewDb();
|
||||
// Run 1: load the animal normally
|
||||
await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
Assert.Equal(1, await db.Gerbils.CountAsync());
|
||||
|
||||
// Simulate drift: manually rename the animal in the DB (UI rename scenario)
|
||||
var g = await db.Gerbils.SingleAsync(x => x.ExternalRef == "drift");
|
||||
g.Name = "Umbenannt Tier";
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Run 2: animals.json still has old name "Drift Tier" — must NOT throw
|
||||
var report2 = await new ImportService(db, dir, dir).RunAsync(execute: false);
|
||||
// Dry-run should complete without throwing; animal is found by ExternalRef fallback
|
||||
Assert.Equal(1, await db.Gerbils.CountAsync()); // no duplicate created
|
||||
}
|
||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CR11_ColorVariety_derived_from_genotype_when_no_explicit_farbschlag()
|
||||
{
|
||||
// CR-11: deep-band animals have empty Farbschlag but a full genotype. The loader
|
||||
// must derive ColorVarietyId from the catalog when the name-match yields nothing.
|
||||
// "Agouti" = aa CC DD EE GG PP spsp rere (first seed entry, ID 00000001).
|
||||
var dir = Path.Combine(Path.GetTempPath(), "cr11-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||
// Exact Agouti genotype, no explicit Farbschlag name
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[{"id":"agouti-deep","name":"Opa Waldmann","dob":"01.01.2018","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{"A":["a","a"],"C":["C","C"],"D":["D","D"],"E":["E","E"],"G":["G","G"],"P":["P","P"],"Sp":["sp","sp"],"Re":["re","re"]},
|
||||
"rawGenotype":"aa CC DD EE GG PP spsp rere","unmappedTokens":[]},
|
||||
"conflict":false}]
|
||||
""");
|
||||
using var db = NewDb();
|
||||
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
|
||||
var tier = await db.Gerbils.SingleAsync(g => g.ExternalRef == "agouti-deep");
|
||||
// ColorVarietyId must be set even though no explicit Farbschlag name was given
|
||||
Assert.NotNull(tier.ColorVarietyId);
|
||||
// Should be the "Agouti" variety (id = 00000000-0000-0000-0000-000000000001)
|
||||
var variety = await db.ColorVarieties.FindAsync(tier.ColorVarietyId);
|
||||
Assert.Equal("Agouti", variety!.Name);
|
||||
// Report counter should reflect the genotype derivation
|
||||
Assert.True(report.Animals.FarbschlagDerivedFromGenotype > 0);
|
||||
}
|
||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||
}
|
||||
|
||||
// ---- fixtures ----
|
||||
private const string LittersJson = """
|
||||
[
|
||||
{"id":"L1","litterId":"A","date":"01.02.2020","damName":"Mama [X]","sireName":"Papa of Y","totalBorn":4,"zuchtnummer":"G01/ZdkC","note":"erster Wurf"},
|
||||
{"id":"L2","litterId":"B","date":"05.03.2021","damName":"Oma [Z]","sireName":"Opa of W","totalBorn":2,"zuchtnummer":"G02/ZdkC","note":""}
|
||||
]
|
||||
""";
|
||||
|
||||
private const string AnimalsJson = """
|
||||
[
|
||||
{"id":"a1","name":"Kind Eins","dob":"01.02.2020","death":"","gender":null,
|
||||
"farbschlag":"Agouti","farbschlagVariants":["Agouti"],
|
||||
"genotype":{"mapped8locus":{"A":["a","a"],"C":["C","C"],"D":["D","?"],"E":["e","e^f"],"Sls":["Sl","sl"]},"rawGenotype":"aa CC D- ee[f] WP dea WFNZ","unmappedTokens":[]},
|
||||
"deaf":true,"tags":["WFNZ"],
|
||||
"zucht":"","parentRefs":[],"photos":[],"sourceFiles":["f1"],"conflict":false,
|
||||
"litterRef":{"litterId":"L1","method":"geburtsdatum+eltern","confidence":"hoch"}},
|
||||
{"id":"a2","name":"Streit","dob":"01.01.2019","death":"","gender":null,
|
||||
"farbschlag":"Schwarz","farbschlagVariants":["Schwarz"],
|
||||
"genotype":{"mapped8locus":{"A":["a","a"]},"rawGenotype":"aa","unmappedTokens":[]},
|
||||
"zucht":"","parentRefs":[],"photos":[],"sourceFiles":["f1","f2"],"conflict":true},
|
||||
{"id":"a3","name":"Namenlos","dob":"","death":"","gender":null,
|
||||
"farbschlag":"","farbschlagVariants":[],
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},
|
||||
"zucht":"","parentRefs":[],"photos":[],"sourceFiles":["f1"],"conflict":false},
|
||||
{"id":"a4","name":"Unsicher","dob":"05.03.2021","death":"","gender":null,
|
||||
"farbschlag":"","farbschlagVariants":[],
|
||||
"genotype":{"mapped8locus":{"A":["A","a"]},"rawGenotype":"Aa","unmappedTokens":[]},
|
||||
"zucht":"","parentRefs":[],"photos":[],"sourceFiles":["f1"],"conflict":false,
|
||||
"litterRef":{"litterId":"L2","method":"geburtsdatum","confidence":"niedrig"}}
|
||||
]
|
||||
""";
|
||||
}
|
||||
}
|
||||
217
GerbilManager.Tests/InboxDraftTests.cs
Normal file
217
GerbilManager.Tests/InboxDraftTests.cs
Normal file
@@ -0,0 +1,217 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Inbox;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.SaleAd;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// INBOX-2: KI-Antwortentwurf — Prompt-Assembly (inkl. Datenminimierung +
|
||||
/// Zitat-/Signatur-Stripping), 503-unkonfiguriert, und der Endpoint-Round-Trip
|
||||
/// gegen einen Stub-Anbieter (Entwurf wird gespeichert UND zurückgegeben).
|
||||
/// </summary>
|
||||
public class InboxDraftTests
|
||||
{
|
||||
private static Request SampleRequest() => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GmailMessageId = $"<{Guid.NewGuid():N}@mail.example>",
|
||||
FromAddress = "anna.musterfrau@example.de",
|
||||
FromName = "Anna Musterfrau",
|
||||
Subject = "Anfrage: zwei Weibchen?",
|
||||
BodyText = "Hallo! Habt ihr aktuell zwei junge Weibchen zur Abgabe?\nViele Grüße, Anna",
|
||||
ReceivedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
// ── Zitat-/Signatur-Stripping ──────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void StripQuotedText_entfernt_Zitate_Signatur_und_Outlook_Header()
|
||||
{
|
||||
var body = string.Join('\n',
|
||||
"Hallo, habt ihr Tiere abzugeben?",
|
||||
"> alte zitierte Zeile",
|
||||
"Von: jemand@example.de",
|
||||
"Danke!",
|
||||
"-- ",
|
||||
"Anna Musterfrau",
|
||||
"Musterweg 1");
|
||||
var stripped = DraftReplyService.StripQuotedText(body);
|
||||
|
||||
Assert.Contains("Hallo, habt ihr Tiere abzugeben?", stripped);
|
||||
Assert.Contains("Danke!", stripped);
|
||||
Assert.DoesNotContain("alte zitierte Zeile", stripped);
|
||||
Assert.DoesNotContain("jemand@example.de", stripped);
|
||||
Assert.DoesNotContain("Musterweg 1", stripped); // Signatur weg
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StripQuotedText_schneidet_beim_Gmail_Zitat_Intro_ab()
|
||||
{
|
||||
var body = "Meine Frage steht oben.\nAm 05.06.2026 um 10:00 schrieb Zucht der kleinen Chaoten:\n> früherer Verlauf";
|
||||
var stripped = DraftReplyService.StripQuotedText(body);
|
||||
|
||||
Assert.Equal("Meine Frage steht oben.", stripped);
|
||||
}
|
||||
|
||||
// ── Prompt-Assembly + Datenminimierung ─────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_enthält_Anfrage_und_Abgabeliste_aber_keine_Mailadresse()
|
||||
{
|
||||
var prompt = DraftReplyService.BuildUserPrompt(SampleRequest(),
|
||||
[new DraftReplyService.ForSaleAnimal("Frieda", "Gold"),
|
||||
new DraftReplyService.ForSaleAnimal("Fine", null)]);
|
||||
|
||||
Assert.Contains("Von: Anna Musterfrau", prompt);
|
||||
Assert.Contains("Betreff: Anfrage: zwei Weibchen?", prompt);
|
||||
Assert.Contains("zwei junge Weibchen zur Abgabe", prompt);
|
||||
Assert.Contains("- Frieda (Gold)", prompt);
|
||||
Assert.Contains("- Fine", prompt);
|
||||
// Datenminimierung: die E-Mail-Adresse der Absenderin geht NICHT zum Anbieter
|
||||
Assert.DoesNotContain("anna.musterfrau@example.de", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_ohne_Abgabetiere_enthält_keine_Liste()
|
||||
{
|
||||
var prompt = DraftReplyService.BuildUserPrompt(SampleRequest(), []);
|
||||
Assert.DoesNotContain("abzugebende Tiere", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SystemPrompt_verlangt_Entwurf_ohne_Preise_und_ohne_erfundene_Fakten()
|
||||
{
|
||||
var prompt = DraftReplyService.BuildSystemPrompt();
|
||||
Assert.Contains("ENTWERFEN", prompt);
|
||||
Assert.Contains("KEINE Fakten erfinden", prompt);
|
||||
Assert.Contains("Keine Preise", prompt);
|
||||
Assert.Contains("keine Adressen", prompt);
|
||||
}
|
||||
|
||||
// ── 503: unkonfiguriert (Service-Ebene) ────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DraftAsync_meldet_NotConfigured_ohne_AI_Konfiguration()
|
||||
{
|
||||
var service = new DraftReplyService(
|
||||
new HttpClient(new StubHandler(_ => throw new InvalidOperationException("darf nicht aufgerufen werden"))),
|
||||
Options.Create(new AiOptions()));
|
||||
|
||||
var result = await service.DraftAsync(SampleRequest(), []);
|
||||
|
||||
Assert.Equal(GerbilManagerWebAPI.Ai.AiCallStatus.NotConfigured, result.Status);
|
||||
}
|
||||
|
||||
// ── Endpoint-Round-Trip ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Draft_Endpoint_meldet_503_AiKeyMissing_wenn_unkonfiguriert()
|
||||
{
|
||||
using var factory = new ApiFactory();
|
||||
var id = await SeedRequestAsync(factory);
|
||||
|
||||
var response = await factory.CreateClient().PostAsync($"/api/requests/{id}/draft", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
||||
Assert.Contains("AiKeyMissing", await response.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Draft_Endpoint_speichert_und_liefert_den_Entwurf_des_Stubs()
|
||||
{
|
||||
const string canned = "Hallo Anna,\n\nschön, dass du fragst — aktuell suchen Frieda und Fine ein Zuhause.\n\nHerzliche Grüße";
|
||||
var stub = new StubHandler(_ => Canned(canned));
|
||||
using var factory = new ApiFactory
|
||||
{
|
||||
ConfigureTestServices = services =>
|
||||
{
|
||||
services.PostConfigure<AiOptions>(o =>
|
||||
{
|
||||
o.BaseUrl = "https://api.example.com/v1";
|
||||
o.ApiKey = "test";
|
||||
o.Model = "test-model";
|
||||
});
|
||||
services.AddHttpClient<DraftReplyService>()
|
||||
.ConfigurePrimaryHttpMessageHandler(() => stub);
|
||||
},
|
||||
};
|
||||
|
||||
var id = await SeedRequestAsync(factory, alsoForSaleGerbil: true);
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsync($"/api/requests/{id}/draft", null);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
using var dto = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
Assert.Equal(canned, dto.RootElement.GetProperty("draftReply").GetString());
|
||||
|
||||
// Persistiert: erneutes GET liefert den Entwurf
|
||||
var again = await client.GetFromJsonAsync<JsonElement>($"/api/requests/{id}");
|
||||
Assert.Equal(canned, again.GetProperty("draftReply").GetString());
|
||||
|
||||
// Wire-Privacy: Anfragetext + ForSale-Tier gingen zum Anbieter, die
|
||||
// Mailadresse der Absenderin NICHT.
|
||||
Assert.NotNull(stub.LastRequestBody);
|
||||
Assert.Contains("zwei junge Weibchen", stub.LastRequestBody);
|
||||
Assert.Contains("Aki", stub.LastRequestBody);
|
||||
Assert.DoesNotContain("anna.musterfrau@example.de", stub.LastRequestBody);
|
||||
}
|
||||
|
||||
// ── Helfer ─────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<Guid> SeedRequestAsync(ApiFactory factory, bool alsoForSaleGerbil = false)
|
||||
{
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
|
||||
var request = SampleRequest();
|
||||
db.Add(request);
|
||||
if (alsoForSaleGerbil)
|
||||
{
|
||||
db.Add(new Gerbil
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Aki",
|
||||
Gender = Gender.female,
|
||||
Status = GerbilStatus.ForSale,
|
||||
});
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return request.Id;
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Canned(string content)
|
||||
{
|
||||
var completion = new
|
||||
{
|
||||
choices = new[] { new { message = new { role = "assistant", content } } },
|
||||
};
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(completion),
|
||||
Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>HttpMessageHandler-Stub (Variante von SaleAdTests, hier mit Body-Capture).</summary>
|
||||
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond)
|
||||
: HttpMessageHandler
|
||||
{
|
||||
public string? LastRequestBody { get; private set; }
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequestBody = request.Content is null
|
||||
? null
|
||||
: await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
return respond(request);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
181
GerbilManager.Tests/IngestResolvedServiceTests.cs
Normal file
181
GerbilManager.Tests/IngestResolvedServiceTests.cs
Normal file
@@ -0,0 +1,181 @@
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
public class IngestResolvedServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _dir;
|
||||
private readonly string _resolvedJsonPath;
|
||||
|
||||
public IngestResolvedServiceTests()
|
||||
{
|
||||
_dir = Path.Combine(Path.GetTempPath(), "ingest-resolved-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_dir);
|
||||
_resolvedJsonPath = Path.Combine(_dir, "resolved_import.json");
|
||||
|
||||
var contactId = Guid.NewGuid();
|
||||
var fatherId = Guid.NewGuid();
|
||||
var motherId = Guid.NewGuid();
|
||||
var litterId = Guid.NewGuid();
|
||||
var photoId = Guid.NewGuid();
|
||||
|
||||
var data = new
|
||||
{
|
||||
Contacts = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
Id = contactId,
|
||||
Name = "Test Breeder",
|
||||
ZuchtName = "Test Zucht",
|
||||
City = "Test City",
|
||||
Email = "test@example.com",
|
||||
Homepage = "",
|
||||
Phone = "",
|
||||
Address = ""
|
||||
}
|
||||
},
|
||||
Litters = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
Id = litterId,
|
||||
Name = "Wurf A",
|
||||
Date = "2026-01-01",
|
||||
TotalBorn = 5,
|
||||
DeathsWithin8Weeks = 0,
|
||||
FatherId = fatherId,
|
||||
MotherId = motherId,
|
||||
ExpectedGoHomeDate = (string?)null,
|
||||
Notes = "Test litter notes",
|
||||
PairingCode = "PC01",
|
||||
ExternalRef = "ext-litter-1",
|
||||
LitterLetter = "A"
|
||||
}
|
||||
},
|
||||
Gerbils = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
Id = fatherId,
|
||||
Name = "Papa",
|
||||
Gender = "male",
|
||||
Status = "Breeding",
|
||||
LitterId = (Guid?)null,
|
||||
OriginContactId = contactId,
|
||||
ReceiverContactId = (Guid?)null,
|
||||
EnclosureId = (Guid?)null,
|
||||
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"), // Schwarz
|
||||
DateOfBirth = "2025-01-01",
|
||||
DateOfDeath = (string?)null,
|
||||
CauseOfDeath = (string?)null,
|
||||
GoHomeDate = (string?)null,
|
||||
Genotype = "aa CC DD EE GG PP spsp rere",
|
||||
Notes = "",
|
||||
ImportSource = "docx-export",
|
||||
ExternalRef = "ext-papa",
|
||||
RawImportData = "{}",
|
||||
OriginBreeder = "Test Zucht",
|
||||
NameSearch = "papa",
|
||||
CharacterTraits = new string[] {},
|
||||
CharacterNote = (string?)null,
|
||||
IsDeaf = false,
|
||||
IsResident = true
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = motherId,
|
||||
Name = "Mama",
|
||||
Gender = "female",
|
||||
Status = "Breeding",
|
||||
LitterId = (Guid?)null,
|
||||
OriginContactId = contactId,
|
||||
ReceiverContactId = (Guid?)null,
|
||||
EnclosureId = (Guid?)null,
|
||||
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"), // Schwarz
|
||||
DateOfBirth = "2025-01-01",
|
||||
DateOfDeath = (string?)null,
|
||||
CauseOfDeath = (string?)null,
|
||||
GoHomeDate = (string?)null,
|
||||
Genotype = "aa CC DD EE GG PP spsp rere",
|
||||
Notes = "",
|
||||
ImportSource = "docx-export",
|
||||
ExternalRef = "ext-mama",
|
||||
RawImportData = "{}",
|
||||
OriginBreeder = "Test Zucht",
|
||||
NameSearch = "mama",
|
||||
CharacterTraits = new string[] {},
|
||||
CharacterNote = (string?)null,
|
||||
IsDeaf = false,
|
||||
IsResident = true
|
||||
}
|
||||
},
|
||||
GerbilPhotos = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
Id = photoId,
|
||||
GerbilId = fatherId,
|
||||
FileName = "photo1.jpg",
|
||||
SortOrder = 0
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
File.WriteAllText(_resolvedJsonPath, JsonSerializer.Serialize(data));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
private ApplicationContext NewDb()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseInMemoryDatabase("ingest-resolved-" + Guid.NewGuid().ToString("N"))
|
||||
.Options;
|
||||
var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated();
|
||||
return db;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestResolved_loads_data_and_resolves_all_relations()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
{ "Import:SourcePath", _dir }
|
||||
})
|
||||
.Build();
|
||||
|
||||
var service = new IngestResolvedService(db, config, null!);
|
||||
var result = await service.RunAsync();
|
||||
|
||||
Assert.Contains("Ingestion successful!", result);
|
||||
|
||||
// Verify counts
|
||||
Assert.Equal(1, await db.Contacts.CountAsync());
|
||||
Assert.Equal(1, await db.Litters.CountAsync());
|
||||
Assert.Equal(2, await db.Gerbils.CountAsync());
|
||||
Assert.Equal(1, await db.GerbilPhotos.CountAsync());
|
||||
|
||||
// Verify relations
|
||||
var litter = await db.Litters.SingleAsync();
|
||||
var father = await db.Gerbils.SingleAsync(g => g.Name == "Papa");
|
||||
var mother = await db.Gerbils.SingleAsync(g => g.Name == "Mama");
|
||||
var photo = await db.GerbilPhotos.SingleAsync();
|
||||
|
||||
Assert.Equal(father.Id, litter.FatherId);
|
||||
Assert.Equal(mother.Id, litter.MotherId);
|
||||
Assert.Equal(father.Id, photo.GerbilId);
|
||||
Assert.Equal(father.OriginContactId, mother.OriginContactId);
|
||||
}
|
||||
}
|
||||
}
|
||||
88
GerbilManager.Tests/LitterMortalityTests.cs
Normal file
88
GerbilManager.Tests/LitterMortalityTests.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>LITTER-MORTALITY: deathsWithin8Weeks field — round-trip, validation, null default.</summary>
|
||||
public class LitterMortalityTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
public LitterMortalityTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||
|
||||
private static int? GetInt(JsonElement el, string prop) =>
|
||||
el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Number
|
||||
? v.GetInt32() : null;
|
||||
|
||||
private async Task<JsonElement> CreateAndGet(object body)
|
||||
{
|
||||
var post = await _client.PostAsJsonAsync("/litters", body);
|
||||
post.EnsureSuccessStatusCode();
|
||||
var idStr = JsonDocument.Parse(await post.Content.ReadAsStringAsync()).RootElement
|
||||
.GetProperty("id").GetString()!;
|
||||
return JsonDocument.Parse(await _client.GetStringAsync($"/litters/{idStr}")).RootElement;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeathsWithin8Weeks_round_trips()
|
||||
{
|
||||
var el = await CreateAndGet(new
|
||||
{
|
||||
name = "Wurf Mortalitätstest",
|
||||
date = "2026-05-01",
|
||||
totalBorn = 6,
|
||||
deathsWithin8Weeks = 2,
|
||||
});
|
||||
Assert.Equal(6, GetInt(el, "totalBorn"));
|
||||
Assert.Equal(2, GetInt(el, "deathsWithin8Weeks"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeathsWithin8Weeks_defaults_null()
|
||||
{
|
||||
var el = await CreateAndGet(new { name = "Wurf Kein Mort", date = "2026-05-02" });
|
||||
Assert.True(el.TryGetProperty("deathsWithin8Weeks", out var v));
|
||||
Assert.Equal(JsonValueKind.Null, v.ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeathsWithin8Weeks_exceeding_TotalBorn_returns_400()
|
||||
{
|
||||
var resp = await _client.PostAsJsonAsync("/litters", new
|
||||
{
|
||||
name = "Ungültig",
|
||||
date = "2026-05-03",
|
||||
totalBorn = 4,
|
||||
deathsWithin8Weeks = 5,
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
Assert.Contains("TotalBorn", body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Negative_DeathsWithin8Weeks_returns_400()
|
||||
{
|
||||
var resp = await _client.PostAsJsonAsync("/litters", new
|
||||
{
|
||||
name = "Negativ",
|
||||
date = "2026-05-04",
|
||||
deathsWithin8Weeks = -1,
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
Assert.Contains("negativ", body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeathsWithin8Weeks_allowed_when_TotalBorn_null()
|
||||
{
|
||||
var el = await CreateAndGet(new
|
||||
{
|
||||
name = "Kein TotalBorn",
|
||||
date = "2026-05-05",
|
||||
deathsWithin8Weeks = 3,
|
||||
});
|
||||
Assert.Equal(3, GetInt(el, "deathsWithin8Weeks"));
|
||||
}
|
||||
}
|
||||
280
GerbilManager.Tests/NameSuggestionTests.cs
Normal file
280
GerbilManager.Tests/NameSuggestionTests.cs
Normal file
@@ -0,0 +1,280 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Names;
|
||||
using GerbilManagerWebAPI.SaleAd;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-NAMEGEN / NAMEGEN-2-BE: NameSuggestionService — prompt assembly, usage-code mapping,
|
||||
/// JSON parse (incl. Markdown fence strip), 503-not-configured path, upstream-error path.
|
||||
/// </summary>
|
||||
public class NameSuggestionTests
|
||||
{
|
||||
// ── Prompt assembly ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SystemPrompt_verlangt_reines_JSON_ohne_Erklärungen()
|
||||
{
|
||||
var prompt = NameSuggestionService.BuildSystemPrompt();
|
||||
Assert.Contains("reinen JSON-Array", prompt);
|
||||
Assert.Contains("KEINE Markdown-Code-Blöcke", prompt);
|
||||
Assert.Contains("name", prompt);
|
||||
Assert.Contains("meaning", prompt);
|
||||
Assert.Contains("origin", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SystemPrompt_erklärt_thematische_Kategorien()
|
||||
{
|
||||
var prompt = NameSuggestionService.BuildSystemPrompt();
|
||||
Assert.Contains("thematischen Kategorien", prompt);
|
||||
Assert.Contains("Erfinde KEINE Etymologie", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_enthält_Anzahl_und_Anfangsbuchstaben()
|
||||
{
|
||||
var prompt = NameSuggestionService.BuildUserPrompt("A", "female", "norn,mythg", 6);
|
||||
Assert.Contains("6", prompt);
|
||||
Assert.Contains("\"A\"", prompt);
|
||||
Assert.Contains("weibliche", prompt);
|
||||
// codes are mapped to German descriptions
|
||||
Assert.Contains("Nordische/Altnordische Etymologie", prompt);
|
||||
Assert.Contains("Griechische Mythologie", prompt);
|
||||
Assert.Contains("Kulturkreisen", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_ohne_optionale_Parameter_ist_gültig()
|
||||
{
|
||||
var prompt = NameSuggestionService.BuildUserPrompt(null, null, null, 5);
|
||||
Assert.Contains("5", prompt);
|
||||
Assert.DoesNotContain("Buchstaben", prompt);
|
||||
Assert.DoesNotContain("weibliche", prompt);
|
||||
Assert.DoesNotContain("männliche", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_gender_any_wird_nicht_im_Prompt_erwähnt()
|
||||
{
|
||||
var prompt = NameSuggestionService.BuildUserPrompt(null, "any", null, 3);
|
||||
Assert.DoesNotContain("weibliche", prompt);
|
||||
Assert.DoesNotContain("männliche", prompt);
|
||||
}
|
||||
|
||||
// ── NAMEGEN-2-BE: neue Themen-Kategorien ─────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("disney", "Disney-Charaktere")]
|
||||
[InlineData("pokemon", "Pokémon-Namen")]
|
||||
[InlineData("encities", "Namen englischer Städte")]
|
||||
[InlineData("hrcities", "Namen kroatischer Städte")]
|
||||
[InlineData("usstates", "Namen von US-Bundesstaaten")]
|
||||
public void UsageMap_enthält_alle_fünf_neuen_Codes(string code, string expectedDescription)
|
||||
{
|
||||
Assert.True(NameSuggestionService.UsageMap.TryGetValue(code, out var entry));
|
||||
Assert.Equal(expectedDescription, entry.Description);
|
||||
Assert.True(entry.Thematic);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("disney", "Disney-Charaktere")]
|
||||
[InlineData("pokemon", "Pokémon-Namen")]
|
||||
[InlineData("encities", "Namen englischer Städte")]
|
||||
[InlineData("hrcities", "Namen kroatischer Städte")]
|
||||
[InlineData("usstates", "Namen von US-Bundesstaaten")]
|
||||
public void UserPrompt_enthält_Themen_Kategorie_Beschreibung(string code, string expectedDescription)
|
||||
{
|
||||
var prompt = NameSuggestionService.BuildUserPrompt(null, null, code, 5);
|
||||
Assert.Contains(expectedDescription, prompt);
|
||||
Assert.Contains("Themen-Kategorien", prompt);
|
||||
Assert.DoesNotContain("Kulturkreisen", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_trennt_etym_und_thematische_Kategorien()
|
||||
{
|
||||
var prompt = NameSuggestionService.BuildUserPrompt("D", null, "norn,disney", 4);
|
||||
Assert.Contains("Nordische/Altnordische Etymologie", prompt);
|
||||
Assert.Contains("Kulturkreisen", prompt);
|
||||
Assert.Contains("Disney-Charaktere", prompt);
|
||||
Assert.Contains("Themen-Kategorien", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_thematisch_enthält_Geschlecht_Hinweis()
|
||||
{
|
||||
var prompt = NameSuggestionService.BuildUserPrompt(null, "female", "encities", 3);
|
||||
Assert.Contains("Geschlecht-Filter kann ignoriert werden", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuggestAsync_parst_thematische_Kategorie_Antwort()
|
||||
{
|
||||
var payload = """[{"name":"Dumbo","meaning":"Elefant mit großen Ohren aus dem Disney-Film Dumbo (1941)","origin":"Disney"}]""";
|
||||
var handler = new StubHandler(_ => Canned(payload));
|
||||
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||
|
||||
var result = await service.SuggestAsync("D", null, "disney", 1);
|
||||
|
||||
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
|
||||
Assert.NotNull(result.Suggestions);
|
||||
Assert.Equal("Dumbo", result.Suggestions![0].Name);
|
||||
Assert.Equal("Disney", result.Suggestions[0].Origin);
|
||||
}
|
||||
|
||||
// ── JSON parsing ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ParseSuggestions_verarbeitet_reines_JSON()
|
||||
{
|
||||
var json = """[{"name":"Astrid","meaning":"göttliche Stärke","origin":"Altnordisch"}]""";
|
||||
var result = NameSuggestionService.ParseSuggestions(json);
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("Astrid", result[0].Name);
|
||||
Assert.Equal("göttliche Stärke", result[0].Meaning);
|
||||
Assert.Equal("Altnordisch", result[0].Origin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSuggestions_strippt_json_Markdown_Fence()
|
||||
{
|
||||
var fenced = "```json\n[{\"name\":\"Aiko\",\"meaning\":\"kleine Geliebte\",\"origin\":\"Japanisch\"}]\n```";
|
||||
var result = NameSuggestionService.ParseSuggestions(fenced);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("Aiko", result![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSuggestions_strippt_generische_Markdown_Fence()
|
||||
{
|
||||
var fenced = "```\n[{\"name\":\"Luna\",\"meaning\":\"Mond\",\"origin\":\"Lateinisch\"}]\n```";
|
||||
var result = NameSuggestionService.ParseSuggestions(fenced);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("Luna", result![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSuggestions_toleriert_umgebenden_Text_vor_Array()
|
||||
{
|
||||
var messy = "Hier sind die Namen:\n[{\"name\":\"Sol\",\"meaning\":\"Sonne\",\"origin\":\"Nordisch\"}]\nHoffnungslos.";
|
||||
var result = NameSuggestionService.ParseSuggestions(messy);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("Sol", result![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSuggestions_gibt_null_zurück_bei_ungültigem_JSON()
|
||||
{
|
||||
Assert.Null(NameSuggestionService.ParseSuggestions("kein json"));
|
||||
Assert.Null(NameSuggestionService.ParseSuggestions("{\"name\":\"X\"}"));
|
||||
Assert.Null(NameSuggestionService.ParseSuggestions(""));
|
||||
}
|
||||
|
||||
// ── 503: nicht konfiguriert ───────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "key", "model")]
|
||||
[InlineData("https://api.example.com/v1", null, "model")]
|
||||
[InlineData("https://api.example.com/v1", "key", null)]
|
||||
[InlineData(null, null, null)]
|
||||
public async Task SuggestAsync_gibt_NotConfigured_wenn_AI_Key_fehlt(
|
||||
string? baseUrl, string? apiKey, string? model)
|
||||
{
|
||||
var service = CreateService(baseUrl, apiKey, model,
|
||||
new StubHandler(_ => throw new InvalidOperationException("darf nicht aufgerufen werden")));
|
||||
|
||||
var result = await service.SuggestAsync(null, null, null, 5);
|
||||
|
||||
Assert.Equal(NameSuggestionStatus.NotConfigured, result.Status);
|
||||
Assert.Null(result.Suggestions);
|
||||
}
|
||||
|
||||
// ── Gemini-Antwort wird geparst ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task SuggestAsync_parst_valide_JSON_Antwort()
|
||||
{
|
||||
var payload = """[{"name":"Astrid","meaning":"göttliche Stärke","origin":"Altnordisch"},{"name":"Aiko","meaning":"kleine Geliebte","origin":"Japanisch"}]""";
|
||||
var handler = new StubHandler(_ => Canned(payload));
|
||||
var service = CreateService("https://generativelanguage.googleapis.com/v1beta/openai", "k", "gemini-2.0-flash", handler);
|
||||
|
||||
var result = await service.SuggestAsync("A", "female", "norn,japa", 2);
|
||||
|
||||
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
|
||||
Assert.NotNull(result.Suggestions);
|
||||
Assert.Equal(2, result.Suggestions!.Count);
|
||||
Assert.Equal("Astrid", result.Suggestions[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuggestAsync_parst_JSON_in_Markdown_Fence()
|
||||
{
|
||||
var fenced = "```json\n[{\"name\":\"Luna\",\"meaning\":\"Mond\",\"origin\":\"Lateinisch\"}]\n```";
|
||||
var handler = new StubHandler(_ => Canned(fenced));
|
||||
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||
|
||||
var result = await service.SuggestAsync(null, null, null, 1);
|
||||
|
||||
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
|
||||
Assert.Equal("Luna", result.Suggestions![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuggestAsync_gibt_UpstreamError_bei_ungültigem_JSON()
|
||||
{
|
||||
var handler = new StubHandler(_ => Canned("das ist kein json"));
|
||||
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||
|
||||
var result = await service.SuggestAsync(null, null, null, 3);
|
||||
|
||||
Assert.Equal(NameSuggestionStatus.UpstreamError, result.Status);
|
||||
Assert.Null(result.Suggestions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuggestAsync_gibt_UpstreamError_bei_HTTP_Fehler()
|
||||
{
|
||||
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.TooManyRequests));
|
||||
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||
|
||||
var result = await service.SuggestAsync(null, null, null, 3);
|
||||
|
||||
Assert.Equal(NameSuggestionStatus.UpstreamError, result.Status);
|
||||
}
|
||||
|
||||
// ── Helfer ────────────────────────────────────────────────────────────
|
||||
|
||||
private static NameSuggestionService CreateService(
|
||||
string? baseUrl, string? apiKey, string? model, StubHandler handler)
|
||||
{
|
||||
var options = Options.Create(new AiOptions { BaseUrl = baseUrl, ApiKey = apiKey, Model = model });
|
||||
return new NameSuggestionService(new HttpClient(handler), options);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Canned(string content)
|
||||
{
|
||||
var completion = new
|
||||
{
|
||||
choices = new[] { new { message = new { role = "assistant", content } } },
|
||||
};
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(completion),
|
||||
Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond)
|
||||
: HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(respond(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
145
GerbilManager.Tests/PartialUpdateTests.cs
Normal file
145
GerbilManager.Tests/PartialUpdateTests.cs
Normal file
@@ -0,0 +1,145 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>CR-2 regression: PUT /gerbils/{id} must not silently destroy fields that
|
||||
/// the caller omits (PATCH semantics). The ForSale-toggle and Charakterbogen-save
|
||||
/// send partial bodies — all other fields must be preserved unchanged.</summary>
|
||||
public class PartialUpdateTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public PartialUpdateTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||
|
||||
// ------------------------------------------------------------------ helpers
|
||||
|
||||
private static Guid ExtractId(string json) =>
|
||||
Guid.Parse(Regex.Match(json, "\"id\":\"([^\"]+)\"").Groups[1].Value);
|
||||
|
||||
private static string? GetStr(JsonElement el, string prop) =>
|
||||
el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String
|
||||
? v.GetString() : null;
|
||||
|
||||
private static string? GetNested(JsonElement el, string prop) =>
|
||||
el.TryGetProperty(prop, out var v) ? v.ToString() : null;
|
||||
|
||||
/// <summary>Create a fully-populated gerbil and return its id.</summary>
|
||||
private async Task<Guid> CreateFullGerbil(string name = "TestTier")
|
||||
{
|
||||
// Use name-derived unique externalRef so the DB-1 unique constraint doesn't fire
|
||||
// when multiple tests in the same fixture share the SQLite connection.
|
||||
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new
|
||||
{
|
||||
name,
|
||||
gender = "female",
|
||||
genotype = "aa CC DD ee GG PP spsp rere",
|
||||
originBreeder = "Zucht der kleinen Chaoten",
|
||||
isDeaf = false,
|
||||
characterTraits = new[] { "neugierig", "zutraulich" },
|
||||
characterNote = "Liebling der Familie",
|
||||
notes = "Eine Notiz",
|
||||
externalRef = $"ext-{name.GetHashCode():X8}",
|
||||
}));
|
||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
return ExtractId(await resp.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ tests
|
||||
|
||||
[Fact]
|
||||
public async Task ForSale_toggle_does_not_wipe_other_fields()
|
||||
{
|
||||
// Arrange: create a fully-populated gerbil
|
||||
var id = await CreateFullGerbil("ForSale-Tier");
|
||||
|
||||
// Act: send only { status: 'ForSale' } — exactly what the detail page does
|
||||
var put = await _client.PutAsync($"/gerbils/{id}",
|
||||
JsonContent.Create(new { status = "ForSale" }));
|
||||
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||
|
||||
// Assert: re-read and check all fields survive
|
||||
var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement;
|
||||
|
||||
Assert.Equal("ForSale", GetStr(json, "status"));
|
||||
Assert.Equal("ForSale-Tier", GetStr(json, "name"));
|
||||
Assert.Equal("female", GetStr(json, "gender"));
|
||||
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
||||
Assert.Equal("Zucht der kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
|
||||
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
|
||||
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
|
||||
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CharacterSave_does_not_wipe_other_fields()
|
||||
{
|
||||
// Arrange
|
||||
var id = await CreateFullGerbil("Charakter-Tier");
|
||||
|
||||
// Act: send only { characterTraits, characterNote } — exactly what the Charakterbogen save does
|
||||
var put = await _client.PutAsync($"/gerbils/{id}", JsonContent.Create(new
|
||||
{
|
||||
characterTraits = new[] { "handzahm" },
|
||||
characterNote = "Neue Notiz",
|
||||
}));
|
||||
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||
|
||||
// Assert: name / genotype / originBreeder / status / externalRef all intact
|
||||
var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement;
|
||||
|
||||
Assert.Equal("Breeding", GetStr(json, "status"));
|
||||
Assert.Equal("Charakter-Tier", GetStr(json, "name"));
|
||||
Assert.Equal("female", GetStr(json, "gender"));
|
||||
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
||||
Assert.Equal("Zucht der kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
|
||||
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
|
||||
Assert.Contains("handzahm", GetNested(json, "characterTraits") ?? "");
|
||||
Assert.Equal("Neue Notiz", GetStr(json, "characterNote"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditForm_omitting_import_fields_keeps_them()
|
||||
{
|
||||
// Arrange: gerbil with import-set fields
|
||||
var id = await CreateFullGerbil("EditForm-Tier");
|
||||
|
||||
// Act: send a typical edit-form body that omits originBreeder/isDeaf/externalRef/characterTraits/characterNote
|
||||
var put = await _client.PutAsync($"/gerbils/{id}", JsonContent.Create(new
|
||||
{
|
||||
name = "EditForm-Tier (umbenannt)",
|
||||
gender = "female",
|
||||
genotype = "aa CC DD ee GG PP spsp rere",
|
||||
notes = "Aktualisierte Notiz",
|
||||
}));
|
||||
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||
|
||||
// Assert: name updated, import fields survive
|
||||
var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement;
|
||||
|
||||
Assert.Equal("EditForm-Tier (umbenannt)", GetStr(json, "name"));
|
||||
Assert.Equal("Aktualisierte Notiz", GetStr(json, "notes"));
|
||||
Assert.Equal("Zucht der kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
|
||||
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
|
||||
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_with_minimal_body_uses_safe_defaults()
|
||||
{
|
||||
// A minimal POST (only name) must still produce a valid gerbil
|
||||
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new { name = "Minimal" }));
|
||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
|
||||
var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("Minimal", GetStr(json, "name"));
|
||||
Assert.Equal("Breeding", GetStr(json, "status"));
|
||||
// characterTraits defaults to [] on create when not provided
|
||||
Assert.Contains("[]", GetNested(json, "characterTraits") ?? "");
|
||||
}
|
||||
}
|
||||
192
GerbilManager.Tests/PedigreeCalculatorTests.cs
Normal file
192
GerbilManager.Tests/PedigreeCalculatorTests.cs
Normal file
@@ -0,0 +1,192 @@
|
||||
using GerbilManagerWebAPI.Genetics;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Exact-math checks for the inbreeding coefficient (Inzuchtkoeffizient) against
|
||||
/// the textbook reference values for canonical pedigrees.
|
||||
/// </summary>
|
||||
public class PedigreeCalculatorTests
|
||||
{
|
||||
private const int Precision = 10;
|
||||
|
||||
[Fact]
|
||||
public void UnknownAncestry_IsZero()
|
||||
{
|
||||
var ped = new PedigreeBuilder();
|
||||
var lonely = ped.Add("Lonely");
|
||||
var calc = ped.Build();
|
||||
|
||||
var result = calc.ForIndividual(lonely);
|
||||
|
||||
Assert.Equal(0.0, result.Coefficient, Precision);
|
||||
Assert.Equal(0, result.GenerationsAvailable);
|
||||
Assert.Empty(result.CommonAncestors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OneKnownParent_IsZero()
|
||||
{
|
||||
var ped = new PedigreeBuilder();
|
||||
var sire = ped.Add("Sire");
|
||||
var calc = ped.Build();
|
||||
|
||||
// Only one parent known → no inbreeding can be established.
|
||||
var result = calc.ForOffspringOf(sire, null);
|
||||
|
||||
Assert.Equal(0.0, result.Coefficient, Precision);
|
||||
Assert.Empty(result.CommonAncestors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParentOffspring_IsQuarter()
|
||||
{
|
||||
// Z is the offspring of P mated with its own daughter C.
|
||||
var ped = new PedigreeBuilder();
|
||||
var p = ped.Add("P");
|
||||
var mate = ped.Add("Mate");
|
||||
var c = ped.AddChild("C", p, mate);
|
||||
|
||||
var calc = ped.Build();
|
||||
var result = calc.ForOffspringOf(p, c);
|
||||
|
||||
Assert.Equal(0.25, result.Coefficient, Precision);
|
||||
Assert.Equal(25.0, result.Percent, Precision);
|
||||
var ancestor = Assert.Single(result.CommonAncestors);
|
||||
Assert.Equal(p, ancestor.Id);
|
||||
Assert.Equal(0.25, ancestor.Contribution, Precision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullSiblings_IsQuarter()
|
||||
{
|
||||
// Parents S and D are full siblings (both children of GF and GM).
|
||||
var ped = new PedigreeBuilder();
|
||||
var gf = ped.Add("GF");
|
||||
var gm = ped.Add("GM");
|
||||
var s = ped.AddChild("S", gf, gm);
|
||||
var d = ped.AddChild("D", gf, gm);
|
||||
|
||||
var calc = ped.Build();
|
||||
var result = calc.ForOffspringOf(s, d);
|
||||
|
||||
Assert.Equal(0.25, result.Coefficient, Precision);
|
||||
Assert.Equal(2, result.CommonAncestors.Count);
|
||||
Assert.All(result.CommonAncestors, a => Assert.Equal(0.125, a.Contribution, Precision));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HalfSiblings_IsEighth()
|
||||
{
|
||||
// Parents S and D share only GF (other parents unrelated).
|
||||
var ped = new PedigreeBuilder();
|
||||
var gf = ped.Add("GF");
|
||||
var x = ped.Add("X");
|
||||
var y = ped.Add("Y");
|
||||
var s = ped.AddChild("S", gf, x);
|
||||
var d = ped.AddChild("D", gf, y);
|
||||
|
||||
var calc = ped.Build();
|
||||
var result = calc.ForOffspringOf(s, d);
|
||||
|
||||
Assert.Equal(0.125, result.Coefficient, Precision);
|
||||
var ancestor = Assert.Single(result.CommonAncestors);
|
||||
Assert.Equal(gf, ancestor.Id);
|
||||
Assert.Equal(0.125, ancestor.Contribution, Precision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FirstCousins_IsSixteenth()
|
||||
{
|
||||
// Parents S and D are first cousins: their respective parents A1 and B1
|
||||
// are full siblings (children of GF and GM).
|
||||
var ped = new PedigreeBuilder();
|
||||
var gf = ped.Add("GF");
|
||||
var gm = ped.Add("GM");
|
||||
var a1 = ped.AddChild("A1", gf, gm);
|
||||
var b1 = ped.AddChild("B1", gf, gm);
|
||||
var x = ped.Add("X");
|
||||
var y = ped.Add("Y");
|
||||
var s = ped.AddChild("S", a1, x);
|
||||
var d = ped.AddChild("D", b1, y);
|
||||
|
||||
var calc = ped.Build();
|
||||
var result = calc.ForOffspringOf(s, d);
|
||||
|
||||
Assert.Equal(0.0625, result.Coefficient, Precision);
|
||||
Assert.Equal(2, result.CommonAncestors.Count);
|
||||
Assert.All(result.CommonAncestors, a => Assert.Equal(0.03125, a.Contribution, Precision));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeepCommonAncestry_SumsAllDisjointPaths()
|
||||
{
|
||||
// Full siblings S and D whose shared parents (GF, GM) are themselves full
|
||||
// siblings (children of GGF, GGM). GF and GM are NOT inbred (their parents
|
||||
// are unrelated founders, so F_GF = F_GM = 0), but the great-grandparents
|
||||
// are reached by additional disjoint paths:
|
||||
// GF + GM: 2 * (1/2)^3 = 0.25
|
||||
// GGF: 2 * (1/2)^5 = 0.0625 (S-GF-GGF / D-GM-GGF and swap)
|
||||
// GGM: 2 * (1/2)^5 = 0.0625
|
||||
// ---------------------------------------------
|
||||
// F = 0.375 (verified against the recursive kinship method)
|
||||
var ped = new PedigreeBuilder();
|
||||
var ggf = ped.Add("GGF");
|
||||
var ggm = ped.Add("GGM");
|
||||
var gf = ped.AddChild("GF", ggf, ggm);
|
||||
var gm = ped.AddChild("GM", ggf, ggm);
|
||||
var s = ped.AddChild("S", gf, gm);
|
||||
var d = ped.AddChild("D", gf, gm);
|
||||
|
||||
var calc = ped.Build();
|
||||
var result = calc.ForOffspringOf(s, d);
|
||||
|
||||
Assert.Equal(0.375, result.Coefficient, Precision);
|
||||
// Four common ancestors: GF, GM, GGF, GGM.
|
||||
Assert.Equal(4, result.CommonAncestors.Count);
|
||||
Assert.Equal(0.375, result.CommonAncestors.Sum(a => a.Contribution), Precision);
|
||||
// Deepest known generation above the offspring: S/D (1) → GF/GM (2) → GGF/GGM (3).
|
||||
Assert.Equal(3, result.GenerationsAvailable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerationsAvailable_CountsDeepestKnownGeneration()
|
||||
{
|
||||
var ped = new PedigreeBuilder();
|
||||
var gf = ped.Add("GF");
|
||||
var gm = ped.Add("GM");
|
||||
var father = ped.AddChild("Father", gf, gm);
|
||||
var mother = ped.Add("Mother");
|
||||
|
||||
var calc = ped.Build();
|
||||
var result = calc.ForOffspringOf(father, mother);
|
||||
|
||||
// Father side reaches grandparents (2 generations); mother side reaches 1.
|
||||
Assert.Equal(2, result.GenerationsAvailable);
|
||||
}
|
||||
|
||||
/// <summary>Small helper to assemble a pedigree of plain GUIDs for the calculator.</summary>
|
||||
private sealed class PedigreeBuilder
|
||||
{
|
||||
private readonly Dictionary<Guid, (Guid? Father, Guid? Mother)> _parents = new();
|
||||
private readonly Dictionary<Guid, string> _names = new();
|
||||
|
||||
public Guid Add(string name)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
_parents[id] = (null, null);
|
||||
_names[id] = name;
|
||||
return id;
|
||||
}
|
||||
|
||||
public Guid AddChild(string name, Guid father, Guid mother)
|
||||
{
|
||||
var id = Add(name);
|
||||
_parents[id] = (father, mother);
|
||||
return id;
|
||||
}
|
||||
|
||||
public PedigreeCalculator Build() => new(_parents, _names);
|
||||
}
|
||||
}
|
||||
}
|
||||
82
GerbilManager.Tests/RequestEndpointsTests.cs
Normal file
82
GerbilManager.Tests/RequestEndpointsTests.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Inbox;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>INBOX-0 HTTP: sync endpoint imports (via a canned reader), status filter + triage,
|
||||
/// and the Gmail App Password is never returned by GET /api/mail-settings.</summary>
|
||||
public class RequestEndpointsTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
public RequestEndpointsTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
/// <summary>Test double for the IMAP reader: returns whatever the test stages, filtered by UID.</summary>
|
||||
private sealed class CannedReader : IGmailMailReader
|
||||
{
|
||||
public static List<MailSummary> Messages { get; set; } = new();
|
||||
public Task<IReadOnlyList<MailSummary>> FetchAsync(MailConnection c, uint sinceUid, CancellationToken ct = default)
|
||||
=> Task.FromResult((IReadOnlyList<MailSummary>)Messages.Where(m => m.Uid > sinceUid).ToList());
|
||||
}
|
||||
|
||||
private HttpClient ClientWithCannedReader() =>
|
||||
_factory.WithWebHostBuilder(b => b.ConfigureTestServices(s =>
|
||||
{
|
||||
s.RemoveAll<IGmailMailReader>();
|
||||
s.AddScoped<IGmailMailReader, CannedReader>();
|
||||
})).CreateClient();
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_imports_then_triage_assigns_contact()
|
||||
{
|
||||
var client = ClientWithCannedReader();
|
||||
CannedReader.Messages = new()
|
||||
{
|
||||
new("<triage@1>", null, null, null, "interessent@example.de", "Anna", "Suche Rennmäuse", "Hallo!",
|
||||
DateTimeOffset.UtcNow, Uid: 1001),
|
||||
};
|
||||
|
||||
// configure mail (otherwise sync reports MailNotConfigured)
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await client.PutAsJsonAsync("/api/mail-settings",
|
||||
new { gmailAddress = "zucht@gmail.com", appPassword = "app-pw-xyz" })).StatusCode);
|
||||
|
||||
var sync = await client.PostAsync("/api/requests/sync", null);
|
||||
Assert.Equal(HttpStatusCode.OK, sync.StatusCode);
|
||||
Assert.True(JsonDocument.Parse(await sync.Content.ReadAsStringAsync()).RootElement.GetProperty("imported").GetInt32() >= 1);
|
||||
|
||||
// find the imported request via the status filter
|
||||
var listed = JsonDocument.Parse(await client.GetStringAsync("/api/requests?filter=status==New&pageSize=100"));
|
||||
var item = listed.RootElement.GetProperty("items").EnumerateArray()
|
||||
.Single(r => r.GetProperty("gmailMessageId").GetString() == "<triage@1>");
|
||||
var reqId = item.GetProperty("id").GetString();
|
||||
|
||||
// a contact + triage
|
||||
var contactId = JsonDocument.Parse(await (await client.PostAsJsonAsync("/contacts", new { name = "Anna" }))
|
||||
.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
var put = await client.PutAsJsonAsync($"/api/requests/{reqId}", new { status = "Assigned", assignedContactId = contactId });
|
||||
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||
|
||||
var got = JsonDocument.Parse(await client.GetStringAsync($"/api/requests/{reqId}")).RootElement;
|
||||
Assert.Equal("Assigned", got.GetProperty("status").GetString());
|
||||
Assert.Equal(contactId, got.GetProperty("assignedContactId").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MailSettings_stores_password_but_never_returns_it()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var put = await client.PutAsJsonAsync("/api/mail-settings",
|
||||
new { gmailAddress = "sekret@gmail.com", appPassword = "abcd efgh ijkl mnop" });
|
||||
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||
|
||||
var json = await client.GetStringAsync("/api/mail-settings");
|
||||
Assert.Contains("sekret@gmail.com", json);
|
||||
Assert.Contains("\"hasAppPassword\":true", json.Replace(" ", ""));
|
||||
Assert.DoesNotContain("abcd", json); // the app password must never leave the server
|
||||
Assert.DoesNotContain("appPassword", json);
|
||||
}
|
||||
}
|
||||
86
GerbilManager.Tests/RequestSyncServiceTests.cs
Normal file
86
GerbilManager.Tests/RequestSyncServiceTests.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using GerbilManagerWebAPI.Inbox;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>INBOX-0: sync dedup + idempotency + config-guard, against an in-memory DB and a
|
||||
/// fake IGmailMailReader (the live Gmail/MailKit path is gated on Julian's App Password).</summary>
|
||||
public class RequestSyncServiceTests
|
||||
{
|
||||
private static ApplicationContext NewDb()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseInMemoryDatabase("inbox-" + Guid.NewGuid().ToString("N")).Options;
|
||||
var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated(); // seeds the MailSettings singleton
|
||||
return db;
|
||||
}
|
||||
|
||||
private sealed class FakeReader : IGmailMailReader
|
||||
{
|
||||
public List<MailSummary> Canned { get; set; } = new();
|
||||
public Task<IReadOnlyList<MailSummary>> FetchAsync(MailConnection c, uint sinceUid, CancellationToken ct = default)
|
||||
=> Task.FromResult((IReadOnlyList<MailSummary>)Canned.Where(m => m.Uid > sinceUid).ToList());
|
||||
}
|
||||
|
||||
private static MailSummary M(uint uid, string msgId, string from) =>
|
||||
new(msgId, ThreadId: null, InReplyTo: null, References: null,
|
||||
FromAddress: from, FromName: null, Subject: "Anfrage", BodyText: "Hallo",
|
||||
ReceivedAt: DateTimeOffset.UtcNow, Uid: uid);
|
||||
|
||||
private static async Task<(ApplicationContext db, RequestSyncService sync, FakeReader reader)> SetupAsync(bool configured = true)
|
||||
{
|
||||
var db = NewDb();
|
||||
var settings = new MailSettingsService(db, new EphemeralDataProtectionProvider());
|
||||
var s = await settings.GetAsync();
|
||||
if (configured)
|
||||
{
|
||||
s.GmailAddress = "zucht@gmail.com";
|
||||
settings.SetPassword(s, "app-pw-1234");
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
var reader = new FakeReader();
|
||||
return (db, new RequestSyncService(db, reader, settings), reader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_imports_new_and_dedups_within_batch()
|
||||
{
|
||||
var (db, sync, reader) = await SetupAsync();
|
||||
reader.Canned = new() { M(1, "<a@x>", "a@x.de"), M(2, "<b@y>", "b@y.de"), M(3, "<b@y>", "b@y.de") };
|
||||
|
||||
var res = await sync.SyncAsync();
|
||||
|
||||
Assert.Null(res.Error);
|
||||
Assert.Equal(2, res.Imported); // <b@y> deduped
|
||||
Assert.Equal(2, await db.Requests.CountAsync());
|
||||
Assert.All(await db.Requests.ToListAsync(), r => Assert.Equal(RequestStatus.New, r.Status));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resync_imports_nothing_new()
|
||||
{
|
||||
var (db, sync, reader) = await SetupAsync();
|
||||
reader.Canned = new() { M(1, "<a@x>", "a@x.de"), M(2, "<b@y>", "b@y.de") };
|
||||
await sync.SyncAsync();
|
||||
|
||||
var second = await sync.SyncAsync(); // LastUid advanced -> reader returns nothing newer
|
||||
|
||||
Assert.Equal(0, second.Imported);
|
||||
Assert.Equal(2, await db.Requests.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_without_config_reports_not_configured()
|
||||
{
|
||||
var (_, sync, reader) = await SetupAsync(configured: false);
|
||||
reader.Canned = new() { M(1, "<a@x>", "a@x.de") };
|
||||
|
||||
var res = await sync.SyncAsync();
|
||||
|
||||
Assert.Equal("MailNotConfigured", res.Error);
|
||||
Assert.Equal(0, res.Imported);
|
||||
}
|
||||
}
|
||||
282
GerbilManager.Tests/SaleAdTests.cs
Normal file
282
GerbilManager.Tests/SaleAdTests.cs
Normal file
@@ -0,0 +1,282 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.SaleAd;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-12a: sale-ad backend — prompt assembly, the 503-unconfigured path,
|
||||
/// the OpenAI-compatible wire handling against a stubbed provider, and the
|
||||
/// provider config matrix (Gemini / Groq / Mistral / Ollama base URLs).
|
||||
/// </summary>
|
||||
public class SaleAdTests
|
||||
{
|
||||
private static SaleAdRequest SampleRequest() => new(
|
||||
Animals:
|
||||
[
|
||||
new SaleAdAnimal("Balu", "CP-Agouti", "2024-03-12", "ruhig, nimmt Leckerlis aus der Hand"),
|
||||
new SaleAdAnimal("Benny", "Schwarz Schecke", "2024-03-12", "neugieriger Entdecker"),
|
||||
],
|
||||
StatusLine: "FREI",
|
||||
Hints: "bitte die Bruder-Dynamik betonen");
|
||||
|
||||
// ── Prompt assembly ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_enthält_Tiere_Status_und_Hinweise()
|
||||
{
|
||||
var prompt = SaleAdPromptBuilder.BuildUserPrompt(SampleRequest());
|
||||
|
||||
Assert.Contains("Status-Zeile: FREI", prompt);
|
||||
Assert.Contains("Name: Balu", prompt);
|
||||
Assert.Contains("Farbschlag: CP-Agouti", prompt);
|
||||
Assert.Contains("Name: Benny", prompt);
|
||||
Assert.Contains("ruhig, nimmt Leckerlis aus der Hand", prompt);
|
||||
Assert.Contains("Wünsche/Hinweise: bitte die Bruder-Dynamik betonen", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_formatiert_Geburtsdatum_deutsch()
|
||||
{
|
||||
var prompt = SaleAdPromptBuilder.BuildUserPrompt(SampleRequest());
|
||||
Assert.Contains("geboren am 12.03.2024", prompt);
|
||||
Assert.DoesNotContain("2024-03-12", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_lässt_fehlende_Angaben_weg()
|
||||
{
|
||||
var request = new SaleAdRequest(
|
||||
[new SaleAdAnimal("Mysti", null, null, null)], "RESERVIERT", "");
|
||||
var prompt = SaleAdPromptBuilder.BuildUserPrompt(request);
|
||||
|
||||
Assert.Contains("Name: Mysti", prompt);
|
||||
Assert.DoesNotContain("Farbschlag:", prompt);
|
||||
Assert.DoesNotContain("geboren am", prompt);
|
||||
Assert.DoesNotContain("Notizen:", prompt);
|
||||
Assert.DoesNotContain("Wünsche/Hinweise:", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SystemPrompt_enthält_Stilregeln_und_beide_Beispiele()
|
||||
{
|
||||
var prompt = SaleAdPromptBuilder.BuildSystemPrompt();
|
||||
|
||||
Assert.Contains("Zucht der kleinen Chaoten", prompt);
|
||||
Assert.Contains("NIEMALS Preise", prompt);
|
||||
Assert.Contains("KEINE Fakten erfinden", prompt);
|
||||
// few-shot: verbatim listings from kleine-chaoten.jimdofree.com
|
||||
Assert.Contains("Status: LOCKER RESERVIERT LEONIE", prompt);
|
||||
Assert.Contains("Status: FREI", prompt);
|
||||
Assert.Contains("Großer und kleiner Bruder Dynamik", prompt);
|
||||
}
|
||||
|
||||
// ── FEAT-14c: Charakterbogen (Traits + Notiz) im Prompt ────────────
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_enthält_Traits_und_Charakternotiz()
|
||||
{
|
||||
var request = new SaleAdRequest(
|
||||
[new SaleAdAnimal("Balu", "CP-Agouti", "2024-03-12", "alte Verwaltungsnotiz",
|
||||
Traits: ["zutraulich", "buddelt gern"],
|
||||
CharacterNote: "klettert abends auf die Hand")],
|
||||
"FREI", "");
|
||||
var prompt = SaleAdPromptBuilder.BuildUserPrompt(request);
|
||||
|
||||
Assert.Contains("Charakter: zutraulich, buddelt gern", prompt);
|
||||
Assert.Contains("Charakter-Notiz: klettert abends auf die Hand", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_bevorzugt_Charakterbogen_vor_generischen_Notizen()
|
||||
{
|
||||
// Traits vorhanden -> die generische Notiz (oft Verwaltungs-Info)
|
||||
// gehört NICHT in den Prompt.
|
||||
var request = new SaleAdRequest(
|
||||
[new SaleAdAnimal("Balu", null, null, "Käfig wurde am 3.5. gereinigt",
|
||||
Traits: ["neugierig"])],
|
||||
"FREI", "");
|
||||
var prompt = SaleAdPromptBuilder.BuildUserPrompt(request);
|
||||
|
||||
Assert.Contains("Charakter: neugierig", prompt);
|
||||
Assert.DoesNotContain("Notizen:", prompt);
|
||||
Assert.DoesNotContain("Käfig wurde", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_fällt_ohne_Charakterbogen_auf_Notizen_zurück()
|
||||
{
|
||||
// Kein Charakterbogen (null bzw. leer) -> Notizen wie bisher.
|
||||
var request = new SaleAdRequest(
|
||||
[
|
||||
new SaleAdAnimal("Mysti", null, null, "sehr verschmust", Traits: null),
|
||||
new SaleAdAnimal("Bo", null, null, "mag Kolbenhirse", Traits: []),
|
||||
],
|
||||
"FREI", "");
|
||||
var prompt = SaleAdPromptBuilder.BuildUserPrompt(request);
|
||||
|
||||
Assert.Contains("Notizen: sehr verschmust", prompt);
|
||||
Assert.Contains("Notizen: mag Kolbenhirse", prompt);
|
||||
Assert.DoesNotContain("Charakter:", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SystemPrompt_verlangt_fließende_Prosa_statt_Stichwortliste()
|
||||
{
|
||||
var prompt = SaleAdPromptBuilder.BuildSystemPrompt();
|
||||
Assert.Contains("Charakter-Eigenschaften", prompt);
|
||||
Assert.Contains("niemals als Aufzählung", prompt);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("2024-03-12", "12.03.2024")]
|
||||
[InlineData("kaputt", "kaputt")] // unparsebar -> unverändert durchreichen
|
||||
public void FormatGermanDate_konvertiert_ISO_nach_TT_MM_JJJJ(string input, string expected)
|
||||
=> Assert.Equal(expected, SaleAdPromptBuilder.FormatGermanDate(input));
|
||||
|
||||
// ── 503: unkonfiguriert ────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "key", "model")]
|
||||
[InlineData("https://api.example.com/v1", null, "model")]
|
||||
[InlineData("https://api.example.com/v1", "key", null)]
|
||||
[InlineData(null, null, null)]
|
||||
public async Task GenerateAsync_meldet_NotConfigured_wenn_eine_Einstellung_fehlt(
|
||||
string? baseUrl, string? apiKey, string? model)
|
||||
{
|
||||
var service = CreateService(baseUrl, apiKey, model,
|
||||
new StubHandler(_ => throw new InvalidOperationException("darf nicht aufgerufen werden")));
|
||||
|
||||
var result = await service.GenerateAsync(SampleRequest());
|
||||
|
||||
Assert.Equal(SaleAdStatus.NotConfigured, result.Status);
|
||||
Assert.Null(result.Text);
|
||||
}
|
||||
|
||||
// ── Fake-Server: OpenAI-kompatible Antwort wird geparst ────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateAsync_parst_die_Chat_Completion_des_Stubs()
|
||||
{
|
||||
var handler = new StubHandler(_ => Canned("Status: FREI\n\nWunderbares Duo …"));
|
||||
var service = CreateService("https://api.groq.com/openai/v1", "k", "llama-3.3-70b", handler);
|
||||
|
||||
var result = await service.GenerateAsync(SampleRequest());
|
||||
|
||||
Assert.Equal(SaleAdStatus.Ok, result.Status);
|
||||
Assert.Equal("Status: FREI\n\nWunderbares Duo …", result.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateAsync_sendet_Modell_Messages_und_Bearer_Key()
|
||||
{
|
||||
var handler = new StubHandler(_ => Canned("ok"));
|
||||
var service = CreateService("https://api.example.com/v1", "geheim", "test-model", handler);
|
||||
|
||||
await service.GenerateAsync(SampleRequest());
|
||||
|
||||
Assert.NotNull(handler.LastRequest);
|
||||
Assert.Equal("Bearer", handler.LastRequest!.Headers.Authorization?.Scheme);
|
||||
Assert.Equal("geheim", handler.LastRequest.Headers.Authorization?.Parameter);
|
||||
|
||||
using var body = JsonDocument.Parse(handler.LastRequestBody!);
|
||||
Assert.Equal("test-model", body.RootElement.GetProperty("model").GetString());
|
||||
var messages = body.RootElement.GetProperty("messages");
|
||||
Assert.Equal(2, messages.GetArrayLength());
|
||||
Assert.Equal("system", messages[0].GetProperty("role").GetString());
|
||||
Assert.Equal("user", messages[1].GetProperty("role").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateAsync_meldet_UpstreamError_bei_Provider_HTTP_Fehler()
|
||||
{
|
||||
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
Content = new StringContent("rate limited"),
|
||||
});
|
||||
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||
|
||||
var result = await service.GenerateAsync(SampleRequest());
|
||||
|
||||
Assert.Equal(SaleAdStatus.UpstreamError, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateAsync_meldet_UpstreamError_bei_leerer_Antwort()
|
||||
{
|
||||
var handler = new StubHandler(_ => Canned(""));
|
||||
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||
|
||||
var result = await service.GenerateAsync(SampleRequest());
|
||||
|
||||
Assert.Equal(SaleAdStatus.UpstreamError, result.Status);
|
||||
}
|
||||
|
||||
// ── Provider-Konfigurationsmatrix: BaseUrl -> /chat/completions ────
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions")] // Gemini
|
||||
[InlineData("https://api.groq.com/openai/v1",
|
||||
"https://api.groq.com/openai/v1/chat/completions")] // Groq
|
||||
[InlineData("https://api.mistral.ai/v1",
|
||||
"https://api.mistral.ai/v1/chat/completions")] // Mistral
|
||||
[InlineData("http://truenas:11434/v1",
|
||||
"http://truenas:11434/v1/chat/completions")] // Ollama (lokal)
|
||||
[InlineData("https://api.groq.com/openai/v1/",
|
||||
"https://api.groq.com/openai/v1/chat/completions")] // trailing slash
|
||||
public async Task Konfigurationsmatrix_baut_die_richtige_Completions_URL(
|
||||
string baseUrl, string expectedUri)
|
||||
{
|
||||
Assert.Equal(new Uri(expectedUri), SaleAdService.BuildCompletionsUri(baseUrl));
|
||||
|
||||
// und der Stub sieht den Aufruf tatsächlich auf dieser URL
|
||||
var handler = new StubHandler(_ => Canned("ok"));
|
||||
var service = CreateService(baseUrl, "k", "m", handler);
|
||||
await service.GenerateAsync(SampleRequest());
|
||||
Assert.Equal(new Uri(expectedUri), handler.LastRequest!.RequestUri);
|
||||
}
|
||||
|
||||
// ── Helfer ─────────────────────────────────────────────────────────
|
||||
|
||||
private static SaleAdService CreateService(
|
||||
string? baseUrl, string? apiKey, string? model, StubHandler handler)
|
||||
{
|
||||
var options = Options.Create(new AiOptions { BaseUrl = baseUrl, ApiKey = apiKey, Model = model });
|
||||
return new SaleAdService(new HttpClient(handler), options);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Canned(string content)
|
||||
{
|
||||
var completion = new
|
||||
{
|
||||
choices = new[] { new { message = new { role = "assistant", content } } },
|
||||
};
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(completion),
|
||||
Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>HttpMessageHandler-Stub, der Request + Body zur Inspektion festhält.</summary>
|
||||
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond)
|
||||
: HttpMessageHandler
|
||||
{
|
||||
public HttpRequestMessage? LastRequest { get; private set; }
|
||||
public string? LastRequestBody { get; private set; }
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequest = request;
|
||||
LastRequestBody = request.Content is null
|
||||
? null
|
||||
: await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
return respond(request);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
150
GerbilManager.Tests/SearchVariationsTests.cs
Normal file
150
GerbilManager.Tests/SearchVariationsTests.cs
Normal file
@@ -0,0 +1,150 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// SEARCH verification — every search variation Julian asked about, against the
|
||||
/// REAL endpoints (SQLite host). Covers: substring/contains, case-insensitivity,
|
||||
/// the no-trailing-* contains fix, separator-insensitive nameSearch (spaces ==
|
||||
/// hyphens), umlauts, the OriginBreeder (Herkunft) filter, the /gerbils/breeders
|
||||
/// distinct dropdown source, paged envelope, and a combined status+name filter.
|
||||
/// Each test uses a unique token so the shared class DB doesn't cross-contaminate.
|
||||
/// </summary>
|
||||
public class SearchVariationsTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
public SearchVariationsTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||
|
||||
private async Task Create(string name, string? originBreeder = null, string status = "Breeding")
|
||||
{
|
||||
var resp = await _client.PostAsJsonAsync("/gerbils",
|
||||
new { name, gender = "female", status, originBreeder });
|
||||
resp.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
/// <summary>GET /gerbils?filter=... → the list of returned names.</summary>
|
||||
private async Task<List<string>> Names(string filter)
|
||||
{
|
||||
var url = $"/gerbils?filter={Uri.EscapeDataString(filter)}&page=1&pageSize=200";
|
||||
var doc = JsonDocument.Parse(await _client.GetStringAsync(url));
|
||||
return doc.RootElement.GetProperty("items").EnumerateArray()
|
||||
.Select(x => x.GetProperty("name").GetString()!).ToList();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Contains_matches_substring_case_insensitively()
|
||||
{
|
||||
await Create("SuchSchokoEins");
|
||||
await Create("SuchSchokoladeZwei");
|
||||
await Create("SuchKeksDrei");
|
||||
|
||||
// lowercase query, capitalised names → exercises /i contains
|
||||
var hits = await Names("name=*suchschoko/i");
|
||||
Assert.Contains("SuchSchokoEins", hits);
|
||||
Assert.Contains("SuchSchokoladeZwei", hits);
|
||||
Assert.DoesNotContain("SuchKeksDrei", hits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Contains_single_letter_returns_matches_not_the_trailing_star_bug()
|
||||
{
|
||||
// Regression guard for the contains hotfix (0ee2b53): the frontend used to
|
||||
// build name=*VALUE* which Gridify read as literal "VALUE*" → 0 rows.
|
||||
// Correct form name=*VALUE must return every name containing VALUE.
|
||||
await Create("BugGuardAlpha");
|
||||
await Create("BugGuardBeta");
|
||||
var hits = await Names("name=*bugguard/i");
|
||||
Assert.Equal(2, hits.Count(n => n.StartsWith("BugGuard")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NameSearch_is_separator_insensitive_spaces_equal_hyphens()
|
||||
{
|
||||
// The headline ask: "clan kleine chaoten" must match "clan-kleine-chaoten".
|
||||
await Create("SepClan-Kleine-Chaoten");
|
||||
await Create("SepClan Kleine Chaoten");
|
||||
await Create("SepClanKleineChaoten");
|
||||
await Create("SepUnrelatedMaus");
|
||||
|
||||
// Frontend normalizes the typed term the same way the backend stores it:
|
||||
// lowercase + strip whitespace/.-_ → "sepclankleinechaoten".
|
||||
var hits = await Names("nameSearch=*sepclankleinechaoten");
|
||||
|
||||
Assert.Contains("SepClan-Kleine-Chaoten", hits); // hyphens
|
||||
Assert.Contains("SepClan Kleine Chaoten", hits); // spaces
|
||||
Assert.Contains("SepClanKleineChaoten", hits); // none
|
||||
Assert.DoesNotContain("SepUnrelatedMaus", hits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NameSearch_dotted_and_underscored_separators_also_match()
|
||||
{
|
||||
await Create("SepDot.Von.Den.Chaoten");
|
||||
await Create("SepDot_Von_Den_Chaoten");
|
||||
var hits = await Names("nameSearch=*sepdotvondenchaoten");
|
||||
Assert.Equal(2, hits.Count(n => n.StartsWith("SepDot")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NameSearch_handles_umlauts()
|
||||
{
|
||||
await Create("SuchKrümel von den Chaoten");
|
||||
// nameSearch is stored pre-lowercased; the frontend sends a pre-lowercased
|
||||
// normalized term, so this works without provider unicode case-folding.
|
||||
var hits = await Names("nameSearch=*suchkrümelvondenchaoten");
|
||||
Assert.Contains("SuchKrümel von den Chaoten", hits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OriginBreeder_filter_returns_only_that_breeder()
|
||||
{
|
||||
await Create("HerkunftA1", originBreeder: "Zucht HerkunftTest Eins");
|
||||
await Create("HerkunftA2", originBreeder: "Zucht HerkunftTest Eins");
|
||||
await Create("HerkunftB1", originBreeder: "Zucht HerkunftTest Zwei");
|
||||
|
||||
var hits = await Names("originBreeder==Zucht HerkunftTest Eins");
|
||||
Assert.Contains("HerkunftA1", hits);
|
||||
Assert.Contains("HerkunftA2", hits);
|
||||
Assert.DoesNotContain("HerkunftB1", hits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Breeders_endpoint_returns_distinct_nonempty_sorted_values()
|
||||
{
|
||||
await Create("DistinctX1", originBreeder: "ZZZ Distinct Eins");
|
||||
await Create("DistinctX2", originBreeder: "ZZZ Distinct Eins"); // duplicate breeder
|
||||
await Create("DistinctX3", originBreeder: "ZZZ Distinct Zwei");
|
||||
await Create("DistinctNoBreeder", originBreeder: null);
|
||||
|
||||
var list = JsonDocument.Parse(await _client.GetStringAsync("/gerbils/breeders"))
|
||||
.RootElement.EnumerateArray().Select(x => x.GetString()!).ToList();
|
||||
|
||||
Assert.Contains("ZZZ Distinct Eins", list);
|
||||
Assert.Contains("ZZZ Distinct Zwei", list);
|
||||
Assert.Equal(1, list.Count(v => v == "ZZZ Distinct Eins")); // de-duplicated
|
||||
Assert.DoesNotContain(list, string.IsNullOrWhiteSpace); // no empty/null
|
||||
Assert.Equal(list.OrderBy(v => v, StringComparer.Ordinal).ToList(), list); // sorted
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_returns_paged_envelope()
|
||||
{
|
||||
var doc = JsonDocument.Parse(await _client.GetStringAsync("/gerbils?page=1&pageSize=5"));
|
||||
var root = doc.RootElement;
|
||||
Assert.True(root.TryGetProperty("items", out _));
|
||||
Assert.True(root.TryGetProperty("totalCount", out _));
|
||||
Assert.Equal(1, root.GetProperty("page").GetInt32());
|
||||
Assert.Equal(5, root.GetProperty("pageSize").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Combined_status_and_name_filter()
|
||||
{
|
||||
await Create("KombiVerkauf", status: "ForSale");
|
||||
await Create("KombiAktiv", status: "Breeding");
|
||||
var hits = await Names("status==ForSale,nameSearch=*kombi");
|
||||
Assert.Contains("KombiVerkauf", hits);
|
||||
Assert.DoesNotContain("KombiAktiv", hits);
|
||||
}
|
||||
}
|
||||
120
GerbilManager.Tests/SendReplyServiceTests.cs
Normal file
120
GerbilManager.Tests/SendReplyServiceTests.cs
Normal file
@@ -0,0 +1,120 @@
|
||||
using GerbilManagerWebAPI.Inbox;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using MailKit.Security;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>INBOX-3: threaded reply send — correct In-Reply-To/References/Subject, status
|
||||
/// transition to Answered, and the not-configured / auth-failure error paths. Fake SMTP
|
||||
/// sender (live Gmail gated on Julian's App Password).</summary>
|
||||
public class SendReplyServiceTests
|
||||
{
|
||||
private sealed class CapturingSender : IGmailMailSender
|
||||
{
|
||||
public OutgoingMail? Last;
|
||||
public bool ThrowAuth;
|
||||
public Task SendAsync(MailConnection c, OutgoingMail m, CancellationToken ct = default)
|
||||
{
|
||||
if (ThrowAuth) throw new AuthenticationException("bad app password");
|
||||
Last = m;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private static ApplicationContext NewDb()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseInMemoryDatabase("send-" + Guid.NewGuid().ToString("N")).Options;
|
||||
var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated();
|
||||
return db;
|
||||
}
|
||||
|
||||
private static async Task<(ApplicationContext db, SendReplyService svc, CapturingSender sender, Request req)>
|
||||
SetupAsync(bool configured = true, string subject = "Anfrage Rennmäuse", string? refs = "<a@x> <b@y>")
|
||||
{
|
||||
var db = NewDb();
|
||||
var settings = new MailSettingsService(db, new EphemeralDataProtectionProvider());
|
||||
var s = await settings.GetAsync();
|
||||
if (configured) { s.GmailAddress = "zucht@gmail.com"; settings.SetPassword(s, "app-pw"); }
|
||||
var req = new Request
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GmailMessageId = "<orig@gmail>",
|
||||
ReferencesHeader = refs,
|
||||
FromAddress = "kunde@web.de",
|
||||
Subject = subject,
|
||||
Status = RequestStatus.New,
|
||||
ReceivedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
db.Requests.Add(req);
|
||||
await db.SaveChangesAsync();
|
||||
var sender = new CapturingSender();
|
||||
return (db, new SendReplyService(db, sender, settings), sender, req);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_threads_and_marks_answered()
|
||||
{
|
||||
var (db, svc, sender, req) = await SetupAsync();
|
||||
|
||||
var outcome = await svc.SendAsync(req, "Hallo, sehr gerne!");
|
||||
|
||||
Assert.True(outcome.Sent);
|
||||
Assert.Null(outcome.Error);
|
||||
Assert.NotNull(sender.Last);
|
||||
Assert.Equal("kunde@web.de", sender.Last!.To);
|
||||
Assert.Equal("Re: Anfrage Rennmäuse", sender.Last.Subject);
|
||||
Assert.Equal("<orig@gmail>", sender.Last.InReplyTo);
|
||||
Assert.Equal("<a@x> <b@y> <orig@gmail>", sender.Last.References); // chain + original
|
||||
|
||||
var saved = await db.Requests.SingleAsync();
|
||||
Assert.Equal(RequestStatus.Answered, saved.Status);
|
||||
Assert.NotNull(saved.AnsweredAt);
|
||||
Assert.Equal("Hallo, sehr gerne!", saved.DraftReply);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_does_not_double_prefix_re_subject()
|
||||
{
|
||||
var (_, svc, sender, req) = await SetupAsync(subject: "Re: läuft schon", refs: null);
|
||||
await svc.SendAsync(req, "ok");
|
||||
Assert.Equal("Re: läuft schon", sender.Last!.Subject);
|
||||
Assert.Equal("<orig@gmail>", sender.Last.References); // no prior chain -> just the original
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_without_config_fails_and_keeps_status()
|
||||
{
|
||||
var (db, svc, sender, req) = await SetupAsync(configured: false);
|
||||
var outcome = await svc.SendAsync(req, "ok");
|
||||
Assert.Equal("MailNotConfigured", outcome.Error);
|
||||
Assert.Null(sender.Last);
|
||||
Assert.Equal(RequestStatus.New, (await db.Requests.SingleAsync()).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_auth_failure_does_not_mark_answered()
|
||||
{
|
||||
var (db, svc, sender, req) = await SetupAsync();
|
||||
sender.ThrowAuth = true;
|
||||
var outcome = await svc.SendAsync(req, "ok");
|
||||
Assert.Equal("MailAuthFailed", outcome.Error);
|
||||
Assert.Equal(RequestStatus.New, (await db.Requests.SingleAsync()).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildMimeMessage_sets_threading_headers()
|
||||
{
|
||||
var msg = GmailMailSender.BuildMimeMessage("zucht@gmail.com",
|
||||
new OutgoingMail("kunde@web.de", "Re: Test", "Hallo", "<orig@gmail>", "<a@x> <orig@gmail>"));
|
||||
|
||||
Assert.Equal("Re: Test", msg.Subject);
|
||||
Assert.Contains("kunde@web.de", msg.To.ToString());
|
||||
Assert.Contains("orig@gmail", msg.InReplyTo);
|
||||
Assert.Contains("orig@gmail", string.Join(' ', msg.References));
|
||||
Assert.Contains("a@x", string.Join(' ', msg.References));
|
||||
}
|
||||
}
|
||||
410
GerbilManager.Tests/SiteRendererTests.cs
Normal file
410
GerbilManager.Tests/SiteRendererTests.cs
Normal file
@@ -0,0 +1,410 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using GerbilManagerWebAPI.Cms;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WEB-1: static site renderer — snapshot → HTML file map.
|
||||
/// All tests are pure unit tests (no DB, no HTTP); they build JsonObject
|
||||
/// snapshots directly and assert on rendered HTML.
|
||||
/// </summary>
|
||||
public class SiteRendererTests
|
||||
{
|
||||
// ── Snapshot builder helpers ─────────────────────────────────────────────
|
||||
|
||||
private static JsonObject MakeSnapshot(params (string slug, string title, JsonArray blocks)[] pages)
|
||||
{
|
||||
var navOrder = new JsonArray();
|
||||
var pagesArr = new JsonArray();
|
||||
foreach (var (slug, title, blocks) in pages)
|
||||
{
|
||||
navOrder.Add(slug);
|
||||
pagesArr.Add(new JsonObject
|
||||
{
|
||||
["slug"] = slug,
|
||||
["title"] = title,
|
||||
["seoDescription"] = $"SEO für {title}",
|
||||
["status"] = "Published",
|
||||
["blocks"] = blocks,
|
||||
});
|
||||
}
|
||||
return new JsonObject
|
||||
{
|
||||
["site"] = new JsonObject { ["defaultLocale"] = "de", ["navOrder"] = navOrder },
|
||||
["pages"] = pagesArr,
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonArray Blocks(params JsonObject[] blocks)
|
||||
{
|
||||
var arr = new JsonArray();
|
||||
int i = 0;
|
||||
foreach (var b in blocks) { b["order"] = i++; arr.Add(b); }
|
||||
return arr;
|
||||
}
|
||||
|
||||
private static JsonObject HeadingBlock(string text, int level = 1) => new()
|
||||
{
|
||||
["type"] = "Heading",
|
||||
["data"] = new JsonObject { ["text"] = text, ["level"] = level },
|
||||
};
|
||||
|
||||
private static JsonObject RichTextBlock(string md) => new()
|
||||
{
|
||||
["type"] = "RichText",
|
||||
["data"] = new JsonObject { ["markdown"] = md },
|
||||
};
|
||||
|
||||
private static JsonObject ImageBlock(string url, string alt) => new()
|
||||
{
|
||||
["type"] = "Image",
|
||||
["data"] = new JsonObject { ["url"] = url, ["alt"] = alt },
|
||||
};
|
||||
|
||||
private static JsonObject ContactInfoBlock(string name, string email, string phone) => new()
|
||||
{
|
||||
["type"] = "ContactInfo",
|
||||
["data"] = new JsonObject { ["name"] = name, ["email"] = email, ["phone"] = phone },
|
||||
};
|
||||
|
||||
private static JsonObject AbgabetiereBlock(string intro,
|
||||
params (string name, string farbe, string? group, string? photo, string? gender, string? dob, string? note)[] animals)
|
||||
{
|
||||
var animalArr = new JsonArray();
|
||||
foreach (var (name, farbe, group, photo, gender, dob, note) in animals)
|
||||
{
|
||||
var photos = new JsonArray();
|
||||
if (photo is not null) photos.Add(photo);
|
||||
animalArr.Add(new JsonObject
|
||||
{
|
||||
["name"] = name,
|
||||
["farbschlag"] = farbe,
|
||||
["group"] = group,
|
||||
["gender"] = gender,
|
||||
["dateOfBirth"] = dob,
|
||||
["characterNote"] = note,
|
||||
["photos"] = photos,
|
||||
["aiSaleText"] = (JsonNode?)null,
|
||||
});
|
||||
}
|
||||
return new JsonObject
|
||||
{
|
||||
["type"] = "AbgabetiereList",
|
||||
["data"] = new JsonObject { ["mode"] = "auto", ["intro"] = intro, ["animals"] = animalArr },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Render_produces_index_html_for_start_page()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "Startseite", Blocks(HeadingBlock("Willkommen"))));
|
||||
var files = SiteRenderer.Render(snap);
|
||||
Assert.True(files.ContainsKey("index.html"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_produces_slug_subfolder_for_non_start_pages()
|
||||
{
|
||||
var snap = MakeSnapshot(
|
||||
("start", "Start", Blocks()),
|
||||
("abgabetiere", "Abgabetiere", Blocks()));
|
||||
var files = SiteRenderer.Render(snap);
|
||||
Assert.True(files.ContainsKey("abgabetiere/index.html"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_includes_css_asset()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "Start", Blocks()));
|
||||
var files = SiteRenderer.Render(snap);
|
||||
Assert.True(files.ContainsKey(SiteRenderer.CssPath));
|
||||
Assert.Contains("site-header", files[SiteRenderer.CssPath]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Page_html_is_valid_doctype_and_contains_title()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "Startseite", Blocks(HeadingBlock("Hallo"))));
|
||||
var html = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.StartsWith("<!DOCTYPE html>", html);
|
||||
Assert.Contains("<title>Startseite |", html);
|
||||
Assert.Contains("lang=\"de\"", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Page_html_includes_seo_description()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "Start", Blocks()));
|
||||
// seoDescription is set to "SEO für Start" by our helper
|
||||
var html = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.Contains("meta name=\"description\"", html);
|
||||
Assert.Contains("SEO für Start", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nav_links_are_rendered_with_all_slugs()
|
||||
{
|
||||
var snap = MakeSnapshot(
|
||||
("start", "Start", Blocks()),
|
||||
("abgabetiere", "Abgabetiere", Blocks()),
|
||||
("kontakt", "Kontakt", Blocks()));
|
||||
var html = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.Contains("Abgabetiere", html);
|
||||
Assert.Contains("Kontakt", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Current_page_nav_link_has_aria_current()
|
||||
{
|
||||
var snap = MakeSnapshot(
|
||||
("start", "Start", Blocks()),
|
||||
("abgabetiere", "Abgabetiere", Blocks()));
|
||||
var abgHtml = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
||||
Assert.Contains("aria-current=\"page\"", abgHtml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Heading_block_renders_h_tag_with_text()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "Start", Blocks(HeadingBlock("Meine Rennmäuse", 2))));
|
||||
var html = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.Contains("<h2", html);
|
||||
Assert.Contains("Meine Rennmäuse", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RichText_block_renders_markdown_bold_and_paragraph()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "Start", Blocks(RichTextBlock("**Hallo** Welt"))));
|
||||
var html = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.Contains("<strong>Hallo</strong>", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RichText_block_renders_markdown_list()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "Start", Blocks(RichTextBlock("- Eins\n- Zwei"))));
|
||||
var html = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.Contains("<ul>", html);
|
||||
Assert.Contains("<li>", html);
|
||||
Assert.Contains("Eins", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Image_block_renders_img_with_alt()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "Start", Blocks(ImageBlock("/img/maus.jpg", "Eine Rennmaus"))));
|
||||
var html = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.Contains("src=\"/img/maus.jpg\"", html);
|
||||
Assert.Contains("alt=\"Eine Rennmaus\"", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContactInfo_block_renders_name_and_email()
|
||||
{
|
||||
var snap = MakeSnapshot(("kontakt", "Kontakt",
|
||||
Blocks(ContactInfoBlock("Kleine Chaoten", "info@kleine-chaoten.de", "+49 123 456789"))));
|
||||
var html = SiteRenderer.Render(snap)["kontakt/index.html"];
|
||||
Assert.Contains("Kleine Chaoten", html);
|
||||
Assert.Contains("info@kleine-chaoten.de", html);
|
||||
Assert.Contains("+49 123 456789", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbgabetiereList_renders_group_card_with_name_farbschlag_and_photo()
|
||||
{
|
||||
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
||||
Blocks(AbgabetiereBlock("Aktuelle Tiere:", ("Krümel", "CP-Agouti", "Großbecken", "/photos/files/abc.jpg", null, null, null)))));
|
||||
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
||||
Assert.Contains("Krümel", html);
|
||||
Assert.Contains("CP-Agouti", html);
|
||||
Assert.Contains("Großbecken", html);
|
||||
Assert.Contains("/photos/files/abc.jpg", html);
|
||||
Assert.Contains("cms-group-card", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbgabetiereList_group_card_shows_gender_label_and_available_badge()
|
||||
{
|
||||
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
||||
Blocks(AbgabetiereBlock("",
|
||||
("Frieda", "Agouti", "Becken 1", null, "female", null, null),
|
||||
("Rosa", "PEW", "Becken 1", null, "female", null, null)))));
|
||||
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
||||
Assert.Contains("Weibchen", html);
|
||||
Assert.Contains("Verfügbar", html);
|
||||
Assert.Contains("cms-badge--free", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbgabetiereList_shows_dateOfBirth_and_characterNote()
|
||||
{
|
||||
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
||||
Blocks(AbgabetiereBlock("",
|
||||
("Pünktchen", "Siamesisch", null, null, "female", "2024-03-15", "Sehr neugierig und verspielt.")))));
|
||||
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
||||
Assert.Contains("März 2024", html);
|
||||
Assert.Contains("Sehr neugierig und verspielt.", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbgabetiereList_solo_animal_without_group_gets_its_own_card()
|
||||
{
|
||||
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
||||
Blocks(AbgabetiereBlock("",
|
||||
("Einzelkind", "Zobel", null, null, "male", null, null)))));
|
||||
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
||||
Assert.Contains("Einzelkind", html);
|
||||
Assert.Contains("Männchen", html);
|
||||
Assert.Contains("cms-group-card", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbgabetiereList_empty_shows_placeholder_text()
|
||||
{
|
||||
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
||||
Blocks(AbgabetiereBlock(""))));
|
||||
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
||||
Assert.Contains("cms-abgabe-empty", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Draft_pages_are_excluded_from_output()
|
||||
{
|
||||
var pages = new JsonArray
|
||||
{
|
||||
new JsonObject { ["slug"] = "start", ["title"] = "Start", ["status"] = "Published",
|
||||
["seoDescription"] = (JsonNode?)null, ["blocks"] = new JsonArray() },
|
||||
new JsonObject { ["slug"] = "entwurf", ["title"] = "Entwurf", ["status"] = "Draft",
|
||||
["seoDescription"] = (JsonNode?)null, ["blocks"] = new JsonArray() },
|
||||
};
|
||||
var snap = new JsonObject
|
||||
{
|
||||
["site"] = new JsonObject { ["defaultLocale"] = "de", ["navOrder"] = new JsonArray { "start", "entwurf" } },
|
||||
["pages"] = pages,
|
||||
};
|
||||
var files = SiteRenderer.Render(snap);
|
||||
Assert.True(files.ContainsKey("index.html"));
|
||||
Assert.False(files.ContainsKey("entwurf/index.html"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Html_encoding_prevents_xss_in_page_title()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "<script>alert(1)</script>", Blocks()));
|
||||
var html = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.DoesNotContain("<script>", html);
|
||||
Assert.Contains("<script>", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_six_seeded_pages_produces_expected_paths()
|
||||
{
|
||||
var snap = MakeSnapshot(
|
||||
("start", "Startseite", Blocks()),
|
||||
("ueber-die-zucht", "Über die Zucht", Blocks()),
|
||||
("abgabetiere", "Abgabetiere", Blocks(AbgabetiereBlock(""))),
|
||||
("abgabebedingungen", "Abgabebedingungen", Blocks()),
|
||||
("farben-genetik", "Farben & Genetik", Blocks()),
|
||||
("kontakt", "Kontakt", Blocks()));
|
||||
var files = SiteRenderer.Render(snap);
|
||||
Assert.True(files.ContainsKey("index.html"));
|
||||
Assert.True(files.ContainsKey("ueber-die-zucht/index.html"));
|
||||
Assert.True(files.ContainsKey("abgabetiere/index.html"));
|
||||
Assert.True(files.ContainsKey("abgabebedingungen/index.html"));
|
||||
Assert.True(files.ContainsKey("farben-genetik/index.html"));
|
||||
Assert.True(files.ContainsKey("kontakt/index.html"));
|
||||
Assert.True(files.ContainsKey(SiteRenderer.CssPath));
|
||||
// 6 pages + 1 CSS
|
||||
Assert.Equal(7, files.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_impressum_and_datenschutz_pages_are_rendered()
|
||||
{
|
||||
var snap = MakeSnapshot(
|
||||
("start", "Startseite", Blocks()),
|
||||
("impressum", "Impressum", Blocks(RichTextBlock("§ 5 TMG Platzhalter"))),
|
||||
("datenschutz", "Datenschutz", Blocks(RichTextBlock("Datenschutz Platzhalter"))));
|
||||
var files = SiteRenderer.Render(snap);
|
||||
Assert.True(files.ContainsKey("impressum/index.html"));
|
||||
Assert.True(files.ContainsKey("datenschutz/index.html"));
|
||||
Assert.Contains("§ 5 TMG Platzhalter", files["impressum/index.html"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Footer_contains_impressum_and_datenschutz_links()
|
||||
{
|
||||
var snap = MakeSnapshot(
|
||||
("start", "Startseite", Blocks()),
|
||||
("kontakt", "Kontakt", Blocks()));
|
||||
var startHtml = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.Contains("impressum/index.html", startHtml);
|
||||
Assert.Contains("datenschutz/index.html", startHtml);
|
||||
// links in footer, not nav
|
||||
Assert.Contains("site-footer-link", startHtml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Footer_links_use_correct_relative_path_from_subpage()
|
||||
{
|
||||
var snap = MakeSnapshot(
|
||||
("start", "Startseite", Blocks()),
|
||||
("kontakt", "Kontakt", Blocks()));
|
||||
var kontaktHtml = SiteRenderer.Render(snap)["kontakt/index.html"];
|
||||
// subpage needs ../ prefix for footer links
|
||||
Assert.Contains("../impressum/index.html", kontaktHtml);
|
||||
Assert.Contains("../datenschutz/index.html", kontaktHtml);
|
||||
}
|
||||
|
||||
// ── Markdown tests ───────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("**fett**", "<strong>fett</strong>")]
|
||||
[InlineData("*kursiv*", "<em>kursiv</em>")]
|
||||
[InlineData("`code`", "<code>code</code>")]
|
||||
public void Markdown_inline_renders_correctly(string md, string expectedFragment)
|
||||
{
|
||||
var html = SiteRenderer.MarkdownToHtml(md);
|
||||
Assert.Contains(expectedFragment, html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Markdown_heading_renders_h_tag()
|
||||
{
|
||||
var html = SiteRenderer.MarkdownToHtml("## Abschnitt");
|
||||
Assert.Contains("<h2>Abschnitt</h2>", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Markdown_xss_text_is_encoded()
|
||||
{
|
||||
var html = SiteRenderer.MarkdownToHtml("<script>alert(1)</script>");
|
||||
Assert.DoesNotContain("<script>", html);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>WEB-1: smoke-test the /api/render-site endpoint via ApiFactory.</summary>
|
||||
public class RenderSiteEndpointTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
public RenderSiteEndpointTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||
|
||||
[Fact]
|
||||
public async Task RenderSite_returns_file_list_with_css_and_pages()
|
||||
{
|
||||
var json = await _client.GetStringAsync("/api/render-site");
|
||||
var files = JsonSerializer.Deserialize<List<RenderEntry>>(json,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
Assert.Contains(files, f => f.Path == SiteRenderer.CssPath);
|
||||
Assert.Contains(files, f => f.Path == "index.html");
|
||||
Assert.All(files, f => Assert.True(f.Size > 0));
|
||||
}
|
||||
|
||||
private record RenderEntry(string Path, int Size);
|
||||
}
|
||||
124
GerbilManager.postman_collection.json
Normal file
124
GerbilManager.postman_collection.json
Normal file
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "2b7d1860-b8d4-44d1-8548-be847b925c74",
|
||||
"name": "GerbilManager",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||
"_exporter_id": "19567620"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "Gerbils",
|
||||
"item": [
|
||||
{
|
||||
"name": "Get All Gerbil",
|
||||
"protocolProfileBehavior": {
|
||||
"strictSSL": false
|
||||
},
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{base}}/Gerbils",
|
||||
"host": [
|
||||
"{{base}}"
|
||||
],
|
||||
"path": [
|
||||
"Gerbils"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Post random Gerbil",
|
||||
"protocolProfileBehavior": {
|
||||
"disabledSystemHeaders": {
|
||||
"content-type": true
|
||||
}
|
||||
},
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\r\n \"name\": \"{{$randomFirstName}}\",\r\n \"gender\": {{randomGender}}\r\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base}}/Gerbils",
|
||||
"host": [
|
||||
"{{base}}"
|
||||
],
|
||||
"path": [
|
||||
"Gerbils"
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"event": [
|
||||
{
|
||||
"listen": "prerequest",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.globals.set('randomGender', Math.floor(Math.random() * 2)+1);"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
""
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "base_url",
|
||||
"value": "https://localhost",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"key": "portDebug",
|
||||
"value": "7191",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"key": "portProductive",
|
||||
"value": "80",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"key": "rootD",
|
||||
"value": "http://localhost",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"key": "rootR",
|
||||
"value": "http://localhost",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"key": "base",
|
||||
"value": "{{base_url}}:{{portDebug}}",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.5.002.0
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GerbilManagerWebAPI", "GerbilManagerWebAPI\GerbilManagerWebAPI.csproj", "{48875578-7112-4F6F-A4C0-F1E7637B513D}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{48875578-7112-4F6F-A4C0-F1E7637B513D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{48875578-7112-4F6F-A4C0-F1E7637B513D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{48875578-7112-4F6F-A4C0-F1E7637B513D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{48875578-7112-4F6F-A4C0-F1E7637B513D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {7F573F67-6F28-4056-8D14-0E04C8C54B7E}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
6
GerbilManager.slnx
Normal file
6
GerbilManager.slnx
Normal file
@@ -0,0 +1,6 @@
|
||||
<Solution>
|
||||
<Project Path="GerbilManager.AppHost/GerbilManager.AppHost.csproj" />
|
||||
<Project Path="GerbilManager.ServiceDefaults/GerbilManager.ServiceDefaults.csproj" />
|
||||
<Project Path="GerbilManager.Tests/GerbilManager.Tests.csproj" />
|
||||
<Project Path="GerbilManagerWebAPI/GerbilManagerWebAPI.csproj" />
|
||||
</Solution>
|
||||
99
GerbilManagerWebAPI/Ai/OpenAiChatClient.cs
Normal file
99
GerbilManagerWebAPI/Ai/OpenAiChatClient.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using GerbilManagerWebAPI.SaleAd;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GerbilManagerWebAPI.Ai
|
||||
{
|
||||
public enum AiCallStatus
|
||||
{
|
||||
Ok,
|
||||
/// <summary>AI section not (fully) configured -> callers map to 503 "AiKeyMissing".</summary>
|
||||
NotConfigured,
|
||||
/// <summary>Provider call failed -> callers map to 502 "AiUpstreamError".</summary>
|
||||
UpstreamError,
|
||||
}
|
||||
|
||||
public sealed record AiCallResult(AiCallStatus Status, string? Text, string? Error = null);
|
||||
|
||||
/// <summary>
|
||||
/// INBOX-2: provider-agnostic chat client, extracted from SaleAdService so the
|
||||
/// reply-draft (and future AI features) reuse the SAME wire implementation:
|
||||
/// plain JSON POST to {AI:BaseUrl}/chat/completions with a Bearer key — covers
|
||||
/// Gemini (compat endpoint), Groq, Mistral, local Ollama; no vendor SDK.
|
||||
/// Configuration stays the single AI section (AiOptions, env-only).
|
||||
/// </summary>
|
||||
public sealed class OpenAiChatClient(HttpClient http, IOptions<AiOptions> options)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
public async Task<AiCallResult> CompleteAsync(
|
||||
string systemPrompt, string userPrompt, CancellationToken ct = default)
|
||||
{
|
||||
var ai = options.Value;
|
||||
if (!ai.IsConfigured)
|
||||
{
|
||||
return new AiCallResult(AiCallStatus.NotConfigured, null,
|
||||
"KI-Anbieter ist nicht konfiguriert (AI__BaseUrl / AI__ApiKey / AI__Model).");
|
||||
}
|
||||
|
||||
var payload = new ChatRequest(
|
||||
Model: ai.Model!,
|
||||
Messages:
|
||||
[
|
||||
new ChatMessage("system", systemPrompt),
|
||||
new ChatMessage("user", userPrompt),
|
||||
],
|
||||
Temperature: 0.7);
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, BuildCompletionsUri(ai.BaseUrl!))
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(payload, JsonOptions),
|
||||
Encoding.UTF8, "application/json"),
|
||||
};
|
||||
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ai.ApiKey);
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await http.SendAsync(httpRequest, ct);
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new AiCallResult(AiCallStatus.UpstreamError, null,
|
||||
$"KI-Anbieter antwortete mit HTTP {(int)response.StatusCode}.");
|
||||
}
|
||||
|
||||
var completion = JsonSerializer.Deserialize<ChatResponse>(body, JsonOptions);
|
||||
var text = completion?.Choices?.FirstOrDefault()?.Message?.Content?.Trim();
|
||||
return string.IsNullOrWhiteSpace(text)
|
||||
? new AiCallResult(AiCallStatus.UpstreamError, null,
|
||||
"KI-Antwort enthielt keinen Text.")
|
||||
: new AiCallResult(AiCallStatus.Ok, text);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
|
||||
{
|
||||
return new AiCallResult(AiCallStatus.UpstreamError, null,
|
||||
$"KI-Anbieter nicht erreichbar: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>{BaseUrl}/chat/completions — tolerant of a trailing slash on BaseUrl.</summary>
|
||||
internal static Uri BuildCompletionsUri(string baseUrl) =>
|
||||
new($"{baseUrl.TrimEnd('/')}/chat/completions");
|
||||
|
||||
// ── OpenAI-compatible wire records (request + the slice of the response we read) ──
|
||||
internal sealed record ChatRequest(string Model, List<ChatMessage> Messages, double? Temperature);
|
||||
|
||||
internal sealed record ChatMessage(string Role, string Content);
|
||||
|
||||
internal sealed record ChatResponse(List<ChatChoice>? Choices);
|
||||
|
||||
internal sealed record ChatChoice(ChatMessage? Message);
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,395 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class ApplicationContext : DbContext
|
||||
{
|
||||
public ApplicationContext(DbContextOptions options)
|
||||
:base(options)
|
||||
public ApplicationContext(DbContextOptions options) : base(options)
|
||||
{
|
||||
}
|
||||
public DbSet<Gerbil> Gerbils { get; set; }
|
||||
}
|
||||
|
||||
public DbSet<Gerbil> Gerbils => Set<Gerbil>();
|
||||
public DbSet<Litter> Litters => Set<Litter>();
|
||||
public DbSet<Contact> Contacts => Set<Contact>();
|
||||
public DbSet<Enclosure> Enclosures => Set<Enclosure>();
|
||||
public DbSet<ColorVariety> ColorVarieties => Set<ColorVariety>();
|
||||
public DbSet<GerbilPhoto> GerbilPhotos => Set<GerbilPhoto>();
|
||||
public DbSet<EnclosurePhoto> EnclosurePhotos => Set<EnclosurePhoto>();
|
||||
public DbSet<HealthRecord> HealthRecords => Set<HealthRecord>();
|
||||
public DbSet<WeightRecord> WeightRecords => Set<WeightRecord>();
|
||||
public DbSet<SaleContract> SaleContracts => Set<SaleContract>();
|
||||
public DbSet<BreederSettings> BreederSettings => Set<BreederSettings>();
|
||||
public DbSet<Site> Sites => Set<Site>();
|
||||
public DbSet<Page> Pages => Set<Page>();
|
||||
public DbSet<Block> Blocks => Set<Block>();
|
||||
public DbSet<Media> Media => Set<Media>();
|
||||
public DbSet<Request> Requests => Set<Request>();
|
||||
public DbSet<MailSettings> MailSettings => Set<MailSettings>();
|
||||
|
||||
// Keep Gerbil.NameSearch in sync on every save (separator-insensitive search key),
|
||||
// so it can never drift from Name regardless of which code path mutates the entity.
|
||||
public override int SaveChanges(bool acceptAllChangesOnSuccess)
|
||||
{
|
||||
SyncNameSearch();
|
||||
return base.SaveChanges(acceptAllChangesOnSuccess);
|
||||
}
|
||||
|
||||
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
|
||||
{
|
||||
SyncNameSearch();
|
||||
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
|
||||
}
|
||||
|
||||
private void SyncNameSearch()
|
||||
{
|
||||
foreach (var entry in ChangeTracker.Entries<Gerbil>())
|
||||
{
|
||||
if (entry.State is EntityState.Added or EntityState.Modified)
|
||||
entry.Entity.NameSearch = GerbilSearch.Normalize(entry.Entity.Name);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
// DB-4: ICU German collation on user-visible / searched text columns.
|
||||
// "de-x-icu" gives correct German sort order (ä between a and b) and locale-aware
|
||||
// case-folding (lower('Ä')='ä'). Applied only on Npgsql (Postgres); SQLite does not
|
||||
// support custom collation names and would fail EnsureCreated on the test host.
|
||||
const string DeIcu = "de-x-icu";
|
||||
bool isNpgsql = Database.ProviderName?.Contains("Npgsql", StringComparison.OrdinalIgnoreCase) ?? false;
|
||||
|
||||
modelBuilder.Entity<Gerbil>(e =>
|
||||
{
|
||||
// Enums persisted as their string names (readable, Gridify-friendly).
|
||||
e.Property(g => g.Gender).HasConversion<string>();
|
||||
e.Property(g => g.Status).HasConversion<string>();
|
||||
|
||||
// Residency defaults to true (own stock unless explicitly marked external).
|
||||
e.Property(g => g.IsResident).HasDefaultValue(true);
|
||||
|
||||
// DB-4: German collation on searched/sorted name fields (Npgsql-only).
|
||||
if (isNpgsql)
|
||||
{
|
||||
e.Property(g => g.Name).UseCollation(DeIcu);
|
||||
e.Property(g => g.NameSearch).UseCollation(DeIcu);
|
||||
e.Property(g => g.OriginBreeder).UseCollation(DeIcu);
|
||||
}
|
||||
|
||||
// FEAT-14: character traits stored as a JSON text column (works on both
|
||||
// Npgsql and the SQLite test host; opaque labels, no backend vocabulary).
|
||||
var traitsConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<string>, string>(
|
||||
v => System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions?)null),
|
||||
v => string.IsNullOrEmpty(v)
|
||||
? new List<string>()
|
||||
: System.Text.Json.JsonSerializer.Deserialize<List<string>>(v, (System.Text.Json.JsonSerializerOptions?)null) ?? new List<string>());
|
||||
var traitsComparer = new Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer<List<string>>(
|
||||
(a, b) => (a ?? new List<string>()).SequenceEqual(b ?? new List<string>()),
|
||||
v => v == null ? 0 : v.Aggregate(0, (h, s) => HashCode.Combine(h, s.GetHashCode())),
|
||||
v => v.ToList());
|
||||
e.Property(g => g.CharacterTraits).HasConversion(traitsConverter, traitsComparer);
|
||||
|
||||
e.HasOne(g => g.Litter).WithMany()
|
||||
.HasForeignKey(g => g.LitterId).OnDelete(DeleteBehavior.SetNull);
|
||||
e.HasOne(g => g.OriginContact).WithMany()
|
||||
.HasForeignKey(g => g.OriginContactId).OnDelete(DeleteBehavior.Restrict);
|
||||
e.HasOne(g => g.ReceiverContact).WithMany()
|
||||
.HasForeignKey(g => g.ReceiverContactId).OnDelete(DeleteBehavior.Restrict);
|
||||
e.HasOne(g => g.Enclosure).WithMany(en => en.Gerbils)
|
||||
.HasForeignKey(g => g.EnclosureId).OnDelete(DeleteBehavior.SetNull);
|
||||
e.HasOne(g => g.ColorVariety).WithMany()
|
||||
.HasForeignKey(g => g.ColorVarietyId).OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
// DB-1: ExternalRef is the import idempotency key — enforce uniqueness at the DB level.
|
||||
// Filtered (nulls allowed: manually-entered animals have no ExternalRef).
|
||||
e.HasIndex(g => g.ExternalRef)
|
||||
.IsUnique()
|
||||
.HasFilter("\"ExternalRef\" IS NOT NULL");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Litter>(e =>
|
||||
{
|
||||
e.HasOne(l => l.Father).WithMany()
|
||||
.HasForeignKey(l => l.FatherId).OnDelete(DeleteBehavior.Restrict);
|
||||
e.HasOne(l => l.Mother).WithMany()
|
||||
.HasForeignKey(l => l.MotherId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// DB-5: ExternalRef = source litter id from extract.py.
|
||||
// Unique (filtered, nulls allowed for manually-entered litters).
|
||||
e.HasIndex(l => l.ExternalRef)
|
||||
.IsUnique()
|
||||
.HasFilter("\"ExternalRef\" IS NOT NULL");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<HealthRecord>(e =>
|
||||
{
|
||||
e.Property(h => h.Type).HasConversion<string>();
|
||||
e.HasOne<Gerbil>().WithMany()
|
||||
.HasForeignKey(h => h.GerbilId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<WeightRecord>(e =>
|
||||
e.HasOne<Gerbil>().WithMany()
|
||||
.HasForeignKey(w => w.GerbilId).OnDelete(DeleteBehavior.Cascade));
|
||||
|
||||
modelBuilder.Entity<GerbilPhoto>(e =>
|
||||
e.HasOne<Gerbil>().WithMany()
|
||||
.HasForeignKey(p => p.GerbilId).OnDelete(DeleteBehavior.Cascade));
|
||||
|
||||
modelBuilder.Entity<EnclosurePhoto>(e =>
|
||||
e.HasOne<Enclosure>().WithMany()
|
||||
.HasForeignKey(p => p.EnclosureId).OnDelete(DeleteBehavior.Cascade));
|
||||
|
||||
// FEAT-13: Abgabeverträge + Zuchtprofil.
|
||||
modelBuilder.Entity<SaleContract>(e =>
|
||||
{
|
||||
e.Property(c => c.Price).HasPrecision(10, 2);
|
||||
// Restrict: ein Kontakt mit Verträgen ist ein Dokumentenbestand,
|
||||
// kein versehentlich löschbarer Datensatz.
|
||||
e.HasOne(c => c.Contact).WithMany()
|
||||
.HasForeignKey(c => c.ContactId).OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<SaleContractAnimal>(e =>
|
||||
{
|
||||
e.HasKey(a => new { a.SaleContractId, a.GerbilId });
|
||||
e.HasOne<SaleContract>().WithMany(c => c.Animals)
|
||||
.HasForeignKey(a => a.SaleContractId).OnDelete(DeleteBehavior.Cascade);
|
||||
// Cascade: wird ein Tier gelöscht, verschwindet nur die Verknüpfung —
|
||||
// der Vertrag (und seine .docx als Beleg) bleibt bestehen.
|
||||
e.HasOne(a => a.Gerbil).WithMany()
|
||||
.HasForeignKey(a => a.GerbilId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// Zuchtprofil: genau eine (leere) Zeile mit fixer Id.
|
||||
modelBuilder.Entity<BreederSettings>()
|
||||
.HasData(new BreederSettings { Id = GerbilManagerWebAPI.Models.BreederSettings.SingletonId });
|
||||
|
||||
// WEB epic: CMS content model.
|
||||
modelBuilder.Entity<Site>(e =>
|
||||
{
|
||||
var navConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<Guid>, string>(
|
||||
v => System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions?)null),
|
||||
v => string.IsNullOrEmpty(v)
|
||||
? new List<Guid>()
|
||||
: System.Text.Json.JsonSerializer.Deserialize<List<Guid>>(v, (System.Text.Json.JsonSerializerOptions?)null) ?? new List<Guid>());
|
||||
var navComparer = new Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer<List<Guid>>(
|
||||
(a, b) => (a ?? new List<Guid>()).SequenceEqual(b ?? new List<Guid>()),
|
||||
v => v == null ? 0 : v.Aggregate(0, (h, x) => HashCode.Combine(h, x.GetHashCode())),
|
||||
v => v.ToList());
|
||||
e.Property(s => s.NavOrder).HasConversion(navConverter, navComparer);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Page>(e =>
|
||||
{
|
||||
e.HasIndex(p => p.Slug).IsUnique();
|
||||
e.Property(p => p.Status).HasConversion<string>();
|
||||
e.HasMany(p => p.Blocks).WithOne()
|
||||
.HasForeignKey(b => b.PageId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Block>(e => e.Property(b => b.Type).HasConversion<string>());
|
||||
|
||||
SeedCms(modelBuilder);
|
||||
|
||||
// INBOX epic: Gmail request inbox.
|
||||
modelBuilder.Entity<Request>(e =>
|
||||
{
|
||||
e.Property(r => r.Status).HasConversion<string>();
|
||||
e.HasIndex(r => r.GmailMessageId).IsUnique();
|
||||
e.HasOne(r => r.AssignedContact).WithMany()
|
||||
.HasForeignKey(r => r.AssignedContactId).OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
// exactly one MailSettings row, fixed id.
|
||||
modelBuilder.Entity<MailSettings>()
|
||||
.HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId });
|
||||
|
||||
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
|
||||
if (isNpgsql)
|
||||
{
|
||||
modelBuilder.Entity<ColorVariety>()
|
||||
.Property(v => v.Name).UseCollation(DeIcu);
|
||||
modelBuilder.Entity<Contact>()
|
||||
.Property(c => c.Name).UseCollation(DeIcu);
|
||||
}
|
||||
|
||||
SeedColorVarieties(modelBuilder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seed the public website's CMS: one Site + the 6 Jimdo-mirror pages, each with a
|
||||
/// placeholder Heading; the abgabetiere page also carries an auto AbgabetiereList block
|
||||
/// (resolved from live ForSale animals at snapshot time). Hand-editable afterwards.
|
||||
/// </summary>
|
||||
private static void SeedCms(ModelBuilder modelBuilder)
|
||||
{
|
||||
Guid Pid(int n) => new($"51720001-0000-0000-0000-{n:D12}");
|
||||
Guid Bid(int n) => new($"51720002-0000-0000-0000-{n:D12}");
|
||||
|
||||
(int n, string Slug, string Title)[] pages =
|
||||
{
|
||||
(1, "start", "Startseite"),
|
||||
(2, "ueber-die-zucht", "Über die Zucht"),
|
||||
(3, "abgabetiere", "Abgabetiere"),
|
||||
(4, "abgabebedingungen", "Abgabebedingungen"),
|
||||
(5, "farben-genetik", "Farben & Genetik"),
|
||||
(6, "kontakt", "Kontakt"),
|
||||
};
|
||||
|
||||
// WEB-6a: footer-only legal pages (not in main nav)
|
||||
(int n, string Slug, string Title, string BodyMd)[] legalPages =
|
||||
{
|
||||
(7, "impressum", "Impressum",
|
||||
"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*"),
|
||||
(8, "datenschutz", "Datenschutz",
|
||||
"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*"),
|
||||
};
|
||||
|
||||
var pageRows = new List<Page>();
|
||||
var blockRows = new List<Block>();
|
||||
foreach (var p in pages)
|
||||
{
|
||||
pageRows.Add(new Page { Id = Pid(p.n), Slug = p.Slug, Title = p.Title, Status = PageStatus.Published });
|
||||
// placeholder Heading per page
|
||||
blockRows.Add(new Block
|
||||
{
|
||||
Id = Bid(p.n), PageId = Pid(p.n), Order = 0, Type = BlockType.Heading,
|
||||
Data = $"{{\"text\":\"{p.Title}\",\"level\":1}}",
|
||||
});
|
||||
}
|
||||
// abgabetiere (n=3): auto AbgabetiereList as the second block
|
||||
blockRows.Add(new Block
|
||||
{
|
||||
Id = Bid(10), PageId = Pid(3), Order = 1, Type = BlockType.AbgabetiereList,
|
||||
Data = "{\"mode\":\"auto\",\"intro\":\"\"}",
|
||||
});
|
||||
foreach (var lp in legalPages)
|
||||
{
|
||||
pageRows.Add(new Page { Id = Pid(lp.n), Slug = lp.Slug, Title = lp.Title, Status = PageStatus.Published });
|
||||
blockRows.Add(new Block { Id = Bid(lp.n), PageId = Pid(lp.n), Order = 0, Type = BlockType.Heading, Data = $"{{\"text\":\"{lp.Title}\",\"level\":1}}" });
|
||||
blockRows.Add(new Block { Id = Bid(lp.n * 10), PageId = Pid(lp.n), Order = 1, Type = BlockType.RichText, Data = $"{{\"markdown\":\"{lp.BodyMd}\"}}" });
|
||||
}
|
||||
|
||||
modelBuilder.Entity<Page>().HasData(pageRows);
|
||||
modelBuilder.Entity<Block>().HasData(blockRows);
|
||||
|
||||
modelBuilder.Entity<Site>().HasData(new Site
|
||||
{
|
||||
Id = Site.SingletonId,
|
||||
DefaultLocale = "de",
|
||||
NavOrder = pages.Select(p => Pid(p.n)).ToList(),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seed the colour-variety catalog. Source of truth = Kevin's GEN-2 generated list
|
||||
/// (gerbil-manager-web/src/genetics/colorVarietySeed.generated.json): 73 varieties
|
||||
/// (the 18 frozen-contract names first, then the baseportal.de extension), in stable
|
||||
/// SortOrder. Names are UI filter keys — spelling changes route through god.
|
||||
/// </summary>
|
||||
private static void SeedColorVarieties(ModelBuilder modelBuilder)
|
||||
{
|
||||
// CATALOG-RESEED (AR-5 / colorVarietySeed.backend.json): 70 entries, frozen symbols.
|
||||
// ID = array_index + 1 (stable, never changes — live FKs safe).
|
||||
// SortOrder comes from colorVarietySeed.backend.json (decoupled from ID).
|
||||
// New entries appended at end (IDs 67-70: Dilute Algierfuchs/Goldfuchs/Rotfuchs/Polarfuchs).
|
||||
// GUARDRAIL: DO NOT reseed from colorVarietySeed.generated.json — it uses bracket display
|
||||
// notation (e[f]/c[chm]) which breaks CR-11 GenotypePotentiallyMatches() (frozen vs brackets).
|
||||
(string Name, string Genotype, int SortOrder)[] catalog =
|
||||
{
|
||||
// --- C-locus white / partial albino (IDs 1-3) ---
|
||||
("REW", "AA chch DD EE GG pp spsp rere", 0),
|
||||
("Hermelin", "aa chch DD EE GG PP spsp rere", 1),
|
||||
("Himalaya", "AA chch DD EE GG PP spsp rere", 2),
|
||||
// --- Zobel / colourpoint dark (ID 4) ---
|
||||
("Zobel", "aa cchmcchm DD EE gg PP spsp rere", 3),
|
||||
// --- Schimmel family (IDs 5) ---
|
||||
("Rotaugenschimmel", "AA CC DD efef GG pp spsp rere", 4),
|
||||
// --- Standard colours (IDs 6-15) ---
|
||||
("Agouti", "AA CC DD EE GG PP spsp rere", 5),
|
||||
("Schwarz", "aa CC DD EE GG PP spsp rere", 6),
|
||||
("Silberagouti","AA CC DD EE gg PP spsp rere", 7),
|
||||
("Anthrazit", "aa CC DD EE gg PP spsp rere", 8),
|
||||
("Algierfuchs", "AA CC DD ee GG PP spsp rere", 9),
|
||||
("Blau", "aa CC dd EE GG PP spsp rere", 10),
|
||||
("Gold", "AA CC DD EE GG pp spsp rere", 11),
|
||||
("Platin", "aa CC DD EE GG pp spsp rere", 12),
|
||||
("Goldfuchs", "AA CC DD ee GG pp spsp rere", 13),
|
||||
("Rotfuchs", "aa CC DD ee GG pp spsp rere", 14),
|
||||
// --- Dilute (dd) standard (IDs 16-17) ---
|
||||
("Dilute Gold", "AA CC dd EE GG pp spsp rere", 15),
|
||||
("Dilute Platin","aa CC dd EE GG pp spsp rere", 16),
|
||||
// --- REW / Apricot / misc C-locus (IDs 18-28) ---
|
||||
("Altweiss (REW)", "aa CC DD EE gg pp spsp rere", 17),
|
||||
("Apricot (Blassfuchs)","AA CC DD ee gg pp spsp rere", 18),
|
||||
("Blaufuchs", "aa CC DD ee gg PP spsp rere", 19),
|
||||
("C-Separator", "aa CC DD ee gg pp spsp rere", 20),
|
||||
("Elfenbein", "AA CC DD EE gg pp spsp rere", 21),
|
||||
("Kohlfuchs", "aa CC DD ee GG PP spsp rere", 22),
|
||||
("Polarfuchs", "AA CC DD ee gg PP spsp rere", 23),
|
||||
("Saphir", "aa CC DD EE GG pp spsp rere", 24),
|
||||
("Orangeschimmel","AA CC DD efef GG PP spsp rere", 25),
|
||||
("Topas", "AA CC DD EE GG pp spsp rere", 26),
|
||||
("Platin-Hell", "aa CC DD EE GG pp spsp rere", 27),
|
||||
// --- Dilute (dd) varieties (IDs 29-32) ---
|
||||
("Dilute Agouti", "AA CC dd EE GG PP spsp rere", 28),
|
||||
("Dilute Silberagouti","AA CC dd EE gg PP spsp rere", 29),
|
||||
("Dilute Kohlfuchs", "aa CC dd ee GG PP spsp rere", 30),
|
||||
("Dilute Anthrazit", "aa CC dd EE gg PP spsp rere", 31),
|
||||
// --- Schimmel / Fuchsschimmel (IDs 33-37) ---
|
||||
("Silberschimmel", "AA CC DD efef gg PP spsp rere", 36),
|
||||
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere", 37),
|
||||
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere", 38),
|
||||
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere", 39),
|
||||
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere", 40),
|
||||
// --- Hell variants (IDs 38-48) ---
|
||||
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere", 41),
|
||||
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere", 42),
|
||||
("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere", 43),
|
||||
("Gold-Hell", "AA CC DD EE GG pp spsp rere", 44),
|
||||
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere", 45),
|
||||
("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere", 46),
|
||||
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere", 47),
|
||||
("Kohlfuchsschimmel, hell","aa CC DD efef GG PP spsp rere", 48),
|
||||
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere", 49),
|
||||
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere", 50),
|
||||
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere", 51),
|
||||
// --- Dilute (dd) renamed variants (IDs 49-50) ---
|
||||
("Dilute Topas", "AA CC dd EE GG pp spsp rere", 52),
|
||||
("Dilute Blaufuchs","aa CC dd ee gg pp spsp rere", 53),
|
||||
// --- Marder / Siam / CP- series (IDs 51-66) ---
|
||||
("Marder", "aa cchmcchm DD EE GG PP spsp rere", 54),
|
||||
("Siam", "aa cchmch DD EE GG PP spsp rere", 55),
|
||||
("Zobel-Hell","aa cchmch DD EE gg PP spsp rere", 56),
|
||||
("CP-Agouti", "AA cchmcchm DD EE GG PP spsp rere", 57),
|
||||
("CP-Silberagouti","AA cchmcchm DD EE gg PP spsp rere", 59),
|
||||
("CP-Algierfuchs", "AA cchmcchm DD ee GG PP spsp rere", 61),
|
||||
("CP-Polarfuchs", "AA cchmcchm DD ee gg PP spsp rere", 63),
|
||||
("CP-Fuchs", "AA cchmcchm dd ee GG PP spsp rere", 65),
|
||||
("CP-Fuchs-Hell", "AA cchmch dd ee GG PP spsp rere", 66),
|
||||
("CP-Blaufuchs", "AA cchmcchm dd ee gg PP spsp rere", 67),
|
||||
("CP-Orangeschimmel","AA cchmcchm DD efef GG PP spsp rere", 68),
|
||||
// GEN-3g / CATALOG-RESEED: CP-*-Hell interleaved (IDs 62-66, SortOrders from backend.json)
|
||||
("CP-Agouti-Hell", "AA cchmch DD EE GG PP spsp rere", 58),
|
||||
("CP-Silberagouti-Hell", "AA cchmch DD EE gg PP spsp rere", 60),
|
||||
("CP-Algierfuchs-Hell", "AA cchmch DD ee GG PP spsp rere", 62),
|
||||
("CP-Polarfuchs-Hell", "AA cchmch DD ee gg PP spsp rere", 64),
|
||||
("CP-Orangeschimmel-Hell","AA cchmch DD efef GG PP spsp rere", 69),
|
||||
// CATALOG-RESEED: 4 new Dilute (dd) Fuchs varieties appended (IDs 67-70)
|
||||
("Dilute Algierfuchs","AA CC dd ee GG PP spsp rere", 32),
|
||||
("Dilute Goldfuchs", "AA CC dd ee GG pp spsp rere", 33),
|
||||
("Dilute Rotfuchs", "aa CC dd ee GG pp spsp rere", 34),
|
||||
("Dilute Polarfuchs", "AA CC dd ee gg PP spsp rere", 35),
|
||||
};
|
||||
|
||||
var rows = new ColorVariety[catalog.Length];
|
||||
for (int i = 0; i < catalog.Length; i++)
|
||||
{
|
||||
rows[i] = new ColorVariety
|
||||
{
|
||||
// Stable, deterministic GUIDs — ID = array_index + 1 (never reorder existing entries).
|
||||
Id = new Guid($"00000000-0000-0000-0000-{(i + 1):D12}"),
|
||||
Name = catalog[i].Name,
|
||||
CanonicalGenotype = catalog[i].Genotype,
|
||||
SortOrder = catalog[i].SortOrder,
|
||||
};
|
||||
}
|
||||
modelBuilder.Entity<ColorVariety>().HasData(rows);
|
||||
}
|
||||
}
|
||||
|
||||
16
GerbilManagerWebAPI/Cms/PublicSiteOptions.cs
Normal file
16
GerbilManagerWebAPI/Cms/PublicSiteOptions.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace GerbilManagerWebAPI.Cms
|
||||
{
|
||||
/// <summary>
|
||||
/// WEB-2: path where POST /api/publish writes the rendered static site.
|
||||
/// Env var: PublicSite__RootPath (empty = publish disabled, returns 503).
|
||||
/// In production this volume is shared with the publicsite-nginx container.
|
||||
/// </summary>
|
||||
public sealed class PublicSiteOptions
|
||||
{
|
||||
public const string SectionName = "PublicSite";
|
||||
|
||||
public string? RootPath { get; set; }
|
||||
|
||||
public bool IsConfigured => !string.IsNullOrWhiteSpace(RootPath);
|
||||
}
|
||||
}
|
||||
667
GerbilManagerWebAPI/Cms/SiteRenderer.cs
Normal file
667
GerbilManagerWebAPI/Cms/SiteRenderer.cs
Normal file
@@ -0,0 +1,667 @@
|
||||
// System.Net.WebUtility intentionally NOT used — its HtmlEncode encodes all non-ASCII
|
||||
// characters as named entities (e.g. ä→ä), which is wrong for UTF-8 pages.
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GerbilManagerWebAPI.Cms
|
||||
{
|
||||
/// <summary>
|
||||
/// WEB-1: Deterministic static site renderer. Consumes a CMS snapshot (from
|
||||
/// SiteSnapshotService.BuildAsync) and produces a flat path→content dictionary
|
||||
/// ready for upload to Cloudflare Pages. No external dependencies; Markdown
|
||||
/// support handles the common subset used in RichText blocks.
|
||||
/// </summary>
|
||||
public static class SiteRenderer
|
||||
{
|
||||
public const string SiteName = "Kleine Chaoten";
|
||||
public const string CssPath = "assets/site.css";
|
||||
|
||||
// ── Public entry point ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Renders the snapshot to a flat file map (relative-path → file-content).
|
||||
/// Only <c>Published</c> pages are included. The "start" page maps to
|
||||
/// <c>index.html</c>; all others to <c>{slug}/index.html</c>.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, string> Render(JsonObject snapshot)
|
||||
{
|
||||
var files = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
files[CssPath] = SiteCss();
|
||||
|
||||
var navSlugs = NavSlugs(snapshot);
|
||||
var pages = snapshot["pages"] as JsonArray ?? [];
|
||||
|
||||
foreach (var node in pages)
|
||||
{
|
||||
if (node is not JsonObject page) continue;
|
||||
if (Str(page["status"]) != "Published") continue;
|
||||
|
||||
var slug = Str(page["slug"]) ?? "page";
|
||||
var title = Str(page["title"]) ?? slug;
|
||||
var seoDes = Str(page["seoDescription"]);
|
||||
var blocks = page["blocks"] as JsonArray ?? [];
|
||||
|
||||
var cssHref = slug == "start" ? CssPath : $"../{CssPath}";
|
||||
var body = RenderBlocks(blocks, slug);
|
||||
var html = PageHtml(title, seoDes, body, navSlugs, slug, cssHref);
|
||||
var path = slug == "start" ? "index.html" : $"{slug}/index.html";
|
||||
files[path] = html;
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
// ── Block rendering ──────────────────────────────────────────────────
|
||||
|
||||
private static string RenderBlocks(JsonArray blocks, string currentSlug)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var node in blocks.OrderBy(b => b?["order"]?.GetValue<int>() ?? 0))
|
||||
{
|
||||
if (node is not JsonObject b) continue;
|
||||
var type = Str(b["type"]);
|
||||
var data = b["data"] as JsonObject ?? [];
|
||||
sb.Append(type switch
|
||||
{
|
||||
"Heading" => RenderHeading(data),
|
||||
"RichText" => RenderRichText(data),
|
||||
"Image" => RenderImage(data),
|
||||
"Gallery" => RenderGallery(data),
|
||||
"ContactInfo" => RenderContactInfo(data),
|
||||
"AbgabetiereList" => RenderAbgabetiereList(data),
|
||||
_ => string.Empty,
|
||||
});
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string RenderHeading(JsonObject d)
|
||||
{
|
||||
var level = d["level"]?.GetValue<int>() ?? 2;
|
||||
level = Math.Clamp(level, 1, 6);
|
||||
var text = H(Str(d["text"]) ?? "");
|
||||
return $"\n<h{level} class=\"cms-heading\">{text}</h{level}>\n";
|
||||
}
|
||||
|
||||
private static string RenderRichText(JsonObject d)
|
||||
{
|
||||
var md = Str(d["markdown"]) ?? "";
|
||||
return $"\n<div class=\"cms-richtext\">{MarkdownToHtml(md)}</div>\n";
|
||||
}
|
||||
|
||||
private static string RenderImage(JsonObject d)
|
||||
{
|
||||
var url = H(Str(d["url"]) ?? "");
|
||||
var alt = H(Str(d["alt"]) ?? "");
|
||||
return $"\n<figure class=\"cms-image\"><img src=\"{url}\" alt=\"{alt}\" loading=\"lazy\"></figure>\n";
|
||||
}
|
||||
|
||||
private static string RenderGallery(JsonObject d)
|
||||
{
|
||||
var images = d["images"] as JsonArray ?? [];
|
||||
var sb = new StringBuilder("\n<div class=\"cms-gallery\">\n");
|
||||
foreach (var img in images)
|
||||
{
|
||||
if (img is not JsonObject o) continue;
|
||||
var url = H(Str(o["url"]) ?? "");
|
||||
var alt = H(Str(o["alt"]) ?? "");
|
||||
sb.Append($" <figure class=\"cms-gallery-item\"><img src=\"{url}\" alt=\"{alt}\" loading=\"lazy\"></figure>\n");
|
||||
}
|
||||
sb.Append("</div>\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string RenderContactInfo(JsonObject d)
|
||||
{
|
||||
var sb = new StringBuilder("\n<address class=\"cms-contact\">\n");
|
||||
void Row(string? val, string icon) { if (!string.IsNullOrWhiteSpace(val)) sb.AppendLine($" <span class=\"cms-contact-row\">{icon} {H(val)}</span>"); }
|
||||
Row(Str(d["name"]), "👤");
|
||||
Row(Str(d["address"]), "📍");
|
||||
var phone = Str(d["phone"]);
|
||||
if (!string.IsNullOrWhiteSpace(phone))
|
||||
sb.AppendLine($" <span class=\"cms-contact-row\">📞 <a href=\"tel:{H(phone)}\">{H(phone)}</a></span>");
|
||||
var email = Str(d["email"]);
|
||||
if (!string.IsNullOrWhiteSpace(email))
|
||||
sb.AppendLine($" <span class=\"cms-contact-row\">✉️ <a href=\"mailto:{H(email)}\">{H(email)}</a></span>");
|
||||
sb.Append("</address>\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string RenderAbgabetiereList(JsonObject d)
|
||||
{
|
||||
var intro = Str(d["intro"]);
|
||||
var animals = (d["animals"] as JsonArray ?? [])
|
||||
.OfType<JsonObject>().ToList();
|
||||
var sb = new StringBuilder("\n<section class=\"cms-abgabe\">\n");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(intro))
|
||||
sb.Append($" <p class=\"cms-abgabe-intro\">{H(intro)}</p>\n");
|
||||
|
||||
if (animals.Count == 0)
|
||||
{
|
||||
sb.Append(" <p class=\"cms-abgabe-empty\">Zurzeit stehen keine Tiere zur Abgabe bereit.</p>\n");
|
||||
sb.Append("</section>\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// Group by Becken name; null group → solo card keyed by animal name
|
||||
var groups = animals
|
||||
.GroupBy(a => Str(a["group"]) ?? ("__solo__" + (Str(a["name"]) ?? "")))
|
||||
.ToList();
|
||||
|
||||
sb.Append(" <div class=\"cms-group-list\">\n");
|
||||
foreach (var grp in groups)
|
||||
{
|
||||
var isSolo = grp.Key.StartsWith("__solo__", StringComparison.Ordinal);
|
||||
var groupName = isSolo ? null : grp.Key;
|
||||
var memberList = grp.ToList();
|
||||
|
||||
// Determine group gender label
|
||||
var genders = memberList.Select(a => Str(a["gender"]) ?? "unknown").Distinct().ToList();
|
||||
string genderLabel = genders.Count == 1
|
||||
? genders[0] switch { "female" => "Weibchen", "male" => "Männchen", _ => "" }
|
||||
: genders.Any(g => g != "unknown") ? "Gemischt" : "";
|
||||
|
||||
sb.Append(" <article class=\"cms-group-card\">\n");
|
||||
sb.Append(" <div class=\"cms-group-header\">\n");
|
||||
if (!string.IsNullOrEmpty(groupName))
|
||||
sb.Append($" <h3 class=\"cms-group-name\">{H(groupName)}</h3>\n");
|
||||
var metaParts = new List<string>();
|
||||
if (!string.IsNullOrEmpty(genderLabel)) metaParts.Add(H(genderLabel));
|
||||
metaParts.Add("<span class=\"cms-badge cms-badge--free\">Verfügbar</span>");
|
||||
sb.Append($" <span class=\"cms-group-meta\">{string.Join(" · ", metaParts)}</span>\n");
|
||||
sb.Append(" </div>\n");
|
||||
|
||||
// Gallery: first photo of each animal in the group
|
||||
var gallery = memberList
|
||||
.Select(a => (
|
||||
url: (a["photos"] as JsonArray ?? []).FirstOrDefault()?.GetValue<string>(),
|
||||
name: Str(a["name"]) ?? ""))
|
||||
.Where(x => !string.IsNullOrEmpty(x.url))
|
||||
.ToList();
|
||||
if (gallery.Count > 0)
|
||||
{
|
||||
sb.Append(" <div class=\"cms-group-gallery\">\n");
|
||||
foreach (var (url, name) in gallery)
|
||||
sb.Append($" <img src=\"{H(url)}\" alt=\"{H(name)}\" loading=\"lazy\" class=\"cms-group-photo\">\n");
|
||||
sb.Append(" </div>\n");
|
||||
}
|
||||
|
||||
// Per-animal detail rows
|
||||
sb.Append(" <ul class=\"cms-animal-list\">\n");
|
||||
foreach (var a in memberList)
|
||||
{
|
||||
var name = H(Str(a["name"]) ?? "");
|
||||
var farbe = Str(a["farbschlag"]);
|
||||
var dob = Str(a["dateOfBirth"]);
|
||||
var note = Str(a["characterNote"]);
|
||||
var saleText = Str(a["aiSaleText"]);
|
||||
|
||||
sb.Append(" <li class=\"cms-animal-row\">\n");
|
||||
sb.Append($" <span class=\"cms-animal-name\">{name}</span>\n");
|
||||
var details = new List<string>();
|
||||
if (!string.IsNullOrEmpty(farbe)) details.Add(H(farbe));
|
||||
if (!string.IsNullOrEmpty(dob) && DateOnly.TryParse(dob, out var dob2))
|
||||
details.Add($"* {dob2.ToString("MMMM yyyy", System.Globalization.CultureInfo.GetCultureInfo("de-DE"))}");
|
||||
if (details.Count > 0)
|
||||
sb.Append($" <span class=\"cms-animal-detail\">{string.Join(" · ", details)}</span>\n");
|
||||
var personalityText = note ?? saleText;
|
||||
if (!string.IsNullOrWhiteSpace(personalityText))
|
||||
sb.Append($" <span class=\"cms-animal-note\">{H(personalityText)}</span>\n");
|
||||
sb.Append(" </li>\n");
|
||||
}
|
||||
sb.Append(" </ul>\n");
|
||||
sb.Append(" </article>\n");
|
||||
}
|
||||
sb.Append(" </div>\n");
|
||||
|
||||
sb.Append("</section>\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ── Page template ────────────────────────────────────────────────────
|
||||
|
||||
private static string PageHtml(
|
||||
string title, string? seoDesc, string body,
|
||||
IReadOnlyList<string> navSlugs, string currentSlug, string cssHref)
|
||||
{
|
||||
var fullTitle = H($"{title} | {SiteName}");
|
||||
var metaDesc = seoDesc is null ? ""
|
||||
: $"\n <meta name=\"description\" content=\"{H(seoDesc)}\">";
|
||||
|
||||
var navBase = currentSlug == "start" ? "" : "../";
|
||||
var navHtml = BuildNav(navSlugs, currentSlug, navBase);
|
||||
|
||||
return $"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">{metaDesc}
|
||||
<title>{fullTitle}</title>
|
||||
<link rel="stylesheet" href="{H(cssHref)}">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<a href="{navBase}index.html" class="site-logo">🐭 {H(SiteName)}</a>
|
||||
<nav class="site-nav" aria-label="Hauptnavigation">
|
||||
{navHtml}
|
||||
</nav>
|
||||
</header>
|
||||
<main class="site-main">
|
||||
{body}
|
||||
</main>
|
||||
<footer class="site-footer">
|
||||
<p>© {H(SiteName)} — Mongolische Rennmäuse</p>
|
||||
<nav class="site-footer-nav" aria-label="Rechtliches">
|
||||
<a href="{navBase}impressum/index.html" class="site-footer-link">Impressum</a>
|
||||
<a href="{navBase}datenschutz/index.html" class="site-footer-link">Datenschutz</a>
|
||||
</nav>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
""";
|
||||
}
|
||||
|
||||
private static string BuildNav(IReadOnlyList<string> slugs, string current, string navBase)
|
||||
{
|
||||
var labels = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["start"] = "Start",
|
||||
["ueber-die-zucht"] = "Über die Zucht",
|
||||
["abgabetiere"] = "Abgabetiere",
|
||||
["abgabebedingungen"] = "Abgabebedingungen",
|
||||
["farben-genetik"] = "Farben & Genetik",
|
||||
["kontakt"] = "Kontakt",
|
||||
["impressum"] = "Impressum",
|
||||
["datenschutz"] = "Datenschutz",
|
||||
};
|
||||
|
||||
var sb = new StringBuilder();
|
||||
foreach (var slug in slugs)
|
||||
{
|
||||
var label = labels.TryGetValue(slug, out var l) ? l : slug;
|
||||
var href = slug == "start" ? $"{navBase}index.html" : $"{navBase}{slug}/index.html";
|
||||
var active = slug == current ? " aria-current=\"page\"" : "";
|
||||
sb.AppendLine($" <a href=\"{H(href)}\" class=\"site-nav-link\"{active}>{H(label)}</a>");
|
||||
}
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
// ── Minimal Markdown → HTML ──────────────────────────────────────────
|
||||
|
||||
internal static string MarkdownToHtml(string md)
|
||||
{
|
||||
if (string.IsNullOrEmpty(md)) return "";
|
||||
var lines = md.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n');
|
||||
var sb = new StringBuilder();
|
||||
var inList = false;
|
||||
var isOrdered = false;
|
||||
|
||||
void CloseList()
|
||||
{
|
||||
if (!inList) return;
|
||||
sb.AppendLine(isOrdered ? "</ol>" : "</ul>");
|
||||
inList = false;
|
||||
}
|
||||
|
||||
foreach (var raw in lines)
|
||||
{
|
||||
var line = raw;
|
||||
|
||||
// ATX headings
|
||||
var hm = Regex.Match(line, @"^(#{1,6})\s+(.+)$");
|
||||
if (hm.Success)
|
||||
{
|
||||
CloseList();
|
||||
var lvl = hm.Groups[1].Length;
|
||||
sb.AppendLine($"<h{lvl}>{InlineHtml(hm.Groups[2].Value)}</h{lvl}>");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unordered list
|
||||
var ulm = Regex.Match(line, @"^[-*+]\s+(.+)$");
|
||||
if (ulm.Success)
|
||||
{
|
||||
if (!inList || isOrdered) { CloseList(); sb.AppendLine("<ul>"); inList = true; isOrdered = false; }
|
||||
sb.AppendLine($"<li>{InlineHtml(ulm.Groups[1].Value)}</li>");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered list
|
||||
var olm = Regex.Match(line, @"^\d+\.\s+(.+)$");
|
||||
if (olm.Success)
|
||||
{
|
||||
if (!inList || !isOrdered) { CloseList(); sb.AppendLine("<ol>"); inList = true; isOrdered = true; }
|
||||
sb.AppendLine($"<li>{InlineHtml(olm.Groups[1].Value)}</li>");
|
||||
continue;
|
||||
}
|
||||
|
||||
CloseList();
|
||||
|
||||
// Blank line
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
sb.AppendLine();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
if (Regex.IsMatch(line, @"^(-{3,}|\*{3,}|_{3,})$"))
|
||||
{
|
||||
sb.AppendLine("<hr>");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular paragraph
|
||||
sb.AppendLine($"<p>{InlineHtml(line)}</p>");
|
||||
}
|
||||
|
||||
CloseList();
|
||||
return sb.ToString().Trim();
|
||||
}
|
||||
|
||||
private static string InlineHtml(string text)
|
||||
{
|
||||
// First HTML-encode, then selectively un-encode our safe inline patterns
|
||||
// to avoid encoding the tags we are about to add.
|
||||
// Order matters: bold before italic.
|
||||
text = H(text);
|
||||
// Bold **text**
|
||||
text = Regex.Replace(text, @"\*\*(.+?)\*\*", "<strong>$1</strong>");
|
||||
// Italic *text* (single, not preceded/followed by *)
|
||||
text = Regex.Replace(text, @"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", "<em>$1</em>");
|
||||
// Inline code `text`
|
||||
text = Regex.Replace(text, @"`(.+?)`", "<code>$1</code>");
|
||||
// Links [text](url) — note: url is already HTML-encoded by H()
|
||||
text = Regex.Replace(text, @"\[(.+?)\]\((.+?)\)", "<a href=\"$2\">$1</a>");
|
||||
return text;
|
||||
}
|
||||
|
||||
// ── CSS ──────────────────────────────────────────────────────────────
|
||||
|
||||
internal static string SiteCss() => """
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--color-bg: #fdf7f0;
|
||||
--color-surface: #fff;
|
||||
--color-surface-warm: #fef9f4;
|
||||
--color-text: #2a2118;
|
||||
--color-muted: #7a6d62;
|
||||
--color-accent: #b5331a;
|
||||
--color-accent-hover: #8f2815;
|
||||
--color-accent-soft: #fdf0ed;
|
||||
--color-border: #e8ddd4;
|
||||
--color-badge-free: #2d7a4a;
|
||||
--color-badge-free-bg: #e6f4ec;
|
||||
--font-body: Georgia, "Times New Roman", serif;
|
||||
--font-ui: system-ui, -apple-system, sans-serif;
|
||||
--max-w: 900px;
|
||||
--radius: 10px;
|
||||
}
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
line-height: 1.7;
|
||||
font-size: 1rem;
|
||||
}
|
||||
a { color: var(--color-accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; color: var(--color-accent-hover); }
|
||||
img { max-width: 100%; height: auto; display: block; }
|
||||
p { margin-bottom: .75rem; }
|
||||
|
||||
/* ── Header ── */
|
||||
.site-header {
|
||||
background: var(--color-surface);
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
min-height: 56px;
|
||||
}
|
||||
.site-logo {
|
||||
font-family: var(--font-body);
|
||||
font-weight: 700;
|
||||
font-size: 1.15rem;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: 1.5rem;
|
||||
border-right: 1px solid var(--color-border);
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.site-logo:hover { color: var(--color-accent); text-decoration: none; }
|
||||
.site-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
padding-left: .5rem;
|
||||
}
|
||||
.site-nav::-webkit-scrollbar { display: none; }
|
||||
.site-nav-link {
|
||||
font-family: var(--font-ui);
|
||||
font-size: .875rem;
|
||||
color: var(--color-muted);
|
||||
padding: .5rem .65rem;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
transition: color .15s, background .15s;
|
||||
}
|
||||
.site-nav-link:hover { color: var(--color-accent); background: var(--color-accent-soft); text-decoration: none; }
|
||||
.site-nav-link[aria-current="page"] {
|
||||
color: var(--color-accent);
|
||||
background: var(--color-accent-soft);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Main ── */
|
||||
.site-main {
|
||||
max-width: var(--max-w);
|
||||
margin: 2.5rem auto;
|
||||
padding: 0 1rem 4rem;
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
.site-footer {
|
||||
background: var(--color-surface);
|
||||
border-top: 2px solid var(--color-border);
|
||||
padding: 1.75rem 1rem;
|
||||
text-align: center;
|
||||
font-family: var(--font-ui);
|
||||
font-size: .85rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
.site-footer-nav { margin-top: .6rem; display: flex; justify-content: center; gap: 1.25rem; }
|
||||
.site-footer-link { color: var(--color-muted); font-size: .8rem; }
|
||||
.site-footer-link:hover { color: var(--color-accent); }
|
||||
|
||||
/* ── CMS blocks ── */
|
||||
.cms-heading { margin: 2rem 0 .6rem; line-height: 1.3; }
|
||||
h1.cms-heading { font-size: 2rem; margin-top: 0; }
|
||||
h2.cms-heading { font-size: 1.5rem; }
|
||||
h3.cms-heading { font-size: 1.2rem; }
|
||||
.cms-richtext { margin: 1rem 0; }
|
||||
.cms-richtext p { margin-bottom: .75rem; }
|
||||
.cms-richtext ul, .cms-richtext ol { margin: .5rem 0 .75rem 1.5rem; }
|
||||
.cms-richtext li { margin-bottom: .35rem; }
|
||||
.cms-richtext hr { border: none; border-top: 1px solid var(--color-border); margin: 1.5rem 0; }
|
||||
.cms-image { margin: 1.75rem 0; }
|
||||
.cms-image img { border-radius: var(--radius); box-shadow: 0 2px 8px rgba(0,0,0,.08); }
|
||||
.cms-gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: .75rem;
|
||||
margin: 1.75rem 0;
|
||||
}
|
||||
.cms-gallery-item img { border-radius: 6px; aspect-ratio: 1; object-fit: cover; }
|
||||
.cms-contact {
|
||||
font-style: normal;
|
||||
font-family: var(--font-ui);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .5rem;
|
||||
margin: 1.25rem 0;
|
||||
background: var(--color-surface-warm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.cms-contact-row { display: flex; gap: .6rem; align-items: flex-start; }
|
||||
|
||||
/* ── Abgabetiere — group cards ── */
|
||||
.cms-abgabe { margin: 1.25rem 0; }
|
||||
.cms-abgabe-intro { margin-bottom: 1.5rem; font-size: 1.05rem; }
|
||||
.cms-abgabe-empty { color: var(--color-muted); font-style: italic; }
|
||||
|
||||
.cms-group-list { display: flex; flex-direction: column; gap: 2rem; }
|
||||
|
||||
.cms-group-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,.06);
|
||||
}
|
||||
.cms-group-header {
|
||||
padding: 1rem 1.25rem .75rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: .5rem .75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-surface-warm);
|
||||
}
|
||||
.cms-group-name {
|
||||
font-family: var(--font-body);
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.cms-group-meta {
|
||||
font-family: var(--font-ui);
|
||||
font-size: .875rem;
|
||||
color: var(--color-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
/* availability badge */
|
||||
.cms-badge {
|
||||
display: inline-block;
|
||||
font-size: .75rem;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-ui);
|
||||
padding: .2rem .55rem;
|
||||
border-radius: 20px;
|
||||
letter-spacing: .02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.cms-badge--free {
|
||||
background: var(--color-badge-free-bg);
|
||||
color: var(--color-badge-free);
|
||||
}
|
||||
|
||||
/* group photo strip */
|
||||
.cms-group-gallery {
|
||||
display: flex;
|
||||
gap: .375rem;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
padding: .75rem 1.25rem;
|
||||
background: #f5ede4;
|
||||
}
|
||||
.cms-group-gallery::-webkit-scrollbar { display: none; }
|
||||
.cms-group-photo {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* animal detail list */
|
||||
.cms-animal-list {
|
||||
list-style: none;
|
||||
padding: .75rem 1.25rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .875rem;
|
||||
}
|
||||
.cms-animal-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .15rem;
|
||||
padding-bottom: .875rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.cms-animal-row:last-child { border-bottom: none; padding-bottom: 0; }
|
||||
.cms-animal-name {
|
||||
font-family: var(--font-body);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.cms-animal-detail {
|
||||
font-family: var(--font-ui);
|
||||
font-size: .875rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
.cms-animal-note {
|
||||
font-style: italic;
|
||||
font-size: .925rem;
|
||||
color: var(--color-text);
|
||||
margin-top: .1rem;
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 600px) {
|
||||
.site-header { padding: 0 .75rem; min-height: 50px; }
|
||||
.site-logo { font-size: 1rem; padding-right: 1rem; }
|
||||
h1.cms-heading { font-size: 1.5rem; }
|
||||
.site-main { margin-top: 1.5rem; }
|
||||
.cms-group-photo { width: 110px; height: 110px; }
|
||||
}
|
||||
@media (min-width: 700px) {
|
||||
.cms-animal-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem; }
|
||||
.cms-animal-row { border-bottom: none; padding-bottom: 0; border: 1px solid var(--color-border); border-radius: 8px; padding: .75rem; }
|
||||
}
|
||||
""";
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
private static IReadOnlyList<string> NavSlugs(JsonObject snapshot)
|
||||
{
|
||||
var navArr = snapshot["site"]?["navOrder"] as JsonArray;
|
||||
if (navArr is null) return [];
|
||||
return navArr.Select(n => n?.GetValue<string>()).Where(s => s is not null).ToList()!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HTML-encode only the 5 HTML special characters. Non-ASCII characters (e.g. German
|
||||
/// umlauts) are left as-is — the page declares UTF-8, so no entity encoding needed.
|
||||
/// </summary>
|
||||
private static string H(string? s)
|
||||
{
|
||||
if (s is null) return "";
|
||||
return s.Replace("&", "&")
|
||||
.Replace("<", "<")
|
||||
.Replace(">", ">")
|
||||
.Replace("\"", """)
|
||||
.Replace("'", "'");
|
||||
}
|
||||
|
||||
/// <summary>Extract a string value from a JsonNode; returns null if not a string.</summary>
|
||||
private static string? Str(JsonNode? node) =>
|
||||
node is JsonValue v && v.TryGetValue<string>(out var s) ? s : null;
|
||||
}
|
||||
}
|
||||
130
GerbilManagerWebAPI/Cms/SiteSnapshotService.cs
Normal file
130
GerbilManagerWebAPI/Cms/SiteSnapshotService.cs
Normal file
@@ -0,0 +1,130 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Cms
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the full CMS snapshot consumed by the static site renderer (WEB-1). The
|
||||
/// abgabetiere page's auto AbgabetiereList block is resolved here from live ForSale
|
||||
/// animals — nothing private is ever exposed (publish = snapshot of public content).
|
||||
/// </summary>
|
||||
public sealed class SiteSnapshotService
|
||||
{
|
||||
private readonly ApplicationContext _db;
|
||||
|
||||
public SiteSnapshotService(ApplicationContext db) => _db = db;
|
||||
|
||||
public async Task<JsonObject> BuildAsync()
|
||||
{
|
||||
var site = await _db.Sites.AsNoTracking().FirstOrDefaultAsync()
|
||||
?? new Site { Id = Site.SingletonId };
|
||||
var pages = await _db.Pages.AsNoTracking().Include(p => p.Blocks).ToListAsync();
|
||||
var pageById = pages.ToDictionary(p => p.Id);
|
||||
|
||||
var navOrder = new JsonArray();
|
||||
foreach (var id in site.NavOrder)
|
||||
if (pageById.TryGetValue(id, out var p))
|
||||
navOrder.Add(p.Slug);
|
||||
|
||||
JsonArray? forSale = null; // resolved lazily, reused across blocks
|
||||
|
||||
int NavIndex(Guid id) { var i = site.NavOrder.IndexOf(id); return i < 0 ? int.MaxValue : i; }
|
||||
|
||||
var pagesArr = new JsonArray();
|
||||
foreach (var page in pages.OrderBy(p => NavIndex(p.Id)).ThenBy(p => p.Slug))
|
||||
{
|
||||
var blocks = new JsonArray();
|
||||
foreach (var b in page.Blocks.OrderBy(b => b.Order))
|
||||
{
|
||||
var data = JsonNode.Parse(string.IsNullOrWhiteSpace(b.Data) ? "{}" : b.Data) as JsonObject
|
||||
?? new JsonObject();
|
||||
if (b.Type == BlockType.AbgabetiereList && AsString(data["mode"]) == "auto")
|
||||
{
|
||||
forSale ??= await BuildForSaleAsync();
|
||||
data["animals"] = forSale.DeepClone();
|
||||
}
|
||||
blocks.Add(new JsonObject
|
||||
{
|
||||
["id"] = b.Id.ToString(),
|
||||
["order"] = b.Order,
|
||||
["type"] = b.Type.ToString(),
|
||||
["data"] = data,
|
||||
});
|
||||
}
|
||||
pagesArr.Add(new JsonObject
|
||||
{
|
||||
["slug"] = page.Slug,
|
||||
["title"] = page.Title,
|
||||
["seoDescription"] = page.SeoDescription,
|
||||
["status"] = page.Status.ToString(),
|
||||
["blocks"] = blocks,
|
||||
});
|
||||
}
|
||||
|
||||
return new JsonObject
|
||||
{
|
||||
["site"] = new JsonObject
|
||||
{
|
||||
["defaultLocale"] = site.DefaultLocale,
|
||||
["navOrder"] = navOrder,
|
||||
},
|
||||
["pages"] = pagesArr,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<JsonArray> BuildForSaleAsync()
|
||||
{
|
||||
var animals = await _db.Gerbils.AsNoTracking()
|
||||
.Where(g => g.Status == GerbilStatus.ForSale)
|
||||
.OrderBy(g => g.Name)
|
||||
.Select(g => new
|
||||
{
|
||||
g.Id,
|
||||
g.Name,
|
||||
g.Gender,
|
||||
g.DateOfBirth,
|
||||
g.CharacterNote,
|
||||
Farbschlag = g.ColorVariety != null ? g.ColorVariety.Name : null,
|
||||
// group = Becken (enclosure) name — matches how the Abgabe composer
|
||||
// groups ForSale animals by enclosure-mates (god ruling). null = single.
|
||||
Group = g.Enclosure != null ? g.Enclosure.Name : null,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var ids = animals.Select(a => a.Id).ToList();
|
||||
var photos = await _db.GerbilPhotos.AsNoTracking()
|
||||
.Where(p => ids.Contains(p.GerbilId))
|
||||
.OrderBy(p => p.SortOrder)
|
||||
.Select(p => new { p.GerbilId, p.FileName })
|
||||
.ToListAsync();
|
||||
var photosByGerbil = photos
|
||||
.GroupBy(p => p.GerbilId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(x => $"/photos/files/{x.FileName}").ToList());
|
||||
|
||||
var arr = new JsonArray();
|
||||
foreach (var a in animals)
|
||||
{
|
||||
var photoArr = new JsonArray();
|
||||
if (photosByGerbil.TryGetValue(a.Id, out var urls))
|
||||
foreach (var u in urls) photoArr.Add(u);
|
||||
|
||||
arr.Add(new JsonObject
|
||||
{
|
||||
["name"] = a.Name,
|
||||
["gender"] = a.Gender.ToString(),
|
||||
["dateOfBirth"] = a.DateOfBirth?.ToString("yyyy-MM-dd"),
|
||||
["characterNote"] = a.CharacterNote,
|
||||
["farbschlag"] = a.Farbschlag,
|
||||
["group"] = a.Group,
|
||||
["photos"] = photoArr,
|
||||
["aiSaleText"] = null, // populated by WEB-5 once a Gemini key exists
|
||||
});
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
private static string? AsString(JsonNode? node) =>
|
||||
node is JsonValue v && v.TryGetValue<string>(out var s) ? s : null;
|
||||
}
|
||||
}
|
||||
57
GerbilManagerWebAPI/Common/PagedResult.cs
Normal file
57
GerbilManagerWebAPI/Common/PagedResult.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using Gridify;
|
||||
using Gridify.EntityFramework;
|
||||
|
||||
namespace GerbilManagerWebAPI.Common
|
||||
{
|
||||
/// <summary>Standard paged list envelope returned by every list endpoint.</summary>
|
||||
public record PagedResult<T>(IReadOnlyList<T> Items, int TotalCount, int Page, int PageSize);
|
||||
|
||||
/// <summary>
|
||||
/// Query-string parameters for list endpoints. All optional (nullable) so the
|
||||
/// frontend can call a list with no params; Gridify's own GridifyQuery has
|
||||
/// non-nullable int Page/PageSize which [AsParameters] would make REQUIRED.
|
||||
/// Bound via [AsParameters]: ?filter=…&orderBy=…&page=1&pageSize=20.
|
||||
/// </summary>
|
||||
public class GridifyParams
|
||||
{
|
||||
public string? Filter { get; set; }
|
||||
public string? OrderBy { get; set; }
|
||||
public int? Page { get; set; }
|
||||
public int? PageSize { get; set; }
|
||||
|
||||
public GridifyQuery ToQuery() => new()
|
||||
{
|
||||
Filter = NormalizeFilter(Filter),
|
||||
OrderBy = OrderBy,
|
||||
Page = Page is > 0 ? Page.Value : 1,
|
||||
PageSize = PageSize is > 0 ? PageSize.Value : 20,
|
||||
};
|
||||
|
||||
// The frontend's gridify.ts emits "==" for equals (its documented convention),
|
||||
// but Gridify's equals operator is a single "=". Translate "==" -> "=". This is
|
||||
// safe because the frontend backslash-escapes any "=" inside values, and the
|
||||
// other operators it uses ( != >= <= =* ) contain no literal "==".
|
||||
private static string? NormalizeFilter(string? filter) =>
|
||||
string.IsNullOrEmpty(filter) ? filter : filter.Replace("==", "=");
|
||||
}
|
||||
|
||||
public static class QueryableExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Apply a Gridify query (filter/order/page) to an EF query and project each
|
||||
/// row to a DTO, returning the standard paged envelope. Filter/orderBy names
|
||||
/// are the ENTITY property names (case-insensitive), e.g. "status==Active",
|
||||
/// "orderBy=dateOfBirth", "litterId==…".
|
||||
/// </summary>
|
||||
public static async Task<PagedResult<TDto>> ToPagedResultAsync<TEntity, TDto>(
|
||||
this IQueryable<TEntity> source,
|
||||
GridifyParams parameters,
|
||||
Func<TEntity, TDto> map)
|
||||
{
|
||||
var query = parameters.ToQuery();
|
||||
Paging<TEntity> paging = await source.GridifyAsync(query);
|
||||
var items = paging.Data.Select(map).ToList();
|
||||
return new PagedResult<TDto>(items, paging.Count, query.Page, query.PageSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
29
GerbilManagerWebAPI/Contracts/BreederProfile.cs
Normal file
29
GerbilManagerWebAPI/Contracts/BreederProfile.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
namespace GerbilManagerWebAPI.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Zuchtprofil — der parameterisierte Verkäufer-Block des Abgabevertrags.
|
||||
/// Phase A: wird aus der appsettings-Sektion <c>"BreederProfile"</c> gebunden
|
||||
/// (z. B. <c>builder.Configuration.GetSection("BreederProfile").Get<BreederProfile>()</c>);
|
||||
/// eine Settings-UI/Entity folgt in Phase B. Die Vorlage selbst enthält KEINE
|
||||
/// echten Daten (nur {{Platzhalter}}) — Werte kommen ausschließlich von hier.
|
||||
/// </summary>
|
||||
public sealed class BreederProfile
|
||||
{
|
||||
/// <summary>Name der Zucht, z. B. „Zucht der Kleinen Chaoten“.</summary>
|
||||
public string ZuchtName { get; set; } = "";
|
||||
|
||||
/// <summary>Vor- und Nachname inkl. Anrede, z. B. „Frau Erika Muster“.</summary>
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
/// <summary>Anschrift einzeilig: „Straße Nr, PLZ Ort“.</summary>
|
||||
public string Address { get; set; } = "";
|
||||
|
||||
public string Phone { get; set; } = "";
|
||||
|
||||
public string Email { get; set; } = "";
|
||||
|
||||
public string Homepage { get; set; } = "";
|
||||
|
||||
/// <summary>Ort für die Unterschriftszeile („{Ort}, den {Datum}“).</summary>
|
||||
public string City { get; set; } = "";
|
||||
}
|
||||
37
GerbilManagerWebAPI/Contracts/ContractData.cs
Normal file
37
GerbilManagerWebAPI/Contracts/ContractData.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
namespace GerbilManagerWebAPI.Contracts;
|
||||
|
||||
/// <summary>Käufer/Abnehmer-Block des Abgabevertrags.</summary>
|
||||
/// <param name="Name">Vor- und Nachname inkl. Anrede, z. B. „Herr Max Muster“.</param>
|
||||
/// <param name="Address">Anschrift einzeilig: „Straße Nr, PLZ Ort“.</param>
|
||||
public sealed record ContractBuyer(
|
||||
string Name,
|
||||
string Address,
|
||||
string? Phone = null,
|
||||
string? Email = null);
|
||||
|
||||
/// <summary>Ein abgegebenes Tier (eine Tabelle im Vertrag pro Tier).</summary>
|
||||
/// <param name="Geschlecht">Deutscher Anzeigetext („Weiblich“/„Männlich“) — die
|
||||
/// Abbildung vom <c>Gender</c>-Enum passiert im Aufrufer (Phase B), der
|
||||
/// Generator bleibt frei von Modell-Abhängigkeiten.</param>
|
||||
/// <param name="Photo">Optionales Tierfoto (Rohbytes der Bilddatei) fürs Vertragsbild;
|
||||
/// nur der PDF-Generator wertet es aus, der .docx-Generator ignoriert es.</param>
|
||||
public sealed record ContractAnimal(
|
||||
string Name,
|
||||
string Geschlecht,
|
||||
DateOnly? Geburtsdatum = null,
|
||||
string? Farbschlag = null,
|
||||
byte[]? Photo = null);
|
||||
|
||||
/// <summary>
|
||||
/// Alle Eingaben des Vertragsgenerators. Reines Daten-Objekt, keine EF-Typen.
|
||||
/// </summary>
|
||||
/// <param name="Price">Kaufpreis in Euro; gerendert als de-DE, z. B. „72,00 €“.</param>
|
||||
/// <param name="HandoverDate">Übergabedatum (Abschnitt 3), Format TT.MM.JJJJ.</param>
|
||||
/// <param name="ContractDate">Datum der Unterschriftszeile; Standard = Übergabedatum.</param>
|
||||
public sealed record ContractData(
|
||||
BreederProfile Seller,
|
||||
ContractBuyer Buyer,
|
||||
IReadOnlyList<ContractAnimal> Animals,
|
||||
decimal Price,
|
||||
DateOnly HandoverDate,
|
||||
DateOnly? ContractDate = null);
|
||||
152
GerbilManagerWebAPI/Contracts/ContractGenerator.cs
Normal file
152
GerbilManagerWebAPI/Contracts/ContractGenerator.cs
Normal file
@@ -0,0 +1,152 @@
|
||||
using System.Globalization;
|
||||
using DocumentFormat.OpenXml;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Wordprocessing;
|
||||
|
||||
namespace GerbilManagerWebAPI.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// FEAT-13: Erzeugt Abgabeverträge (.docx) aus der eingebetteten Vorlage
|
||||
/// (<c>Contracts/Templates/Abgabevertrag.docx</c>, abgeleitet aus dem echten
|
||||
/// Mustervertrag; Boilerplate-Abschnitte 4–8 stehen verbatim in der Vorlage).
|
||||
///
|
||||
/// Reiner Dienst: keine EF-/HTTP-Abhängigkeiten — Eingabe ist ein
|
||||
/// <see cref="ContractData"/>, Ausgabe sind die fertigen .docx-Bytes.
|
||||
/// Die Vorlage enthält pro Wert genau EINEN {{Token}}-Run (beim Vorlagenbau
|
||||
/// zusammengeführt), darum genügt einfache Textersetzung; die Tier-Tabelle
|
||||
/// wird pro Tier geklont (variable Anzahl 1..n).
|
||||
/// </summary>
|
||||
public static class ContractGenerator
|
||||
{
|
||||
private const string TemplateResource = "GerbilManagerWebAPI.Contracts.Templates.Abgabevertrag.docx";
|
||||
|
||||
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
||||
|
||||
/// <summary>Erzeugt den Vertrag mit der eingebetteten Standard-Vorlage.</summary>
|
||||
public static byte[] Generate(ContractData data) => Generate(data, LoadEmbeddedTemplate());
|
||||
|
||||
/// <summary>Erzeugt den Vertrag mit einer expliziten Vorlage (Tests/Sonderfälle).</summary>
|
||||
public static byte[] Generate(ContractData data, byte[] template)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(data);
|
||||
if (data.Animals.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("Ein Abgabevertrag braucht mindestens ein Tier.", nameof(data));
|
||||
}
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
stream.Write(template);
|
||||
|
||||
using (var document = WordprocessingDocument.Open(stream, isEditable: true))
|
||||
{
|
||||
var body = document.MainDocumentPart?.Document.Body
|
||||
?? throw new InvalidOperationException("Vorlage ohne Dokumentrumpf.");
|
||||
|
||||
FillAnimalTables(body, data.Animals);
|
||||
ReplaceTokens(body, GlobalTokens(data));
|
||||
document.MainDocumentPart!.Document.Save();
|
||||
}
|
||||
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Die Vorlage enthält genau eine Tier-Tabelle (mit {{TierName}}). Für
|
||||
/// jedes weitere Tier wird sie samt Abstands-Absatz geklont; danach wird
|
||||
/// jede Tabelle mit den Werten „ihres“ Tieres gefüllt.
|
||||
/// </summary>
|
||||
private static void FillAnimalTables(Body body, IReadOnlyList<ContractAnimal> animals)
|
||||
{
|
||||
var templateTable = body.Descendants<Table>()
|
||||
.Single(t => t.InnerText.Contains("{{TierName}}"));
|
||||
|
||||
var tables = new List<Table> { templateTable };
|
||||
// Abstands-Absatz hinter der Tabelle (Optik wie im Original-Mehrtier-Vertrag).
|
||||
OpenXmlElement anchor = templateTable.NextSibling() is Paragraph spacer
|
||||
? spacer
|
||||
: templateTable;
|
||||
|
||||
for (var i = 1; i < animals.Count; i++)
|
||||
{
|
||||
var clone = (Table)templateTable.CloneNode(deep: true);
|
||||
anchor = anchor.InsertAfterSelf(clone);
|
||||
anchor = anchor.InsertAfterSelf(new Paragraph());
|
||||
tables.Add(clone);
|
||||
}
|
||||
|
||||
for (var i = 0; i < animals.Count; i++)
|
||||
{
|
||||
ReplaceTokens(tables[i], AnimalTokens(animals[i]));
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> GlobalTokens(ContractData data) => new()
|
||||
{
|
||||
["{{ZuchtName}}"] = data.Seller.ZuchtName,
|
||||
["{{VerkaeuferName}}"] = data.Seller.Name,
|
||||
["{{VerkaeuferAdresse}}"] = data.Seller.Address,
|
||||
["{{VerkaeuferTelefon}}"] = data.Seller.Phone,
|
||||
["{{VerkaeuferEmail}}"] = data.Seller.Email,
|
||||
["{{VerkaeuferHomepage}}"] = data.Seller.Homepage,
|
||||
["{{KaeuferName}}"] = data.Buyer.Name,
|
||||
["{{KaeuferAdresse}}"] = data.Buyer.Address,
|
||||
["{{KaeuferTelefon}}"] = data.Buyer.Phone ?? "",
|
||||
["{{KaeuferEmail}}"] = data.Buyer.Email ?? "",
|
||||
["{{Kaufpreis}}"] = FormatPrice(data.Price),
|
||||
["{{Uebergabedatum}}"] = FormatDate(data.HandoverDate),
|
||||
["{{VertragsOrt}}"] = data.Seller.City,
|
||||
["{{VertragsDatum}}"] = FormatDate(data.ContractDate ?? data.HandoverDate),
|
||||
};
|
||||
|
||||
private static Dictionary<string, string> AnimalTokens(ContractAnimal animal) => new()
|
||||
{
|
||||
["{{TierName}}"] = animal.Name,
|
||||
["{{TierGeschlecht}}"] = animal.Geschlecht,
|
||||
["{{TierGeburtsdatum}}"] = animal.Geburtsdatum is { } born ? FormatDate(born) : "",
|
||||
["{{TierFarbschlag}}"] = animal.Farbschlag ?? "",
|
||||
};
|
||||
|
||||
/// <summary>z. B. 72m → „72,00 €“, 1234.5m → „1.234,50 €“.</summary>
|
||||
private static string FormatPrice(decimal price) => price.ToString("N2", German) + " €";
|
||||
|
||||
/// <summary>TT.MM.JJJJ (deutsche Schreibweise, wie im Mustervertrag).</summary>
|
||||
private static string FormatDate(DateOnly date) => date.ToString("dd.MM.yyyy", German);
|
||||
|
||||
/// <summary>
|
||||
/// Ersetzt Tokens in allen Text-Runs unterhalb von <paramref name="root"/>.
|
||||
/// Tokens liegen in der Vorlage garantiert in einzelnen Runs — kein
|
||||
/// Run-übergreifendes Matching nötig.
|
||||
/// </summary>
|
||||
private static void ReplaceTokens(OpenXmlElement root, IReadOnlyDictionary<string, string> tokens)
|
||||
{
|
||||
foreach (var text in root.Descendants<Text>())
|
||||
{
|
||||
if (!text.Text.Contains("{{"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var (token, value) in tokens)
|
||||
{
|
||||
if (text.Text.Contains(token))
|
||||
{
|
||||
text.Text = text.Text.Replace(token, value);
|
||||
}
|
||||
}
|
||||
|
||||
if (text.Text.Length > 0 && (char.IsWhiteSpace(text.Text[0]) || char.IsWhiteSpace(text.Text[^1])))
|
||||
{
|
||||
text.Space = SpaceProcessingModeValues.Preserve;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] LoadEmbeddedTemplate()
|
||||
{
|
||||
using var resource = typeof(ContractGenerator).Assembly.GetManifestResourceStream(TemplateResource)
|
||||
?? throw new InvalidOperationException($"Eingebettete Vorlage fehlt: {TemplateResource}");
|
||||
using var buffer = new MemoryStream();
|
||||
resource.CopyTo(buffer);
|
||||
return buffer.ToArray();
|
||||
}
|
||||
}
|
||||
241
GerbilManagerWebAPI/Contracts/ContractPdfGenerator.cs
Normal file
241
GerbilManagerWebAPI/Contracts/ContractPdfGenerator.cs
Normal file
@@ -0,0 +1,241 @@
|
||||
using System.Globalization;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
|
||||
namespace GerbilManagerWebAPI.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// FEAT-13 (PDF): erzeugt den Abgabevertrag als druckfertiges PDF (A4) aus
|
||||
/// <see cref="ContractData"/> — gleiche Daten wie der .docx-Generator, reiner
|
||||
/// .NET-Code (QuestPDF), keine externen Konverter. Der Fließtext der Abschnitte
|
||||
/// 4–8 entspricht dem Mustervertrag (Vorlage Abgabevertrag.docx).
|
||||
/// </summary>
|
||||
public static class ContractPdfGenerator
|
||||
{
|
||||
static ContractPdfGenerator()
|
||||
{
|
||||
// QuestPDF Community License (kostenlos für Einzelpersonen / kleine Firmen).
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
}
|
||||
|
||||
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
||||
private static string Price(decimal p) => p.ToString("N2", German) + " €";
|
||||
private static string D(DateOnly d) => d.ToString("dd.MM.yyyy", German);
|
||||
private static string Or(string? v, string fallback = "—") => string.IsNullOrWhiteSpace(v) ? fallback : v!;
|
||||
|
||||
private const string Accent = "#A85F2E";
|
||||
private const string Ink = "#3D2E23";
|
||||
private const string Line = "#D9CCB4";
|
||||
|
||||
public static byte[] Generate(ContractData data)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(data);
|
||||
|
||||
return Document.Create(doc =>
|
||||
{
|
||||
doc.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.A4);
|
||||
page.Margin(1.8f, Unit.Centimetre);
|
||||
page.DefaultTextStyle(x => x.FontSize(9.5f).FontColor(Ink).LineHeight(1.25f));
|
||||
|
||||
page.Header().PaddingBottom(8).Column(h =>
|
||||
{
|
||||
h.Item().Text("Schutzvertrag / Kaufvertrag").FontSize(18).Bold().FontColor(Accent);
|
||||
h.Item().Text("Mongolische Rennmäuse").FontSize(10).FontColor("#8C7F6E");
|
||||
});
|
||||
|
||||
page.Content().PaddingTop(6).Column(col =>
|
||||
{
|
||||
col.Spacing(12);
|
||||
Parties(col, data);
|
||||
Animals(col, data);
|
||||
Subject(col, data);
|
||||
Clauses(col);
|
||||
Signatures(col, data);
|
||||
});
|
||||
|
||||
page.Footer().AlignCenter().Text(t =>
|
||||
{
|
||||
t.DefaultTextStyle(x => x.FontSize(8).FontColor("#8C7F6E"));
|
||||
t.Span("Seite ");
|
||||
t.CurrentPageNumber();
|
||||
t.Span(" / ");
|
||||
t.TotalPages();
|
||||
});
|
||||
});
|
||||
}).GeneratePdf();
|
||||
}
|
||||
|
||||
private static void Heading(ColumnDescriptor col, string text) =>
|
||||
col.Item().PaddingTop(4).Text(text).FontSize(12).Bold().FontColor(Accent);
|
||||
|
||||
private static void Kv(ColumnDescriptor col, string label, string value) =>
|
||||
col.Item().Row(r =>
|
||||
{
|
||||
r.ConstantItem(150).Text(label).FontColor("#8C7F6E");
|
||||
r.RelativeItem().Text(value).SemiBold();
|
||||
});
|
||||
|
||||
private static void Bullet(ColumnDescriptor col, string text) =>
|
||||
col.Item().Row(r =>
|
||||
{
|
||||
r.ConstantItem(14).Text("•").FontColor(Accent);
|
||||
r.RelativeItem().Text(text);
|
||||
});
|
||||
|
||||
private static void Parties(ColumnDescriptor col, ContractData data)
|
||||
{
|
||||
var s = data.Seller;
|
||||
var b = data.Buyer;
|
||||
Heading(col, "1. Vertragspartner");
|
||||
col.Item().Text("Verkäufer / Züchter").SemiBold();
|
||||
col.Item().Column(c =>
|
||||
{
|
||||
c.Spacing(2);
|
||||
Kv(c, "Zuchtname:", Or(s.ZuchtName));
|
||||
Kv(c, "Vor- und Nachname:", Or(s.Name));
|
||||
Kv(c, "Adresse:", Or(s.Address));
|
||||
Kv(c, "Telefon:", Or(s.Phone, ""));
|
||||
Kv(c, "E-Mail:", Or(s.Email, ""));
|
||||
Kv(c, "Homepage:", Or(s.Homepage, ""));
|
||||
});
|
||||
col.Item().PaddingTop(4).Text("Käufer / Abnehmer").SemiBold();
|
||||
col.Item().Column(c =>
|
||||
{
|
||||
c.Spacing(2);
|
||||
Kv(c, "Vor- und Nachname:", Or(b.Name));
|
||||
Kv(c, "Adresse:", Or(b.Address));
|
||||
Kv(c, "Telefon:", Or(b.Phone, ""));
|
||||
Kv(c, "E-Mail:", Or(b.Email, ""));
|
||||
});
|
||||
}
|
||||
|
||||
private static void Animals(ColumnDescriptor col, ContractData data)
|
||||
{
|
||||
Heading(col, "2. Angaben zu den Tieren");
|
||||
col.Item().Column(list =>
|
||||
{
|
||||
list.Spacing(10);
|
||||
foreach (var a in data.Animals)
|
||||
{
|
||||
// Ein Block je Tier: links das (optionale) Foto, rechts die Stammdaten —
|
||||
// wie im Mustervertrag (das große Rechteck links).
|
||||
list.Item().Border(1).BorderColor(Line).Row(row =>
|
||||
{
|
||||
row.RelativeItem(2f).MinHeight(150).BorderRight(1).BorderColor(Line)
|
||||
.Padding(4).Element(box =>
|
||||
{
|
||||
if (a.Photo is { Length: > 0 } bytes)
|
||||
box.AlignMiddle().Image(bytes).FitArea();
|
||||
else
|
||||
box.AlignMiddle().AlignCenter().Text("Kein Foto")
|
||||
.FontSize(8).FontColor("#B8AC97");
|
||||
});
|
||||
|
||||
row.RelativeItem(3f).Column(kv =>
|
||||
{
|
||||
AnimalRow(kv, "Name:", Or(a.Name, ""));
|
||||
AnimalRow(kv, "Tierart:", "Mongolische Rennmaus");
|
||||
AnimalRow(kv, "Geschlecht:", Or(a.Geschlecht));
|
||||
AnimalRow(kv, "Geburtsdatum:", a.Geburtsdatum is { } born ? D(born) : "—");
|
||||
AnimalRow(kv, "Farbschlag:", Or(a.Farbschlag, ""), last: true);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void AnimalRow(ColumnDescriptor col, string label, string value, bool last = false)
|
||||
{
|
||||
var cell = col.Item();
|
||||
if (!last) cell = cell.BorderBottom(1).BorderColor(Line);
|
||||
cell.PaddingVertical(7).PaddingHorizontal(8).Row(r =>
|
||||
{
|
||||
r.ConstantItem(100).Text(label).FontColor("#8C7F6E");
|
||||
r.RelativeItem().Text(value).SemiBold();
|
||||
});
|
||||
}
|
||||
|
||||
private static void Subject(ColumnDescriptor col, ContractData data)
|
||||
{
|
||||
Heading(col, "3. Gegenstand des Vertrages");
|
||||
col.Item().Text(
|
||||
$"Der Züchter verkauft dem Käufer mit Unterzeichnung des vorliegenden Vertrages die oben " +
|
||||
$"erwähnten Tiere zu einem Kaufpreis von {Price(data.Price)}. Der Kaufpreis ist bei Übergabe " +
|
||||
$"der Tiere vollständig bezahlt worden. Die Tiere wurden am {D(data.HandoverDate)} dem Käufer " +
|
||||
$"von dem Züchter übergeben.");
|
||||
}
|
||||
|
||||
private static void Clauses(ColumnDescriptor col)
|
||||
{
|
||||
Heading(col, "4. Einverständniserklärung zur Erhebung personenbezogener Daten");
|
||||
col.Item().Text(
|
||||
"Der Käufer stimmt der Erhebung und der Verarbeitung seiner oben aufgeführten Daten durch den " +
|
||||
"Verkäufer zu. Diese werden von dem Züchter vertraulich im Rahmen seiner Zucht behandelt. Darüber " +
|
||||
"hinaus benötigt es für jede weitere Datenerhebung die Zustimmung des Käufers. Der Abnehmer hat das " +
|
||||
"Recht, diese Einwilligung jederzeit ohne Angabe einer Begründung zu widerrufen. Weiterhin können " +
|
||||
"erhobene Daten bei Bedarf korrigiert, gelöscht oder deren Erhebung eingeschränkt werden.");
|
||||
|
||||
Heading(col, "5. Pflichten des Käufers");
|
||||
col.Item().Text(
|
||||
"Tiere sind keine Sache, sondern empfindungs- und leidensfähige Lebewesen. Der Abnehmer ist sich " +
|
||||
"seiner hohen Verantwortung bewusst und übernimmt folgende Pflichten:");
|
||||
col.Item().Column(c =>
|
||||
{
|
||||
c.Spacing(3);
|
||||
Bullet(c, "Der Käufer verpflichtet sich, die Tiere artgerecht zu halten, zu füttern und zu pflegen. Er verpflichtet sich, die stets aktuellen Vorschriften des Tierschutzgesetzes einzuhalten. Die schriftlichen Anweisungen des Züchters über Haltung, Pflege und Unterkunft der Tiere sind zu befolgen.");
|
||||
Bullet(c, "Der Abnehmer verpflichtet sich, die Tiere in Krankheitsfällen oder bei Anzeichen auf diese unverzüglich veterinärmedizinisch behandeln zu lassen. Die Tiere dürfen nicht ohne zwingende veterinärmedizinische Gründe euthanasiert werden.");
|
||||
Bullet(c, "Dem Käufer ist eine Weitergabe der Tiere ohne die Zustimmung des Züchters nicht gestattet. Eine Abgabe an ein Tierheim oder eine Auffangstation ist ohne vorherige Absprache untersagt.");
|
||||
Bullet(c, "Der Abnehmer verpflichtet sich, die Tiere niemals zu misshandeln, für Versuchszwecke oder als Zuchttier einzusetzen oder als Futtertier zu verwenden.");
|
||||
Bullet(c, "Die Einzelhaltung der Tiere ist dem Käufer untersagt. Er verpflichtet sich im Falle eines einzelnen Tieres zur artgerechten Vergesellschaftung mit der Trenngitter-Methode.");
|
||||
Bullet(c, "Der Käufer setzt den Verkäufer über das Versterben sowie die Todesursache der Tiere in Kenntnis.");
|
||||
Bullet(c, "Ernsthafte Erkrankungen genetischer Ursache sind nach gestellter Diagnose unverzüglich dem Züchter zu melden.");
|
||||
});
|
||||
|
||||
Heading(col, "6. Rechte des Käufers");
|
||||
col.Item().Column(c =>
|
||||
{
|
||||
c.Spacing(3);
|
||||
Bullet(c, "Der Käufer hat das Recht, den Züchter für die unentgeltliche Weitervermittlung der Tiere zu beauftragen. Dies schließt jedoch nicht die Aufnahme der Rennmäuse bei dem Züchter ein.");
|
||||
Bullet(c, "Der Verkäufer steht dem Käufer für Fragen zur Tierhaltung zur Verfügung. Der Käufer kann diese Dienste in beschränktem Umfang unentgeltlich in Anspruch nehmen.");
|
||||
Bullet(c, "Der Käufer hat das Recht, gegen Vorlage einer aktuellen negativen Kotuntersuchung den kostenpflichtigen Vergesellschaftungsservice für die Rennmaus in Anspruch zu nehmen.");
|
||||
});
|
||||
|
||||
Heading(col, "7. Haftung / Zuwiderhandlung");
|
||||
col.Item().Column(c =>
|
||||
{
|
||||
c.Spacing(3);
|
||||
Bullet(c, "Bei Vertragsverstoß ist der Züchter berechtigt, den Vertrag fristlos zu kündigen.");
|
||||
Bullet(c, "Im Falle des Verstoßes ist der Käufer zur entschädigungslosen Rückgabe der Tiere an den Züchter verpflichtet.");
|
||||
Bullet(c, "Zur Sicherstellung der Käuferpflichten wird eine Vertragsstrafe in Höhe von 200,- € festgelegt.");
|
||||
});
|
||||
|
||||
Heading(col, "8. Besondere Vereinbarungen / Bemerkungen");
|
||||
col.Item().Text(
|
||||
"Mündliche Vereinbarungen haben keine Gültigkeit. Jede Änderung oder Ergänzung des Vertrages bedarf " +
|
||||
"der Schriftform. Die Vertragsparteien verzichten auf jegliche Sachgewährleistungsansprüche. " +
|
||||
"Gerichtsstand für beide Parteien ist der Wohnort des Züchters. Der Vertrag wird zweifach ausgefertigt " +
|
||||
"und unterzeichnet. Käufer und Verkäufer erhalten je ein Exemplar. Mit der Unterschrift wird versichert, " +
|
||||
"den Vertragsinhalt gelesen und akzeptiert zu haben und sich an diesen zu halten.");
|
||||
}
|
||||
|
||||
private static void Signatures(ColumnDescriptor col, ContractData data)
|
||||
{
|
||||
var ort = string.IsNullOrWhiteSpace(data.Seller.City) ? "" : data.Seller.City + ", ";
|
||||
col.Item().PaddingTop(14).Text($"{ort}den {D(data.ContractDate ?? data.HandoverDate)}");
|
||||
col.Item().PaddingTop(28).Row(r =>
|
||||
{
|
||||
r.RelativeItem().Column(c =>
|
||||
{
|
||||
c.Item().BorderTop(1).BorderColor(Ink).PaddingTop(4).Text("Unterschrift des Verkäufers");
|
||||
});
|
||||
r.ConstantItem(40);
|
||||
r.RelativeItem().Column(c =>
|
||||
{
|
||||
c.Item().BorderTop(1).BorderColor(Ink).PaddingTop(4).Text("Unterschrift des Käufers");
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
BIN
GerbilManagerWebAPI/Contracts/Templates/Abgabevertrag.docx
Normal file
BIN
GerbilManagerWebAPI/Contracts/Templates/Abgabevertrag.docx
Normal file
Binary file not shown.
@@ -1,91 +0,0 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Repositories;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
|
||||
using GerbilManagerWebAPI.Converter;
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Xml;
|
||||
|
||||
namespace GerbilManagerWebAPI.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("breeders")]
|
||||
public class BreederController : ControllerBase
|
||||
{
|
||||
private readonly IGerbilRepository gerbilrepository;
|
||||
private readonly IBreederRepository repository;
|
||||
private readonly ILitterRepository litterRepository;
|
||||
|
||||
public BreederController(IBreederRepository repo)
|
||||
{
|
||||
this.repository = repo;
|
||||
}
|
||||
|
||||
//GET /gerbils
|
||||
[HttpGet]
|
||||
public IEnumerable<BreederDto> GetBreeders()
|
||||
{
|
||||
var items = repository.GetBreeders().Select( breeder => breeder.AsDto());
|
||||
return items;
|
||||
}
|
||||
|
||||
//GET /gerbils
|
||||
[HttpGet("{id}")]
|
||||
public ActionResult<BreederDto> GetBreeder(Guid id)
|
||||
{
|
||||
var item = repository.GetBreeder(id).AsDto();
|
||||
|
||||
if(item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
[HttpPost()]
|
||||
public ActionResult<BreederDto> CreateBreeder(CreateBreederDto dto)
|
||||
{
|
||||
Breeder breeder = new Breeder{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = dto.Name,
|
||||
};
|
||||
|
||||
this.repository.CreateBreeder(breeder);
|
||||
|
||||
return CreatedAtAction(nameof(GetBreeder), new { id = breeder.Id}, breeder.AsDto());
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public ActionResult UpdateBreeder(Guid id, UpdateBreederDto breederDto)
|
||||
{
|
||||
var existingItem = repository.GetBreeder(id);
|
||||
if(existingItem is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
Breeder updatedBreeder = existingItem with{
|
||||
Name = breederDto.Name
|
||||
};
|
||||
|
||||
repository.UpdateBreeder(updatedBreeder);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public ActionResult DeleteBreeder(Guid id)
|
||||
{
|
||||
var existingLitter = repository.GetBreeder(id);
|
||||
|
||||
if(existingLitter is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
repository.DeleteBreeder(existingLitter);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Repositories;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using GerbilManagerWebAPI.Converter;
|
||||
|
||||
namespace GerbilManagerWebAPI.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("gerbils")]
|
||||
public class GerbilsController : ControllerBase
|
||||
{
|
||||
private readonly IGerbilRepository repository;
|
||||
private readonly IBreederRepository breederRepository;
|
||||
private readonly ILitterRepository litterRepository;
|
||||
|
||||
public GerbilsController(IGerbilRepository repo)
|
||||
{
|
||||
this.repository = repo;
|
||||
}
|
||||
|
||||
//GET /gerbils
|
||||
[HttpGet]
|
||||
public IEnumerable<GerbilDto> GetGerbils()
|
||||
{
|
||||
var items = repository.GetGerbils().Select( gerbil => gerbil.AsDto());
|
||||
return items;
|
||||
}
|
||||
|
||||
//GET /gerbils
|
||||
[HttpGet("{id}")]
|
||||
public ActionResult<GerbilDto> GetGerbil(Guid id)
|
||||
{
|
||||
var item = repository.GetGerbil(id).AsDto();
|
||||
|
||||
if(item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
[HttpPost()]
|
||||
public ActionResult<GerbilDto> CreateGerbil(CreateGerbilDto dto)
|
||||
{
|
||||
Gerbil gerbil = new Gerbil{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = dto.Name,
|
||||
Breeder = breederRepository.GetBreeder(dto.Breeder),
|
||||
Litter = litterRepository.GetLitter(dto.Litter),
|
||||
Gender = (Gender) (int) dto.Gender
|
||||
};
|
||||
|
||||
this.repository.CreateGerbil(gerbil);
|
||||
|
||||
return CreatedAtAction(nameof(GetGerbil), new { id = gerbil.Id}, gerbil.AsDto());
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public ActionResult UpdateGerbil(Guid id, UpdateGerbilDto gerbilDto)
|
||||
{
|
||||
var existingItem = repository.GetGerbil(id);
|
||||
if(existingItem is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
Gerbil updatedGerbil = existingItem with{
|
||||
Breeder = breederRepository.GetBreeder(gerbilDto.Breeder.Id),
|
||||
Litter = litterRepository.GetLitter(gerbilDto.Litter.Id),
|
||||
Gender = (Gender) (int) gerbilDto.Gender,
|
||||
Name = gerbilDto.Name
|
||||
};
|
||||
|
||||
repository.UpdateGerbil(updatedGerbil);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public ActionResult DeleteGerbil(Guid id)
|
||||
{
|
||||
var existingGerbil = repository.GetGerbil(id);
|
||||
|
||||
if(existingGerbil is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
repository.DeleteGerbil(existingGerbil);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Repositories;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
|
||||
using GerbilManagerWebAPI.Converter;
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Xml;
|
||||
|
||||
namespace GerbilManagerWebAPI.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("litters")]
|
||||
public class LittersController : ControllerBase
|
||||
{
|
||||
private readonly IGerbilRepository gerbilrepository;
|
||||
private readonly IBreederRepository breederRepository;
|
||||
private readonly ILitterRepository repository;
|
||||
|
||||
public LittersController(ILitterRepository repo)
|
||||
{
|
||||
this.repository = repo;
|
||||
}
|
||||
|
||||
//GET /gerbils
|
||||
[HttpGet]
|
||||
public IEnumerable<LitterDto> GetLitters()
|
||||
{
|
||||
var items = repository.GetLitters().Select( litter => litter.AsDto());
|
||||
return items;
|
||||
}
|
||||
|
||||
//GET /gerbils
|
||||
[HttpGet("{id}")]
|
||||
public ActionResult<LitterDto> GetLitter(Guid id)
|
||||
{
|
||||
var item = repository.GetLitter(id).AsDto();
|
||||
|
||||
if(item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
[HttpPost()]
|
||||
public ActionResult<LitterDto> CreateLitter(CreateLitterDto dto)
|
||||
{
|
||||
Litter litter = new Litter{
|
||||
Id = Guid.NewGuid(),
|
||||
Date = dto.Date,
|
||||
Father = gerbilrepository.GetGerbil(dto.Father),
|
||||
Mother = gerbilrepository.GetGerbil(dto.Mother),
|
||||
Name = dto.Name,
|
||||
Strength = dto.Strength
|
||||
};
|
||||
|
||||
this.repository.CreateLitter(litter);
|
||||
|
||||
return CreatedAtAction(nameof(GetLitter), new { id = litter.Id}, litter.AsDto());
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public ActionResult UpdateLitter(Guid id, UpdateLitterDto litterDto)
|
||||
{
|
||||
var existingItem = repository.GetLitter(id);
|
||||
if(existingItem is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
Litter updatedLitter = existingItem with{
|
||||
Date = litterDto.Date,
|
||||
Strength = litterDto.Strength,
|
||||
Father = gerbilrepository.GetGerbil(litterDto.Father),
|
||||
Mother = gerbilrepository.GetGerbil(litterDto.Mother),
|
||||
Name = litterDto.Name
|
||||
};
|
||||
|
||||
repository.UpdateLitter(updatedLitter);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public ActionResult DeleteLitter(Guid id)
|
||||
{
|
||||
var existingLitter = repository.GetLitter(id);
|
||||
|
||||
if(existingLitter is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
repository.DeleteLitter(existingLitter);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
|
||||
namespace GerbilManagerWebAPI.Converter
|
||||
{
|
||||
public static class BreederConverterExtension
|
||||
{
|
||||
public static BreederDto AsDto(this Breeder breeder)
|
||||
{
|
||||
return new BreederDto{
|
||||
Id = breeder.Id,
|
||||
Name = breeder.Name
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
|
||||
namespace GerbilManagerWebAPI.Converter
|
||||
{
|
||||
public static class GerbilConverterExtension
|
||||
{
|
||||
public static GerbilDto AsDto(this Gerbil gerbil)
|
||||
{
|
||||
return new GerbilDto{
|
||||
Breeder = gerbil.Breeder.AsDto(),
|
||||
Gender = (GenderDto) ((int) gerbil.Gender),
|
||||
Name = gerbil.Name,
|
||||
Litter = gerbil.Litter.AsDto()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
|
||||
namespace GerbilManagerWebAPI.Converter
|
||||
{
|
||||
public static class LitterConverterExtension
|
||||
{
|
||||
public static LitterDto AsDto(this Litter litter)
|
||||
{
|
||||
return new LitterDto{
|
||||
Id = litter.Id,
|
||||
Date = litter.Date,
|
||||
Father = litter.Father.AsDto(),
|
||||
Mother = litter.Mother.AsDto(),
|
||||
Name = litter.Name,
|
||||
Strength = litter.Strength
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
23
GerbilManagerWebAPI/Dockerfile
Normal file
23
GerbilManagerWebAPI/Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
# Build context: repo root (includes GerbilManager.ServiceDefaults).
|
||||
# docker build -f GerbilManagerWebAPI/Dockerfile -t gerbilmanager-api .
|
||||
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Restore: project files only for layer-cache efficiency.
|
||||
COPY GerbilManager.ServiceDefaults/GerbilManager.ServiceDefaults.csproj GerbilManager.ServiceDefaults/
|
||||
COPY GerbilManagerWebAPI/GerbilManagerWebAPI.csproj GerbilManagerWebAPI/
|
||||
RUN dotnet restore GerbilManagerWebAPI/GerbilManagerWebAPI.csproj
|
||||
|
||||
# Full source copy + publish.
|
||||
COPY GerbilManager.ServiceDefaults/ GerbilManager.ServiceDefaults/
|
||||
COPY GerbilManagerWebAPI/ GerbilManagerWebAPI/
|
||||
WORKDIR /src/GerbilManagerWebAPI
|
||||
RUN dotnet publish GerbilManagerWebAPI.csproj -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||
WORKDIR /app
|
||||
# .NET 10 containers default to ASPNETCORE_HTTP_PORTS=8080 bound on all interfaces.
|
||||
EXPOSE 8080
|
||||
COPY --from=build /app/publish .
|
||||
ENTRYPOINT ["dotnet", "GerbilManagerWebAPI.dll"]
|
||||
113
GerbilManagerWebAPI/Dtos/ApiDtos.cs
Normal file
113
GerbilManagerWebAPI/Dtos/ApiDtos.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
// Response DTOs ----------------------------------------------------------
|
||||
// Enums serialise as string names; JSON property names are camelCase (configured
|
||||
// globally in Program.cs). FK references are FLAT ids (no nested objects) per the
|
||||
// frontend contract (Kelly's pedigree + Oscar's filters depend on this).
|
||||
|
||||
public record GerbilDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
Gender Gender,
|
||||
GerbilStatus Status,
|
||||
Guid? LitterId,
|
||||
Guid? OriginContactId,
|
||||
Guid? ReceiverContactId,
|
||||
Guid? EnclosureId,
|
||||
Guid? ColorVarietyId,
|
||||
DateOnly? DateOfBirth,
|
||||
DateOnly? DateOfDeath,
|
||||
string? CauseOfDeath,
|
||||
DateOnly? GoHomeDate,
|
||||
string? Genotype,
|
||||
string? SpottingType,
|
||||
string? Notes,
|
||||
string? ImportSource,
|
||||
string? ExternalRef,
|
||||
string? OriginBreeder,
|
||||
List<string> CharacterTraits,
|
||||
string? CharacterNote,
|
||||
bool? IsDeaf,
|
||||
bool IsResident,
|
||||
string? ProfilePhotoUrl,
|
||||
bool IsCastrated);
|
||||
|
||||
public record LitterDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
DateOnly? Date,
|
||||
int? TotalBorn,
|
||||
int? DeathsWithin8Weeks,
|
||||
Guid? FatherId,
|
||||
Guid? MotherId,
|
||||
DateOnly? ExpectedGoHomeDate,
|
||||
string? Notes);
|
||||
|
||||
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix);
|
||||
|
||||
public record EnclosureDto(Guid Id, string Name, string? Notes);
|
||||
|
||||
public record ColorVarietyDto(Guid Id, string Name, string? CanonicalGenotype, int SortOrder);
|
||||
|
||||
public record HealthRecordDto(
|
||||
Guid Id, Guid GerbilId, DateOnly Date, HealthRecordType Type,
|
||||
string Description, string? Veterinarian, DateTimeOffset CreatedAt);
|
||||
|
||||
public record WeightRecordDto(
|
||||
Guid Id, Guid GerbilId, DateOnly Date, int WeightGrams, string? Notes);
|
||||
|
||||
public record PhotoDto(Guid Id, string FileName, string? Caption, int SortOrder, string Url);
|
||||
|
||||
// Request DTOs -----------------------------------------------------------
|
||||
|
||||
public record GerbilInput(
|
||||
string? Name,
|
||||
Gender? Gender,
|
||||
GerbilStatus? Status,
|
||||
Guid? LitterId,
|
||||
Guid? OriginContactId,
|
||||
Guid? ReceiverContactId,
|
||||
Guid? EnclosureId,
|
||||
Guid? ColorVarietyId,
|
||||
DateOnly? DateOfBirth,
|
||||
DateOnly? DateOfDeath,
|
||||
string? CauseOfDeath,
|
||||
DateOnly? GoHomeDate,
|
||||
string? Genotype,
|
||||
string? SpottingType,
|
||||
string? Notes,
|
||||
string? ImportSource,
|
||||
string? ExternalRef,
|
||||
string? OriginBreeder,
|
||||
List<string>? CharacterTraits,
|
||||
string? CharacterNote,
|
||||
bool? IsDeaf,
|
||||
bool? IsResident,
|
||||
bool? IsCastrated);
|
||||
|
||||
public record LitterInput(
|
||||
string Name,
|
||||
DateOnly? Date,
|
||||
int? TotalBorn,
|
||||
int? DeathsWithin8Weeks,
|
||||
Guid? FatherId,
|
||||
Guid? MotherId,
|
||||
DateOnly? ExpectedGoHomeDate,
|
||||
string? Notes);
|
||||
|
||||
public record ContactInput(string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix);
|
||||
|
||||
public record EnclosureInput(string Name, string? Notes);
|
||||
|
||||
public record ColorVarietyInput(string Name, string? CanonicalGenotype, int? SortOrder);
|
||||
|
||||
public record HealthRecordInput(
|
||||
Guid GerbilId, DateOnly Date, HealthRecordType Type, string Description, string? Veterinarian);
|
||||
|
||||
public record WeightRecordInput(Guid GerbilId, DateOnly Date, int WeightGrams, string? Notes);
|
||||
|
||||
/// <summary>Hypothetical pairing request for POST /genetics/test-inbreeding.</summary>
|
||||
public record TestInbreedingDto(Guid? FatherId, Guid? MotherId);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
public record BreederDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string Name { get; init; }
|
||||
}
|
||||
}
|
||||
26
GerbilManagerWebAPI/Dtos/CmsDtos.cs
Normal file
26
GerbilManagerWebAPI/Dtos/CmsDtos.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
// Response DTOs ----------------------------------------------------------
|
||||
|
||||
public record PageSummaryDto(Guid Id, string Slug, string Title, string? SeoDescription, PageStatus Status);
|
||||
|
||||
public record PageDto(
|
||||
Guid Id, string Slug, string Title, string? SeoDescription, PageStatus Status,
|
||||
IReadOnlyList<BlockDto> Blocks);
|
||||
|
||||
/// <summary>Block.Data is emitted as a parsed JSON object (not a string).</summary>
|
||||
public record BlockDto(Guid Id, int Order, BlockType Type, JsonNode? Data);
|
||||
|
||||
// Request DTOs -----------------------------------------------------------
|
||||
|
||||
public record PageInput(string Slug, string Title, string? SeoDescription, PageStatus? Status);
|
||||
|
||||
/// <summary>Data is the type-specific JSON object (e.g. {"markdown":"…"} for RichText).</summary>
|
||||
public record BlockInput(BlockType Type, JsonElement Data, int? Order);
|
||||
|
||||
public record BlockOrderInput(List<Guid> BlockIds);
|
||||
}
|
||||
36
GerbilManagerWebAPI/Dtos/ContractDtos.cs
Normal file
36
GerbilManagerWebAPI/Dtos/ContractDtos.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
// FEAT-13: Abgabeverträge + Zuchtprofil (eigene Datei, hält ApiDtos.cs konfliktfrei).
|
||||
|
||||
public record SaleContractDto(
|
||||
Guid Id,
|
||||
Guid ContactId,
|
||||
decimal Price,
|
||||
DateOnly HandoverDate,
|
||||
DateOnly ContractDate,
|
||||
string FileName,
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyList<Guid> GerbilIds,
|
||||
// Download-URL der .docx ("/contracts/{id}/file").
|
||||
string Url);
|
||||
|
||||
public record SaleContractInput(
|
||||
Guid ContactId,
|
||||
List<Guid> GerbilIds,
|
||||
decimal Price,
|
||||
DateOnly HandoverDate,
|
||||
DateOnly? ContractDate,
|
||||
// Optionales Tierfoto je Tier (GerbilId -> GerbilPhoto-Id) fürs Vertragsbild.
|
||||
Dictionary<Guid, Guid>? AnimalPhotos = null);
|
||||
|
||||
/// <summary>Zuchtprofil — Antwort UND Request-Body von /settings/breeder-profile.</summary>
|
||||
public record BreederProfileDto(
|
||||
string ZuchtName,
|
||||
string Name,
|
||||
string Address,
|
||||
string Phone,
|
||||
string Email,
|
||||
string Homepage,
|
||||
string City,
|
||||
string NameSuffix);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
public record CreateBreederDto
|
||||
{
|
||||
public string Name { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
public record CreateGerbilDto
|
||||
{
|
||||
public string Name { get; init; }
|
||||
public GenderDto Gender { get; init; }
|
||||
public Guid Litter { get; init; }
|
||||
public Guid Breeder { get; init; }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
public record CreateLitterDto
|
||||
{
|
||||
public string Name { get; init; }
|
||||
public DateTime Date { get; init; }
|
||||
public int Strength { get; init; }
|
||||
public Guid Father { get; init; }
|
||||
public Guid Mother { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
public enum GenderDto
|
||||
{
|
||||
unknown = 0,
|
||||
male = 1,
|
||||
female = 2
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
public record GerbilDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string Name { get; init; }
|
||||
public GenderDto Gender { get; init; }
|
||||
public LitterDto Litter { get; init; }
|
||||
public BreederDto Breeder { get; init; }
|
||||
}
|
||||
}
|
||||
41
GerbilManagerWebAPI/Dtos/InboxDtos.cs
Normal file
41
GerbilManagerWebAPI/Dtos/InboxDtos.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
public record RequestDto(
|
||||
Guid Id,
|
||||
string GmailMessageId,
|
||||
string? ThreadId,
|
||||
string FromAddress,
|
||||
string? FromName,
|
||||
string? Subject,
|
||||
string? BodyText,
|
||||
DateTimeOffset ReceivedAt,
|
||||
RequestStatus Status,
|
||||
Guid? AssignedContactId,
|
||||
string? DraftReply,
|
||||
DateTimeOffset? AnsweredAt);
|
||||
|
||||
/// <summary>Triage update: change status and/or assign a contact.</summary>
|
||||
public record RequestTriageInput(RequestStatus? Status, Guid? AssignedContactId);
|
||||
|
||||
/// <summary>Mail settings read shape — the App Password is NEVER returned (only HasAppPassword).</summary>
|
||||
public record MailSettingsDto(
|
||||
string? GmailAddress,
|
||||
int PollIntervalMinutes,
|
||||
string Folder,
|
||||
bool BackgroundPollEnabled,
|
||||
bool HasAppPassword);
|
||||
|
||||
/// <summary>Body of the (edited) reply to send for POST /api/requests/{id}/send.</summary>
|
||||
public record SendReplyInput(string Body);
|
||||
|
||||
/// <summary>Mail settings write shape. AppPassword is write-only; null/omitted leaves it unchanged,
|
||||
/// empty string clears it.</summary>
|
||||
public record MailSettingsInput(
|
||||
string? GmailAddress,
|
||||
string? AppPassword,
|
||||
int? PollIntervalMinutes,
|
||||
string? Folder,
|
||||
bool? BackgroundPollEnabled);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
public record LitterDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string Name { get; init; }
|
||||
public DateTime Date { get; init; }
|
||||
public int Strength { get; init; }
|
||||
public GerbilDto Father { get; init; }
|
||||
public GerbilDto Mother { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
public record UpdateBreederDto
|
||||
{
|
||||
public string Name { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
public record UpdateGerbilDto
|
||||
{
|
||||
public string Name { get; init; }
|
||||
public GenderDto Gender { get; init; }
|
||||
public LitterDto Litter { get; init; }
|
||||
public BreederDto Breeder { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
public record UpdateLitterDto
|
||||
{
|
||||
public string Name { get; init; }
|
||||
public DateTime Date { get; init; }
|
||||
public int Strength { get; init; }
|
||||
public Guid Father { get; init; }
|
||||
public Guid Mother { get; init; }
|
||||
}
|
||||
}
|
||||
233
GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs
Normal file
233
GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs
Normal file
@@ -0,0 +1,233 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using GerbilManagerWebAPI.Cms;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// WEB-0: CMS for the public website. GET /api/site-snapshot is the publish-time export
|
||||
/// (with Abgabetiere resolved from live ForSale animals); the /api/pages + /api/blocks
|
||||
/// CRUD edits content (hand-edit admin UI in WEB-0b, AI tools later). LAN-only like the
|
||||
/// rest of the API.
|
||||
/// </summary>
|
||||
public static class CmsEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapCmsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var api = app.MapGroup("/api").WithTags("CMS");
|
||||
|
||||
// ---- snapshot (consumed by the static renderer) ----
|
||||
api.MapGet("/site-snapshot", async (ApplicationContext db) =>
|
||||
TypedResults.Ok(await new SiteSnapshotService(db).BuildAsync()));
|
||||
|
||||
// ---- static render dry-run (WEB-1: returns rendered file map as JSON) ----
|
||||
api.MapGet("/render-site", async (ApplicationContext db) =>
|
||||
{
|
||||
var snapshot = await new SiteSnapshotService(db).BuildAsync();
|
||||
var files = SiteRenderer.Render(snapshot);
|
||||
return TypedResults.Ok(files.Select(kv => new { path = kv.Key, size = kv.Value.Length }).ToList());
|
||||
});
|
||||
|
||||
// ---- WEB-2: publish — rendert Snapshot auf Disk, atomic swap live/ ----
|
||||
api.MapPost("/publish", async (ApplicationContext db, IOptions<PublicSiteOptions> opts) =>
|
||||
{
|
||||
if (!opts.Value.IsConfigured)
|
||||
return Results.Problem(
|
||||
detail: "PublicSite__RootPath ist nicht konfiguriert. Setze die Umgebungsvariable.",
|
||||
statusCode: 503,
|
||||
title: "PublicSite nicht konfiguriert");
|
||||
|
||||
var snapshot = await new SiteSnapshotService(db).BuildAsync();
|
||||
var files = SiteRenderer.Render(snapshot);
|
||||
await PublishToDirectoryAsync(files, opts.Value.RootPath!);
|
||||
return Results.Ok(new { filesPublished = files.Count });
|
||||
});
|
||||
|
||||
// ---- WEB-3: lokale Vorschau — rendert live (nur veröffentlichte Seiten)
|
||||
// und liefert die Datei mit passendem Content-Type aus. Relative
|
||||
// Links/CSS der gerenderten Seite funktionieren dadurch im
|
||||
// Vorschau-iframe genauso wie später auf der echten Webseite. ----
|
||||
api.MapGet("/preview/{**path}", async (string? path, ApplicationContext db) =>
|
||||
{
|
||||
var snapshot = await new SiteSnapshotService(db).BuildAsync();
|
||||
var files = SiteRenderer.Render(snapshot);
|
||||
|
||||
var key = string.IsNullOrWhiteSpace(path) ? "index.html" : path.TrimEnd('/');
|
||||
if (!files.TryGetValue(key, out var content) &&
|
||||
!files.TryGetValue($"{key}/index.html", out content))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
return Results.Content(content, PreviewContentType(key));
|
||||
});
|
||||
|
||||
// ---- pages ----
|
||||
api.MapGet("/pages", async (ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.Pages.AsNoTracking().OrderBy(p => p.Slug)
|
||||
.Select(p => new PageSummaryDto(p.Id, p.Slug, p.Title, p.SeoDescription, p.Status))
|
||||
.ToListAsync()));
|
||||
|
||||
api.MapGet("/pages/{slug}", async Task<Results<Ok<PageDto>, NotFound>> (string slug, ApplicationContext db) =>
|
||||
{
|
||||
var p = await db.Pages.AsNoTracking().Include(x => x.Blocks)
|
||||
.FirstOrDefaultAsync(x => x.Slug == slug);
|
||||
return p is null ? TypedResults.NotFound() : TypedResults.Ok(ToPageDto(p));
|
||||
});
|
||||
|
||||
api.MapPost("/pages", async Task<Results<Created<PageDto>, ValidationProblem, Conflict<string>>> (PageInput input, ApplicationContext db) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.Slug) || string.IsNullOrWhiteSpace(input.Title))
|
||||
return TypedResults.ValidationProblem(new Dictionary<string, string[]> { ["slug"] = ["Slug and title are required."] });
|
||||
if (await db.Pages.AnyAsync(p => p.Slug == input.Slug))
|
||||
return TypedResults.Conflict($"A page with slug '{input.Slug}' already exists.");
|
||||
|
||||
var page = new Page
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Slug = input.Slug,
|
||||
Title = input.Title,
|
||||
SeoDescription = input.SeoDescription,
|
||||
Status = input.Status ?? PageStatus.Draft,
|
||||
};
|
||||
db.Pages.Add(page);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/api/pages/{page.Slug}", ToPageDto(page));
|
||||
});
|
||||
|
||||
api.MapPut("/pages/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, PageInput input, ApplicationContext db) =>
|
||||
{
|
||||
var page = await db.Pages.FirstOrDefaultAsync(p => p.Id == id);
|
||||
if (page is null) return TypedResults.NotFound();
|
||||
page.Slug = input.Slug;
|
||||
page.Title = input.Title;
|
||||
page.SeoDescription = input.SeoDescription;
|
||||
if (input.Status is PageStatus s) page.Status = s;
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
api.MapDelete("/pages/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var page = await db.Pages.FirstOrDefaultAsync(p => p.Id == id);
|
||||
if (page is null) return TypedResults.NotFound();
|
||||
db.Pages.Remove(page); // cascades blocks
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
// ---- blocks ----
|
||||
api.MapPost("/pages/{pageId:guid}/blocks", async Task<Results<Created<BlockDto>, NotFound, BadRequest<string>>> (
|
||||
Guid pageId, BlockInput input, ApplicationContext db) =>
|
||||
{
|
||||
if (!await db.Pages.AnyAsync(p => p.Id == pageId)) return TypedResults.NotFound();
|
||||
if (input.Data.ValueKind is not (JsonValueKind.Object or JsonValueKind.Undefined))
|
||||
return TypedResults.BadRequest("Block data must be a JSON object.");
|
||||
|
||||
int order = input.Order ?? ((await db.Blocks.Where(b => b.PageId == pageId)
|
||||
.Select(b => (int?)b.Order).MaxAsync() ?? -1) + 1);
|
||||
var block = new Block
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PageId = pageId,
|
||||
Order = order,
|
||||
Type = input.Type,
|
||||
Data = input.Data.ValueKind == JsonValueKind.Object ? input.Data.GetRawText() : "{}",
|
||||
};
|
||||
db.Blocks.Add(block);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/api/blocks/{block.Id}", ToBlockDto(block));
|
||||
});
|
||||
|
||||
api.MapPut("/blocks/{id:guid}", async Task<Results<NoContent, NotFound, BadRequest<string>>> (
|
||||
Guid id, BlockInput input, ApplicationContext db) =>
|
||||
{
|
||||
var block = await db.Blocks.FirstOrDefaultAsync(b => b.Id == id);
|
||||
if (block is null) return TypedResults.NotFound();
|
||||
if (input.Data.ValueKind is not (JsonValueKind.Object or JsonValueKind.Undefined))
|
||||
return TypedResults.BadRequest("Block data must be a JSON object.");
|
||||
block.Type = input.Type;
|
||||
if (input.Data.ValueKind == JsonValueKind.Object) block.Data = input.Data.GetRawText();
|
||||
if (input.Order is int o) block.Order = o;
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
api.MapDelete("/blocks/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var block = await db.Blocks.FirstOrDefaultAsync(b => b.Id == id);
|
||||
if (block is null) return TypedResults.NotFound();
|
||||
db.Blocks.Remove(block);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
// reorder: body = ordered block ids; Order set to the list index
|
||||
api.MapPut("/pages/{pageId:guid}/blocks/order", async Task<Results<NoContent, NotFound, BadRequest<string>>> (
|
||||
Guid pageId, BlockOrderInput input, ApplicationContext db) =>
|
||||
{
|
||||
var blocks = await db.Blocks.Where(b => b.PageId == pageId).ToListAsync();
|
||||
if (blocks.Count == 0 && !await db.Pages.AnyAsync(p => p.Id == pageId))
|
||||
return TypedResults.NotFound();
|
||||
var byId = blocks.ToDictionary(b => b.Id);
|
||||
if (input.BlockIds.Count != blocks.Count || input.BlockIds.Any(bid => !byId.ContainsKey(bid)))
|
||||
return TypedResults.BadRequest("blockIds must list exactly the page's block ids.");
|
||||
for (int i = 0; i < input.BlockIds.Count; i++)
|
||||
byId[input.BlockIds[i]].Order = i;
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WEB-2: Writes rendered files to <paramref name="rootPath"/>/_staging_new, then
|
||||
/// atomically swaps to live/ (rename on the same filesystem = one syscall, never partial).
|
||||
/// </summary>
|
||||
internal static async Task PublishToDirectoryAsync(
|
||||
IReadOnlyDictionary<string, string> files, string rootPath)
|
||||
{
|
||||
var stagingDir = Path.Combine(rootPath, "_staging_new");
|
||||
var liveDir = Path.Combine(rootPath, "live");
|
||||
var oldDir = Path.Combine(rootPath, "_old");
|
||||
|
||||
if (Directory.Exists(stagingDir)) Directory.Delete(stagingDir, recursive: true);
|
||||
Directory.CreateDirectory(stagingDir);
|
||||
|
||||
foreach (var (relativePath, content) in files)
|
||||
{
|
||||
var normalPath = relativePath.Replace('/', Path.DirectorySeparatorChar);
|
||||
var fullPath = Path.Combine(stagingDir, normalPath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
|
||||
await File.WriteAllTextAsync(fullPath, content, Encoding.UTF8);
|
||||
}
|
||||
|
||||
// Atomic swap: _staging_new → live
|
||||
if (Directory.Exists(oldDir)) Directory.Delete(oldDir, recursive: true);
|
||||
if (Directory.Exists(liveDir)) Directory.Move(liveDir, oldDir);
|
||||
Directory.Move(stagingDir, liveDir);
|
||||
try { if (Directory.Exists(oldDir)) Directory.Delete(oldDir, recursive: true); }
|
||||
catch { /* non-fatal — old dir gone on next publish */ }
|
||||
}
|
||||
|
||||
/// <summary>WEB-3: Content-Type der Vorschau-Dateien (Renderer erzeugt HTML + CSS).</summary>
|
||||
private static string PreviewContentType(string path) =>
|
||||
path.EndsWith(".css", StringComparison.OrdinalIgnoreCase) ? "text/css; charset=utf-8"
|
||||
: path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) ? "application/xml; charset=utf-8"
|
||||
: path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) ? "text/plain; charset=utf-8"
|
||||
: "text/html; charset=utf-8";
|
||||
|
||||
private static BlockDto ToBlockDto(Block b) =>
|
||||
new(b.Id, b.Order, b.Type, JsonNode.Parse(string.IsNullOrWhiteSpace(b.Data) ? "{}" : b.Data));
|
||||
|
||||
private static PageDto ToPageDto(Page p) => new(
|
||||
p.Id, p.Slug, p.Title, p.SeoDescription, p.Status,
|
||||
p.Blocks.OrderBy(b => b.Order).Select(ToBlockDto).ToList());
|
||||
}
|
||||
}
|
||||
67
GerbilManagerWebAPI/Endpoints/ColorVarietyEndpoints.cs
Normal file
67
GerbilManagerWebAPI/Endpoints/ColorVarietyEndpoints.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using GerbilManagerWebAPI.Common;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Gridify;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
public static class ColorVarietyEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapColorVarietyEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/color-varieties").WithTags("ColorVarieties");
|
||||
|
||||
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.ColorVarieties.AsNoTracking().OrderBy(v => v.SortOrder)
|
||||
.ToPagedResultAsync(query, ToDto)));
|
||||
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<ColorVarietyDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var v = await db.ColorVarieties.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||
return v is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(v));
|
||||
});
|
||||
|
||||
group.MapPost("/", async (ColorVarietyInput input, ApplicationContext db) =>
|
||||
{
|
||||
var v = new ColorVariety
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = input.Name,
|
||||
CanonicalGenotype = input.CanonicalGenotype,
|
||||
SortOrder = input.SortOrder ?? 1000,
|
||||
};
|
||||
db.ColorVarieties.Add(v);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/color-varieties/{v.Id}", ToDto(v));
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ColorVarietyInput input, ApplicationContext db) =>
|
||||
{
|
||||
var v = await db.ColorVarieties.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (v is null) return TypedResults.NotFound();
|
||||
v.Name = input.Name;
|
||||
v.CanonicalGenotype = input.CanonicalGenotype;
|
||||
if (input.SortOrder is int so) v.SortOrder = so;
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound, Conflict<string>>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var v = await db.ColorVarieties.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (v is null) return TypedResults.NotFound();
|
||||
bool inUse = await db.Gerbils.AnyAsync(g => g.ColorVarietyId == id);
|
||||
if (inUse) return TypedResults.Conflict("Color variety is in use by one or more gerbils and cannot be deleted.");
|
||||
db.ColorVarieties.Remove(v);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static ColorVarietyDto ToDto(ColorVariety v) => new(v.Id, v.Name, v.CanonicalGenotype, v.SortOrder);
|
||||
}
|
||||
}
|
||||
60
GerbilManagerWebAPI/Endpoints/ContactEndpoints.cs
Normal file
60
GerbilManagerWebAPI/Endpoints/ContactEndpoints.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using GerbilManagerWebAPI.Common;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Gridify;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
public static class ContactEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapContactEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/contacts").WithTags("Contacts");
|
||||
|
||||
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.Contacts.AsNoTracking().ToPagedResultAsync(query, ToDto)));
|
||||
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<ContactDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var c = await db.Contacts.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||
return c is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(c));
|
||||
});
|
||||
|
||||
group.MapPost("/", async (ContactInput input, ApplicationContext db) =>
|
||||
{
|
||||
var c = new Contact { Id = Guid.NewGuid(), Name = input.Name, Email = input.Email, Phone = input.Phone, Address = input.Address, Notes = input.Notes, IsBreeder = input.IsBreeder, IsReceiver = input.IsReceiver, NameSuffix = input.NameSuffix };
|
||||
db.Contacts.Add(c);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/contacts/{c.Id}", ToDto(c));
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ContactInput input, ApplicationContext db) =>
|
||||
{
|
||||
var c = await db.Contacts.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (c is null) return TypedResults.NotFound();
|
||||
c.Name = input.Name; c.Email = input.Email; c.Phone = input.Phone; c.Address = input.Address; c.Notes = input.Notes;
|
||||
c.IsBreeder = input.IsBreeder; c.IsReceiver = input.IsReceiver; c.NameSuffix = input.NameSuffix;
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
// 409 if any gerbil references this contact as origin or receiver.
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound, Conflict<string>>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var c = await db.Contacts.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (c is null) return TypedResults.NotFound();
|
||||
bool linked = await db.Gerbils.AnyAsync(g => g.OriginContactId == id || g.ReceiverContactId == id);
|
||||
if (linked) return TypedResults.Conflict("Contact is linked to one or more gerbils and cannot be deleted.");
|
||||
db.Contacts.Remove(c);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static ContactDto ToDto(Contact c) => new(c.Id, c.Name, c.Email, c.Phone, c.Address, c.Notes, c.IsBreeder, c.IsReceiver, c.NameSuffix);
|
||||
}
|
||||
}
|
||||
280
GerbilManagerWebAPI/Endpoints/ContractEndpoints.cs
Normal file
280
GerbilManagerWebAPI/Endpoints/ContractEndpoints.cs
Normal file
@@ -0,0 +1,280 @@
|
||||
using GerbilManagerWebAPI.Common;
|
||||
using GerbilManagerWebAPI.Contracts;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-13: Abgabeverträge.
|
||||
/// POST /contracts -> erzeugt .docx (phase-A-Generator), speichert sie im
|
||||
/// Vertrags-Dateiroot, legt die Vertragszeile an und stellt
|
||||
/// die Tiere in DERSELBEN Transaktion auf Abgegeben
|
||||
/// (ReceiverContactId, GoHomeDate, Status).
|
||||
/// GET /contracts -> Gridify-paged (z. B. filter=contactId==…)
|
||||
/// GET /contracts/{id} -> Metadaten
|
||||
/// GET /contracts/{id}/file -> .docx-Download (deutscher Dateiname)
|
||||
/// DELETE /contracts/{id} -> entfernt Zeile + Datei (Tier-Status bleibt unberührt)
|
||||
/// </summary>
|
||||
public static class ContractEndpoints
|
||||
{
|
||||
private const string DocxContentType =
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
private const string PdfContentType = "application/pdf";
|
||||
|
||||
public static IEndpointRouteBuilder MapContractEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/contracts").WithTags("Contracts");
|
||||
|
||||
// GET /contracts (Gridify; GerbilIds via Include mitgeladen)
|
||||
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.SaleContracts.AsNoTracking()
|
||||
.Include(c => c.Animals)
|
||||
.ToPagedResultAsync(query, ToDto)));
|
||||
|
||||
// GET /contracts/{id}
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<SaleContractDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var c = await db.SaleContracts.AsNoTracking()
|
||||
.Include(x => x.Animals)
|
||||
.FirstOrDefaultAsync(x => x.Id == id);
|
||||
return c is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(c));
|
||||
});
|
||||
|
||||
// POST /contracts — der Abgabe-Abschluss.
|
||||
group.MapPost("/", async Task<Results<Created<SaleContractDto>, ValidationProblem>> (
|
||||
SaleContractInput input, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
var errors = new Dictionary<string, string[]>();
|
||||
|
||||
var gerbilIds = (input.GerbilIds ?? []).Distinct().ToList();
|
||||
var photoChoice = input.AnimalPhotos ?? [];
|
||||
if (gerbilIds.Count == 0)
|
||||
errors["gerbilIds"] = ["Mindestens ein Tier auswählen."];
|
||||
if (input.Price < 0)
|
||||
errors["price"] = ["Der Kaufpreis darf nicht negativ sein."];
|
||||
|
||||
var contact = await db.Contacts.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == input.ContactId);
|
||||
if (contact is null)
|
||||
errors["contactId"] = ["Der Abnehmer wurde nicht gefunden."];
|
||||
|
||||
var gerbils = await db.Gerbils
|
||||
.Include(g => g.ColorVariety)
|
||||
.Where(g => gerbilIds.Contains(g.Id))
|
||||
.ToListAsync();
|
||||
if (gerbils.Count != gerbilIds.Count)
|
||||
errors["gerbilIds"] = ["Mindestens ein ausgewähltes Tier wurde nicht gefunden."];
|
||||
|
||||
if (errors.Count > 0) return TypedResults.ValidationProblem(errors);
|
||||
|
||||
var settings = await db.BreederSettings.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Id == BreederSettings.SingletonId)
|
||||
?? new BreederSettings();
|
||||
|
||||
var contractDate = input.ContractDate ?? input.HandoverDate;
|
||||
var data = new ContractData(
|
||||
Seller: ToSeller(settings),
|
||||
Buyer: new ContractBuyer(contact!.Name, contact.Address ?? "", contact.Phone, contact.Email),
|
||||
Animals: gerbils
|
||||
.Select(g => new ContractAnimal(g.Name, GeschlechtText(g.Gender), g.DateOfBirth, g.ColorVariety?.Name))
|
||||
.ToList(),
|
||||
Price: input.Price,
|
||||
HandoverDate: input.HandoverDate,
|
||||
ContractDate: contractDate);
|
||||
|
||||
var bytes = ContractGenerator.Generate(data);
|
||||
|
||||
var fileName = $"{Guid.NewGuid():N}.docx";
|
||||
var root = ContractRoot(config, env);
|
||||
Directory.CreateDirectory(root);
|
||||
await File.WriteAllBytesAsync(Path.Combine(root, fileName), bytes);
|
||||
|
||||
var entity = new SaleContract
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ContactId = contact.Id,
|
||||
Price = input.Price,
|
||||
HandoverDate = input.HandoverDate,
|
||||
ContractDate = contractDate,
|
||||
FileName = fileName,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Animals = gerbilIds.Select(id => new SaleContractAnimal
|
||||
{
|
||||
GerbilId = id,
|
||||
PhotoId = photoChoice.TryGetValue(id, out var pid) ? pid : null,
|
||||
}).ToList(),
|
||||
};
|
||||
db.SaleContracts.Add(entity);
|
||||
|
||||
// Abgabe-Abschluss-Semantik: in derselben SaveChanges-Transaktion.
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
foreach (var g in gerbils)
|
||||
{
|
||||
g.ReceiverContactId = contact.Id;
|
||||
g.GoHomeDate = input.HandoverDate;
|
||||
GerbilStatusService.Apply(g, today);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// DB fehlgeschlagen -> verwaiste Datei wieder aufräumen.
|
||||
var orphan = Path.Combine(root, fileName);
|
||||
if (File.Exists(orphan)) File.Delete(orphan);
|
||||
throw;
|
||||
}
|
||||
|
||||
return TypedResults.Created($"/contracts/{entity.Id}", ToDto(entity));
|
||||
});
|
||||
|
||||
// GET /contracts/{id}/file — Download mit sprechendem deutschen Dateinamen.
|
||||
group.MapGet("/{id:guid}/file", async Task<Results<PhysicalFileHttpResult, NotFound>> (
|
||||
Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
var c = await db.SaleContracts.AsNoTracking()
|
||||
.Include(x => x.Contact)
|
||||
.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (c is null) return TypedResults.NotFound();
|
||||
|
||||
var path = Path.Combine(ContractRoot(config, env), c.FileName);
|
||||
if (!File.Exists(path)) return TypedResults.NotFound();
|
||||
|
||||
var download = $"Abgabevertrag_{c.ContractDate:yyyy-MM-dd}_{Sanitize(c.Contact?.Name)}.docx";
|
||||
return TypedResults.PhysicalFile(path, DocxContentType, download);
|
||||
});
|
||||
|
||||
// GET /contracts/{id}/pdf — druckfertiges PDF, aus den (aktuellen) Daten regeneriert.
|
||||
group.MapGet("/{id:guid}/pdf", async Task<Results<FileContentHttpResult, NotFound>> (
|
||||
Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
var c = await db.SaleContracts.AsNoTracking()
|
||||
.Include(x => x.Contact)
|
||||
.Include(x => x.Animals)
|
||||
.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (c is null || c.Contact is null) return TypedResults.NotFound();
|
||||
|
||||
var gerbilIds = c.Animals.Select(a => a.GerbilId).ToList();
|
||||
var gerbils = await db.Gerbils.AsNoTracking()
|
||||
.Include(g => g.ColorVariety)
|
||||
.Where(g => gerbilIds.Contains(g.Id))
|
||||
.ToListAsync();
|
||||
var settings = await db.BreederSettings.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Id == BreederSettings.SingletonId)
|
||||
?? new BreederSettings();
|
||||
|
||||
// Gewählte Vertragsfotos nachladen (Foto muss zum Tier gehören und die Datei
|
||||
// existieren — sonst bleibt das Bild leer statt zu scheitern).
|
||||
var photoByGerbil = c.Animals
|
||||
.Where(a => a.PhotoId is not null)
|
||||
.ToDictionary(a => a.GerbilId, a => a.PhotoId!.Value);
|
||||
var photoBytes = await LoadAnimalPhotos(db, photoByGerbil, PhotoRoot(config, env));
|
||||
|
||||
var data = new ContractData(
|
||||
Seller: ToSeller(settings),
|
||||
Buyer: new ContractBuyer(c.Contact.Name, c.Contact.Address ?? "", c.Contact.Phone, c.Contact.Email),
|
||||
Animals: gerbils
|
||||
.Select(g => new ContractAnimal(
|
||||
g.Name, GeschlechtText(g.Gender), g.DateOfBirth, g.ColorVariety?.Name,
|
||||
photoBytes.GetValueOrDefault(g.Id)))
|
||||
.ToList(),
|
||||
Price: c.Price,
|
||||
HandoverDate: c.HandoverDate,
|
||||
ContractDate: c.ContractDate);
|
||||
|
||||
var pdf = ContractPdfGenerator.Generate(data);
|
||||
var download = $"Abgabevertrag_{c.ContractDate:yyyy-MM-dd}_{Sanitize(c.Contact.Name)}.pdf";
|
||||
return TypedResults.File(pdf, PdfContentType, download);
|
||||
});
|
||||
|
||||
// DELETE /contracts/{id} — Zeile + Datei; Tier-Status wird NICHT zurückgedreht
|
||||
// (das wäre Magie — Status korrigiert man am Tier selbst).
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (
|
||||
Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
var c = await db.SaleContracts.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (c is null) return TypedResults.NotFound();
|
||||
|
||||
var path = Path.Combine(ContractRoot(config, env), c.FileName);
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
|
||||
db.SaleContracts.Remove(c); // Joins kaskadieren
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static string ContractRoot(IConfiguration config, IWebHostEnvironment env) =>
|
||||
config["Contracts:RootPath"] ?? Path.Combine(env.ContentRootPath, "contract-storage");
|
||||
|
||||
/// <summary>Foto-Dateiroot (geteilt mit den Foto-Endpoints).</summary>
|
||||
private static string PhotoRoot(IConfiguration config, IWebHostEnvironment env) =>
|
||||
config["Photos:RootPath"] ?? Path.Combine(env.ContentRootPath, "photo-storage");
|
||||
|
||||
/// <summary>Lädt die gewählten Vertragsfotos als Rohbytes (GerbilId -> Bytes).
|
||||
/// Überspringt Fotos, die nicht zum Tier gehören oder deren Datei fehlt.</summary>
|
||||
private static async Task<Dictionary<Guid, byte[]>> LoadAnimalPhotos(
|
||||
ApplicationContext db, Dictionary<Guid, Guid> photoByGerbil, string photoRoot)
|
||||
{
|
||||
var result = new Dictionary<Guid, byte[]>();
|
||||
if (photoByGerbil.Count == 0) return result;
|
||||
|
||||
var photoIds = photoByGerbil.Values.ToList();
|
||||
var photos = await db.GerbilPhotos.AsNoTracking()
|
||||
.Where(p => photoIds.Contains(p.Id))
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var (gerbilId, photoId) in photoByGerbil)
|
||||
{
|
||||
var photo = photos.FirstOrDefault(p => p.Id == photoId && p.GerbilId == gerbilId);
|
||||
if (photo is null) continue;
|
||||
var path = Path.Combine(photoRoot, photo.FileName);
|
||||
if (File.Exists(path))
|
||||
result[gerbilId] = await File.ReadAllBytesAsync(path);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static BreederProfile ToSeller(BreederSettings s) => new()
|
||||
{
|
||||
ZuchtName = s.ZuchtName,
|
||||
Name = s.Name,
|
||||
Address = s.Address,
|
||||
Phone = s.Phone,
|
||||
Email = s.Email,
|
||||
Homepage = s.Homepage,
|
||||
City = s.City,
|
||||
};
|
||||
|
||||
/// <summary>Deutscher Anzeigetext fürs Geschlecht (Vertragsdokument).</summary>
|
||||
internal static string GeschlechtText(Gender gender) => gender switch
|
||||
{
|
||||
Gender.male => "Männlich",
|
||||
Gender.female => "Weiblich",
|
||||
_ => "Unbekannt",
|
||||
};
|
||||
|
||||
/// <summary>Kontaktname → Dateinamens-tauglich (Umlaute bleiben, Trenner -> '-').</summary>
|
||||
private static string Sanitize(string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) return "Abnehmer";
|
||||
var cleaned = new string(name.Trim()
|
||||
.Select(ch => char.IsLetterOrDigit(ch) ? ch : '-')
|
||||
.ToArray());
|
||||
return cleaned.Trim('-');
|
||||
}
|
||||
|
||||
internal static SaleContractDto ToDto(SaleContract c) => new(
|
||||
c.Id, c.ContactId, c.Price, c.HandoverDate, c.ContractDate, c.FileName, c.CreatedAt,
|
||||
c.Animals.Select(a => a.GerbilId).ToList(),
|
||||
$"/contracts/{c.Id}/file");
|
||||
}
|
||||
}
|
||||
59
GerbilManagerWebAPI/Endpoints/EnclosureEndpoints.cs
Normal file
59
GerbilManagerWebAPI/Endpoints/EnclosureEndpoints.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using GerbilManagerWebAPI.Common;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Gridify;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
public static class EnclosureEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapEnclosureEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/enclosures").WithTags("Enclosures");
|
||||
|
||||
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.Enclosures.AsNoTracking().ToPagedResultAsync(query, ToDto)));
|
||||
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<EnclosureDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var e = await db.Enclosures.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||
return e is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(e));
|
||||
});
|
||||
|
||||
group.MapPost("/", async (EnclosureInput input, ApplicationContext db) =>
|
||||
{
|
||||
var e = new Enclosure { Id = Guid.NewGuid(), Name = input.Name, Notes = input.Notes };
|
||||
db.Enclosures.Add(e);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/enclosures/{e.Id}", ToDto(e));
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, EnclosureInput input, ApplicationContext db) =>
|
||||
{
|
||||
var e = await db.Enclosures.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (e is null) return TypedResults.NotFound();
|
||||
e.Name = input.Name; e.Notes = input.Notes;
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
// 409 if the enclosure still houses gerbils.
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound, Conflict<string>>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var e = await db.Enclosures.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (e is null) return TypedResults.NotFound();
|
||||
bool occupied = await db.Gerbils.AnyAsync(g => g.EnclosureId == id);
|
||||
if (occupied) return TypedResults.Conflict("Enclosure still contains gerbils and cannot be deleted.");
|
||||
db.Enclosures.Remove(e);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static EnclosureDto ToDto(Enclosure e) => new(e.Id, e.Name, e.Notes);
|
||||
}
|
||||
}
|
||||
36
GerbilManagerWebAPI/Endpoints/ExportEndpoints.cs
Normal file
36
GerbilManagerWebAPI/Endpoints/ExportEndpoints.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using GerbilManagerWebAPI.Export;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// EXPORT-1: GET /export — komplette Datensicherung als Zip
|
||||
/// (export.json voll, CSVs deutsch/Excel-freundlich, LIESMICH.txt; OHNE Fotos).
|
||||
/// </summary>
|
||||
public static class ExportEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapExportEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/export", async (ApplicationContext db, CancellationToken ct) =>
|
||||
{
|
||||
var data = new ExportService.ExportData(
|
||||
await db.Gerbils.AsNoTracking().ToListAsync(ct),
|
||||
await db.Litters.AsNoTracking().ToListAsync(ct),
|
||||
await db.Contacts.AsNoTracking().ToListAsync(ct),
|
||||
await db.Enclosures.AsNoTracking().ToListAsync(ct),
|
||||
await db.ColorVarieties.AsNoTracking().ToListAsync(ct),
|
||||
await db.HealthRecords.AsNoTracking().ToListAsync(ct),
|
||||
await db.WeightRecords.AsNoTracking().ToListAsync(ct),
|
||||
await db.GerbilPhotos.AsNoTracking().ToListAsync(ct));
|
||||
|
||||
var today = DateOnly.FromDateTime(DateTime.Now);
|
||||
var bytes = ExportService.BuildZip(data, today);
|
||||
return Results.File(bytes, "application/zip",
|
||||
$"rennmaus-export-{today:yyyy-MM-dd}.zip");
|
||||
})
|
||||
.WithTags("Export");
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
}
|
||||
142
GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs
Normal file
142
GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs
Normal file
@@ -0,0 +1,142 @@
|
||||
using GerbilManagerWebAPI.Common;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
using Gridify;
|
||||
using Gridify.EntityFramework;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
public static class GerbilEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapGerbilEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/gerbils").WithTags("Gerbils");
|
||||
|
||||
// GET /gerbils (Gridify: filter/order/page; e.g. status==Active, litterId==…, orderBy=name)
|
||||
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
|
||||
{
|
||||
var q = query.ToQuery();
|
||||
var paging = await db.Gerbils.AsNoTracking().GridifyAsync(q);
|
||||
var ids = paging.Data.Select(g => g.Id).ToList();
|
||||
var photoMap = ids.Count == 0 ? new Dictionary<Guid, string>() :
|
||||
await db.GerbilPhotos.AsNoTracking()
|
||||
.Where(p => ids.Contains(p.GerbilId))
|
||||
.GroupBy(p => p.GerbilId)
|
||||
.Select(g => new { GerbilId = g.Key, FileName = g.OrderBy(p => p.SortOrder).First().FileName })
|
||||
.ToDictionaryAsync(x => x.GerbilId, x => x.FileName);
|
||||
var items = paging.Data
|
||||
.Select(g => ToDto(g, photoMap.TryGetValue(g.Id, out var fn) ? $"/photos/files/{fn}" : null))
|
||||
.ToList();
|
||||
return TypedResults.Ok(new PagedResult<GerbilDto>(items, paging.Count, q.Page, q.PageSize));
|
||||
});
|
||||
|
||||
// GET /gerbils/breeders — distinct non-empty Herkunft values for the Tiere filter dropdown
|
||||
group.MapGet("/breeders", async (ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.Gerbils.AsNoTracking()
|
||||
.Where(g => g.OriginBreeder != null && g.OriginBreeder != "")
|
||||
.Select(g => g.OriginBreeder!)
|
||||
.Distinct().OrderBy(b => b).ToListAsync()));
|
||||
|
||||
// GET /gerbils/{id}
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<GerbilDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var g = await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (g is null) return TypedResults.NotFound();
|
||||
var photoFileName = await db.GerbilPhotos.AsNoTracking()
|
||||
.Where(p => p.GerbilId == id)
|
||||
.OrderBy(p => p.SortOrder)
|
||||
.Select(p => p.FileName)
|
||||
.FirstOrDefaultAsync();
|
||||
var profilePhotoUrl = photoFileName != null ? $"/photos/files/{photoFileName}" : null;
|
||||
return TypedResults.Ok(ToDto(g, profilePhotoUrl));
|
||||
});
|
||||
|
||||
// POST /gerbils
|
||||
group.MapPost("/", async Task<Results<Created<GerbilDto>, ValidationProblem>> (GerbilInput input, ApplicationContext db) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.Name))
|
||||
return TypedResults.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
|
||||
|
||||
var g = new Gerbil { Id = Guid.NewGuid(), Name = input.Name! };
|
||||
Apply(g, input, isCreate: true);
|
||||
db.Gerbils.Add(g);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/gerbils/{g.Id}", ToDto(g));
|
||||
});
|
||||
|
||||
// PUT /gerbils/{id} — PATCH semantics: omitted/null fields keep the stored value.
|
||||
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, GerbilInput input, ApplicationContext db) =>
|
||||
{
|
||||
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (g is null) return TypedResults.NotFound();
|
||||
Apply(g, input, isCreate: false);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
// DELETE /gerbils/{id} (409 if referenced as a litter parent)
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound, Conflict<string>>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (g is null) return TypedResults.NotFound();
|
||||
db.Gerbils.Remove(g);
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
return TypedResults.Conflict("Gerbil is referenced as a litter parent and cannot be deleted.");
|
||||
}
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// CR-2 FIX: PATCH semantics — every omitted/null field keeps the stored value.
|
||||
// Prevents silent data loss when the frontend sends partial bodies (ForSale toggle,
|
||||
// Charakterbogen save, any partial updateGerbil call). On create, supply safe defaults
|
||||
// for fields the frontend omits. A non-null input value always wins (including explicit
|
||||
// nulls — callers that want to clear a nullable field must send a full object; a
|
||||
// dedicated PATCH endpoint can be added later if point-clear is needed).
|
||||
private static void Apply(Gerbil g, GerbilInput i, bool isCreate)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(i.Name)) g.Name = i.Name!;
|
||||
g.Gender = i.Gender ?? (isCreate ? Gender.unknown : g.Gender);
|
||||
// Status is applied as a user preference and then overridden by GerbilStatusService.
|
||||
g.Status = i.Status ?? (isCreate ? GerbilStatus.Breeding : g.Status);
|
||||
g.LitterId = i.LitterId ?? g.LitterId;
|
||||
g.OriginContactId = i.OriginContactId ?? g.OriginContactId;
|
||||
g.ReceiverContactId = i.ReceiverContactId ?? g.ReceiverContactId;
|
||||
g.EnclosureId = i.EnclosureId ?? g.EnclosureId;
|
||||
g.ColorVarietyId = i.ColorVarietyId ?? g.ColorVarietyId;
|
||||
g.DateOfBirth = i.DateOfBirth ?? g.DateOfBirth;
|
||||
g.DateOfDeath = i.DateOfDeath ?? g.DateOfDeath;
|
||||
g.CauseOfDeath = i.CauseOfDeath ?? g.CauseOfDeath;
|
||||
g.GoHomeDate = i.GoHomeDate ?? g.GoHomeDate;
|
||||
g.Genotype = i.Genotype ?? g.Genotype;
|
||||
g.SpottingType = i.SpottingType ?? g.SpottingType;
|
||||
g.Notes = i.Notes ?? g.Notes;
|
||||
g.ImportSource = i.ImportSource ?? g.ImportSource;
|
||||
g.ExternalRef = i.ExternalRef ?? g.ExternalRef;
|
||||
g.OriginBreeder = i.OriginBreeder ?? g.OriginBreeder;
|
||||
g.CharacterTraits = i.CharacterTraits ?? g.CharacterTraits;
|
||||
g.CharacterNote = i.CharacterNote ?? g.CharacterNote;
|
||||
g.IsDeaf = i.IsDeaf ?? g.IsDeaf;
|
||||
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
|
||||
g.IsCastrated = i.IsCastrated ?? (isCreate ? false : g.IsCastrated);
|
||||
GerbilStatusService.Apply(g, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
}
|
||||
|
||||
internal static GerbilDto ToDto(Gerbil g, string? profilePhotoUrl = null) => new(
|
||||
g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId,
|
||||
g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath,
|
||||
g.GoHomeDate, g.Genotype, g.SpottingType, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
|
||||
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident, profilePhotoUrl, g.IsCastrated);
|
||||
}
|
||||
}
|
||||
28
GerbilManagerWebAPI/Endpoints/ImportDocxEndpoints.cs
Normal file
28
GerbilManagerWebAPI/Endpoints/ImportDocxEndpoints.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
public static class ImportDocxEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapImportDocxEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/import/docx").WithTags("Import");
|
||||
|
||||
// POST /import/docx/dry-run — analyse without writing
|
||||
group.MapPost("/dry-run",
|
||||
async Task<Ok<ImportDocxReport>> (
|
||||
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
TypedResults.Ok(await new ImportDocxService(db, config, env).RunAsync(execute: false)));
|
||||
|
||||
// POST /import/docx/execute — GATED: enriches Gerbils with Litter-Link,
|
||||
// ReceiverContact, GoHomeDate, DateOfDeath, CauseOfDeath (fill-NULL-only).
|
||||
group.MapPost("/execute",
|
||||
async Task<Ok<ImportDocxReport>> (
|
||||
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
TypedResults.Ok(await new ImportDocxService(db, config, env).RunAsync(execute: true)));
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
}
|
||||
42
GerbilManagerWebAPI/Endpoints/ImportEndpoints.cs
Normal file
42
GerbilManagerWebAPI/Endpoints/ImportEndpoints.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-8 stage 3 — spreadsheet import loader. Consumes tools/import/output.
|
||||
/// POST /import/dry-run -> report what WOULD be created/linked/quarantined (no writes)
|
||||
/// POST /import/execute -> load the conflict-free subset (idempotent). GATED: only run
|
||||
/// against Julian's DB under god-supervision after he approves the dry-run.
|
||||
/// </summary>
|
||||
public static class ImportEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapImportEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/import").WithTags("Import");
|
||||
|
||||
group.MapPost("/dry-run", async Task<Ok<ImportReport>> (
|
||||
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
var report = await new ImportService(db, config, env).RunAsync(execute: false);
|
||||
return TypedResults.Ok(report);
|
||||
});
|
||||
|
||||
group.MapPost("/execute", async Task<Ok<ImportReport>> (
|
||||
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
var report = await new ImportService(db, config, env).RunAsync(execute: true);
|
||||
return TypedResults.Ok(report);
|
||||
});
|
||||
|
||||
group.MapPost("/ingest-resolved", async Task<Ok<string>> (
|
||||
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
var result = await new IngestResolvedService(db, config, env).RunAsync();
|
||||
return TypedResults.Ok(result);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
}
|
||||
33
GerbilManagerWebAPI/Endpoints/InbreedingEndpoints.cs
Normal file
33
GerbilManagerWebAPI/Endpoints/InbreedingEndpoints.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Genetics;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Inzuchtkoeffizient endpoints (FEAT-1b). Routes/response shapes are IDENTICAL to
|
||||
/// the former InbreedingController — only the hosting style changed to Minimal API.
|
||||
/// </summary>
|
||||
public static class InbreedingEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapInbreedingEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
// GET /gerbils/{id}/inbreeding-coefficient
|
||||
app.MapGet("/gerbils/{id:guid}/inbreeding-coefficient",
|
||||
Results<Ok<InbreedingResult>, NotFound> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var result = new InbreedingService(db).ForGerbil(id);
|
||||
return result is null ? TypedResults.NotFound() : TypedResults.Ok(result);
|
||||
})
|
||||
.WithTags("Inbreeding");
|
||||
|
||||
// POST /genetics/test-inbreeding
|
||||
app.MapPost("/genetics/test-inbreeding",
|
||||
Ok<InbreedingResult> (TestInbreedingDto dto, ApplicationContext db) =>
|
||||
TypedResults.Ok(new InbreedingService(db).ForPairing(dto.FatherId, dto.MotherId)))
|
||||
.WithTags("Inbreeding");
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
}
|
||||
106
GerbilManagerWebAPI/Endpoints/LitterEndpoints.cs
Normal file
106
GerbilManagerWebAPI/Endpoints/LitterEndpoints.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using GerbilManagerWebAPI.Common;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Gridify;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
public static class LitterEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapLitterEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/litters").WithTags("Litters");
|
||||
|
||||
// GET /litters (Gridify: date range + orderBy=date supported on the DateOnly column)
|
||||
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.Litters.AsNoTracking().ToPagedResultAsync(query, ToDto)));
|
||||
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<LitterDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var l = await db.Litters.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||
return l is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(l));
|
||||
});
|
||||
|
||||
group.MapPost("/", async Task<Results<Created<LitterDto>, BadRequest<ParentGenderError>, BadRequest<string>>> (LitterInput input, ApplicationContext db) =>
|
||||
{
|
||||
var err = await ValidateParents(db, input.FatherId, input.MotherId);
|
||||
if (err is not null) return TypedResults.BadRequest(err);
|
||||
var mortalityErr = ValidateMortality(input.DeathsWithin8Weeks, input.TotalBorn);
|
||||
if (mortalityErr is not null) return TypedResults.BadRequest(mortalityErr);
|
||||
|
||||
var l = new Litter { Id = Guid.NewGuid(), Name = input.Name, Date = input.Date };
|
||||
Apply(l, input);
|
||||
db.Litters.Add(l);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/litters/{l.Id}", ToDto(l));
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound, BadRequest<ParentGenderError>, BadRequest<string>>> (Guid id, LitterInput input, ApplicationContext db) =>
|
||||
{
|
||||
var l = await db.Litters.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (l is null) return TypedResults.NotFound();
|
||||
var err = await ValidateParents(db, input.FatherId, input.MotherId);
|
||||
if (err is not null) return TypedResults.BadRequest(err);
|
||||
var mortalityErr = ValidateMortality(input.DeathsWithin8Weeks, input.TotalBorn);
|
||||
if (mortalityErr is not null) return TypedResults.BadRequest(mortalityErr);
|
||||
l.Name = input.Name;
|
||||
l.Date = input.Date;
|
||||
Apply(l, input);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var l = await db.Litters.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (l is null) return TypedResults.NotFound();
|
||||
db.Litters.Remove(l);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// father must not be female; mother must not be male (unknown is allowed).
|
||||
private static async Task<ParentGenderError?> ValidateParents(ApplicationContext db, Guid? fatherId, Guid? motherId)
|
||||
{
|
||||
var father = fatherId is Guid f ? await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == f) : null;
|
||||
var mother = motherId is Guid m ? await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == m) : null;
|
||||
if (father?.Gender == Gender.female || mother?.Gender == Gender.male)
|
||||
{
|
||||
return new ParentGenderError("InvalidParentGender",
|
||||
father?.Gender ?? Gender.unknown, mother?.Gender ?? Gender.unknown);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void Apply(Litter l, LitterInput i)
|
||||
{
|
||||
l.TotalBorn = i.TotalBorn;
|
||||
l.DeathsWithin8Weeks = i.DeathsWithin8Weeks;
|
||||
l.FatherId = i.FatherId;
|
||||
l.MotherId = i.MotherId;
|
||||
l.ExpectedGoHomeDate = i.ExpectedGoHomeDate;
|
||||
l.Notes = i.Notes;
|
||||
}
|
||||
|
||||
private static string? ValidateMortality(int? deaths, int? totalBorn)
|
||||
{
|
||||
if (deaths is null) return null;
|
||||
if (deaths < 0) return "DeathsWithin8Weeks darf nicht negativ sein.";
|
||||
if (totalBorn is not null && deaths > totalBorn)
|
||||
return "DeathsWithin8Weeks darf nicht größer als TotalBorn sein.";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static LitterDto ToDto(Litter l) => new(
|
||||
l.Id, l.Name, l.Date, l.TotalBorn, l.DeathsWithin8Weeks,
|
||||
l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes);
|
||||
}
|
||||
|
||||
/// <summary>400 body for a father×mother gender mismatch; frontend localises by Code.</summary>
|
||||
public record ParentGenderError(string Code, Gender FatherGender, Gender MotherGender);
|
||||
}
|
||||
38
GerbilManagerWebAPI/Endpoints/NamesEndpoints.cs
Normal file
38
GerbilManagerWebAPI/Endpoints/NamesEndpoints.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using GerbilManagerWebAPI.Names;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-NAMEGEN: GET /names/suggest — meaningful gerbil name suggestions via Gemini.
|
||||
///
|
||||
/// 503 {code:"NamesKeyMissing"} while the AI section is unconfigured — the
|
||||
/// frontend maps exactly this code to its German disabled-state hint.
|
||||
/// </summary>
|
||||
public static class NamesEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapNamesEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/names/suggest", async (
|
||||
string? letter,
|
||||
string? gender,
|
||||
string? usages,
|
||||
int count,
|
||||
NameSuggestionService service,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var result = await service.SuggestAsync(letter, gender, usages, count, ct);
|
||||
return result.Status switch
|
||||
{
|
||||
NameSuggestionStatus.Ok => Results.Ok(result.Suggestions),
|
||||
NameSuggestionStatus.NotConfigured => Results.Json(
|
||||
new { code = "NamesKeyMissing", message = result.Error ?? "" }, statusCode: 503),
|
||||
_ => Results.Json(
|
||||
new { code = "NamesUpstreamError", message = result.Error ?? "" }, statusCode: 502),
|
||||
};
|
||||
})
|
||||
.WithTags("Names");
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
}
|
||||
163
GerbilManagerWebAPI/Endpoints/PhotoEndpoints.cs
Normal file
163
GerbilManagerWebAPI/Endpoints/PhotoEndpoints.cs
Normal file
@@ -0,0 +1,163 @@
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Gerbil photo endpoints (FEAT-1b phase 2), per the contract agreed with Oscar:
|
||||
/// GET /gerbils/{id}/photos -> plain array (not paged)
|
||||
/// POST /gerbils/{id}/photos -> multipart 'file' + optional 'caption' -> 201 PhotoDto
|
||||
/// DELETE /photos/{id} -> 204
|
||||
/// GET /photos/files/{fileName} -> the image bytes
|
||||
/// url = "/photos/files/{fileName}"; profile photo = first by SortOrder.
|
||||
/// </summary>
|
||||
public static class PhotoEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapPhotoEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/gerbils/{id:guid}/photos",
|
||||
async Task<Results<Ok<List<PhotoDto>>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
if (!await db.Gerbils.AnyAsync(g => g.Id == id)) return TypedResults.NotFound();
|
||||
var photos = await db.GerbilPhotos.AsNoTracking()
|
||||
.Where(p => p.GerbilId == id)
|
||||
.OrderBy(p => p.SortOrder)
|
||||
.ToListAsync();
|
||||
return TypedResults.Ok(photos.Select(ToDto).ToList());
|
||||
}).WithTags("Photos");
|
||||
|
||||
app.MapPost("/gerbils/{id:guid}/photos",
|
||||
async Task<Results<Created<PhotoDto>, NotFound, BadRequest<string>>> (
|
||||
Guid id, IFormFile file, [Microsoft.AspNetCore.Mvc.FromForm] string? caption,
|
||||
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
if (!await db.Gerbils.AnyAsync(g => g.Id == id)) return TypedResults.NotFound();
|
||||
if (file is null || file.Length == 0) return TypedResults.BadRequest("No file uploaded.");
|
||||
|
||||
var ext = Path.GetExtension(file.FileName);
|
||||
var fileName = $"{Guid.NewGuid():N}{ext}";
|
||||
var root = PhotoRoot(config, env);
|
||||
Directory.CreateDirectory(root);
|
||||
await using (var stream = File.Create(Path.Combine(root, fileName)))
|
||||
await file.CopyToAsync(stream);
|
||||
|
||||
int nextSort = (await db.GerbilPhotos.Where(p => p.GerbilId == id)
|
||||
.Select(p => (int?)p.SortOrder).MaxAsync() ?? -1) + 1;
|
||||
|
||||
var photo = new GerbilPhoto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GerbilId = id,
|
||||
FileName = fileName,
|
||||
Caption = caption,
|
||||
SortOrder = nextSort,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
db.GerbilPhotos.Add(photo);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/photos/{photo.Id}", ToDto(photo));
|
||||
}).WithTags("Photos").DisableAntiforgery();
|
||||
|
||||
app.MapDelete("/photos/{id:guid}",
|
||||
async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
var photo = await db.GerbilPhotos.FirstOrDefaultAsync(p => p.Id == id);
|
||||
if (photo is null) return TypedResults.NotFound();
|
||||
var path = Path.Combine(PhotoRoot(config, env), photo.FileName);
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
db.GerbilPhotos.Remove(photo);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
}).WithTags("Photos");
|
||||
|
||||
// ── Gehege (enclosure) photos — mirror the gerbil endpoints, shared file store ──
|
||||
app.MapGet("/enclosures/{id:guid}/photos",
|
||||
async Task<Results<Ok<List<PhotoDto>>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
if (!await db.Enclosures.AnyAsync(e => e.Id == id)) return TypedResults.NotFound();
|
||||
var photos = await db.EnclosurePhotos.AsNoTracking()
|
||||
.Where(p => p.EnclosureId == id)
|
||||
.OrderBy(p => p.SortOrder)
|
||||
.ToListAsync();
|
||||
return TypedResults.Ok(photos.Select(ToEnclosureDto).ToList());
|
||||
}).WithTags("Photos");
|
||||
|
||||
app.MapPost("/enclosures/{id:guid}/photos",
|
||||
async Task<Results<Created<PhotoDto>, NotFound, BadRequest<string>>> (
|
||||
Guid id, IFormFile file, [Microsoft.AspNetCore.Mvc.FromForm] string? caption,
|
||||
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
if (!await db.Enclosures.AnyAsync(e => e.Id == id)) return TypedResults.NotFound();
|
||||
if (file is null || file.Length == 0) return TypedResults.BadRequest("No file uploaded.");
|
||||
|
||||
var ext = Path.GetExtension(file.FileName);
|
||||
var fileName = $"{Guid.NewGuid():N}{ext}";
|
||||
var root = PhotoRoot(config, env);
|
||||
Directory.CreateDirectory(root);
|
||||
await using (var stream = File.Create(Path.Combine(root, fileName)))
|
||||
await file.CopyToAsync(stream);
|
||||
|
||||
int nextSort = (await db.EnclosurePhotos.Where(p => p.EnclosureId == id)
|
||||
.Select(p => (int?)p.SortOrder).MaxAsync() ?? -1) + 1;
|
||||
|
||||
var photo = new EnclosurePhoto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
EnclosureId = id,
|
||||
FileName = fileName,
|
||||
Caption = caption,
|
||||
SortOrder = nextSort,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
db.EnclosurePhotos.Add(photo);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/enclosure-photos/{photo.Id}", ToEnclosureDto(photo));
|
||||
}).WithTags("Photos").DisableAntiforgery();
|
||||
|
||||
app.MapDelete("/enclosure-photos/{id:guid}",
|
||||
async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
var photo = await db.EnclosurePhotos.FirstOrDefaultAsync(p => p.Id == id);
|
||||
if (photo is null) return TypedResults.NotFound();
|
||||
var path = Path.Combine(PhotoRoot(config, env), photo.FileName);
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
db.EnclosurePhotos.Remove(photo);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
}).WithTags("Photos");
|
||||
|
||||
app.MapGet("/photos/files/{fileName}",
|
||||
Results<PhysicalFileHttpResult, NotFound, BadRequest<string>> (string fileName, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
// guard against path traversal: only a bare file name is allowed
|
||||
if (fileName.Contains('/') || fileName.Contains('\\') || fileName.Contains(".."))
|
||||
return TypedResults.BadRequest("Invalid file name.");
|
||||
var path = Path.Combine(PhotoRoot(config, env), fileName);
|
||||
if (!File.Exists(path)) return TypedResults.NotFound();
|
||||
return TypedResults.PhysicalFile(path, ContentType(fileName));
|
||||
}).WithTags("Photos");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static string PhotoRoot(IConfiguration config, IWebHostEnvironment env) =>
|
||||
config["Photos:RootPath"] ?? Path.Combine(env.ContentRootPath, "photo-storage");
|
||||
|
||||
private static string ContentType(string fileName) => Path.GetExtension(fileName).ToLowerInvariant() switch
|
||||
{
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
".bmp" => "image/bmp",
|
||||
_ => "image/jpeg",
|
||||
};
|
||||
|
||||
private static PhotoDto ToDto(GerbilPhoto p) =>
|
||||
new(p.Id, p.FileName, p.Caption, p.SortOrder, $"/photos/files/{p.FileName}");
|
||||
|
||||
private static PhotoDto ToEnclosureDto(EnclosurePhoto p) =>
|
||||
new(p.Id, p.FileName, p.Caption, p.SortOrder, $"/photos/files/{p.FileName}");
|
||||
}
|
||||
}
|
||||
121
GerbilManagerWebAPI/Endpoints/RecordEndpoints.cs
Normal file
121
GerbilManagerWebAPI/Endpoints/RecordEndpoints.cs
Normal file
@@ -0,0 +1,121 @@
|
||||
using GerbilManagerWebAPI.Common;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Gridify;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
public static class RecordEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapHealthRecordEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/health-records").WithTags("HealthRecords");
|
||||
|
||||
// GET /health-records?filter=gerbilId==…
|
||||
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.HealthRecords.AsNoTracking().ToPagedResultAsync(query, ToDto)));
|
||||
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<HealthRecordDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var r = await db.HealthRecords.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||
return r is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(r));
|
||||
});
|
||||
|
||||
group.MapPost("/", async (HealthRecordInput input, ApplicationContext db) =>
|
||||
{
|
||||
var r = new HealthRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GerbilId = input.GerbilId,
|
||||
Date = input.Date,
|
||||
Type = input.Type,
|
||||
Description = input.Description,
|
||||
Veterinarian = input.Veterinarian,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
db.HealthRecords.Add(r);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/health-records/{r.Id}", ToDto(r));
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, HealthRecordInput input, ApplicationContext db) =>
|
||||
{
|
||||
var r = await db.HealthRecords.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (r is null) return TypedResults.NotFound();
|
||||
r.GerbilId = input.GerbilId; r.Date = input.Date; r.Type = input.Type;
|
||||
r.Description = input.Description; r.Veterinarian = input.Veterinarian;
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var r = await db.HealthRecords.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (r is null) return TypedResults.NotFound();
|
||||
db.HealthRecords.Remove(r);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
public static IEndpointRouteBuilder MapWeightRecordEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/weight-records").WithTags("WeightRecords");
|
||||
|
||||
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.WeightRecords.AsNoTracking().ToPagedResultAsync(query, ToDto)));
|
||||
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<WeightRecordDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var r = await db.WeightRecords.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||
return r is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(r));
|
||||
});
|
||||
|
||||
group.MapPost("/", async (WeightRecordInput input, ApplicationContext db) =>
|
||||
{
|
||||
var r = new WeightRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GerbilId = input.GerbilId,
|
||||
Date = input.Date,
|
||||
WeightGrams = input.WeightGrams,
|
||||
Notes = input.Notes,
|
||||
};
|
||||
db.WeightRecords.Add(r);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/weight-records/{r.Id}", ToDto(r));
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, WeightRecordInput input, ApplicationContext db) =>
|
||||
{
|
||||
var r = await db.WeightRecords.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (r is null) return TypedResults.NotFound();
|
||||
r.GerbilId = input.GerbilId; r.Date = input.Date;
|
||||
r.WeightGrams = input.WeightGrams; r.Notes = input.Notes;
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var r = await db.WeightRecords.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (r is null) return TypedResults.NotFound();
|
||||
db.WeightRecords.Remove(r);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static HealthRecordDto ToDto(HealthRecord r) =>
|
||||
new(r.Id, r.GerbilId, r.Date, r.Type, r.Description, r.Veterinarian, r.CreatedAt);
|
||||
|
||||
private static WeightRecordDto ToDto(WeightRecord r) =>
|
||||
new(r.Id, r.GerbilId, r.Date, r.WeightGrams, r.Notes);
|
||||
}
|
||||
}
|
||||
129
GerbilManagerWebAPI/Endpoints/RequestEndpoints.cs
Normal file
129
GerbilManagerWebAPI/Endpoints/RequestEndpoints.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
using GerbilManagerWebAPI.Common;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Inbox;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// INBOX-0: Gmail request inbox. Sync imports mail into Request rows; the list/detail/triage
|
||||
/// endpoints drive the in-app triage (frontend INBOX-1). LAN-only like the rest of the API.
|
||||
/// </summary>
|
||||
public static class RequestEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapRequestEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var api = app.MapGroup("/api").WithTags("Inbox");
|
||||
|
||||
// POST /api/requests/sync — fetch from Gmail + import (dedup on Message-Id)
|
||||
api.MapPost("/requests/sync", async (RequestSyncService sync) =>
|
||||
TypedResults.Ok(await sync.SyncAsync()));
|
||||
|
||||
// GET /api/requests?filter=status==New&orderBy=receivedAt desc (Gridify paged)
|
||||
api.MapGet("/requests", async ([Microsoft.AspNetCore.Http.AsParameters] GridifyParams query, ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.Requests.AsNoTracking()
|
||||
.ToPagedResultAsync(query, ToDto)));
|
||||
|
||||
api.MapGet("/requests/{id:guid}", async Task<Results<Ok<RequestDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var r = await db.Requests.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||
return r is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(r));
|
||||
});
|
||||
|
||||
// PUT /api/requests/{id} — triage: status and/or assigned contact
|
||||
api.MapPut("/requests/{id:guid}", async Task<Results<NoContent, NotFound, BadRequest<string>>> (
|
||||
Guid id, RequestTriageInput input, ApplicationContext db) =>
|
||||
{
|
||||
var r = await db.Requests.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (r is null) return TypedResults.NotFound();
|
||||
if (input.AssignedContactId is Guid cid && !await db.Contacts.AnyAsync(c => c.Id == cid))
|
||||
return TypedResults.BadRequest("Assigned contact does not exist.");
|
||||
|
||||
r.AssignedContactId = input.AssignedContactId;
|
||||
if (input.Status is RequestStatus s)
|
||||
{
|
||||
r.Status = s;
|
||||
if (s == RequestStatus.Answered) r.AnsweredAt ??= DateTimeOffset.UtcNow;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
// POST /api/requests/{id}/draft — INBOX-2: KI-Antwortentwurf. NUR Entwurf
|
||||
// (human-in-the-loop, Versand ist /send). 503 AiKeyMissing solange die
|
||||
// AI-Sektion unkonfiguriert ist (gleiches UI-Muster wie sale-ad).
|
||||
api.MapPost("/requests/{id:guid}/draft", async Task<IResult> (
|
||||
Guid id, DraftReplyService drafter, ApplicationContext db, CancellationToken ct) =>
|
||||
{
|
||||
var r = await db.Requests.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (r is null) return TypedResults.NotFound();
|
||||
|
||||
// Datenminimierung: NUR die ForSale-Liste (Name + Farbschlag) geht
|
||||
// zusätzlich zum Anfragetext an den Anbieter (arch §privacy).
|
||||
var forSale = await db.Gerbils.AsNoTracking()
|
||||
.Where(g => g.Status == GerbilStatus.ForSale)
|
||||
.OrderBy(g => g.Name)
|
||||
.Select(g => new DraftReplyService.ForSaleAnimal(g.Name, g.ColorVariety!.Name))
|
||||
.ToListAsync(ct);
|
||||
|
||||
var result = await drafter.DraftAsync(r, forSale, ct);
|
||||
if (result.Status == Ai.AiCallStatus.NotConfigured)
|
||||
return Results.Json(new { code = "AiKeyMissing", message = result.Error }, statusCode: 503);
|
||||
if (result.Status != Ai.AiCallStatus.Ok)
|
||||
return Results.Json(new { code = "AiUpstreamError", message = result.Error }, statusCode: 502);
|
||||
|
||||
r.DraftReply = result.Text;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return TypedResults.Ok(ToDto(r));
|
||||
});
|
||||
|
||||
// POST /api/requests/{id}/send — send the (edited) reply, threaded, mark Answered
|
||||
api.MapPost("/requests/{id:guid}/send", async Task<Results<Ok<RequestDto>, NotFound, ProblemHttpResult>> (
|
||||
Guid id, SendReplyInput input, SendReplyService sender, ApplicationContext db) =>
|
||||
{
|
||||
var r = await db.Requests.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (r is null) return TypedResults.NotFound();
|
||||
|
||||
var outcome = await sender.SendAsync(r, input.Body ?? "");
|
||||
return outcome.Error switch
|
||||
{
|
||||
null => TypedResults.Ok(ToDto(r)),
|
||||
"MailNotConfigured" => TypedResults.Problem(
|
||||
"Gmail ist noch nicht konfiguriert.", statusCode: 503, title: "MailNotConfigured"),
|
||||
"MailAuthFailed" => TypedResults.Problem(
|
||||
"Gmail-Anmeldung fehlgeschlagen — App-Passwort erneut eingeben.", statusCode: 502, title: "MailAuthFailed"),
|
||||
var code => TypedResults.Problem(code, statusCode: 500, title: code),
|
||||
};
|
||||
});
|
||||
|
||||
// GET/PUT /api/mail-settings — App Password never leaves the server
|
||||
api.MapGet("/mail-settings", async (MailSettingsService svc) =>
|
||||
{
|
||||
var s = await svc.GetAsync();
|
||||
return TypedResults.Ok(new MailSettingsDto(
|
||||
s.GmailAddress, s.PollIntervalMinutes, s.Folder, s.BackgroundPollEnabled, svc.HasAppPassword(s)));
|
||||
});
|
||||
|
||||
api.MapPut("/mail-settings", async (MailSettingsInput input, MailSettingsService svc, ApplicationContext db) =>
|
||||
{
|
||||
var s = await svc.GetAsync();
|
||||
if (input.GmailAddress is not null) s.GmailAddress = string.IsNullOrWhiteSpace(input.GmailAddress) ? null : input.GmailAddress.Trim();
|
||||
if (input.PollIntervalMinutes is int m) s.PollIntervalMinutes = m;
|
||||
if (input.Folder is not null) s.Folder = string.IsNullOrWhiteSpace(input.Folder) ? "INBOX" : input.Folder.Trim();
|
||||
if (input.BackgroundPollEnabled is bool b) s.BackgroundPollEnabled = b;
|
||||
// AppPassword: null => leave unchanged; "" => clear; value => set (encrypted)
|
||||
if (input.AppPassword is not null) svc.SetPassword(s, input.AppPassword);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static RequestDto ToDto(Request r) => new(
|
||||
r.Id, r.GmailMessageId, r.ThreadId, r.FromAddress, r.FromName, r.Subject, r.BodyText,
|
||||
r.ReceivedAt, r.Status, r.AssignedContactId, r.DraftReply, r.AnsweredAt);
|
||||
}
|
||||
}
|
||||
34
GerbilManagerWebAPI/Endpoints/SaleAdEndpoints.cs
Normal file
34
GerbilManagerWebAPI/Endpoints/SaleAdEndpoints.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using GerbilManagerWebAPI.SaleAd;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-12a: POST /gerbils/sale-ad — AI-gestütztes Abgabe-Inserat (frozen
|
||||
/// contract, see gerbil-manager-web/src/api/saleAd.ts).
|
||||
///
|
||||
/// 503 {code:"AiKeyMissing"} while the AI section is unconfigured — the
|
||||
/// frontend maps exactly this code to its German disabled-state hint.
|
||||
/// </summary>
|
||||
public static class SaleAdEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapSaleAdEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapPost("/gerbils/sale-ad",
|
||||
async (SaleAdRequest request, SaleAdService service, CancellationToken ct) =>
|
||||
{
|
||||
var result = await service.GenerateAsync(request, ct);
|
||||
return result.Status switch
|
||||
{
|
||||
SaleAdStatus.Ok => Results.Ok(new SaleAdResponse(result.Text!)),
|
||||
SaleAdStatus.NotConfigured => Results.Json(
|
||||
new SaleAdError("AiKeyMissing", result.Error ?? ""), statusCode: 503),
|
||||
_ => Results.Json(
|
||||
new SaleAdError("AiUpstreamError", result.Error ?? ""), statusCode: 502),
|
||||
};
|
||||
})
|
||||
.WithTags("SaleAd");
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
}
|
||||
61
GerbilManagerWebAPI/Endpoints/SettingsEndpoints.cs
Normal file
61
GerbilManagerWebAPI/Endpoints/SettingsEndpoints.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-13: Zuchtprofil (Verkäufer-Block der Abgabeverträge) — Einzelzeile,
|
||||
/// per Migration leer geseedet, von der Züchterin unter /einstellungen gepflegt.
|
||||
/// GET /settings/breeder-profile -> BreederProfileDto
|
||||
/// PUT /settings/breeder-profile -> 204
|
||||
/// </summary>
|
||||
public static class SettingsEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/settings").WithTags("Settings");
|
||||
|
||||
group.MapGet("/breeder-profile", async Task<Ok<BreederProfileDto>> (ApplicationContext db) =>
|
||||
{
|
||||
var s = await LoadAsync(db, track: false);
|
||||
return TypedResults.Ok(ToDto(s));
|
||||
});
|
||||
|
||||
group.MapPut("/breeder-profile", async Task<NoContent> (BreederProfileDto input, ApplicationContext db) =>
|
||||
{
|
||||
var s = await LoadAsync(db, track: true);
|
||||
s.ZuchtName = input.ZuchtName ?? "";
|
||||
s.Name = input.Name ?? "";
|
||||
s.Address = input.Address ?? "";
|
||||
s.Phone = input.Phone ?? "";
|
||||
s.Email = input.Email ?? "";
|
||||
s.Homepage = input.Homepage ?? "";
|
||||
s.City = input.City ?? "";
|
||||
s.NameSuffix = input.NameSuffix ?? "";
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>Die Singleton-Zeile; defensiv neu anlegen, falls sie fehlt.</summary>
|
||||
private static async Task<BreederSettings> LoadAsync(ApplicationContext db, bool track)
|
||||
{
|
||||
var query = track ? db.BreederSettings : db.BreederSettings.AsNoTracking();
|
||||
var s = await query.FirstOrDefaultAsync(x => x.Id == BreederSettings.SingletonId);
|
||||
if (s is null)
|
||||
{
|
||||
s = new BreederSettings { Id = BreederSettings.SingletonId };
|
||||
db.BreederSettings.Add(s);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
internal static BreederProfileDto ToDto(BreederSettings s) =>
|
||||
new(s.ZuchtName, s.Name, s.Address, s.Phone, s.Email, s.Homepage, s.City, s.NameSuffix);
|
||||
}
|
||||
}
|
||||
214
GerbilManagerWebAPI/Export/ExportService.cs
Normal file
214
GerbilManagerWebAPI/Export/ExportService.cs
Normal file
@@ -0,0 +1,214 @@
|
||||
using System.Globalization;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
|
||||
namespace GerbilManagerWebAPI.Export
|
||||
{
|
||||
/// <summary>
|
||||
/// EXPORT-1: user-facing Datenexport — builds a zip containing
|
||||
/// - export.json (full fidelity: every entity incl. genotypes + import provenance)
|
||||
/// - *.csv (Tiere, Würfe, Kontakte, Gesundheit, Gewichte — German headers,
|
||||
/// de-DE formats, semicolon-separated, UTF-8 with BOM for Excel)
|
||||
/// - LIESMICH.txt (German explainer; notes that photos live on disk, not in the zip)
|
||||
///
|
||||
/// Pure function over <see cref="ExportData"/> (no DbContext) so the round-trip
|
||||
/// and escaping tests run without a database.
|
||||
/// </summary>
|
||||
public static class ExportService
|
||||
{
|
||||
public sealed record ExportData(
|
||||
List<Gerbil> Gerbils,
|
||||
List<Litter> Litters,
|
||||
List<Contact> Contacts,
|
||||
List<Enclosure> Enclosures,
|
||||
List<ColorVariety> ColorVarieties,
|
||||
List<HealthRecord> HealthRecords,
|
||||
List<WeightRecord> WeightRecords,
|
||||
List<GerbilPhoto> Photos);
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
WriteIndented = true,
|
||||
ReferenceHandler = ReferenceHandler.IgnoreCycles,
|
||||
};
|
||||
|
||||
// German labels for enum values (CSV is the wife-facing backup format;
|
||||
// the JSON keeps the enum member names for lossless re-import).
|
||||
private static readonly Dictionary<Gender, string> GenderDe = new()
|
||||
{
|
||||
[Gender.unknown] = "Unbekannt",
|
||||
[Gender.male] = "Männlich",
|
||||
[Gender.female] = "Weiblich",
|
||||
};
|
||||
|
||||
private static readonly Dictionary<GerbilStatus, string> StatusDe = new()
|
||||
{
|
||||
[GerbilStatus.Breeding] = "Zucht",
|
||||
[GerbilStatus.Deceased] = "Verstorben",
|
||||
[GerbilStatus.GivenAway] = "Abgegeben",
|
||||
[GerbilStatus.ForSale] = "Abzugeben",
|
||||
[GerbilStatus.Pet] = "Liebhaber",
|
||||
};
|
||||
|
||||
private static readonly Dictionary<HealthRecordType, string> HealthTypeDe = new()
|
||||
{
|
||||
[HealthRecordType.Examination] = "Untersuchung",
|
||||
[HealthRecordType.Treatment] = "Behandlung",
|
||||
[HealthRecordType.Injury] = "Verletzung",
|
||||
[HealthRecordType.Vaccination] = "Impfung",
|
||||
[HealthRecordType.Other] = "Sonstiges",
|
||||
};
|
||||
|
||||
public static byte[] BuildZip(ExportData data, DateOnly exportDate)
|
||||
{
|
||||
var gerbilName = data.Gerbils.ToDictionary(g => g.Id, g => g.Name);
|
||||
var litterName = data.Litters.ToDictionary(l => l.Id, l => l.Name);
|
||||
var contactName = data.Contacts.ToDictionary(c => c.Id, c => c.Name);
|
||||
var enclosureName = data.Enclosures.ToDictionary(e => e.Id, e => e.Name);
|
||||
var colorName = data.ColorVarieties.ToDictionary(c => c.Id, c => c.Name);
|
||||
|
||||
string? Lookup<TKey>(Dictionary<TKey, string> map, TKey? key) where TKey : struct =>
|
||||
key is null ? null : map.GetValueOrDefault(key.Value);
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
using (var zip = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
AddText(zip, "export.json", JsonSerializer.Serialize(new
|
||||
{
|
||||
exportedAt = exportDate.ToString("yyyy-MM-dd"),
|
||||
data.Gerbils,
|
||||
data.Litters,
|
||||
data.Contacts,
|
||||
data.Enclosures,
|
||||
data.ColorVarieties,
|
||||
data.HealthRecords,
|
||||
data.WeightRecords,
|
||||
data.Photos,
|
||||
}, JsonOptions), bom: false);
|
||||
|
||||
AddCsv(zip, "tiere.csv",
|
||||
["Name", "Geschlecht", "Status", "Geburtsdatum", "Todesdatum", "Todesursache",
|
||||
"Abgabedatum", "Farbschlag", "Becken", "Wurf", "Herkunft", "Abnehmer",
|
||||
"Genotyp", "Notizen"],
|
||||
data.Gerbils.OrderBy(g => g.Name).Select(g => new[]
|
||||
{
|
||||
g.Name, GenderDe[g.Gender], StatusDe[g.Status],
|
||||
De(g.DateOfBirth), De(g.DateOfDeath), g.CauseOfDeath,
|
||||
De(g.GoHomeDate), Lookup(colorName, g.ColorVarietyId),
|
||||
Lookup(enclosureName, g.EnclosureId), Lookup(litterName, g.LitterId),
|
||||
Lookup(contactName, g.OriginContactId), Lookup(contactName, g.ReceiverContactId),
|
||||
g.Genotype, g.Notes,
|
||||
}));
|
||||
|
||||
AddCsv(zip, "wuerfe.csv",
|
||||
["Bezeichnung", "Wurfdatum", "Vater", "Mutter", "Wurfstärke",
|
||||
"Voraussichtliches Abgabedatum", "Notizen"],
|
||||
data.Litters.OrderBy(l => l.Date).Select(l => new[]
|
||||
{
|
||||
l.Name, De(l.Date), Lookup(gerbilName, l.FatherId), Lookup(gerbilName, l.MotherId),
|
||||
l.TotalBorn?.ToString(CultureInfo.InvariantCulture),
|
||||
De(l.ExpectedGoHomeDate), l.Notes,
|
||||
}));
|
||||
|
||||
AddCsv(zip, "kontakte.csv",
|
||||
["Name", "E-Mail", "Telefon", "Adresse", "Notizen"],
|
||||
data.Contacts.OrderBy(c => c.Name).Select(c => new[]
|
||||
{
|
||||
c.Name, c.Email, c.Phone, c.Address, c.Notes,
|
||||
}));
|
||||
|
||||
AddCsv(zip, "gesundheit.csv",
|
||||
["Tier", "Datum", "Art", "Beschreibung", "Tierarzt"],
|
||||
data.HealthRecords.OrderBy(h => h.Date).Select(h => new[]
|
||||
{
|
||||
gerbilName.GetValueOrDefault(h.GerbilId), De(h.Date),
|
||||
HealthTypeDe[h.Type], h.Description, h.Veterinarian,
|
||||
}));
|
||||
|
||||
AddCsv(zip, "gewichte.csv",
|
||||
["Tier", "Datum", "Gewicht (g)", "Notizen"],
|
||||
data.WeightRecords.OrderBy(w => w.Date).Select(w => new[]
|
||||
{
|
||||
gerbilName.GetValueOrDefault(w.GerbilId), De(w.Date),
|
||||
w.WeightGrams.ToString(CultureInfo.InvariantCulture), w.Notes,
|
||||
}));
|
||||
|
||||
AddText(zip, "LIESMICH.txt", $"""
|
||||
Rennmaus-Manager — Datenexport vom {exportDate.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)}
|
||||
|
||||
Enthalten:
|
||||
- export.json : vollständige Datensicherung (alle Tiere, Würfe, Kontakte,
|
||||
Becken, Farbschläge, Gesundheits- und Gewichtseinträge,
|
||||
Foto-Verzeichnis, inkl. Genotypen und Import-Herkunft).
|
||||
- tiere.csv : alle Tiere als Tabelle (für Excel/LibreOffice).
|
||||
- wuerfe.csv : alle Würfe.
|
||||
- kontakte.csv : alle Kontakte.
|
||||
- gesundheit.csv: alle Gesundheitseinträge.
|
||||
- gewichte.csv : alle Gewichtseinträge.
|
||||
|
||||
Die CSV-Dateien sind mit Semikolon getrennt und öffnen sich in einem
|
||||
deutschen Excel per Doppelklick. Datumsangaben im Format TT.MM.JJJJ.
|
||||
|
||||
FOTOS sind aus Platzgründen NICHT im Export enthalten. Sie liegen als
|
||||
normale Bilddateien im Datenordner der Anwendung (Ordner "photo-storage"
|
||||
neben der API bzw. der in Photos:RootPath konfigurierte Pfad) und können
|
||||
von dort direkt kopiert/gesichert werden. Die Zuordnung Foto -> Tier
|
||||
steht in export.json (Abschnitt "photos").
|
||||
""", bom: false);
|
||||
}
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
// ── CSV building blocks ────────────────────────────────────────────
|
||||
|
||||
/// <summary>"2024-03-12" -> "12.03.2024"; null -> empty.</summary>
|
||||
private static string? De(DateOnly? date) =>
|
||||
date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture);
|
||||
|
||||
private static string De(DateOnly date) =>
|
||||
date.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>
|
||||
/// Semicolon-separated (German Excel default), CRLF rows, fields quoted when
|
||||
/// they contain separator/quote/newline; quotes doubled.
|
||||
/// </summary>
|
||||
internal static string BuildCsv(string[] header, IEnumerable<string?[]> rows)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(string.Join(';', header.Select(Escape))).Append("\r\n");
|
||||
foreach (var row in rows)
|
||||
sb.Append(string.Join(';', row.Select(Escape))).Append("\r\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
internal static string Escape(string? field)
|
||||
{
|
||||
if (string.IsNullOrEmpty(field)) return "";
|
||||
return field.Contains(';') || field.Contains('"') || field.Contains('\n') || field.Contains('\r')
|
||||
? $"\"{field.Replace("\"", "\"\"")}\""
|
||||
: field;
|
||||
}
|
||||
|
||||
private static void AddCsv(ZipArchive zip, string name, string[] header,
|
||||
IEnumerable<string?[]> rows) =>
|
||||
AddText(zip, name, BuildCsv(header, rows), bom: true);
|
||||
|
||||
private static void AddText(ZipArchive zip, string name, string content, bool bom)
|
||||
{
|
||||
var entry = zip.CreateEntry(name, CompressionLevel.Optimal);
|
||||
using var entryStream = entry.Open();
|
||||
if (bom)
|
||||
{
|
||||
// UTF-8 BOM: ohne ihn zeigt ein deutsches Excel Umlaute kaputt an.
|
||||
entryStream.Write(Encoding.UTF8.GetPreamble());
|
||||
}
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
entryStream.Write(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
25
GerbilManagerWebAPI/Genetics/InbreedingResult.cs
Normal file
25
GerbilManagerWebAPI/Genetics/InbreedingResult.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
namespace GerbilManagerWebAPI.Genetics
|
||||
{
|
||||
/// <summary>
|
||||
/// Result of an inbreeding-coefficient (Inzuchtkoeffizient) calculation for an
|
||||
/// individual or a hypothetical pairing.
|
||||
/// </summary>
|
||||
/// <param name="Coefficient">Wright's inbreeding coefficient F, in the range 0..1.</param>
|
||||
/// <param name="Percent">The coefficient expressed as a percentage (F * 100).</param>
|
||||
/// <param name="GenerationsAvailable">
|
||||
/// The deepest generation of known ancestry above the individual (parents = 1,
|
||||
/// grandparents = 2, …). 0 when no parents are recorded.
|
||||
/// </param>
|
||||
/// <param name="CommonAncestors">
|
||||
/// The ancestors common to both parents, each with its additive contribution to F,
|
||||
/// ordered by contribution descending. Empty when there is no inbreeding.
|
||||
/// </param>
|
||||
public record InbreedingResult(
|
||||
double Coefficient,
|
||||
double Percent,
|
||||
int GenerationsAvailable,
|
||||
IReadOnlyList<CommonAncestorContribution> CommonAncestors);
|
||||
|
||||
/// <summary>A single common ancestor and how much it contributes to F.</summary>
|
||||
public record CommonAncestorContribution(Guid Id, string Name, double Contribution);
|
||||
}
|
||||
64
GerbilManagerWebAPI/Genetics/InbreedingService.cs
Normal file
64
GerbilManagerWebAPI/Genetics/InbreedingService.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Genetics
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads the pedigree out of the database into in-memory lookups (the home dataset
|
||||
/// is small) and delegates the actual maths to <see cref="PedigreeCalculator"/>.
|
||||
///
|
||||
/// Parent links live in EF shadow foreign keys: a gerbil's birth litter is
|
||||
/// <c>Gerbils.LitterId</c>; that litter's parents are <c>Litters.FatherId</c> and
|
||||
/// <c>Litters.MotherId</c>.
|
||||
/// </summary>
|
||||
public sealed class InbreedingService
|
||||
{
|
||||
private readonly ApplicationContext _db;
|
||||
|
||||
public InbreedingService(ApplicationContext db) => _db = db;
|
||||
|
||||
/// <summary>F for an existing gerbil, or null if no such gerbil exists.</summary>
|
||||
public InbreedingResult? ForGerbil(Guid id)
|
||||
{
|
||||
var calculator = BuildCalculator();
|
||||
return calculator.Contains(id) ? calculator.ForIndividual(id) : null;
|
||||
}
|
||||
|
||||
/// <summary>F for the hypothetical offspring of the given sire and dam.</summary>
|
||||
public InbreedingResult ForPairing(Guid? fatherId, Guid? motherId)
|
||||
{
|
||||
return BuildCalculator().ForOffspringOf(fatherId, motherId);
|
||||
}
|
||||
|
||||
private PedigreeCalculator BuildCalculator()
|
||||
{
|
||||
var litters = _db.Litters
|
||||
.Select(l => new { l.Id, l.FatherId, l.MotherId })
|
||||
.ToDictionary(l => l.Id, l => (l.FatherId, l.MotherId));
|
||||
|
||||
var gerbils = _db.Gerbils
|
||||
.Select(g => new { g.Id, g.Name, g.LitterId })
|
||||
.ToList();
|
||||
|
||||
var parents = new Dictionary<Guid, (Guid? Father, Guid? Mother)>(gerbils.Count);
|
||||
var names = new Dictionary<Guid, string>(gerbils.Count);
|
||||
|
||||
foreach (var g in gerbils)
|
||||
{
|
||||
names[g.Id] = g.Name;
|
||||
|
||||
Guid? father = null;
|
||||
Guid? mother = null;
|
||||
if (g.LitterId is Guid litterId && litters.TryGetValue(litterId, out var litterParents))
|
||||
{
|
||||
father = litterParents.FatherId;
|
||||
mother = litterParents.MotherId;
|
||||
}
|
||||
|
||||
parents[g.Id] = (father, mother);
|
||||
}
|
||||
|
||||
return new PedigreeCalculator(parents, names);
|
||||
}
|
||||
}
|
||||
}
|
||||
219
GerbilManagerWebAPI/Genetics/PedigreeCalculator.cs
Normal file
219
GerbilManagerWebAPI/Genetics/PedigreeCalculator.cs
Normal file
@@ -0,0 +1,219 @@
|
||||
namespace GerbilManagerWebAPI.Genetics
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the inbreeding coefficient (Inzuchtkoeffizient) using Wright's path
|
||||
/// method:
|
||||
///
|
||||
/// F = Σ over common ancestors, over every valid path, of
|
||||
/// (1/2)^(n1 + n2 + 1) * (1 + F_A)
|
||||
///
|
||||
/// where, for a given common ancestor A, n1 is the number of links from the sire
|
||||
/// up to A and n2 the number of links from the dam up to A, and F_A is A's own
|
||||
/// inbreeding coefficient (computed recursively at full available pedigree depth).
|
||||
/// A path counts only if the sire-side and dam-side chains share no individual
|
||||
/// other than A — this is what prevents double counting.
|
||||
///
|
||||
/// This type is deliberately free of any database / EF dependency: it works purely
|
||||
/// from a parent lookup, so it can be unit-tested with hand-built pedigrees.
|
||||
/// </summary>
|
||||
public sealed class PedigreeCalculator
|
||||
{
|
||||
private readonly IReadOnlyDictionary<Guid, (Guid? Father, Guid? Mother)> _parents;
|
||||
private readonly IReadOnlyDictionary<Guid, string> _names;
|
||||
|
||||
// F_A memo + re-entrancy guard (the latter only matters if the data somehow
|
||||
// contains a cycle, which is biologically impossible but cheap to defend).
|
||||
private readonly Dictionary<Guid, double> _fCache = new();
|
||||
private readonly HashSet<Guid> _fInProgress = new();
|
||||
private readonly Dictionary<Guid, int> _depthCache = new();
|
||||
|
||||
public PedigreeCalculator(
|
||||
IReadOnlyDictionary<Guid, (Guid? Father, Guid? Mother)> parents,
|
||||
IReadOnlyDictionary<Guid, string> names)
|
||||
{
|
||||
_parents = parents;
|
||||
_names = names;
|
||||
}
|
||||
|
||||
/// <summary>True if the given individual exists in the pedigree.</summary>
|
||||
public bool Contains(Guid id) => _parents.ContainsKey(id);
|
||||
|
||||
/// <summary>Inbreeding coefficient of an existing individual.</summary>
|
||||
public InbreedingResult ForIndividual(Guid id)
|
||||
{
|
||||
var (father, mother) = ParentsOf(id);
|
||||
return ForOffspringOf(father, mother);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inbreeding coefficient of the (possibly hypothetical) offspring of the given
|
||||
/// sire and dam — equivalently, the kinship between the two parents.
|
||||
/// </summary>
|
||||
public InbreedingResult ForOffspringOf(Guid? sire, Guid? dam)
|
||||
{
|
||||
int generations = GenerationsAvailable(sire, dam);
|
||||
|
||||
if (sire is not Guid s || dam is not Guid d)
|
||||
{
|
||||
// A parent is unknown → no inbreeding can be established.
|
||||
return new InbreedingResult(0.0, 0.0, generations, Array.Empty<CommonAncestorContribution>());
|
||||
}
|
||||
|
||||
var contributions = Coefficient(s, d);
|
||||
double f = contributions.Values.Sum();
|
||||
|
||||
var commonAncestors = contributions
|
||||
.Select(kv => new CommonAncestorContribution(kv.Key, NameOf(kv.Key), kv.Value))
|
||||
.OrderByDescending(c => c.Contribution)
|
||||
.ThenBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return new InbreedingResult(f, f * 100.0, generations, commonAncestors);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-common-ancestor contributions to the kinship of <paramref name="sire"/>
|
||||
/// and <paramref name="dam"/>. The sum of the values is the coefficient.
|
||||
/// </summary>
|
||||
private Dictionary<Guid, double> Coefficient(Guid sire, Guid dam)
|
||||
{
|
||||
var sirePaths = EnumerateAncestorPaths(sire);
|
||||
var damPaths = EnumerateAncestorPaths(dam);
|
||||
|
||||
// Group the dam's paths by the ancestor they terminate at for quick lookup.
|
||||
var damPathsByAncestor = damPaths
|
||||
.GroupBy(p => p[^1])
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
var contributions = new Dictionary<Guid, double>();
|
||||
|
||||
foreach (var sirePath in sirePaths)
|
||||
{
|
||||
var ancestor = sirePath[^1];
|
||||
if (!damPathsByAncestor.TryGetValue(ancestor, out var matchingDamPaths))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var sireNodes = new HashSet<Guid>(sirePath);
|
||||
|
||||
foreach (var damPath in matchingDamPaths)
|
||||
{
|
||||
// The two chains may meet only at the common ancestor itself.
|
||||
if (damPath.Any(node => node != ancestor && sireNodes.Contains(node)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int n1 = sirePath.Count - 1;
|
||||
int n2 = damPath.Count - 1;
|
||||
double term = Math.Pow(0.5, n1 + n2 + 1) * (1.0 + F(ancestor));
|
||||
|
||||
contributions[ancestor] = contributions.GetValueOrDefault(ancestor) + term;
|
||||
}
|
||||
}
|
||||
|
||||
return contributions;
|
||||
}
|
||||
|
||||
/// <summary>Inbreeding coefficient F of a single individual (memoized).</summary>
|
||||
private double F(Guid id)
|
||||
{
|
||||
if (_fCache.TryGetValue(id, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Defend against accidental cycles in the data.
|
||||
if (!_fInProgress.Add(id))
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
double f = 0.0;
|
||||
var (father, mother) = ParentsOf(id);
|
||||
if (father is Guid s && mother is Guid d)
|
||||
{
|
||||
f = Coefficient(s, d).Values.Sum();
|
||||
}
|
||||
|
||||
_fInProgress.Remove(id);
|
||||
_fCache[id] = f;
|
||||
return f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every chain of parent links starting at <paramref name="start"/>, including
|
||||
/// the trivial chain [start] itself. Each returned list runs from the start
|
||||
/// individual down to one of its ancestors (inclusive). A visited set keeps the
|
||||
/// walk finite even if the data contains a cycle.
|
||||
/// </summary>
|
||||
private List<List<Guid>> EnumerateAncestorPaths(Guid start)
|
||||
{
|
||||
var all = new List<List<Guid>>();
|
||||
var path = new List<Guid>();
|
||||
|
||||
void Walk(Guid node)
|
||||
{
|
||||
path.Add(node);
|
||||
all.Add(new List<Guid>(path));
|
||||
|
||||
var (father, mother) = ParentsOf(node);
|
||||
foreach (var parent in new[] { father, mother })
|
||||
{
|
||||
if (parent is Guid p && !path.Contains(p))
|
||||
{
|
||||
Walk(p);
|
||||
}
|
||||
}
|
||||
|
||||
path.RemoveAt(path.Count - 1);
|
||||
}
|
||||
|
||||
Walk(start);
|
||||
return all;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deepest generation of known ancestry for the offspring of the two parents
|
||||
/// (parents = 1, grandparents = 2, …). 0 when neither parent is known.
|
||||
/// </summary>
|
||||
private int GenerationsAvailable(Guid? sire, Guid? dam)
|
||||
{
|
||||
int FromParent(Guid? parent) => parent is Guid p ? 1 + AncestorDepth(p) : 0;
|
||||
return Math.Max(FromParent(sire), FromParent(dam));
|
||||
}
|
||||
|
||||
/// <summary>Number of ancestral generations above <paramref name="id"/> (0 for a founder).</summary>
|
||||
private int AncestorDepth(Guid id)
|
||||
{
|
||||
if (_depthCache.TryGetValue(id, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Temporary 0 breaks any cycle while the real value is computed.
|
||||
_depthCache[id] = 0;
|
||||
|
||||
var (father, mother) = ParentsOf(id);
|
||||
int depth = 0;
|
||||
if (father is Guid f)
|
||||
{
|
||||
depth = Math.Max(depth, 1 + AncestorDepth(f));
|
||||
}
|
||||
if (mother is Guid m)
|
||||
{
|
||||
depth = Math.Max(depth, 1 + AncestorDepth(m));
|
||||
}
|
||||
|
||||
_depthCache[id] = depth;
|
||||
return depth;
|
||||
}
|
||||
|
||||
private (Guid? Father, Guid? Mother) ParentsOf(Guid id) =>
|
||||
_parents.TryGetValue(id, out var p) ? p : (null, null);
|
||||
|
||||
private string NameOf(Guid id) =>
|
||||
_names.TryGetValue(id, out var name) ? name : id.ToString();
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,41 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="7.0.12" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="7.0.12" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>d5041cff-22a2-4aba-80ff-903ce5203eb3</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.4.2" />
|
||||
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
|
||||
<PackageReference Include="MailKit" Version="4.17.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||
<PackageReference Include="Gridify" Version="2.19.1" />
|
||||
<PackageReference Include="Gridify.EntityFramework" Version="2.19.1" />
|
||||
<PackageReference Include="QuestPDF" Version="2026.6.0" />
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GerbilManager.ServiceDefaults\GerbilManager.ServiceDefaults.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- FEAT-13: Abgabevertrag-Vorlage wird als eingebettete Ressource ausgeliefert. -->
|
||||
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- FEAT-12a: Tests prüfen interne Bausteine (z. B. die Completions-URL). -->
|
||||
<InternalsVisibleTo Include="GerbilManager.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
437
GerbilManagerWebAPI/Import/ImportDocxService.cs
Normal file
437
GerbilManagerWebAPI/Import/ImportDocxService.cs
Normal file
@@ -0,0 +1,437 @@
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Import
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-8d docx loader. Consumes tools/import/output/docx_litters.json +
|
||||
/// docx_animals.json (produced by extract_docx.py) and enriches OR CREATES animals.
|
||||
///
|
||||
/// Load policy (IDEMPOTENT NACHZUG after main WIPE+REIMPORT):
|
||||
/// Match order per docx row:
|
||||
/// 1. ExternalRef "docx-…" (previously created by this loader) → enrich (fill-NULL-only)
|
||||
/// 2. normalize(name)+litterDob in Gerbils (from main import) → enrich (fill-NULL-only)
|
||||
/// 3. No match → CREATE: abgegebene Jungtiere that never appeared in the pedigree charts.
|
||||
///
|
||||
/// Created animals: Status=GivenAway (or Deceased), IsResident=false,
|
||||
/// OriginBreeder='Zucht der Kleinen Chaoten', ImportSource="docx",
|
||||
/// ExternalRef = stable "docx-{ws}-{normname}-{litterDob}[-N]" key (idempotent;
|
||||
/// -N ordinal suffix disambiguates same-name siblings in a litter).
|
||||
///
|
||||
/// NEVER overwrites a manually-set non-null value (fill-NULL-only for all fields).
|
||||
///
|
||||
/// Idempotent: running multiple times is safe. Re-run finds existing rows via ExternalRef.
|
||||
/// Execute wraps all writes in a single transaction via CreateExecutionStrategy() so that
|
||||
/// providers using EnableRetryOnFailure (e.g. NpgsqlRetryingExecutionStrategy) are
|
||||
/// compatible. The strategy lambda resets all mutable state at the top so it is safe
|
||||
/// to re-run on transient-failure retry.
|
||||
/// Execute is gated by the endpoint; this service only acts when asked.
|
||||
/// </summary>
|
||||
public sealed class ImportDocxService
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
private readonly ApplicationContext _db;
|
||||
private readonly string _sourceDir;
|
||||
|
||||
public ImportDocxService(ApplicationContext db, IConfiguration config, IWebHostEnvironment env)
|
||||
: this(db,
|
||||
config["Import:SourcePath"]
|
||||
?? Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "tools", "import", "output")))
|
||||
{ }
|
||||
|
||||
public ImportDocxService(ApplicationContext db, string sourceDir)
|
||||
{
|
||||
_db = db;
|
||||
_sourceDir = sourceDir;
|
||||
}
|
||||
|
||||
public async Task<ImportDocxReport> RunAsync(bool execute)
|
||||
{
|
||||
var notes = new List<string>();
|
||||
|
||||
var docxLitters = Load<List<DocxLitter>>("docx_litters.json") ?? new();
|
||||
var docxAnimals = Load<List<DocxAnimal>>("docx_animals.json") ?? new();
|
||||
|
||||
var pdfLitters = Load<List<DocxLitter>>("pdf_litters.json") ?? new();
|
||||
var pdfAnimals = Load<List<DocxAnimal>>("pdf_animals.json") ?? new();
|
||||
|
||||
int docxLitCount = docxLitters.Count;
|
||||
int docxAnimCount = docxAnimals.Count;
|
||||
int pdfLitCount = pdfLitters.Count;
|
||||
int pdfAnimCount = pdfAnimals.Count;
|
||||
|
||||
docxLitters.AddRange(pdfLitters);
|
||||
docxAnimals.AddRange(pdfAnimals);
|
||||
|
||||
if (docxLitters.Count == 0 && docxAnimals.Count == 0)
|
||||
{
|
||||
notes.Add($"Keine Quelldaten in {_sourceDir} (docx_litters.json/docx_animals.json oder pdf_litters.json/pdf_animals.json). " +
|
||||
"extract_docx.py zuerst ausführen oder PDF-VLM-Daten bereitstellen.");
|
||||
return new ImportDocxReport(false, 0, 0, 0, 0, 0, 0, 0, notes);
|
||||
}
|
||||
|
||||
// Litter lookup by birth date (DayNumber) → list of matching DB litters.
|
||||
// NOTE: WsCode in docx is a litter-size fraction ("4/4", "/5") — NOT a PairingCode.
|
||||
// Date-only lookup with uniqueness guard avoids false links (only link when
|
||||
// exactly one DB litter falls within ±5 days of the docx litter DOB).
|
||||
var littersInDb = await _db.Litters
|
||||
.Select(l => new { l.Id, l.Date })
|
||||
.ToListAsync();
|
||||
var littersByDayNumber = littersInDb
|
||||
.Where(l => l.Date.HasValue)
|
||||
.GroupBy(l => l.Date!.Value.DayNumber)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
// normalize(name)+litterDob → Gerbil snapshot (main-import enrich path)
|
||||
var gerbilsInDb = await _db.Gerbils
|
||||
.Select(g => new { g.Id, g.Name, g.DateOfBirth, g.LitterId,
|
||||
g.ReceiverContactId, g.GoHomeDate, g.DateOfDeath, g.CauseOfDeath,
|
||||
g.ExternalRef })
|
||||
.ToListAsync();
|
||||
var gerbilByKey = gerbilsInDb
|
||||
.Where(g => g.DateOfBirth is not null)
|
||||
.GroupBy(g => NameDobKey(g.Name, g.DateOfBirth!.Value))
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
// ExternalRef → snapshot for previously docx-created animals (idempotency across runs)
|
||||
var docxExternalRefs = gerbilsInDb
|
||||
.Where(g => g.ExternalRef?.StartsWith("docx-") == true)
|
||||
.ToDictionary(g => g.ExternalRef!,
|
||||
g => new { g.Id, g.LitterId, g.GoHomeDate, g.DateOfDeath, g.ReceiverContactId });
|
||||
|
||||
// Contact lookup: normalized name → existing Contact.Id
|
||||
var contactsInDb = await _db.Contacts
|
||||
.Select(c => new { c.Id, c.Name })
|
||||
.ToListAsync();
|
||||
var contactByNorm = contactsInDb
|
||||
.GroupBy(c => NormalizeName(c.Name))
|
||||
.ToDictionary(g => g.Key, g => g.First().Id);
|
||||
|
||||
// Snapshot of DB contacts before any writes.
|
||||
// Used to reset contactByNorm on strategy retry (rolled-back contacts vanish from DB
|
||||
// but would remain in the in-memory dict without this reset).
|
||||
var contactByNormBase = new Dictionary<string, Guid>(contactByNorm);
|
||||
|
||||
// ColorVariety lookup: normalized name → Id (for CREATE path Farbschlag matching)
|
||||
var colorVarietyByName = (await _db.ColorVarieties
|
||||
.Select(cv => new { cv.Id, cv.Name })
|
||||
.ToListAsync())
|
||||
.GroupBy(cv => NormalizeName(cv.Name))
|
||||
.ToDictionary(g => g.Key, g => g.First().Id);
|
||||
|
||||
int animalsCreated = 0, litterLinked = 0, goHomeFilled = 0, deathFilled = 0;
|
||||
int ownerLinked = 0, ownerCreated = 0, skipped = 0;
|
||||
|
||||
// Ordinal counter for collision-free ExternalRef within this batch.
|
||||
var externalRefOrdinals = new Dictionary<string, int>();
|
||||
|
||||
// Belt-and-suspenders: guard against adding the same ExternalRef twice in one run.
|
||||
var batchRefs = new HashSet<string>();
|
||||
|
||||
// Inner loop — shared by dry-run and execute paths.
|
||||
// All local variables above are captured by reference (C# closure), so the strategy
|
||||
// lambda can reset them before each retry and RunLoopAsync sees the fresh state.
|
||||
async Task RunLoopAsync()
|
||||
{
|
||||
foreach (var da in docxAnimals)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(da.Name)) { skipped++; continue; }
|
||||
|
||||
// Collision-free ExternalRef: ordinal suffix for same-name siblings
|
||||
var baseRef = DocxExternalRefBase(da);
|
||||
externalRefOrdinals.TryGetValue(baseRef, out var ord);
|
||||
ord++;
|
||||
externalRefOrdinals[baseRef] = ord;
|
||||
var externalRef = ord == 1 ? baseRef : $"{baseRef}-{ord}";
|
||||
|
||||
// Resolve litter: date ±5 days, unambiguous (exactly one candidate)
|
||||
Guid? litterId = null;
|
||||
if (!string.IsNullOrWhiteSpace(da.LitterDob))
|
||||
{
|
||||
var litterDob = ParseDate(da.LitterDob);
|
||||
if (litterDob is not null)
|
||||
{
|
||||
var candidates = new List<Guid>();
|
||||
for (int delta = -5; delta <= 5; delta++)
|
||||
{
|
||||
if (littersByDayNumber.TryGetValue(litterDob.Value.DayNumber + delta, out var cl))
|
||||
candidates.AddRange(cl.Select(l => l.Id));
|
||||
}
|
||||
if (candidates.Count == 1)
|
||||
litterId = candidates[0];
|
||||
// If 0 or >1 candidates: no link (avoid false links)
|
||||
}
|
||||
}
|
||||
|
||||
// Animal DOB = litter birth date (docx has no per-animal DOB)
|
||||
var animalDob = litterId is not null
|
||||
? (await _db.Litters.Where(l => l.Id == litterId).Select(l => (DateOnly?)l.Date).FirstOrDefaultAsync())
|
||||
: ParseDate(da.LitterDob);
|
||||
|
||||
if (animalDob is null) { skipped++; continue; }
|
||||
|
||||
var goHomeDate = ParseDate(da.AbgabeDate);
|
||||
var deathDate = ParseDate(da.DeathDate);
|
||||
|
||||
// Resolve receiver contact (lookup-or-create; shared by all paths)
|
||||
Guid? receiverId = null;
|
||||
if (!string.IsNullOrWhiteSpace(da.Owner))
|
||||
{
|
||||
var normOwner = NormalizeName(da.Owner);
|
||||
if (contactByNorm.TryGetValue(normOwner, out var existingContactId))
|
||||
{
|
||||
receiverId = existingContactId;
|
||||
ownerLinked++;
|
||||
}
|
||||
else
|
||||
{
|
||||
ownerCreated++;
|
||||
if (execute)
|
||||
{
|
||||
var newContact = new Contact { Id = Guid.NewGuid(), Name = da.Owner.Trim() };
|
||||
_db.Contacts.Add(newContact);
|
||||
await _db.SaveChangesAsync(); // flush within the outer tx
|
||||
receiverId = newContact.Id;
|
||||
contactByNorm[normOwner] = receiverId.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── PATH 1: previously docx-created animal (idempotent re-run) ──────────
|
||||
if (docxExternalRefs.TryGetValue(externalRef, out var prevSnap))
|
||||
{
|
||||
bool willLink = litterId is not null && prevSnap.LitterId is null;
|
||||
bool willHome = goHomeDate is not null && prevSnap.GoHomeDate is null;
|
||||
bool willDeath = deathDate is not null && prevSnap.DateOfDeath is null;
|
||||
|
||||
if (willLink) litterLinked++;
|
||||
if (willHome) goHomeFilled++;
|
||||
if (willDeath) deathFilled++;
|
||||
|
||||
if (execute)
|
||||
{
|
||||
var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == prevSnap.Id);
|
||||
if (row is null) continue;
|
||||
if (willLink) row.LitterId = litterId;
|
||||
if (receiverId is not null && row.ReceiverContactId is null) row.ReceiverContactId = receiverId;
|
||||
if (willHome) row.GoHomeDate = goHomeDate;
|
||||
if (willDeath)
|
||||
{
|
||||
row.DateOfDeath = deathDate;
|
||||
if (!string.IsNullOrWhiteSpace(da.DeathCause) && row.CauseOfDeath is null)
|
||||
row.CauseOfDeath = da.DeathCause.Trim();
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── PATH 2: main-import animal matched by name+dob ───────────────────────
|
||||
var nameKey = NameDobKey(da.Name, animalDob.Value);
|
||||
if (gerbilByKey.TryGetValue(nameKey, out var gerbilCands))
|
||||
{
|
||||
var gerbilSnap = gerbilCands.FirstOrDefault(g => g.LitterId == null)
|
||||
?? gerbilCands.First();
|
||||
|
||||
bool willLink = litterId is not null && gerbilSnap.LitterId is null;
|
||||
bool willHome = goHomeDate is not null && gerbilSnap.GoHomeDate is null;
|
||||
bool willDeath = deathDate is not null && gerbilSnap.DateOfDeath is null;
|
||||
|
||||
if (willLink) litterLinked++;
|
||||
if (willHome) goHomeFilled++;
|
||||
if (willDeath) deathFilled++;
|
||||
|
||||
if (execute)
|
||||
{
|
||||
var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == gerbilSnap.Id);
|
||||
if (row is null) continue;
|
||||
if (willLink) row.LitterId = litterId;
|
||||
if (receiverId is not null && row.ReceiverContactId is null) row.ReceiverContactId = receiverId;
|
||||
if (willHome) row.GoHomeDate = goHomeDate;
|
||||
if (willDeath)
|
||||
{
|
||||
row.DateOfDeath = deathDate;
|
||||
if (!string.IsNullOrWhiteSpace(da.DeathCause) && row.CauseOfDeath is null)
|
||||
row.CauseOfDeath = da.DeathCause.Trim();
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── PATH 3: no match → CREATE ────────────────────────────────────────────
|
||||
// Belt-and-suspenders: ordinal should ensure uniqueness, but guard anyway
|
||||
if (!batchRefs.Add(externalRef)) { skipped++; continue; }
|
||||
|
||||
animalsCreated++;
|
||||
if (litterId is not null) litterLinked++;
|
||||
if (goHomeDate is not null) goHomeFilled++;
|
||||
if (deathDate is not null) deathFilled++;
|
||||
|
||||
if (execute)
|
||||
{
|
||||
colorVarietyByName.TryGetValue(NormalizeName(da.Farbschlag ?? ""), out var cvId);
|
||||
|
||||
_db.Gerbils.Add(new Gerbil
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = da.Name.Trim(),
|
||||
DateOfBirth = animalDob,
|
||||
Gender = ParseGender(da.Gender),
|
||||
Status = GerbilStatusService.Derive(GerbilStatus.GivenAway, animalDob, deathDate, isAbgegeben: receiverId is not null, DateOnly.FromDateTime(DateTime.UtcNow)),
|
||||
LitterId = litterId,
|
||||
ReceiverContactId = receiverId,
|
||||
GoHomeDate = goHomeDate,
|
||||
DateOfDeath = deathDate,
|
||||
CauseOfDeath = string.IsNullOrWhiteSpace(da.DeathCause) ? null : da.DeathCause.Trim(),
|
||||
ColorVarietyId = cvId == default ? null : cvId,
|
||||
OriginBreeder = "Zucht der kleinen Chaoten",
|
||||
IsResident = false,
|
||||
ImportSource = "docx",
|
||||
ExternalRef = externalRef,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!execute)
|
||||
{
|
||||
// Dry-run: just count, no writes, no transaction needed.
|
||||
await RunLoopAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Execute: wrap the entire transaction in the execution strategy so that providers
|
||||
// with EnableRetryOnFailure (NpgsqlRetryingExecutionStrategy) are compatible.
|
||||
// The lambda resets all mutable state at the top so retries start clean.
|
||||
var strategy = _db.Database.CreateExecutionStrategy();
|
||||
await strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
// Reset mutable state — idempotent on strategy retry
|
||||
_db.ChangeTracker.Clear();
|
||||
externalRefOrdinals.Clear();
|
||||
batchRefs.Clear();
|
||||
animalsCreated = 0; litterLinked = 0; goHomeFilled = 0; deathFilled = 0;
|
||||
ownerLinked = 0; ownerCreated = 0; skipped = 0;
|
||||
// Rebuild from DB snapshot: contacts added in a failed attempt were rolled back
|
||||
contactByNorm = new Dictionary<string, Guid>(contactByNormBase);
|
||||
|
||||
await using var tx = await _db.Database.BeginTransactionAsync();
|
||||
await RunLoopAsync();
|
||||
if ((animalsCreated + litterLinked + goHomeFilled + deathFilled + ownerCreated) > 0)
|
||||
await _db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
});
|
||||
}
|
||||
|
||||
notes.Add($"Quelle: {docxLitCount} Würfe / {docxAnimCount} Tiere aus DOCX. {pdfLitCount} Würfe / {pdfAnimCount} Tiere aus PDF.");
|
||||
notes.Add($"Neu angelegt: {animalsCreated} Jungtiere (abgegeben, nicht in Stammbäumen).");
|
||||
notes.Add($"Litter-Links: {litterLinked} Tiere einem Wurf zugeordnet (DOB-Match ±5 Tage, eindeutig).");
|
||||
notes.Add($"Abnehmer: {ownerLinked} bestehende Kontakte verknüpft, {ownerCreated} neue Kontakte angelegt.");
|
||||
notes.Add($"GoHomeDate: {goHomeFilled} Abgabe-Daten nachgetragen.");
|
||||
notes.Add($"Tod-Datum: {deathFilled} Todesdaten nachgetragen.");
|
||||
notes.Add($"Übersprungen: {skipped} Zeilen (kein Name oder kein Datum).");
|
||||
if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/docx/execute schreibt die Änderungen.");
|
||||
|
||||
return new ImportDocxReport(execute, animalsCreated, litterLinked, ownerLinked + ownerCreated,
|
||||
goHomeFilled, deathFilled, ownerCreated, skipped, notes);
|
||||
}
|
||||
|
||||
private T? Load<T>(string file)
|
||||
{
|
||||
var path = Path.Combine(_sourceDir, file);
|
||||
if (!File.Exists(path)) return default;
|
||||
using var fs = File.OpenRead(path);
|
||||
return JsonSerializer.Deserialize<T>(fs, Json);
|
||||
}
|
||||
|
||||
private static DateOnly? ParseDate(string? s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s)) return null;
|
||||
var m = System.Text.RegularExpressions.Regex.Match(s,
|
||||
@"(\d{1,2})\.(\d{1,2})\.(\d{2,4})");
|
||||
if (!m.Success) return null;
|
||||
int d = int.Parse(m.Groups[1].Value), mo = int.Parse(m.Groups[2].Value);
|
||||
int y = int.Parse(m.Groups[3].Value);
|
||||
if (y < 100) y += 2000;
|
||||
try { return new DateOnly(y, mo, d); } catch { return null; }
|
||||
}
|
||||
|
||||
private static string NameDobKey(string name, DateOnly dob)
|
||||
{
|
||||
var n = System.Text.RegularExpressions.Regex.Replace(
|
||||
(name ?? "").ToLowerInvariant(), @"[^a-z0-9äöüß]", "");
|
||||
return $"{n}|{dob:yyyy-MM-dd}";
|
||||
}
|
||||
|
||||
private static string NormalizeName(string name)
|
||||
{
|
||||
var n = (name ?? "").ToLowerInvariant();
|
||||
n = System.Text.RegularExpressions.Regex.Replace(n, @"\s+", " ").Trim();
|
||||
return n;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base ExternalRef key (before ordinal disambiguation). Two animals sharing the same
|
||||
/// ws+name+litterDob get this same base; the caller appends -2, -3 … for duplicates.
|
||||
/// </summary>
|
||||
internal static string DocxExternalRefBase(DocxAnimal da)
|
||||
{
|
||||
var ws = (da.WsCode ?? "").Replace(" ", "").ToLowerInvariant();
|
||||
var name = System.Text.RegularExpressions.Regex.Replace(
|
||||
(da.Name ?? "").ToLowerInvariant(), @"[^a-z0-9äöüß]", "");
|
||||
return $"docx-{ws}-{name}-{da.LitterDob}";
|
||||
}
|
||||
|
||||
private static Gender ParseGender(string? s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s)) return Gender.unknown;
|
||||
var lower = s.ToLowerInvariant();
|
||||
if (lower == "male" || lower == "m" || lower.Contains("männ")) return Gender.male;
|
||||
if (lower == "female" || lower == "f" || lower == "w" || lower.Contains("weibl")) return Gender.female;
|
||||
return Gender.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Source shapes (from extract_docx.py output) ----
|
||||
|
||||
public sealed class DocxLitter
|
||||
{
|
||||
public string LitterId { get; set; } = "";
|
||||
public string Dob { get; set; } = "";
|
||||
public string MotherName { get; set; } = "";
|
||||
public string FatherName { get; set; } = "";
|
||||
public string WsCode { get; set; } = "";
|
||||
public string Note { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class DocxAnimal
|
||||
{
|
||||
public string WsCode { get; set; } = "";
|
||||
public string LitterDob { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string Farbschlag { get; set; } = "";
|
||||
public string Gender { get; set; } = "";
|
||||
public string Owner { get; set; } = "";
|
||||
public string AbgabeDate { get; set; } = "";
|
||||
public string AbgabeWeight { get; set; } = "";
|
||||
public string DeathDate { get; set; } = "";
|
||||
public string DeathCause { get; set; } = "";
|
||||
public string PartnerName { get; set; } = "";
|
||||
public string PartnerDob { get; set; } = "";
|
||||
}
|
||||
|
||||
// ---- Report ----
|
||||
|
||||
public sealed record ImportDocxReport(
|
||||
bool Executed,
|
||||
int Created,
|
||||
int LitterLinked,
|
||||
int OwnerLinked,
|
||||
int GoHomeFilled,
|
||||
int DeathFilled,
|
||||
int ContactsCreated,
|
||||
int Skipped,
|
||||
IReadOnlyList<string> Notes);
|
||||
}
|
||||
116
GerbilManagerWebAPI/Import/ImportModels.cs
Normal file
116
GerbilManagerWebAPI/Import/ImportModels.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace GerbilManagerWebAPI.Import
|
||||
{
|
||||
// ---- Source shapes (tools/import/output JSON, produced by extract.py) ----
|
||||
|
||||
public sealed class SourceAnimal
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public List<string> NameVariants { get; set; } = new();
|
||||
public string Dob { get; set; } = "";
|
||||
public string Death { get; set; } = "";
|
||||
public string? Gender { get; set; }
|
||||
public string Farbschlag { get; set; } = "";
|
||||
public List<string> FarbschlagVariants { get; set; } = new();
|
||||
public SourceGenotype Genotype { get; set; } = new();
|
||||
public string Zucht { get; set; } = "";
|
||||
public string ZuchtCanon { get; set; } = ""; // declension-folded Zucht key (residency rule a)
|
||||
public List<SourceParentRef> ParentRefs { get; set; } = new();
|
||||
public List<string> Photos { get; set; } = new();
|
||||
public List<string> SourceFiles { get; set; } = new();
|
||||
public bool Conflict { get; set; }
|
||||
public SourceLitterRef? LitterRef { get; set; }
|
||||
|
||||
// GEN-3b normalization: hearing/deaf phenotype flag (null = not stated) and
|
||||
// provenance/breeding tags (WFNZ/RV/GV/DP) — neither is genotype.
|
||||
public bool? Deaf { get; set; }
|
||||
public List<string> Tags { get; set; } = new();
|
||||
|
||||
// Set by extract.py when a human conflict-decision (conflict-decisions.json) un-quarantined
|
||||
// this animal (its genotype/farbschlag are then authoritative). For reporting.
|
||||
public bool ResolvedByDecision { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SourceGenotype
|
||||
{
|
||||
public Dictionary<string, List<string>> Mapped8locus { get; set; } = new();
|
||||
public string RawGenotype { get; set; } = "";
|
||||
public List<string> UnmappedTokens { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class SourceParentRef
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string Dob { get; set; } = "";
|
||||
public string RoleGuess { get; set; } = ""; // "father" | "mother"
|
||||
public string Method { get; set; } = ""; // e.g. "chart-position"
|
||||
public string Confidence { get; set; } = ""; // "hoch" | "medium" | "niedrig"
|
||||
}
|
||||
|
||||
public sealed class SourceLitterRef
|
||||
{
|
||||
public string LitterId { get; set; } = "";
|
||||
public string Method { get; set; } = "";
|
||||
public string Confidence { get; set; } = ""; // "hoch" | "niedrig" | (ambiguous => candidates)
|
||||
public List<string>? Candidates { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SourceLitter
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string LitterId { get; set; } = "";
|
||||
public string Date { get; set; } = "";
|
||||
public string DamName { get; set; } = "";
|
||||
public string SireName { get; set; } = "";
|
||||
public int? TotalBorn { get; set; }
|
||||
public string Zuchtnummer { get; set; } = "";
|
||||
public string Note { get; set; } = "";
|
||||
public List<string> Warnings { get; set; } = new();
|
||||
}
|
||||
|
||||
// ---- Report shapes (Julian-readable: counts per category + samples) ----
|
||||
|
||||
public sealed record ImportReport(
|
||||
bool Executed,
|
||||
LitterSummary Litters,
|
||||
AnimalSummary Animals,
|
||||
PhotoSummary Photos,
|
||||
IReadOnlyList<string> Samples,
|
||||
IReadOnlyList<string> Notes,
|
||||
ResidencySummary? Residency = null);
|
||||
|
||||
/// <summary>Bestand (resident) vs external pedigree ancestors; FlippedByParentRule = foreign-
|
||||
/// Zuchtname animals made resident because they parented a Clan offspring (rule b).
|
||||
/// HerkunftBackfilled = resident animals whose null OriginBreeder was filled (safe, no overwrite).
|
||||
/// FarbschlagWouldRebackfill = already-imported animals with a stale ColorVariety that the
|
||||
/// current extract would update (no-op counter until the safety mechanism is ratified).</summary>
|
||||
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule,
|
||||
int HerkunftBackfilled = 0, int FarbschlagWouldRebackfill = 0);
|
||||
|
||||
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
|
||||
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0,
|
||||
int ParentFksBackfilled = 0, int WithoutDate = 0);
|
||||
|
||||
public sealed record AnimalSummary(
|
||||
int InSource,
|
||||
int Created,
|
||||
int LinkedToLitter,
|
||||
int FarbschlagMatched,
|
||||
int FarbschlagUnmatched,
|
||||
int AlreadyImported,
|
||||
QuarantineSummary Quarantined,
|
||||
int ParentLinksFromChart = 0,
|
||||
int ConflictsResolvedByDecision = 0,
|
||||
int FarbschlagDerivedFromGenotype = 0);
|
||||
|
||||
public sealed record QuarantineSummary(
|
||||
int Conflicts,
|
||||
int Stubs,
|
||||
int DateOnlyLinks,
|
||||
int AmbiguousLinks,
|
||||
int Total);
|
||||
|
||||
public sealed record PhotoSummary(int Attached, int SourceFilesMissing);
|
||||
}
|
||||
754
GerbilManagerWebAPI/Import/ImportService.cs
Normal file
754
GerbilManagerWebAPI/Import/ImportService.cs
Normal file
@@ -0,0 +1,754 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Import
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-8c import loader. Consumes tools/import/output (animals.json + litters.json
|
||||
/// produced by extract.py) and loads conflict-free data into the database:
|
||||
/// litters first (Wurfchronik = authoritative), then animals matched onto them.
|
||||
///
|
||||
/// Load policy:
|
||||
/// - Litters: all created (idempotent by ExternalRef = source litter id).
|
||||
/// - Animals: created when they have a DOB and are NOT in conflict. The birth-litter
|
||||
/// link is set only for HIGH-confidence matches (litterRef.confidence == "hoch");
|
||||
/// date-only/ambiguous links are quarantined (animal loads with LitterId = null).
|
||||
/// - QUARANTINED (never loaded): conflicts + stubs (no DOB) — await the wife's review.
|
||||
/// Idempotent: re-running matches on ExternalRef and skips existing rows.
|
||||
/// Execute is gated by the endpoint; this service only acts when asked.
|
||||
/// </summary>
|
||||
public sealed class ImportService
|
||||
{
|
||||
private static readonly string[] LocusOrder = { "A", "C", "D", "E", "G", "P", "Sp", "Re" };
|
||||
private static readonly JsonSerializerOptions Json = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
private const string ImportSourceTag = "FEAT-8 Stammbaum/Wurfchronik";
|
||||
|
||||
private readonly ApplicationContext _db;
|
||||
private readonly string _sourceDir;
|
||||
private readonly string _photoRoot;
|
||||
|
||||
/// <summary>Test-friendly constructor with explicit paths.</summary>
|
||||
public ImportService(ApplicationContext db, string sourceDir, string photoRoot)
|
||||
{
|
||||
_db = db;
|
||||
_sourceDir = sourceDir;
|
||||
_photoRoot = photoRoot;
|
||||
}
|
||||
|
||||
public ImportService(ApplicationContext db, IConfiguration config, IWebHostEnvironment env)
|
||||
: this(db,
|
||||
config["Import:SourcePath"]
|
||||
?? Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "tools", "import", "output")),
|
||||
config["Photos:RootPath"] ?? Path.Combine(env.ContentRootPath, "photo-storage"))
|
||||
{
|
||||
}
|
||||
|
||||
public async Task<ImportReport> RunAsync(bool execute)
|
||||
{
|
||||
var notes = new List<string>();
|
||||
var samples = new List<string>();
|
||||
|
||||
var animals = Load<List<SourceAnimal>>("animals.json") ?? new();
|
||||
var litters = Load<List<SourceLitter>>("litters.json") ?? new();
|
||||
if (animals.Count == 0 && litters.Count == 0)
|
||||
notes.Add($"Keine Quelldaten gefunden in {_sourceDir} (animals.json/litters.json). extract.py zuerst ausführen.");
|
||||
|
||||
// ---- categorise animals ----
|
||||
var loadable = new List<SourceAnimal>();
|
||||
int conflicts = 0, stubs = 0, dateOnly = 0, ambiguous = 0;
|
||||
foreach (var a in animals)
|
||||
{
|
||||
if (a.Conflict) { conflicts++; continue; }
|
||||
if (string.IsNullOrEmpty(a.Dob)) { stubs++; continue; }
|
||||
loadable.Add(a);
|
||||
var conf = a.LitterRef?.Confidence;
|
||||
if (a.LitterRef?.Candidates is { Count: > 0 }) ambiguous++;
|
||||
else if (conf == "niedrig") dateOnly++;
|
||||
}
|
||||
|
||||
// existing rows (idempotency).
|
||||
var existingGerbilExtRefs = await _db.Gerbils
|
||||
.Where(g => g.ExternalRef != null)
|
||||
.Select(g => g.ExternalRef!).ToListAsync();
|
||||
var existingGerbilSet = existingGerbilExtRefs.ToHashSet();
|
||||
|
||||
// DB-5: Litters now carry ExternalRef (= source litter id from extract.py).
|
||||
// Primary idempotency: ExternalRef. Fallback: Name+Date for litters created before DB-5.
|
||||
var existingLitterData = await _db.Litters
|
||||
.Select(l => new { l.Name, l.Date, l.ExternalRef }).ToListAsync();
|
||||
var existingLitterKeySet = existingLitterData
|
||||
.Select(x => $"{x.Name}|{(x.Date.HasValue ? x.Date.Value.ToString("yyyy-MM-dd") : "")}").ToHashSet();
|
||||
var existingLitterExtRefSet = existingLitterData
|
||||
.Where(x => x.ExternalRef != null)
|
||||
.Select(x => x.ExternalRef!).ToHashSet();
|
||||
|
||||
// colour-variety name -> id (case-insensitive)
|
||||
var varieties = await _db.ColorVarieties.Select(v => new { v.Id, v.Name }).ToListAsync();
|
||||
var varietyByName = varieties
|
||||
.GroupBy(v => v.Name.Trim().ToLowerInvariant())
|
||||
.ToDictionary(g => g.Key, g => g.First().Id);
|
||||
|
||||
// gender inference from litter roles (sire -> male, dam -> female; both -> unknown)
|
||||
var sireNames = litters.Select(l => Normalize(StripZucht(l.SireName))).Where(s => s.Length > 0).ToHashSet();
|
||||
var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet();
|
||||
|
||||
// ---- litters: create map source.id -> Litter (for high-confidence animal links) ----
|
||||
// Idempotency (DB-5): ExternalRef-match is primary (stable source id); Name+Date is the
|
||||
// fallback for litters created before DB-5 (those have ExternalRef=null in the DB).
|
||||
// COUNTER-BUG FIX: undated litters (31 in the Wurfchronik) have no parseable date,
|
||||
// so skip them early — they can never be created or linked to animals.
|
||||
int littersCreated = 0, littersExisting = 0, littersWithoutDate = 0;
|
||||
var litterIdMap = new Dictionary<string, Guid>(); // source litter id -> Litter.Id
|
||||
foreach (var sl in litters)
|
||||
{
|
||||
var date = ParseDate(sl.Date);
|
||||
if (date is null) { littersWithoutDate++; continue; } // undated: skip entirely
|
||||
|
||||
var name = $"Wurf {sl.LitterId}".Trim();
|
||||
var key = $"{name}|{date:yyyy-MM-dd}";
|
||||
// DB-5: check ExternalRef first (stable, source-id-based); fall back to Name+Date
|
||||
// for litters imported before ExternalRef existed (those have ExternalRef = null).
|
||||
if (existingLitterExtRefSet.Contains(sl.Id) || existingLitterKeySet.Contains(key))
|
||||
{ littersExisting++; continue; }
|
||||
|
||||
var id = Guid.NewGuid();
|
||||
litterIdMap[sl.Id] = id;
|
||||
littersCreated++;
|
||||
if (execute)
|
||||
{
|
||||
_db.Litters.Add(new Litter
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Date = date.Value,
|
||||
TotalBorn = sl.TotalBorn,
|
||||
Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note,
|
||||
PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer,
|
||||
ExternalRef = sl.Id, // DB-5: stable import key for future re-imports
|
||||
});
|
||||
}
|
||||
if (samples.Count < 8)
|
||||
samples.Add($"Wurf: {name} ({sl.Date}) — {sl.DamName} × {sl.SireName}");
|
||||
}
|
||||
if (execute) await _db.SaveChangesAsync();
|
||||
|
||||
// ---- animals ----
|
||||
int animalsCreated = 0, linked = 0, fbMatched = 0, fbUnmatched = 0, animalsExisting = 0;
|
||||
int photosAttached = 0, photosMissing = 0;
|
||||
var createdAnimalByName = new Dictionary<string, Guid>(); // normalized name -> gerbil id (for litter back-link)
|
||||
|
||||
// name+DOB -> gid index, across EXISTING rows AND this run's planned animals, so that
|
||||
// chart-position parentRefs (PEDIGREE-LINK) can resolve a parent to a real gerbil id.
|
||||
var existingRows = await _db.Gerbils
|
||||
.Select(g => new { g.Id, g.Name, g.DateOfBirth, g.ExternalRef, g.LitterId, g.ColorVarietyId }).ToListAsync();
|
||||
var gidByNameDob = new Dictionary<string, Guid>();
|
||||
foreach (var g in existingRows)
|
||||
gidByNameDob[NameDobKey(g.Name, g.DateOfBirth)] = g.Id;
|
||||
var existingLitterByExtRef = existingRows.Where(g => g.ExternalRef != null)
|
||||
.ToDictionary(g => g.ExternalRef!, g => g.LitterId);
|
||||
var existingColorVarietyByExtRef = existingRows.Where(g => g.ExternalRef != null)
|
||||
.ToDictionary(g => g.ExternalRef!, g => g.ColorVarietyId);
|
||||
// CR-9: ExternalRef → Gerbil.Id fallback for name/DOB drift on re-import
|
||||
var existingGidByExtRef = existingRows.Where(g => g.ExternalRef != null)
|
||||
.ToDictionary(g => g.ExternalRef!, g => g.Id);
|
||||
|
||||
// CR-11: load CanonicalGenotype for genotype-derived Farbschlag matching
|
||||
var varietiesWithGeno = await _db.ColorVarieties
|
||||
.Select(v => new { v.Id, v.Name, v.CanonicalGenotype }).ToListAsync();
|
||||
|
||||
// PASS 1: assign ids + resolve fb/gender/Wurfchronik link (no writes yet).
|
||||
var plan = new List<AnimalPlan>();
|
||||
int fbDerivedFromGenotype = 0;
|
||||
foreach (var a in loadable)
|
||||
{
|
||||
bool exists = existingGerbilSet.Contains(a.Id);
|
||||
// CR-9: use TryGetValue; fall back to ExternalRef lookup for name/DOB drift
|
||||
// (e.g. correctDob remap or manual rename). Prevents throwing KeyNotFoundException.
|
||||
Guid gid;
|
||||
if (exists)
|
||||
{
|
||||
if (!gidByNameDob.TryGetValue(NameDobKey(a.Name, ParseDate(a.Dob)), out gid))
|
||||
{
|
||||
if (existingGidByExtRef.TryGetValue(a.Id, out gid))
|
||||
notes.Add($"Hinweis: '{a.Name}' (*{a.Dob}) per ExternalRef gefunden trotz Name/DOB-Drift (correctDob oder UI-Umbenennung).");
|
||||
else
|
||||
{
|
||||
notes.Add($"Warnung: ExternalRef '{a.Id}' in DB vorhanden aber nicht auflösbar — Tier übersprungen.");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
else gid = Guid.NewGuid();
|
||||
|
||||
Guid? wurfLitterId = null;
|
||||
if (a.LitterRef?.Confidence == "hoch" && a.LitterRef.Candidates is not { Count: > 0 }
|
||||
&& litterIdMap.TryGetValue(a.LitterRef.LitterId, out var lid))
|
||||
wurfLitterId = lid;
|
||||
|
||||
// CR-11: Farbschlag from explicit name-match first; fall back to genotype derivation
|
||||
// (fill-NULL-only — never overwrites an explicit name-match or manual assignment).
|
||||
Guid? colorVarietyId = null;
|
||||
var fbCandidates = new[] { a.Farbschlag }.Concat(a.FarbschlagVariants)
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s));
|
||||
foreach (var fb in fbCandidates)
|
||||
if (varietyByName.TryGetValue(fb.Trim().ToLowerInvariant(), out var vid))
|
||||
{ colorVarietyId = vid; break; }
|
||||
if (colorVarietyId is null && a.Genotype.Mapped8locus.Count >= 8)
|
||||
{
|
||||
// CR-11: only derive from a fully-specified genotype (all 8 loci known,
|
||||
// no "??" wildcards). Partial genotypes (single-locus or sparse records)
|
||||
// would match any catalog entry via wildcards and produce false positives.
|
||||
var composed = ComposeGenotype(a.Genotype);
|
||||
if (!composed.Contains("??"))
|
||||
foreach (var v in varietiesWithGeno)
|
||||
if (!string.IsNullOrWhiteSpace(v.CanonicalGenotype)
|
||||
&& GenotypePotentiallyMatches(composed, v.CanonicalGenotype))
|
||||
{ colorVarietyId = v.Id; fbDerivedFromGenotype++; break; }
|
||||
}
|
||||
|
||||
var gender = InferGender(a, sireNames, damNames);
|
||||
var norm = Normalize(StripZucht(a.Name));
|
||||
if (norm.Length > 0) createdAnimalByName.TryAdd(norm, gid);
|
||||
if (!exists) gidByNameDob.TryAdd(NameDobKey(a.Name, ParseDate(a.Dob)), gid);
|
||||
|
||||
var currentLitter = exists && existingLitterByExtRef.TryGetValue(a.Id, out var el) ? el : null;
|
||||
var currentColorVarietyId = exists && existingColorVarietyByExtRef.TryGetValue(a.Id, out var ecv) ? ecv : null;
|
||||
plan.Add(new AnimalPlan(a, gid, exists, wurfLitterId, currentLitter, colorVarietyId, gender, currentColorVarietyId));
|
||||
}
|
||||
|
||||
// PASS 1.5: PEDIGREE-LINK — synthesize/reuse a litter from chart-position parentRefs for
|
||||
// any animal that has no Wurfchronik litter and isn't already litter-linked. Siblings
|
||||
// (same father+mother+dob) share one derived litter. Computed for dry-run counts too.
|
||||
int parentLinksAdded = 0, derivedLitters = 0;
|
||||
var synthLitterForGid = new Dictionary<Guid, Guid>(); // offspring gid -> synth litter id
|
||||
var synthLitters = new Dictionary<string, SynthLitter>(); // parents+date key -> synth litter
|
||||
foreach (var p in plan)
|
||||
{
|
||||
if (p.WurfLitterId is not null || p.CurrentLitterId is not null) continue;
|
||||
if (p.A.ParentRefs is not { Count: > 0 }) continue;
|
||||
var father = ResolveParentGid(p.A, "father", gidByNameDob);
|
||||
var mother = ResolveParentGid(p.A, "mother", gidByNameDob);
|
||||
if (father is null && mother is null) continue; // nothing resolvable to link
|
||||
var dob = ParseDate(p.A.Dob);
|
||||
var conf = p.A.ParentRefs.FirstOrDefault()?.Confidence ?? "medium";
|
||||
var key = $"{father}|{mother}|{dob:yyyy-MM-dd}";
|
||||
if (!synthLitters.TryGetValue(key, out var sl))
|
||||
{
|
||||
sl = new SynthLitter(Guid.NewGuid(), father, mother, dob, conf);
|
||||
synthLitters[key] = sl;
|
||||
derivedLitters++;
|
||||
}
|
||||
synthLitterForGid[p.Gid] = sl.Id;
|
||||
parentLinksAdded++;
|
||||
}
|
||||
|
||||
// FK-INTEGRITY (PEDIGREE-LINK bug fix): a litter's Father/MotherId must resolve to a
|
||||
// gerbil that is created-or-existing, or Postgres throws FK_Litters_Gerbils_*. Compute
|
||||
// the persisted set (existing DB rows + this run's loadable animals) and drop any parent
|
||||
// FK that isn't in it; SKIP a derived litter whose BOTH parents are unresolvable (its
|
||||
// offspring then load with LitterId=null — still better than 'unbekannt' won't regress).
|
||||
// This runs in dry-run too, so a green dry-run GUARANTEES /import/execute won't FK-fault.
|
||||
var persisted = new HashSet<Guid>(existingRows.Select(r => r.Id));
|
||||
foreach (var p in plan) persisted.Add(p.Gid);
|
||||
int litterParentFksDropped = 0, derivedLittersSkipped = 0;
|
||||
foreach (var key in synthLitters.Keys.ToList())
|
||||
{
|
||||
var sl = synthLitters[key];
|
||||
var f = sl.Father is Guid gf && persisted.Contains(gf) ? sl.Father : null;
|
||||
var m = sl.Mother is Guid gm && persisted.Contains(gm) ? sl.Mother : null;
|
||||
if (sl.Father is not null && f is null) litterParentFksDropped++;
|
||||
if (sl.Mother is not null && m is null) litterParentFksDropped++;
|
||||
if (f is null && m is null)
|
||||
{
|
||||
derivedLittersSkipped++; derivedLitters--;
|
||||
foreach (var gid in synthLitterForGid.Where(kv => kv.Value == sl.Id).Select(kv => kv.Key).ToList())
|
||||
synthLitterForGid.Remove(gid);
|
||||
synthLitters.Remove(key);
|
||||
continue;
|
||||
}
|
||||
synthLitters[key] = sl with { Father = f, Mother = m };
|
||||
}
|
||||
parentLinksAdded = synthLitterForGid.Count;
|
||||
|
||||
// litter id -> (father, mother) gids, across synthesized + Wurfchronik (by name) litters,
|
||||
// each FK guarded by the persisted set. Used by residency rule (b) below; augmented with
|
||||
// existing DB litters under execute.
|
||||
var litterParents = new Dictionary<Guid, (Guid? F, Guid? M)>();
|
||||
foreach (var sl in synthLitters.Values)
|
||||
litterParents[sl.Id] = (sl.Father, sl.Mother);
|
||||
foreach (var sl in litters)
|
||||
if (litterIdMap.TryGetValue(sl.Id, out var lid))
|
||||
{
|
||||
Guid? f = createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.SireName)), out var fid) && persisted.Contains(fid) ? fid : null;
|
||||
Guid? m = createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.DamName)), out var mid) && persisted.Contains(mid) ? mid : null;
|
||||
litterParents[lid] = (f, m);
|
||||
}
|
||||
|
||||
// FIX-IMPORT-CYCLE: track deferred synth litter parent FKs (populated in PASS 2 below).
|
||||
// Synth litters are added with null FatherId/MotherId to break the Gerbil↔Litter cycle;
|
||||
// the actual FKs are applied AFTER SaveChanges once all gerbils are persisted.
|
||||
var synthLitterPendingParents = new Dictionary<Guid, (Guid? Father, Guid? Mother)>();
|
||||
|
||||
// PASS 2: stage synthesized litters (parents already guarded above). Litters are added
|
||||
// with FatherId/MotherId = null (deferred) so that the SaveChanges below has only a
|
||||
// one-directional Gerbil→Litter dependency — no Litter→Gerbil FKs in the same batch,
|
||||
// which would cause EF's topo-sort to throw "circular dependency detected".
|
||||
if (execute)
|
||||
{
|
||||
// reuse an existing litter with the same parents+date instead of duplicating.
|
||||
var existingLitterRows = await _db.Litters
|
||||
.Select(l => new { l.Id, l.FatherId, l.MotherId, l.Date }).ToListAsync();
|
||||
var litterByParentsDate = new Dictionary<string, Guid>();
|
||||
foreach (var l in existingLitterRows)
|
||||
{
|
||||
litterByParentsDate[$"{l.FatherId}|{l.MotherId}|{(l.Date.HasValue ? l.Date.Value.ToString("yyyy-MM-dd") : "")}"] = l.Id;
|
||||
litterParents[l.Id] = (l.FatherId, l.MotherId);
|
||||
}
|
||||
|
||||
foreach (var sl in synthLitters.Values.ToList())
|
||||
{
|
||||
var reuseKey = $"{sl.Father}|{sl.Mother}|{(sl.Date.HasValue ? sl.Date.Value.ToString("yyyy-MM-dd") : "")}";
|
||||
if (litterByParentsDate.TryGetValue(reuseKey, out var existingId))
|
||||
{
|
||||
// remap offspring to the existing litter; don't create a duplicate.
|
||||
foreach (var g in synthLitterForGid.Where(kv => kv.Value == sl.Id).Select(kv => kv.Key).ToList())
|
||||
synthLitterForGid[g] = existingId;
|
||||
derivedLitters--;
|
||||
continue;
|
||||
}
|
||||
// Defer FatherId/MotherId: both parent gerbils and offspring gerbils may be [Added]
|
||||
// in this same batch. Setting them now causes EF circular dependency
|
||||
// (Gerbil[Added] ← Litter.MotherId [Added] ← Gerbil.LitterId [Added]).
|
||||
synthLitterPendingParents[sl.Id] = (sl.Father, sl.Mother);
|
||||
_db.Litters.Add(new Litter
|
||||
{
|
||||
Id = sl.Id,
|
||||
Name = $"Wurf (aus Diagramm) {(sl.Date.HasValue ? sl.Date.Value.ToString("yyyy-MM-dd") : "")}".Trim(),
|
||||
Date = sl.Date,
|
||||
FatherId = null, // deferred — applied after gerbils SaveChanges
|
||||
MotherId = null, // deferred — applied after gerbils SaveChanges
|
||||
Notes = $"aus Stammbaum-Diagramm abgeleitet (Konfidenz: {sl.Confidence})",
|
||||
});
|
||||
}
|
||||
// NOTE: no SaveChanges here — staged with the gerbils below.
|
||||
}
|
||||
|
||||
// OWNERSHIP/RESIDENCY (runs AFTER litter links exist): (a) Zuchtname matches the Clan
|
||||
// kennel (zuchtCanon contains 'kleinechaote'); (b) parent of a Clan offspring, even if
|
||||
// the parent's own Zuchtname is foreign. See hive/agents/god/OWNERSHIP-residency.md.
|
||||
static bool ClanCanon(string? zc) =>
|
||||
(zc ?? "").Contains("kleinechaote", StringComparison.OrdinalIgnoreCase);
|
||||
var resident = new HashSet<Guid>();
|
||||
foreach (var p in plan) if (ClanCanon(p.A.ZuchtCanon)) resident.Add(p.Gid); // (a)
|
||||
int residentByA = resident.Count, flippedByParentRule = 0;
|
||||
foreach (var p in plan)
|
||||
{
|
||||
if (!ClanCanon(p.A.ZuchtCanon)) continue;
|
||||
var litId = p.WurfLitterId ?? (synthLitterForGid.TryGetValue(p.Gid, out var s) ? s : (Guid?)null);
|
||||
if (litId is null || !litterParents.TryGetValue(litId.Value, out var par)) continue;
|
||||
if (par.F is Guid gf && resident.Add(gf)) flippedByParentRule++; // (b)
|
||||
if (par.M is Guid gm && resident.Add(gm)) flippedByParentRule++;
|
||||
}
|
||||
int residentTotal = plan.Count(p => resident.Contains(p.Gid));
|
||||
int externalTotal = plan.Count - residentTotal;
|
||||
|
||||
foreach (var p in plan)
|
||||
{
|
||||
Guid? litterId = p.WurfLitterId
|
||||
?? (synthLitterForGid.TryGetValue(p.Gid, out var slid) ? slid : (Guid?)null);
|
||||
if (litterId is not null) linked++;
|
||||
bool isResident = resident.Contains(p.Gid);
|
||||
|
||||
if (p.ColorVarietyId is null) fbUnmatched++; else fbMatched++;
|
||||
|
||||
if (samples.Count < 16)
|
||||
samples.Add($"Tier: {p.A.Name} (*{p.A.Dob}), Genotyp {ComposeGenotype(p.A.Genotype)}"
|
||||
+ (litterId is not null ? (p.WurfLitterId is not null ? ", Wurf-verknüpft" : ", Eltern aus Diagramm") : "")
|
||||
+ (p.ColorVarietyId is not null ? $", Farbschlag „{p.A.Farbschlag}\"" : ""));
|
||||
|
||||
if (p.Exists)
|
||||
{
|
||||
animalsExisting++;
|
||||
// sweep idempotency: re-link a now-linkable animal + refresh its residency.
|
||||
if (execute)
|
||||
{
|
||||
var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == p.Gid);
|
||||
if (row is not null)
|
||||
{
|
||||
if (p.CurrentLitterId is null && litterId is not null) row.LitterId = litterId;
|
||||
row.IsResident = isResident;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
animalsCreated++;
|
||||
|
||||
if (execute)
|
||||
{
|
||||
var importedGerbil = new Gerbil
|
||||
{
|
||||
Id = p.Gid,
|
||||
Name = p.A.Name,
|
||||
Gender = p.Gender,
|
||||
Status = GerbilStatus.Breeding,
|
||||
DateOfBirth = ParseDate(p.A.Dob),
|
||||
DateOfDeath = ParseDate(p.A.Death),
|
||||
LitterId = litterId,
|
||||
ColorVarietyId = p.ColorVarietyId,
|
||||
Genotype = ComposeGenotype(p.A.Genotype),
|
||||
IsDeaf = p.A.Deaf,
|
||||
IsResident = isResident,
|
||||
ImportSource = ImportSourceTag,
|
||||
ExternalRef = p.A.Id,
|
||||
OriginBreeder = string.IsNullOrWhiteSpace(p.A.Zucht) ? null : p.A.Zucht.Trim(),
|
||||
RawImportData = JsonSerializer.Serialize(new
|
||||
{
|
||||
p.A.Genotype.RawGenotype,
|
||||
p.A.Genotype.UnmappedTokens,
|
||||
// GEN-3b: Sls (2nd spotting locus) preserved here until Kevin's GEN-3a
|
||||
// parser adopts it into the compact Genotype contract; tags + deaf too.
|
||||
Sls = p.A.Genotype.Mapped8locus.TryGetValue("Sls", out var sls) ? sls : null,
|
||||
p.A.Tags,
|
||||
p.A.Deaf,
|
||||
p.A.Zucht,
|
||||
p.A.SourceFiles,
|
||||
FarbschlagRaw = p.A.Farbschlag,
|
||||
}),
|
||||
};
|
||||
GerbilStatusService.Apply(importedGerbil, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
_db.Gerbils.Add(importedGerbil);
|
||||
}
|
||||
|
||||
// photos
|
||||
foreach (var rel in p.A.Photos)
|
||||
{
|
||||
var src = Path.Combine(_sourceDir, rel.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (!File.Exists(src)) { photosMissing++; continue; }
|
||||
photosAttached++;
|
||||
if (execute)
|
||||
{
|
||||
Directory.CreateDirectory(_photoRoot);
|
||||
var fileName = $"{Guid.NewGuid():N}{Path.GetExtension(src)}";
|
||||
File.Copy(src, Path.Combine(_photoRoot, fileName), overwrite: true);
|
||||
_db.GerbilPhotos.Add(new GerbilPhoto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GerbilId = p.Gid,
|
||||
FileName = fileName,
|
||||
SortOrder = 0,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (execute) await _db.SaveChangesAsync();
|
||||
|
||||
// Apply deferred synth litter parent FKs — all new gerbils are now persisted in the DB,
|
||||
// so no cycle. FK guard already applied above (persisted set); values in the dict are safe.
|
||||
if (execute && synthLitterPendingParents.Count > 0)
|
||||
{
|
||||
foreach (var (litId, (f, m)) in synthLitterPendingParents)
|
||||
{
|
||||
var row = await _db.Litters.FindAsync(litId);
|
||||
if (row is null) continue;
|
||||
if (f is not null) row.FatherId = f;
|
||||
if (m is not null) row.MotherId = m;
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// ---- back-link Wurfchronik litter parents by name (best effort) ----
|
||||
if (execute)
|
||||
{
|
||||
foreach (var sl in litters)
|
||||
{
|
||||
if (!litterIdMap.TryGetValue(sl.Id, out var lid)) continue;
|
||||
var litter = await _db.Litters.FirstOrDefaultAsync(l => l.Id == lid);
|
||||
if (litter is null) continue;
|
||||
// guard: only link parents that are actually persisted (avoid an orphan FK).
|
||||
if (createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.SireName)), out var fId) && persisted.Contains(fId))
|
||||
litter.FatherId = fId;
|
||||
if (createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.DamName)), out var mId) && persisted.Contains(mId))
|
||||
litter.MotherId = mId;
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// PARENT-FK BACKFILL (idempotent re-run): already-imported Wurfchronik litters that
|
||||
// have null Father/MotherId because the parent was previously quarantined may now be
|
||||
// resolvable. Two lookup sources — must check BOTH:
|
||||
// (a) createdAnimalByName: animals loaded/re-linked in THIS run (new or existing).
|
||||
// (b) allDbNormToGid: ALL gerbils already in the DB, for parents loaded in an
|
||||
// EARLIER run who are no longer in the current extract (e.g. alreadyImported
|
||||
// animals absent from this run's animals.json, or name normalization mismatch
|
||||
// between animals.json and the Wurfchronik sire/dam field).
|
||||
// Counted for dry-run too; writes only when execute=true.
|
||||
int parentFksBackfilled = 0;
|
||||
{
|
||||
// Build DB-wide normalized-name lookup (supplementary to createdAnimalByName).
|
||||
var allDbNormToGid = existingRows
|
||||
.GroupBy(g => Normalize(StripZucht(g.Name)))
|
||||
.ToDictionary(grp => grp.Key, grp => grp.First().Id);
|
||||
|
||||
var existingWithNullParent = await _db.Litters
|
||||
.Where(l => l.FatherId == null || l.MotherId == null)
|
||||
.Select(l => new { l.Id, l.Name, l.FatherId, l.MotherId })
|
||||
.ToListAsync();
|
||||
var sourceByName = litters
|
||||
.GroupBy(sl => $"Wurf {sl.LitterId}".Trim())
|
||||
.ToDictionary(g => g.Key, g => g.First());
|
||||
|
||||
Guid? ResolveParentForBackfill(string rawName)
|
||||
{
|
||||
var n = Normalize(StripZucht(rawName));
|
||||
if (n.Length == 0) return null;
|
||||
if (createdAnimalByName.TryGetValue(n, out var fromLoadable) && persisted.Contains(fromLoadable))
|
||||
return fromLoadable;
|
||||
if (allDbNormToGid.TryGetValue(n, out var fromDb) && persisted.Contains(fromDb))
|
||||
return fromDb;
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var el in existingWithNullParent)
|
||||
{
|
||||
if (!sourceByName.TryGetValue(el.Name, out var sl)) continue;
|
||||
var newF = el.FatherId == null ? ResolveParentForBackfill(sl.SireName) : null;
|
||||
var newM = el.MotherId == null ? ResolveParentForBackfill(sl.DamName) : null;
|
||||
if (newF is null && newM is null) continue;
|
||||
parentFksBackfilled++;
|
||||
if (execute)
|
||||
{
|
||||
var row = await _db.Litters.FirstOrDefaultAsync(l => l.Id == el.Id);
|
||||
if (row is not null)
|
||||
{
|
||||
if (newF is not null) row.FatherId = newF;
|
||||
if (newM is not null) row.MotherId = newM;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (execute && parentFksBackfilled > 0) await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// FARBSCHLAG-RE-MATCH — no-op counter (safety mechanism pending god/Julian sign-off).
|
||||
// Counts already-imported animals where the current extract matched a DIFFERENT
|
||||
// ColorVariety than what is currently stored in the DB. Does NOT update any row.
|
||||
int farbschlagWouldRebackfill = plan.Count(p =>
|
||||
p.Exists && p.ColorVarietyId is not null && p.ColorVarietyId != p.CurrentColorVarietyId);
|
||||
|
||||
// CR-11: FARBSCHLAG FROM GENOTYPE post-sweep (fill-NULL-only, safe): existing DB animals
|
||||
// with null ColorVarietyId whose stored Genotype matches a catalog entry get filled.
|
||||
// Mirrors the plan-loop derivation; never overwrites a manually-set or name-matched value.
|
||||
{
|
||||
var noColor = await _db.Gerbils
|
||||
.Where(g => g.ColorVarietyId == null && g.Genotype != null)
|
||||
.Select(g => new { g.Id, g.Genotype })
|
||||
.ToListAsync();
|
||||
foreach (var g in noColor)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(g.Genotype) || g.Genotype!.Contains("??")) continue;
|
||||
Guid? derivedVid = null;
|
||||
foreach (var v in varietiesWithGeno)
|
||||
if (!string.IsNullOrWhiteSpace(v.CanonicalGenotype)
|
||||
&& GenotypePotentiallyMatches(g.Genotype, v.CanonicalGenotype))
|
||||
{ derivedVid = v.Id; break; }
|
||||
if (derivedVid is null) continue;
|
||||
fbDerivedFromGenotype++;
|
||||
if (execute)
|
||||
{
|
||||
var row = await _db.Gerbils.FindAsync(g.Id);
|
||||
if (row is not null && row.ColorVarietyId is null) row.ColorVarietyId = derivedVid;
|
||||
}
|
||||
}
|
||||
if (execute && fbDerivedFromGenotype > 0) await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// HERKUNFT BACKFILL (fill-NULL-only, safe): sweep all resident animals whose
|
||||
// OriginBreeder is null and fill it with a derived value or 'Zucht der Kleinen Chaoten'.
|
||||
// NEVER overwrites a non-null OriginBreeder (Julian: "alle Schreibweisen unterstützen").
|
||||
const string DefaultClanBreeder = "Zucht der kleinen Chaoten";
|
||||
int herkunftBackfilled = 0;
|
||||
{
|
||||
var needsHerkunft = await _db.Gerbils
|
||||
.Where(g => g.OriginBreeder == null && g.IsResident)
|
||||
.Select(g => new { g.Id, g.LitterId })
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var g in needsHerkunft)
|
||||
{
|
||||
herkunftBackfilled++;
|
||||
if (execute)
|
||||
{
|
||||
// Prefer the OriginBreeder of an existing parent (father first, then mother).
|
||||
string? derived = null;
|
||||
if (g.LitterId is not null)
|
||||
{
|
||||
var parentIds = await _db.Litters
|
||||
.Where(l => l.Id == g.LitterId)
|
||||
.Select(l => new { l.FatherId, l.MotherId })
|
||||
.FirstOrDefaultAsync();
|
||||
if (parentIds?.FatherId is Guid fid)
|
||||
derived = await _db.Gerbils.Where(gb => gb.Id == fid && gb.OriginBreeder != null)
|
||||
.Select(gb => gb.OriginBreeder).FirstOrDefaultAsync();
|
||||
if (derived is null && parentIds?.MotherId is Guid mid)
|
||||
derived = await _db.Gerbils.Where(gb => gb.Id == mid && gb.OriginBreeder != null)
|
||||
.Select(gb => gb.OriginBreeder).FirstOrDefaultAsync();
|
||||
}
|
||||
var row = await _db.Gerbils.FindAsync(g.Id);
|
||||
if (row is not null && row.OriginBreeder is null)
|
||||
row.OriginBreeder = derived ?? DefaultClanBreeder;
|
||||
}
|
||||
}
|
||||
if (execute && herkunftBackfilled > 0) await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
if (littersWithoutDate > 0)
|
||||
notes.Add($"Würfe ohne Datum: {littersWithoutDate} Wurfchronik-Einträge ohne parsbares Geburtsdatum übersprungen (weder erstellt noch verknüpft).");
|
||||
notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin.");
|
||||
if (parentLinksAdded > 0)
|
||||
notes.Add($"Stammbaum-Diagramm: {parentLinksAdded} Tiere über Eltern-Verknüpfung einem (abgeleiteten) Wurf zugeordnet ({derivedLitters} abgeleitete Würfe).");
|
||||
notes.Add($"FK-Integrität: {litterParentFksDropped} Eltern-Verknüpfung(en) verworfen (Elternteil nicht ladbar), {derivedLittersSkipped} abgeleitete Würfe übersprungen (kein ladbares Elternteil). Bei 0/0 ist /import/execute FK-sicher.");
|
||||
if (parentFksBackfilled > 0)
|
||||
notes.Add($"Parent-FK-Backfill: {parentFksBackfilled} bereits importierte Würfe haben jetzt eine Eltern-Verknüpfung (Elternteil war zuvor in Quarantäne, jetzt geladen).");
|
||||
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Zucht der kleinen Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
|
||||
if (herkunftBackfilled > 0)
|
||||
notes.Add($"Herkunft-Backfill: {herkunftBackfilled} Bestand-Tier(e) mit leerem Herkunft-Feld befüllt (Zucht der kleinen Chaoten oder von Elternteil abgeleitet). Niemals überschrieben.");
|
||||
if (farbschlagWouldRebackfill > 0)
|
||||
notes.Add($"Farbschlag-Hinweis (kein Overwrite): {farbschlagWouldRebackfill} bereits importierte Tier(e) haben einen veralteten Farbschlag, den der aktuelle Extraktor korrigieren würde — Overwrite-Mechanismus ausstehend (god/Julian Freigabe).");
|
||||
int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);
|
||||
if (conflictsResolvedByDecision > 0)
|
||||
notes.Add($"Konfliktauflösungen: {conflictsResolvedByDecision} Tier(e) anhand von conflict-decisions.json un-quarantänet (Genotyp/Farbschlag der Züchterin ist maßgeblich).");
|
||||
if (fbDerivedFromGenotype > 0)
|
||||
notes.Add($"Farbschlag aus Genotyp: {fbDerivedFromGenotype} Tier(e) ohne expliziten Farbschlag-Namen wurden über den Katalog-Genotyp-Abgleich zugeordnet (band-aware Deep-Band-Tiere).");
|
||||
if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/execute lädt die konfliktfreien Daten.");
|
||||
|
||||
return new ImportReport(
|
||||
Executed: execute,
|
||||
Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters, derivedLittersSkipped, litterParentFksDropped, parentFksBackfilled, littersWithoutDate),
|
||||
Animals: new AnimalSummary(
|
||||
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
|
||||
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),
|
||||
parentLinksAdded, conflictsResolvedByDecision, fbDerivedFromGenotype),
|
||||
Photos: new PhotoSummary(photosAttached, photosMissing),
|
||||
Samples: samples,
|
||||
Notes: notes,
|
||||
Residency: new ResidencySummary(residentTotal, externalTotal, flippedByParentRule,
|
||||
HerkunftBackfilled: herkunftBackfilled, FarbschlagWouldRebackfill: farbschlagWouldRebackfill));
|
||||
}
|
||||
|
||||
private T? Load<T>(string file)
|
||||
{
|
||||
var path = Path.Combine(_sourceDir, file);
|
||||
if (!File.Exists(path)) return default;
|
||||
using var fs = File.OpenRead(path);
|
||||
return JsonSerializer.Deserialize<T>(fs, Json);
|
||||
}
|
||||
|
||||
public static string ComposeGenotype(SourceGenotype g)
|
||||
{
|
||||
var tokens = LocusOrder.Select(locus =>
|
||||
{
|
||||
if (g.Mapped8locus.TryGetValue(locus, out var pair) && pair.Count == 2)
|
||||
return StripCaret(pair[0]) + StripCaret(pair[1]);
|
||||
return "??";
|
||||
}).ToList();
|
||||
|
||||
// GEN-3a contract (Kevin): Sls is appended LAST and ONLY for carriers — the
|
||||
// wild-type sl/sl is omitted so existing 8-locus strings stay unchanged. WP het
|
||||
// renders as the trailing token "Slsl"; S(l)S(l) is lethal so never appears.
|
||||
if (g.Mapped8locus.TryGetValue("Sls", out var sls) && sls.Count == 2
|
||||
&& !(sls[0] == "sl" && sls[1] == "sl"))
|
||||
{
|
||||
tokens.Add(StripCaret(sls[0]) + StripCaret(sls[1]));
|
||||
}
|
||||
return string.Join(' ', tokens);
|
||||
}
|
||||
|
||||
private static string StripCaret(string allele) => allele.Replace("^", "");
|
||||
|
||||
/// <summary>CR-11: check if a composed animal genotype is compatible with a catalog canonical
|
||||
/// genotype. Both are space-separated 8-locus tokens (e.g. "aa CC DD ee GG PP spsp rere").
|
||||
/// "??" in either position is a wildcard. The first 8 tokens are compared; any trailing
|
||||
/// Sls token is ignored (it is outside the base 8-locus contract).</summary>
|
||||
private static bool GenotypePotentiallyMatches(string animalGeno, string catalogGeno)
|
||||
{
|
||||
var a = animalGeno.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var c = catalogGeno.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (a.Length < 8 || c.Length < 8) return false;
|
||||
for (int i = 0; i < 8; i++)
|
||||
{
|
||||
if (a[i] == "??" || c[i] == "??") continue;
|
||||
if (!string.Equals(a[i], c[i], StringComparison.OrdinalIgnoreCase)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Gender InferGender(SourceAnimal a, HashSet<string> sires, HashSet<string> dams)
|
||||
{
|
||||
// Box colour (blue=male, white=female) is the authoritative breeder signal — prefer it
|
||||
// over sire/dam name inference (PEDIGREE-LINK, Julian 2026-06-06).
|
||||
if (string.Equals(a.Gender, "male", StringComparison.OrdinalIgnoreCase)) return Gender.male;
|
||||
if (string.Equals(a.Gender, "female", StringComparison.OrdinalIgnoreCase)) return Gender.female;
|
||||
|
||||
var n = Normalize(StripZucht(a.Name));
|
||||
bool isSire = sires.Contains(n), isDam = dams.Contains(n);
|
||||
if (isSire && !isDam) return Gender.male;
|
||||
if (isDam && !isSire) return Gender.female;
|
||||
return Gender.unknown;
|
||||
}
|
||||
|
||||
public static DateOnly? ParseDate(string s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s)) return null;
|
||||
var m = Regex.Match(s, @"(\d{1,2})\.(\d{1,2})\.(\d{2,4})");
|
||||
if (!m.Success) return null;
|
||||
int d = int.Parse(m.Groups[1].Value), mo = int.Parse(m.Groups[2].Value);
|
||||
int y = int.Parse(m.Groups[3].Value);
|
||||
if (y < 100) y += 2000;
|
||||
try { return new DateOnly(y, mo, d); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static string StripZucht(string name)
|
||||
{
|
||||
var n = Regex.Replace(name ?? "", @"\[.*?\]", " ");
|
||||
n = Regex.Replace(n, @"\s+(?:of|von\s+den|von\s+der|v\.\s?d\.|von)\s+.*$", "", RegexOptions.IgnoreCase);
|
||||
return n.Trim();
|
||||
}
|
||||
|
||||
private static string Normalize(string name)
|
||||
{
|
||||
var n = (name ?? "").ToLowerInvariant();
|
||||
n = Regex.Replace(n, @"\bgen\.\b", " ");
|
||||
n = Regex.Replace(n, @"[^a-z0-9äöüß]", "");
|
||||
return n;
|
||||
}
|
||||
|
||||
/// <summary>Dedup identity for parent resolution: normalized call-name + DOB.</summary>
|
||||
private static string NameDobKey(string name, DateOnly? dob) =>
|
||||
$"{Normalize(StripZucht(name))}|{dob:yyyy-MM-dd}";
|
||||
|
||||
/// <summary>Resolve a chart-position parentRef (by role) to a known gerbil id, or null.</summary>
|
||||
private static Guid? ResolveParentGid(SourceAnimal a, string role, Dictionary<string, Guid> gidByNameDob)
|
||||
{
|
||||
var pr = a.ParentRefs.FirstOrDefault(p =>
|
||||
string.Equals(p.RoleGuess, role, StringComparison.OrdinalIgnoreCase));
|
||||
if (pr is null || string.IsNullOrWhiteSpace(pr.Name)) return null;
|
||||
return gidByNameDob.TryGetValue(NameDobKey(pr.Name, ParseDate(pr.Dob)), out var id) ? id : null;
|
||||
}
|
||||
|
||||
/// <summary>Per-animal plan computed before any write so synthesis can run in dry-run too.</summary>
|
||||
private sealed record AnimalPlan(
|
||||
SourceAnimal A, Guid Gid, bool Exists, Guid? WurfLitterId,
|
||||
Guid? CurrentLitterId, Guid? ColorVarietyId, Gender Gender,
|
||||
Guid? CurrentColorVarietyId = null);
|
||||
|
||||
/// <summary>A litter synthesized from chart-position parentRefs (PEDIGREE-LINK).</summary>
|
||||
private sealed record SynthLitter(Guid Id, Guid? Father, Guid? Mother, DateOnly? Date, string Confidence);
|
||||
}
|
||||
}
|
||||
260
GerbilManagerWebAPI/Import/IngestResolvedService.cs
Normal file
260
GerbilManagerWebAPI/Import/IngestResolvedService.cs
Normal file
@@ -0,0 +1,260 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
|
||||
namespace GerbilManagerWebAPI.Import
|
||||
{
|
||||
public sealed class IngestResolvedService
|
||||
{
|
||||
private readonly ApplicationContext _db;
|
||||
private readonly string _resolvedJsonPath;
|
||||
private readonly string _sourceDir;
|
||||
private readonly string _photoRoot;
|
||||
|
||||
public IngestResolvedService(ApplicationContext db, IConfiguration config, IWebHostEnvironment? env)
|
||||
{
|
||||
_db = db;
|
||||
var contentRoot = env?.ContentRootPath ?? Directory.GetCurrentDirectory();
|
||||
_sourceDir = config["Import:SourcePath"]
|
||||
?? Path.GetFullPath(Path.Combine(contentRoot, "..", "tools", "import", "output"));
|
||||
_resolvedJsonPath = Path.Combine(_sourceDir, "resolved_import.json");
|
||||
_photoRoot = config["Photos:RootPath"]
|
||||
?? Path.Combine(contentRoot, "photo-storage");
|
||||
}
|
||||
|
||||
public async Task<string> RunAsync()
|
||||
{
|
||||
if (!File.Exists(_resolvedJsonPath))
|
||||
{
|
||||
return $"Error: Resolved import file not found at {_resolvedJsonPath}. Run resolve_import.py first.";
|
||||
}
|
||||
|
||||
string jsonContent = await File.ReadAllTextAsync(_resolvedJsonPath);
|
||||
var jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
jsonOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
||||
var data = JsonSerializer.Deserialize<ResolvedImportData>(jsonContent, jsonOptions);
|
||||
|
||||
if (data == null)
|
||||
{
|
||||
return "Error: Failed to deserialize resolved_import.json.";
|
||||
}
|
||||
|
||||
// 1. Provider-agnostic clean wipe of dependent tables to prevent duplicate keys
|
||||
if (_db.Database.ProviderName == "Microsoft.EntityFrameworkCore.InMemory")
|
||||
{
|
||||
// Break circular references first
|
||||
foreach (var l in _db.Litters)
|
||||
{
|
||||
l.FatherId = null;
|
||||
l.MotherId = null;
|
||||
}
|
||||
foreach (var g in _db.Gerbils)
|
||||
{
|
||||
g.LitterId = null;
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_db.GerbilPhotos.RemoveRange(_db.GerbilPhotos);
|
||||
_db.WeightRecords.RemoveRange(_db.WeightRecords);
|
||||
_db.HealthRecords.RemoveRange(_db.HealthRecords);
|
||||
_db.SaleContracts.RemoveRange(_db.SaleContracts);
|
||||
_db.Gerbils.RemoveRange(_db.Gerbils);
|
||||
_db.Litters.RemoveRange(_db.Litters);
|
||||
_db.Contacts.RemoveRange(_db.Contacts);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Break circular references first using ExecuteUpdateAsync
|
||||
await _db.Litters.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(l => l.FatherId, (Guid?)null)
|
||||
.SetProperty(l => l.MotherId, (Guid?)null));
|
||||
|
||||
await _db.Gerbils.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(g => g.LitterId, (Guid?)null));
|
||||
|
||||
await _db.GerbilPhotos.ExecuteDeleteAsync();
|
||||
await _db.WeightRecords.ExecuteDeleteAsync();
|
||||
await _db.HealthRecords.ExecuteDeleteAsync();
|
||||
await _db.SaleContracts.ExecuteDeleteAsync();
|
||||
await _db.Gerbils.ExecuteDeleteAsync();
|
||||
await _db.Litters.ExecuteDeleteAsync();
|
||||
await _db.Contacts.ExecuteDeleteAsync();
|
||||
}
|
||||
|
||||
// 2. Import Contacts (Add new ones, and update roles/details of existing ones)
|
||||
var existingContacts = await _db.Contacts.ToDictionaryAsync(c => c.Id);
|
||||
var addedContactIds = new HashSet<Guid>();
|
||||
int contactsAdded = 0;
|
||||
int contactsUpdated = 0;
|
||||
foreach (var c in data.Contacts)
|
||||
{
|
||||
if (existingContacts.TryGetValue(c.Id, out var existingContact))
|
||||
{
|
||||
existingContact.Name = c.Name;
|
||||
existingContact.Email = c.Email;
|
||||
existingContact.Phone = c.Phone;
|
||||
existingContact.Address = c.Address;
|
||||
existingContact.Notes = c.Notes;
|
||||
existingContact.IsBreeder = c.IsBreeder;
|
||||
existingContact.IsReceiver = c.IsReceiver;
|
||||
contactsUpdated++;
|
||||
}
|
||||
else if (addedContactIds.Add(c.Id))
|
||||
{
|
||||
_db.Contacts.Add(c);
|
||||
contactsAdded++;
|
||||
}
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// 3. Pass 1: Insert all Litters with parent references set to null to avoid FK errors
|
||||
var littersToInsert = new List<Litter>();
|
||||
foreach (var l in data.Litters)
|
||||
{
|
||||
littersToInsert.Add(new Litter
|
||||
{
|
||||
Id = l.Id,
|
||||
Name = l.Name,
|
||||
Date = l.Date,
|
||||
TotalBorn = l.TotalBorn,
|
||||
DeathsWithin8Weeks = l.DeathsWithin8Weeks,
|
||||
FatherId = null, // Set in Pass 2
|
||||
MotherId = null, // Set in Pass 2
|
||||
ExpectedGoHomeDate = l.ExpectedGoHomeDate,
|
||||
Notes = l.Notes,
|
||||
PairingCode = l.PairingCode,
|
||||
ExternalRef = l.ExternalRef,
|
||||
LitterLetter = l.LitterLetter
|
||||
});
|
||||
}
|
||||
_db.Litters.AddRange(littersToInsert);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// 4. Pass 1: Insert all Gerbils with LitterId set to null to avoid FK errors
|
||||
var gerbilsToInsert = new List<Gerbil>();
|
||||
foreach (var g in data.Gerbils)
|
||||
{
|
||||
var importedGerbil = new Gerbil
|
||||
{
|
||||
Id = g.Id,
|
||||
Name = g.Name,
|
||||
Gender = g.Gender,
|
||||
Status = g.Status,
|
||||
LitterId = null, // Set in Pass 2
|
||||
OriginContactId = g.OriginContactId,
|
||||
ReceiverContactId = g.ReceiverContactId,
|
||||
EnclosureId = g.EnclosureId,
|
||||
ColorVarietyId = g.ColorVarietyId,
|
||||
DateOfBirth = g.DateOfBirth,
|
||||
DateOfDeath = g.DateOfDeath,
|
||||
CauseOfDeath = g.CauseOfDeath,
|
||||
GoHomeDate = g.GoHomeDate,
|
||||
Genotype = g.Genotype,
|
||||
Notes = g.Notes,
|
||||
ImportSource = g.ImportSource,
|
||||
ExternalRef = g.ExternalRef,
|
||||
RawImportData = g.RawImportData,
|
||||
OriginBreeder = g.OriginBreeder,
|
||||
NameSearch = g.NameSearch,
|
||||
CharacterTraits = g.CharacterTraits,
|
||||
CharacterNote = g.CharacterNote,
|
||||
IsDeaf = g.IsDeaf,
|
||||
IsResident = g.IsResident
|
||||
};
|
||||
GerbilStatusService.Apply(importedGerbil, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
gerbilsToInsert.Add(importedGerbil);
|
||||
}
|
||||
_db.Gerbils.AddRange(gerbilsToInsert);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// 5. Pass 2: Set the actual foreign key relationships
|
||||
// Update Litters with their parent animal IDs
|
||||
var littersInDb = await _db.Litters.ToDictionaryAsync(l => l.Id);
|
||||
foreach (var l in data.Litters)
|
||||
{
|
||||
if (littersInDb.TryGetValue(l.Id, out var dbLitter))
|
||||
{
|
||||
dbLitter.FatherId = l.FatherId;
|
||||
dbLitter.MotherId = l.MotherId;
|
||||
}
|
||||
}
|
||||
|
||||
// Update Gerbils with their actual LitterId
|
||||
var gerbilsInDb = await _db.Gerbils.ToDictionaryAsync(g => g.Id);
|
||||
foreach (var g in data.Gerbils)
|
||||
{
|
||||
if (gerbilsInDb.TryGetValue(g.Id, out var dbGerbil))
|
||||
{
|
||||
dbGerbil.LitterId = g.LitterId;
|
||||
}
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// 6. Insert all Photos & copy physical files
|
||||
Directory.CreateDirectory(_photoRoot);
|
||||
foreach (var p in data.GerbilPhotos)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(p.SourcePath))
|
||||
{
|
||||
var relPath = p.SourcePath.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);
|
||||
var src = Path.Combine(_sourceDir, relPath);
|
||||
if (File.Exists(src))
|
||||
{
|
||||
var dest = Path.Combine(_photoRoot, p.FileName);
|
||||
try
|
||||
{
|
||||
File.Copy(src, dest, overwrite: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Warning: Failed to copy photo {src} to {dest}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Warning: Source photo file not found at {src}");
|
||||
}
|
||||
}
|
||||
|
||||
_db.GerbilPhotos.Add(new GerbilPhoto
|
||||
{
|
||||
Id = p.Id,
|
||||
GerbilId = p.GerbilId,
|
||||
FileName = p.FileName,
|
||||
Caption = p.Caption,
|
||||
SortOrder = p.SortOrder,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return $"Ingestion successful! Imported {contactsAdded} contacts ({contactsUpdated} updated), {data.Litters.Count} litters, {data.Gerbils.Count} gerbils, and {data.GerbilPhotos.Count} photos.";
|
||||
}
|
||||
}
|
||||
|
||||
public class ResolvedImportData
|
||||
{
|
||||
public List<Contact> Contacts { get; set; } = new();
|
||||
public List<Litter> Litters { get; set; } = new();
|
||||
public List<Gerbil> Gerbils { get; set; } = new();
|
||||
public List<ResolvedPhoto> GerbilPhotos { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ResolvedPhoto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid GerbilId { get; set; }
|
||||
public required string FileName { get; set; }
|
||||
public string? Caption { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
[JsonPropertyName("_source_path")]
|
||||
public string? SourcePath { get; set; }
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user