Compare commits
507 Commits
74dee4a6e2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 45c3f87f01 | |||
| 748ce4733f | |||
| 5a6d1c2541 | |||
| bf074696b4 | |||
| fdad1633cd | |||
| 54e873c115 | |||
| 7a2feac04d | |||
| 0b99cfd2bd | |||
| 1cb9e52117 | |||
| 2e7911074f | |||
| 0a4bcabd98 | |||
| 1484d04917 | |||
| 854da1a000 | |||
| 60214a7c92 | |||
| 45673bb066 | |||
| 45b8533f18 | |||
| 84365bba7f | |||
| a333bce1b3 | |||
| bbd305861f | |||
| 8aaddea4ef | |||
| 54cc493230 | |||
| bb1b8f8c76 | |||
| 00a3919c9f | |||
| b0e92c363a | |||
| 7262b34ae9 | |||
| ebc70cc0b8 | |||
| f9e672c707 | |||
| fe79be2eee | |||
| 41ee4ee3ab | |||
| f7a1a7724b | |||
| 750619d3d7 | |||
| 48b138bbfd | |||
| c0a7b1a5ee | |||
| a0fa33da42 | |||
| 6b264a9b71 | |||
| 26977d73ff | |||
| 12f5de00a0 | |||
| 0540498e85 | |||
| a87d1ec865 | |||
| 8c1ee75b81 | |||
| aa4552ff84 | |||
| 18d74ff3f3 | |||
| 72ad706ca6 | |||
| a66739e84d | |||
| 00dba91a5c | |||
| c7b054e909 | |||
| 00dc724605 | |||
| 156ede120e | |||
| db2c29769e | |||
| ae7d171131 | |||
| 6a64698601 | |||
| 1469a7f3dc | |||
| 6123b8ef9e | |||
| 0b0ebd5fad | |||
| 4ec0f787dd | |||
| d380a0c2ef | |||
| 40c92c70d4 | |||
| 4e7cd21b70 | |||
| 3ad276637c | |||
| b4dc37de78 | |||
| d25d2ee152 | |||
| 55e79cda51 | |||
| 23f028123d | |||
| 4e30dc7fe3 | |||
| c15dd78bc7 | |||
| dcf0e40f9b | |||
| b9021fe577 | |||
| ce1704c3b7 | |||
| 9c6ca2118a | |||
| 8bfa2a7911 | |||
| 5edd84046d | |||
| d27a389f0e | |||
| b437d09312 | |||
| 5069c0eb1e | |||
| f1c1069821 | |||
| 3f31a2efbc | |||
| 4c0cba854a | |||
| ac06cadb23 | |||
| e1299a1a63 | |||
| 5175c3229a | |||
| 5e14124322 | |||
| aa473b764e | |||
| 2befe53df9 | |||
| cf672c0933 | |||
| a31fea17fa | |||
| 438c816820 | |||
| 9c98c37d2a | |||
| b9e5b031c4 | |||
| 1845d37476 | |||
| 2868aad369 | |||
| 03ad9e6e81 | |||
| 18efab6996 | |||
| 070a896072 | |||
| 3715e32303 | |||
| cb2ee03a14 | |||
| 41ff973f9a | |||
| d2993418b6 | |||
| 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 |
35
.claude/settings.json
Normal file
35
.claude/settings.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "mempalace hook run --hook session-start --harness claude-code"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "mempalace hook run --hook stop --harness claude-code"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "mempalace hook run --hook precompact --harness claude-code"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
214
.claude/skills/ticket-triage/SKILL.md
Normal file
214
.claude/skills/ticket-triage/SKILL.md
Normal file
@@ -0,0 +1,214 @@
|
||||
---
|
||||
name: ticket-triage
|
||||
description: >-
|
||||
Fehlerberichte (Tickets) der Züchterin abarbeiten: neue Tickets per Subagent
|
||||
triagieren, Rückfragen an die Tickets hängen, beantwortete Tickets wieder
|
||||
aufgreifen, Fixes gebündelt umsetzen und beim Schließen dokumentieren, was
|
||||
gefixt wurde. Nutze diesen Skill, wenn der Nutzer „Tickets abarbeiten /
|
||||
triagieren", „Fehlerberichte bearbeiten" o. Ä. verlangt.
|
||||
---
|
||||
|
||||
# Ticket-Triage & Abarbeitung
|
||||
|
||||
Tickets entstehen über das „Fehler melden"-Feature (`POST /feedback`) und werden
|
||||
unter **Hilfe → Meine Tickets** (`/hilfe/tickets`) verwaltet. Dieser Skill
|
||||
automatisiert das Triagieren, Rückfragen-Stellen, Umsetzen und Schließen.
|
||||
|
||||
## Status-Lebenszyklus (Feedback.Status)
|
||||
|
||||
| Status | Bedeutung | Wer ist dran |
|
||||
|---|---|---|
|
||||
| `Open` | Neues Ticket, noch nicht triagiert | **KI** (triagieren) |
|
||||
| `NeedsInfo` | KI hat eine **Rückfrage** angehängt | Züchterin (antworten) |
|
||||
| `Answered` | Züchterin hat geantwortet | **KI** (wieder aufgreifen, umsetzen) |
|
||||
| `Resolved` | Erledigt — mit `fixNote` (Changelog) | — |
|
||||
|
||||
**Zu bearbeiten sind `Open` (neu) und `Answered` (KI wieder dran).** `NeedsInfo`
|
||||
wartet auf die Züchterin, `Resolved` ist fertig — beide überspringen.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- App läuft (Aspire): `dotnet run --project GerbilManager.AppHost`; API auf
|
||||
http://localhost:5179. Siehe `CLAUDE.md` (Architektur, Import→DB-Workflow,
|
||||
Domänenwissen: 6-Jahre-Lebensspanne, Geschwisterverpaarung, Genotyp/E-Locus,
|
||||
`isResident`).
|
||||
- Feedback überlebt den Import-Ingest (FK-freie Spalten) — Tickets bleiben
|
||||
erhalten, auch wenn die DB neu befüllt wird.
|
||||
|
||||
## Ziel-System: lokal ODER Prod (WICHTIG — zuerst klären)
|
||||
|
||||
Die Tickets liegen dort, wo die Züchterin sie meldet — im Regelfall **Prod**
|
||||
(TrueNAS Custom-App). Kläre zu Beginn, gegen welches System der Lauf geht, und
|
||||
setze die **API-Base-URL** entsprechend für ALLE `feedback`/`gerbils`/`litters`-Aufrufe:
|
||||
|
||||
| System | API-Base | Bookmark der Züchterin |
|
||||
|---|---|---|
|
||||
| **Prod** (Regelfall) | `http://truenas:8090/api` | http://truenas:8090/ |
|
||||
| **Lokal** (Aspire) | `http://localhost:5179` | — (App via `dotnet run --project GerbilManager.AppHost`) |
|
||||
|
||||
- **Ticket-CRUD läuft immer gegen das Ziel-System** (Prod: `…/api/feedback`).
|
||||
Einzelticket: `GET /feedback/{id}`; nur die offenen: `GET /feedback?status=Open,Answered`
|
||||
(spart den 2-MB-Volldump). Encoding: über Datei holen + Python-UTF-8 lesen.
|
||||
- **Untersuchung bleibt lokal**: die Quellen (Stammbaum-xlsx, `_rpro3.db`, Wurfchronik,
|
||||
`output/*.json`) liegen lokal. Datenfixes gehören re-ingest-stabil in
|
||||
`conflict-decisions.json` / `rpro3-decisions.json` — NICHT die Live-DB patchen.
|
||||
- **Prod-Re-Ingest ohne SSH** (bevorzugt): lokal `resolved_import.json` generieren
|
||||
(extract → extract_contracts → merge_and_resolve), dann **per HTTP hochladen + ingesten**:
|
||||
```bash
|
||||
curl -X POST http://truenas:8090/api/import/ingest-resolved/upload \
|
||||
-F "resolved=@tools/import/output/resolved_import.json" \
|
||||
[-F "photos=@neue_fotos.zip"] # photos optional, nur bei neuen Fotos
|
||||
```
|
||||
Der Endpoint staget die Datei serverseitig und ruft denselben UPSERT-Ingest auf
|
||||
(manuell/verified/feedback überleben). **DB-Copy vom lokalen Stand ist VERBOTEN**,
|
||||
sobald Prod eigenständig kuratiert wurde — es überschreibt die Prod-Kuratierung;
|
||||
nur der Upsert-Ingest bewahrt sie. (Fallback ohne Endpoint: `resolved_import.json`
|
||||
per `scp` + `docker cp` nach `ix-gerbilmanager-api-1:/tools/import/output/` und
|
||||
`POST …/api/import/ingest-resolved`.)
|
||||
- Vor jeder **Prod-Mutation** (Ingest, Ticket-Schließen im großen Stil) kurz beim
|
||||
Nutzer rückversichern.
|
||||
|
||||
## Ablauf
|
||||
|
||||
### 1. Tickets holen & einteilen
|
||||
`curl -s http://localhost:5179/feedback` (neueste zuerst). Encoding-Falle: über
|
||||
eine Datei holen (`curl -o raw.json`) und mit `python … .decode('utf-8')` lesen,
|
||||
nicht via Windows-stdin-Pipe (sonst Mojibake). Test-/Dummy-Tickets ausschließen.
|
||||
Filtere auf `status in (Open, Answered)`. Bei `Answered` zusätzlich das Feld
|
||||
`answer` (Antwort der Züchterin) mitnehmen — darauf basiert die Umsetzung.
|
||||
|
||||
### 2. Triage per Subagent (ein Agent pro Ticket)
|
||||
Nutze die **Workflow-Orchestrierung** (`pipeline`) — einen Triage-Agenten pro
|
||||
Ticket. args ASCII-sicher halten (nur id/context/gerbilId/litterId übergeben; die
|
||||
deutschen Nachrichten holt der Agent selbst aus `GET /feedback`). Defensive im
|
||||
Script: `const tickets = Array.isArray(args) ? args : JSON.parse(args)`.
|
||||
|
||||
Jeder Triage-Agent:
|
||||
1. liest `CLAUDE.md` + `docs/ticket-triage.md` (falls vorhanden),
|
||||
2. holt seinen Ticket-Text (+ `answer` bei `Answered`) aus `GET /feedback`,
|
||||
3. untersucht die echten Daten über die API (`GET /gerbils/{id}`, `/litters/{id}`,
|
||||
Eltern verfolgen; `provenance` zeigt Import-Herkunft inkl. verworfener Werte),
|
||||
4. liefert strukturiert (StructuredOutput-Schema): `ticketId, title, category,
|
||||
rootCause, recommendation, fixLayer (import-data|import-logic|backend|frontend|
|
||||
genetics-engine), affectedAreas[], overlapKey (Cluster-Slug), effort (S/M/L),
|
||||
confidence, needsUserInfo`,
|
||||
5. **terminale Aktion am Ticket selbst** (jeder Triage-Agent schließt seinen
|
||||
Vorgang ab, lässt nichts offen liegen): braucht es Infos von der Züchterin →
|
||||
**Rückfrage ordentlich stellen** (siehe 3); ist die Ursache rein eine Daten-/
|
||||
Code-Korrektur ohne Rückfrage → an den passenden Umsetzungs-Cluster (4)
|
||||
übergeben (NICHT selbst querschnittlich Dateien ändern).
|
||||
|
||||
Sammle alle Ergebnisse in **eine Datei** `docs/ticket-triage.md` (Cluster-/
|
||||
Überschneidungs-Matrix + offene Rückfragen) — token-schonend per Python aus dem
|
||||
Workflow-Ergebnis generieren, nicht 70 KB in den Kontext laden.
|
||||
|
||||
### 3. Rückfragen ordentlich stellen (durch den Triage-Agenten)
|
||||
Wenn ein Ticket Infos der Züchterin braucht, hängt der Triage-Agent die Rückfrage
|
||||
**direkt selbst** ans Ticket:
|
||||
`PUT /feedback/{id}` mit `{"question": "<Rückfrage>"}` → Status wird `NeedsInfo`.
|
||||
Anforderungen an eine gute Rückfrage: **eine konkrete Frage** (kein Sammelsurium),
|
||||
in **einfacher, freundlicher Sprache** für die Züchterin (keine Code-/Fachbegriffe),
|
||||
mit dem nötigen Kontext (welches Tier/welcher Wurf, was unklar ist), und mit einer
|
||||
klaren Handlungsaufforderung (z. B. „Wie heißt die Mutter von X?"). Die Züchterin
|
||||
beantwortet es in der App; beim nächsten Lauf erscheint es als `Answered` und wird
|
||||
wieder aufgegriffen.
|
||||
|
||||
### 4. Umsetzen — gebündelt nach Cluster (Überschneidungen vermeiden!)
|
||||
Die Triage-Matrix zeigt, welche Tickets sich Dateien teilen. Bündele nach
|
||||
**datei-disjunkten Workstreams**, damit parallele Subagenten nicht kollidieren:
|
||||
- **Genetik-Engine** → `gerbil-manager-web/src/genetics/**`, `tools/import/genotype.py`.
|
||||
- **Stammbaum-Import** (Eltern/Dedup/Gender/`isResident`) → `tools/import/extract.py`,
|
||||
`merge_and_resolve.py`, `conflict-decisions.json`.
|
||||
- **Frontend-Anzeige** (z. B. Nicht-Bestandstiere ausblenden) → `GerbilDetailPage.tsx`,
|
||||
`de.ts`.
|
||||
Gemeinsam genutzte Dateien (`de.ts`, `GerbilDetailPage.tsx`) gehören **genau einem**
|
||||
Workstream — sonst sequenziell. Datenkorrekturen, die die Züchterin nennt, als
|
||||
Override in `conflict-decisions.json` kodieren (data-driven, re-ingest-stabil).
|
||||
Subagenten committen NICHT und re-ingesten NICHT — das koordiniert der Haupt-Loop.
|
||||
|
||||
### 5. Import neu einspielen (bei Daten-/Import-Änderungen)
|
||||
`python tools/import/extract.py` → `extract_contracts.py` → `merge_and_resolve.py`,
|
||||
dann `POST /import/ingest-resolved` (wischt + lädt neu). Danach betroffene Tickets
|
||||
gegen die API verifizieren.
|
||||
|
||||
### 6. Ticket schließen — MIT Changelog (durch den bearbeitenden Subagenten)
|
||||
Der Subagent/Cluster, der den Fix umgesetzt hat, schließt **seine** Tickets selbst
|
||||
ab — aber erst **nachdem der Fix verifiziert und (bei Daten-/Import-Fixes) live
|
||||
eingespielt ist** (Schritt 5), damit „erledigt" auch stimmt:
|
||||
`PUT /feedback/{id}` mit `{"status": "Resolved", "fixNote": "<verständliche
|
||||
Erklärung, was korrigiert wurde>"}`.
|
||||
**Die `fixNote` ist ein Changelog AUSSCHLIESSLICH für die Züchterin (Laiin) —
|
||||
kein technisches Geschwafel.** Harte Regel:
|
||||
- Schreib, **was sich für SIE sichtbar geändert hat**, in Alltagssprache; 1–3 Sätze.
|
||||
- KEINE Dateinamen, Funktionen, Feldnamen, IDs, Status-Codes, „Resolver/Migration/
|
||||
Genotyp-Parser/isResident/conflict-decisions" o. Ä. Nenne Tiere/Würfe bei ihrem
|
||||
**Namen**, nicht per ID.
|
||||
- Sag ggf., was sie tun soll (z. B. „Seite neu laden").
|
||||
|
||||
Gut: „Die Mutter von **Yuki** war falsch — sie ist jetzt **Izumi** (vorher stand
|
||||
da Benjiro). Bitte den Stammbaum neu laden."
|
||||
Gut: „**Hagrid** wird nicht mehr als dein eigenes Tier geführt, sondern nur noch
|
||||
als Vorfahre — seine Würfe und der Charakter-Bereich werden nicht mehr angezeigt."
|
||||
Schlecht (NICHT so): „assign_parent_roles korrigiert, motherId via
|
||||
conflict-decisions.json gesetzt, re-ingest." / „isResident=false gesetzt."
|
||||
|
||||
### Abschluss-Regel (wichtig)
|
||||
**Kein bearbeitetes Ticket bleibt offen liegen.** Jedes `Open`/`Answered`-Ticket
|
||||
endet nach einem Lauf in genau einem Endzustand:
|
||||
- **`NeedsInfo`** — Rückfrage wurde ordentlich gestellt (Info fehlt), oder
|
||||
- **`Resolved`** — Fix umgesetzt + verifiziert, mit `fixNote`-Changelog.
|
||||
Kann ein Ticket weder gefixt noch sinnvoll erfragt werden, stelle trotzdem eine
|
||||
klärende Rückfrage (NeedsInfo) statt es offen zu lassen.
|
||||
|
||||
## Mehrrundige Rückfragen & Kontext-Kontinuität
|
||||
|
||||
Der Dialog kann mehrere Runden haben: `Open → (Rückfrage) NeedsInfo → (Antwort)
|
||||
Answered → (KI prüft) ggf. neue Rückfrage NeedsInfo → Answered → … → Resolved`.
|
||||
Jede **weitere Antwort** der Züchterin bringt das Ticket wieder auf `Answered` und
|
||||
damit erneut in die Bearbeitung (Schritt 1 filtert `Answered` immer mit ein).
|
||||
|
||||
**Kontext-Träger ist das Ticket, nicht der Subagent.** Subagenten sind
|
||||
sitzungsgebunden — antwortet die Züchterin später (neue Session), existiert der
|
||||
ursprüngliche Agent nicht mehr. Daher MUSS der gesamte Verlauf am Ticket hängen
|
||||
(Frage/Antwort je Runde) + der Triage-Eintrag in `docs/ticket-triage.md`. Beim
|
||||
Wiederaufgreifen eines `Answered`-Tickets lädt der Subagent diesen vollständigen
|
||||
Verlauf + die frühere Triage und macht **mit demselben Kontext** weiter (statt bei
|
||||
null neu zu untersuchen) — praktisch „derselbe Vorgang, fortgesetzt".
|
||||
|
||||
**Innerhalb derselben Session** kann man stattdessen denselben Subagenten direkt
|
||||
fortsetzen (SendMessage an seine Agent-ID, Kontext bleibt erhalten) — nur dann ist
|
||||
echte Agent-Kontinuität möglich.
|
||||
|
||||
Das Datenmodell unterstützt Mehrrunden über einen **Verlauf/Thread** am Ticket
|
||||
(mehrere Frage/Antwort-Einträge mit Autor + Zeit), nicht nur ein einzelnes
|
||||
Frage/Antwort-Paar — so geht bei weiteren Antworten nichts verloren.
|
||||
|
||||
### Internes Agenten-Kontextfeld (für die Züchterin unsichtbar)
|
||||
|
||||
Das Ticket hat ein **internes Kontextfeld** (`agentContext`, NICHT in der Züchterin-
|
||||
UI sichtbar) — das **Arbeitsgedächtnis** des Agenten. Damit ersetzt das Ticket den
|
||||
sitzungsgebundenen Subagenten-Kontext:
|
||||
- **Schreiben:** Bei jeder Bearbeitung legt der Agent dort seinen Arbeitsstand ab
|
||||
(kompaktes JSON/Markdown): Befunde, vermutete Ursache, geprüfte Hypothesen,
|
||||
betroffene Dateien/Daten, der Plan, **worauf gewartet wird** (welche Info die
|
||||
Rückfrage klären soll) und schon erledigte Teilschritte.
|
||||
- **Lesen:** Beim Wiederaufgreifen eines `Answered`-Tickets liest der Agent zuerst
|
||||
`agentContext` (+ Verlauf + Triage-Eintrag) und setzt **dort an, wo er aufgehört
|
||||
hat** — keine Neu-Untersuchung von null.
|
||||
- **Trennung:** `agentContext` ist intern/technisch (darf IDs/Dateinamen/Hypothesen
|
||||
enthalten); die **sichtbaren** Felder bleiben laienverständlich (Frage, Antwort,
|
||||
`fixNote`). `agentContext` wird in der Tickets-Seite NICHT angezeigt.
|
||||
- `PUT /feedback/{id}` mit `{"agentContext": "<arbeitsstand>"}` aktualisiert es,
|
||||
ohne den Status zu ändern.
|
||||
|
||||
## Verifikation vor „fertig"
|
||||
- Code-Fixes: `npx tsc --noEmit`, `npx vitest run`, betroffene `npx playwright test`,
|
||||
`dotnet build`/`dotnet test`, Python-`test_*.py` — grün.
|
||||
- Daten-Fixes: in `resolved_import.json` stichprobenartig prüfen, dann re-ingest +
|
||||
API-Check des konkreten Tiers/Wurfs.
|
||||
- Jedes erledigte Ticket auf `Resolved` mit `fixNote` gesetzt.
|
||||
|
||||
## Hinweise
|
||||
- Pro Ticket genau ein Triage-Agent; Umsetzung gebündelt nach Cluster.
|
||||
- Encoding: deutsche Texte als UTF-8 (Datei, nicht stdin-Pipe).
|
||||
- Nichts committen, bis der Haupt-Loop verifiziert hat; auf `main` arbeiten.
|
||||
188
.gitea/workflows/ci.yml
Normal file
188
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,188 @@
|
||||
# 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
|
||||
# Manueller „Run workflow"-Button in Gitea: testet, baut, pusht und deployt frisch.
|
||||
# Fuer einen Rollback auf ein altes Image stattdessen auf der NAS:
|
||||
# TAG=<git-sha> sh /opt/gerbilmanager/deploy/truenas/scripts/deploy.sh
|
||||
workflow_dispatch:
|
||||
|
||||
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' || github.event_name == 'workflow_dispatch')
|
||||
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 }}
|
||||
|
||||
deploy:
|
||||
name: Deploy auf TrueNAS (Custom App)
|
||||
# Laeuft auf dem vorhandenen containerisierten Gitea-Runner (Label ubuntu-latest).
|
||||
# Der Runner hat KEINEN Host-Dateisystem-/midclt-Zugriff, daher: per SSH zum NAS-Host
|
||||
# verbinden, die Deploy-Dateien in das App-Home auf dem Pool kopieren und dort
|
||||
# truenas-deploy.sh ausfuehren (legt die TrueNAS "Custom App" an bzw. rollt sie neu
|
||||
# aus via `midclt` — die App bleibt unter Apps sichtbar).
|
||||
# Hinweis: / (inkl. /opt) ist auf TrueNAS Goldeye read-only -> App-Home auf dem Pool.
|
||||
#
|
||||
# Benoetigte Repo-Secrets (Gitea -> Einstellungen -> Actions -> Secrets):
|
||||
# NAS_SSH_HOST z. B. 192.168.2.115 (Host-LAN-IP der NAS)
|
||||
# NAS_SSH_KEY privater SSH-Deploy-Key (Pubkey liegt in /root/.ssh/authorized_keys)
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-and-push]
|
||||
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
||||
env:
|
||||
APP_HOME: /mnt/JailStorage/DockerVolumes/gerbilmanager
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: SSH-Client + Key einrichten
|
||||
run: |
|
||||
# Nur openssh-client wird gebraucht (Dateitransfer laeuft unten per "tar | ssh").
|
||||
# FRUEHER stand hier zusaetzlich rsync im apt-Aufruf, der aber nie lief: der Guard
|
||||
# prueft nur `ssh`, und ssh ist im Runner-Image vorhanden => "rsync: command not
|
||||
# found" kippte den Deploy-Job (Run 107). Deshalb kein rsync mehr.
|
||||
command -v ssh >/dev/null 2>&1 || (apt-get update && apt-get install -y --no-install-recommends openssh-client)
|
||||
install -m 700 -d ~/.ssh
|
||||
printf '%s\n' "${{ secrets.NAS_SSH_KEY }}" > ~/.ssh/id_deploy
|
||||
chmod 600 ~/.ssh/id_deploy
|
||||
ssh-keyscan -H "${{ secrets.NAS_SSH_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
|
||||
- name: Deploy-Dateien auf den NAS-Host kopieren
|
||||
run: |
|
||||
NAS="root@${{ secrets.NAS_SSH_HOST }}"
|
||||
SSH="ssh -i ~/.ssh/id_deploy -o StrictHostKeyChecking=accept-new"
|
||||
$SSH "$NAS" "mkdir -p $APP_HOME/deploy/truenas/scripts"
|
||||
# tar-over-ssh statt rsync: rsync fehlt im Runner-Image, tar gibt es auf beiden
|
||||
# Seiten. -o beim Entpacken => Dateien gehoeren root; Modi bleiben erhalten,
|
||||
# die Deploy-Skripte bleiben also ausfuehrbar.
|
||||
tar -C deploy/truenas -cf - custom-app.compose.yaml scripts \
|
||||
| $SSH "$NAS" "tar -C $APP_HOME/deploy/truenas -xof -"
|
||||
|
||||
- name: Custom App neu ausrollen (midclt create/redeploy + Health-Check)
|
||||
run: |
|
||||
NAS="root@${{ secrets.NAS_SSH_HOST }}"
|
||||
ssh -i ~/.ssh/id_deploy -o StrictHostKeyChecking=accept-new "$NAS" \
|
||||
"sh $APP_HOME/deploy/truenas/scripts/truenas-deploy.sh"
|
||||
34
.gitignore
vendored
34
.gitignore
vendored
@@ -127,3 +127,37 @@ $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/
|
||||
|
||||
# Runtime: gespeicherte Vertrags-Dateien
|
||||
GerbilManagerWebAPI/contract-storage/
|
||||
|
||||
# Agent-Worktrees (lokal, nicht versionieren)
|
||||
.claude/worktrees/
|
||||
|
||||
# Lokaler Publish-Output der öffentlichen Seite (Aspire dev)
|
||||
deploy/.local-publicsite/
|
||||
|
||||
# RennmausPro III SQLite-Quelle (lokal, nicht committen)
|
||||
tools/import/_rpro3.db
|
||||
tools/import/_rpro3_extract/
|
||||
tools/import/_rpro3_backup.zip
|
||||
|
||||
# Lokale Scratch-/Werkzeug-Verzeichnisse (nicht versionieren)
|
||||
.agents/
|
||||
tools/rag/
|
||||
|
||||
# Produktions-Secrets (nur auf dem NAS, nie im Repo)
|
||||
deploy/truenas/.env
|
||||
|
||||
8
.mcp.json
Normal file
8
.mcp.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mempalace": {
|
||||
"command": "mempalace-mcp",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
}
|
||||
34
.vscode/launch.json
vendored
34
.vscode/launch.json
vendored
@@ -1,42 +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
|
||||
// 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": ".NET Core Launch (web)",
|
||||
"name": "GerbilManager (Aspire — alles starten)",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build",
|
||||
"program": "${workspaceFolder}/GerbilManagerWebAPI/bin/Debug/net7.0/GerbilManagerWebAPI.dll",
|
||||
"program": "${workspaceFolder}/GerbilManager.AppHost/bin/Debug/net10.0/GerbilManager.AppHost.dll",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}/GerbilManagerWebAPI",
|
||||
"cwd": "${workspaceFolder}/GerbilManager.AppHost",
|
||||
"stopAtEntry": false,
|
||||
"env": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
},
|
||||
"serverReadyAction": {
|
||||
"action": "openExternally",
|
||||
"pattern": "\\bNow listening on:\\s+(https?://\\S+)"
|
||||
},
|
||||
"env": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"sourceFileMap": {
|
||||
"/Views": "${workspaceFolder}/GerbilManagerWebAPI/Views"
|
||||
"pattern": "Login to the dashboard at\\s+(https?://\\S+)",
|
||||
"uriFormat": "%s"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": ".NET Core Attach",
|
||||
"type": "coreclr",
|
||||
"request": "attach"
|
||||
},
|
||||
{
|
||||
"name": "Docker .NET Launch",
|
||||
"type": "docker",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "docker-run: debug",
|
||||
"netCore": {
|
||||
"appProject": "${workspaceFolder}/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
6
.vscode/tasks.json
vendored
6
.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,7 +33,7 @@
|
||||
"watch",
|
||||
"run",
|
||||
"--project",
|
||||
"${workspaceFolder}/GerbilManager.sln"
|
||||
"${workspaceFolder}/GerbilManager.slnx"
|
||||
],
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
|
||||
434
CLAUDE.md
Normal file
434
CLAUDE.md
Normal file
@@ -0,0 +1,434 @@
|
||||
# CLAUDE.md — GerbilManager
|
||||
|
||||
Leitfaden für die Arbeit an diesem Repo. Bitte vor Änderungen lesen.
|
||||
|
||||
## Was ist das?
|
||||
|
||||
Verwaltungs-App für eine **Rennmaus-Zucht** („Zucht der kleinen Chaoten"): Tiere,
|
||||
Würfe, Stammbäume/Ahnentafeln, Genetik (Farbschläge/Genotypen), Gehege, Kontakte
|
||||
(Züchter/Abnehmer), Abgabeverträge, Statistik und eine öffentliche CMS-Webseite.
|
||||
Single-User (die Züchterin), Daten größtenteils aus bestehenden Quelldateien
|
||||
importiert.
|
||||
|
||||
## Architektur / Projekte
|
||||
|
||||
| Projekt | Zweck |
|
||||
|---|---|
|
||||
| `GerbilManager.AppHost` | **.NET Aspire** Orchestrator — startet PostgreSQL (Container) + WebAPI |
|
||||
| `GerbilManagerWebAPI` | Backend: ASP.NET **Minimal API**, EF Core + Postgres, Endpoints in `Endpoints/`, Entities in `Models/`, DTOs in `Dtos/` |
|
||||
| `GerbilManager.ServiceDefaults` | Aspire-Defaults (Telemetry/Health) |
|
||||
| `GerbilManager.Tests` | Backend-Tests (xUnit) |
|
||||
| `gerbil-manager-web` | Frontend: **React + Vite + TypeScript** (React Router, react-d3-tree für den Stammbaum) |
|
||||
| `tools/import` | **Python**-Import-Pipeline (zero-dep), wandelt Quelldateien → `resolved_import.json` |
|
||||
|
||||
## App starten (IMMER über Aspire, nicht nur die WebAPI)
|
||||
|
||||
```bash
|
||||
dotnet run --project GerbilManager.AppHost
|
||||
```
|
||||
- WebAPI: http://localhost:5179 · OpenAPI: `/openapi/v1.json`
|
||||
- Aspire-Dashboard: https://localhost:17004 (Login-Token steht im AppHost-Log)
|
||||
- EF-Migrationen werden beim Start automatisch angewandt (`db.Database.Migrate()` in `Program.cs`).
|
||||
- Postgres läuft als Container (Name wechselt pro Run, z. B. `postgres-xxxx`); die
|
||||
**Daten liegen in einem persistenten Volume** → überleben Neustarts. DB-Name: `gerbilmanager`.
|
||||
|
||||
## Produktion / Deployment (TrueNAS)
|
||||
|
||||
Prod läuft als **TrueNAS SCALE Custom-App** `gerbilmanager` auf dem Host **`truenas`**
|
||||
(LAN-IP `192.168.2.115`, TrueNAS 25.10 „Goldeye", Docker). Erstinstallation 2026-07-19.
|
||||
|
||||
- **Zugriff (Bookmark der Züchterin):** **http://truenas:8090/** · API-Doku: `/scalar`.
|
||||
(Port 80 ist durch den nginx-Reverse-Proxy der NAS belegt → App auf **8090**, LAN-only, keine Auth.)
|
||||
- **Sichtbar unter Apps**, weil via `midclt call app.create {custom_app:true, app_name:"gerbilmanager",
|
||||
custom_compose_config_string:<yaml>}` angelegt — **nicht** via `docker compose` (das taucht in Apps
|
||||
NICHT auf). Container: `ix-gerbilmanager-{db,api,frontend,backup}-1`.
|
||||
- **5 Dienste:** `db` (postgres:18) · `api` (.NET, EF-Migrationen laufen beim Start) · `frontend`
|
||||
(nginx: SPA + `/api`-Proxy → `api:8080`, Host-Port 8090) · `publicsite` (nginx, **nur intern**,
|
||||
Host-Port **8081** — serviert statisches HTML aus dem `publicsite`-Volume, das die API via
|
||||
`POST /api/publish` schreibt; ohne Publish leer → 404) · `backup` (täglich 03:00 pg_dump +
|
||||
Foto-Archiv, Shell-Scheduler).
|
||||
- **App-Home auf dem Pool:** `/mnt/JailStorage/DockerVolumes/gerbilmanager/` mit
|
||||
`pgdata/ photos/ keys/ backups/ scripts/ publicsite/` und `deploy/truenas/{custom-app.compose.yaml,.env,scripts}`.
|
||||
Images aus der Gitea-Registry `git.rismer.de/gulum/gerbilmanager-{api,frontend}:latest`.
|
||||
|
||||
### Deployen
|
||||
|
||||
**Automatisch (Regelfall):** Push auf `main` → Gitea Actions (`.gitea/workflows/ci.yml`): Tests →
|
||||
Images bauen+pushen → Job `deploy` verbindet sich per SSH auf den NAS-Host und ruft
|
||||
`deploy/truenas/scripts/truenas-deploy.sh` (→ `midclt app.redeploy`, zieht `:latest` neu, `pgdata`/
|
||||
`photos` bleiben erhalten). Braucht die Gitea-Repo-Secrets **`NAS_SSH_HOST`** (=192.168.2.115) und
|
||||
**`NAS_SSH_KEY`** (privater Deploy-Key; Pubkey liegt in `/root/.ssh/authorized_keys`).
|
||||
|
||||
**Manuell (auf dem NAS-Host):**
|
||||
```bash
|
||||
sh /mnt/JailStorage/DockerVolumes/gerbilmanager/deploy/truenas/scripts/truenas-deploy.sh
|
||||
```
|
||||
Legt die App an, wenn sie fehlt (create), sonst redeploy. Rendert `custom-app.compose.yaml` mit den
|
||||
Werten aus `deploy/truenas/.env` (enthält `POSTGRES_PASSWORD` — **nur auf der NAS, nie im Repo**).
|
||||
|
||||
**Lokale DB → Prod kopieren** (einmalig gemacht): `pg_dump` der Aspire-DB → `psql`-Restore in den
|
||||
`ix-gerbilmanager-db-1`-Container (dabei `api` kurz stoppen); Fotos aus `GerbilManagerWebAPI/photo-storage/`
|
||||
per `tar | ssh` nach `…/gerbilmanager/photos/`.
|
||||
|
||||
### Deployment-Stolperfallen
|
||||
- **`/opt` ist auf Goldeye read-only** → App-Home MUSS auf den Pool (`/mnt/JailStorage/...`).
|
||||
- **`postgres:18`** legt PGDATA in `/var/lib/postgresql/18/docker` ab und deklariert das Volume als
|
||||
`/var/lib/postgresql`. Mount auf `/var/lib/postgresql/data` (alte v≤17-Konvention) lässt v18 **nicht
|
||||
starten** ("data in unused mount/volume") → db unhealthy → App-Rollback. Immer `/var/lib/postgresql`
|
||||
mounten. (Debian-Image, nicht alpine → Locale `libc/en_US.utf8` = Quell-DB.)
|
||||
- Gitea-Runner ist **containerisiert** (Label `ubuntu-latest`, kein `goldeye`, kein Host-/`midclt`-Zugriff)
|
||||
und läuft **`maxParallel=1`** (Jobs sequenziell) — daher der SSH-Weg im Deploy-Job.
|
||||
- **Auto-Deploy lief bis 2026-08-19 nie durch** (Runs 99–107 rot), zwei Ursachen: (1) die Repo-Secrets
|
||||
`NAS_SSH_HOST`/`NAS_SSH_KEY` waren nie angelegt → `ssh root@` mit leerem Key („Could not resolve
|
||||
hostname"); jetzt gesetzt, Deploy-Key `gitea-ci-deploy@gerbilmanager` (privat nur im Gitea-Secret,
|
||||
Pubkey in `/root/.ssh/authorized_keys`). (2) Der Runner hat **kein `rsync`**, und der apt-Guard prüfte
|
||||
nur `ssh` → „rsync: command not found". Dateitransfer läuft daher per **`tar | ssh`** (tar gibt es auf
|
||||
beiden Seiten; `-xof` erhält die Modi, Skripte bleiben ausführbar).
|
||||
- CI-Run-Status notfalls aus der Gitea-DB: `docker exec -e PGPASSWORD=gitea ix-gitea-postgresdb-1
|
||||
psql -U gitea -d gitea -c "select name,status from action_run_job where run_id=(select max(id) from action_run);"`
|
||||
(`status`: 1=success, 2=failure, 4=skipped).
|
||||
|
||||
## Import → DB (3-stufig, WICHTIG)
|
||||
|
||||
Die DB wird **nicht** direkt von Python beschrieben. Nach jeder Import-Code-Änderung
|
||||
neu generieren UND neu einspielen, sonst zeigt die App den alten Stand.
|
||||
|
||||
> **Arbeitsweise (verbindlich, Owner-Regel — aktualisiert 2026-07-12):** Für **variable**
|
||||
> (von der Züchterin **nicht** angefasste) Import-Tiere gilt weiter: Datenfehler IMMER **im
|
||||
> Importierer** beheben (Pipeline-Code + `conflict-decisions.json` / `rpro3-decisions.json`)
|
||||
> und **neu importieren** — für diese Tiere **nicht** die Live-DB patchen. Jeder Importer-Fix
|
||||
> muss **re-ingest-stabil** sein (data-driven, deterministische GUIDs).
|
||||
>
|
||||
> **Neu:** Das Ziel ist **nicht** mehr „Importer perfekt, dann einmal final", sondern
|
||||
> **Tier für Tier konvergieren**. Die Züchterin **darf** über die UI kuratieren, und das
|
||||
> **überlebt den Ingest** (der wischt **nicht** mehr alles): (a) **manuell angelegte Tiere/
|
||||
> Würfe/Kontakte** (`IsManual=true`) und (b) **Overrides** (`GerbilOverride`) für als
|
||||
> **„vollständig korrekt"** markierte bzw. **von Hand bearbeitete** Tiere. Deren eigene Felder
|
||||
> werden nach jedem Ingest **eingefroren** (Freeze). Siehe „Geprüfte Tiere / Freeze" unten.
|
||||
> Zusätzlich zu diesen Overrides überleben weiterhin **Tickets/Feedback**.
|
||||
|
||||
```bash
|
||||
# 1. Stammbäume + Wurfchronik → animals.json/litters.json (+ Fotos)
|
||||
python tools/import/extract.py
|
||||
# 2. Abgabeverträge (DOCX) → contracts.json
|
||||
python tools/import/extract_contracts.py
|
||||
# 3. Dedup, Eltern-Resolver, Provenance, Vertrags-Anreicherung → resolved_import.json
|
||||
python tools/import/merge_and_resolve.py
|
||||
# 4. In die laufende DB laden (API muss laufen): UPSERT + Freeze
|
||||
curl -X POST http://localhost:5179/import/ingest-resolved
|
||||
# 4b. Gegen PROD (ohne SSH): resolved_import.json per HTTP hochladen + ingesten
|
||||
curl -X POST http://truenas:8090/api/import/ingest-resolved/upload \
|
||||
-F "resolved=@tools/import/output/resolved_import.json" # [-F "photos=@neue_fotos.zip"] nur bei neuen Fotos
|
||||
```
|
||||
- **`POST /import/ingest-resolved/upload`** (multipart `resolved` + optional `photos`-ZIP): staged die
|
||||
hochgeladene Datei serverseitig in `Import:SourcePath` und ruft denselben UPSERT-Ingest — damit läuft
|
||||
der Import **gegen Prod ohne SSH/`docker cp`**. Der alte `POST /import/ingest-resolved` (liest die
|
||||
serverseitige Datei) bleibt unverändert. **DB-Copy vom lokalen Stand ist verboten**, sobald Prod
|
||||
kuratiert wurde — nur der Upsert-Ingest bewahrt manuelle/verified/feedback-Zeilen.
|
||||
|
||||
- **`IngestResolvedService` (seit 2026-07-12: UPSERT statt Total-Wipe).** Importierte Zeilen
|
||||
(Gerbils/Litters/Contacts) werden **per deterministischer Id gematcht und in place
|
||||
aktualisiert**; **stale** importierte Zeilen (nicht mehr im Payload) werden entfernt (dangling
|
||||
Referenzen manueller Zeilen werden vorher genullt + im Rückgabestring gewarnt). **Nie gelöscht/
|
||||
überschrieben:** Zeilen mit `IsManual=true` sowie **user-eingegebene Sub-Records**
|
||||
(Weights/Health) und Fotos manueller Tiere. **SaleContracts** werden weiter aus dem Payload
|
||||
neu erzeugt (manuelle Verträge sind noch **nicht** geschützt — Folgeaufgabe). Am Ende werden
|
||||
alle **`GerbilOverride`** angewandt (**Freeze** der eigenen Felder) und für verifizierte Tiere
|
||||
der Rohimport-Stand vorher als Drift-Snapshot erfasst.
|
||||
- **Feedback + GerbilOverride überleben den Ingest** (werden nicht gewischt) — daher haben sie nur
|
||||
**lose, nullable Guid-Spalten ohne FK** (`Feedback`: GerbilId/LitterId/ContactId; `GerbilOverride`:
|
||||
GerbilId). Dieses Muster für alles nutzen, was Re-Ingests überleben soll.
|
||||
- `resolved_import.json` ist **gitignored** (Runtime-Output); `review-report.md` ist getrackt.
|
||||
- Deterministische GUIDs via `generate_guid("...")` → stabile IDs über Re-Ingests.
|
||||
|
||||
### Quelldateien (lokal/Netzwerk)
|
||||
- Stammbäume: `C:\Users\gulum\dev\Sttammbäume\*.xlsx`
|
||||
- Wurfchronik: `C:\Users\gulum\dev\Wurfchronik der Kleine Chaoten Teil1.xlsx`
|
||||
- Abgabeverträge: `\\truenas\Datengrab\Rennmäuse\Verträge\*.docx` (~1,4k; kann zeitweise offline sein)
|
||||
|
||||
## Build & Test
|
||||
|
||||
**Frontend** (`cd gerbil-manager-web`):
|
||||
```bash
|
||||
npx tsc --noEmit # Typecheck (muss sauber sein)
|
||||
npx vitest run # Unit-Tests
|
||||
npx eslint <dateien> # Lint
|
||||
npx playwright test [spec]# e2e (Projekte: desktop + phone), läuft gegen Mock-API
|
||||
```
|
||||
- e2e nutzt einen **Mock-Modus** (`e2e/mock-api.ts` + `e2e/mock-data.ts`), kein echtes Backend.
|
||||
Neue API-Routen/Felder dort spiegeln. `skipUnlessMock()` am Testanfang.
|
||||
|
||||
**Backend**:
|
||||
```bash
|
||||
dotnet build
|
||||
dotnet test # GerbilManager.Tests
|
||||
dotnet ef migrations add <Name> --project GerbilManagerWebAPI # neue Migration
|
||||
```
|
||||
- Zum Bauen muss eine laufende WebAPI ggf. gestoppt werden (Datei-Lock auf `bin`).
|
||||
|
||||
**Python** (`cd tools/import`, zero-dep, exit 0 = pass):
|
||||
```bash
|
||||
python test_extract.py test_extract_docx.py test_genotype.py test_merge_resolve.py test_extract_contracts.py
|
||||
```
|
||||
|
||||
## Konventionen (bitte einhalten)
|
||||
|
||||
- **Alle deutschen UI-Texte in `gerbil-manager-web/src/strings/de.ts`** — niemals in
|
||||
Komponenten hartkodieren.
|
||||
- **EF-Migration** für jede Schema-Änderung; wird beim Aspire-Start angewandt.
|
||||
- **Provenance/Datenherkunft** liegt als **JSON-Text-Spalte** (`Provenance`) auf
|
||||
Gerbil/Contact/Litter → JSON-Feld-Erweiterungen brauchen **keine** Migration.
|
||||
- **Lose nullable Guid-Spalten ohne FK** für Tabellen, die den Ingest-Wipe überleben.
|
||||
- Commits: nur wenn der Nutzer es verlangt; auf `main` wird gearbeitet (Remote: `http://truenas:13000/Gulum/GerbilManager.git`). Commit-Messages auf Deutsch, Format `feat(scope): …`.
|
||||
- Subagenten committen NICHT — Haupt-Loop verifiziert und committet.
|
||||
|
||||
## Domänenwissen (Rennmaus-Zucht)
|
||||
|
||||
- **Lebensspanne ~6 Jahre** → ein Elternteil kann max. ~6 Jahre älter als sein Kind
|
||||
sein und muss vorher geboren sein (`parent_age_plausible` im Import; verwirft
|
||||
unplausible Eltern-Links).
|
||||
- **Geschwisterverpaarung** (Vollgeschwister als Eltern): erkannt, wenn Vater & Mutter
|
||||
**dieselbe `litterId`** ODER **dasselbe Geburtsdatum** teilen (gleiches DOB = starkes
|
||||
Indiz). UI: Diagramm führt den Vorfahren-Ast zusammen (Verweis-Knoten); Akte zeigt
|
||||
„⚭ Geschwisterverpaarung"-Chip.
|
||||
- **Box-Farbe im Stammbaum-xlsx = Geschlecht**: weiß = weiblich, blau = männlich.
|
||||
- **Bestandstier (`isResident`) vs. reiner Vorfahre** (Regel der Züchterin, Ticket 09bcac78):
|
||||
Ein Tier ist **ihr Zuchttier** ⇔ es hatte bei ihr Nachzucht mit Herkunft **„von den
|
||||
Kleinen Chaoten"** (nur dann konnten die Jungtiere ihren Zuchtnamen tragen). Trägt die
|
||||
Nachzucht einen **fremden** Herkunftsnamen (z. B. „of Black Forest", „of Topolino"), ist
|
||||
das Tier nur **Vorfahre** (`isResident=false`, keine Würfe/Charakter-Bereich). **Ausnahme:
|
||||
zugekaufte Zuchttiere** (fremder Herkunftsname, aber bei ihr gezüchtet → Nachzucht „von den
|
||||
Kleinen Chaoten") bleiben **Bestandstiere** (z. B. Echo/Elia of Black Forest).
|
||||
**Sweep umgesetzt (2026-06-27, Ticket 381f7e51):** Der Wurfchronik-Import-Default ist
|
||||
**nicht mehr `is_resident=True`**. Neue Regel in `merge_and_resolve.py` (später Sweep
|
||||
nach Eltern-/`addLitters`-Verknüpfung): `isResident=true` **genau dann**, wenn (1) ein
|
||||
expliziter `conflict-decisions.json`-`isResident`-Override greift (gewinnt immer — `true`
|
||||
wie Echo/Elia, `false` wie Akane/Naémi/Bentley), **oder** (2) das Tier **Elternteil eines
|
||||
eigenen Wurfs** ist (nicht-virtuell ODER aus Wurfchronik ODER Clan-Nachzucht ODER mit
|
||||
residentem Elternteil ODER `addLitter` mit `ShowInChronicle`). Reine virtuelle
|
||||
Ahnen-Verpaarungen machen **nicht** resident (z. B. Antares bleibt `false`). Sonst
|
||||
`false`. `ShowInChronicle` wird **nach** dem Sweep berechnet (nutzt die finale Residenz).
|
||||
Effekt: resident **1711 → 442**. UI-Label (`de.ts` `isResidentLabel`) = **„Zuchtanwärter/
|
||||
innen"** — die Züchterin markiert künftige Zuchttiere selbst. **Offene Folgeaufgabe:**
|
||||
manuelle UI-Residenz-Markierungen überleben den Re-Ingest noch **nicht** (bräuchten eine
|
||||
ingest-stabile, FK-freie Override-Ablage analog `Feedback`).
|
||||
- **Genotyp/Farbschlag**: 8-Locus-Notation (siehe `tools/import/genotype.py`,
|
||||
`gerbil-manager-web/src/genetics`). Unbekanntes Allel = `-` (nicht `?`).
|
||||
E-Locus: `ee`=Fuchs, `eef`=Fuchsschimmel, `efef`=Schimmel. Rezessiver Fuchs ist
|
||||
zwingend homozygot → `e-` ist ungültig (wirft), `ee[-]`→`ee`.
|
||||
- **C-Locus-Zygotie (GEN-6, Tickets bde4ec70/f89e95ad — Regel der Züchterin):** Platin,
|
||||
Saphir und Platin-Hell unterscheiden sich **nur** in der C-Zygotie: `aa CC …pp` = **Platin**,
|
||||
`aa Cc[chm] …pp` = **Saphir**, `aa Cc[h] …pp` = **Platin-Hell**. Der Katalog-Match kann
|
||||
deshalb pro Locus ein **exaktes, ungeordnetes Allelpaar** verlangen — Token-Form `"x/y"`
|
||||
(z. B. `C: 'C/cchm'`), sonst reduziert `locusToken()` auf das dominante Allel und der erste
|
||||
Treffer (Platin) verschluckt die anderen. Gilt in **beiden** Mirrors (`catalog.ts` +
|
||||
`genotype.py`) und im Seed (`colorVarietySeed.*`, `ApplicationContext.SeedColorVarieties`,
|
||||
dort in Kompaktnotation `Ccchm`/`Cch` ohne Klammern).
|
||||
- **`c[hm]` ist ein Schreibfehler-Alias für `c[chm]`** (fehlendes c in einigen Stammbaum-Charts):
|
||||
beide Parser normalisieren ihn, sonst ist der Gencode unparsebar bzw. ergibt „…-Hell".
|
||||
- **Genetik-Engine = korrektheitskritisch: IMMER vollständig mit Tests absichern.**
|
||||
Jede Änderung an `src/genetics/**` (+ Backend-Mirror + `genotype.py`) braucht pro
|
||||
Use-Case/Ticket einen Regressionstest (`src/genetics/__tests__/genetics.test.ts`
|
||||
und `tools/import/test_genotype.py`), damit nie eine Regression entsteht. `npx vitest
|
||||
run` + `python test_genotype.py` müssen grün sein, bevor „fertig".
|
||||
- **Stammbaum-Charts**: Generationen = Spaltenbänder (Proband links in Spalte B/2,
|
||||
je Generation +3 Spalten); Eltern-Position: Vater oben, Mutter unten. Fotos liegen
|
||||
je nach Datei links/auf der Namenszelle — die Seite wird heuristisch über die
|
||||
geringste Spalten-Fehlausrichtung bestimmt (`extract._attach_photos`).
|
||||
|
||||
## Stolperfallen
|
||||
|
||||
- **Aspire-Containername wechselt** pro Run, das Daten-Volume bleibt → DB-Inhalt
|
||||
überlebt Neustarts (auch alte Schema-Stände → Migrationskonflikte möglich, s. u.).
|
||||
- **Migrations-Historie vs. committete Migration-IDs**: Wird eine Migration angewandt
|
||||
und danach mit neuem Zeitstempel neu generiert, sieht EF sie als „pending" und der
|
||||
Start crasht („column … already exists"). Schema ist dann korrekt — nur die
|
||||
`__EFMigrationsHistory`-IDs müssen an die committeten Dateinamen angeglichen werden.
|
||||
- **PowerShell + psql**: PascalCase-Identifier (`"__EFMigrationsHistory"`) über
|
||||
PowerShell→docker→psql zerschießen das Quoting. Stattdessen Bash-Heredoc:
|
||||
`docker exec -i -e PGPASSWORD=$PW <container> psql -U postgres -d gerbilmanager <<'SQL' … SQL`.
|
||||
Passwort: `docker exec <container> printenv POSTGRES_PASSWORD`.
|
||||
- **Direkte DB-Schreibzugriffe** (psql) werden vom Auto-Mode-Classifier blockiert —
|
||||
vorher beim Nutzer rückversichern.
|
||||
- Bash-Tool ist **Git-Bash (POSIX)**, das andere Tool ist **PowerShell** — je eigene Syntax.
|
||||
|
||||
## Wo liegt was (Feature-Landkarte)
|
||||
|
||||
- **Stammbaum-Viewer**: `gerbil-manager-web/src/pages/StammbaumPage.tsx`,
|
||||
`src/pedigree/*` (build.ts = Baum-Aufbau + Geschwister-Erkennung; types.ts).
|
||||
Desktop passt den ganzen Baum ein; Touch: Long-Press öffnet das Kontextmenü
|
||||
(ID kopieren / Fehler melden).
|
||||
- **Datenherkunft/Nachverfolgung**: `src/components/ProvenanceDialog.tsx`; erzeugt im
|
||||
Import via `build_entity_provenance`/`_build_*_history` (chronologische, datei-
|
||||
attribuierte `history[]`, inkl. „⚠ … verworfen — Grund; … verwendet").
|
||||
- **Fehler melden (Feedback)**: `src/components/ReportErrorDialog.tsx`,
|
||||
`src/api/feedback.ts`, Backend `Endpoints/FeedbackEndpoints.cs` + `Models/Feedback.cs`.
|
||||
- **Geprüfte Tiere / „vollständig korrekt"-Freeze**: Backend `Models/GerbilOverride.cs` (FK-frei,
|
||||
ingest-überlebend), `Import/GerbilSnapshotService.cs` (Akten-Snapshot + Freeze-Map + Diff, von App
|
||||
**und** Test geteilt), `Endpoints/VerifiedGerbilEndpoints.cs` (`/verified-gerbils`: POST markieren,
|
||||
DELETE entfernen/Toggle, GET Liste+Drift, GET `/export` = Golden-Fixture). Freeze wird in
|
||||
`IngestResolvedService` angewandt; `GerbilEndpoints` setzt `IsManual=true` bei POST und legt bei
|
||||
Edit eines importierten Tiers einen per-field-Override an (verifiziert → Golden auto-aktualisiert).
|
||||
Frontend: `src/api/verified.ts`, Badge/Toggle/Drift-Karte in `pages/GerbilDetailPage.tsx`,
|
||||
Übersicht `pages/VerifizierteTierePage.tsx` (`/hilfe/verifizierte-tiere`); Texte in `strings/de.ts`
|
||||
(`verified`-Block). **Modell:** `OverrideJson` = Feld→Wert-Map; `IsVerified=true` = zertifiziert +
|
||||
voller Freeze + Regression/Export, `false` = „geschützt" (nur geänderte Felder). Status/Gehege und
|
||||
Abstammung/Nachzucht werden **nicht** eingefroren (nur erkannt/gemeldet). Regressionstest:
|
||||
`GerbilManager.Tests/VerifiedGoldenTests.cs` gegen `tools/import/verified-golden.json` (via
|
||||
`GET /verified-gerbils/export` erzeugen + committen; skippt, wenn Fixture/`resolved_import.json` fehlt).
|
||||
**Scope-Grenze:** Freeze/Override gelten für **Gerbils**; Edits an importierten Würfen/Kontakten und
|
||||
manuelle SaleContracts sind noch **nicht** geschützt (Folgeaufgaben).
|
||||
- **Verträge**: `Models/SaleContract.cs`, `Endpoints/ContractEndpoints.cs`,
|
||||
`ContractGenerator.cs`, Frontend `src/pages/Vertraege*`; Import: `extract_contracts.py`
|
||||
+ `enrich_from_contracts` in `merge_and_resolve.py`.
|
||||
- **Import-Kernlogik**: `tools/import/merge_and_resolve.py` (Dedup, Eltern-Resolver mit
|
||||
Gender-/Alters-Plausibilität, Litter-/Sibling-Dedup, Provenance, Vertrags-Anreicherung).
|
||||
- **RennmausPro-III-Import (rpro3)**: Backend-Importer `GerbilManagerWebAPI/Import/Rpro3/*`
|
||||
(`Rpro3ImportService` Analyze/Execute, `Rpro3Dedup` = automatischer Dubletten-Merge, Port von
|
||||
`tools/import/compare_rpro3.py`). **Manuelle Dubletten-Entscheidungen der Züchterin** (aus den
|
||||
„rpro3-import"-Tickets) liegen in `GerbilManagerWebAPI/Import/Rpro3/rpro3-decisions.json` und
|
||||
werden von `Rpro3Dedup.Run(animals, decisions)` honoriert: `same` = Force-Merge (eine rid zieht
|
||||
den Auto-Cluster mit), `different` = Force-Split, `fields` = Feld-Overrides (Farbe/DOB/Herkunft/
|
||||
`genotype` (roher Fcode)/`resident`/Notiz) am Cluster. Der `genotype`-Override ist nötig, wenn
|
||||
Varianten sich im Gencode unterscheiden (z. B. C-Locus c[h] vs. c[chm]) und der Auto-Merge sonst
|
||||
den falschen Gencode (erstes Cluster-Mitglied) zöge — die Züchterin gibt den korrekten vor
|
||||
(z. B. MilkyWay → `aa Cc[h] DD ee Gg P- spsp`). **Schlüssel ist die RPRO3-Nummer (rid), NIE der Variantenbuchstabe**
|
||||
(Buchstaben verschieben sich bei geänderter Clusterbildung) — rids aus dem Ticket-`agentContext`.
|
||||
Triage-Helfer `tools/import/rpro3_lookup.py` (Name- oder `--rids`-Modus) zieht Farbe/Gencode/
|
||||
Eltern/Nachzucht+Partner aus `_rpro3.db` für die Rückfragen an die Züchterin.
|
||||
- **Eltern an der Tier-Akte bearbeiten (QOL)**: Im Tier-Formular gibt es den Block
|
||||
**„Abstammung"** (`gerbil-manager-web/src/pages/GerbilFormPage.tsx`) mit Wurf-Auswahl +
|
||||
Vater/Mutter-Picker (`components/AnimalPicker`), damit die Züchterin die Eltern **ohne** den
|
||||
Umweg über die Wurf-Seite pflegen kann. **Datenmodell unverändert**: Eltern hängen weiter am
|
||||
Geburtswurf. Backend `PUT /gerbils/{id}/parents` (`GerbilEndpoints`, DTOs `GerbilParentsInput`/
|
||||
`GerbilParentsResult`, Tests `GerbilManager.Tests/GerbilParentsTests.cs`) schreibt genau dorthin:
|
||||
hat das Tier einen Wurf → dessen `FatherId`/`MotherId` (gilt zwangsläufig für alle Geschwister,
|
||||
die UI warnt mit `siblingCount`); hat es keinen → ein Wurf mit **gleichem Elternpaar + gleichem
|
||||
Datum** wird verknüpft (`litterAttached`), sonst wird ein Träger-Wurf angelegt (`litterCreated`,
|
||||
Name „Wurf von X + Y", `ShowInChronicle=false`, `IsManual=true`). Geschlechts-Validierung
|
||||
wiederverwendet `LitterEndpoints.ValidateParents` (400 `InvalidParentGender`).
|
||||
**Grenze:** Eltern/Abstammung sind vom Freeze **ausgenommen** (`GerbilSnapshotService.FreezeFields`
|
||||
ohne `litterId`) → an einem **importierten** Wurf überlebt eine Eltern-Korrektur den Re-Ingest
|
||||
**nicht** (gleiches Verhalten wie beim Editieren über die Wurf-Seite; dauerhaft nur über den
|
||||
Importer/`conflict-decisions.json`).
|
||||
- **Manuell ergänzte Würfe / versteckte Würfe**: `conflict-decisions.json` kennt neben
|
||||
`resolutions`/`addAnimals` auch **`addLitters`** [{name, mother(+motherDob), father, children[],
|
||||
showInChronicle, totalBorn, date, notes}] — materialisiert in `merge_and_resolve.py` als
|
||||
**späten** Schritt (nach Dedup/Parent-Roles, vor json.dump): Wurf mit deterministischer
|
||||
`generate_guid("decision-litter-<slug>")`, verknüpft Mutter/Vater/Kinder per Name(+dob),
|
||||
setzt Kinder-`LitterId`. Die name+dob-Resolutions können zusätzlich `isResident`, `notes`,
|
||||
`receiver` (Kontaktname→ReceiverContactId) auf bestehende Tiere setzen (läuft NACH der
|
||||
Residenz-Propagation). `Litter.ShowInChronicle` (bool, Default true; Migration
|
||||
`AddLitterShowInChronicle`): `false` blendet den Wurf aus der Wurfchronik
|
||||
(`WuerfeListPage`, Filter `showInChronicle==true`) aus, zeigt ihn aber weiter auf der
|
||||
Tier-Akte (parentLitters-Query, ungefiltert). Use-Case: extern entstandener Wurf eines
|
||||
nicht-residenten Tieres (z. B. Akane × Bonaparte).
|
||||
- **Über-gemergte Würfe korrigieren**: `conflict-decisions.json` kennt **`litterChildren`**
|
||||
[{date, matchChild, keep[], renameTo?, note}] — autoritative Kinder-Whitelist eines Wurfs
|
||||
(später Schritt in `merge_and_resolve.py` nach `litterOrder`). Match per `date` + `matchChild`
|
||||
(ein aktuell zugeordnetes Kind, disambiguiert gleich-datierte Würfe); Kinder außerhalb `keep`
|
||||
werden aus dem Wurf gelöst (`LitterId=None`, Tier bleibt erhalten), optional `renameTo` setzt
|
||||
den kanonischen Wurf-Namen. Use-Case: Wurfchronik-Abnehmer-Sammelseiten, die fälschlich in einen
|
||||
datierten Buchstaben-Wurf gemergt wurden (Tickets 4b9f49fb M-Wurf, 8d259edf Gale).
|
||||
**`add`** [Name | {name, dob}] ist das Gegenstück (seit 2026-08-18): hängt ein Jungtier an
|
||||
**diesen** Wurf, wenn die Chart-Positions-Heuristik es in einen falschen **virtuellen**
|
||||
Stammbaum-Wurf gezogen hat; wird der alte Wurf dadurch kinderlos UND virtuell, entfernt der
|
||||
Importer ihn (sonst bliebe ein Phantom-Wurf in der Wurfchronik stehen). Use-Case Akane
|
||||
(Ticket 88389f8e): die neuen Stammbäume führen die Eltern eine Zeile unter dem Kind-Block
|
||||
(Vater N80/Mutter N81) — `_reconstruct_parents` greift dann eine Zeile zu hoch und paart die
|
||||
Mutter des Nachbar-Blocks mit dem echten Vater.
|
||||
- **EMF/WMF sind keine Fotos**: Excel legt neben dem Foto teils ein Vektor-Metafile ab; Browser
|
||||
können es nicht darstellen (kaputte Bildkachel in der Akte) → `extract._attach_photos`
|
||||
überspringt `.emf`/`.wmf` (Regressionstest in `test_extract.py`).
|
||||
- **Resolution-Schlüssel in `conflict-decisions.json` (Stand 2026-08-19)**: neben
|
||||
`father/mother(+Dob)`, `isResident`, `notes`, `receiver`, `dateOfDeath`, `deceased`,
|
||||
`correctDob`, `renameTo`, `mergeExternalRefs`, `suppressExternalRefs`, `farbschlag` auch
|
||||
**`goHomeDate`** (autoritativ, überschreibt auch einen vorhandenen/kaputten Wert und zieht
|
||||
Status `GivenAway` nach, sofern nicht `Deceased`) und **`spottingType`** (Scheckungsart —
|
||||
der Importer leitet sie aus keiner Quelle ab; `IngestResolvedService` überträgt sie nur,
|
||||
wenn der Payload einen Wert hat, sonst bliebe eine von Hand erfasste Angabe nicht stehen).
|
||||
**`genotype`** gewinnt bei **präzisem** Match (externalRef ODER name+dob) auch gegen einen
|
||||
bereits gefüllten Wert — bei Wurfchronik-Tieren ist der meist nur der Kanon-Fill des
|
||||
Farbschlags; ein name-only-Match füllt weiterhin nur leere Felder (Namensvetter-Schutz).
|
||||
- **Pro Tier greift nur EINE Resolution** (first match: externalRef → endswith → name+dob →
|
||||
name-only). Nie einen zweiten Eintrag mit derselben externalRef/demselben (name,dob) anlegen,
|
||||
sondern den bestehenden **in place** erweitern — sonst gewinnt still der letzte und Felder
|
||||
gehen verloren. **Wurfchronik-Jungtiere haben zur Override-Zeit noch kein DOB** (das kommt
|
||||
später aus dem Wurfdatum) → dort ist `externalRef` der einzige zuverlässige Schlüssel.
|
||||
- **`renameTo` zieht durch Freitext-Notizen** (`apply_rename_note_sweep`, letzter Schritt vor
|
||||
dem Payload-Dump): der alte Name blieb sonst in Wurf-Notizen stehen („Blacky + Kruke v. …").
|
||||
Wortgenau, mit Guards — Quellname < 4 Zeichen oder > 2 Wörter wird ignoriert, kein Ersetzen,
|
||||
wenn der alte Name im neuen Rufnamen steckt, und Notizen, die die Umbenennung selbst
|
||||
dokumentieren („umbenannt"/„ursprünglich"), bleiben unberührt. Ohne diese Guards trifft der
|
||||
Sweep Abnehmer-Namen und Gewichtsnotizen (im Probelauf 16 von 22 Ersetzungen falsch).
|
||||
- **`parse_date` verwirft implausible Jahre** (< 1900 oder > aktuelles Jahr + 1) → `None`;
|
||||
Wurfchronik-Transkriptionen bauen Daten sonst falsch zusammen (JackJack: „1310-05-13").
|
||||
- **Sternloses Geburtsdatum in Stammbaum-Charts**: `extract.py` normalisiert Zellen wie
|
||||
`22.08.2019/+09.04.2024` (ohne `*`) zu `*…`, wenn Block-Kontext vorliegt (Genotyp ≤3 Zeilen
|
||||
darunter, Namenszelle ≤3 darüber). Ohne das erkennt die Blockheuristik das Tier nicht und
|
||||
`_reconstruct_parents` greift in den Nachbar-Ast (Ticket 65266679 Bijou).
|
||||
- **`renameTo` ist eltern-verknüpfungssicher**: umbenannte Tiere werden im Eltern-Namensindex
|
||||
zusätzlich unter ihrem **Quell-Namen** (`_pre_rename_name`) geführt, sonst verlieren Würfe, die
|
||||
den alten Namen referenzieren, ihren Eltern-Link (Ticket e0a0c304 Kruke→Kuke). `dateOfDeath`
|
||||
ist als Resolution-Override unterstützt (setzt Todesdatum + Status Deceased; rpro3 hat keine
|
||||
Sterbespalte).
|
||||
|
||||
## Verifikations-Checkliste vor „fertig"
|
||||
|
||||
1. `npx tsc --noEmit`, `npx vitest run`, `npx eslint` sauber.
|
||||
2. Betroffene `npx playwright test`-Specs grün.
|
||||
3. `dotnet build` + `dotnet test` grün.
|
||||
4. Bei Import-Änderungen: `python test_*.py` grün + `resolved_import.json` plausibel
|
||||
(Stichprobe), danach App neu starten + `POST /import/ingest-resolved`.
|
||||
|
||||
## Gedächtnis (MemPalace) — IMMER nutzen
|
||||
|
||||
Dieses Projekt nutzt **MemPalace** als KI-Langzeitgedächtnis (lokal, kein API-Key).
|
||||
Die Anbindung ist im Repo persistiert und lädt automatisch:
|
||||
- `.mcp.json` (committet) registriert den MCP-Server `mempalace-mcp` → **19 MCP-Tools**
|
||||
(`mempalace_search`, `mempalace_kg_query/add/invalidate/timeline`, `mempalace_add_drawer`,
|
||||
`mempalace_diary_*` …). Aktiv ab Claude-Neustart (MCP-Server einmal bestätigen).
|
||||
- `.claude/settings.json` (committet) verdrahtet die Hooks
|
||||
`mempalace hook run --hook {session-start,stop,precompact} --harness claude-code`:
|
||||
**SessionStart** lädt relevanten Kontext automatisch, **Stop/PreCompact** persistieren.
|
||||
- Voraussetzung: `mempalace` ist installiert und auf PATH (`~/.local/bin`). Nach `git clone`
|
||||
ggf. MemPalace installieren; `mempalace.yaml` (Raum-Config) ist bewusst **gitignored**
|
||||
(per-project, lokal). Der Palast selbst liegt lokal unter `~/.mempalace` (nicht im Repo).
|
||||
|
||||
**Arbeitsweise pro Session:** zu Aufgabenbeginn relevantes Wissen abrufen
|
||||
(`mempalace_search` / `mempalace_kg_query`), bevor du im Code suchst; am Ende neue
|
||||
durable Erkenntnisse ablegen.
|
||||
|
||||
**Subagenten MÜSSEN MemPalace ebenfalls nutzen** — das im Subagent-Prompt IMMER explizit
|
||||
anweisen: zu Aufgabenbeginn den Palast abfragen (am einfachsten per CLI, kein Key nötig:
|
||||
`mempalace search "<keywords>"` bzw. `mempalace search "<…>" --wing gerbilmanager`; falls
|
||||
MCP-Tools verfügbar auch `mempalace_search`/`mempalace_kg_query`) — relevante Domänenregeln,
|
||||
frühere Entscheidungen und Stolperfallen holen, BEVOR im Code gesucht wird; durable
|
||||
Erkenntnisse am Ende ablegen (`mempalace add-drawer`/MCP). Gilt für JEDEN gespawnten Agenten.
|
||||
|
||||
### Memory-Policy — WANN / WIE / WO eine Memory anlegen
|
||||
|
||||
**WANN** (anlegen): nur **dauerhaft** nützliches, **nicht-offensichtliches** Wissen, das
|
||||
künftige Sessions braucht — Architektur-/Design-Entscheidungen und ihr *Warum*, Domänenregeln
|
||||
(z. B. 6-Jahre-Lebensspanne, Geschwisterverpaarung), Konventionen, Stolperfallen, bestätigte
|
||||
Nutzer-Präferenzen, wiederkehrende Workflows.
|
||||
**NICHT anlegen**: transienter Task-Status; was Code/Git/CLAUDE.md ohnehin festhalten;
|
||||
Dinge, die nur für das aktuelle Gespräch zählen.
|
||||
|
||||
**WIE** (anlegen/pflegen):
|
||||
- Fakt mit Gültigkeit → `mempalace_kg_add`. Freitext-Notiz/Erkenntnis → `mempalace_add_drawer`
|
||||
(vorher `mempalace_check_duplicate`).
|
||||
- Ändert/erledigt sich ein Fakt → `mempalace_kg_invalidate` (setzt End-Datum, **nicht löschen** —
|
||||
der Graph ist temporal). So bleibt nichts veraltet stehen.
|
||||
- Repo-Dateien werden über `mempalace mine <dir>` automatisch gefilet; `mempalace sync --apply
|
||||
--wing gerbilmanager` entfernt Drawers gelöschter/verschobener Dateien.
|
||||
|
||||
**WO** (welcher Speicher):
|
||||
- **MemPalace-Palast** (`~/.mempalace`, lokal): reichhaltiges, durchsuchbares Recall — der Default
|
||||
für Memories.
|
||||
- **CLAUDE.md** (Repo, committet): die **kanonischen** Projektfakten/Konventionen, die geteilt und
|
||||
versioniert sein müssen. Wichtige durable Erkenntnisse hier spiegeln, damit sie im Repo (nicht nur
|
||||
lokal) liegen.
|
||||
|
||||
Faustregel: durable + projektweit kanonisch → **CLAUDE.md** *und* Palast; sonstiges durable Wissen →
|
||||
Palast; veraltetes → invalidieren, nicht stehen lassen.
|
||||
91
GerbilManager.AppHost/AppHost.cs
Normal file
91
GerbilManager.AppHost/AppHost.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
|
||||
var builder = DistributedApplication.CreateBuilder(args);
|
||||
|
||||
// WEB-2: lokales Verzeichnis für die veröffentlichte öffentliche Seite. Die API schreibt
|
||||
// nach <root>/live (POST /api/publish), der publicsite-nginx serviert genau dieses live/
|
||||
// — dieselbe Form wie in Produktion (deploy/truenas), nur lokal zum Testen.
|
||||
var publicSiteRoot = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "deploy", ".local-publicsite"));
|
||||
Directory.CreateDirectory(Path.Combine(publicSiteRoot, "live")); // leeres live/, damit nginx sofort startet
|
||||
var publicSiteConf = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "deploy", "truenas", "nginx", "publicsite.conf"));
|
||||
|
||||
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)
|
||||
// WEB-2: Publish-Ziel der öffentlichen Seite (sonst gibt POST /api/publish 503).
|
||||
.WithEnvironment("PublicSite__RootPath", publicSiteRoot)
|
||||
.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}";
|
||||
});
|
||||
|
||||
// WEB-2: publicsite-nginx — serviert die veröffentlichte öffentliche Seite lokal (gleiche
|
||||
// nginx-Config wie in Produktion). Liest das live/-Verzeichnis aus dem Publish-Output der API.
|
||||
// Test: in der App veröffentlichen (oder POST /api/publish), dann http://localhost:8081 öffnen.
|
||||
builder.AddContainer("publicsite", "nginx", "alpine")
|
||||
.WithBindMount(publicSiteRoot, "/usr/share/nginx/html", isReadOnly: true)
|
||||
.WithBindMount(publicSiteConf, "/etc/nginx/conf.d/default.conf", isReadOnly: true)
|
||||
.WithHttpEndpoint(port: 8081, targetPort: 80, name: "http")
|
||||
.WithExternalHttpEndpoints();
|
||||
|
||||
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>
|
||||
175
GerbilManager.Tests/AcquisitionEndpointTests.cs
Normal file
175
GerbilManager.Tests/AcquisitionEndpointTests.cs
Normal file
@@ -0,0 +1,175 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// ERWERB: acquisition data per animal (purchase date/price/note).
|
||||
/// - POST creates, GET ?gerbilId= filters, PUT updates, DELETE removes.
|
||||
/// - CRITICAL: acquisition rows survive the import re-ingest wipe (loose, FK-free
|
||||
/// GerbilId/SourceContactId), exactly like Feedback.
|
||||
/// </summary>
|
||||
public class AcquisitionEndpointTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
public AcquisitionEndpointTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Crud_roundtrip_create_filter_update_delete()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var gerbilId = Guid.NewGuid();
|
||||
var contactId = Guid.NewGuid();
|
||||
|
||||
// CREATE
|
||||
var resp = await client.PostAsJsonAsync("/acquisitions", new
|
||||
{
|
||||
gerbilId,
|
||||
sourceContactId = contactId,
|
||||
date = "2025-03-14",
|
||||
price = 25.50m,
|
||||
note = "Auf der Börse gekauft.",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
var created = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||
var id = created.GetProperty("id").GetString()!;
|
||||
Assert.Equal(gerbilId.ToString(), created.GetProperty("gerbilId").GetString());
|
||||
Assert.Equal(25.50m, created.GetProperty("price").GetDecimal());
|
||||
Assert.Equal("2025-03-14", created.GetProperty("date").GetString());
|
||||
|
||||
// GET ?gerbilId= returns it
|
||||
var listed = JsonDocument.Parse(await client.GetStringAsync($"/acquisitions?gerbilId={gerbilId}")).RootElement;
|
||||
Assert.Contains(listed.EnumerateArray(),
|
||||
a => a.GetProperty("note").GetString() == "Auf der Börse gekauft.");
|
||||
|
||||
// a different gerbilId yields nothing
|
||||
var other = JsonDocument.Parse(await client.GetStringAsync($"/acquisitions?gerbilId={Guid.NewGuid()}")).RootElement;
|
||||
Assert.Empty(other.EnumerateArray());
|
||||
|
||||
// UPDATE
|
||||
var put = await client.PutAsJsonAsync($"/acquisitions/{id}", new
|
||||
{
|
||||
gerbilId,
|
||||
sourceContactId = (Guid?)null,
|
||||
date = "2025-04-01",
|
||||
price = 30m,
|
||||
note = "Korrigiert.",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||
var afterPut = JsonDocument.Parse(await client.GetStringAsync($"/acquisitions/{id}")).RootElement;
|
||||
Assert.Equal(30m, afterPut.GetProperty("price").GetDecimal());
|
||||
Assert.Equal("Korrigiert.", afterPut.GetProperty("note").GetString());
|
||||
Assert.True(afterPut.GetProperty("sourceContactId").ValueKind == JsonValueKind.Null);
|
||||
|
||||
// DELETE
|
||||
var del = await client.DeleteAsync($"/acquisitions/{id}");
|
||||
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
|
||||
var gone = await client.GetAsync($"/acquisitions/{id}");
|
||||
Assert.Equal(HttpStatusCode.NotFound, gone.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Acquisition_survives_ingest_wipe()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "acq-ingest-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var contactId = Guid.NewGuid();
|
||||
var fatherId = Guid.NewGuid();
|
||||
var motherId = Guid.NewGuid();
|
||||
var litterId = Guid.NewGuid();
|
||||
|
||||
var data = new
|
||||
{
|
||||
Contacts = new[]
|
||||
{
|
||||
new { Id = contactId, Name = "Test Breeder", Email = "t@e.de", Phone = "", Address = "", Notes = (string?)null, IsBreeder = true, IsReceiver = false, NameSuffix = (string?)null, Provenance = (string?)null }
|
||||
},
|
||||
Litters = new[]
|
||||
{
|
||||
new { Id = litterId, Name = "Wurf A", Date = "2026-01-01", TotalBorn = 5, DeathsWithin8Weeks = 0, FatherId = fatherId, MotherId = motherId, ExpectedGoHomeDate = (string?)null, Notes = "", PairingCode = "PC01", ExternalRef = "ext-litter-1", LitterLetter = "A" }
|
||||
},
|
||||
Gerbils = new[]
|
||||
{
|
||||
Animal(fatherId, "Papa", "male", contactId),
|
||||
Animal(motherId, "Mama", "female", contactId),
|
||||
},
|
||||
GerbilPhotos = Array.Empty<object>(),
|
||||
};
|
||||
File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(data));
|
||||
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseInMemoryDatabase("acq-ingest-" + Guid.NewGuid().ToString("N"))
|
||||
.Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated();
|
||||
|
||||
// An acquisition referencing the gerbil + contact the wipe will delete.
|
||||
var acqId = Guid.NewGuid();
|
||||
db.AcquisitionRecords.Add(new AcquisitionRecord
|
||||
{
|
||||
Id = acqId,
|
||||
GerbilId = fatherId,
|
||||
SourceContactId = contactId,
|
||||
Date = new DateOnly(2025, 3, 14),
|
||||
Price = 25.50m,
|
||||
Note = "Auf der Börse gekauft.",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", dir } })
|
||||
.Build();
|
||||
|
||||
var result = await new IngestResolvedService(db, config, null!).RunAsync();
|
||||
Assert.Contains("Ingestion successful!", result);
|
||||
|
||||
// The acquisition row survives even though the gerbil + contact were wiped/recreated.
|
||||
var survivor = await db.AcquisitionRecords.SingleAsync(a => a.Id == acqId);
|
||||
Assert.Equal(fatherId, survivor.GerbilId);
|
||||
Assert.Equal(contactId, survivor.SourceContactId);
|
||||
Assert.Equal(25.50m, survivor.Price);
|
||||
Assert.Equal("Auf der Börse gekauft.", survivor.Note);
|
||||
Assert.Equal(1, await db.AcquisitionRecords.CountAsync());
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static object Animal(Guid id, string name, string gender, Guid contactId) => new
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Gender = gender,
|
||||
Status = "Breeding",
|
||||
LitterId = (Guid?)null,
|
||||
OriginContactId = contactId,
|
||||
ReceiverContactId = (Guid?)null,
|
||||
EnclosureId = (Guid?)null,
|
||||
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"),
|
||||
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-" + name,
|
||||
RawImportData = "{}",
|
||||
OriginBreeder = "Test Zucht",
|
||||
NameSearch = name.ToLowerInvariant(),
|
||||
CharacterTraits = Array.Empty<string>(),
|
||||
CharacterNote = (string?)null,
|
||||
IsDeaf = false,
|
||||
IsResident = true,
|
||||
};
|
||||
}
|
||||
79
GerbilManager.Tests/ApiFactory.cs
Normal file
79
GerbilManager.Tests/ApiFactory.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
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}");
|
||||
|
||||
/// <summary>Optionaler Isolations-Override für den Import-Quellordner (Import:SourcePath),
|
||||
/// damit der Upload-Ingest-Test in einem Temp-Verzeichnis arbeitet statt im echten Repo.</summary>
|
||||
public string? ImportSourcePath { get; init; }
|
||||
|
||||
/// <summary>Optionaler Isolations-Override für den Foto-Ordner (Photos:RootPath).</summary>
|
||||
public string? PhotosRootPath { get; init; }
|
||||
|
||||
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);
|
||||
if (ImportSourcePath is not null) builder.UseSetting("Import:SourcePath", ImportSourcePath);
|
||||
if (PhotosRootPath is not null) builder.UseSetting("Photos:RootPath", PhotosRootPath);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
118
GerbilManager.Tests/EnclosureEndpointTests.cs
Normal file
118
GerbilManager.Tests/EnclosureEndpointTests.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gehege-Reinigungszyklus (RennmausPro becken_tb): Enclosure trägt Maße (Size),
|
||||
/// Kapazität (Capacity), letzte Reinigung (LastCleanedDate) und Reinigungsintervall
|
||||
/// (CleaningCycleDays). NextCleaningDate = LastCleanedDate + CleaningCycleDays wird
|
||||
/// berechnet ausgegeben. "mark-cleaned" setzt die letzte Reinigung auf heute.
|
||||
/// </summary>
|
||||
public class EnclosureEndpointTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
public EnclosureEndpointTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Post_persists_cleaning_fields_and_computes_next_cleaning()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/enclosures", new
|
||||
{
|
||||
name = "Reinigungs-Becken",
|
||||
notes = "Test",
|
||||
size = "120×50 cm",
|
||||
capacity = 6,
|
||||
lastCleanedDate = "2026-06-01",
|
||||
cleaningCycleDays = 14,
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
var dto = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("120×50 cm", dto.GetProperty("size").GetString());
|
||||
Assert.Equal(6, dto.GetProperty("capacity").GetInt32());
|
||||
Assert.Equal("2026-06-01", dto.GetProperty("lastCleanedDate").GetString());
|
||||
Assert.Equal(14, dto.GetProperty("cleaningCycleDays").GetInt32());
|
||||
// 2026-06-01 + 14 Tage = 2026-06-15
|
||||
Assert.Equal("2026-06-15", dto.GetProperty("nextCleaningDate").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NextCleaning_is_null_without_cycle_or_lastCleaned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
// Nur letzte Reinigung, kein Zyklus -> keine nächste fällige Reinigung.
|
||||
var resp = await client.PostAsJsonAsync("/enclosures", new
|
||||
{
|
||||
name = "Becken ohne Zyklus",
|
||||
lastCleanedDate = "2026-06-01",
|
||||
});
|
||||
var dto = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal(JsonValueKind.Null, dto.GetProperty("nextCleaningDate").ValueKind);
|
||||
Assert.Equal(JsonValueKind.Null, dto.GetProperty("cleaningCycleDays").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_updates_cleaning_fields()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var created = JsonDocument.Parse(await (await client.PostAsJsonAsync("/enclosures", new
|
||||
{
|
||||
name = "Becken-Edit",
|
||||
cleaningCycleDays = 7,
|
||||
})).Content.ReadAsStringAsync()).RootElement;
|
||||
var id = created.GetProperty("id").GetString();
|
||||
|
||||
var put = await client.PutAsJsonAsync($"/enclosures/{id}", new
|
||||
{
|
||||
name = "Becken-Edit",
|
||||
size = "80×40 cm",
|
||||
capacity = 4,
|
||||
lastCleanedDate = "2026-05-20",
|
||||
cleaningCycleDays = 10,
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||
|
||||
var dto = JsonDocument.Parse(await client.GetStringAsync($"/enclosures/{id}")).RootElement;
|
||||
Assert.Equal("80×40 cm", dto.GetProperty("size").GetString());
|
||||
Assert.Equal(4, dto.GetProperty("capacity").GetInt32());
|
||||
Assert.Equal("2026-05-20", dto.GetProperty("lastCleanedDate").GetString());
|
||||
Assert.Equal(10, dto.GetProperty("cleaningCycleDays").GetInt32());
|
||||
Assert.Equal("2026-05-30", dto.GetProperty("nextCleaningDate").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkCleaned_sets_lastCleaned_to_today()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var created = JsonDocument.Parse(await (await client.PostAsJsonAsync("/enclosures", new
|
||||
{
|
||||
name = "Becken-Mark",
|
||||
cleaningCycleDays = 21,
|
||||
lastCleanedDate = "2020-01-01",
|
||||
})).Content.ReadAsStringAsync()).RootElement;
|
||||
var id = created.GetProperty("id").GetString();
|
||||
|
||||
var resp = await client.PostAsync($"/enclosures/{id}/mark-cleaned", null);
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
|
||||
var dto = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
Assert.Equal(today.ToString("yyyy-MM-dd"), dto.GetProperty("lastCleanedDate").GetString());
|
||||
Assert.Equal(today.AddDays(21).ToString("yyyy-MM-dd"), dto.GetProperty("nextCleaningDate").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkCleaned_unknown_id_returns_404()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var resp = await client.PostAsync($"/enclosures/{Guid.NewGuid()}/mark-cleaned", null);
|
||||
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||
}
|
||||
}
|
||||
211
GerbilManager.Tests/ExhibitionEndpointTests.cs
Normal file
211
GerbilManager.Tests/ExhibitionEndpointTests.cs
Normal file
@@ -0,0 +1,211 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// EXHIBITION: show/exhibition results & awards per animal (/exhibitions).
|
||||
/// - POST creates a row; GET (optionally filtered by gerbilId) returns it, newest first.
|
||||
/// - PUT edits, DELETE removes; unknown ids return 404; an empty EventName is rejected (400).
|
||||
/// - CRITICAL: exhibition rows survive the import re-ingest wipe (loose, FK-free GerbilId).
|
||||
/// </summary>
|
||||
public class ExhibitionEndpointTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
public ExhibitionEndpointTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Post_creates_and_get_filters_by_gerbilId()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var gerbilId = Guid.NewGuid();
|
||||
var resp = await client.PostAsJsonAsync("/exhibitions", new
|
||||
{
|
||||
gerbilId,
|
||||
entityName = "Krümel",
|
||||
eventName = "Nationale Rennmausschau 2026",
|
||||
date = "2026-03-15T00:00:00Z",
|
||||
placement = "1. Platz",
|
||||
award = "Best in Show",
|
||||
note = "Sehr ausgeglichenes Tier.",
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
var created = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||
var id = created.GetProperty("id").GetString();
|
||||
Assert.False(string.IsNullOrEmpty(id));
|
||||
Assert.Equal("Nationale Rennmausschau 2026", created.GetProperty("eventName").GetString());
|
||||
Assert.Equal("1. Platz", created.GetProperty("placement").GetString());
|
||||
Assert.Equal("Best in Show", created.GetProperty("award").GetString());
|
||||
Assert.Equal(gerbilId.ToString(), created.GetProperty("gerbilId").GetString());
|
||||
|
||||
// GET filtered to this gerbil returns the row.
|
||||
var listed = JsonDocument.Parse(await client.GetStringAsync($"/exhibitions?gerbilId={gerbilId}")).RootElement;
|
||||
Assert.Contains(listed.EnumerateArray(),
|
||||
x => x.GetProperty("id").GetString() == id
|
||||
&& x.GetProperty("eventName").GetString() == "Nationale Rennmausschau 2026");
|
||||
|
||||
// GET filtered to a DIFFERENT gerbil does not.
|
||||
var other = JsonDocument.Parse(await client.GetStringAsync($"/exhibitions?gerbilId={Guid.NewGuid()}")).RootElement;
|
||||
Assert.DoesNotContain(other.EnumerateArray(), x => x.GetProperty("id").GetString() == id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Crud_lifecycle_edit_and_delete()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var create = await client.PostAsJsonAsync("/exhibitions", new
|
||||
{
|
||||
eventName = "Lokalschau",
|
||||
entityName = "Balu",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
|
||||
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
|
||||
// Edit: change placement + add a date.
|
||||
var edit = await client.PutAsJsonAsync($"/exhibitions/{id}", new
|
||||
{
|
||||
placement = "2. Platz",
|
||||
date = "2025-11-01T00:00:00Z",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.OK, edit.StatusCode);
|
||||
var edited = JsonDocument.Parse(await edit.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("2. Platz", edited.GetProperty("placement").GetString());
|
||||
Assert.Equal("Lokalschau", edited.GetProperty("eventName").GetString());
|
||||
|
||||
// Delete -> 204, then 404 on subsequent edit/delete.
|
||||
var del = await client.DeleteAsync($"/exhibitions/{id}");
|
||||
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.DeleteAsync($"/exhibitions/{id}")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.PutAsJsonAsync($"/exhibitions/{id}", new { eventName = "x" })).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Post_rejects_empty_event_name()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var resp = await client.PostAsJsonAsync("/exhibitions", new { eventName = " " });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_and_delete_unknown_id_return_404()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var missing = Guid.NewGuid();
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.PutAsJsonAsync($"/exhibitions/{missing}", new { eventName = "x" })).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.DeleteAsync($"/exhibitions/{missing}")).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exhibition_survives_ingest_wipe()
|
||||
{
|
||||
// Fresh in-memory DB seeded with a resolved import file (mirrors IngestResolvedServiceTests).
|
||||
var dir = Path.Combine(Path.GetTempPath(), "exhibition-ingest-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var contactId = Guid.NewGuid();
|
||||
var fatherId = Guid.NewGuid();
|
||||
var motherId = Guid.NewGuid();
|
||||
var litterId = Guid.NewGuid();
|
||||
|
||||
var data = new
|
||||
{
|
||||
Contacts = new[]
|
||||
{
|
||||
new { Id = contactId, Name = "Test Breeder", Email = "t@e.de", Phone = "", Address = "", Notes = (string?)null, IsBreeder = true, IsReceiver = false, NameSuffix = (string?)null, Provenance = (string?)null }
|
||||
},
|
||||
Litters = new[]
|
||||
{
|
||||
new { Id = litterId, Name = "Wurf A", Date = "2026-01-01", TotalBorn = 5, DeathsWithin8Weeks = 0, FatherId = fatherId, MotherId = motherId, ExpectedGoHomeDate = (string?)null, Notes = "", PairingCode = "PC01", ExternalRef = "ext-litter-1", LitterLetter = "A" }
|
||||
},
|
||||
Gerbils = new[]
|
||||
{
|
||||
Animal(fatherId, "Papa", "male", contactId),
|
||||
Animal(motherId, "Mama", "female", contactId),
|
||||
},
|
||||
GerbilPhotos = Array.Empty<object>(),
|
||||
};
|
||||
File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(data));
|
||||
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseInMemoryDatabase("exhibition-ingest-" + Guid.NewGuid().ToString("N"))
|
||||
.Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated();
|
||||
|
||||
// An exhibition result referencing the gerbil that the wipe will delete/recreate.
|
||||
var exhId = Guid.NewGuid();
|
||||
db.ExhibitionResults.Add(new ExhibitionResult
|
||||
{
|
||||
Id = exhId,
|
||||
GerbilId = fatherId,
|
||||
EntityName = "Papa",
|
||||
EventName = "Nationale Rennmausschau 2026",
|
||||
Date = new DateTime(2026, 3, 15, 0, 0, 0, DateTimeKind.Utc),
|
||||
Placement = "1. Platz",
|
||||
Award = "Best in Show",
|
||||
Note = "Tolles Tier.",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", dir } })
|
||||
.Build();
|
||||
|
||||
// Run the ingest wipe + reload.
|
||||
var result = await new IngestResolvedService(db, config, null!).RunAsync();
|
||||
Assert.Contains("Ingestion successful!", result);
|
||||
|
||||
// Gerbils/litters/contacts were wiped & re-created, but the exhibition row is untouched.
|
||||
var survivor = await db.ExhibitionResults.SingleAsync(x => x.Id == exhId);
|
||||
Assert.Equal(fatherId, survivor.GerbilId); // loose id preserved even though the gerbil row was deleted/recreated
|
||||
Assert.Equal("Papa", survivor.EntityName);
|
||||
Assert.Equal("Nationale Rennmausschau 2026", survivor.EventName);
|
||||
Assert.Equal("1. Platz", survivor.Placement);
|
||||
Assert.Equal("Best in Show", survivor.Award);
|
||||
Assert.Equal(1, await db.ExhibitionResults.CountAsync());
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static object Animal(Guid id, string name, string gender, Guid contactId) => new
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Gender = gender,
|
||||
Status = "Breeding",
|
||||
LitterId = (Guid?)null,
|
||||
OriginContactId = contactId,
|
||||
ReceiverContactId = (Guid?)null,
|
||||
EnclosureId = (Guid?)null,
|
||||
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"),
|
||||
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-" + name,
|
||||
RawImportData = "{}",
|
||||
OriginBreeder = "Test Zucht",
|
||||
NameSearch = name.ToLowerInvariant(),
|
||||
CharacterTraits = Array.Empty<string>(),
|
||||
CharacterNote = (string?)null,
|
||||
IsDeaf = false,
|
||||
IsResident = true,
|
||||
};
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
562
GerbilManager.Tests/FeedbackEndpointTests.cs
Normal file
562
GerbilManager.Tests/FeedbackEndpointTests.cs
Normal file
@@ -0,0 +1,562 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// FEEDBACK: the "Fehler melden" report sink.
|
||||
/// - POST /feedback persists a row (with captured debug context) and GET /feedback returns it.
|
||||
/// - Validation: an empty message is rejected with 400.
|
||||
/// - CRITICAL: feedback rows survive the import re-ingest wipe (loose, FK-free GerbilId/LitterId).
|
||||
/// </summary>
|
||||
public class FeedbackEndpointTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
public FeedbackEndpointTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Post_feedback_persists_row_with_debug_context()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var gerbilId = Guid.NewGuid();
|
||||
var resp = await client.PostAsJsonAsync("/feedback", new
|
||||
{
|
||||
message = "Der Stammbaum zeigt den falschen Vater.",
|
||||
context = "stammbaum",
|
||||
gerbilId,
|
||||
litterId = (Guid?)null,
|
||||
entityName = "Krümel",
|
||||
url = "http://localhost:5173/rennmaeuse/kruemel/stammbaum",
|
||||
clientTimestamp = "2026-06-22T12:00:00Z",
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
var created = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.False(string.IsNullOrEmpty(created.GetProperty("id").GetString()));
|
||||
Assert.Equal("stammbaum", created.GetProperty("context").GetString());
|
||||
Assert.Equal("Krümel", created.GetProperty("entityName").GetString());
|
||||
Assert.Equal(gerbilId.ToString(), created.GetProperty("gerbilId").GetString());
|
||||
|
||||
// A freshly created ticket starts Open with no ResolvedAt.
|
||||
Assert.Equal("Open", created.GetProperty("status").GetString());
|
||||
Assert.Equal(JsonValueKind.Null, created.GetProperty("resolvedAt").ValueKind);
|
||||
|
||||
// GET returns it (newest first)
|
||||
var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
|
||||
Assert.Contains(listed.EnumerateArray(),
|
||||
f => f.GetProperty("entityName").GetString() == "Krümel"
|
||||
&& f.GetProperty("message").GetString() == "Der Stammbaum zeigt den falschen Vater."
|
||||
&& f.GetProperty("status").GetString() == "Open");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Feedback_crud_lifecycle_edit_resolve_reopen_delete()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
// Create
|
||||
var create = await client.PostAsJsonAsync("/feedback", new
|
||||
{
|
||||
message = "Gewicht wird falsch gerundet.",
|
||||
context = "gerbil-detail",
|
||||
entityName = "Balu",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
|
||||
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
Assert.False(string.IsNullOrEmpty(id));
|
||||
|
||||
// List contains it
|
||||
var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
|
||||
Assert.Contains(listed.EnumerateArray(), f => f.GetProperty("id").GetString() == id);
|
||||
|
||||
// Edit message
|
||||
var edit = await client.PutAsJsonAsync($"/feedback/{id}", new { message = "Gewicht wird falsch gerundet (auf der Verlaufskurve)." });
|
||||
Assert.Equal(HttpStatusCode.OK, edit.StatusCode);
|
||||
var edited = JsonDocument.Parse(await edit.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("Gewicht wird falsch gerundet (auf der Verlaufskurve).", edited.GetProperty("message").GetString());
|
||||
Assert.Equal("Open", edited.GetProperty("status").GetString());
|
||||
|
||||
// Empty message is rejected
|
||||
var empty = await client.PutAsJsonAsync($"/feedback/{id}", new { message = " " });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, empty.StatusCode);
|
||||
|
||||
// Mark resolved -> ResolvedAt is set
|
||||
var resolve = await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved" });
|
||||
Assert.Equal(HttpStatusCode.OK, resolve.StatusCode);
|
||||
var resolved = JsonDocument.Parse(await resolve.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("Resolved", resolved.GetProperty("status").GetString());
|
||||
Assert.NotEqual(JsonValueKind.Null, resolved.GetProperty("resolvedAt").ValueKind);
|
||||
|
||||
// Reopen -> ResolvedAt cleared
|
||||
var reopen = await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Open" });
|
||||
Assert.Equal(HttpStatusCode.OK, reopen.StatusCode);
|
||||
var reopened = JsonDocument.Parse(await reopen.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("Open", reopened.GetProperty("status").GetString());
|
||||
Assert.Equal(JsonValueKind.Null, reopened.GetProperty("resolvedAt").ValueKind);
|
||||
|
||||
// Soft-Delete -> 204; das Ticket bleibt erhalten (DeletedAt gesetzt) und ist über GET noch da.
|
||||
var del = await client.DeleteAsync($"/feedback/{id}");
|
||||
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
|
||||
|
||||
var afterDelete = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
|
||||
var deletedRow = afterDelete.EnumerateArray().Single(f => f.GetProperty("id").GetString() == id);
|
||||
Assert.NotEqual(JsonValueKind.Null, deletedRow.GetProperty("deletedAt").ValueKind);
|
||||
|
||||
// Wiederherstellen -> DeletedAt wieder null, Ticket weiterhin bearbeitbar.
|
||||
var restore = await client.PostAsync($"/feedback/{id}/restore", null);
|
||||
Assert.Equal(HttpStatusCode.OK, restore.StatusCode);
|
||||
var restored = JsonDocument.Parse(await restore.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal(JsonValueKind.Null, restored.GetProperty("deletedAt").ValueKind);
|
||||
|
||||
// Delete/Restore eines unbekannten Tickets -> 404.
|
||||
var delMissing = await client.DeleteAsync($"/feedback/{Guid.NewGuid()}");
|
||||
Assert.Equal(HttpStatusCode.NotFound, delMissing.StatusCode);
|
||||
var restoreMissing = await client.PostAsync($"/feedback/{Guid.NewGuid()}/restore", null);
|
||||
Assert.Equal(HttpStatusCode.NotFound, restoreMissing.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reopen_resolved_ticket_without_Rueckfrage_sets_NeedsInfo_and_ReopenedAt()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var create = await client.PostAsJsonAsync("/feedback", new
|
||||
{
|
||||
message = "Farbschlag stimmt nicht.",
|
||||
context = "gerbil-detail",
|
||||
});
|
||||
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
|
||||
// Direkt (ohne Rückfrage) lösen …
|
||||
await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved", fixNote = "behoben" });
|
||||
|
||||
// … und wieder öffnen: Frontend schickt NeedsInfo + Bitte-um-Infos-Frage.
|
||||
var reopen = await client.PutAsJsonAsync($"/feedback/{id}", new
|
||||
{
|
||||
status = "NeedsInfo",
|
||||
question = "Bitte beschreibe, was noch fehlt.",
|
||||
});
|
||||
var r = JsonDocument.Parse(await reopen.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("NeedsInfo", r.GetProperty("status").GetString());
|
||||
Assert.Equal("Bitte beschreibe, was noch fehlt.", r.GetProperty("question").GetString());
|
||||
// Echt gelöstes Ticket ohne vorherige Rückfrage -> ReopenedAt gesetzt.
|
||||
Assert.NotEqual(JsonValueKind.Null, r.GetProperty("reopenedAt").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Attachment_upload_list_serve_delete_lifecycle()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var create = await client.PostAsJsonAsync("/feedback", new
|
||||
{
|
||||
message = "Foto vom Fellschlag.",
|
||||
context = "gerbil-detail",
|
||||
});
|
||||
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
|
||||
// 1×1-PNG hochladen.
|
||||
const string pngB64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
||||
var up = await client.PostAsJsonAsync($"/feedback/{id}/attachments", new
|
||||
{
|
||||
fileName = "maus.png",
|
||||
contentType = "image/png",
|
||||
dataBase64 = pngB64,
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Created, up.StatusCode);
|
||||
var att = JsonDocument.Parse(await up.Content.ReadAsStringAsync()).RootElement;
|
||||
var attId = att.GetProperty("id").GetString();
|
||||
Assert.Equal("maus.png", att.GetProperty("fileName").GetString());
|
||||
Assert.True(att.GetProperty("size").GetInt32() > 0);
|
||||
|
||||
// GET /feedback liefert die Metadaten (ohne Bytes).
|
||||
var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
|
||||
var row = listed.EnumerateArray().Single(f => f.GetProperty("id").GetString() == id);
|
||||
Assert.Equal(1, row.GetProperty("attachments").GetArrayLength());
|
||||
|
||||
// Bytes ausliefern.
|
||||
var bytes = await client.GetByteArrayAsync($"/feedback/attachments/{attId}");
|
||||
Assert.Equal(Convert.FromBase64String(pngB64).Length, bytes.Length);
|
||||
|
||||
// Löschen -> danach keine Anhänge mehr.
|
||||
var del = await client.DeleteAsync($"/feedback/attachments/{attId}");
|
||||
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
|
||||
var after = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
|
||||
var rowAfter = after.EnumerateArray().Single(f => f.GetProperty("id").GetString() == id);
|
||||
Assert.Equal(0, rowAfter.GetProperty("attachments").GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Attachment_upload_rejects_invalid_base64()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var create = await client.PostAsJsonAsync("/feedback", new { message = "x", context = "gerbil-detail" });
|
||||
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
var up = await client.PostAsJsonAsync($"/feedback/{id}/attachments", new
|
||||
{
|
||||
fileName = "x.png",
|
||||
contentType = "image/png",
|
||||
dataBase64 = "###nicht base64###",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, up.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reopen_resolved_ticket_that_had_a_Rueckfrage_keeps_question_and_no_ReopenedAt()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var create = await client.PostAsJsonAsync("/feedback", new
|
||||
{
|
||||
message = "Welches Tier ist gemeint?",
|
||||
context = "gerbil-detail",
|
||||
});
|
||||
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
|
||||
// Rückfrage stellen (NeedsInfo) …
|
||||
await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Meinst du A oder B?" });
|
||||
// … manuell lösen …
|
||||
await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved" });
|
||||
// … und wieder öffnen: Frontend schickt dieselbe Frage erneut (keine Bitte-um-Infos).
|
||||
var reopen = await client.PutAsJsonAsync($"/feedback/{id}", new
|
||||
{
|
||||
status = "NeedsInfo",
|
||||
question = "Meinst du A oder B?",
|
||||
});
|
||||
var r = JsonDocument.Parse(await reopen.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("NeedsInfo", r.GetProperty("status").GetString());
|
||||
Assert.Equal("Meinst du A oder B?", r.GetProperty("question").GetString());
|
||||
// Hatte bereits eine offene Rückfrage -> KEIN „Wieder geöffnet am"-Zeitstempel.
|
||||
Assert.Equal(JsonValueKind.Null, r.GetProperty("reopenedAt").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_question_sets_NeedsInfo_and_answer_sets_Answered()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
// Create an open ticket.
|
||||
var create = await client.PostAsJsonAsync("/feedback", new
|
||||
{
|
||||
message = "Wurf B hat ein falsches Geburtsdatum.",
|
||||
context = "litter-detail",
|
||||
entityName = "Wurf B",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
|
||||
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
|
||||
// Attach a clarifying question -> Status becomes NeedsInfo, question echoed back, no answer yet.
|
||||
var ask = await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Welches Datum stimmt?" });
|
||||
Assert.Equal(HttpStatusCode.OK, ask.StatusCode);
|
||||
var asked = JsonDocument.Parse(await ask.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("NeedsInfo", asked.GetProperty("status").GetString());
|
||||
Assert.Equal("Welches Datum stimmt?", asked.GetProperty("question").GetString());
|
||||
Assert.Equal(JsonValueKind.Null, asked.GetProperty("answer").ValueKind);
|
||||
Assert.Equal(JsonValueKind.Null, asked.GetProperty("answeredAt").ValueKind);
|
||||
|
||||
// Breeder answers -> Status becomes Answered, AnsweredAt stamped, answer echoed, question retained.
|
||||
var reply = await client.PutAsJsonAsync($"/feedback/{id}", new { answer = "Der 2. Juni." });
|
||||
Assert.Equal(HttpStatusCode.OK, reply.StatusCode);
|
||||
var answered = JsonDocument.Parse(await reply.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("Answered", answered.GetProperty("status").GetString());
|
||||
Assert.Equal("Der 2. Juni.", answered.GetProperty("answer").GetString());
|
||||
Assert.Equal("Welches Datum stimmt?", answered.GetProperty("question").GetString());
|
||||
Assert.NotEqual(JsonValueKind.Null, answered.GetProperty("answeredAt").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_question_on_resolved_ticket_keeps_it_resolved()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var create = await client.PostAsJsonAsync("/feedback", new { message = "x", context = "gerbil-detail" });
|
||||
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
|
||||
await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved" });
|
||||
var put = await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Noch eine Frage?" });
|
||||
var dto = JsonDocument.Parse(await put.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("Resolved", dto.GetProperty("status").GetString());
|
||||
Assert.Equal("Noch eine Frage?", dto.GetProperty("question").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_resolve_with_fixNote_stores_breeder_changelog()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var create = await client.PostAsJsonAsync("/feedback", new { message = "Vater falsch.", context = "stammbaum", entityName = "Yuki" });
|
||||
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
|
||||
// Resolve the ticket together with a breeder-friendly fix note (changelog).
|
||||
var resolve = await client.PutAsJsonAsync($"/feedback/{id}", new
|
||||
{
|
||||
status = "Resolved",
|
||||
fixNote = "Die Mutter von Yuki war falsch — sie ist jetzt Izumi. Bitte den Stammbaum neu laden.",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.OK, resolve.StatusCode);
|
||||
var dto = JsonDocument.Parse(await resolve.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("Resolved", dto.GetProperty("status").GetString());
|
||||
Assert.Equal("Die Mutter von Yuki war falsch — sie ist jetzt Izumi. Bitte den Stammbaum neu laden.",
|
||||
dto.GetProperty("fixNote").GetString());
|
||||
Assert.NotEqual(JsonValueKind.Null, dto.GetProperty("resolvedAt").ValueKind);
|
||||
|
||||
// The fix note survives a re-fetch via GET.
|
||||
var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
|
||||
Assert.Contains(listed.EnumerateArray(),
|
||||
f => f.GetProperty("id").GetString() == id
|
||||
&& f.GetProperty("fixNote").GetString()!.StartsWith("Die Mutter von Yuki war falsch"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_agentContext_round_trips_without_changing_status()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var create = await client.PostAsJsonAsync("/feedback", new { message = "Gewicht falsch.", context = "gerbil-detail" });
|
||||
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
|
||||
// Setting only the internal agent context must NOT change the lifecycle status.
|
||||
var put = await client.PutAsJsonAsync($"/feedback/{id}", new
|
||||
{
|
||||
agentContext = "{\"rootCause\":\"rounding in WeightChart\",\"files\":[\"WeightChart.tsx\"],\"plan\":\"fix toFixed\"}",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.OK, put.StatusCode);
|
||||
var dto = JsonDocument.Parse(await put.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("Open", dto.GetProperty("status").GetString());
|
||||
Assert.Equal("{\"rootCause\":\"rounding in WeightChart\",\"files\":[\"WeightChart.tsx\"],\"plan\":\"fix toFixed\"}",
|
||||
dto.GetProperty("agentContext").GetString());
|
||||
|
||||
// It is returned by GET as well (for tooling) but the UI never renders it.
|
||||
var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
|
||||
Assert.Contains(listed.EnumerateArray(),
|
||||
f => f.GetProperty("id").GetString() == id
|
||||
&& f.GetProperty("agentContext").GetString()!.Contains("rootCause"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task New_question_pushes_prior_QandA_into_thread()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var create = await client.PostAsJsonAsync("/feedback", new { message = "Wurf C unklar.", context = "litter-detail", entityName = "Wurf C" });
|
||||
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
|
||||
// Round 1: maintainer asks, breeder answers.
|
||||
await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Wie heißt die Mutter?" });
|
||||
await client.PutAsJsonAsync($"/feedback/{id}", new { answer = "Die Mutter ist Luna." });
|
||||
|
||||
// Round 2: a NEW question is asked while the prior Q&A exists.
|
||||
var ask2 = await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Und wer ist der Vater?" });
|
||||
Assert.Equal(HttpStatusCode.OK, ask2.StatusCode);
|
||||
var dto = JsonDocument.Parse(await ask2.Content.ReadAsStringAsync()).RootElement;
|
||||
|
||||
// Current open exchange = the new question, fresh (no answer yet), status NeedsInfo.
|
||||
Assert.Equal("NeedsInfo", dto.GetProperty("status").GetString());
|
||||
Assert.Equal("Und wer ist der Vater?", dto.GetProperty("question").GetString());
|
||||
Assert.Equal(JsonValueKind.Null, dto.GetProperty("answer").ValueKind);
|
||||
Assert.Equal(JsonValueKind.Null, dto.GetProperty("answeredAt").ValueKind);
|
||||
|
||||
// The prior round was archived into the thread (oldest first): maintainer Q then breeder A.
|
||||
var thread = dto.GetProperty("thread");
|
||||
Assert.Equal(JsonValueKind.Array, thread.ValueKind);
|
||||
Assert.Equal(2, thread.GetArrayLength());
|
||||
Assert.Equal("maintainer", thread[0].GetProperty("role").GetString());
|
||||
Assert.Equal("Wie heißt die Mutter?", thread[0].GetProperty("text").GetString());
|
||||
Assert.Equal("breeder", thread[1].GetProperty("role").GetString());
|
||||
Assert.Equal("Die Mutter ist Luna.", thread[1].GetProperty("text").GetString());
|
||||
|
||||
// Round 2 answered, then round 3 question → thread now holds 4 entries (two full rounds).
|
||||
await client.PutAsJsonAsync($"/feedback/{id}", new { answer = "Der Vater ist Max." });
|
||||
var ask3 = await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Stimmt das Geburtsdatum?" });
|
||||
var dto3 = JsonDocument.Parse(await ask3.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal(4, dto3.GetProperty("thread").GetArrayLength());
|
||||
Assert.Equal("Stimmt das Geburtsdatum?", dto3.GetProperty("question").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_and_delete_unknown_id_return_404()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var missing = Guid.NewGuid();
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.PutAsJsonAsync($"/feedback/{missing}", new { message = "x" })).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.DeleteAsync($"/feedback/{missing}")).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Post_feedback_rejects_empty_message()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var resp = await client.PostAsJsonAsync("/feedback", new { message = " ", context = "gerbil-detail" });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Feedback_survives_ingest_wipe()
|
||||
{
|
||||
// Fresh in-memory DB seeded with a resolved import file (mirrors IngestResolvedServiceTests).
|
||||
var dir = Path.Combine(Path.GetTempPath(), "feedback-ingest-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var contactId = Guid.NewGuid();
|
||||
var fatherId = Guid.NewGuid();
|
||||
var motherId = Guid.NewGuid();
|
||||
var litterId = Guid.NewGuid();
|
||||
|
||||
var data = new
|
||||
{
|
||||
Contacts = new[]
|
||||
{
|
||||
new { Id = contactId, Name = "Test Breeder", Email = "t@e.de", Phone = "", Address = "", Notes = (string?)null, IsBreeder = true, IsReceiver = false, NameSuffix = (string?)null, Provenance = (string?)null }
|
||||
},
|
||||
Litters = new[]
|
||||
{
|
||||
new { Id = litterId, Name = "Wurf A", Date = "2026-01-01", TotalBorn = 5, DeathsWithin8Weeks = 0, FatherId = fatherId, MotherId = motherId, ExpectedGoHomeDate = (string?)null, Notes = "", PairingCode = "PC01", ExternalRef = "ext-litter-1", LitterLetter = "A" }
|
||||
},
|
||||
Gerbils = new[]
|
||||
{
|
||||
Animal(fatherId, "Papa", "male", contactId),
|
||||
Animal(motherId, "Mama", "female", contactId),
|
||||
},
|
||||
GerbilPhotos = Array.Empty<object>(),
|
||||
};
|
||||
File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(data));
|
||||
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseInMemoryDatabase("feedback-ingest-" + Guid.NewGuid().ToString("N"))
|
||||
.Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated();
|
||||
|
||||
// A feedback report referencing the gerbil + litter that the wipe will delete.
|
||||
var feedbackId = Guid.NewGuid();
|
||||
db.Feedback.Add(new Feedback
|
||||
{
|
||||
Id = feedbackId,
|
||||
Message = "Bitte prüfen.",
|
||||
Context = "gerbil-detail",
|
||||
GerbilId = fatherId,
|
||||
LitterId = litterId,
|
||||
EntityName = "Papa",
|
||||
Url = "http://localhost/rennmaeuse/papa",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Status = "Answered",
|
||||
ResolvedAt = null,
|
||||
Question = "Welches Datum stimmt?",
|
||||
Answer = "Der 2. Juni.",
|
||||
AnsweredAt = DateTimeOffset.UtcNow,
|
||||
FixNote = "Das Geburtsdatum von Papa wurde korrigiert.",
|
||||
AgentContext = "{\"plan\":\"awaiting DOB confirmation\"}",
|
||||
Thread = "[{\"role\":\"maintainer\",\"text\":\"Erste Frage?\",\"at\":\"2026-06-20T10:00:00Z\"},{\"role\":\"breeder\",\"text\":\"Erste Antwort.\",\"at\":\"2026-06-20T11:00:00Z\"}]",
|
||||
});
|
||||
// A contact-scoped feedback report — the ContactId is a loose (FK-free) id,
|
||||
// so it must survive the contact-table wipe just like gerbil/litter ids.
|
||||
var contactFeedbackId = Guid.NewGuid();
|
||||
db.Feedback.Add(new Feedback
|
||||
{
|
||||
Id = contactFeedbackId,
|
||||
Message = "Adresse stimmt nicht.",
|
||||
Context = "contact-detail",
|
||||
ContactId = contactId,
|
||||
EntityName = "Test Breeder",
|
||||
Url = "http://localhost/kontakte/test-breeder",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", dir } })
|
||||
.Build();
|
||||
|
||||
// Run the ingest wipe + reload.
|
||||
var result = await new IngestResolvedService(db, config, null!).RunAsync();
|
||||
Assert.Contains("Ingestion successful!", result);
|
||||
|
||||
// Gerbils/litters/contacts were wiped & re-created, but feedback is untouched.
|
||||
var survivor = await db.Feedback.SingleAsync(f => f.Id == feedbackId);
|
||||
Assert.Equal(fatherId, survivor.GerbilId); // loose id preserved even though the gerbil row was deleted/recreated
|
||||
Assert.Equal(litterId, survivor.LitterId);
|
||||
Assert.Equal("Papa", survivor.EntityName);
|
||||
Assert.Equal("Answered", survivor.Status); // ticket status column survives the wipe
|
||||
// The new question/answer columns survive the wipe too.
|
||||
Assert.Equal("Welches Datum stimmt?", survivor.Question);
|
||||
Assert.Equal("Der 2. Juni.", survivor.Answer);
|
||||
Assert.NotNull(survivor.AnsweredAt);
|
||||
// The new fix-note / agent-context / thread columns survive the wipe too.
|
||||
Assert.Equal("Das Geburtsdatum von Papa wurde korrigiert.", survivor.FixNote);
|
||||
Assert.Equal("{\"plan\":\"awaiting DOB confirmation\"}", survivor.AgentContext);
|
||||
Assert.Contains("Erste Frage?", survivor.Thread);
|
||||
|
||||
// The contact-scoped report also survives the contacts wipe (loose ContactId).
|
||||
var contactSurvivor = await db.Feedback.SingleAsync(f => f.Id == contactFeedbackId);
|
||||
Assert.Equal(contactId, contactSurvivor.ContactId);
|
||||
Assert.Equal("contact-detail", contactSurvivor.Context);
|
||||
Assert.Equal("Test Breeder", contactSurvivor.EntityName);
|
||||
Assert.Equal(2, await db.Feedback.CountAsync());
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_single_feedback_by_id_and_status_filter()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
// Zwei Tickets mit unterscheidbaren Namen anlegen.
|
||||
var open = await client.PostAsJsonAsync("/feedback", new { message = "Offenes Ticket", context = "gerbil-detail", entityName = "FilterOpen" });
|
||||
var openId = JsonDocument.Parse(await open.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString()!;
|
||||
var other = await client.PostAsJsonAsync("/feedback", new { message = "Zweites Ticket", context = "gerbil-detail", entityName = "FilterOther" });
|
||||
var otherId = JsonDocument.Parse(await other.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString()!;
|
||||
|
||||
// Einzel-GET liefert genau dieses Ticket (mit attachments-Feld).
|
||||
var single = JsonDocument.Parse(await client.GetStringAsync($"/feedback/{openId}")).RootElement;
|
||||
Assert.Equal("FilterOpen", single.GetProperty("entityName").GetString());
|
||||
Assert.Equal(JsonValueKind.Array, single.GetProperty("attachments").ValueKind);
|
||||
|
||||
// Unbekannte Id -> 404.
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/feedback/{Guid.NewGuid()}")).StatusCode);
|
||||
|
||||
// Zweites Ticket auf Answered setzen, dann Status-Filter prüfen.
|
||||
await client.PutAsJsonAsync($"/feedback/{otherId}", new { status = "Answered" });
|
||||
var answered = JsonDocument.Parse(await client.GetStringAsync("/feedback?status=Answered")).RootElement;
|
||||
Assert.Contains(answered.EnumerateArray(), f => f.GetProperty("id").GetString() == otherId);
|
||||
Assert.DoesNotContain(answered.EnumerateArray(), f => f.GetProperty("id").GetString() == openId);
|
||||
|
||||
// Kombinierter Filter (Open,Answered) enthält beide.
|
||||
var both = JsonDocument.Parse(await client.GetStringAsync("/feedback?status=Open,Answered")).RootElement;
|
||||
var ids = both.EnumerateArray().Select(f => f.GetProperty("id").GetString()).ToHashSet();
|
||||
Assert.Contains(openId, ids);
|
||||
Assert.Contains(otherId, ids);
|
||||
}
|
||||
|
||||
private static object Animal(Guid id, string name, string gender, Guid contactId) => new
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Gender = gender,
|
||||
Status = "Breeding",
|
||||
LitterId = (Guid?)null,
|
||||
OriginContactId = contactId,
|
||||
ReceiverContactId = (Guid?)null,
|
||||
EnclosureId = (Guid?)null,
|
||||
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"),
|
||||
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-" + name,
|
||||
RawImportData = "{}",
|
||||
OriginBreeder = "Test Zucht",
|
||||
NameSearch = name.ToLowerInvariant(),
|
||||
CharacterTraits = Array.Empty<string>(),
|
||||
CharacterNote = (string?)null,
|
||||
IsDeaf = false,
|
||||
IsResident = true,
|
||||
};
|
||||
}
|
||||
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>
|
||||
188
GerbilManager.Tests/GerbilParentsTests.cs
Normal file
188
GerbilManager.Tests/GerbilParentsTests.cs
Normal file
@@ -0,0 +1,188 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// QOL-ELTERN: PUT /gerbils/{id}/parents — Eltern direkt an der Tier-Akte pflegen.
|
||||
/// Das Datenmodell bleibt unverändert (Eltern hängen am Geburtswurf), der Endpoint
|
||||
/// schreibt nur dorthin bzw. legt den Träger-Wurf an, wenn es noch keinen gibt.
|
||||
/// </summary>
|
||||
public class GerbilParentsTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
public GerbilParentsTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||
|
||||
private async Task<string> CreateGerbil(string name, string gender, string? dob = null, string? litterId = null)
|
||||
{
|
||||
var resp = await _client.PostAsJsonAsync("/gerbils", new
|
||||
{
|
||||
name,
|
||||
gender,
|
||||
dateOfBirth = dob,
|
||||
litterId,
|
||||
});
|
||||
resp.EnsureSuccessStatusCode();
|
||||
return JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement
|
||||
.GetProperty("id").GetString()!;
|
||||
}
|
||||
|
||||
private async Task<string> CreateLitter(string name, string date, string? fatherId = null, string? motherId = null)
|
||||
{
|
||||
var resp = await _client.PostAsJsonAsync("/litters", new { name, date, fatherId, motherId });
|
||||
resp.EnsureSuccessStatusCode();
|
||||
return JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement
|
||||
.GetProperty("id").GetString()!;
|
||||
}
|
||||
|
||||
private async Task<JsonElement> SetParents(string gerbilId, string? fatherId, string? motherId)
|
||||
{
|
||||
var resp = await _client.PutAsJsonAsync($"/gerbils/{gerbilId}/parents", new { fatherId, motherId });
|
||||
resp.EnsureSuccessStatusCode();
|
||||
return JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||
}
|
||||
|
||||
private async Task<JsonElement> GetLitter(string litterId) =>
|
||||
JsonDocument.Parse(await _client.GetStringAsync($"/litters/{litterId}")).RootElement;
|
||||
|
||||
private static string? Str(JsonElement el, string prop) =>
|
||||
el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||||
|
||||
[Fact]
|
||||
public async Task Parents_of_existing_litter_are_written_to_that_litter()
|
||||
{
|
||||
var father = await CreateGerbil("Vater A", "male", "2024-01-01");
|
||||
var mother = await CreateGerbil("Mutter A", "female", "2024-01-01");
|
||||
var litterId = await CreateLitter("A-Wurf", "2025-03-01");
|
||||
var pup = await CreateGerbil("Jungtier A", "female", "2025-03-01", litterId);
|
||||
|
||||
var result = await SetParents(pup, father, mother);
|
||||
|
||||
Assert.Equal(litterId, Str(result, "litterId"));
|
||||
Assert.False(result.GetProperty("litterCreated").GetBoolean());
|
||||
Assert.False(result.GetProperty("litterAttached").GetBoolean());
|
||||
var litter = await GetLitter(litterId);
|
||||
Assert.Equal(father, Str(litter, "fatherId"));
|
||||
Assert.Equal(mother, Str(litter, "motherId"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SiblingCount_reports_the_other_pups_the_change_applies_to()
|
||||
{
|
||||
var litterId = await CreateLitter("B-Wurf", "2025-04-01");
|
||||
var pup = await CreateGerbil("Jungtier B1", "female", "2025-04-01", litterId);
|
||||
await CreateGerbil("Jungtier B2", "male", "2025-04-01", litterId);
|
||||
await CreateGerbil("Jungtier B3", "male", "2025-04-01", litterId);
|
||||
var mother = await CreateGerbil("Mutter B", "female", "2024-02-01");
|
||||
|
||||
var result = await SetParents(pup, null, mother);
|
||||
|
||||
Assert.Equal(2, result.GetProperty("siblingCount").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gerbil_without_litter_gets_a_hidden_carrier_litter()
|
||||
{
|
||||
var father = await CreateGerbil("Vater C", "male", "2024-03-01");
|
||||
var mother = await CreateGerbil("Mutter C", "female", "2024-03-01");
|
||||
var orphan = await CreateGerbil("Waise C", "female", "2025-05-01");
|
||||
|
||||
var result = await SetParents(orphan, father, mother);
|
||||
|
||||
Assert.True(result.GetProperty("litterCreated").GetBoolean());
|
||||
var litterId = Str(result, "litterId")!;
|
||||
Assert.Equal("Wurf von Vater C + Mutter C", Str(result, "litterName"));
|
||||
|
||||
var litter = await GetLitter(litterId);
|
||||
Assert.Equal(father, Str(litter, "fatherId"));
|
||||
Assert.Equal(mother, Str(litter, "motherId"));
|
||||
// Nur zur Abstammung — der Hilfs-Wurf darf die Wurfchronik nicht zumüllen.
|
||||
Assert.False(litter.GetProperty("showInChronicle").GetBoolean());
|
||||
// Wurfdatum = Geburtsdatum des Tiers.
|
||||
Assert.Equal("2025-05-01", Str(litter, "date"));
|
||||
|
||||
// Das Tier hängt jetzt an diesem Wurf.
|
||||
var gerbil = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{orphan}")).RootElement;
|
||||
Assert.Equal(litterId, Str(gerbil, "litterId"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Same_parents_and_same_dob_attach_to_the_existing_litter_instead_of_duplicating()
|
||||
{
|
||||
var father = await CreateGerbil("Vater D", "male", "2024-04-01");
|
||||
var mother = await CreateGerbil("Mutter D", "female", "2024-04-01");
|
||||
var first = await CreateGerbil("Waise D1", "female", "2025-06-01");
|
||||
var second = await CreateGerbil("Waise D2", "male", "2025-06-01");
|
||||
|
||||
var created = await SetParents(first, father, mother);
|
||||
Assert.True(created.GetProperty("litterCreated").GetBoolean());
|
||||
|
||||
var attached = await SetParents(second, father, mother);
|
||||
Assert.False(attached.GetProperty("litterCreated").GetBoolean());
|
||||
Assert.True(attached.GetProperty("litterAttached").GetBoolean());
|
||||
// Beide sind jetzt Geschwister im selben Wurf.
|
||||
Assert.Equal(Str(created, "litterId"), Str(attached, "litterId"));
|
||||
Assert.Equal(1, attached.GetProperty("siblingCount").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clearing_parents_nulls_them_on_the_litter()
|
||||
{
|
||||
var father = await CreateGerbil("Vater E", "male", "2024-05-01");
|
||||
var litterId = await CreateLitter("E-Wurf", "2025-07-01", father);
|
||||
var pup = await CreateGerbil("Jungtier E", "female", "2025-07-01", litterId);
|
||||
|
||||
await SetParents(pup, null, null);
|
||||
|
||||
var litter = await GetLitter(litterId);
|
||||
Assert.Equal(JsonValueKind.Null, litter.GetProperty("fatherId").ValueKind);
|
||||
Assert.Equal(JsonValueKind.Null, litter.GetProperty("motherId").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task No_litter_and_no_parents_is_a_no_op()
|
||||
{
|
||||
var orphan = await CreateGerbil("Waise F", "female", "2025-08-01");
|
||||
|
||||
var result = await SetParents(orphan, null, null);
|
||||
|
||||
Assert.Equal(JsonValueKind.Null, result.GetProperty("litterId").ValueKind);
|
||||
Assert.False(result.GetProperty("litterCreated").GetBoolean());
|
||||
var gerbil = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{orphan}")).RootElement;
|
||||
Assert.Equal(JsonValueKind.Null, gerbil.GetProperty("litterId").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wrong_gender_parent_returns_400_InvalidParentGender()
|
||||
{
|
||||
var female = await CreateGerbil("Weibchen G", "female", "2024-06-01");
|
||||
var pup = await CreateGerbil("Jungtier G", "male", "2025-09-01");
|
||||
|
||||
var resp = await _client.PutAsJsonAsync($"/gerbils/{pup}/parents",
|
||||
new { fatherId = female, motherId = (string?)null });
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
Assert.Contains("InvalidParentGender", await resp.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Self_as_parent_returns_400()
|
||||
{
|
||||
var g = await CreateGerbil("Selbstbezug H", "male", "2025-10-01");
|
||||
|
||||
var resp = await _client.PutAsJsonAsync($"/gerbils/{g}/parents",
|
||||
new { fatherId = g, motherId = (string?)null });
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_gerbil_returns_404()
|
||||
{
|
||||
var resp = await _client.PutAsJsonAsync($"/gerbils/{Guid.NewGuid()}/parents",
|
||||
new { fatherId = (string?)null, motherId = (string?)null });
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
61
GerbilManager.Tests/ImportIngestUploadEndpointTests.cs
Normal file
61
GerbilManager.Tests/ImportIngestUploadEndpointTests.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// POST /import/ingest-resolved/upload — Upload-Variante des Ingests: eine hochgeladene
|
||||
/// resolved_import.json wird serverseitig in den Import-Quellordner gestaged und direkt ingested.
|
||||
/// Damit lässt sich der Import gegen PROD fahren, ohne die Datei per SSH/docker cp in den Container
|
||||
/// zu kopieren. Der Test isoliert Quell-/Fotoordner über ApiFactory in Temp-Verzeichnisse.
|
||||
/// </summary>
|
||||
public class ImportIngestUploadEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Upload_stages_resolved_json_and_runs_ingest()
|
||||
{
|
||||
var srcDir = Path.Combine(Path.GetTempPath(), "ingest-upload-" + Guid.NewGuid().ToString("N"));
|
||||
var photoDir = Path.Combine(Path.GetTempPath(), "ingest-upload-photos-" + Guid.NewGuid().ToString("N"));
|
||||
using var factory = new ApiFactory { ImportSourcePath = srcDir, PhotosRootPath = photoDir };
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Minimaler, aber valider Payload (leere Sammlungen) — genügt, um Staging + Ingest-Lauf zu prüfen.
|
||||
const string json = "{\"Gerbils\":[],\"Litters\":[],\"Contacts\":[],\"SaleContracts\":[],\"GerbilPhotos\":[]}";
|
||||
|
||||
using var form = new MultipartFormDataContent();
|
||||
var file = new ByteArrayContent(Encoding.UTF8.GetBytes(json));
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
||||
form.Add(file, "resolved", "resolved_import.json");
|
||||
|
||||
var resp = await client.PostAsync("/import/ingest-resolved/upload", form);
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
Assert.DoesNotContain("Error:", body); // Ingest lief, keine "file not found"-Fehlermeldung
|
||||
|
||||
// Die hochgeladene Datei wurde an den erwarteten Ort gestaged.
|
||||
Assert.True(File.Exists(Path.Combine(srcDir, "resolved_import.json")));
|
||||
|
||||
try { Directory.Delete(srcDir, recursive: true); } catch { /* best effort */ }
|
||||
try { Directory.Delete(photoDir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_rejects_empty_file_with_400()
|
||||
{
|
||||
var srcDir = Path.Combine(Path.GetTempPath(), "ingest-upload-" + Guid.NewGuid().ToString("N"));
|
||||
using var factory = new ApiFactory { ImportSourcePath = srcDir };
|
||||
var client = factory.CreateClient();
|
||||
|
||||
using var form = new MultipartFormDataContent();
|
||||
var file = new ByteArrayContent(Array.Empty<byte>());
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
||||
form.Add(file, "resolved", "resolved_import.json");
|
||||
|
||||
var resp = await client.PostAsync("/import/ingest-resolved/upload", form);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
|
||||
try { Directory.Delete(srcDir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
342
GerbilManager.Tests/IngestResolvedServiceTests.cs
Normal file
342
GerbilManager.Tests/IngestResolvedServiceTests.cs
Normal file
@@ -0,0 +1,342 @@
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
public class IngestResolvedServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _dir;
|
||||
private readonly string _resolvedJsonPath;
|
||||
private Guid ContactId { get; set; }
|
||||
private Guid FatherId { get; set; }
|
||||
private Guid ContractId { get; set; }
|
||||
private Guid UnknownGerbilId { get; set; }
|
||||
|
||||
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();
|
||||
ContactId = contactId;
|
||||
FatherId = fatherId;
|
||||
ContractId = Guid.NewGuid();
|
||||
UnknownGerbilId = 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 = "",
|
||||
Provenance = (string?)"{\"sourceFiles\":[\"Stammbaum\"],\"mergedRecordCount\":1,\"notes\":[\"als Züchter erkannt\"]}"
|
||||
}
|
||||
},
|
||||
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",
|
||||
Provenance = (string?)"{\"sourceFiles\":[\"Wurfchronik-Detail.docx\"],\"mergedRecordCount\":1,\"fromWurfchronik\":true,\"notes\":[\"aus Wurfchronik\"]}"
|
||||
}
|
||||
},
|
||||
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,
|
||||
Provenance = (string?)"{\"sourceFiles\":[\"Stammbaum von Papa.xlsx\",\"Wurfchronik-Detail.docx\"],\"mergedRecordCount\":2,\"fromWurfchronik\":true,\"notes\":[\"aus 2 Datensätzen zusammengeführt\"],\"history\":[\"In \\u201eStammbaum von Papa.xlsx\\u201c gefunden.\",\"Genotyp aus \\u201eWurfchronik-Detail.docx\\u201c.\"]}"
|
||||
},
|
||||
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,
|
||||
Provenance = (string?)null
|
||||
}
|
||||
},
|
||||
GerbilPhotos = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
Id = photoId,
|
||||
GerbilId = fatherId,
|
||||
FileName = "photo1.jpg",
|
||||
SortOrder = 0
|
||||
}
|
||||
},
|
||||
SaleContracts = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
Id = ContractId,
|
||||
ContactId = contactId,
|
||||
Price = 35.0m,
|
||||
HandoverDate = "2023-09-29",
|
||||
ContractDate = "2023-09-29",
|
||||
FileName = "Zucht der kleinen Chaoten _ (Papa) - Test Breeder_.docx",
|
||||
// One known animal + one unknown id (must be skipped defensively).
|
||||
Animals = new[] { fatherId, UnknownGerbilId }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
// Provenance round-trips verbatim through ingest (and stays null when absent).
|
||||
Assert.NotNull(father.Provenance);
|
||||
Assert.Contains("Stammbaum von Papa.xlsx", father.Provenance);
|
||||
Assert.Contains("mergedRecordCount", father.Provenance);
|
||||
// The chronological, file-attributed history survives ingest verbatim
|
||||
// (stored as opaque JSON on the text column — no schema for it).
|
||||
Assert.Contains("history", father.Provenance);
|
||||
Assert.Contains("Genotyp aus", father.Provenance);
|
||||
Assert.Null(mother.Provenance);
|
||||
|
||||
// Contact + litter provenance also round-trips through the ingest.
|
||||
var contact = await db.Contacts.SingleAsync();
|
||||
Assert.NotNull(contact.Provenance);
|
||||
Assert.Contains("als Züchter erkannt", contact.Provenance);
|
||||
|
||||
Assert.NotNull(litter.Provenance);
|
||||
Assert.Contains("aus Wurfchronik", litter.Provenance);
|
||||
Assert.Contains("fromWurfchronik", litter.Provenance);
|
||||
|
||||
// SaleContracts are created from the payload, and animal links to
|
||||
// unknown gerbils are skipped defensively (only the known father links).
|
||||
Assert.Equal(1, await db.SaleContracts.CountAsync());
|
||||
var contract = await db.SaleContracts.Include(c => c.Animals).SingleAsync();
|
||||
Assert.Equal(ContractId, contract.Id);
|
||||
Assert.Equal(ContactId, contract.ContactId);
|
||||
Assert.Equal(35.0m, contract.Price);
|
||||
Assert.Equal(new DateOnly(2023, 9, 29), contract.HandoverDate);
|
||||
var link = Assert.Single(contract.Animals);
|
||||
Assert.Equal(FatherId, link.GerbilId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestResolved_recreates_sale_contracts_on_every_ingest()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
{ "Import:SourcePath", _dir }
|
||||
})
|
||||
.Build();
|
||||
|
||||
var service = new IngestResolvedService(db, config, null!);
|
||||
await service.RunAsync();
|
||||
Assert.Equal(1, await db.SaleContracts.CountAsync());
|
||||
|
||||
// Re-ingest wipes then recreates from the (deterministic) payload —
|
||||
// the contract survives by being part of the payload, no duplicate PK.
|
||||
await service.RunAsync();
|
||||
Assert.Equal(1, await db.SaleContracts.CountAsync());
|
||||
var contract = await db.SaleContracts.Include(c => c.Animals).SingleAsync();
|
||||
Assert.Equal(ContractId, contract.Id);
|
||||
Assert.Single(contract.Animals);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestResolved_rederives_status_after_deceased_override()
|
||||
{
|
||||
// Ticket 37ab228a "Gaida": ein verstorbenes Tier blieb nach dem Ingest auf Status
|
||||
// 'Breeding' hängen, weil der Freeze das Todesdatum aus dem Override zurückschrieb,
|
||||
// ohne den Status neu abzuleiten. Der Ingest muss GerbilStatusService.Apply erneut aufrufen.
|
||||
using var db = NewDb();
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", _dir } })
|
||||
.Build();
|
||||
|
||||
db.GerbilOverrides.Add(new GerbilOverride
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GerbilId = FatherId,
|
||||
EntityName = "Papa",
|
||||
IsVerified = false, // "geschützt" — wird trotzdem angewandt
|
||||
OverrideJson = "{\"dateOfDeath\":\"2025-10-09\"}",
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await new IngestResolvedService(db, config, null!).RunAsync();
|
||||
|
||||
var father = await db.Gerbils.SingleAsync(g => g.Id == FatherId);
|
||||
Assert.NotNull(father.DateOfDeath); // Todesdatum aus Override angewandt
|
||||
Assert.Equal(GerbilStatus.Deceased, father.Status); // ... und Status daraus abgeleitet
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Setzt bzw. entfernt die Scheckungsart im gestageten Payload (Testdaten werden hier
|
||||
/// selbst aufgebaut — der Test hängt bewusst an keiner echten conflict-decisions.json).
|
||||
/// </summary>
|
||||
private void SetPayloadSpottingType(string? value, bool remove = false)
|
||||
{
|
||||
var root = JsonNode.Parse(File.ReadAllText(_resolvedJsonPath))!;
|
||||
var papa = root["Gerbils"]!.AsArray()
|
||||
.First(g => (string?)g!["Name"] == "Papa")!.AsObject();
|
||||
if (remove) papa.Remove("SpottingType");
|
||||
else papa["SpottingType"] = value;
|
||||
File.WriteAllText(_resolvedJsonPath, root.ToJsonString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestResolved_writes_spotting_type_and_keeps_it_when_payload_has_none()
|
||||
{
|
||||
// Ticket a547be62 (Eliza): Die Scheckungsart kennt der Importer nur über
|
||||
// conflict-decisions.json — sie MUSS also durch den Ingest getragen werden.
|
||||
// Umgekehrt darf ein leerer Payload-Wert eine von Hand in der App erfasste
|
||||
// Scheckungsart NICHT nullen (der Importer weiß von ihr nichts).
|
||||
using var db = NewDb();
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", _dir } })
|
||||
.Build();
|
||||
|
||||
// (1) Neues Tier: Wert aus dem Payload landet in der DB.
|
||||
SetPayloadSpottingType("Ansatzschecke");
|
||||
await new IngestResolvedService(db, config, null!).RunAsync();
|
||||
var father = await db.Gerbils.SingleAsync(g => g.Id == FatherId);
|
||||
Assert.Equal("Ansatzschecke", father.SpottingType);
|
||||
|
||||
// (2) Re-Ingest, Feld fehlt im Payload → bestehender Wert bleibt stehen.
|
||||
SetPayloadSpottingType(null, remove: true);
|
||||
await new IngestResolvedService(db, config, null!).RunAsync();
|
||||
father = await db.Gerbils.SingleAsync(g => g.Id == FatherId);
|
||||
Assert.Equal("Ansatzschecke", father.SpottingType);
|
||||
|
||||
// (3) Re-Ingest mit explizitem null → ebenfalls kein Nullen (Guard im Update-Zweig).
|
||||
SetPayloadSpottingType(null);
|
||||
await new IngestResolvedService(db, config, null!).RunAsync();
|
||||
father = await db.Gerbils.SingleAsync(g => g.Id == FatherId);
|
||||
Assert.Equal("Ansatzschecke", father.SpottingType);
|
||||
|
||||
// (4) Ein NEUER Wert aus dem Payload gewinnt weiterhin (Entscheidung korrigiert).
|
||||
SetPayloadSpottingType("Mantelschecke");
|
||||
await new IngestResolvedService(db, config, null!).RunAsync();
|
||||
father = await db.Gerbils.SingleAsync(g => g.Id == FatherId);
|
||||
Assert.Equal("Mantelschecke", father.SpottingType);
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
44
GerbilManager.Tests/PushEndpointTests.cs
Normal file
44
GerbilManager.Tests/PushEndpointTests.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WEB-PUSH: VAPID-Public-Key abrufen + Abo speichern/entfernen.
|
||||
/// (Das tatsächliche Versenden wird nicht getestet — es geht an externe Push-Dienste.)
|
||||
/// </summary>
|
||||
public class PushEndpointTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
public PushEndpointTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Vapid_public_key_is_exposed()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var doc = JsonDocument.Parse(await client.GetStringAsync("/push/vapid-public-key")).RootElement;
|
||||
Assert.True(doc.GetProperty("enabled").GetBoolean());
|
||||
Assert.False(string.IsNullOrWhiteSpace(doc.GetProperty("publicKey").GetString()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_is_idempotent_and_unsubscribe_works()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var endpoint = $"https://push.example.com/{Guid.NewGuid()}";
|
||||
var body = new { endpoint, p256dh = "abc123", auth = "def456" };
|
||||
|
||||
var first = await client.PostAsJsonAsync("/push/subscribe", body);
|
||||
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
||||
// Erneutes Abo mit derselben Endpoint-URL -> weiterhin OK (Upsert, kein Duplikat).
|
||||
var second = await client.PostAsJsonAsync("/push/subscribe", body);
|
||||
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
|
||||
|
||||
var bad = await client.PostAsJsonAsync("/push/subscribe", new { endpoint = "", p256dh = "", auth = "" });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode);
|
||||
|
||||
var unsub = await client.PostAsJsonAsync("/push/unsubscribe", new { endpoint });
|
||||
Assert.Equal(HttpStatusCode.NoContent, unsub.StatusCode);
|
||||
}
|
||||
}
|
||||
123
GerbilManager.Tests/RefsEndpointTests.cs
Normal file
123
GerbilManager.Tests/RefsEndpointTests.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// REFS: POST /refs/resolve löst getippte Kurz-Verweise (tier|kontakt|wurf|gehege) auf.
|
||||
/// - exakter (volle GUID) und kurzer (Präfix) Treffer je Typ
|
||||
/// - mehrdeutiges Präfix (mehrere Treffer) => id=null (nicht geraten)
|
||||
/// - unbekannter Code / unbekannter Typ => id=null
|
||||
/// Entitäten werden mit deterministischen GUIDs direkt in die SQLite-Test-DB geseedet,
|
||||
/// damit Präfixe und Mehrdeutigkeit kontrolliert getestet werden können.
|
||||
/// </summary>
|
||||
public class RefsEndpointTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
public RefsEndpointTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
// Deterministische GUIDs: zwei Tiere mit GEMEINSAMEM 6-Hex-Präfix "abcdef" (mehrdeutig),
|
||||
// sowie je eine eindeutige Entität pro Typ.
|
||||
private static readonly Guid TierA = new("abcdef00-0000-0000-0000-000000000001");
|
||||
private static readonly Guid TierB = new("abcdef00-0000-0000-0000-000000000002");
|
||||
private static readonly Guid Kontakt = new("11112222-3333-4444-5555-666677778888");
|
||||
private static readonly Guid Wurf = new("99990000-aaaa-bbbb-cccc-ddddeeeeffff");
|
||||
private static readonly Guid Gehege = new("12345678-9abc-def0-1234-567890abcdef");
|
||||
|
||||
private async Task SeedAsync()
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
|
||||
if (db.Gerbils.Any(g => g.Id == TierA)) return; // einmalig (geteilte Fixture)
|
||||
|
||||
db.Gerbils.Add(new Gerbil { Id = TierA, Name = "Krümel", Gender = Gender.female });
|
||||
db.Gerbils.Add(new Gerbil { Id = TierB, Name = "Möhre", Gender = Gender.male });
|
||||
db.Contacts.Add(new Contact { Id = Kontakt, Name = "Erika Musterfrau" });
|
||||
db.Litters.Add(new Litter { Id = Wurf, Name = "Wurf X", Date = new DateOnly(2026, 1, 1) });
|
||||
db.Enclosures.Add(new Enclosure { Id = Gehege, Name = "Terrarium 1" });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task<List<JsonElement>> Resolve(params object[] refs)
|
||||
{
|
||||
var resp = await _factory.CreateClient()
|
||||
.PostAsJsonAsync("/refs/resolve", new { refs });
|
||||
resp.EnsureSuccessStatusCode();
|
||||
var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
|
||||
return doc.RootElement.EnumerateArray().ToList();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolves_full_guid_per_type()
|
||||
{
|
||||
await SeedAsync();
|
||||
var rows = await Resolve(
|
||||
new { type = "tier", code = TierA.ToString() },
|
||||
new { type = "kontakt", code = Kontakt.ToString() },
|
||||
new { type = "wurf", code = Wurf.ToString() },
|
||||
new { type = "gehege", code = Gehege.ToString() });
|
||||
|
||||
Assert.Equal(TierA.ToString(), rows[0].GetProperty("id").GetString());
|
||||
Assert.Equal("Krümel", rows[0].GetProperty("name").GetString());
|
||||
Assert.Equal(Kontakt.ToString(), rows[1].GetProperty("id").GetString());
|
||||
Assert.Equal("Erika Musterfrau", rows[1].GetProperty("name").GetString());
|
||||
Assert.Equal(Wurf.ToString(), rows[2].GetProperty("id").GetString());
|
||||
Assert.Equal("Wurf X", rows[2].GetProperty("name").GetString());
|
||||
Assert.Equal(Gehege.ToString(), rows[3].GetProperty("id").GetString());
|
||||
Assert.Equal("Terrarium 1", rows[3].GetProperty("name").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolves_short_prefix_when_unique()
|
||||
{
|
||||
await SeedAsync();
|
||||
// 8-Hex-Kurz-ID des Kontakts ("11112222") ist eindeutig.
|
||||
var rows = await Resolve(new { type = "kontakt", code = "11112222" });
|
||||
Assert.Equal(Kontakt.ToString(), rows[0].GetProperty("id").GetString());
|
||||
Assert.Equal("Erika Musterfrau", rows[0].GetProperty("name").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tolerates_dashes_and_case_in_code()
|
||||
{
|
||||
await SeedAsync();
|
||||
// Gemischte Schreibweise + Bindestriche im Kurz-Code des Geheges.
|
||||
var rows = await Resolve(new { type = "gehege", code = "1234-5678" });
|
||||
Assert.Equal(Gehege.ToString(), rows[0].GetProperty("id").GetString());
|
||||
Assert.Equal("Terrarium 1", rows[0].GetProperty("name").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ambiguous_prefix_resolves_to_null()
|
||||
{
|
||||
await SeedAsync();
|
||||
// "abcdef" trifft TierA UND TierB => mehrdeutig => id/name null.
|
||||
var rows = await Resolve(new { type = "tier", code = "abcdef" });
|
||||
Assert.Equal(JsonValueKind.Null, rows[0].GetProperty("id").ValueKind);
|
||||
Assert.Equal(JsonValueKind.Null, rows[0].GetProperty("name").ValueKind);
|
||||
Assert.Equal("tier", rows[0].GetProperty("type").GetString());
|
||||
Assert.Equal("abcdef", rows[0].GetProperty("code").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_code_and_unknown_type_resolve_to_null()
|
||||
{
|
||||
await SeedAsync();
|
||||
var rows = await Resolve(
|
||||
new { type = "tier", code = "fffffffe" }, // unbekannter Code
|
||||
new { type = "schubladen", code = "11112222" }); // unbekannter Typ
|
||||
Assert.Equal(JsonValueKind.Null, rows[0].GetProperty("id").ValueKind);
|
||||
Assert.Equal(JsonValueKind.Null, rows[1].GetProperty("id").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Too_short_code_resolves_to_null()
|
||||
{
|
||||
await SeedAsync();
|
||||
// < 6 Hex-Zeichen wird nicht aufgelöst (auch wenn es eindeutig wäre).
|
||||
var rows = await Resolve(new { type = "kontakt", code = "1111" });
|
||||
Assert.Equal(JsonValueKind.Null, rows[0].GetProperty("id").ValueKind);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
208
GerbilManager.Tests/ReturnRecordEndpointTests.cs
Normal file
208
GerbilManager.Tests/ReturnRecordEndpointTests.cs
Normal file
@@ -0,0 +1,208 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// RÜCKNAHMEN (getback_tb): zurückgekommene/zurückgenommene Tiere.
|
||||
/// - Voller CRUD-Round-Trip (POST/GET[?gerbilId=]/PUT/DELETE).
|
||||
/// - Validierung: ohne Tier (weder GerbilId noch GerbilName) → 400.
|
||||
/// - CRITICAL: Rücknahme-Zeilen überleben den Import-Re-Ingest-Wipe
|
||||
/// (lose, FK-freie GerbilId/FromContactId — wie Feedback).
|
||||
/// </summary>
|
||||
public class ReturnRecordEndpointTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
public ReturnRecordEndpointTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Crud_round_trip_create_list_update_delete()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var gerbilId = Guid.NewGuid();
|
||||
var contactId = Guid.NewGuid();
|
||||
|
||||
// POST
|
||||
var resp = await client.PostAsJsonAsync("/returns", new
|
||||
{
|
||||
gerbilId,
|
||||
gerbilName = "Krümel",
|
||||
returnDate = "2026-05-01T00:00:00Z",
|
||||
returnPrice = 0m,
|
||||
originalPrice = 15m,
|
||||
originalSaleDate = "2025-09-01T00:00:00Z",
|
||||
fromContactId = contactId,
|
||||
fromContactName = "Familie Meier",
|
||||
note = "Allergie in der Familie",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
var created = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||
var id = created.GetProperty("id").GetString()!;
|
||||
Assert.Equal("Krümel", created.GetProperty("gerbilName").GetString());
|
||||
Assert.Equal(gerbilId.ToString(), created.GetProperty("gerbilId").GetString());
|
||||
Assert.Equal("Familie Meier", created.GetProperty("fromContactName").GetString());
|
||||
|
||||
// GET (unfiltered) returns it
|
||||
var listed = JsonDocument.Parse(await client.GetStringAsync("/returns")).RootElement;
|
||||
Assert.Contains(listed.EnumerateArray(), r => r.GetProperty("id").GetString() == id);
|
||||
|
||||
// GET ?gerbilId= filters
|
||||
var filtered = JsonDocument.Parse(
|
||||
await client.GetStringAsync($"/returns?gerbilId={gerbilId}")).RootElement;
|
||||
Assert.All(filtered.EnumerateArray(),
|
||||
r => Assert.Equal(gerbilId.ToString(), r.GetProperty("gerbilId").GetString()));
|
||||
Assert.Contains(filtered.EnumerateArray(), r => r.GetProperty("id").GetString() == id);
|
||||
|
||||
var other = JsonDocument.Parse(
|
||||
await client.GetStringAsync($"/returns?gerbilId={Guid.NewGuid()}")).RootElement;
|
||||
Assert.Empty(other.EnumerateArray());
|
||||
|
||||
// PUT updates
|
||||
var putResp = await client.PutAsJsonAsync($"/returns/{id}", new
|
||||
{
|
||||
gerbilId,
|
||||
gerbilName = "Krümel",
|
||||
returnDate = "2026-05-02T00:00:00Z",
|
||||
returnPrice = 5m,
|
||||
note = "Korrigierter Grund",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.OK, putResp.StatusCode);
|
||||
var updated = JsonDocument.Parse(await putResp.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("Korrigierter Grund", updated.GetProperty("note").GetString());
|
||||
Assert.Equal(5m, updated.GetProperty("returnPrice").GetDecimal());
|
||||
|
||||
// DELETE removes
|
||||
var delResp = await client.DeleteAsync($"/returns/{id}");
|
||||
Assert.Equal(HttpStatusCode.NoContent, delResp.StatusCode);
|
||||
var afterDelete = JsonDocument.Parse(await client.GetStringAsync("/returns")).RootElement;
|
||||
Assert.DoesNotContain(afterDelete.EnumerateArray(), r => r.GetProperty("id").GetString() == id);
|
||||
|
||||
// DELETE again → 404
|
||||
var delAgain = await client.DeleteAsync($"/returns/{id}");
|
||||
Assert.Equal(HttpStatusCode.NotFound, delAgain.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Post_rejects_record_without_animal()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var resp = await client.PostAsJsonAsync("/returns", new { note = "irgendwas" });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_unknown_id_returns_404()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var resp = await client.PutAsJsonAsync($"/returns/{Guid.NewGuid()}", new { gerbilName = "X" });
|
||||
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnRecord_survives_ingest_wipe()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "returns-ingest-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var contactId = Guid.NewGuid();
|
||||
var fatherId = Guid.NewGuid();
|
||||
var motherId = Guid.NewGuid();
|
||||
var litterId = Guid.NewGuid();
|
||||
|
||||
var data = new
|
||||
{
|
||||
Contacts = new[]
|
||||
{
|
||||
new { Id = contactId, Name = "Test Breeder", Email = "t@e.de", Phone = "", Address = "", Notes = (string?)null, IsBreeder = true, IsReceiver = false, NameSuffix = (string?)null, Provenance = (string?)null }
|
||||
},
|
||||
Litters = new[]
|
||||
{
|
||||
new { Id = litterId, Name = "Wurf A", Date = "2026-01-01", TotalBorn = 5, DeathsWithin8Weeks = 0, FatherId = fatherId, MotherId = motherId, ExpectedGoHomeDate = (string?)null, Notes = "", PairingCode = "PC01", ExternalRef = "ext-litter-1", LitterLetter = "A" }
|
||||
},
|
||||
Gerbils = new[]
|
||||
{
|
||||
Animal(fatherId, "Papa", "male", contactId),
|
||||
Animal(motherId, "Mama", "female", contactId),
|
||||
},
|
||||
GerbilPhotos = Array.Empty<object>(),
|
||||
};
|
||||
File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(data));
|
||||
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseInMemoryDatabase("returns-ingest-" + Guid.NewGuid().ToString("N"))
|
||||
.Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated();
|
||||
|
||||
// A return record referencing the gerbil + contact that the wipe will delete.
|
||||
var recordId = Guid.NewGuid();
|
||||
db.ReturnRecords.Add(new ReturnRecord
|
||||
{
|
||||
Id = recordId,
|
||||
GerbilId = fatherId,
|
||||
GerbilName = "Papa",
|
||||
ReturnDate = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
ReturnPrice = 0m,
|
||||
FromContactId = contactId,
|
||||
FromContactName = "Test Breeder",
|
||||
Note = "kam zurück",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", dir } })
|
||||
.Build();
|
||||
|
||||
var result = await new IngestResolvedService(db, config, null!).RunAsync();
|
||||
Assert.Contains("Ingestion successful!", result);
|
||||
|
||||
// The gerbils/contacts were wiped & recreated, but the return record is untouched.
|
||||
var survivor = await db.ReturnRecords.SingleAsync(r => r.Id == recordId);
|
||||
Assert.Equal(fatherId, survivor.GerbilId); // loose id preserved even though the gerbil row was deleted/recreated
|
||||
Assert.Equal(contactId, survivor.FromContactId);
|
||||
Assert.Equal("Papa", survivor.GerbilName);
|
||||
Assert.Equal("Test Breeder", survivor.FromContactName);
|
||||
Assert.Equal(1, await db.ReturnRecords.CountAsync());
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static object Animal(Guid id, string name, string gender, Guid contactId) => new
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Gender = gender,
|
||||
Status = "Breeding",
|
||||
LitterId = (Guid?)null,
|
||||
OriginContactId = contactId,
|
||||
ReceiverContactId = (Guid?)null,
|
||||
EnclosureId = (Guid?)null,
|
||||
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"),
|
||||
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-" + name,
|
||||
RawImportData = "{}",
|
||||
OriginBreeder = "Test Zucht",
|
||||
NameSearch = name.ToLowerInvariant(),
|
||||
CharacterTraits = Array.Empty<string>(),
|
||||
CharacterNote = (string?)null,
|
||||
IsDeaf = false,
|
||||
IsResident = true,
|
||||
};
|
||||
}
|
||||
407
GerbilManager.Tests/Rpro3ImportTests.cs
Normal file
407
GerbilManager.Tests/Rpro3ImportTests.cs
Normal file
@@ -0,0 +1,407 @@
|
||||
using GerbilManagerWebAPI.Import.Rpro3;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests für den RennmausPro-III-Importer (Reader + Dedup + Execute).
|
||||
/// Die meisten Tests bauen eine hermetische Mini-SQLite im RPRO3-Schema; ein optionaler
|
||||
/// Test gegen die echte entpackte DB läuft nur, wenn sie lokal vorhanden ist.
|
||||
/// </summary>
|
||||
public class Rpro3ImportTests
|
||||
{
|
||||
static Rpro3ImportTests()
|
||||
{
|
||||
// CP1252 für die Tests verfügbar machen (die Produktion registriert es im Reader-Ctor).
|
||||
System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);
|
||||
}
|
||||
|
||||
// ── Unit: Decode / JDN / Genotyp ──────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void JdnToDate_converts_known_julian_day_numbers()
|
||||
{
|
||||
// Blacky _BIRTH = 2455036.0 → 2009-07-23 (siehe rpro3-vergleich.md).
|
||||
Assert.Equal(new DateOnly(2009, 7, 23), Rpro3Reader.JdnToDate(2455036.0));
|
||||
Assert.Null(Rpro3Reader.JdnToDate(0));
|
||||
Assert.Null(Rpro3Reader.JdnToDate(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SmartDecode_prefers_utf8_then_cp1252()
|
||||
{
|
||||
var utf8 = System.Text.Encoding.UTF8.GetBytes("männlich");
|
||||
Assert.Equal("männlich", Rpro3Reader.SmartDecode(utf8));
|
||||
|
||||
var cp1252 = System.Text.Encoding.GetEncoding(1252).GetBytes("männlich");
|
||||
// CP1252-Bytes sind kein gültiges UTF-8 → Fallback dekodiert korrekt.
|
||||
System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);
|
||||
Assert.Equal("männlich", Rpro3Reader.SmartDecode(cp1252));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CleanGenotype_strips_brackets_keeps_dash()
|
||||
{
|
||||
Assert.Equal("aa Ccchm DD EE GG Pp spsp", Rpro3Reader_CleanGenotype("aa Cc[chm] DD EE GG Pp spsp"));
|
||||
Assert.Equal("Kim AA C- Dd", Rpro3Reader_CleanGenotype("Kim AA C- Dd"));
|
||||
}
|
||||
|
||||
private static string? Rpro3Reader_CleanGenotype(string s)
|
||||
{
|
||||
// CleanGenotype ist internal in Rpro3ImportService → über InternalsVisibleTo erreichbar.
|
||||
var m = typeof(Rpro3ImportService).GetMethod("CleanGenotype",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||
return (string?)m!.Invoke(null, new object?[] { s });
|
||||
}
|
||||
|
||||
// ── Unit: Dedup ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Dedup_merges_compatible_same_name_records()
|
||||
{
|
||||
var animals = new List<Rpro3Animal>
|
||||
{
|
||||
Ext("u1", "Alice", new DateOnly(2010, 7, 19), "Schimmel", "Clan A"),
|
||||
Ext("u2", "Alice", new DateOnly(2010, 7, 19), "Schimmel", ""), // kompatibel → merge
|
||||
Ext("u3", "Alice", null, "Schimmel", "Clan A"), // kompatibel → merge
|
||||
Ext("u4", "Alice", new DateOnly(2001, 1, 1), "Schwarz", "Clan B"), // Konflikt → eigener Cluster
|
||||
};
|
||||
var r = Rpro3Dedup.Run(animals);
|
||||
// Mind. ein Merge-Cluster (u1/u2/u3), u4 separat → Name "Alice" mehrdeutig.
|
||||
Assert.Single(r.MergeClusters);
|
||||
Assert.Equal(3, r.MergeClusters.Values.First().Count);
|
||||
Assert.Contains(r.Ambiguous, a => a.Name == "Alice");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dedup_does_not_merge_placeholder_names()
|
||||
{
|
||||
var animals = new List<Rpro3Animal>
|
||||
{
|
||||
Ext("u1", "unbekannt", null, "", ""),
|
||||
Ext("u2", "unbekannt", null, "", ""),
|
||||
};
|
||||
var r = Rpro3Dedup.Run(animals);
|
||||
Assert.Empty(r.MergeClusters);
|
||||
Assert.Equal(2, r.Placeholders.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dedup_force_merges_variants_declared_same_by_breeder()
|
||||
{
|
||||
// Zwei Varianten, die der Auto-Dedup wegen Farbkonflikt getrennt lässt …
|
||||
var animals = new List<Rpro3Animal>
|
||||
{
|
||||
Ext("u1", "Akiro", new DateOnly(2008, 2, 5), "Polarfuchs", "Privatzucht"), // Variante B
|
||||
Ext("u2", "Akiro", new DateOnly(2008, 2, 6), "Polarfuchs, hell", "unbekannt"), // Variante C
|
||||
};
|
||||
// ohne Entscheidung: getrennt (Farbe + DOB unterschiedlich → Konflikt)
|
||||
Assert.Empty(Rpro3Dedup.Run(animals).MergeClusters);
|
||||
|
||||
// … werden per „Same"-Entscheidung der Züchterin zusammengelegt.
|
||||
var decisions = new Rpro3Decisions
|
||||
{
|
||||
Decisions = { new Rpro3Decision { Name = "Akiro", Same = { new() { "u1", "u2" } } } }
|
||||
};
|
||||
var r = Rpro3Dedup.Run(animals, decisions);
|
||||
Assert.Single(r.MergeClusters);
|
||||
Assert.Equal(2, r.MergeClusters.Values.First().Count);
|
||||
Assert.Equal(r.RidToRoot["u1"], r.RidToRoot["u2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dedup_force_splits_variants_declared_different_by_breeder()
|
||||
{
|
||||
// Zwei Datensätze, die der Auto-Dedup zusammenlegen würde (kompatibel) …
|
||||
var animals = new List<Rpro3Animal>
|
||||
{
|
||||
Ext("u1", "Max", new DateOnly(2013, 2, 1), "Marder", "Clan A"),
|
||||
Ext("u2", "Max", new DateOnly(2013, 2, 1), "Marder", "Clan A"),
|
||||
};
|
||||
Assert.Single(Rpro3Dedup.Run(animals).MergeClusters);
|
||||
|
||||
// … bleiben durch „Different" getrennt.
|
||||
var decisions = new Rpro3Decisions
|
||||
{
|
||||
Decisions = { new Rpro3Decision { Name = "Max", Different = { new() { "u1" }, new() { "u2" } } } }
|
||||
};
|
||||
var r = Rpro3Dedup.Run(animals, decisions);
|
||||
Assert.Empty(r.MergeClusters);
|
||||
Assert.NotEqual(r.RidToRoot["u1"], r.RidToRoot["u2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rpro3Decisions_load_returns_empty_when_file_missing()
|
||||
{
|
||||
var d = Rpro3Decisions.Load(Path.Combine(Path.GetTempPath(), "does-not-exist-" + Guid.NewGuid().ToString("N") + ".json"));
|
||||
Assert.Empty(d.Decisions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rpro3Decisions_roundtrips_through_json()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "rpro3-dec-" + Guid.NewGuid().ToString("N") + ".json");
|
||||
File.WriteAllText(path,
|
||||
"{\"decisions\":[{\"name\":\"Bura\",\"same\":[[\"228\",\"u2128\"]],\"fields\":{\"228\":{\"origin\":\"Sarah Wörz\"}}}," +
|
||||
"{\"name\":\"MilkyWay\",\"same\":[[\"u1031\",\"u1019\"]],\"fields\":{\"u1031\":{\"genotype\":\"aa Cc[h] DD ee Gg P- spsp\",\"color\":\"Kohlfuchs-Hell\"}}}]}");
|
||||
try
|
||||
{
|
||||
var d = Rpro3Decisions.Load(path);
|
||||
Assert.Equal(2, d.Decisions.Count);
|
||||
Assert.Equal("Sarah Wörz", d.BuildFieldIndex()["228"].Origin);
|
||||
Assert.Equal(new[] { "228", "u2128" }, d.SameGroups().First());
|
||||
// Gencode-Override (z. B. MilkyWay: korrektes C-Locus c[h]) muss durch den JSON-Roundtrip kommen.
|
||||
Assert.Equal("aa Cc[h] DD ee Gg P- spsp", d.BuildFieldIndex()["u1031"].Genotype);
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
// ── Integration: Reader + Execute gegen eine Mini-RPRO3-DB ────
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_imports_animals_litters_contacts_and_is_idempotent()
|
||||
{
|
||||
var dbPath = BuildMiniRpro3();
|
||||
try
|
||||
{
|
||||
var data = new Rpro3Reader().ReadFromDbFile(dbPath);
|
||||
Assert.Equal(2, data.Animals.Count(a => a.Src == Rpro3Src.Stamm));
|
||||
Assert.True(data.Animals.Count(a => a.Src == Rpro3Src.Fremd) >= 2);
|
||||
|
||||
using var efConn = new SqliteConnection("DataSource=:memory:");
|
||||
efConn.Open();
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(efConn).Options;
|
||||
using var ctx = new ApplicationContext(opts);
|
||||
ctx.Database.EnsureCreated();
|
||||
|
||||
var config = new ConfigurationBuilder().Build();
|
||||
var service = new Rpro3ImportService(ctx, config, null);
|
||||
|
||||
var analyze = await service.AnalyzeAsync(data, photosProvided: false, availablePhotoFiles: null);
|
||||
Assert.Equal(2, analyze.Counts.OwnAnimals);
|
||||
Assert.True(analyze.NewVsCurrentNew >= 2);
|
||||
|
||||
var first = await service.ExecuteAsync(data, Path.GetTempPath(), photosProvided: false, photoSourcePaths: null);
|
||||
Assert.True(first.GerbilsImported >= 4); // 2 stamm + ≥2 fremd
|
||||
Assert.Equal(1, first.LittersImported >= 1 ? 1 : 0); // mind. 1 Wurf importiert
|
||||
var gerbilCount1 = await ctx.Gerbils.CountAsync();
|
||||
var litterCount1 = await ctx.Litters.CountAsync();
|
||||
|
||||
// Re-Run: idempotent — gleiche Zeilenzahl, keine Dubletten.
|
||||
var second = await service.ExecuteAsync(data, Path.GetTempPath(), photosProvided: false, photoSourcePaths: null);
|
||||
Assert.Equal(gerbilCount1, await ctx.Gerbils.CountAsync());
|
||||
Assert.Equal(litterCount1, await ctx.Litters.CountAsync());
|
||||
|
||||
// Pedigree: Wurf hat Mutter + Vater verknüpft (resolved gegen importierte Tiere).
|
||||
var litter = await ctx.Litters.FirstAsync(l => l.Name!.StartsWith("Wurf"));
|
||||
Assert.NotNull(litter.MotherId);
|
||||
Assert.NotNull(litter.FatherId);
|
||||
|
||||
// Genotyp wurde aus _FCODE übernommen + Klammern entfernt.
|
||||
var blacky = await ctx.Gerbils.FirstAsync(g => g.Name == "Blacky");
|
||||
Assert.Equal("aa Ccchm DD EE GG Pp spsp", blacky.Genotype);
|
||||
Assert.True(blacky.IsResident);
|
||||
|
||||
// Encoding: "männlich" korrekt dekodiert (Latin-1-Quelle).
|
||||
Assert.Equal(Gender.male, blacky.Gender);
|
||||
}
|
||||
finally { try { File.Delete(dbPath); } catch { } }
|
||||
}
|
||||
|
||||
// ── Integration: Feature-Tabellen (Becken/Erwerb/Reservierung/Rücknahme/Warteliste/Ausstellung) ──
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_imports_feature_tables_and_is_idempotent()
|
||||
{
|
||||
var dbPath = BuildMiniRpro3();
|
||||
try
|
||||
{
|
||||
var data = new Rpro3Reader().ReadFromDbFile(dbPath);
|
||||
// Reader hat alle Feature-Tabellen erfasst.
|
||||
Assert.Equal(2, data.Becken.Count);
|
||||
Assert.Single(data.Acquisitions);
|
||||
Assert.Single(data.Abgeben);
|
||||
Assert.Single(data.Abstat);
|
||||
Assert.Single(data.Getback);
|
||||
Assert.Single(data.Nachfrage);
|
||||
Assert.Single(data.Ausstellungen);
|
||||
Assert.Equal(1, data.StammBecken["1"]); // Blacky → Becken 1
|
||||
|
||||
using var efConn = new SqliteConnection("DataSource=:memory:");
|
||||
efConn.Open();
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(efConn).Options;
|
||||
using var ctx = new ApplicationContext(opts);
|
||||
ctx.Database.EnsureCreated();
|
||||
var service = new Rpro3ImportService(ctx, new ConfigurationBuilder().Build(), null);
|
||||
|
||||
var res = await service.ExecuteAsync(data, Path.GetTempPath(), photosProvided: false, photoSourcePaths: null);
|
||||
Assert.Equal(2, res.EnclosuresImported);
|
||||
Assert.Equal(1, res.AcquisitionsImported);
|
||||
Assert.Equal(2, res.ReservationsImported); // abgeben(Blacky, reserviert) + abstat(Kuke, abgegeben)
|
||||
Assert.Equal(1, res.ReturnsImported);
|
||||
Assert.Equal(1, res.WaitingListImported);
|
||||
Assert.Equal(1, res.ExhibitionsImported);
|
||||
|
||||
// Becken → Gehege; Blacky ist Becken 1 zugeordnet.
|
||||
Assert.Equal(2, await ctx.Enclosures.CountAsync());
|
||||
var kaefig = await ctx.Enclosures.FirstAsync(e => e.Name == "Käfig A");
|
||||
Assert.Equal("80x35x40", kaefig.Size);
|
||||
Assert.Equal(3, kaefig.Capacity);
|
||||
Assert.Equal(60, kaefig.CleaningCycleDays);
|
||||
var blacky = await ctx.Gerbils.FirstAsync(g => g.Name == "Blacky");
|
||||
Assert.Equal(kaefig.Id, blacky.EnclosureId);
|
||||
|
||||
// Erwerb verknüpft auf Blacky mit Preis/Datum.
|
||||
var acq = await ctx.AcquisitionRecords.SingleAsync();
|
||||
Assert.Equal(blacky.Id, acq.GerbilId);
|
||||
Assert.Equal(9.5m, acq.Price);
|
||||
Assert.NotNull(acq.Date);
|
||||
|
||||
// Reservierung: Blacky reserviert für Lisa M.
|
||||
var resv = await ctx.SaleReservations.FirstAsync(r => r.GerbilId == blacky.Id);
|
||||
Assert.Equal("reserviert", resv.Status);
|
||||
Assert.Equal("Lisa M.", resv.ContactName);
|
||||
|
||||
// Abgabe-Abschluss: Kuke → abgegeben.
|
||||
var kuke = await ctx.Gerbils.FirstAsync(g => g.Name == "Kuke");
|
||||
var kukeRes = await ctx.SaleReservations.FirstAsync(r => r.GerbilId == kuke.Id);
|
||||
Assert.Equal("abgegeben", kukeRes.Status);
|
||||
Assert.Equal(15.0m, kukeRes.Price);
|
||||
|
||||
// Rücknahme: Blacky kam zurück.
|
||||
var ret = await ctx.ReturnRecords.SingleAsync();
|
||||
Assert.Equal(blacky.Id, ret.GerbilId);
|
||||
Assert.Equal(15.0m, ret.OriginalPrice);
|
||||
Assert.Equal("Lisa M.", ret.FromContactName);
|
||||
|
||||
// Warteliste: männlicher Schimmel.
|
||||
var wl = await ctx.WaitingListEntries.SingleAsync();
|
||||
Assert.Equal("male", wl.WishGender);
|
||||
Assert.Equal("Schimmel", wl.WishColor);
|
||||
Assert.Equal("offen", wl.Status);
|
||||
|
||||
// Ausstellung: Blacky, 1. Platz, BOB.
|
||||
var exh = await ctx.ExhibitionResults.SingleAsync();
|
||||
Assert.Equal(blacky.Id, exh.GerbilId);
|
||||
Assert.Equal("Schau 2011", exh.EventName);
|
||||
Assert.Equal("1. Platz", exh.Placement);
|
||||
Assert.Equal("BOB", exh.Award);
|
||||
|
||||
// Re-Run: idempotent — keine Dubletten in den Feature-Tabellen.
|
||||
await service.ExecuteAsync(data, Path.GetTempPath(), photosProvided: false, photoSourcePaths: null);
|
||||
Assert.Equal(2, await ctx.Enclosures.CountAsync());
|
||||
Assert.Equal(1, await ctx.AcquisitionRecords.CountAsync());
|
||||
Assert.Equal(2, await ctx.SaleReservations.CountAsync()); // Blacky + Kuke
|
||||
Assert.Equal(1, await ctx.ReturnRecords.CountAsync());
|
||||
Assert.Equal(1, await ctx.WaitingListEntries.CountAsync());
|
||||
Assert.Equal(1, await ctx.ExhibitionResults.CountAsync());
|
||||
}
|
||||
finally { try { File.Delete(dbPath); } catch { } }
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
private static Rpro3Animal Ext(string rid, string name, DateOnly? dob, string farbe, string origin) =>
|
||||
new() { Rid = rid, Src = Rpro3Src.Fremd, Name = name, Dob = dob, Farbe = farbe, Origin = origin };
|
||||
|
||||
/// <summary>Baut eine minimale SQLite im RPRO3-Schema (Latin-1-Geschlecht, JDN-Daten).</summary>
|
||||
private static string BuildMiniRpro3()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "mini-rpro3-" + Guid.NewGuid().ToString("N") + ".db");
|
||||
using var conn = new SqliteConnection($"DataSource={path}");
|
||||
conn.Open();
|
||||
void Exec(string sql) { using var c = conn.CreateCommand(); c.CommandText = sql; c.ExecuteNonQuery(); }
|
||||
|
||||
Exec("CREATE TABLE herk_tb (id INTEGER, _BEZ, _ANREDE, _VNAME, _NNAME, _CLAN, _STR, _ORT, _PLZ, _TELP, _TELD, _MAIL, _HTTP, _MEMO, _BEM)");
|
||||
Exec("CREATE TABLE abn_tb (id INTEGER, _BEZ, _ANREDE, _VNAME, _NNAME, _CLAN, _STR, _ORT, _PLZ, _TELP, _TELD, _MAIL, _HTTP, _MEMO, _BEM)");
|
||||
Exec("CREATE TABLE baum_tb (id, _MID, _PID)");
|
||||
Exec("CREATE TABLE stamm_tb (id INTEGER, _NAME, _SEX, _BIRTH, _HERKUNFT, _ZB, _STATUS, _FEHLER, _KASTRAT_DATE, _BECKEN)");
|
||||
Exec("CREATE TABLE fremd_tb (id INTEGER, _NAME, _SEX, _BIRTH, _ZB, _HERK, _MID, _PID, _TODAM, _KASTRAT_DATE)");
|
||||
Exec("CREATE TABLE wurf_tb (id INTEGER, _MID, _PID, _AM, _BEZ, _BEM)");
|
||||
Exec("CREATE TABLE wurftier_tb (id, _WID, _NAME, _SEX, _BIRTH, _SID, _TODAM, _WARUM, _ABAM, _ABN, _PRICE, _ZB, _FEHLER)");
|
||||
Exec("CREATE TABLE color_tb (id, _FARBE, _FCODE)");
|
||||
Exec("CREATE TABLE wurfcolor_tb (id, _FARBE, _FCODE)");
|
||||
Exec("CREATE TABLE fremdcolor_tb (id, _FARBE, _FCODE)");
|
||||
Exec("CREATE TABLE tod_tb (tid, _AM, _WARUM)");
|
||||
Exec("CREATE TABLE waage_tb (_TID, _GRAMM, _DATE, _BEM)");
|
||||
Exec("CREATE TABLE jungwaage_tb (_TID, _WID, _GRAMM, _DATE, _BEM)");
|
||||
Exec("CREATE TABLE krank_tb (_TID, _DATE, _BEM, _MED)");
|
||||
Exec("CREATE TABLE diary_tb (_TID, _BEZ, _DATE, _DESC)");
|
||||
Exec("CREATE TABLE photo_tb (id, _P1, _P2, _P3)");
|
||||
// Feature-Tabellen.
|
||||
Exec("CREATE TABLE becken_tb (id INTEGER, _BEZ, _SIZE, _MENGE, _CLEANED, _CYCLUS, _NEXT)");
|
||||
Exec("CREATE TABLE herktier_tb (id INTEGER, _TID, _DATE, _PRICE, _BEM, _ART)");
|
||||
Exec("CREATE TABLE abgeben_tb (tid, _RES, _BEM, _TERMIN, _ABN, _BEZ)");
|
||||
Exec("CREATE TABLE abstat_tb (tid, _IDABN, _AM, _PRICE, _ART, _BEM)");
|
||||
Exec("CREATE TABLE getback_tb (id INTEGER, _TID, _ZAM, _ZPREIS, _AM, _PREIS, _ABN, _BEM)");
|
||||
Exec("CREATE TABLE nachfrage_tb (id INTEGER, _COLOR, _SEX, _DATE, _ABN, _CODE, _VOM, _STATUS, _ART)");
|
||||
Exec("CREATE TABLE ausz_tb (id INTEGER, _TID, _VERANSTALTUNG, _DATE, _ORT, _PLATZ, _AUSZ, _EXTRA, _JUROR, _BEM)");
|
||||
|
||||
// Kontakte
|
||||
Exec("INSERT INTO herk_tb (id,_BEZ,_CLAN,_ORT,_MAIL) VALUES (1,'eigene Zucht','','','')");
|
||||
Exec("INSERT INTO herk_tb (id,_BEZ,_CLAN,_ORT,_MAIL) VALUES (61,'M.Knoss GG','Clan X','Gießen','a@b.de')");
|
||||
|
||||
// Geschlecht als Latin-1-Bytes einfügen (BLOB), um den smart_decode-Fallback zu testen.
|
||||
var maennBytes = System.Text.Encoding.GetEncoding(1252).GetBytes("männlich");
|
||||
var weibBytes = System.Text.Encoding.UTF8.GetBytes("weiblich");
|
||||
void InsertStamm(int id, string name, byte[] sex, double birth, int herk, string zb, int becken = 0)
|
||||
{
|
||||
using var c = conn.CreateCommand();
|
||||
c.CommandText = "INSERT INTO stamm_tb (id,_NAME,_SEX,_BIRTH,_HERKUNFT,_ZB,_STATUS,_FEHLER,_BECKEN) VALUES ($id,$n,$s,$b,$h,$z,3,'',$bk)";
|
||||
c.Parameters.AddWithValue("$id", id);
|
||||
c.Parameters.AddWithValue("$n", name);
|
||||
c.Parameters.AddWithValue("$s", sex);
|
||||
c.Parameters.AddWithValue("$b", birth);
|
||||
c.Parameters.AddWithValue("$h", herk);
|
||||
c.Parameters.AddWithValue("$z", zb);
|
||||
c.Parameters.AddWithValue("$bk", becken);
|
||||
c.ExecuteNonQuery();
|
||||
}
|
||||
InsertStamm(1, "Blacky", maennBytes, 2455036.0, 61, "ZdKC-B.09.2009", becken: 1);
|
||||
InsertStamm(2, "Kuke", weibBytes, 2455093.0, 61, "ZdKC-B.10.2009");
|
||||
|
||||
// Genotyp (Bracket-Notation) für Blacky.
|
||||
Exec("INSERT INTO color_tb (id,_FARBE,_FCODE) VALUES ('1','Schwarz','aa Cc[chm] DD EE GG Pp spsp')");
|
||||
|
||||
// Externe Ahnen (Eltern von Blacky & Kuke via baum_tb).
|
||||
Exec("INSERT INTO fremd_tb (id,_NAME,_SEX,_BIRTH,_HERK,_MID,_PID) VALUES (1,'Oma','weiblich',0,1,'','')");
|
||||
Exec("INSERT INTO fremd_tb (id,_NAME,_SEX,_BIRTH,_HERK,_MID,_PID) VALUES (2,'Opa','männlich',0,1,'','')");
|
||||
Exec("INSERT INTO baum_tb (id,_MID,_PID) VALUES ('1','u1','u2')"); // Blacky Eltern = Oma×Opa
|
||||
Exec("INSERT INTO baum_tb (id,_MID,_PID) VALUES ('2','u1','u2')"); // Kuke Eltern = Oma×Opa
|
||||
|
||||
// Wurf: Mutter Kuke (2) × Vater Blacky (1)
|
||||
Exec("INSERT INTO wurf_tb (id,_MID,_PID,_AM,_BEZ,_BEM) VALUES (1,'2','1',2455246.0,'Wurf A','Erster Wurf')");
|
||||
Exec("INSERT INTO wurftier_tb (id,_WID,_NAME,_SEX,_BIRTH,_SID,_TODAM,_ABAM,_ABN,_PRICE,_ZB) VALUES ('1j1','1','Alvin','männlich',2455246.0,'','','',NULL,'','21 A')");
|
||||
|
||||
// Gewicht + Krankheit für Blacky.
|
||||
Exec("INSERT INTO waage_tb (_TID,_GRAMM,_DATE,_BEM) VALUES ('1',55.0,2455083.0,'')");
|
||||
Exec("INSERT INTO krank_tb (_TID,_DATE,_BEM,_MED) VALUES ('1',2455077.0,'Prellung','')");
|
||||
|
||||
// Feature-Tabellen-Daten.
|
||||
// Abnehmer-Kontakt (für Reservierung/Rücknahme/Warteliste).
|
||||
Exec("INSERT INTO abn_tb (id,_BEZ,_VNAME,_NNAME) VALUES (5,'Lisa M.','Lisa','Müller')");
|
||||
// Becken 1 (Blacky wohnt dort, _BECKEN=1), Becken 2 ohne Bewohner.
|
||||
Exec("INSERT INTO becken_tb (id,_BEZ,_SIZE,_MENGE,_CLEANED,_CYCLUS,_NEXT) VALUES (1,'Käfig A','80x35x40',3,2455595.0,60,2455655.0)");
|
||||
Exec("INSERT INTO becken_tb (id,_BEZ,_SIZE,_MENGE,_CLEANED,_CYCLUS,_NEXT) VALUES (2,'Terrarium','55x48x110',32,2455638.0,90,2455728.0)");
|
||||
// Erwerb: Blacky (stamm 1) am JDN für 9.50.
|
||||
Exec("INSERT INTO herktier_tb (id,_TID,_DATE,_PRICE,_BEM) VALUES (61,'1',2455076.0,9.5,'gekauft')");
|
||||
// Reservierung: Blacky (stamm 1) reserviert für abn 5.
|
||||
Exec("INSERT INTO abgeben_tb (tid,_RES,_BEM,_TERMIN,_ABN,_BEZ) VALUES ('1',1,'',2455730.0,5,0)");
|
||||
// Abgabe-Abschluss: Kuke (stamm 2) abgegeben an abn 5 für 15.
|
||||
Exec("INSERT INTO abstat_tb (tid,_IDABN,_AM,_PRICE,_BEM) VALUES ('2',5,2455716.0,15.0,'Zog aus.')");
|
||||
// Rücknahme: Blacky kam zurück von abn 5.
|
||||
Exec("INSERT INTO getback_tb (id,_TID,_ZAM,_ZPREIS,_AM,_PREIS,_ABN,_BEM) VALUES (2,'1',2455992.0,0.0,2455798.0,15.0,5,'kam zurück')");
|
||||
// Warteliste: Wunsch nach männlichem Schimmel von abn 5.
|
||||
Exec("INSERT INTO nachfrage_tb (id,_COLOR,_SEX,_DATE,_ABN,_CODE,_VOM,_STATUS) VALUES (1,'Schimmel','männlich',0.0,5,'egal',2456172.0,'')");
|
||||
// Ausstellung: ein Ergebnis für Blacky (in echten Backups oft leer).
|
||||
Exec("INSERT INTO ausz_tb (id,_TID,_VERANSTALTUNG,_DATE,_ORT,_PLATZ,_AUSZ,_JUROR,_BEM) VALUES (1,'1','Schau 2011',2455700.0,'Halle A','1. Platz','BOB','Frau X','schön')");
|
||||
|
||||
conn.Close();
|
||||
SqliteConnection.ClearAllPools();
|
||||
return path;
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
214
GerbilManager.Tests/SaleReservationEndpointTests.cs
Normal file
214
GerbilManager.Tests/SaleReservationEndpointTests.cs
Normal file
@@ -0,0 +1,214 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// ABGABE-STATUS (Reservierungs-/Abgabe-Status): the pre-handover pipeline
|
||||
/// verfügbar → reserviert → abgegeben.
|
||||
/// - Full CRUD round-trip via /reservations.
|
||||
/// - GET ?gerbilId= filters to one animal.
|
||||
/// - Invalid status falls back to "verfuegbar".
|
||||
/// - CRITICAL: rows survive the import re-ingest wipe (loose, FK-free GerbilId/ReservedForContactId).
|
||||
/// </summary>
|
||||
public class SaleReservationEndpointTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
public SaleReservationEndpointTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Reservation_crud_lifecycle_create_reserve_handover_delete()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var gerbilId = Guid.NewGuid();
|
||||
var contactId = Guid.NewGuid();
|
||||
|
||||
// Create -> defaults to verfuegbar.
|
||||
var create = await client.PostAsJsonAsync("/reservations", new
|
||||
{
|
||||
gerbilId,
|
||||
gerbilName = "Pippa",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
|
||||
var created = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement;
|
||||
var id = created.GetProperty("id").GetString();
|
||||
Assert.False(string.IsNullOrEmpty(id));
|
||||
Assert.Equal("verfuegbar", created.GetProperty("status").GetString());
|
||||
Assert.Equal(gerbilId.ToString(), created.GetProperty("gerbilId").GetString());
|
||||
Assert.Equal("Pippa", created.GetProperty("gerbilName").GetString());
|
||||
|
||||
// List contains it.
|
||||
var listed = JsonDocument.Parse(await client.GetStringAsync("/reservations")).RootElement;
|
||||
Assert.Contains(listed.EnumerateArray(), r => r.GetProperty("id").GetString() == id);
|
||||
|
||||
// Filter by gerbilId returns exactly this row.
|
||||
var byGerbil = JsonDocument.Parse(await client.GetStringAsync($"/reservations?gerbilId={gerbilId}")).RootElement;
|
||||
Assert.All(byGerbil.EnumerateArray(), r => Assert.Equal(gerbilId.ToString(), r.GetProperty("gerbilId").GetString()));
|
||||
Assert.Contains(byGerbil.EnumerateArray(), r => r.GetProperty("id").GetString() == id);
|
||||
|
||||
// PUT -> reserve for a contact with an appointment + price.
|
||||
var reserve = await client.PutAsJsonAsync($"/reservations/{id}", new
|
||||
{
|
||||
status = "reserviert",
|
||||
reservedForContactId = contactId,
|
||||
contactName = "Familie Huber",
|
||||
appointmentDate = "2026-07-01T10:00:00Z",
|
||||
price = 25.50m,
|
||||
note = "Käfig wird mitgebracht.",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.OK, reserve.StatusCode);
|
||||
var reserved = JsonDocument.Parse(await reserve.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("reserviert", reserved.GetProperty("status").GetString());
|
||||
Assert.Equal(contactId.ToString(), reserved.GetProperty("reservedForContactId").GetString());
|
||||
Assert.Equal("Familie Huber", reserved.GetProperty("contactName").GetString());
|
||||
Assert.Equal(25.50m, reserved.GetProperty("price").GetDecimal());
|
||||
|
||||
// PUT -> hand over.
|
||||
var handover = await client.PutAsJsonAsync($"/reservations/{id}", new
|
||||
{
|
||||
status = "abgegeben",
|
||||
reservedForContactId = contactId,
|
||||
handedOverDate = "2026-07-01T10:30:00Z",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.OK, handover.StatusCode);
|
||||
var handed = JsonDocument.Parse(await handover.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("abgegeben", handed.GetProperty("status").GetString());
|
||||
Assert.NotEqual(JsonValueKind.Null, handed.GetProperty("handedOverDate").ValueKind);
|
||||
|
||||
// Delete -> 204, then 404.
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await client.DeleteAsync($"/reservations/{id}")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.DeleteAsync($"/reservations/{id}")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.PutAsJsonAsync($"/reservations/{id}", new { status = "verfuegbar" })).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Post_with_unknown_status_falls_back_to_verfuegbar()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var create = await client.PostAsJsonAsync("/reservations", new { gerbilId = Guid.NewGuid(), status = "bananen" });
|
||||
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
|
||||
var dto = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("verfuegbar", dto.GetProperty("status").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Post_without_gerbilId_is_rejected()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var create = await client.PostAsJsonAsync("/reservations", new { gerbilId = Guid.Empty, status = "reserviert" });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, create.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reservation_survives_ingest_wipe()
|
||||
{
|
||||
// Fresh in-memory DB seeded with a resolved import file (mirrors IngestResolvedServiceTests).
|
||||
var dir = Path.Combine(Path.GetTempPath(), "reservation-ingest-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var contactId = Guid.NewGuid();
|
||||
var fatherId = Guid.NewGuid();
|
||||
var motherId = Guid.NewGuid();
|
||||
var litterId = Guid.NewGuid();
|
||||
|
||||
var data = new
|
||||
{
|
||||
Contacts = new[]
|
||||
{
|
||||
new { Id = contactId, Name = "Test Breeder", Email = "t@e.de", Phone = "", Address = "", Notes = (string?)null, IsBreeder = true, IsReceiver = false, NameSuffix = (string?)null, Provenance = (string?)null }
|
||||
},
|
||||
Litters = new[]
|
||||
{
|
||||
new { Id = litterId, Name = "Wurf A", Date = "2026-01-01", TotalBorn = 5, DeathsWithin8Weeks = 0, FatherId = fatherId, MotherId = motherId, ExpectedGoHomeDate = (string?)null, Notes = "", PairingCode = "PC01", ExternalRef = "ext-litter-1", LitterLetter = "A" }
|
||||
},
|
||||
Gerbils = new[]
|
||||
{
|
||||
Animal(fatherId, "Papa", "male", contactId),
|
||||
Animal(motherId, "Mama", "female", contactId),
|
||||
},
|
||||
GerbilPhotos = Array.Empty<object>(),
|
||||
};
|
||||
File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(data));
|
||||
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseInMemoryDatabase("reservation-ingest-" + Guid.NewGuid().ToString("N"))
|
||||
.Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated();
|
||||
|
||||
// A reservation referencing the gerbil + contact that the wipe will delete.
|
||||
var resId = Guid.NewGuid();
|
||||
db.SaleReservations.Add(new SaleReservation
|
||||
{
|
||||
Id = resId,
|
||||
GerbilId = fatherId,
|
||||
GerbilName = "Papa",
|
||||
Status = "reserviert",
|
||||
ReservedForContactId = contactId,
|
||||
ContactName = "Test Breeder",
|
||||
AppointmentDate = new DateTime(2026, 7, 1, 10, 0, 0, DateTimeKind.Utc),
|
||||
Price = 30m,
|
||||
Note = "Abholung am Wochenende.",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", dir } })
|
||||
.Build();
|
||||
|
||||
// Run the ingest wipe + reload.
|
||||
var result = await new IngestResolvedService(db, config, null!).RunAsync();
|
||||
Assert.Contains("Ingestion successful!", result);
|
||||
|
||||
// Gerbils/contacts were wiped & re-created, but the reservation is untouched.
|
||||
var survivor = await db.SaleReservations.SingleAsync(r => r.Id == resId);
|
||||
Assert.Equal(fatherId, survivor.GerbilId); // loose id preserved
|
||||
Assert.Equal(contactId, survivor.ReservedForContactId);
|
||||
Assert.Equal("Papa", survivor.GerbilName);
|
||||
Assert.Equal("Test Breeder", survivor.ContactName);
|
||||
Assert.Equal("reserviert", survivor.Status);
|
||||
Assert.Equal(30m, survivor.Price);
|
||||
Assert.Equal(1, await db.SaleReservations.CountAsync());
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static object Animal(Guid id, string name, string gender, Guid contactId) => new
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Gender = gender,
|
||||
Status = "Breeding",
|
||||
LitterId = (Guid?)null,
|
||||
OriginContactId = contactId,
|
||||
ReceiverContactId = (Guid?)null,
|
||||
EnclosureId = (Guid?)null,
|
||||
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"),
|
||||
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-" + name,
|
||||
RawImportData = "{}",
|
||||
OriginBreeder = "Test Zucht",
|
||||
NameSearch = name.ToLowerInvariant(),
|
||||
CharacterTraits = Array.Empty<string>(),
|
||||
CharacterNote = (string?)null,
|
||||
IsDeaf = false,
|
||||
IsResident = true,
|
||||
};
|
||||
}
|
||||
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);
|
||||
}
|
||||
339
GerbilManager.Tests/VerifiedGerbilTests.cs
Normal file
339
GerbilManager.Tests/VerifiedGerbilTests.cs
Normal file
@@ -0,0 +1,339 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Regression tests for the "Freeze/Override" mechanism (verified key animals + manual work
|
||||
/// survive the re-ingest). Exercised at the service + ingest level with an InMemory DB, mirroring
|
||||
/// the harness in <see cref="IngestResolvedServiceTests"/>.
|
||||
/// </summary>
|
||||
public class VerifiedGerbilTests : IDisposable
|
||||
{
|
||||
private static readonly Guid Schwarz = new("00000000-0000-0000-0000-000000000006");
|
||||
private readonly List<string> _dirs = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var d in _dirs)
|
||||
try { Directory.Delete(d, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
// ── Harness helpers ──────────────────────────────────────────────────
|
||||
|
||||
private string NewDir()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "verified-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
_dirs.Add(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
private static ApplicationContext NewDb()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseInMemoryDatabase("verified-" + Guid.NewGuid().ToString("N"))
|
||||
.Options;
|
||||
var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated();
|
||||
return db;
|
||||
}
|
||||
|
||||
private static IngestResolvedService Service(ApplicationContext db, string dir)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
{ "Import:SourcePath", dir },
|
||||
{ "Photos:RootPath", dir },
|
||||
})
|
||||
.Build();
|
||||
return new IngestResolvedService(db, config, null!);
|
||||
}
|
||||
|
||||
private static void WritePayload(string dir, object[] gerbils, object[]? litters = null, object[]? contacts = null) =>
|
||||
File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(new
|
||||
{
|
||||
Contacts = contacts ?? Array.Empty<object>(),
|
||||
Litters = litters ?? Array.Empty<object>(),
|
||||
Gerbils = gerbils,
|
||||
GerbilPhotos = Array.Empty<object>(),
|
||||
SaleContracts = Array.Empty<object>(),
|
||||
}));
|
||||
|
||||
private static object ImportGerbil(Guid id, string name, string genotype, string gender = "female", string dob = "2022-01-01") => new
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Gender = gender,
|
||||
Status = "Breeding",
|
||||
DateOfBirth = dob,
|
||||
Genotype = genotype,
|
||||
ColorVarietyId = Schwarz,
|
||||
IsResident = true,
|
||||
OriginBreeder = "Test Zucht",
|
||||
};
|
||||
|
||||
// ── 1. Manual animal survives the ingest (never wiped) ───────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Manual_animal_survives_ingest_that_omits_it()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var dir = NewDir();
|
||||
|
||||
var manualId = Guid.NewGuid();
|
||||
db.Gerbils.Add(new Gerbil { Id = manualId, Name = "Handmaus", Gender = Gender.female, IsManual = true });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var importedId = Guid.NewGuid();
|
||||
WritePayload(dir, new[] { ImportGerbil(importedId, "Importiert", "aa CC DD EE GG PP spsp") });
|
||||
|
||||
var result = await Service(db, dir).RunAsync();
|
||||
|
||||
Assert.StartsWith("Ingestion successful!", result);
|
||||
// Manual row is still there, unchanged.
|
||||
var manual = await db.Gerbils.FindAsync(manualId);
|
||||
Assert.NotNull(manual);
|
||||
Assert.True(manual!.IsManual);
|
||||
Assert.Equal("Handmaus", manual.Name);
|
||||
// Imported row was loaded.
|
||||
Assert.NotNull(await db.Gerbils.FindAsync(importedId));
|
||||
Assert.Equal(2, await db.Gerbils.CountAsync());
|
||||
}
|
||||
|
||||
// ── 2. Verified freeze forces the golden values over a drifted import ─
|
||||
|
||||
[Fact]
|
||||
public async Task Verified_override_forces_golden_and_records_drift()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var dir = NewDir();
|
||||
var id = new Guid("11111111-1111-1111-1111-111111111111");
|
||||
|
||||
// First ingest: import brings X in with the WRONG genotype/name.
|
||||
WritePayload(dir, new[] { ImportGerbil(id, "Falschname", "aa dd DD EE GG PP spsp") });
|
||||
var svc = Service(db, dir);
|
||||
await svc.RunAsync();
|
||||
|
||||
// The breeder corrects X and marks it "vollständig korrekt".
|
||||
var g = await db.Gerbils.FindAsync(id);
|
||||
Assert.NotNull(g);
|
||||
g!.Name = "Korrekt";
|
||||
g.Genotype = "aa CC DD EE GG PP spsp";
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var golden = await GerbilSnapshotService.BuildSnapshotAsync(db, id);
|
||||
db.GerbilOverrides.Add(new GerbilOverride
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GerbilId = id,
|
||||
EntityName = g.Name,
|
||||
IsVerified = true,
|
||||
OverrideJson = GerbilSnapshotService.BuildFreezeJson(g),
|
||||
SnapshotJson = GerbilSnapshotService.SerializeSnapshot(golden!),
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// A later import DRIFTS back to wrong values.
|
||||
WritePayload(dir, new[] { ImportGerbil(id, "Wiederfalsch", "aa dd DD EE GG PP spsp") });
|
||||
await svc.RunAsync();
|
||||
|
||||
// Golden wins (freeze re-applied on top of the fresh import).
|
||||
var after = await db.Gerbils.FindAsync(id);
|
||||
Assert.Equal("Korrekt", after!.Name);
|
||||
Assert.Equal("aa CC DD EE GG PP spsp", after.Genotype);
|
||||
|
||||
// Drift was detected and recorded, naming the offending fields.
|
||||
var ov = await db.GerbilOverrides.SingleAsync();
|
||||
Assert.False(string.IsNullOrEmpty(ov.LastImportDiffJson));
|
||||
Assert.Contains("genotype", ov.LastImportDiffJson);
|
||||
Assert.Contains("name", ov.LastImportDiffJson);
|
||||
Assert.NotNull(ov.LastImportSnapshotJson);
|
||||
}
|
||||
|
||||
// ── 3. No drift → LastImportDiffJson stays null ──────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Verified_override_without_drift_leaves_diff_null()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var dir = NewDir();
|
||||
var id = new Guid("22222222-2222-2222-2222-222222222222");
|
||||
|
||||
WritePayload(dir, new[] { ImportGerbil(id, "Stabil", "aa CC DD EE GG PP spsp") });
|
||||
var svc = Service(db, dir);
|
||||
await svc.RunAsync();
|
||||
|
||||
var g = await db.Gerbils.FindAsync(id);
|
||||
var golden = await GerbilSnapshotService.BuildSnapshotAsync(db, id);
|
||||
db.GerbilOverrides.Add(new GerbilOverride
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GerbilId = id,
|
||||
EntityName = g!.Name,
|
||||
IsVerified = true,
|
||||
OverrideJson = GerbilSnapshotService.BuildFreezeJson(g),
|
||||
SnapshotJson = GerbilSnapshotService.SerializeSnapshot(golden!),
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Re-ingest with the IDENTICAL payload (== golden).
|
||||
await svc.RunAsync();
|
||||
|
||||
var ov = await db.GerbilOverrides.SingleAsync();
|
||||
Assert.Null(ov.LastImportDiffJson);
|
||||
}
|
||||
|
||||
// ── 4. Removing the override → import-driven again ───────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Removing_override_restores_import_driven_values()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var dir = NewDir();
|
||||
var id = new Guid("33333333-3333-3333-3333-333333333333");
|
||||
|
||||
WritePayload(dir, new[] { ImportGerbil(id, "Import-Wert", "aa CC DD EE GG PP spsp") });
|
||||
var svc = Service(db, dir);
|
||||
await svc.RunAsync();
|
||||
|
||||
var g = await db.Gerbils.FindAsync(id);
|
||||
g!.Genotype = "aa cc DD EE GG PP spsp"; // "golden"
|
||||
await db.SaveChangesAsync();
|
||||
var golden = await GerbilSnapshotService.BuildSnapshotAsync(db, id);
|
||||
var ov = new GerbilOverride
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GerbilId = id,
|
||||
IsVerified = true,
|
||||
OverrideJson = GerbilSnapshotService.BuildFreezeJson(g),
|
||||
SnapshotJson = GerbilSnapshotService.SerializeSnapshot(golden!),
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
db.GerbilOverrides.Add(ov);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Freeze wins while the override exists.
|
||||
await svc.RunAsync();
|
||||
Assert.Equal("aa cc DD EE GG PP spsp", (await db.Gerbils.FindAsync(id))!.Genotype);
|
||||
|
||||
// Remove the override → the animal follows the import again.
|
||||
db.GerbilOverrides.Remove(ov);
|
||||
await db.SaveChangesAsync();
|
||||
await svc.RunAsync();
|
||||
Assert.Equal("aa CC DD EE GG PP spsp", (await db.Gerbils.FindAsync(id))!.Genotype);
|
||||
}
|
||||
|
||||
// ── 5. Per-field protection (pure service level) ─────────────────────
|
||||
|
||||
[Fact]
|
||||
public void PerField_change_pins_only_the_changed_field()
|
||||
{
|
||||
var g = new Gerbil { Name = "Quelle", Notes = "alte Notiz", Genotype = "aa CC DD EE GG PP spsp" };
|
||||
|
||||
var before = GerbilSnapshotService.BuildFreezeObject(g);
|
||||
g.Notes = "neue Notiz";
|
||||
var after = GerbilSnapshotService.BuildFreezeObject(g);
|
||||
|
||||
var changed = GerbilSnapshotService.ChangedFields(before, after);
|
||||
var only = Assert.Single(changed); // ONLY notes changed
|
||||
Assert.Equal("notes", only.Key);
|
||||
|
||||
var merged = GerbilSnapshotService.MergeOverrideJson("{}", changed);
|
||||
var mergedObj = JsonNode.Parse(merged)!.AsObject();
|
||||
Assert.True(mergedObj.ContainsKey("notes"));
|
||||
Assert.False(mergedObj.ContainsKey("genotype")); // untouched fields not pinned
|
||||
|
||||
// Applying it onto a different animal overwrites ONLY notes, not the genotype.
|
||||
var target = new Gerbil { Name = "Ziel", Notes = "Ziel-Notiz", Genotype = "zz ZZ dd ee gg pp spsp" };
|
||||
GerbilSnapshotService.ApplyOverride(target, merged);
|
||||
Assert.Equal("neue Notiz", target.Notes);
|
||||
Assert.Equal("zz ZZ dd ee gg pp spsp", target.Genotype);
|
||||
}
|
||||
|
||||
// ── 6. Dangling reference from a manual row is nulled + warned ────────
|
||||
|
||||
[Fact]
|
||||
public async Task Stale_parent_reference_from_manual_litter_is_nulled_with_warning()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var dir = NewDir();
|
||||
var fatherId = new Guid("44444444-4444-4444-4444-444444444444");
|
||||
|
||||
// Ingest 1 brings in the (imported) father.
|
||||
WritePayload(dir, new[] { ImportGerbil(fatherId, "Vater", "aa CC DD EE GG PP spsp", gender: "male") });
|
||||
var svc = Service(db, dir);
|
||||
await svc.RunAsync();
|
||||
|
||||
// The breeder wires a MANUAL litter to that imported father.
|
||||
var litterId = Guid.NewGuid();
|
||||
db.Litters.Add(new Litter { Id = litterId, Name = "Manueller Wurf", FatherId = fatherId, IsManual = true });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Ingest 2 no longer contains the father → it becomes stale and is removed.
|
||||
WritePayload(dir, Array.Empty<object>());
|
||||
var result = await svc.RunAsync();
|
||||
|
||||
// The manual litter survives, but its dangling parent link was nulled...
|
||||
var litter = await db.Litters.FindAsync(litterId);
|
||||
Assert.NotNull(litter);
|
||||
Assert.True(litter!.IsManual);
|
||||
Assert.Null(litter.FatherId);
|
||||
// ...and a warning was surfaced.
|
||||
Assert.Contains("warning", result);
|
||||
Assert.Contains("nicht mehr existiert", result);
|
||||
// The stale imported father is gone.
|
||||
Assert.Null(await db.Gerbils.FindAsync(fatherId));
|
||||
}
|
||||
|
||||
// ── 7. Diff detects ancestry / offspring differences ─────────────────
|
||||
|
||||
[Fact]
|
||||
public void Diff_detects_parent_and_children_differences()
|
||||
{
|
||||
var self = new SnapshotSelf(
|
||||
"Proband", "female", "2022-01-01", null, null, null, "aa CC DD EE GG PP spsp",
|
||||
null, "Schwarz", true, null, false, null, null, null, null,
|
||||
new List<string>(), null, null);
|
||||
|
||||
var golden = new GerbilSnapshot(
|
||||
self,
|
||||
new SnapshotParent("Papa Alt", "2020-01-01", "aa CC"),
|
||||
null,
|
||||
new List<SnapshotLitter>
|
||||
{
|
||||
new("Wurf1", "2023-01-01", "mother", 3, true, new List<SnapshotChild>
|
||||
{
|
||||
new("Kind1", "2023-01-01", "female"),
|
||||
}),
|
||||
});
|
||||
|
||||
var other = new GerbilSnapshot(
|
||||
self,
|
||||
new SnapshotParent("Papa Neu", "2020-01-01", "aa CC"), // different father
|
||||
null,
|
||||
new List<SnapshotLitter>
|
||||
{
|
||||
new("Wurf1", "2023-01-01", "mother", 3, true, new List<SnapshotChild>
|
||||
{
|
||||
new("Kind1", "2023-01-01", "female"),
|
||||
new("Kind2", "2023-01-02", "male"), // extra child
|
||||
}),
|
||||
});
|
||||
|
||||
var diffs = GerbilSnapshotService.Diff(golden, other);
|
||||
Assert.NotEmpty(diffs);
|
||||
Assert.Contains(diffs, d => d.Path == "father");
|
||||
Assert.Contains(diffs, d => d.Path.StartsWith("wurf["));
|
||||
}
|
||||
}
|
||||
}
|
||||
151
GerbilManager.Tests/VerifiedGoldenTests.cs
Normal file
151
GerbilManager.Tests/VerifiedGoldenTests.cs
Normal file
@@ -0,0 +1,151 @@
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Golden regression fixture: ingests the REAL <c>tools/import/output/resolved_import.json</c>
|
||||
/// (gitignored, local only) and asserts that every verified key animal's Akte still matches its
|
||||
/// frozen golden snapshot in <c>tools/import/verified-golden.json</c> (committed fixture).
|
||||
///
|
||||
/// Both files are optional: if EITHER is missing the test skips cleanly (no failure), so it is a
|
||||
/// no-op on CI / fresh clones but a real guard on the breeder's machine once she has verified
|
||||
/// animals and exported the fixture.
|
||||
/// </summary>
|
||||
public class VerifiedGoldenTests
|
||||
{
|
||||
private readonly ITestOutputHelper _out;
|
||||
public VerifiedGoldenTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
private sealed record GoldenEntry(Guid GerbilId, string? Name, DateTimeOffset? VerifiedAt, GerbilSnapshot? Snapshot);
|
||||
|
||||
private static readonly JsonSerializerOptions Web = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private static string? FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir is not null)
|
||||
{
|
||||
if (Directory.Exists(Path.Combine(dir.FullName, "tools", "import")))
|
||||
return dir.FullName;
|
||||
dir = dir.Parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Verified_animals_still_match_their_golden_snapshot()
|
||||
{
|
||||
var repoRoot = FindRepoRoot();
|
||||
if (repoRoot is null)
|
||||
{
|
||||
_out.WriteLine("SKIP: repo root (tools/import) not found from the test host.");
|
||||
return;
|
||||
}
|
||||
|
||||
var goldenPath = Path.Combine(repoRoot, "tools", "import", "verified-golden.json");
|
||||
var outputDir = Path.Combine(repoRoot, "tools", "import", "output");
|
||||
var resolvedPath = Path.Combine(outputDir, "resolved_import.json");
|
||||
|
||||
if (!File.Exists(goldenPath))
|
||||
{
|
||||
_out.WriteLine($"SKIP: golden fixture not present ({goldenPath}).");
|
||||
return;
|
||||
}
|
||||
if (!File.Exists(resolvedPath))
|
||||
{
|
||||
_out.WriteLine($"SKIP: resolved_import.json not present ({resolvedPath}) — run the import pipeline.");
|
||||
return;
|
||||
}
|
||||
|
||||
List<GoldenEntry>? golden;
|
||||
try
|
||||
{
|
||||
golden = JsonSerializer.Deserialize<List<GoldenEntry>>(await File.ReadAllTextAsync(goldenPath), Web);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_out.WriteLine($"SKIP: could not parse golden fixture: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
if (golden is null || golden.Count == 0)
|
||||
{
|
||||
_out.WriteLine("SKIP: golden fixture is empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ingest the real resolved_import.json into an InMemory DB.
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseInMemoryDatabase("verified-golden-" + Guid.NewGuid().ToString("N"))
|
||||
.Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated();
|
||||
|
||||
var photoDir = Path.Combine(Path.GetTempPath(), "verified-golden-photos-" + Guid.NewGuid().ToString("N"));
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
{ "Import:SourcePath", outputDir },
|
||||
{ "Photos:RootPath", photoDir },
|
||||
})
|
||||
.Build();
|
||||
|
||||
try
|
||||
{
|
||||
var result = await new IngestResolvedService(db, config, null!).RunAsync();
|
||||
Assert.StartsWith("Ingestion successful!", result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_out.WriteLine($"SKIP: ingest of the real resolved_import.json failed: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(photoDir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
var mismatches = new List<string>();
|
||||
int checkedCount = 0;
|
||||
foreach (var entry in golden)
|
||||
{
|
||||
if (entry.Snapshot is null) continue;
|
||||
checkedCount++;
|
||||
GerbilSnapshot? actual;
|
||||
try
|
||||
{
|
||||
actual = await GerbilSnapshotService.BuildSnapshotAsync(db, entry.GerbilId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
mismatches.Add($"• {entry.Name ?? entry.GerbilId.ToString()}: Akte konnte nicht gebaut werden — {ex.Message}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (actual is null)
|
||||
{
|
||||
mismatches.Add($"• {entry.Name ?? entry.GerbilId.ToString()}: fehlt im Import (verifiziertes Tier verschwunden).");
|
||||
continue;
|
||||
}
|
||||
|
||||
var diffs = GerbilSnapshotService.Diff(entry.Snapshot, actual);
|
||||
if (diffs.Count > 0)
|
||||
{
|
||||
var fields = string.Join("; ", diffs.Select(d => $"{d.Path}: golden='{d.GoldenValue}' ≠ import='{d.OtherValue}'"));
|
||||
mismatches.Add($"• {entry.Name ?? entry.GerbilId.ToString()}: {diffs.Count} Abweichung(en) — {fields}");
|
||||
}
|
||||
}
|
||||
|
||||
_out.WriteLine($"Golden-Fixture geprüft: {checkedCount} verifizierte Tiere, {mismatches.Count} abweichend.");
|
||||
if (mismatches.Count > 0)
|
||||
{
|
||||
var report = $"Golden-Regression fehlgeschlagen — {mismatches.Count} von {checkedCount} verifizierten Tieren weichen vom eingefrorenen Snapshot ab:\n"
|
||||
+ string.Join("\n", mismatches);
|
||||
Assert.Fail(report);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
204
GerbilManager.Tests/WaitingListEndpointTests.cs
Normal file
204
GerbilManager.Tests/WaitingListEndpointTests.cs
Normal file
@@ -0,0 +1,204 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WAITLIST (RennmausPro nachfrage_tb): the prospective-buyer waiting list.
|
||||
/// - Full CRUD round-trip: POST -> GET (list + by id) -> PUT (status change) -> DELETE.
|
||||
/// - Validation: an entry with neither contact nor name, and an unknown status, are 400.
|
||||
/// - CRITICAL: waiting-list rows survive the import re-ingest wipe (loose, FK-free ContactId).
|
||||
/// </summary>
|
||||
public class WaitingListEndpointTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
public WaitingListEndpointTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Crud_round_trip()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
// CREATE
|
||||
var contactId = Guid.NewGuid();
|
||||
var create = await client.PostAsJsonAsync("/waiting-list", new
|
||||
{
|
||||
contactId,
|
||||
contactName = "Familie Sonntag",
|
||||
wishColor = "Schwarz",
|
||||
wishGender = "female",
|
||||
requestedAt = "2026-06-01T00:00:00Z",
|
||||
status = "offen",
|
||||
note = "möchte zwei Weibchen",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
|
||||
var created = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement;
|
||||
var id = created.GetProperty("id").GetString()!;
|
||||
Assert.Equal("offen", created.GetProperty("status").GetString());
|
||||
Assert.Equal("Schwarz", created.GetProperty("wishColor").GetString());
|
||||
Assert.Equal(contactId.ToString(), created.GetProperty("contactId").GetString());
|
||||
|
||||
// GET by id
|
||||
var byId = JsonDocument.Parse(await client.GetStringAsync($"/waiting-list/{id}")).RootElement;
|
||||
Assert.Equal("Familie Sonntag", byId.GetProperty("contactName").GetString());
|
||||
|
||||
// LIST contains it
|
||||
var list = JsonDocument.Parse(await client.GetStringAsync("/waiting-list")).RootElement;
|
||||
Assert.Contains(list.EnumerateArray(), e => e.GetProperty("id").GetString() == id);
|
||||
|
||||
// UPDATE: set fulfilled
|
||||
var update = await client.PutAsJsonAsync($"/waiting-list/{id}", new
|
||||
{
|
||||
contactId,
|
||||
contactName = "Familie Sonntag",
|
||||
wishColor = "Schwarz",
|
||||
wishGender = "female",
|
||||
requestedAt = "2026-06-01T00:00:00Z",
|
||||
status = "erfuellt",
|
||||
note = "erledigt",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.OK, update.StatusCode);
|
||||
var updated = JsonDocument.Parse(await update.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("erfuellt", updated.GetProperty("status").GetString());
|
||||
|
||||
// DELETE
|
||||
var del = await client.DeleteAsync($"/waiting-list/{id}");
|
||||
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
|
||||
var after = await client.GetAsync($"/waiting-list/{id}");
|
||||
Assert.Equal(HttpStatusCode.NotFound, after.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Post_rejects_empty_contact_and_name()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var resp = await client.PostAsJsonAsync("/waiting-list", new { status = "offen", wishColor = "Gold" });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Post_rejects_unknown_status()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var resp = await client.PostAsJsonAsync("/waiting-list", new { contactName = "Test", status = "irgendwas" });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_status_defaults_to_offen()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var resp = await client.PostAsJsonAsync("/waiting-list", new { contactName = "Ohne Status" });
|
||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
var created = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("offen", created.GetProperty("status").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitingList_survives_ingest_wipe()
|
||||
{
|
||||
// Fresh in-memory DB seeded with a resolved import file (mirrors FeedbackEndpointTests).
|
||||
var dir = Path.Combine(Path.GetTempPath(), "waitlist-ingest-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var contactId = Guid.NewGuid();
|
||||
var fatherId = Guid.NewGuid();
|
||||
var motherId = Guid.NewGuid();
|
||||
var litterId = Guid.NewGuid();
|
||||
|
||||
var data = new
|
||||
{
|
||||
Contacts = new[]
|
||||
{
|
||||
new { Id = contactId, Name = "Test Breeder", Email = "t@e.de", Phone = "", Address = "", Notes = (string?)null, IsBreeder = true, IsReceiver = false, NameSuffix = (string?)null, Provenance = (string?)null }
|
||||
},
|
||||
Litters = new[]
|
||||
{
|
||||
new { Id = litterId, Name = "Wurf A", Date = "2026-01-01", TotalBorn = 5, DeathsWithin8Weeks = 0, FatherId = fatherId, MotherId = motherId, ExpectedGoHomeDate = (string?)null, Notes = "", PairingCode = "PC01", ExternalRef = "ext-litter-1", LitterLetter = "A" }
|
||||
},
|
||||
Gerbils = new[]
|
||||
{
|
||||
Animal(fatherId, "Papa", "male", contactId),
|
||||
Animal(motherId, "Mama", "female", contactId),
|
||||
},
|
||||
GerbilPhotos = Array.Empty<object>(),
|
||||
};
|
||||
File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(data));
|
||||
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseInMemoryDatabase("waitlist-ingest-" + Guid.NewGuid().ToString("N"))
|
||||
.Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated();
|
||||
|
||||
// A waiting-list entry referencing the contact that the wipe will delete.
|
||||
var entryId = Guid.NewGuid();
|
||||
db.WaitingListEntries.Add(new WaitingListEntry
|
||||
{
|
||||
Id = entryId,
|
||||
ContactId = contactId,
|
||||
ContactName = "Test Breeder",
|
||||
WishColor = "Schwarz",
|
||||
WishGender = "female",
|
||||
RequestedAt = DateTime.UtcNow,
|
||||
Status = "offen",
|
||||
Note = "wartet",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", dir } })
|
||||
.Build();
|
||||
|
||||
// Run the ingest wipe + reload.
|
||||
var result = await new IngestResolvedService(db, config, null!).RunAsync();
|
||||
Assert.Contains("Ingestion successful!", result);
|
||||
|
||||
// Contacts were wiped & re-created, but the waiting-list entry is untouched.
|
||||
var survivor = await db.WaitingListEntries.SingleAsync(e => e.Id == entryId);
|
||||
Assert.Equal(contactId, survivor.ContactId); // loose id preserved even though the contact row was deleted/recreated
|
||||
Assert.Equal("Test Breeder", survivor.ContactName);
|
||||
Assert.Equal("offen", survivor.Status);
|
||||
Assert.Equal(1, await db.WaitingListEntries.CountAsync());
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static object Animal(Guid id, string name, string gender, Guid contactId) => new
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Gender = gender,
|
||||
Status = "Breeding",
|
||||
LitterId = (Guid?)null,
|
||||
OriginContactId = contactId,
|
||||
ReceiverContactId = (Guid?)null,
|
||||
EnclosureId = (Guid?)null,
|
||||
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"),
|
||||
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-" + name,
|
||||
RawImportData = "{}",
|
||||
OriginBreeder = "Test Zucht",
|
||||
NameSearch = name.ToLowerInvariant(),
|
||||
CharacterTraits = Array.Empty<string>(),
|
||||
CharacterNote = (string?)null,
|
||||
IsDeaf = false,
|
||||
IsResident = true,
|
||||
};
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
{
|
||||
"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);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,488 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
public class ApplicationContext : DbContext
|
||||
{
|
||||
public ApplicationContext(DbContextOptions options)
|
||||
: base(options)
|
||||
public ApplicationContext(DbContextOptions options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
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>();
|
||||
public DbSet<Feedback> Feedback => Set<Feedback>();
|
||||
public DbSet<FeedbackAttachment> FeedbackAttachments => Set<FeedbackAttachment>();
|
||||
public DbSet<WebPushSubscription> WebPushSubscriptions => Set<WebPushSubscription>();
|
||||
public DbSet<AcquisitionRecord> AcquisitionRecords => Set<AcquisitionRecord>();
|
||||
public DbSet<SaleReservation> SaleReservations => Set<SaleReservation>();
|
||||
public DbSet<WaitingListEntry> WaitingListEntries => Set<WaitingListEntry>();
|
||||
public DbSet<ReturnRecord> ReturnRecords => Set<ReturnRecord>();
|
||||
public DbSet<ExhibitionResult> ExhibitionResults => Set<ExhibitionResult>();
|
||||
public DbSet<GerbilOverride> GerbilOverrides => Set<GerbilOverride>();
|
||||
|
||||
// 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)
|
||||
{
|
||||
modelBuilder.Entity<Gerbil>().ToTable("Gerbils");
|
||||
modelBuilder.Entity<Litter>().ToTable("Litters");
|
||||
modelBuilder.Entity<Breeder>().ToTable("Breeders");
|
||||
// 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>().HasOne(entity => entity.Litter);
|
||||
modelBuilder.Entity<Litter>().HasOne(entity => entity.Father);
|
||||
modelBuilder.Entity<Litter>().HasOne(entity => entity.Mother);
|
||||
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>();
|
||||
|
||||
modelBuilder
|
||||
.Entity<Gerbil>()
|
||||
.Property(d => d.Gender)
|
||||
.HasConversion(new EnumToStringConverter<Gender>());
|
||||
// Residency defaults to true (own stock unless explicitly marked external).
|
||||
e.Property(g => g.IsResident).HasDefaultValue(true);
|
||||
|
||||
// Manual-flag defaults false: every pre-existing / imported row is import-driven.
|
||||
// Only POST /gerbils sets it true, so the ingest wipe never touches manual animals.
|
||||
e.Property(g => g.IsManual).HasDefaultValue(false);
|
||||
|
||||
// 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");
|
||||
|
||||
e.Property(l => l.IsManual).HasDefaultValue(false);
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
// FEEDBACK: deliberately relationship-free. GerbilId/LitterId are plain nullable
|
||||
// Guid columns (no navigation properties → EF creates NO foreign key), so the
|
||||
// import re-ingest wipe of Gerbils/Litters never cascades into — or breaks —
|
||||
// feedback rows. They survive re-ingest, which is the whole point.
|
||||
modelBuilder.Entity<Feedback>(e =>
|
||||
{
|
||||
e.HasIndex(f => f.CreatedAt);
|
||||
});
|
||||
|
||||
// ERWERB: like Feedback, deliberately relationship-free. GerbilId/SourceContactId
|
||||
// are plain nullable Guid columns (no navigation properties → EF creates NO foreign
|
||||
// key), so the import re-ingest wipe of Gerbils/Contacts never cascades into — or
|
||||
// breaks — acquisition rows. They survive re-ingest, which is the whole point.
|
||||
modelBuilder.Entity<AcquisitionRecord>(e =>
|
||||
{
|
||||
e.Property(a => a.Price).HasPrecision(10, 2);
|
||||
e.HasIndex(a => a.GerbilId);
|
||||
});
|
||||
|
||||
// ABGABE-STATUS: deliberately relationship-free (same rationale as Feedback).
|
||||
// GerbilId/ReservedForContactId are plain Guid columns (no navigation properties →
|
||||
// EF creates NO foreign key), so the import re-ingest wipe of Gerbils/Contacts never
|
||||
// cascades into — or breaks — reservation rows. They survive re-ingest by design.
|
||||
modelBuilder.Entity<SaleReservation>(e =>
|
||||
{
|
||||
e.Property(r => r.Price).HasPrecision(10, 2);
|
||||
e.HasIndex(r => r.GerbilId);
|
||||
});
|
||||
|
||||
// WAITLIST: same relationship-free pattern as Feedback. ContactId is a plain
|
||||
// nullable Guid column (no navigation property → EF creates NO foreign key), so
|
||||
// the import re-ingest wipe of Contacts never cascades into — or breaks —
|
||||
// waiting-list rows. They survive re-ingest, which is the whole point.
|
||||
modelBuilder.Entity<WaitingListEntry>(e =>
|
||||
{
|
||||
e.HasIndex(w => w.CreatedAt);
|
||||
});
|
||||
|
||||
// RÜCKNAHMEN (getback_tb): wie Feedback bewusst beziehungsfrei. GerbilId/
|
||||
// FromContactId sind einfache nullable Guid-Spalten (keine Navigation → EF legt
|
||||
// KEINEN Foreign Key an), damit der Import-Re-Ingest-Wipe von Gerbils/Contacts
|
||||
// diese Zeilen weder löscht noch bricht. Sie überleben den Re-Ingest.
|
||||
modelBuilder.Entity<ReturnRecord>(e =>
|
||||
{
|
||||
e.Property(r => r.ReturnPrice).HasPrecision(10, 2);
|
||||
e.Property(r => r.OriginalPrice).HasPrecision(10, 2);
|
||||
e.HasIndex(r => r.GerbilId);
|
||||
e.HasIndex(r => r.CreatedAt);
|
||||
});
|
||||
|
||||
// EXHIBITION: deliberately relationship-free (same pattern as Feedback). GerbilId is a
|
||||
// plain nullable Guid column (no navigation property → EF creates NO foreign key), so the
|
||||
// import re-ingest wipe of Gerbils never cascades into or breaks exhibition rows.
|
||||
modelBuilder.Entity<ExhibitionResult>(e =>
|
||||
{
|
||||
e.HasIndex(x => x.GerbilId);
|
||||
});
|
||||
|
||||
// VERIFIED/PROTECTED OVERRIDE: deliberately relationship-free (same pattern as Feedback).
|
||||
// GerbilId is a plain Guid column (no navigation property → EF creates NO foreign key), so
|
||||
// the import re-ingest wipe of Gerbils never cascades into — or breaks — override rows. They
|
||||
// survive re-ingest and are re-applied on top of the fresh import (the freeze), which is the
|
||||
// whole point.
|
||||
modelBuilder.Entity<GerbilOverride>(e =>
|
||||
{
|
||||
e.HasIndex(o => o.GerbilId).IsUnique();
|
||||
});
|
||||
|
||||
// Manual-flag default false for Contact (imported/pre-existing rows are import-driven).
|
||||
modelBuilder.Entity<Contact>().Property(c => c.IsManual).HasDefaultValue(false);
|
||||
|
||||
// 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äß § 18 Abs. 1 MStV\\n\\n[Vor- und Nachname]\\n[Straße und Hausnummer]\\n[PLZ und Ort]\\n\\n## Kontakt\\n\\nE-Mail: [E-Mail-Adresse]\\nTelefon: [Telefonnummer]\\n\\n---\\n\\nDiese Seite stellt eine private Hobbyzucht mongolischer Rennmäuse ohne Gewinnerzielungsabsicht vor.\\n\\n*Hinweis für den Seitenbetreiber: Name und ladungsfähige Anschrift (kein Postfach) sind Pflicht (§ 18 Abs. 1 MStV). Sollte die Zucht jemals gewerblich bzw. geschäftsmäßig betrieben werden, gelten zusätzlich § 5 DDG (vollständiges Anbieter-Impressum) und ggf. die Erlaubnispflicht nach § 11 TierSchG — dann zuständige Behörde und Aktenzeichen hier ergänzen. Kein Hinweis auf die EU-Streitbeilegungsplattform: diese wurde zum 20.07.2025 abgeschaltet (ein fortbestehender Link wäre irreführend).*"),
|
||||
(8, "datenschutz", "Datenschutz",
|
||||
"## Datenschutzerklärung\\n\\n## 1. Verantwortlicher\\n\\nVerantwortlich für die Datenverarbeitung auf dieser Webseite ist:\\n[Vor- und Nachname]\\n[Straße und Hausnummer]\\n[PLZ und Ort]\\nE-Mail: [E-Mail-Adresse]\\n\\n## 2. Hosting und Server-Logfiles\\n\\nDiese Webseite wird auf einem eigenen Server betrieben. Beim Aufruf der Seite verarbeitet der Server automatisch Informationen, die dein Browser übermittelt, in sogenannten Server-Logfiles: IP-Adresse, Datum und Uhrzeit des Zugriffs, die aufgerufene Seite bzw. Datei, die übertragene Datenmenge sowie Browser und Betriebssystem.\\nDiese Verarbeitung ist technisch erforderlich, um die Webseite auszuliefern und ihren sicheren und stabilen Betrieb zu gewährleisten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse am störungsfreien Betrieb und an der Sicherheit). Die Logfiles werden nach spätestens [z. B. 7] Tagen gelöscht und nicht mit anderen Daten zusammengeführt.\\n\\n## 3. Cookies, Tracking und externe Dienste\\n\\nDiese Webseite verwendet keine Cookies, kein Tracking und keine Analyse- oder Marketing-Dienste. Es werden keine externen Inhalte Dritter (z. B. Schriftarten, Karten, Videos, CDNs) nachgeladen.\\n\\n## 4. Kontaktaufnahme\\n\\nWenn du uns per E-Mail kontaktierst, verarbeiten wir deine Angaben (z. B. Name, E-Mail-Adresse, Inhalt der Nachricht) ausschließlich zur Bearbeitung deiner Anfrage. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO bzw. Art. 6 Abs. 1 lit. f DSGVO. Die Daten werden gelöscht, sobald sie nicht mehr benötigt werden und keine gesetzlichen Aufbewahrungspflichten entgegenstehen.\\n\\n## 5. Empfänger der Daten\\n\\nEine Weitergabe deiner Daten an Dritte findet nicht statt. Eine Datenübermittlung in Länder außerhalb der EU bzw. des EWR erfolgt nicht.\\n\\n## 6. Deine Rechte\\n\\nDu hast jederzeit das Recht auf:\\n- Auskunft über die zu deiner Person gespeicherten Daten (Art. 15 DSGVO)\\n- Berichtigung unrichtiger Daten (Art. 16 DSGVO)\\n- Löschung (Art. 17 DSGVO)\\n- Einschränkung der Verarbeitung (Art. 18 DSGVO)\\n- Datenübertragbarkeit (Art. 20 DSGVO)\\n- Widerspruch gegen die Verarbeitung (Art. 21 DSGVO)\\n\\nZur Ausübung genügt eine formlose Nachricht an die oben genannte E-Mail-Adresse.\\n\\n## 7. Beschwerderecht bei der Aufsichtsbehörde\\n\\nDu hast das Recht, dich bei einer Datenschutz-Aufsichtsbehörde zu beschweren (Art. 77 DSGVO). Zuständig ist die Aufsichtsbehörde deines Bundeslandes bzw. des Seitenbetreibers: [zuständige Landesdatenschutzbehörde eintragen].\\n\\n## 8. Keine automatisierte Entscheidungsfindung\\n\\nEine automatisierte Entscheidungsfindung einschließlich Profiling nach Art. 22 DSGVO findet nicht statt.\\n\\n---\\n\\n*Hinweis für den Seitenbetreiber: Platzhalter in eckigen Klammern ausfüllen. Aufbewahrungsdauer der Logfiles an deinen Server bzw. Reverse-Proxy anpassen. Sobald externe Dienste oder ein Kontaktformular eingebunden werden, diesen Text ergänzen. Stand: [Datum eintragen].*"),
|
||||
};
|
||||
|
||||
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),
|
||||
// GEN-6 (Tickets bde4ec70/f89e95ad): C-Zygotie unterscheidet Platin (CC),
|
||||
// Saphir (Ccchm) und Platin-Hell (Cch) — vorher waren alle drei "aa CC ...".
|
||||
("Saphir", "aa Ccchm 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 Cch 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),
|
||||
// GEN-5 (ticket 5826e8e2): *Fuchsschimmel = HET ef/e (internal 'efe'),
|
||||
// not hom ef/ef — a Schimmel-modified Fox. Pure *schimmel stay efef.
|
||||
("Polarfuchsschimmel", "AA CC DD efe gg PP spsp rere", 37),
|
||||
("Algierfuchsschimmel", "AA CC DD efe GG PP spsp rere", 38),
|
||||
("Kohlfuchsschimmel", "aa CC DD efe GG PP spsp rere", 39),
|
||||
("Blaufuchsschimmel", "aa CC DD efe 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 efe 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 efe GG pp spsp rere", 46),
|
||||
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere", 47),
|
||||
("Kohlfuchsschimmel, hell","aa CC DD efe 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),
|
||||
// GEN-5 (ticket 3deab547): Blaufuchs is black-eyed; dilution is P-independent.
|
||||
("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,92 +0,0 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.DAL;
|
||||
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 UnitOfWork unitOfWork;
|
||||
|
||||
public BreederController(UnitOfWork unitOfWork)
|
||||
{
|
||||
this.unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
//GET /gerbils
|
||||
[HttpGet]
|
||||
public IEnumerable<BreederDto> GetBreeders()
|
||||
{
|
||||
var items = unitOfWork.BreederRepository.Get().Select( breeder => breeder.AsDto());
|
||||
return items;
|
||||
}
|
||||
|
||||
//GET /gerbils
|
||||
[HttpGet("{id}")]
|
||||
public ActionResult<BreederDto> GetBreeder(Guid id)
|
||||
{
|
||||
var item = unitOfWork.BreederRepository.GetByID(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.unitOfWork.BreederRepository.Insert(breeder);
|
||||
this.unitOfWork.Save();
|
||||
|
||||
return CreatedAtAction(nameof(GetBreeder), new { id = breeder.Id}, breeder.AsDto());
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public ActionResult UpdateBreeder(Guid id, UpdateBreederDto breederDto)
|
||||
{
|
||||
var existingItem = unitOfWork.BreederRepository.GetByID(id);
|
||||
if(existingItem is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
Breeder updatedBreeder = existingItem with {
|
||||
Name = breederDto?.Name!
|
||||
};
|
||||
|
||||
unitOfWork.BreederRepository.Update(updatedBreeder);
|
||||
this.unitOfWork.Save();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public ActionResult DeleteBreeder(Guid id)
|
||||
{
|
||||
var existingLitter = unitOfWork.BreederRepository.GetByID(id);
|
||||
|
||||
if(existingLitter is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
unitOfWork.BreederRepository.Delete(existingLitter);
|
||||
this.unitOfWork.Save();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.DAL;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using GerbilManagerWebAPI.Converter;
|
||||
|
||||
namespace GerbilManagerWebAPI.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("gerbils")]
|
||||
public class GerbilsController : ControllerBase
|
||||
{
|
||||
private readonly UnitOfWork unitOfWork;
|
||||
|
||||
public GerbilsController(UnitOfWork unitOfWork)
|
||||
{
|
||||
this.unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
//GET /gerbils
|
||||
[HttpGet]
|
||||
public IEnumerable<GerbilDto> GetGerbils([FromQuery]string? name)
|
||||
{
|
||||
if(name != null)
|
||||
{
|
||||
return unitOfWork.GerbilRepository.Get(filter: x => x.Name == name).Select( gerbil => gerbil.AsDto());
|
||||
}
|
||||
|
||||
var items = unitOfWork.GerbilRepository.Get().Select( gerbil => gerbil.AsDto());
|
||||
return items;
|
||||
}
|
||||
|
||||
//GET /gerbils
|
||||
[HttpGet("{id}")]
|
||||
public ActionResult<GerbilDto> GetGerbil(Guid id)
|
||||
{
|
||||
var item = unitOfWork.GerbilRepository.GetByID(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,
|
||||
Gender = (Gender) (int) dto.Gender
|
||||
};
|
||||
|
||||
if(dto.Litter != Guid.Empty)
|
||||
{
|
||||
gerbil.Litter = unitOfWork.LitterRepository.GetByID(dto.Litter);
|
||||
}
|
||||
|
||||
if(dto.Breeder != Guid.Empty)
|
||||
{
|
||||
gerbil.Breeder = unitOfWork.BreederRepository.GetByID(dto.Breeder);
|
||||
}
|
||||
|
||||
this.unitOfWork.GerbilRepository.Insert(gerbil);
|
||||
this.unitOfWork.Save();
|
||||
|
||||
return CreatedAtAction(nameof(GetGerbil), new { id = gerbil.Id}, gerbil.AsDto());
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public ActionResult UpdateGerbil(Guid id, UpdateGerbilDto gerbilDto)
|
||||
{
|
||||
var existingItem = unitOfWork.GerbilRepository.GetByID(id);
|
||||
if(existingItem is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
Gerbil updatedGerbil = existingItem with{
|
||||
Breeder = unitOfWork.BreederRepository.GetByID(gerbilDto?.Breeder ?? Guid.Empty),
|
||||
Litter = unitOfWork.LitterRepository.GetByID(gerbilDto?.Litter ?? Guid.Empty),
|
||||
Gender = (Gender) (int) gerbilDto?.Gender!,
|
||||
Name = gerbilDto?.Name!
|
||||
};
|
||||
|
||||
unitOfWork.GerbilRepository.Update(updatedGerbil);
|
||||
this.unitOfWork.Save();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public ActionResult DeleteGerbil(Guid id)
|
||||
{
|
||||
var existingGerbil = unitOfWork.GerbilRepository.GetByID(id);
|
||||
|
||||
if(existingGerbil is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
unitOfWork.GerbilRepository.Delete(existingGerbil);
|
||||
this.unitOfWork.Save();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.DAL;
|
||||
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 UnitOfWork unitOfWork;
|
||||
|
||||
public LittersController(UnitOfWork unitOfWork)
|
||||
{
|
||||
this.unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
//GET /gerbils
|
||||
[HttpGet]
|
||||
public IEnumerable<LitterDto> GetLitters()
|
||||
{
|
||||
var items = unitOfWork.LitterRepository.Get(includeProperties: "Father,Mother").Select( litter => litter.AsDto());
|
||||
return items;
|
||||
}
|
||||
|
||||
//GET /gerbils
|
||||
[HttpGet("{id}")]
|
||||
public ActionResult<LitterDto> GetLitter(Guid id)
|
||||
{
|
||||
var item = unitOfWork.LitterRepository.GetByID(id).AsDto();
|
||||
|
||||
if(item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
[HttpPost()]
|
||||
public ActionResult<LitterDto> CreateLitter(CreateLitterDto dto)
|
||||
{
|
||||
//Sanity check if father is male and mother is female
|
||||
var father = this.unitOfWork.GerbilRepository.GetByID(dto.Father ?? Guid.Empty);
|
||||
var mother = this.unitOfWork.GerbilRepository.GetByID(dto.Mother ?? Guid.Empty);
|
||||
if(father?.Gender == Gender.female || mother?.Gender == Gender.male)
|
||||
{
|
||||
return BadRequest($"The mothers gender is {mother?.Gender.ToString() ?? "unknown"} and the fathers {father?.Gender.ToString() ?? "unknown"}.");
|
||||
}
|
||||
|
||||
Litter litter = new Litter{
|
||||
Id = Guid.NewGuid(),
|
||||
Date = dto.Date,
|
||||
Father = this.unitOfWork.GerbilRepository.GetByID(dto.Father ?? Guid.Empty),
|
||||
Mother = this.unitOfWork.GerbilRepository.GetByID(dto.Mother ?? Guid.Empty),
|
||||
Name = dto.Name,
|
||||
Strength = dto.Strength
|
||||
};
|
||||
|
||||
this.unitOfWork.LitterRepository.Insert(litter);
|
||||
this.unitOfWork.Save();
|
||||
|
||||
return CreatedAtAction(nameof(GetLitter), new { id = litter.Id}, litter.AsDto());
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public ActionResult UpdateLitter(Guid id, UpdateLitterDto litterDto)
|
||||
{
|
||||
var existingItem = unitOfWork.LitterRepository.GetByID(id);
|
||||
if(existingItem is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
Litter updatedLitter = existingItem with{
|
||||
Date = litterDto.Date ?? DateTime.Now,
|
||||
Strength = litterDto.Strength,
|
||||
Father = this.unitOfWork.GerbilRepository.GetByID(litterDto.Father ?? Guid.Empty),
|
||||
Mother = this.unitOfWork.GerbilRepository.GetByID(litterDto.Mother ?? Guid.Empty),
|
||||
Name = litterDto?.Name!
|
||||
};
|
||||
|
||||
unitOfWork.LitterRepository.Update(updatedLitter);
|
||||
this.unitOfWork.Save();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public ActionResult DeleteLitter(Guid id)
|
||||
{
|
||||
var existingLitter = unitOfWork.LitterRepository.GetByID(id);
|
||||
|
||||
if(existingLitter is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
unitOfWork.LitterRepository.Delete(existingLitter);
|
||||
this.unitOfWork.Save();
|
||||
|
||||
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,20 +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{
|
||||
Id = gerbil.Id,
|
||||
Gender = (GenderDto) (int) gerbil.Gender,
|
||||
Name = gerbil.Name,
|
||||
Breeder = gerbil.Breeder?.AsDto(),
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
using System.Linq.Expressions;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.DAL
|
||||
{
|
||||
public class GenericRepository<TEntity> where TEntity : class
|
||||
{
|
||||
internal ApplicationContext context;
|
||||
internal DbSet<TEntity> dbSet;
|
||||
|
||||
public GenericRepository(ApplicationContext context)
|
||||
{
|
||||
this.context = context;
|
||||
this.dbSet = context.Set<TEntity>();
|
||||
}
|
||||
|
||||
public virtual IEnumerable<TEntity> Get(
|
||||
Expression<Func<TEntity, bool>> filter = null!,
|
||||
Func<IQueryable<TEntity>, IOrderedQueryable<TEntity>> orderBy = null!,
|
||||
string includeProperties = "")
|
||||
{
|
||||
IQueryable<TEntity> query = dbSet;
|
||||
|
||||
if (filter != null)
|
||||
{
|
||||
query = query.Where(filter);
|
||||
}
|
||||
|
||||
foreach (var includeProperty in includeProperties.Split
|
||||
(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
query = query.Include(includeProperty);
|
||||
}
|
||||
|
||||
if (orderBy != null)
|
||||
{
|
||||
return orderBy(query).ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
return query.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public virtual TEntity GetByID(object id)
|
||||
{
|
||||
return dbSet?.Find(id)!;
|
||||
}
|
||||
|
||||
public virtual void Insert(TEntity entity)
|
||||
{
|
||||
dbSet.Add(entity);
|
||||
}
|
||||
|
||||
public virtual void Delete(object id)
|
||||
{
|
||||
TEntity entityToDelete = dbSet?.Find(id)!;
|
||||
if (entityToDelete is not null) Delete(entityToDelete);
|
||||
}
|
||||
|
||||
public virtual void Delete(TEntity entityToDelete)
|
||||
{
|
||||
if (context.Entry(entityToDelete).State == EntityState.Detached)
|
||||
{
|
||||
dbSet.Attach(entityToDelete);
|
||||
}
|
||||
dbSet.Remove(entityToDelete);
|
||||
}
|
||||
|
||||
public virtual void Update(TEntity entityToUpdate)
|
||||
{
|
||||
dbSet.Attach(entityToUpdate);
|
||||
context.Entry(entityToUpdate).State = EntityState.Modified;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.DAL
|
||||
{
|
||||
public class UnitOfWork : IDisposable
|
||||
{
|
||||
private ApplicationContext context;
|
||||
private GenericRepository<Gerbil> gerbilRepository = null!;
|
||||
private GenericRepository<Litter> litterRepository = null!;
|
||||
private GenericRepository<Breeder> breederRepository = null!;
|
||||
|
||||
public GenericRepository<Gerbil> GerbilRepository
|
||||
{
|
||||
get
|
||||
{
|
||||
if (this.gerbilRepository == null)
|
||||
{
|
||||
this.gerbilRepository = new GenericRepository<Gerbil>(context);
|
||||
}
|
||||
return gerbilRepository;
|
||||
}
|
||||
}
|
||||
|
||||
public GenericRepository<Litter> LitterRepository
|
||||
{
|
||||
get
|
||||
{
|
||||
if (this.litterRepository == null)
|
||||
{
|
||||
this.litterRepository = new GenericRepository<Litter>(context);
|
||||
}
|
||||
return litterRepository;
|
||||
}
|
||||
}
|
||||
|
||||
public GenericRepository<Breeder> BreederRepository
|
||||
{
|
||||
get
|
||||
{
|
||||
if (this.breederRepository == null)
|
||||
{
|
||||
this.breederRepository = new GenericRepository<Breeder>(context);
|
||||
}
|
||||
return breederRepository;
|
||||
}
|
||||
}
|
||||
|
||||
public UnitOfWork(ApplicationContext context) => this.context = context;
|
||||
|
||||
public void Save()
|
||||
{
|
||||
context.SaveChanges();
|
||||
}
|
||||
|
||||
private bool disposed = false;
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (!this.disposed)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
context.Dispose();
|
||||
}
|
||||
}
|
||||
this.disposed = true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,28 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base
|
||||
WORKDIR /app
|
||||
EXPOSE 80
|
||||
# Build context: repo root (includes GerbilManager.ServiceDefaults).
|
||||
# docker build -f GerbilManagerWebAPI/Dockerfile -t gerbilmanager-api .
|
||||
|
||||
ENV ASPNETCORE_URLS=http://+:80
|
||||
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:7.0 AS build
|
||||
ARG configuration=Release
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY ["GerbilManagerWebAPI/GerbilManagerWebAPI.csproj", "GerbilManagerWebAPI/"]
|
||||
RUN dotnet restore "GerbilManagerWebAPI/GerbilManagerWebAPI.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/GerbilManagerWebAPI"
|
||||
ARG configuration=Release
|
||||
RUN dotnet publish "GerbilManagerWebAPI.csproj" -c $configuration -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM base AS final
|
||||
# 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
|
||||
# curl für den Container-Healthcheck (das Basis-Image bringt weder curl noch wget mit;
|
||||
# ohne dieses würde der compose-Healthcheck dauerhaft fehlschlagen und api nie "healthy").
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# .NET 10 containers default to ASPNETCORE_HTTP_PORTS=8080 bound on all interfaces.
|
||||
EXPOSE 8080
|
||||
COPY --from=build /app/publish .
|
||||
ENTRYPOINT ["dotnet", "GerbilManagerWebAPI.dll"]
|
||||
|
||||
20
GerbilManagerWebAPI/Dtos/AcquisitionDtos.cs
Normal file
20
GerbilManagerWebAPI/Dtos/AcquisitionDtos.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
/// <summary>ERWERB: payload for POST/PUT /acquisitions (acquisition data per animal).</summary>
|
||||
public record AcquisitionInput(
|
||||
Guid? GerbilId,
|
||||
Guid? SourceContactId,
|
||||
DateOnly? Date,
|
||||
decimal? Price,
|
||||
string? Note);
|
||||
|
||||
/// <summary>ERWERB: response DTO for a stored acquisition record.</summary>
|
||||
public record AcquisitionDto(
|
||||
Guid Id,
|
||||
Guid? GerbilId,
|
||||
Guid? SourceContactId,
|
||||
DateOnly? Date,
|
||||
decimal? Price,
|
||||
string? Note,
|
||||
DateTimeOffset CreatedAt);
|
||||
}
|
||||
162
GerbilManagerWebAPI/Dtos/ApiDtos.cs
Normal file
162
GerbilManagerWebAPI/Dtos/ApiDtos.cs
Normal file
@@ -0,0 +1,162 @@
|
||||
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,
|
||||
string? Provenance,
|
||||
int? BirthOrder = null);
|
||||
|
||||
public record LitterDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
DateOnly? Date,
|
||||
int? TotalBorn,
|
||||
int? DeathsWithin8Weeks,
|
||||
int? Stillborn,
|
||||
Guid? FatherId,
|
||||
Guid? MotherId,
|
||||
DateOnly? ExpectedGoHomeDate,
|
||||
string? Notes,
|
||||
string? Provenance,
|
||||
bool ShowInChronicle);
|
||||
|
||||
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix, string? Provenance);
|
||||
|
||||
public record EnclosureDto(
|
||||
Guid Id, string Name, string? Notes,
|
||||
string? Size, int? Capacity,
|
||||
DateOnly? LastCleanedDate, int? CleaningCycleDays, DateOnly? NextCleaningDate);
|
||||
|
||||
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);
|
||||
|
||||
// VERIFIED/PROTECTED OVERRIDE -------------------------------------------
|
||||
// Status: "unchanged" = raw import agrees with the golden state; "drifted" = the raw import
|
||||
// differs from the golden state (see ImportDiff — a signal for what the importer might still
|
||||
// get wrong); "missing" = the verified animal no longer exists in the imported data.
|
||||
public record VerifiedGerbilDto(
|
||||
Guid GerbilId,
|
||||
string? EntityName,
|
||||
bool IsVerified,
|
||||
string Status,
|
||||
DateTimeOffset? VerifiedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string? Note,
|
||||
IReadOnlyList<string> ProtectedFields,
|
||||
IReadOnlyList<SnapshotDiffDto> ImportDiff);
|
||||
|
||||
/// <summary>One field difference between the golden state and the raw import.</summary>
|
||||
public record SnapshotDiffDto(string Path, string? GoldenValue, string? ImportValue);
|
||||
|
||||
/// <summary>Export entry for the committed regression fixture (verified-golden.json).</summary>
|
||||
public record VerifiedGoldenEntry(
|
||||
Guid GerbilId, string? Name, DateTimeOffset? VerifiedAt, Import.GerbilSnapshot? Snapshot);
|
||||
|
||||
// Request DTOs -----------------------------------------------------------
|
||||
|
||||
/// <summary>Body for POST /verified-gerbils/{id} — optional free-text note.</summary>
|
||||
public record VerifyGerbilInput(string? Note);
|
||||
|
||||
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);
|
||||
|
||||
/// <summary>QOL: Eltern direkt in der Tier-Akte setzen (PUT /gerbils/{id}/parents).
|
||||
/// Das Datenmodell bleibt unverändert — geschrieben werden die Eltern des GEBURTSWURFS.</summary>
|
||||
public record GerbilParentsInput(Guid? FatherId, Guid? MotherId);
|
||||
|
||||
/// <summary>Ergebnis von PUT /gerbils/{id}/parents: welcher Wurf die Eltern jetzt trägt, ob er
|
||||
/// dafür neu angelegt (LitterCreated) bzw. ein bestehender verknüpft wurde (LitterAttached) und
|
||||
/// wie viele Geschwister im selben Wurf die Änderung mitbetrifft.</summary>
|
||||
public record GerbilParentsResult(
|
||||
Guid? LitterId, string? LitterName, bool LitterCreated, bool LitterAttached,
|
||||
int SiblingCount, Guid? FatherId, Guid? MotherId);
|
||||
|
||||
public record LitterInput(
|
||||
string Name,
|
||||
DateOnly? Date,
|
||||
int? TotalBorn,
|
||||
int? DeathsWithin8Weeks,
|
||||
int? Stillborn,
|
||||
Guid? FatherId,
|
||||
Guid? MotherId,
|
||||
DateOnly? ExpectedGoHomeDate,
|
||||
string? Notes,
|
||||
bool? ShowInChronicle = null);
|
||||
|
||||
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,
|
||||
string? Size, int? Capacity,
|
||||
DateOnly? LastCleanedDate, int? CleaningCycleDays);
|
||||
|
||||
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 required 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);
|
||||
}
|
||||
40
GerbilManagerWebAPI/Dtos/ContractDtos.cs
Normal file
40
GerbilManagerWebAPI/Dtos/ContractDtos.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
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,
|
||||
// True, wenn die .docx im Vertrags-Dateiroot existiert. Importierte
|
||||
// Verträge haben KEINE Datei (Word-Download 404) -> Frontend blendet den
|
||||
// Word-Button aus; das PDF wird ohnehin aus den Daten neu erzeugt.
|
||||
bool HasFile);
|
||||
|
||||
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 required string Name { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
public record CreateGerbilDto
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required 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 required string Name { get; init; }
|
||||
public required DateTime Date { get; init; }
|
||||
public int? Strength { get; init; }
|
||||
public Guid? Father { get; init; }
|
||||
public Guid? Mother { get; init; }
|
||||
}
|
||||
}
|
||||
34
GerbilManagerWebAPI/Dtos/ExhibitionResultDtos.cs
Normal file
34
GerbilManagerWebAPI/Dtos/ExhibitionResultDtos.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
/// <summary>EXHIBITION: payload for POST /exhibitions.</summary>
|
||||
public record ExhibitionResultInput(
|
||||
Guid? GerbilId,
|
||||
string? EntityName,
|
||||
string EventName,
|
||||
DateTime? Date,
|
||||
string? Placement,
|
||||
string? Award,
|
||||
string? Note);
|
||||
|
||||
/// <summary>EXHIBITION: payload for PUT /exhibitions/{id} (all fields optional/partial).</summary>
|
||||
public record ExhibitionResultUpdate(
|
||||
Guid? GerbilId,
|
||||
string? EntityName,
|
||||
string? EventName,
|
||||
DateTime? Date,
|
||||
string? Placement,
|
||||
string? Award,
|
||||
string? Note);
|
||||
|
||||
/// <summary>EXHIBITION: response DTO for a stored exhibition result.</summary>
|
||||
public record ExhibitionResultDto(
|
||||
Guid Id,
|
||||
Guid? GerbilId,
|
||||
string? EntityName,
|
||||
string EventName,
|
||||
DateTime? Date,
|
||||
string? Placement,
|
||||
string? Award,
|
||||
string? Note,
|
||||
DateTimeOffset CreatedAt);
|
||||
}
|
||||
93
GerbilManagerWebAPI/Dtos/FeedbackDtos.cs
Normal file
93
GerbilManagerWebAPI/Dtos/FeedbackDtos.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
/// <summary>FEEDBACK: payload for POST /feedback (the "Fehler melden" dialog).</summary>
|
||||
public record FeedbackInput(
|
||||
string Message,
|
||||
string Context,
|
||||
Guid? GerbilId,
|
||||
Guid? LitterId,
|
||||
Guid? ContactId,
|
||||
string? EntityName,
|
||||
string? Url,
|
||||
DateTimeOffset? ClientTimestamp);
|
||||
|
||||
/// <summary>
|
||||
/// FEEDBACK: one past Q&A turn in the ticket's thread/history.
|
||||
/// Role is "maintainer" (Rückfrage) or "breeder" (Antwort).
|
||||
/// </summary>
|
||||
public record FeedbackThreadEntry(
|
||||
string Role,
|
||||
string Text,
|
||||
DateTimeOffset? At);
|
||||
|
||||
/// <summary>FEEDBACK: lightweight metadata for an attachment (no bytes).</summary>
|
||||
public record FeedbackAttachmentDto(
|
||||
Guid Id,
|
||||
string FileName,
|
||||
string ContentType,
|
||||
int Size);
|
||||
|
||||
/// <summary>FEEDBACK: payload to upload an attachment (base64-encoded bytes).</summary>
|
||||
public record FeedbackAttachmentInput(
|
||||
string FileName,
|
||||
string ContentType,
|
||||
string DataBase64);
|
||||
|
||||
/// <summary>FEEDBACK: response DTO for a stored report.</summary>
|
||||
public record FeedbackDto(
|
||||
Guid Id,
|
||||
string Message,
|
||||
string Context,
|
||||
Guid? GerbilId,
|
||||
Guid? LitterId,
|
||||
Guid? ContactId,
|
||||
string? EntityName,
|
||||
string? Url,
|
||||
DateTimeOffset? ClientTimestamp,
|
||||
string? UserAgent,
|
||||
DateTimeOffset CreatedAt,
|
||||
string Status,
|
||||
DateTimeOffset? ResolvedAt,
|
||||
string? Question,
|
||||
string? Answer,
|
||||
DateTimeOffset? AnsweredAt,
|
||||
/// <summary>Breeder-friendly changelog written on resolve (shown in the UI).</summary>
|
||||
string? FixNote,
|
||||
/// <summary>INTERNAL agent working memory — exposed for tooling, NEVER shown to the breeder.</summary>
|
||||
string? AgentContext,
|
||||
/// <summary>Earlier Q&A rounds, oldest first; the current open exchange stays in Question/Answer.</summary>
|
||||
IReadOnlyList<FeedbackThreadEntry> Thread,
|
||||
/// <summary>When a genuinely-resolved ticket (no pending Rückfrage) was reopened; else null.</summary>
|
||||
DateTimeOffset? ReopenedAt = null,
|
||||
/// <summary>Soft-delete marker: when the ticket was deleted via the UI (recoverable); else null.</summary>
|
||||
DateTimeOffset? DeletedAt = null,
|
||||
/// <summary>Optional AI-set category/topic for filtering (e.g. "Genetik", "Import"); null = none.</summary>
|
||||
string? Category = null,
|
||||
/// <summary>Was the resolution helpful? true=👍, false=👎, null=no feedback yet.</summary>
|
||||
bool? Helpful = null,
|
||||
/// <summary>Attachment metadata (no bytes); fetch bytes via /feedback/attachments/{id}.</summary>
|
||||
IReadOnlyList<FeedbackAttachmentDto>? Attachments = null);
|
||||
|
||||
/// <summary>
|
||||
/// FEEDBACK: payload for PUT /feedback/{id}. Edit the message and/or toggle status,
|
||||
/// attach a clarifying question (Rückfrage), submit the breeder's answer, set the
|
||||
/// breeder-friendly fix note (changelog), or update the internal agent context.
|
||||
/// </summary>
|
||||
public record FeedbackUpdate(
|
||||
string? Message,
|
||||
string? Status,
|
||||
string? Question,
|
||||
string? Answer,
|
||||
string? FixNote,
|
||||
string? AgentContext,
|
||||
/// <summary>Set/clear the ticket category. Empty/whitespace clears it.</summary>
|
||||
string? Category = null,
|
||||
/// <summary>👍/👎 on a resolved ticket. null leaves it unchanged.</summary>
|
||||
bool? Helpful = null,
|
||||
/// <summary>
|
||||
/// Wenn true, wird nach dem Update eine Push-Benachrichtigung an die Züchterin gesendet.
|
||||
/// Nur die KI/der Betreuer setzt das (z. B. neue Rückfrage / Ticket gelöst); das Frontend
|
||||
/// setzt es NIE — so löst die Züchterin mit eigenen Aktionen keine Selbst-Pushes aus.
|
||||
/// </summary>
|
||||
bool? Notify = null);
|
||||
}
|
||||
@@ -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 required string Name { get; init; }
|
||||
public required 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 required string Name { get; init; }
|
||||
public required DateTime Date { get; init; }
|
||||
public int? Strength { get; init; }
|
||||
public GerbilDto? Father { get; init; }
|
||||
public GerbilDto? Mother { get; init; }
|
||||
}
|
||||
}
|
||||
19
GerbilManagerWebAPI/Dtos/RefsDtos.cs
Normal file
19
GerbilManagerWebAPI/Dtos/RefsDtos.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
/// <summary>
|
||||
/// REFS: ein einzelner aufzulösender Kurz-Verweis (Shortlink, git-Stil) aus einem
|
||||
/// Freitext. <c>Type</c> ist "tier" | "kontakt" | "wurf" | "gehege"; <c>Code</c> ist
|
||||
/// ein GUID-Präfix (mind. 6 Hex-Zeichen) ODER die volle GUID — Bindestrich-tolerant.
|
||||
/// </summary>
|
||||
public record RefRequest(string Type, string Code);
|
||||
|
||||
/// <summary>REFS: Body von POST /refs/resolve.</summary>
|
||||
public record ResolveRefsInput(List<RefRequest> Refs);
|
||||
|
||||
/// <summary>
|
||||
/// REFS: Auflösungsergebnis. <c>Id</c>/<c>Name</c> sind null, wenn der Code unbekannt
|
||||
/// ODER mehrdeutig ist (mehrere Präfix-Treffer) — es wird bewusst nicht geraten.
|
||||
/// <c>Type</c>/<c>Code</c> werden gespiegelt, damit der Client zuordnen kann.
|
||||
/// </summary>
|
||||
public record RefResolution(string Type, string Code, Guid? Id, string? Name);
|
||||
}
|
||||
28
GerbilManagerWebAPI/Dtos/ReturnRecordDtos.cs
Normal file
28
GerbilManagerWebAPI/Dtos/ReturnRecordDtos.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
/// <summary>RÜCKNAHME: Payload für POST/PUT /returns.</summary>
|
||||
public record ReturnRecordInput(
|
||||
Guid? GerbilId,
|
||||
string? GerbilName,
|
||||
DateTime? ReturnDate,
|
||||
decimal? ReturnPrice,
|
||||
decimal? OriginalPrice,
|
||||
DateTime? OriginalSaleDate,
|
||||
Guid? FromContactId,
|
||||
string? FromContactName,
|
||||
string? Note);
|
||||
|
||||
/// <summary>RÜCKNAHME: Antwort-DTO für einen gespeicherten Rücknahme-Datensatz.</summary>
|
||||
public record ReturnRecordDto(
|
||||
Guid Id,
|
||||
Guid? GerbilId,
|
||||
string? GerbilName,
|
||||
DateTime? ReturnDate,
|
||||
decimal? ReturnPrice,
|
||||
decimal? OriginalPrice,
|
||||
DateTime? OriginalSaleDate,
|
||||
Guid? FromContactId,
|
||||
string? FromContactName,
|
||||
string? Note,
|
||||
DateTimeOffset CreatedAt);
|
||||
}
|
||||
66
GerbilManagerWebAPI/Dtos/Rpro3Dtos.cs
Normal file
66
GerbilManagerWebAPI/Dtos/Rpro3Dtos.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
/// <summary>Zählungen für die Analyse-Übersicht (RennmausPro-III-Import).</summary>
|
||||
public record Rpro3Counts(
|
||||
int OwnAnimals, // eigene Tiere (stamm)
|
||||
int ExternalRaw, // externe Ahnen (fremd, roh, vor Dedup)
|
||||
int ExternalAfterDedup, // externe Ahnen nach Dedup
|
||||
int Litters, // Würfe
|
||||
int Contacts, // Kontakte (Herkunft + Abnehmer)
|
||||
int Genotypes, // hinterlegte Genotypen (eigen + extern)
|
||||
int DuplicatesMerged, // weggefallene interne Dubletten
|
||||
int MergeClusters, // Anzahl zusammengelegter Cluster
|
||||
int Enclosures, // Gehege/Becken
|
||||
int Acquisitions, // Erwerbe (herktier_tb)
|
||||
int Reservations, // Reservierungen/Abgaben (abgeben_tb + abstat_tb)
|
||||
int Returns, // Rücknahmen (getback_tb)
|
||||
int WaitingList, // Wartelisten-Einträge (nachfrage_tb)
|
||||
int Exhibitions); // Ausstellungsergebnisse (ausz_tb)
|
||||
|
||||
/// <summary>Eine Variante eines mehrdeutigen Namens (für die Entscheidung der Züchterin).</summary>
|
||||
public record Rpro3AmbiguousVariant(
|
||||
int Count,
|
||||
string Dob,
|
||||
string Farbe,
|
||||
string Origin,
|
||||
bool IsOwn);
|
||||
|
||||
public record Rpro3AmbiguousName(
|
||||
string Name,
|
||||
IReadOnlyList<Rpro3AmbiguousVariant> Variants,
|
||||
int BareCount);
|
||||
|
||||
/// <summary>Ein großer sicherer Merge-Cluster (zur Veranschaulichung der Dedup-Wirkung).</summary>
|
||||
public record Rpro3MergeSample(
|
||||
string Name,
|
||||
int RecordCount,
|
||||
string Dob,
|
||||
string Farbe,
|
||||
string Origin);
|
||||
|
||||
/// <summary>Ergebnis von POST /import/rpro3/analyze — schreibt NICHTS in die DB.</summary>
|
||||
public record Rpro3AnalyzeResult(
|
||||
Rpro3Counts Counts,
|
||||
int NewVsCurrentNew, // wie viele Tiere im aktuellen Stand fehlen würden (neu)
|
||||
int NewVsCurrentExisting, // wie viele schon vorhanden sind (Match)
|
||||
IReadOnlyList<Rpro3MergeSample> TopMerges,
|
||||
IReadOnlyList<Rpro3AmbiguousName> AmbiguousNames,
|
||||
bool PhotosProvided,
|
||||
int PhotoFilesAvailable);
|
||||
|
||||
/// <summary>Ergebnis von POST /import/rpro3/execute.</summary>
|
||||
public record Rpro3ExecuteResult(
|
||||
int GerbilsImported,
|
||||
int LittersImported,
|
||||
int ContactsImported,
|
||||
int HealthRecordsImported,
|
||||
int WeightRecordsImported,
|
||||
int PhotosImported,
|
||||
int EnclosuresImported,
|
||||
int AcquisitionsImported,
|
||||
int ReservationsImported,
|
||||
int ReturnsImported,
|
||||
int WaitingListImported,
|
||||
int ExhibitionsImported,
|
||||
string Message);
|
||||
}
|
||||
43
GerbilManagerWebAPI/Dtos/SaleReservationDtos.cs
Normal file
43
GerbilManagerWebAPI/Dtos/SaleReservationDtos.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
/// <summary>ABGABE-STATUS: response DTO for a stored reservation/sale status.</summary>
|
||||
public record SaleReservationDto(
|
||||
Guid Id,
|
||||
Guid GerbilId,
|
||||
string? GerbilName,
|
||||
string Status,
|
||||
Guid? ReservedForContactId,
|
||||
string? ContactName,
|
||||
DateTime? AppointmentDate,
|
||||
decimal? Price,
|
||||
string? Note,
|
||||
DateTime? HandedOverDate,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
/// <summary>ABGABE-STATUS: payload for POST /reservations.</summary>
|
||||
public record SaleReservationInput(
|
||||
Guid GerbilId,
|
||||
string? GerbilName,
|
||||
string? Status,
|
||||
Guid? ReservedForContactId,
|
||||
string? ContactName,
|
||||
DateTime? AppointmentDate,
|
||||
decimal? Price,
|
||||
string? Note,
|
||||
DateTime? HandedOverDate);
|
||||
|
||||
/// <summary>
|
||||
/// ABGABE-STATUS: payload for PUT /reservations/{id}. All fields optional — only the
|
||||
/// provided ones are changed. A null/blank Status is ignored.
|
||||
/// </summary>
|
||||
public record SaleReservationUpdate(
|
||||
string? GerbilName,
|
||||
string? Status,
|
||||
Guid? ReservedForContactId,
|
||||
string? ContactName,
|
||||
DateTime? AppointmentDate,
|
||||
decimal? Price,
|
||||
string? Note,
|
||||
DateTime? HandedOverDate);
|
||||
}
|
||||
@@ -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 Guid? Litter { get; init; }
|
||||
public Guid? 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; }
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user