diff --git a/.env.sample b/.env.sample index 65926e57f..e94748a2e 100644 --- a/.env.sample +++ b/.env.sample @@ -15,7 +15,7 @@ POSTGRES_HOST=localhost POSTGRES_PORT=5432 POSTGRES_DB=db POSTGRES_USER=pguser -POSTGRES_PASSWORD=pgpassword +POSTGRES_PASSWORD= ANTHROPIC_API_KEY=none ANTHROPIC_MAX_TOKENS=0 diff --git a/.forgejo/workflows/buildAndTest.yml b/.forgejo/workflows/buildAndTest.yml index ea58d2fef..cf4100eda 100644 --- a/.forgejo/workflows/buildAndTest.yml +++ b/.forgejo/workflows/buildAndTest.yml @@ -19,20 +19,21 @@ on: required: false type: boolean push: - branches: [ "main" ] + branches: [ "main", "release/*", "hotfix/*", "fix/*" ] pull_request: - branches: [ "main", "release/*" ] + branches: [ "main", "release/*", "hotfix/*", "fix/*" ] jobs: build: runs-on: docker container: - image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v1 + image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v2 steps: - name: Clone yavsc run: | + set -e cd /src - git clone https://forgejo.pschneider.fr/notazof/yavsc.git _src + git clone "${FORGEJO_SERVER_URL}/${FORGEJO_REPOSITORY}.git" _src cd _src if [ -n "${GITHUB_REF:-}" ]; then git fetch origin "$GITHUB_REF" @@ -40,12 +41,18 @@ jobs: fi git submodule update --init --recursive echo "✅ Checked out at $(git rev-parse HEAD) on $(git branch --show-current 2>/dev/null || echo detached HEAD)" - + - name: Secret scan + run: | + echo "🔍 Scanning for secrets..." + cd /src/_src && dotnet tool restore && dotnet picket git --verbose --redact --exit-code 1 --log-opts main..HEAD \ + - name: Build + run: | + echo "đŸ—ïž Building the solution..." + cd /src/_src && dotnet build --verbosity normal - name: Test run: | echo "🚀 Lancement des tests..." cd /src/_src && dotnet test \ - --verbosity normal \ - --filter="Category!=Platform-Android" \ - --logger "xunit;LogFileName=test-results.xml" \ - && echo "✅ Success !" || echo "❌ Fail ($?)!" + --report-xunit \ + --report-xunit-html \ + --report-xunit-html-filename "test-results.html" diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index a72f92bd6..7d0670a17 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -52,7 +52,7 @@ jobs: release: runs-on: docker container: - image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v1 + image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v2 steps: - name: Clone du repo au tag demandĂ© env: @@ -69,7 +69,7 @@ jobs: cd /src if [[ ! -d _src/.git ]]; then - git clone --depth=1 https://forgejo.pschneider.fr/notazof/yavsc.git _src + git clone --depth=1 "${FORGEJO_SERVER_URL}/${FORGEJO_REPOSITORY}.git" _src fi cd _src @@ -200,6 +200,7 @@ jobs: RELEASE_BODY: ${{ env.RELEASE_BODY }} IS_PRERELEASE: ${{ env.IS_PRERELEASE }} run: | + set -e if [[ -z "$TAG" ]]; then echo "::error::No tag resolved for the API call." exit 1 diff --git a/.gitignore b/.gitignore index 056cf69d2..c1b184a87 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,9 @@ DataDir/ *.log +# gitleaks scan output (local secret-scan report, not for the repo) +/gitleaks-report.json +/test-report.html +/test-report.xml + + diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 000000000..056cf69d2 --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,45 @@ +# Exclure uniquement les dossiers de sortie de compilation +bin/ +obj/ +src/*/bin/ +src/*/obj/ +test/*/bin/ +test/*/obj/ + +# Toolchain front (Node / esbuild) +node_modules/ +build/ +package-lock.json + +# Exclure les caches lourds +.git/ +.vs/ + +.env + +.*.env + +*.csproj.lscache +data/ +appsettings.*.json +appsettings-*.*.json + +# Exception: the Testing-environment override for Yavsc.Org is a tracked +# configuration source, not a secrets file. TestWebApplicationFactory +# (Yavsc.Org.Tests) flips ASPNETCORE_ENVIRONMENT to "Testing" so +# AddConfiguration("org") in Program.Main loads this file as the +# last in the chain (it is optional). It overrides the connection +# string and SMTP section for the in-memory test host and contains +# no production secrets. +!src/Yavsc.Org/appsettings-org.Testing.json + +generated/ +*.tmp +tmp/ +DataDir/ + +*.tests.trx +*.tests.html + +*.log + diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..168ff1424 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,6 @@ +repos: + - repo: https://github.com/gitleaks/gitleaks + rev: v8.24.2 + hooks: + - id: gitleaks + diff --git a/.vscode/launch.json b/.vscode/launch.json index dc8d3c68d..f3411003c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,6 +4,22 @@ // Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ + { + "name": "PostIt Desktop", + "type": "dotnet", + "request": "launch", + "projectPath": "${workspaceFolder}/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj" + }, + { + "name": "PostIt Desktop local", + "type": "coreclr", + "request": "launch", + "program": "${workspaceFolder}/src/PostIt/PostIt.Desktop/bin/Debug/net10.0/PostIt.Desktop.dll", + "env": { + "POSTIT_SETTINGS_JSON": "/home/paul/Workspace/yavsc/src/PostIt/PostIt/postit-settings.json" + }, + "preLaunchTask": "dotnet: build-postit-desktop" + }, { "name": "Android Debug", "type": "mono", @@ -20,16 +36,22 @@ "port": 55555 }, { - "name": "API", + "name": "Yavsc API", "type": "dotnet", "request": "launch", - "projectPath": "${workspaceFolder}/src/Api/Api.csproj" + "projectPath": "${workspaceFolder}/src/Yavsc.Api/Yavsc.Api.csproj" }, { "name": "Yavsc Org", - "type": "dotnet", + "type": "coreclr", "request": "launch", - "projectPath": "${workspaceFolder}/src/Yavsc.Org/Yavsc.Org.csproj", + "program": "${workspaceFolder}/src/Yavsc.Org/bin/Debug/net10.0/Yavsc.Org.dll", + "env": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "cwd": "${workspaceFolder}/src/Yavsc.Org", + "preLaunchTask": "build-web-yavsc-org" + }, { "name": "Yavsc Blogs", @@ -37,12 +59,7 @@ "request": "launch", "projectPath": "${workspaceFolder}/src/Yavsc.Blogs/Yavsc.Blogs.csproj" }, - { - "name": "PostIt Desktop", - "type": "dotnet", - "request": "launch", - "projectPath": "${workspaceFolder}/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj", - }, + { "name": "Test PostIt.Android launch (Xamarin.UITest)", "type": "coreclr", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index a30c9c065..15ee3405b 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,27 +1,13 @@ { "version": "2.0.0", "isRoot": true, - "problemMatcher": [ - { - "owner": "dotnet", - "fileLocation": ["relative", "${workspaceFolder}"], - "source": "dotnet", - "pattern": { - "regexp": "^\\s*(.*)\\((\\d+),(\\d+)\\):\\s+(error|warning) (.*)$", - "file": 1, - "line": 2, - "column": 3, - "severity": 4, - "code": 5, - "message": 6 - } - } - ], + "tasks": [ { "label": "run-debug-android", "command": "dotnet", "type": "shell", + "problemMatcher": ["$msCompile"], "options": { "cwd": "${workspaceFolder}/src/PostIt/PostIt.Android", "env": { @@ -43,11 +29,15 @@ "label": "build", "command": "dotnet", "type": "process", + "problemMatcher": ["$msCompile"], "args": ["build", "/property:GenerateFullPaths=true"], "group": "build", "isBuildCommand": true, "isTestCommand": false, - "isBackground": true + "isBackground": true, + "options": { + "cwd": "${workspaceFolder}" + } }, { "label": "test blogs backend", @@ -63,6 +53,28 @@ "isDefault": false } }, + { + "label": "test api backend (npgsql)", + "type": "process", + "problemMatcher": "$msCompile", + "command": "dotnet", + "args": [ + "test", + "Yavsc.Api.Test.csproj", + "-v", + "minimal" + ], + "options": { + "cwd": "src/Yavsc.Api.Test", + "env": { + "YAVSC_API_TEST_DB_PROVIDER": "npgsql" + } + }, + "group": { + "kind": "test", + "isDefault": false + } + }, { "label": "build-webapi", "type": "process", @@ -76,6 +88,50 @@ "kind": "build" }, "isBackground": true + }, + { + "label": "build-web-yavsc-org", + "type": "process", + "problemMatcher": ["$msCompile"], + "command": "dotnet", + "args": ["build"], + "options": { + "cwd": "src/Yavsc.Org" + }, + "group": { + "kind": "build" + }, + "isBackground": true + }, + { + "label": "build-web-yavsc-blogs", + "type": "process", + "problemMatcher": ["$msCompile"], + "command": "dotnet", + "args": ["build"], + "options": { + "cwd": "src/Yavsc.Blogs" + }, + "group": { + "kind": "build" + }, + "isBackground": true + }, + { + "label": "dotnet: build-postit-desktop", + "type": "process", + "isBuildCommand": true, + "isTestCommand": false, + "isBackground": true, + "command": "dotnet", + "problemMatcher": ["$msCompile"], + "args": ["build", "/property:GenerateFullPaths=true"], + "options": { + "cwd": "src/PostIt/PostIt.Desktop" + }, + "group": { + "kind": "build" + } } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 111c1491a..885b95854 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,184 @@ # Changelog +## [1.0.8-rc17] - unstable + +Fichiers utilisateur et piĂšces jointes sur les devis/demandes (PostIt + +API), systĂšme de gĂ©nĂ©ration `contrib` (vhosts nginx HTTP/3 edge + +appsettings jq + QUIC logiciel Kestrel), et correction du bug de +production oĂč `IsPublished` disparaissait du payload de liste du blog API. + +### Added + +* [PostIt] Espace de stockage personnel (« MyFiles ») : page de gestion + des fichiers de l'utilisateur, entrĂ©e depuis la page d'accueil. +* [PostIt] PiĂšces jointes aux devis et aux demandes : attachement depuis + le dĂ©tail de demande et la page d'Ă©dition de devis. +* [Yavsc.Api] Endpoints d'attachement de fichiers sur les devis + (`EstimateApiController`) et les demandes front-office + (`FrontOfficeApiController`). +* [Yavsc.Api.Client] `UserFilesApiClient` + DTOs associĂ©s ; mĂ©thodes + d'attachement sur `EstimateApiClient` et `FrontOfficeApiClient`. +* [Yavsc.Server] ModĂšles `EstimateAttachedFile`, `QueryAttachedFile`, + `BlogAttachedFile`, `AttachFileRequest` ; migration EF Core + `UserFileAttachments`. +* [contrib] SystĂšme de gĂ©nĂ©ration `Makefile` : vhosts nginx HTTP/3 edge + (`template.vhost`, `QUIC_OPTS`), appsettings gĂ©nĂ©rĂ©s via jq + (`template.appsettings-{org,api,blogs}.json`, Ă©chappement JSON-safe des + secrets), QUIC logiciel Kestrel (`Http1AndHttp2AndHttp3`), et + `README.nginx.md`. +* [Tests] Projet `Yavsc.Server.Tests` : tests du service + `BlogSpotService.Details` + `ServerServicesFixture` (SQLite `:memory:` + par dĂ©faut, Npgsql opt-in via `YAVSC_SERVER_TEST_DB_PROVIDER`). +* [Tests] `IBlogPostWireShapeTests` (forme du wire blog), + `EstimateAttachmentsApiTests`, `FrontOfficeQueryAttachmentsApiTests`, + `FileSystemApiTests` (piĂšces jointes et stockage perso). +* [CI] IntĂ©gration Gitleaks / analyse de secrets (`.pre-commit-config.yaml`). + +### Changed + +* [contrib] `QUIC_OPTS` (`reuseport`) dĂ©sormais activĂ© uniquement en + production sur le vhost Org. La preprod et la prod partageant la mĂȘme + IP, `reuseport` — lĂ©gal une seule fois par `addr:port` (443/udp) — + serait sinon en conflit entre preprod-Org et prod-Org et ferait refuser + la config par nginx. +* [Yavsc.Api.Test] `ApiWebServerFixture` : la connexion admin PostgreSQL + provient de la variable d'environnement + `YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION` ; plus de mot de passe en + source. + +### Fixed + +* [Yavsc.Blogs] Bug de production : `IsPublished` Ă©tait absent du payload + de la liste du blog API. Le endpoint `GetBlogspot` retourne + `IEnumerable` sĂ©rialisĂ© par System.Text.Json d'aprĂšs le type + dĂ©clarĂ© (l'interface), qui omet les propriĂ©tĂ©s du type concret — + `IsPublished` vivait sur `BlogPost` / `BlogPostDto` mais pas sur + `IBlogPost`. PostIt lisait donc `IsPublished == false` pour tout billet, + y compris publiĂ©s (https://yavsc.pschneider.fr/BlogSpot/Details/13). + CorrigĂ© en dĂ©clarant `IsPublished` sur `IBlogPost`. +* [Yavsc.Org] AccĂšs au Swagger UI : les actions MVC scaffoldĂ©es de + `CommentsController` (Index/Details/
) atterrissaient sur la mĂȘme route + `POST api/v1/blogcomments` que l'action d'API `Post`, provoquant une + `SwaggerGeneratorException` (conflit method/path) au chargement de + `/swagger`. CorrigĂ© par `[ApiExplorerSettings(IgnoreApi = true)]` sur + les actions scaffoldĂ©es. + +## [1.0.8-rc16] - unstable + +Flux de signature et de gĂ©nĂ©ration de devis : un devis peut dĂ©sormais ĂȘtre +signĂ© par les deux parties (fournisseur et client) puis tĂ©lĂ©chargĂ© en PDF +ou TeX depuis PostIt, avec les signatures dessinĂ©es en vecteur. + +### Added + +* [PostIt] Pad de signature sur la page de validation d'un devis : on valide + ou on refuse en joignant une signature (cĂŽtĂ© fournisseur ou client), capturĂ©e + au format filaire PostIt (strokes) et envoyĂ©e sur l'acceptation. +* [Yavsc.Api] Finalisation de `POST /api/v1/front/query/accept` et `.../reject` : + autorisation selon le rĂŽle (fournisseur/client/admin), signature optionnelle + persistĂ©e sur l'estimate avec horodatage `ProviderValidationDate` / + `ClientValidationDate` selon le cĂŽtĂ© signataire. +* [Yavsc.Api] `QueryStatus.Accepted` Ă©clatĂ© en `ProAccepted` / `ClientAccepted` + pour distinguer l'acceptation de chaque partie. +* [Yavsc.Api] Endpoints de devis `GET /api/v1/front/query/{id}/estimate.tex` et + `estimate.pdf` (clĂ© par id de query). Le flux de gĂ©nĂ©ration TeX/PDF est + rapatriĂ© de Yavsc.Org vers Yavsc.Api (`TeXHelpers`, `PdfGenerationViewModel`, + `BillViewComponent`, modĂšles Bill/Estimate, `_ViewImports` ; `Program.cs` + cĂąble `AddControllersWithViews` + `SiteSettings`). +* [Yavsc.Api] Signatures du devis dessinĂ©es en TikZ vectoriel + (`TeXHelpers.SignatureToTikz`) en bas du document, fournisseur et client + cĂŽte Ă  cĂŽte dans deux minipages ; plus de dĂ©pendance Ă  un PNG de signature. +* [Yavsc.Api.Client] `DownloadAsync` sur le client + `GetEstimateTexAsync` / + `GetEstimatePdfAsync` ; boutons « TĂ©lĂ©charger PDF » / « TĂ©lĂ©charger TeX » + dans PostIt sur la page de validation du devis et le dĂ©tail de commande, + avec un helper `StorageProvider` d'enregistrement de fichier. +* [PostIt] La liste des devis se rafraĂźchit au retour de navigation (pop-back), + de sorte qu'un devis validĂ© quitte immĂ©diatement la liste du signataire. +* [PostIt] Renommage des titres de liste : « Devis en attente de signature + fournisseur » / « Devis en attente de signature client ». +* [Yavsc.Abstract] `SignatureType` dĂ©placĂ© dans `Yavsc.Abstract.Billing`. +* [CLI] Option `-c configuration-file.json` au lancement. +* [Tests] `EstimatePdfGenerationTests` : tests unitaires du pipeline + signature → TikZ → PDF (devis signĂ© des deux cĂŽtĂ©s), assertant que le PDF + existe au chemin de sortie attendu et que seul le PDF persiste (aux/log + nettoyĂ©s) ; skip propre si `lualatex` est absent. +* [README] DĂ©pendances systĂšme au runtime pour la gĂ©nĂ©ration PDF : + `texlive-binaries` (lualatex), `texlive-luatex` (luaotfload), + `texlive-pictures` (tikz), `texlive-fonts-extra` (bera). + +### Changed + +* [Yavsc.Api] `GetOngoingEstimatesAsProvider` filtre dĂ©sormais sur + `ProviderValidationDate` : un devis signĂ© par le fournisseur quitte sa liste + « en attente ». +* [Yavsc.Api] La gĂ©nĂ©ration PDF compile via le `lualatex` systĂšme, le TeX fourni + sur l'entrĂ©e standard (`/dev/stdin`), le PDF Ă©crit directement dans le + rĂ©pertoire des factures (`Site.Bills`), les artefacts `.aux`/`.log` nettoyĂ©s + afin que seul le `.pdf` persiste. + +### Fixed + +* [Yavsc.Api] `Emergency stop` Ă  la gĂ©nĂ©ration PDF : `lualatex` sans argument + fichier lit stdin en mode terminal et s'arrĂȘte aprĂšs la premiĂšre ligne d'un + document multi-lignes. CorrigĂ© en passant `/dev/stdin` comme nom de fichier + d'entrĂ©e — stdin est alors traitĂ© comme un fichier rĂ©gulier et le document + multi-lignes compile. C'Ă©tait la cause racine de l'Ă©chec en production, pas + `bera` ni `luaotfload` (problĂšmes rĂ©els mais sĂ©parĂ©s, comblĂ©s cĂŽtĂ© paquets). +* [Yavsc.Api] `I can't write on file 'estimate-1.log'` : le chemin relatif + `Site.Bills` (`"bills"`) Ă©tait imbriquĂ© quand il servait Ă  la fois de + `-output-directory` et de rĂ©pertoire de travail ; rĂ©solu en chemin absolu. +* [Yavsc.Api] NRE dans `RenderViewToString` (RouteData / `ViewData.Model` + nuls) : reconstruit sur le `ControllerContext` du contrĂŽleur ; `IViewEngine` + rĂ©solu via `IRazorViewEngine` (.NET 10 n'enregistre pas de `IViewEngine` + unique). +* [Yavsc.Api] `Layout = "null"` (chaĂźne) dans les modĂšles TeX cherchait + `null.cshtml` ; corrigĂ© en `Layout = null` (null C#). +* [Yavsc.Api] `texi2pdf` (outil Texinfo, inadaptĂ© au LaTeX) remplacĂ© par + `lualatex`. +* [Yavsc.Api] Les diagnostics lualatex Ă©taient perdus (Ă©crits dans le `.log` + qu'on supprimait) ; stdout/stderr sont dĂ©sormais capturĂ©s dans le message + d'erreur, et le `.log` est conservĂ© en cas d'Ă©chec. + +## [1.0.8-rc15] - unstable + +### Added + +* [PostIt] Saisie et envoi de pieces jointes sur un billet de blog: le client monte les fichiers en `multipart/form-data` lors du `Save`, ajoute un lien Markdown par piece jointe a l'article, puis re-PUT le billet. Une collection `DraftAttachments` porte les fichiers en cours d'edition. +* [PostIt] Apercu Markdown dans l'editeur de blogs (integration `MarkdownViewer.Core`) avec bascule edition/preview sur `BlogsPage`. +* [PostIt] Gestion des devis: page de liste `EstimateListPage` (vues client et prestataire), page d'edition `EstimateEditionPage` avec lignes de devis (`EstimateLineItemViewModel`), et flux de validation d'un devis. +* [PostIt] Flux des requetes en cours cote prestataire (`ProviderOngoingRequestsPage`) avec tri persistant. +* [Yavsc.Api] Endpoints devis `GET /api/v1/estimate/asclient` et `GET /api/v1/estimate/asprovider` pour lister les devis en cours selon le role. +* [Yavsc.Api] Endpoint `GET /api/v1/billing/provider/ongoing` et `POST /api/v1/billing/prosign/{billingCode}/{id}` pour le flux prestataire. +* [Yavsc.Api.Client] `EstimateApiClient` + DTOs (`EstimateDto`, `EstimateLineDto`) pour le pipeline devis cote client lourd. +* [Yavsc.Api.Client] Reprise de l'upload de fichiers blog dans `BlogApiClient` (`CreateMultipartContent`, `BlogUploadFile`) — la meme forme filaire que PostIt utilise en production. +* [Yavsc.Abstract] `FileServerUrlHelpers` pour deriver les URLs publiques des fichiers utilisateur depuis l'autorite OIDC (alignement sur `UserFilesPath`). +* [Yavsc.Server] `BlogSpotService.AttachFiles` : ecrit les pieces jointes sous la racine des fichiers utilisateur (`{UserFilesDirName}/{user}/blogs/{postId}/{fileName}`). +* [Yavsc.Blogs] `BlogApiController` accepte `multipart/form-data` (champ `blog` + parts `file`) sur `POST` et `PUT /api/v1/blogspot`, en plus du payload JSON existant. +* [PostIt] Variable d'environnement `POSTIT_SETTINGS_JSON` pour surcharger les parametres au lancement (utile en CI/test). +* [Yavsc.Server] Migration EF `fileACL`: `CircleAuthorizationToFile` gagne `OwnerId` + `Access` et un graphe proprieetaire, base du controle d'acces fichier par cercle. +* [Yavsc.Server] Migration EF `genericEstimate`: `Estimate.Query` passe de `RdvQuery` a `NominativeServiceCommand?` pour supporter des devis non lies a un RDV. +* [Yavsc.Server] Migrations EF `NominativeServiceCommand` et `AddHairCutQueryLocationId` (localisation de la coupe). +* [CI] Integration du scan de secrets Gitleaks dans le pipeline Forgejo + `.gitleaksignore`. +* [Tests] Non-regressions: round-trip multipart blog (`BlogApiTests`, `CreateMultipartContentTests` forme + serveur), `BlogAttachmentLinkTests`, `FileServerUrlHelpersTests`, `EstimateApiControllerTests`, VMs `EstimateList`/`EstimateEdition`/`ProviderOngoingRequests`, `BillingControllerTests` (filtrage par utilisateur), `GetOpenIdConfiguration` contre l'hote de test. + +### Changed + +* [PostIt] Renommage `MainPage` -> `BlogsPage` (et `PushMainPageAsync` -> `PushBlogsPageAsync`) pour refletter le role de la page. +* [PostIt] Refacto des `SiteSettings` : reorganisation des parametres, nouvelles valeurs par defaut, et indications plus claires dans les `appsettings`. +* [Yavsc.Api] `BillingController` ne liste plus que les codes billing de l'utilisateur authentifie (filtre par `UserId`), au lieu de l'ensemble des requetes prestataire. +* [Yavsc.Api] Mutualisation d'un `YavscMessageSender` partage dans l'hote API (au lieu d'une instance par controller). +* [Yavsc.Org] La detection du doublon d'email a l'inscription utilise desormais le code erreur generic `DbException` 23505 (fournisseur-agnostique) au lieu de `PostgresException`, pour rester correct en test SQLite. +* [PostIt] `YavscApiClient` tolere les corps vides (`204 No Content` sur `PUT /blogspot/{id}`) au lieu de lever un `JsonException`. +* [CI] Le pipeline compile avant les tests, installe les dependances natives, et retire le `ItemGroup.Using Include=Xunit` superflu. + +### Fixed + +* [PostIt] Suppression d'un faux message d'erreur affiche apres un `PUT /blogspot/{id}` reussi (204 traite comme succes, plus comme une reponse vide invalide). +* [PostIt] Correction de la creation des liens de pieces jointes (`TryAppendAttachmentLinks`) : segment proprietaire et chemin `blogs/{id}/{fileName}` alignes sur ce que `AttachFiles` ecrit reellement. +* [Yavsc.Blogs] Correction de l'upload / de la persistance des pieces jointes (`FileSystemHelpers` + `BlogApiController` multipart). +* [Tests] Stabilisation des suites: seeds billing avec metadonnees d'audit, `GetOpenIdConfiguration_returns_ok` contre l'hote de test, retraits d'assertions abusives, tests SQLite verts. +* [PostIt] Nettoyage de fichiers temporaires accidentels et ignore des repertoires `tmp` dans le depot. + ## [1.0.8-rc14] - unstable ### Added @@ -329,6 +508,8 @@ the ACL now comes along with the article, the same user-visible switch without a schema change. [Unreleased]: https://forgejo.pschneider.fr/notazof/yavsc/compare/HEAD +[1.0.8-rc16]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc15...1.0.8-rc16 +[1.0.8-rc15]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc14...1.0.8-rc15 [1.0.8-rc1]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.7...1.0.8-rc1 [1.0.7]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.6...1.0.7 [1.0.6]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.5...1.0.6 diff --git a/Directory.Build.props b/Directory.Build.props index 83d21579d..bf1bf8bcb 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,5 +2,16 @@ Yavsc NU1701, NU1901, NU1902, NU1507 + + false diff --git a/Directory.Packages.props b/Directory.Packages.props index f1be93f91..b0b055b98 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,53 +4,71 @@ 8.1.0-pazofrc007 - - + + + - - - - - - + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - + + + + + + + + + + - - - - - - + + + + + + + + + + + + + + + + + + + + + + diff --git a/Makefile b/Makefile index 08f0461d2..e883088c2 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,10 @@ all: clean: dotnet clean -c $(CONFIG) +brutal_clean: + @find -name "obj" -exec rm -rf {} \; + @find -name "bin" -exec rm -rf {} \; + src/Yavsc/bin/output/wwwroot: dotnet --project src/Yavsc.Org/Yavsc.Org.csproj publish @@ -48,70 +52,4 @@ docker-build: docker-run: docker run -d -p 5000:5000 --name yavsc yavsc -# CrĂ©e une branche release/ depuis main, met Ă  jour les -# `` des .csproj via dotnet-gitversion, et la -# pousse sur origin. -# -# Usage : make release V=1.0.7-rc1 -# -# PrĂ©-requis : ĂȘtre sur main, working tree clean. La cible -# vĂ©rifie les deux et refuse sinon — elle ne fait JAMAIS -# de checkout automatique, c'est Ă  l'opĂ©rateur de s'ĂȘtre -# positionnĂ© sur la bonne branche au prĂ©alable (sinon le -# bump pourrait partir sur une branche tierce par accident). -# -# Notes : -# - Le nom de branche vient de l'argument V (ex: 1.0.7-rc1 -# donne release/1.0.7-rc1). C'est une Ă©tiquette d'intention, -# pas la version assembly. -# - La version dans les .csproj vient de GitVersion qui la -# calcule depuis l'historique git (tag le plus proche + -# nombre de commits). C'est la version assembly rĂ©elle. -# - L'ordre (fetch → branche → bump → push) garantit qu'on -# part d'un main synchro et qu'on ne pollue pas main avec -# le bump (qui vit sur la branche release). -# - Fail-fast si la branche existe dĂ©jĂ  en local ou sur origin. -release: - @if [ -z "$(V)" ]; then \ - echo "Usage: make release V="; \ - echo " V : version semver (ex. 1.0.7-rc1) — sert Ă  nommer la branche."; \ - exit 1; \ - fi - @if [ -n "$$(git status --porcelain)" ]; then \ - echo "Working tree sale, refus de crĂ©er une branche release."; \ - git status --short; \ - exit 1; \ - fi - @BRANCH="release/$(V)"; \ - if git show-ref --verify --quiet "refs/heads/$$BRANCH"; then \ - echo "La branche $$BRANCH existe dĂ©jĂ  en local."; \ - echo " Pour la supprimer : git branch -D $$BRANCH"; \ - exit 1; \ - fi; \ - if git ls-remote --exit-code --heads origin "$$BRANCH" >/dev/null 2>&1; then \ - echo "La branche $$BRANCH existe dĂ©jĂ  sur origin."; \ - exit 1; \ - fi; \ - echo "==> Fetch + vĂ©rification synchro main"; \ - git fetch origin main; \ - if ! git merge-base --is-ancestor origin/main HEAD; then \ - echo "main a avancĂ© plus loin que HEAD. Fais :"; \ - echo " git pull --ff-only origin main"; \ - exit 1; \ - fi; \ - echo "==> CrĂ©ation de $$BRANCH depuis main"; \ - git checkout -b "$$BRANCH"; \ - echo "==> dotnet-gitversion /updateprojectfiles"; \ - dotnet-gitversion /updateprojectfiles; \ - echo "==> Commit du bump"; \ - git add .; \ - if git diff --cached --quiet; then \ - echo "Pas de changements Ă  committer (gitversion n'a produit aucune diff)."; \ - else \ - git commit -m "chore(release): bump version via gitversion for $(V)"; \ - fi; \ - echo "==> Push de $$BRANCH sur origin"; \ - git push -u origin "$$BRANCH"; \ - echo "==> TerminĂ©. Branche $$BRANCH live sur origin." - -.PHONY: test release +.PHONY: test install docker-image docker-build docker-run diff --git a/README.md b/README.md index 549348f4a..fefbdd48d 100644 --- a/README.md +++ b/README.md @@ -61,9 +61,40 @@ make test ### les services et l'API +#### DĂ©pendances systĂšme au runtime + +La gĂ©nĂ©ration des devis et factures en PDF (`/api/v1/front/query/{id}/estimate.pdf`, +`BillingController` → `BillViewComponent`) compile un source LaTeX via la commande +systĂšme `lualatex` : le TeX est fourni sur l'entrĂ©e standard et le PDF est Ă©crit +directement dans le rĂ©pertoire des factures (`Site.Bills`). `lualatex` doit donc +ĂȘtre installĂ© et opĂ©rationnel sur l'hĂŽte qui exĂ©cute l'API — typiquement le service +systemd `yavscApi`. + +Sous Debian/Ubuntu : + +```bash +sudo apt install texlive-binaries texlive-luatex texlive-pictures texlive-fonts-extra +``` + +- `texlive-binaries` fournit l'exĂ©cutable `lualatex` (collection basic). +- `texlive-luatex` fournit les paquets de support chargĂ©s au dĂ©marrage de lualatex, + notamment `luaotfload`. Sans lui, lualatex dĂ©marre mais s'arrĂȘte sur + `module 'luaotfload-main' not found 
 Emergency stop` et ne produit aucun PDF. + (Sur les Debian rĂ©cents, `luaotfload` a migrĂ© de `texlive-latex-extra` vers + `texlive-luatex` — avoir `texlive-latex-extra` seul ne suffit plus.) +- `texlive-pictures` fournit `tikz`/`pgf`, utilisĂ© pour dessiner les signatures + du client et du fournisseur en bas du devis (vecteur, pas de PNG). +- `texlive-fonts-extra` fournit la fonte `bera` chargĂ©e par le modĂšle de devis. + Sans elle, lualatex s'arrĂȘte sur `! LaTeX Error: File 'bera.sty' not found` + (`Emergency stop`, aucun PDF). + +Sur un hĂŽte sans `lualatex`, les endpoints PDF renvoient une erreur 500 « PDF +generation failed » ; l'endpoint TeX (`estimate.tex`) reste utilisable et permet +de compiler le document hors-ligne. + ### La Prod -`cd srv/Yavsc` : `make pushInProd CONFIGURATION=Release`. +`cd srv/Yavsc/contrib` : `make reinstall CONFIGURATION=Release APP_INSTALL_ENV=production`. puis, pour une premiĂšre installation `make install_service`. diff --git a/contrib/Makefile b/contrib/Makefile index 79145668f..deb55bcc6 100644 --- a/contrib/Makefile +++ b/contrib/Makefile @@ -1,23 +1,32 @@ +DESTDIR= +DOTNET_FRAMEWORK=net10.0 APP_PROJECT_NAMES=Org Blogs Api SLNDIR=.. +APP_INSTALL_ENV=preprod +APP_INSTALL_PREFIX=preprod- -include $(SLNDIR)/.env -include .env +include .$(APP_INSTALL_ENV).env -generated/: - @mkdir -p $@ +reinstall: copy-binaries + @for project in $(APP_PROJECT_NAMES); do \ + echo "🚀 Starting $(APP_INSTALL_PREFIX)yavsc$${project}" ; \ + sudo systemctl start $(APP_INSTALL_PREFIX)yavsc$${project} ; done + @echo "💯 backend started" -generated/yavscOrg.service: -generated/yavscBlogs.service: -generated/yavscApi.service: +reinstall-prod: + make reinstall APP_INSTALL_ENV= APP_INSTALL_PREFIX= APP_INSTALL_ENV=production -generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env +generated/$(APP_INSTALL_PREFIX)yavscOrg.service: +generated/$(APP_INSTALL_PREFIX)yavscBlogs.service: +generated/$(APP_INSTALL_PREFIX)yavscApi.service: + +generated/$(APP_INSTALL_PREFIX)yavsc%.service: generated/ template.service $(SLNDIR)/.env @cat template.service | APP_NAME="$*" \ DESTDIR="$(DESTDIR)" \ HTTP_HOST="$(HTTP_HOST)" \ - HTTP_PORT="$*_$(HTTP_PORT)" \ - BASEAPPDIR="$(BASEAPPDIR)" \ - APP_SYSLOG_ID="yavsc$*" \ + HTTP_PORT="$($*_PORT)" \ + APPDIR="$(APPDIR)" \ + APP_SYSLOG_ID="$(APP_INSTALL_PREFIX)yavsc$*" \ POSTGRES_HOST="$(POSTGRES_HOST)" \ POSTGRES_PORT="$(POSTGRES_PORT)" \ POSTGRES_DB="$(POSTGRES_DB)" \ @@ -34,11 +43,83 @@ generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env envsubst > $@ @echo Created service file: $@ +generate-service-units: generated/$(APP_INSTALL_PREFIX)yavscOrg.service generated/$(APP_INSTALL_PREFIX)yavscBlogs.service generated/$(APP_INSTALL_PREFIX)yavscApi.service -copy-services: copy-service-Org copy-service-Blogs copy-service-Api -copy-service-Org: /etc/systemd/system/yavscOrg.service -copy-service-Blogs: /etc/systemd/system/yavscBlogs.service -copy-service-Api: /etc/systemd/system/yavscApi.service +# --- Vhosts nginx (gĂ©nĂ©rĂ©s par envsubst, Ă  l'image des unitĂ©s systemd) --- +# make generate-nginx -> generated/nginx/preprod-yavsc{Org,Blogs,Api} +# make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX= -> prod (yavsc/api/blogs.pschneider.fr) +# +# HTTP/3 (QUIC) termine au edge nginx (plan contrib/quic.md). Requiert nginx +# avec http_v3_module + UDP 443 ouvert. reuseport sur Org uniquement +# (QUIC_OPTS). Les vhosts gĂ©nĂ©rĂ©s vont dans generated/nginx/ (gitignorĂ©). +NGINX_TEMPLATE := template.vhost +NGINX_OUT_DIR := generated/nginx +NGINX_HOSTS := Org Blogs Api +DOMAIN_ROOT ?= pschneider.fr +# SchĂ©ma backend Kestrel : https en prod (Kestrel Ă©coute HTTPS, cert Let's +# Encrypt), http en preprod (Kestrel Ă©coute HTTP simple, cf. .preprod.env). +BACKEND_SCHEME ?= $(if $(filter production,$(APP_INSTALL_ENV)),https,http) +# sous-domaine par hĂŽte : Org -> yavsc, Api -> api, Blogs -> blogs +nginx_subdomain = $(if $(filter Org,$1),yavsc,$(if $(filter Api,$1),api,$(if $(filter Blogs,$1),blogs,$1))) +# domaine complet : . +# prod Org -> yavsc.pschneider.fr +# preprod Org -> preprod-yavsc.pschneider.fr +nginx_domain = $(APP_INSTALL_PREFIX)$(call nginx_subdomain,$1).$(DOMAIN_ROOT) + +# QUIC_OPTS : "reuseport" n'est lĂ©gal qu'une seule fois par addr:port +# (443/udp). preprod et prod partagent aujourd'hui la mĂȘme IP, donc on ne +# l'active qu'en production, sur Org uniquement. En preprod, QUIC_OPTS est +# vide sur tous les vhosts (sinon nginx refuse la config : duplicate +# reuseport sur le mĂȘme socket 443/udp entre preprod-Org et prod-Org). +$(NGINX_OUT_DIR)/$(APP_INSTALL_PREFIX)yavsc%: $(NGINX_OUT_DIR) $(NGINX_TEMPLATE) .$(APP_INSTALL_ENV).env + @cat $(NGINX_TEMPLATE) \ + | DOMAIN="$(call nginx_domain,$*)" \ + BACKEND_SCHEME="$(BACKEND_SCHEME)" \ + HTTP_HOST="$(HTTP_HOST)" \ + BACKEND_PORT="$($*_PORT)" \ + QUIC_OPTS="$(if $(filter production,$(APP_INSTALL_ENV)),$(if $(filter Org,$*), reuseport,),)" \ + envsubst '$$DOMAIN $$BACKEND_SCHEME $$HTTP_HOST $$BACKEND_PORT $$QUIC_OPTS' > $@ + @echo Created nginx vhost: $@ + +generate-nginx: $(addprefix $(NGINX_OUT_DIR)/$(APP_INSTALL_PREFIX)yavsc,$(NGINX_HOSTS)) + +generate-nginx-prod: + make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX= + +$(NGINX_OUT_DIR): + @mkdir -p $@ + +# --- appsettings (gĂ©nĂ©rĂ©s depuis template.appsettings-*.json) --- +# make generate-appsettings -> generated/appsettings/appsettings-{org,api,blogs}.json +# +# Substitue $DOMAIN (racine du domaine, cf. DOMAIN_ROOT) dans toutes les +# chaĂźnes, et $SMTP_PASSWORD (secret du .env) dans Smtp.Password. +# On utilise jq (et non envsubst) car envsubst n'Ă©chappe pas le JSON : un +# secret contenant \ ou " casserait le JSON (le mot de passe SMTP actuel +# contient un \). jq lit le secret depuis l'environnement (export) -> +# Ă©chappement JSON correct, aucun shell-quoting ni exposition en ligne de cmd. +# Les templates actuels sont issus de la preprod (prĂ©fixe preprod-, ports +# 88-93) ; pour la prod il faudra un template prod (ports 82-87, sans prĂ©fixe). +export ASPNETCORE_Smtp__Password +APPSETTINGS_OUT_DIR := generated/appsettings +APPSETTINGS_HOSTS := org api blogs + +$(APPSETTINGS_OUT_DIR)/appsettings-%.json: $(APPSETTINGS_OUT_DIR) template.appsettings-%.json .$(APP_INSTALL_ENV).env + @jq --arg domain "$(DOMAIN_ROOT)" \ + 'walk(if type=="string" then gsub("\\$$DOMAIN"; $$domain) else . end) | if has("Smtp") then .Smtp.Password = env["ASPNETCORE_Smtp__Password"] else . end' \ + template.appsettings-$*.json > $@ + @echo Created appsettings: $@ + +generate-appsettings: $(addprefix $(APPSETTINGS_OUT_DIR)/appsettings-,$(addsuffix .json,$(APPSETTINGS_HOSTS))) + +$(APPSETTINGS_OUT_DIR): + @mkdir -p $@ + +install-service-%: /etc/systemd/system/$(APP_INSTALL_PREFIX)yavsc%.service + sudo systemctl daemon-reload + sudo systemctl enable $(APP_INSTALL_PREFIX)yavsc$* + +install-services: install-service-Org install-service-Blogs install-service-Api copy-binaries: build_publish_Org build_publish_Blogs build_publish_Api stop-services @for project in $(APP_PROJECT_NAMES); \ @@ -52,39 +133,36 @@ copy-binaries: build_publish_Org build_publish_Blogs build_publish_Api stop-serv rm -f $${BINDIR}/appsettings-$${LCAPI}.json ; \ sync ; \ ls $${BINDIR}/appsettings* 2>&1 ; \ - sudo cp -a $${BINDIR}/* $(BASEAPPDIR) ; \ + sudo cp -a $${BINDIR}/* $(DESTDIR)$(APPDIR) ; \ done - @sudo chown -R $(USER_AND_GROUP) $(BASEAPPDIR) + @sudo chown -R $(USER_AND_GROUP) $(DESTDIR)$(APPDIR) + @sync -/etc/systemd/system/yavsc%.service: generated/yavsc%.service +$(DESTDIR)/etc/systemd/system/$(APP_INSTALL_PREFIX)yavsc%.service: generated/$(APP_INSTALL_PREFIX)yavsc%.service sudo cp $^ $@ sudo chown root:root $@ build_publish_%: clean_publish_dir_% @ASPNETCORE_ENV=$(CONFIGURATION) dotnet publish $(SLNDIR)/src/Yavsc.$*/Yavsc.$*.csproj -build_publish: build_publish_Org build_publish_Blogs build_publish_Api - clean_publish_dir_%: @rm -rf $(SLNDIR)/src/Yavsc.$*/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish -install: build_publish copy-binaries copy-services +install: copy-binaries copy-services @sudo systemctl daemon-reload @for project in $(APP_PROJECT_NAMES); \ do \ - sudo systemctl enable yavsc$${project} ; \ - sudo systemctl start yavsc$${project} ; \ + echo "🖒 enable $(APP_INSTALL_PREFIX)yavsc$${project}" ; \ + sudo systemctl enable $(APP_INSTALL_PREFIX)yavsc$${project} ; \ + echo "🚀 start $(APP_INSTALL_PREFIX)yavsc$${project}" ; \ + sudo systemctl start $(APP_INSTALL_PREFIX)yavsc$${project} ; \ done -reinstall: copy-binaries - @sync - @for project in $(APP_PROJECT_NAMES); do \ - sudo systemctl start yavsc$${project} ; done - stop-services: @for project in $(APP_PROJECT_NAMES); \ do \ - sudo systemctl stop yavsc$${project} ; \ + echo "đŸ”„ Stopping $(APP_INSTALL_PREFIX)yavsc$${project}" ; \ + sudo systemctl stop $(APP_INSTALL_PREFIX)yavsc$${project} ; \ done $(SLNDIR)/src/Yavsc.Org/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish @@ -93,8 +171,19 @@ $(SLNDIR)/src/Yavsc.Api/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_ showConfig: @echo CONFIGURATION: $(CONFIGURATION) - @echo BASEAPPDIR: $(BASEAPPDIR) + @echo APPDIR: $(APPDIR) + +showApiLogs: + @sudo journalctl -u yavscApi.service -S "2 min ago" | tee yavscApi.log + +showOrgLogs: + @sudo journalctl -u yavscOrg.service -S "2 min ago" | tee yavscOrg.log + +showBlogsLogs: + @sudo journalctl -u yavscBlogs.service -S "2 min ago" | tee yavscBlogs.log clean: @rm -rf generated +generated/: + @mkdir -p $@ diff --git a/contrib/README.md b/contrib/README.md deleted file mode 100644 index 011561560..000000000 --- a/contrib/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Read me - -## Note aux icones - -ă‰ÂźđŸ…Źâ›’â›‘đŸ©ŽđŸ©șđŸž«đŸžźđŸž•đŸž–đŸž†đŸ”ŽđŸ””đŸ”ČđŸ–‚đŸ”§đŸ”©đŸ”đŸ”ŒđŸ’ŸđŸ’ŒđŸ’ŹđŸ’­đŸ‘żđŸ‘ŸđŸ·đŸŽŻđŸčđŸŒđŸŽŽđŸ’© diff --git a/contrib/README.nginx.md b/contrib/README.nginx.md new file mode 100644 index 000000000..9564d4ffe --- /dev/null +++ b/contrib/README.nginx.md @@ -0,0 +1,134 @@ +# Vhosts nginx — gĂ©nĂ©ration par `make generate-nginx` + +Les vhosts nginx sont **gĂ©nĂ©rĂ©s** Ă  l'image des unitĂ©s de service systemd +(`template.service` + `envsubst` → `generated/*.service`) : un template +unique `contrib/template.vhost` est substituĂ© par `envsubst` vers +`contrib/generated/nginx/`, qui est **gitignorĂ©**. + +## RĂŽle de nginx : edge HTTP/3 (plan `contrib/quic.md`) + +nginx termine HTTP/3 (QUIC) au edge sur 443/udp, en coexistence avec +h1/h2 sur 443/tcp. Kestrel reste backend h1/h2 (`proxy_http_version 1.1`). +Le « QUIC logiciel » Kestrel (`Protocols: Http1AndHttp2AndHttp3` dans les +`template.appsettings-*.json`) est une capacitĂ© complĂ©mentaire — non +joignable par les clients derriĂšre nginx (nginx OSS = upstream h1.1 +uniquement), mais disponible si Kestrel est un jour exposĂ© en direct. + +Directives gĂ©nĂ©rĂ©es (par vhost) : +```nginx +listen 443 ssl; listen [::]:443 ssl; http2 on; +listen 443 quic $QUIC_OPTS; listen [::]:443 quic $QUIC_OPTS; http3 on; +add_header Alt-Svc 'h3=":443"; ma=86400' always; +ssl_protocols TLSv1.3; +``` +`$QUIC_OPTS = reuseport` sur **Org uniquement**, vide sur Api/Blogs (rĂšgle +`contrib/quic.md` : `reuseport` une seule fois par addr:port, tous vhosts +confondus). + +## PrĂ©requis cĂŽtĂ© host + +1. **nginx avec `http_v3_module`** (≄ 1.25 ; la directive `http3 on;` + exige ≄ 1.27.4 — sur une version 1.25–1.27.3, retirer `http3 on;`, + `listen ... quic` suffit Ă  activer h3) : + ```bash + nginx -V 2>&1 | grep -o 'http_v3_module' # doit ĂȘtre non vide + ``` + Debian 13 « trixie » fournit `nginx` avec `http_v3_module`. Sinon, + backports ou `nginx-full`. +2. **UDP 443 ouvert** : + ```bash + sudo ufw allow 443/udp + sudo ufw status | grep '443/udp' # → ALLOW + ``` +3. **IPv6** : le template Ă©met `listen [::]:443 ...` (et `[::]:80`). Si le + host n'a pas d'IPv6, retirer/commenter ces lignes (sinon `nginx -t` + Ă©choue sur le bind IPv6). +4. Certbot / certs Let's Encrypt dĂ©jĂ  en place (chemins + `/etc/letsencrypt/live//{fullchain,privkey}.pem` rĂ©fĂ©rencĂ©s). +5. (Optionnel, pour le QUIC logiciel Kestrel) `apt install libmsquic` — + sinon Kestrel dĂ©sactive h3 silencieusement (fallback h1/h2 gracieux). + +## GĂ©nĂ©ration + +```bash +cd contrib + +# Preprod (dĂ©faut) -> generated/nginx/preprod-yavsc{Org,Blogs,Api} +make generate-nginx + +# Production -> generated/nginx/yavsc{Org,Blogs,Api} +make generate-nginx-prod +# ou manuellement : +make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX= +``` + +Ports backend et `HTTP_HOST` depuis `.preprod.env` / `.production.env` +(prod Org=83, Api=87, Blogs=85 ; preprod 89/93/91). SchĂ©ma backend +`https` en prod, `http` en preprod. + +| HĂŽte | Sous-domaine | `reuseport` | Backend prod | Backend preprod | +|-------|--------------|-------------|-------------------------|------------------------| +| Org | yavsc | **oui** | `https://localhost:83` | `http://localhost:89` | +| Api | api | non | `https://localhost:87` | `http://localhost:93` | +| Blogs | blogs | non | `https://localhost:85` | `http://localhost:91` | + +Domaines : `.pschneider.fr` +(prod `yavsc.pschneider.fr` ; preprod `preprod-yavsc.pschneider.fr`). + +## DĂ©ploiement sur le host + +```bash +sudo cp generated/nginx/yavscOrg /etc/nginx/sites-available/yavscOrg +sudo ln -sf /etc/nginx/sites-available/yavscOrg /etc/nginx/sites-enabled/yavscOrg +# (idem yavscApi, yavscBlogs) +sudo nginx -t && sudo systemctl reload nginx +``` + +## VĂ©rification (sur le host) + +```bash +# 1. Module h3 prĂ©sent +nginx -V 2>&1 | grep -o 'http_v3_module' + +# 2. UDP 443 ouvert +sudo ufw status | grep '443/udp' + +# 3. En-tĂȘte Alt-Svc annoncĂ© +curl -sI https://yavsc.pschneider.fr/ | grep -i alt-svc # → h3=":443" + +# 4. RequĂȘte HTTP/3 effective (curl compilĂ© avec HTTP/3) +curl -v --http3 https://yavsc.pschneider.fr/ 2>&1 | grep -i 'HTTP/3' + +# 5. Fallback h2 et h1 toujours fonctionnels +curl --http2 https://yavsc.pschneider.fr/ -o /dev/null -w '%{http_code}\n' +curl --http1.1 https://yavsc.pschneider.fr/ -o /dev/null -w '%{http_code}\n' +``` +RĂ©pĂ©ter 3-5 pour `api.pschneider.fr` et `blogs.pschneider.fr` (prod) et +les trois domaines preprod. Sans curl HTTP/3, un navigateur : DevTools → +Network → colonne « Protocole » → `h3` aprĂšs mise Ă  niveau. + +## Comment ça marche (template + envsubst) + +`contrib/template.vhost` contient les placeholders `$DOMAIN`, +`$BACKEND_SCHEME`, `$HTTP_HOST`, `$BACKEND_PORT`, `$QUIC_OPTS`. La rĂšgle +`generated/nginx/$(APP_INSTALL_PREFIX)yavsc%` du `Makefile` les substitue +via `envsubst` avec une **allowlist** : + +```makefile +envsubst '$DOMAIN $BACKEND_SCHEME $HTTP_HOST $BACKEND_PORT $QUIC_OPTS' +``` + +Cette allowlist limite la substitution aux 5 variables citĂ©es, de sorte +que les variables nginx intrinsĂšques (`$host`, `$scheme`, `$remote_addr`, +`$proxy_add_x_forwarded_for`, `$request_uri`) survivent intactes. Sans +allowlist, `envsubst` dĂ©truirait ces variables nginx. + +## Hors pĂ©rimĂštre + +- `docker-compose` / `Dockerfile` : non touchĂ©s (dĂ©ploiement bare-metal + systemd + nginx). +- Backend HTTP/2 vers Kestrel : nginx open-source ne supporte que + HTTP/1.1 en upstream ; on garde `proxy_http_version 1.1`. +- `ssl_protocols TLSv1.3` (suivant `contrib/quic.md`) dĂ©sactive TLS 1.2 + cĂŽtĂ© TCP aussi. Pour conserver la compat TLS 1.2 sur le fallback TCP, + relĂącher en `ssl_protocols TLSv1.2 TLSv1.3;` (QUIC reste en 1.3). \ No newline at end of file diff --git a/contrib/template.appsettings-api.json b/contrib/template.appsettings-api.json new file mode 100644 index 000000000..b364eeef1 --- /dev/null +++ b/contrib/template.appsettings-api.json @@ -0,0 +1,33 @@ +{ + "Site": { + "Authority": "https://preprod-yavsc.$DOMAIN", + "Audience": ["api"], + "CorsAllowedOrigins": [ + "https://preprod-blogs.$DOMAIN" + ], + "Bills": "bills", + "TempDir": "temp" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://localhost:92" + }, + "Https": { + "Url": "https://localhost:93", + "Certificate": { + "Path": "/etc/letsencrypt/live/preprod-api.$DOMAIN/fullchain.pem", + "KeyPath": "/etc/letsencrypt/live/preprod-api.$DOMAIN/privkey.pem" + }, + "Protocols": "Http1AndHttp2AndHttp3" + } + } + } +} diff --git a/contrib/template.appsettings-blogs.json b/contrib/template.appsettings-blogs.json new file mode 100644 index 000000000..f522feaca --- /dev/null +++ b/contrib/template.appsettings-blogs.json @@ -0,0 +1,40 @@ +{ + "Site": { + "Title": "Yavsc Blogs", + "Description": "A collection of blogs about software development, technology, and programming.", + "Keywords": "software development, technology, programming, coding, blogs", + "Authority": "https://preprod-yavsc.$DOMAIN", + "Audience": ["blogs"], + "CorsAllowedOrigins": [ + "https://preprod-api.$DOMAIN" + ], + "Blog": "blog" + }, + "ConnectionStrings": { + "YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore.Authentication": "Debug" + } + }, + "AllowedHosts": "*", + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://localhost:90" + }, + "Https": { + "Url": "https://localhost:91", + "Certificate": { + "Path": "/etc/letsencrypt/live/preprod-blogs.$DOMAIN/fullchain.pem", + "KeyPath": "/etc/letsencrypt/live/preprod-blogs.$DOMAIN/privkey.pem" + }, + "Protocols": "Http1AndHttp2AndHttp3" + } + } + } +} diff --git a/contrib/template.appsettings-org.json b/contrib/template.appsettings-org.json new file mode 100644 index 000000000..76203d9e9 --- /dev/null +++ b/contrib/template.appsettings-org.json @@ -0,0 +1,74 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information" + } + }, + "AllowedHosts": "*", + "Site": { + "Title": "yavsc", + "Slogan": "Yavsc!", + "StyleSheet": "/css/default.css", + "Authority": "https://preprod-yavsc.$DOMAIN", + "Audience": [ + "blogs", + "api" + ], + "ExternalUrl": "https://preprod-yavsc.$DOMAIN", + "ApiUrl": "https://preprod-api.$DOMAIN", + "CorsAllowedOrigins": [ + "https://preprod-blogs.$DOMAIN", + "https://preprod-api.$DOMAIN" + ], + "Banner": "/images/banner-dewang.jpg", + "Owner": { + "Name": "Paul Schneider", + "EMail": "paul@pschneider.fr" + }, + "Admin": { + "Name": "Paul Schneider", + "EMail": "paul@pschneider.fr" + }, + "Quota": 200000000, + "Blog": "blog", + "Avatars": "avatars", + "Bills": "bills", + "TempDir": "temp", + "DataDir": "data", + "GitRepository": "git" + }, + "Smtp": { + "Host": "mercure.pschneider.fr", + "Port": 465, + "SenderName": "Paul Schneider", + "SenderEmail": "paul@pschneider.fr", + "UserName": "paul", + "Password": "$SMTP_PASSWORD" + }, + "DataProtection": { + "Keys": { + "Dir": "DataProtection" + }, + "RSAParamFile": "RSA-Params.json", + "ExpiresInHours": 168 + }, + "Moderation": { + "AutoApproveThreshold": 0.7, + "AutoRejectThreshold": 0.9 + }, + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://localhost:88" + }, + "Https": { + "Url": "https://localhost:89", + "Certificate": { + "Path": "/etc/letsencrypt/live/preprod-yavsc.$DOMAIN/fullchain.pem", + "KeyPath": "/etc/letsencrypt/live/preprod-yavsc.$DOMAIN/privkey.pem" + }, + "Protocols": "Http1AndHttp2AndHttp3" + } + } + } +} diff --git a/contrib/template.service b/contrib/template.service index b075c4da1..3959c7151 100644 --- a/contrib/template.service +++ b/contrib/template.service @@ -10,10 +10,10 @@ RestartSec=5s Type=simple User=yavsc Group=yavsc -WorkingDirectory=$DESTDIR -ExecStart=$DESTDIR/Yavsc.$APP_NAME +WorkingDirectory=$APPDIR +ExecStart=$APPDIR/Yavsc.$APP_NAME Restart=always -Environment="HOME=$DESTDIR" +Environment="HOME=$APPDIR" Environment="ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY" Environment="ANTHROPIC_MAX_TOKENS=$ANTHROPIC_MAX_TOKENS" Environment="ASPNETCORE_Environment=$ENV" @@ -29,8 +29,6 @@ Environment="ASPNETCORE_Smtp__Password=\"$ASPNETCORE_Smtp__Password\"" CapabilityBoundingSet=CAP_NET_BIND_SERVICE AmbientCapabilities=CAP_NET_BIND_SERVICE -StandardOutput=syslog -StandardError=syslog SyslogIdentifier=$APP_SYSLOG_ID [Install] diff --git a/contrib/template.vhost b/contrib/template.vhost new file mode 100644 index 000000000..ea2c2cb6d --- /dev/null +++ b/contrib/template.vhost @@ -0,0 +1,61 @@ +# GĂ©nĂ©rĂ© depuis contrib/template.vhost par `make generate-nginx` — NE PAS ÉDITER. +# Vhost $DOMAIN — backend $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT +# +# HTTP/3 (QUIC) termine au edge nginx (plan contrib/quic.md). Requiert un +# nginx compilĂ© avec http_v3_module (≄ 1.25 ; `http3 on` ≄ 1.27.4) + UDP 443 +# ouvert. Kestrel reste backend h1/h2 (mais configure aussi h3 cĂŽtĂ© logiciel, +# cf. template.appsettings-*.json — capacitĂ©, non joignable derriĂšre nginx). +# +# Variables substituĂ©es par envsubst (allowlist) : +# DOMAIN BACKEND_SCHEME HTTP_HOST BACKEND_PORT QUIC_OPTS +# $QUIC_OPTS = "reuseport" sur un seul vhost (Org) ET en production +# uniquement ; vide en preprod et sur les autres hĂŽtes. RĂšgle : reuseport +# une seule fois par addr:port (443/udp) — or preprod et prod partagent la +# mĂȘme IP, donc seule la prod (Org) l'active. +# Les variables nginx intrinsĂšques ($host, $scheme, $remote_addr, 
) sont +# prĂ©servĂ©es (hors allowlist). + +# Port 80 : redirection vers HTTPS. (Certbot/nginx-plugin gĂšre les challenges +# ACME ; ne pas servir l'app en HTTP clair.) +server { + listen 80; + listen [::]:80; + server_name $DOMAIN; + return 301 https://$host$request_uri; +} + +server { + # 1. TCP : HTTPS classique + HTTP/2 + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + + # 2. UDP : HTTP/3 (QUIC). reuseport sur UN seul vhost (Org via QUIC_OPTS). + listen 443 quic$QUIC_OPTS; + listen [::]:443 quic$QUIC_OPTS; + http3 on; + + server_name $DOMAIN; + + # 3. Certificats Let's Encrypt (Certbot). + ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem; # managed by Certbot + ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem; # managed by Certbot + # QUIC exige TLS 1.3. (TLS 1.2 conservable pour le fallback TCP : + # `ssl_protocols TLSv1.2 TLSv1.3;` — QUIC reste en 1.3.) + ssl_protocols TLSv1.3; + + # 4. Annonce h3 au navigateur sur 443/udp. `always` = Ă©mis mĂȘme en erreur. + add_header Alt-Svc 'h3=":443"; ma=86400' always; + + location / { + proxy_pass $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 100s; + proxy_buffering off; + } + include server-status.conf; +} \ No newline at end of file diff --git a/contrib/yavsc-org b/contrib/yavsc-org deleted file mode 100644 index dc65aa9fe..000000000 --- a/contrib/yavsc-org +++ /dev/null @@ -1,54 +0,0 @@ - - -server { - if ($host = yavsc.pschneider.fr) { - return 301 https://$host$request_uri; - } # managed by Certbot - - - - listen 80; - server_name yavsc.pschneider.fr; - location / { - root /srv/www/yavsc/wwwroot; - dav_methods PUT; - - proxy_pass http://127.0.0.1:3003; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection keep-alive; - proxy_set_header Host $host; - proxy_cache_bypass $http_upgrade; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - -} - -server { - listen 443 ssl; - server_name yavsc.pschneider.fr; - ssl_certificate /etc/letsencrypt/live/yavsc.pschneider.fr-0001/fullchain.pem; # managed by Certbot - ssl_certificate_key /etc/letsencrypt/live/yavsc.pschneider.fr-0001/privkey.pem; # managed by Certbot - # Configure the SignalR Endpoint - location / { - # App server url - proxy_pass http://localhost:3003; - - proxy_cache off; - # WebSockets were implemented after http/1.0 - proxy_http_version 1.1; - - # Configuration for ServerSentEvents - proxy_buffering off; - - # Configuration for LongPolling or if your KeepAliveInterval is longer than 60 seconds - proxy_read_timeout 100s; - - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } -} - diff --git a/dotnet-tools.json b/dotnet-tools.json index b0e38abda..1762a89de 100644 --- a/dotnet-tools.json +++ b/dotnet-tools.json @@ -1,5 +1,13 @@ { "version": 1, "isRoot": true, - "tools": {} + "tools": { + "picket": { + "version": "0.2.12", + "commands": [ + "picket" + ], + "rollForward": false + } + } } \ No newline at end of file diff --git a/global.json b/global.json new file mode 100644 index 000000000..9009caf0b --- /dev/null +++ b/global.json @@ -0,0 +1,5 @@ +{ + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props index 2f27340e9..42d09cd55 100644 --- a/src/PostIt/Directory.Packages.props +++ b/src/PostIt/Directory.Packages.props @@ -1,35 +1,34 @@ - + - - true - 12.1.1 - - - - - - - - - - - - - - - - - - - - - - - - - - - + + true + 12.1.2 + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/PostIt/PostIt.Android/PostIt.Android.csproj b/src/PostIt/PostIt.Android/PostIt.Android.csproj index 3d8be385c..14516758d 100644 --- a/src/PostIt/PostIt.Android/PostIt.Android.csproj +++ b/src/PostIt/PostIt.Android/PostIt.Android.csproj @@ -11,8 +11,8 @@ false 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070 - 1.1.0-beta.1 + 1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e + 1.1.0-estimate.1 diff --git a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj index 4534a2940..f2b2a2bbf 100644 --- a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj +++ b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj @@ -6,8 +6,8 @@ enable 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070 - 1.1.0-beta.1 + 1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e + 1.1.0-estimate.1 diff --git a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj index 948e726c5..46df5025f 100644 --- a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj +++ b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj @@ -7,8 +7,8 @@ enable 1.1.0.0 1.1.0.0 - 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070 - 1.1.0-beta.1 + 1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e + 1.1.0-estimate.1 app.manifest diff --git a/src/PostIt/PostIt.Desktop/Program.cs b/src/PostIt/PostIt.Desktop/Program.cs index 0de3bd69e..a2b8cd66f 100644 --- a/src/PostIt/PostIt.Desktop/Program.cs +++ b/src/PostIt/PostIt.Desktop/Program.cs @@ -12,6 +12,8 @@ sealed class Program [STAThread] public static void Main(string[] args) { + CLIOptionHandler.HandleArgs(args); + // Short-circuit 2nd-instance launches (OS handing us the // postit://callback URL) BEFORE Avalonia spins up a window. // If we let Avalonia initialise, the new MainWindow flashes diff --git a/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs b/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs index 04cc0f314..6cac39603 100644 --- a/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/ActivitiesPageViewModelTests.cs @@ -162,6 +162,12 @@ public class ActivitiesPageViewModelTests return Task.CompletedTask; } + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + public ValueTask DisposeAsync() => ValueTask.CompletedTask; } } diff --git a/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs b/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs index 17fcc968c..34804b4cd 100644 --- a/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs @@ -352,6 +352,12 @@ public class BillingCommandPageViewModelTests return Task.CompletedTask; } + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + public ValueTask DisposeAsync() => ValueTask.CompletedTask; } } diff --git a/src/PostIt/PostIt.Tests/BillingQueriesPageViewModelTests.cs b/src/PostIt/PostIt.Tests/BillingQueriesPageViewModelTests.cs index a7f2b0844..e53139351 100644 --- a/src/PostIt/PostIt.Tests/BillingQueriesPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/BillingQueriesPageViewModelTests.cs @@ -45,10 +45,8 @@ public class BillingQueriesPageViewModelTests Assert.Equal(2, vm.Queries.Count); Assert.All(vm.Queries, q => Assert.DoesNotContain("Rejected", q.StatusLabel, StringComparison.OrdinalIgnoreCase)); Assert.Contains("lecture seule", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); - Assert.False(vm.CanOpenDetails); - vm.SelectedQuery = vm.Queries[0]; - Assert.False(vm.OpenSelectedQueryCommand.CanExecute(null)); + Assert.True(vm.Queries.Count > 0); } private sealed class StubBillingApi : IYavscApiClient @@ -129,6 +127,12 @@ public class BillingQueriesPageViewModelTests return Task.CompletedTask; } + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + public ValueTask DisposeAsync() => ValueTask.CompletedTask; } } diff --git a/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs b/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs index 4f102be20..112c11752 100644 --- a/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs +++ b/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs @@ -62,4 +62,17 @@ internal sealed class RecordingYavscApiClient : YavscApiClient }); return Task.FromResult(default(T)!); } + + /// Multipart overload: BlogApiClient routes + /// Create/UpdatePostAsync with attachments through here. + /// Without this override the call would fall through to the + /// base and attempt a real HTTP + /// round-trip against the stub authority. The body is a + /// factory, so we record the delegate itself; tests that + /// care about the multipart shape invoke it. + public override Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + { + _recorder.Calls.Add((method, path, contentFactory)); + return Task.FromResult(default(T)!); + } } diff --git a/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs b/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs new file mode 100644 index 000000000..946d3d28e --- /dev/null +++ b/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs @@ -0,0 +1,79 @@ +using Yavsc.Blogspot; +using Yavsc.Api.Client; +using PostIt.ViewModels; + +namespace PostIt.Tests; + +/// +/// Pins the markdown link that +/// appends to the article after a successful attachment upload. +/// +/// The link must be an absolute URL derived from the OIDC +/// authority via FileServerUrlHelpers.GetUserFilesUri +/// (Yavsc.Abstract): the /files file server is hosted by +/// Yavsc.Org — the IdP — not by the blogs API host, so a link +/// built from BlogsApiUrl (or left relative) 404s when the +/// reader opens the post. The regression this guards: a generated +/// link of the shape /files/{user}/blogs/{id}/{name} +/// (relative, authority-less) instead of +/// https://{authority}/files/{user}/blogs/{id}/{name}. +/// +public class BlogAttachmentLinkTests +{ + [Fact] + public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority() + { + const string authority = "https://idp.example.test"; + + var recorder = new CallRecorder(); + var api = new RecordingYavscApiClient(recorder); + var blog = new BlogApiClient(api, "https://blogs.example.test/api/v1/"); + var settings = new Settings + { + Authentication = new AuthenticationSettings + { + Authority = authority, + ClientId = "stub", + Scopes = new[] { "openid" }, + }, + }; + var vm = new BlogsViewModel(blog, settings); + + // Update branch: an existing post is selected, the user + // edits the buffers and attaches a file. + vm.SelectedPost = new BlogPostDto + { + Id = 42, + AuthorId = "tester", + Title = "Avant", + Article = "Contenu initial.", + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow, + }; + vm.DraftTitle = "AprĂšs"; + vm.DraftArticle = "Contenu modifiĂ©."; + vm.DraftAttachments.Add(new BlogUploadFile( + "note.txt", + System.Text.Encoding.UTF8.GetBytes("payload test"), + "text/plain")); + + await vm.SaveAsync(); + + // The update branch issues two PUTs: the multipart one + // carrying the file, then — because TryAppendAttachmentLinks + // appended a link — a JSON one carrying the final article. + var linkPut = recorder.Calls.LastOrDefault( + c => c.method == HttpMethod.Put); + Assert.NotEqual(default, linkPut); + var sent = Assert.IsType>(linkPut.body); + var multipartContent = Assert.IsType(sent()); + + var expectedUrl = $"{authority}/files/tester/blogs/42/note.txt"; + var sentStringContent = multipartContent.First() as StringContent ; + + var sentString = await sentStringContent.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.Contains($"- [note.txt]({expectedUrl})", sentString); + // The regression shape: a relative, authority-less link. + Assert.DoesNotContain("](/files/", sentString); + } +} diff --git a/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs b/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs new file mode 100644 index 000000000..17efd391b --- /dev/null +++ b/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs @@ -0,0 +1,283 @@ +using System.Net.Http; +using PostIt.ViewModels; +using Yavsc; +using Yavsc.Api.Client; + +namespace PostIt.Tests; + +public class EstimateEditionPageViewModelTests +{ + private static BillingQuerySummaryDto SampleQuery() => new() + { + Id = 42, + BillingCode = "Brush", + ActivityCode = "hair", + PerformerId = "perf-1", + ClientId = "cli-1", + Status = QueryStatus.InProgress, + Description = "Coupe simple", + EventDate = new DateTime(2026, 9, 12, 10, 0, 0, DateTimeKind.Utc), + }; + + private static EstimateEditionPageViewModel CreateViewModel(StubEstimateApi api, BillingQuerySummaryDto? query = null) + { + var client = new EstimateApiClient(api, "https://business.example/api/v1/"); + var fsClient = new UserFilesApiClient(api, "https://blogs.example/api/v1/"); + return new EstimateEditionPageViewModel(query ?? SampleQuery(), client, fsClient); + } + + [Fact] + public void Constructor_prefills_description_and_adds_a_first_line() + { + var api = new StubEstimateApi(); + var vm = CreateViewModel(api); + + Assert.Equal("Coupe simple", vm.EstimateDescription); + Assert.Single(vm.Lines); + Assert.Same(vm.Lines[0], vm.SelectedLine); + Assert.Contains("#42", vm.ContextLabel); + Assert.Contains("cli-1", vm.ContextLabel); + } + + [Fact] + public void AddLine_appends_and_selects_the_new_line() + { + var api = new StubEstimateApi(); + var vm = CreateViewModel(api); + + vm.AddLineCommand.Execute(null); + + Assert.Equal(2, vm.Lines.Count); + Assert.Same(vm.Lines[1], vm.SelectedLine); + } + + [Fact] + public void RemoveLine_removes_the_selected_line() + { + var api = new StubEstimateApi(); + var vm = CreateViewModel(api); + var first = vm.Lines[0]; + + vm.RemoveLineCommand.Execute(null); + + Assert.Empty(vm.Lines); + Assert.Null(vm.SelectedLine); + Assert.False(vm.RemoveLineCommand.CanExecute(null)); + Assert.DoesNotContain(first, vm.Lines); + } + + [Fact] + public void Total_sums_line_totals_and_tracks_edits() + { + var api = new StubEstimateApi(); + var vm = CreateViewModel(api); + + vm.Lines[0].Count = 2; + vm.Lines[0].UnitaryCost = 15.5m; + + Assert.Equal(31m, vm.Total); + Assert.Equal($"{31m:0.00} EUR", vm.TotalLabel); + + vm.AddLineCommand.Execute(null); + vm.Lines[1].Count = 1; + vm.Lines[1].UnitaryCost = 9m; + + Assert.Equal(40m, vm.Total); + } + + [Fact] + public async Task Send_without_title_warns_and_does_not_post() + { + var api = new StubEstimateApi(); + var vm = CreateViewModel(api); + vm.Lines[0].Name = "Coupe"; + vm.Lines[0].Description = "Coupe simple"; + vm.Lines[0].UnitaryCost = 25m; + + await vm.SendCommand.ExecuteAsync(null); + + Assert.Null(api.LastBody); + Assert.Equal(StatusSeverity.Warning, vm.ActionStatus.Severity); + Assert.Contains("titre", vm.ActionStatus.Message); + } + + [Fact] + public async Task Send_without_any_line_warns_and_does_not_post() + { + var api = new StubEstimateApi(); + var vm = CreateViewModel(api); + vm.EstimateTitle = "Devis coupe"; + vm.Lines.Clear(); + + await vm.SendCommand.ExecuteAsync(null); + + Assert.Null(api.LastBody); + Assert.Equal(StatusSeverity.Warning, vm.ActionStatus.Severity); + Assert.Contains("ligne", vm.ActionStatus.Message); + } + + [Fact] + public async Task Send_with_a_blank_line_name_warns_and_does_not_post() + { + var api = new StubEstimateApi(); + var vm = CreateViewModel(api); + vm.EstimateTitle = "Devis coupe"; + vm.Lines[0].Description = "Oubli du nom"; + + await vm.SendCommand.ExecuteAsync(null); + + Assert.Null(api.LastBody); + Assert.Equal(StatusSeverity.Warning, vm.ActionStatus.Severity); + Assert.Contains("nom", vm.ActionStatus.Message); + } + + [Fact] + public async Task Send_posts_the_estimate_payload_to_the_estimate_route() + { + var api = new StubEstimateApi(); + var vm = CreateViewModel(api); + vm.EstimateTitle = " Devis coupe "; + vm.Lines[0].Name = "Coupe"; + vm.Lines[0].Description = "Coupe simple"; + vm.Lines[0].Count = 2.4m; + vm.Lines[0].UnitaryCost = 25m; + + await vm.SendCommand.ExecuteAsync(null); + + var postCall = Assert.Single(api.Calls, c => c.Method == HttpMethod.Post && c.Path == "https://business.example/api/v1/estimate"); + var payload = Assert.IsType(postCall.Body); + Assert.Equal(42, payload.CommandId); + Assert.Equal("cli-1", payload.ClientId); + Assert.Equal("Brush", payload.CommandType); + Assert.Equal("Devis coupe", payload.Title); + Assert.Equal("Coupe simple", payload.Description); + Assert.Empty(payload.AttachedFiles); + Assert.Empty(payload.AttachedGraphics); + + var line = Assert.Single(payload.Bill); + Assert.Equal("Coupe", line.Name); + Assert.Equal(2, line.Count); + Assert.Equal(25m, line.UnitaryCost); + Assert.Equal("EUR", line.Currency); + } + + [Fact] + public async Task Send_marks_the_page_as_sent_and_disables_resend() + { + var api = new StubEstimateApi(); + var vm = CreateViewModel(api); + vm.EstimateTitle = "Devis coupe"; + vm.Lines[0].Name = "Coupe"; + vm.Lines[0].Description = "Coupe simple"; + vm.Lines[0].UnitaryCost = 25m; + + await vm.SendCommand.ExecuteAsync(null); + + Assert.True(vm.HasSent); + Assert.False(vm.SendCommand.CanExecute(null)); + Assert.Equal("Devis envoyĂ©", vm.SendLabel); + Assert.Equal(StatusSeverity.Info, vm.ActionStatus.Severity); + Assert.Contains("#7", vm.ActionStatus.Message); + } + + [Fact] + public async Task Send_surfaces_server_errors_as_error_status() + { + var api = new StubEstimateApi { Failure = new HttpRequestException("boom", null, System.Net.HttpStatusCode.InternalServerError) }; + var vm = CreateViewModel(api); + vm.EstimateTitle = "Devis coupe"; + vm.Lines[0].Name = "Coupe"; + vm.Lines[0].Description = "Coupe simple"; + + await vm.SendCommand.ExecuteAsync(null); + + Assert.False(vm.HasSent); + Assert.Equal(StatusSeverity.Error, vm.ActionStatus.Severity); + Assert.True(vm.SendCommand.CanExecute(null)); + } + + [Fact] + public async Task Send_accepts_negative_amounts_for_discount_lines() + { + var api = new StubEstimateApi(); + var vm = CreateViewModel(api); + vm.EstimateTitle = "Devis avec remise"; + vm.Lines[0].Name = "Coupe"; + vm.Lines[0].Description = "Coupe simple"; + vm.Lines[0].UnitaryCost = 25m; + + vm.AddLineCommand.Execute(null); + vm.Lines[1].Name = "Remise fidĂ©litĂ©"; + vm.Lines[1].Description = "Remise client rĂ©gulier"; + vm.Lines[1].UnitaryCost = -5m; + + Assert.Equal(20m, vm.Total); + + await vm.SendCommand.ExecuteAsync(null); + + var postCall = Assert.Single(api.Calls, c => c.Method == HttpMethod.Post && c.Path == "https://business.example/api/v1/estimate"); + var payload = Assert.IsType(postCall.Body); + Assert.Equal(2, payload.Bill.Count); + Assert.Equal(-5m, payload.Bill[1].UnitaryCost); + Assert.True(vm.HasSent); + } + + private sealed class StubEstimateApi : IYavscApiClient + { + public HttpClient Http { get; } = new(); + public string? LastPath { get; private set; } + public HttpMethod? LastMethod { get; private set; } + public object? LastBody { get; private set; } + public Exception? Failure { get; init; } + + /// Historique complet des appels (le plus rĂ©cent en dernier). + public List<(HttpMethod Method, string Path, object? Body)> Calls { get; } = new(); + + private void Record(HttpMethod method, string path, object? body) + { + LastMethod = method; + LastPath = path; + LastBody = body; + Calls.Add((method, path, body)); + } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + Record(method, path, body); + + if (Failure is not null) + { + throw Failure; + } + + if (typeof(T) == typeof(EstimateCreatedDto)) + { + var payload = (EstimateDto)body!; + var created = new EstimateCreatedDto { Id = 7, Bill = payload.Bill }; + return Task.FromResult((T)(object)created); + } + + return Task.FromResult(default(T)!); + } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + Record(method, path, body); + return Task.CompletedTask; + } + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public Task DownloadAsync(HttpMethod method, string path, CancellationToken ct = default) + { + Record(method, path, null); + return Task.FromResult(Array.Empty()); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} diff --git a/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs b/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs new file mode 100644 index 000000000..eefd59f20 --- /dev/null +++ b/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs @@ -0,0 +1,138 @@ +using System.Net.Http; +using PostIt.ViewModels; +using Yavsc.Api.Client; + +namespace PostIt.Tests; + +public class EstimateListPageViewModelTests +{ + [Fact] + public async Task Client_perspective_calls_asclient_endpoint() + { + var api = new StubEstimateListApi(); + var client = new EstimateApiClient(api, "https://business.example/api/v1/"); + var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client, front); + + await vm.InitializeAsync(); + + Assert.Contains("https://business.example/api/v1/estimate/asclient", api.Paths); + Assert.Equal("Devis en attente de signature client", vm.Title); + Assert.Equal(2, vm.Estimates.Count); + // Most recent first + Assert.Equal(2, vm.Estimates[0].Id); + Assert.Equal(1, vm.Estimates[1].Id); + } + + [Fact] + public async Task Provider_perspective_calls_asprovider_endpoint() + { + var api = new StubEstimateListApi(); + var client = new EstimateApiClient(api, "https://business.example/api/v1/"); + var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider, front); + + await vm.InitializeAsync(); + + Assert.Contains("https://business.example/api/v1/estimate/asprovider", api.Paths); + Assert.Equal("Devis en attente de signature fournisseur", vm.Title); + Assert.Equal(2, vm.Estimates.Count); + } + + [Fact] + public async Task FilterText_filters_by_title_and_counterpart() + { + var api = new StubEstimateListApi(); + var client = new EstimateApiClient(api, "https://business.example/api/v1/"); + var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider, front); + + await vm.InitializeAsync(); + + vm.FilterText = "plomberie"; + Assert.Single(vm.Estimates); + Assert.Equal(1, vm.Estimates[0].Id); + + vm.FilterText = "BOB"; + Assert.Single(vm.Estimates); + Assert.Equal("bob", vm.Estimates[0].ClientId); + + vm.FilterText = string.Empty; + Assert.Equal(2, vm.Estimates.Count); + } + + [Fact] + public async Task Empty_result_sets_an_info_status() + { + var api = new StubEstimateListApi { Empty = true }; + var client = new EstimateApiClient(api, "https://business.example/api/v1/"); + var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client, front); + + await vm.InitializeAsync(); + + Assert.Empty(vm.Estimates); + Assert.Contains("Aucun devis", vm.StatusMessage); + } + + private sealed class StubEstimateListApi : IYavscApiClient + { + public HttpClient Http { get; } = new(); + public List Paths { get; } = new(); + public bool Empty { get; init; } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + Paths.Add(path); + + if (typeof(T) == typeof(List)) + { + var items = Empty + ? new List() + : new List + { + new() + { + Id = 1, + Title = "Devis plomberie", + Description = "RĂ©paration fuite", + ClientId = "bob", + OwnerId = "alice", + CommandType = "Rdv", + Bill = new List + { + new() { Name = "Main d'Ɠuvre", Description = "Intervention", Count = 1, UnitaryCost = 80m }, + }, + }, + new() + { + Id = 2, + Title = "Devis Ă©lectricitĂ©", + Description = "Mise aux normes", + ClientId = "carol", + OwnerId = "alice", + CommandType = "Rdv", + Bill = new List(), + }, + }; + return Task.FromResult((T)(object)items); + } + + return Task.FromResult(default(T)!); + } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + Paths.Add(path); + return Task.CompletedTask; + } + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} diff --git a/src/PostIt/PostIt.Tests/EstimateValidationPageViewModelTests.cs b/src/PostIt/PostIt.Tests/EstimateValidationPageViewModelTests.cs new file mode 100644 index 000000000..02a6c64bd --- /dev/null +++ b/src/PostIt/PostIt.Tests/EstimateValidationPageViewModelTests.cs @@ -0,0 +1,255 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using PostIt.Controls; +using PostIt.ViewModels; +using Yavsc.Api.Client; +using Yavsc.Models.Billing; + +namespace PostIt.Tests; + +/// +/// Verifies wires the two +/// signature pads (provider + client) into the right +/// call. The view (XAML, rendering) +/// is not tested here; the VM is driven through the same +/// test hooks the signature tests use, +/// and the API client through a recording fake +/// . The test host's +/// TestApp makes GoBackAsync a no-op, so the success paths +/// run to completion without navigation side effects. +/// +public class EstimateValidationPageViewModelTests +{ + private static EstimateDto SampleEstimate(long? commandId = 42) => new() + { + Id = 7, + CommandId = commandId, + CommandType = "Rdv", + Title = "Devis plomberie", + Description = "RĂ©paration", + ClientId = "bob", + OwnerId = "alice", + }; + + private static void DrawOneStroke(SignaturePadControl pad) + { + pad.AppendPointForTest(100, 200); + pad.AppendPointForTest(300, 400); + pad.SealStrokeForTest(); + } + + [Fact] + public async Task Validate_calls_accept_with_strokes_and_command_id() + { + var api = new RecordingApi(); + var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateValidationPageViewModel( + SampleEstimate(), EstimateListPerspective.Provider, front); + + var proPad = new SignaturePadControl(); + var clientPad = new SignaturePadControl(); + vm.Attach(proPad, clientPad); + DrawOneStroke(proPad); // provider signs on their writable pad + + await vm.ValidateAsync(); + + Assert.Equal(HttpMethod.Post, api.LastMethod); + Assert.Contains("front/query/accept", api.LastPath); + Assert.Contains("billingCode=Rdv", api.LastPath); + Assert.Contains("queryId=42", api.LastPath); + var body = Assert.IsType(api.LastBody); + Assert.NotNull(body.Strokes); + Assert.NotEmpty(body.Strokes); + Assert.Equal(10_000, body.CoordinateMax); + // The provider perspective declares the Pro side, so the + // server stores the signature under the provider's author + // regardless of role inference. + Assert.Equal(SignatureType.Pro, body.SignatureType); + } + + [Fact] + public async Task Validate_from_client_perspective_declares_client_side() + { + var api = new RecordingApi(); + var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateValidationPageViewModel( + SampleEstimate(), EstimateListPerspective.Client, front); + + var cliPad = new SignaturePadControl(); + vm.Attach(new SignaturePadControl(), cliPad); + DrawOneStroke(cliPad); // client signs on their writable pad + + await vm.ValidateAsync(); + + var body = Assert.IsType(api.LastBody); + Assert.Equal(SignatureType.Client, body.SignatureType); // Client side + } + + [Fact] + public async Task Reject_calls_reject_endpoint_with_no_body() + { + var api = new RecordingApi(); + var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateValidationPageViewModel( + SampleEstimate(), EstimateListPerspective.Client, front); + + vm.Attach(new SignaturePadControl(), new SignaturePadControl()); + + await vm.RejectAsync(); + + Assert.Equal(HttpMethod.Post, api.LastMethod); + Assert.Contains("front/query/reject", api.LastPath); + Assert.Contains("queryId=42", api.LastPath); + Assert.Null(api.LastBody); + } + + [Fact] + public async Task Validate_without_signature_warns_and_does_not_call() + { + var api = new RecordingApi(); + var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateValidationPageViewModel( + SampleEstimate(), EstimateListPerspective.Provider, front); + + vm.Attach(new SignaturePadControl(), new SignaturePadControl()); // empty pads + + await vm.ValidateAsync(); // no throw: returns before GoBack + + Assert.Null(api.LastMethod); + Assert.Contains("signer", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Validate_command_gates_on_signature_and_command() + { + var api = new RecordingApi(); + var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + var vm = new EstimateValidationPageViewModel( + SampleEstimate(commandId: null), EstimateListPerspective.Provider, front); + + var proPad = new SignaturePadControl(); + vm.Attach(proPad, new SignaturePadControl()); + + // No CommandId ⇒ cannot validate even after signing. + DrawOneStroke(proPad); + Assert.False(vm.ValidateCommand.CanExecute(null)); + + var vm2 = new EstimateValidationPageViewModel( + SampleEstimate(), EstimateListPerspective.Provider, front); + var proPad2 = new SignaturePadControl(); + vm2.Attach(proPad2, new SignaturePadControl()); + Assert.False(vm2.ValidateCommand.CanExecute(null)); // no signature yet + DrawOneStroke(proPad2); + Assert.True(vm2.ValidateCommand.CanExecute(null)); + } + + [Fact] + public void RoleLabel_reflects_perspective() + { + var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/"); + var pro = new EstimateValidationPageViewModel( + SampleEstimate(), EstimateListPerspective.Provider, front); + var cli = new EstimateValidationPageViewModel( + SampleEstimate(), EstimateListPerspective.Client, front); + + Assert.Contains("prestataire", pro.RoleLabel, StringComparison.OrdinalIgnoreCase); + Assert.Contains("client", cli.RoleLabel, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Only_the_author_pad_is_writable() + { + var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/"); + + var proVm = new EstimateValidationPageViewModel( + SampleEstimate(), EstimateListPerspective.Provider, front); + var proPad = new SignaturePadControl(); + var cliPad = new SignaturePadControl(); + proVm.Attach(proPad, cliPad); + Assert.False(proPad.IsReadOnly); // provider writes the Pro pad + Assert.True(cliPad.IsReadOnly); + + var cliVm = new EstimateValidationPageViewModel( + SampleEstimate(), EstimateListPerspective.Client, front); + var proPad2 = new SignaturePadControl(); + var cliPad2 = new SignaturePadControl(); + cliVm.Attach(proPad2, cliPad2); + Assert.True(proPad2.IsReadOnly); + Assert.False(cliPad2.IsReadOnly); // client writes the Client pad + } + + [Fact] + public void Load_existing_signatures_renders_both_sides_in_their_pads() + { + var proStrokes = new[] { 1, 1000, 1000 }; + var cliStrokes = new[] { 1, 9000, 9000 }; + var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/"); + var estimate = SampleEstimate(); + estimate.SignaturePro = new EstimateSignatureDto { Strokes = proStrokes }; + estimate.SignatureClient = new EstimateSignatureDto { Strokes = cliStrokes }; + + // Client perspective: both pads show the stored signatures, but + // only the client pad is writable. + var vm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Client, front); + var proPad = new SignaturePadControl(); + var cliPad = new SignaturePadControl(); + vm.Attach(proPad, cliPad); + + Assert.Equal(proStrokes, proPad.Snapshot().Strokes); + Assert.Equal(cliStrokes, cliPad.Snapshot().Strokes); + Assert.True(vm.HasProSignature); + Assert.True(vm.HasClientSignature); + } + + [Fact] + public void Load_existing_pro_signature_renders_in_pro_pad_only() + { + var strokes = new[] { 1, 5000, 5000 }; + var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/"); + var estimate = SampleEstimate(); + estimate.SignaturePro = new EstimateSignatureDto { Strokes = strokes, CoordinateMax = 10_000 }; + var vm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Provider, front); + + var proPad = new SignaturePadControl(); + var cliPad = new SignaturePadControl(); + vm.Attach(proPad, cliPad); + + Assert.Equal(strokes, proPad.Snapshot().Strokes); + Assert.True(cliPad.Snapshot().IsEmpty); + Assert.True(vm.HasProSignature); + Assert.False(vm.HasClientSignature); + } + + private sealed class RecordingApi : IYavscApiClient + { + public HttpClient Http { get; } = new(); + public HttpMethod? LastMethod { get; private set; } + public string? LastPath { get; private set; } + public object? LastBody { get; private set; } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + LastMethod = method; + LastPath = path; + LastBody = body; + return Task.FromResult(default(T)!); + } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + LastMethod = method; + LastPath = path; + LastBody = body; + return Task.CompletedTask; + } + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt.Tests/FrontOfficeApiClientTests.cs b/src/PostIt/PostIt.Tests/FrontOfficeApiClientTests.cs new file mode 100644 index 000000000..02f6ae149 --- /dev/null +++ b/src/PostIt/PostIt.Tests/FrontOfficeApiClientTests.cs @@ -0,0 +1,157 @@ +using System.Net.Http; +using Yavsc.Api.Client; + +namespace PostIt.Tests; + +/// +/// Verifies issues the right +/// api/v1/front/query/accept / .../reject requests with +/// the PostIt signature payload, using a recording fake +/// (no network). +/// +public class FrontOfficeApiClientTests +{ + private static QueryAcceptanceRequestDto SampleBody() => new() + { + Strokes = new[] { 2, 100, 200, 300, 400 }, + CoordinateMax = 10_000, + CapturedAtUtc = new DateTime(2026, 9, 21, 12, 0, 0, DateTimeKind.Utc), + }; + + [Fact] + public async Task Accept_posts_to_accept_endpoint_with_signature_body() + { + var api = new RecordingApi(); + var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + + var body = SampleBody(); + await client.AcceptQueryAsync("Rdv", 42, body); + + Assert.Equal(HttpMethod.Post, api.LastMethod); + Assert.Equal( + "https://business.example/api/v1/front/query/accept?billingCode=Rdv&queryId=42", + api.LastPath); + Assert.Same(body, api.LastBody); + } + + [Fact] + public async Task Reject_posts_to_reject_endpoint_with_no_body() + { + var api = new RecordingApi(); + var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + + await client.RejectQueryAsync("Rdv", 42); + + Assert.Equal(HttpMethod.Post, api.LastMethod); + Assert.Equal( + "https://business.example/api/v1/front/query/reject?billingCode=Rdv&queryId=42", + api.LastPath); + Assert.Null(api.LastBody); + } + + [Fact] + public async Task Accept_returns_deserialized_response() + { + var api = new RecordingApi + { + Response = new QueryAcceptanceResponseDto + { + QueryId = 42, + Status = "Accepted", + Signature = new QuerySignatureRefDto { Id = 9, EstimateId = 5, Type = "Pro" }, + }, + }; + var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + + var response = await client.AcceptQueryAsync("Rdv", 42, SampleBody()); + + Assert.Equal(42, response.QueryId); + Assert.Equal("Accepted", response.Status); + Assert.NotNull(response.Signature); + Assert.Equal("Pro", response.Signature!.Type); + } + + [Fact] + public async Task Accept_rejects_invalid_arguments() + { + var api = new RecordingApi(); + var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + + await Assert.ThrowsAsync(() => client.AcceptQueryAsync("", 42, SampleBody())); + await Assert.ThrowsAsync(() => client.AcceptQueryAsync("Rdv", 0, SampleBody())); + await Assert.ThrowsAsync(() => client.AcceptQueryAsync("Rdv", 42, null!)); + await Assert.ThrowsAsync(() => client.RejectQueryAsync("", 42)); + await Assert.ThrowsAsync(() => client.RejectQueryAsync("Rdv", 0)); + } + + [Fact] + public async Task GetEstimatePdf_gets_estimate_pdf_endpoint() + { + var api = new RecordingApi(); + var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + + await client.GetEstimatePdfAsync(42); + + Assert.Equal(HttpMethod.Get, api.LastMethod); + Assert.Equal( + "https://business.example/api/v1/front/query/42/estimate.pdf", + api.LastPath); + } + + [Fact] + public async Task GetEstimateTex_gets_estimate_tex_endpoint() + { + var api = new RecordingApi(); + var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); + + await client.GetEstimateTexAsync(42); + + Assert.Equal(HttpMethod.Get, api.LastMethod); + Assert.Equal( + "https://business.example/api/v1/front/query/42/estimate.tex", + api.LastPath); + } + + private sealed class RecordingApi : IYavscApiClient + { + public HttpClient Http { get; } = new(); + public HttpMethod? LastMethod { get; private set; } + public string? LastPath { get; private set; } + public object? LastBody { get; private set; } + public QueryAcceptanceResponseDto? Response { get; set; } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + LastMethod = method; + LastPath = path; + LastBody = body; + + var response = Response ?? new QueryAcceptanceResponseDto(); + return Task.FromResult((T)(object)response); + } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + LastMethod = method; + LastPath = path; + LastBody = body; + return Task.CompletedTask; + } + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) + => CallAsync(method, path, (object?)null, ct); + + public Task DownloadAsync(HttpMethod method, string path, CancellationToken ct = default) + { + LastMethod = method; + LastPath = path; + LastBody = null; + return Task.FromResult(Array.Empty()); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt.Tests/HomePageProviderFlowTests.cs b/src/PostIt/PostIt.Tests/HomePageProviderFlowTests.cs new file mode 100644 index 000000000..16f01c5e9 --- /dev/null +++ b/src/PostIt/PostIt.Tests/HomePageProviderFlowTests.cs @@ -0,0 +1,15 @@ +using PostIt.ViewModels; + +namespace PostIt.Tests; + +public class HomePageProviderFlowTests +{ + [Fact] + public void HomePage_exposes_provider_requests_command() + { + var vm = new HomePageViewModel(); + + Assert.NotNull(vm.OpenProviderRequests); + Assert.True(vm.OpenProviderRequests.CanExecute(null)); + } +} diff --git a/src/PostIt/PostIt.Tests/MainPageButtonsTests.cs b/src/PostIt/PostIt.Tests/MainPageButtonsTests.cs index d1d00532d..f72c8d4d5 100644 --- a/src/PostIt/PostIt.Tests/MainPageButtonsTests.cs +++ b/src/PostIt/PostIt.Tests/MainPageButtonsTests.cs @@ -8,6 +8,8 @@ using Yavsc.Blogspot; using PostIt.Services; using PostIt.ViewModels; using PostIt.Views; +using PostIt.Views.Blogs; +using PostIt.Helpers; namespace PostIt.Tests; @@ -72,13 +74,13 @@ public class MainPageButtonsTests { } } - private static MainViewModel MakeViewModel(BlogPostDto? selectedPost = null) + private static BlogsViewModel MakeViewModel(BlogPostDto? selectedPost = null) { var api = new ThrowingApi(); var blog = new BlogApiClient(api, "http://localhost/"); var circle = new CircleApiClient(api, "http://localhost/"); var acl = new BlogAclApiClient(api, "http://localhost/"); - // Minimal DI graph: only what MainPageViewModel resolves + // Minimal DI graph: only what BlogsViewModel resolves // when the user clicks a navigation button. Today that's // SignaturePageViewModel / CirclesPageViewModel / ACL // dependencies. The graph intentionally stays local to this @@ -93,7 +95,7 @@ public class MainPageButtonsTests services.AddTransient(); services.AddTransient(); services.AddTransient(); - var vm = new MainViewModel(blog, services: services.BuildServiceProvider()); + var vm = new BlogsViewModel(blog, services: services.BuildServiceProvider()); if (selectedPost is not null) vm.SelectedPost = selectedPost; return vm; } @@ -101,7 +103,7 @@ public class MainPageButtonsTests /// /// Mount a real (as /// SessionStatusBannerTests does), push a - /// with the given VM onto + /// with the given VM onto /// NavRoot. PushAsync is awaited (via /// GetAwaiter().GetResult()) so the page is on the /// nav stack before the test tries to interact with its @@ -109,13 +111,17 @@ public class MainPageButtonsTests /// realised and KeyPressQwerty has a real /// to dispatch against. /// - private static (MainView window, MainPage page) MountMainPage(MainViewModel vm) + private static (MainView window, BlogsPage page) MountMainPage(BlogsViewModel vm) { var window = new MainView(); - var page = new MainPage { DataContext = vm }; + var page = new BlogsPage { DataContext = vm }; var app = (PostIt.App)Application.Current!; + app.AttachMainWindow(window); + window.NavRoot.PushAsync(page).GetAwaiter().GetResult(); + var mainWindow = new Window { Content = window }; + mainWindow.Show(); return (window, page); } @@ -145,7 +151,7 @@ public class MainPageButtonsTests } [AvaloniaFact] - public void Acl_button_click_pushes_a_page_onto_nav_stack() + public async Task Acl_button_click_pushes_a_page_onto_nav_stack() { // Arrange: a VM whose SelectedPost is non-null so // CanManageAcl evaluates to true and the button is @@ -178,7 +184,7 @@ public class MainPageButtonsTests } [AvaloniaFact] - public void Circles_button_click_pushes_a_page_onto_nav_stack() + public async Task Circles_button_click_pushes_a_page_onto_nav_stack() { // Arrange: OpenCircles has no CanExecute guard today — // any click should fire it and push the page. @@ -203,7 +209,7 @@ public class MainPageButtonsTests public void Signature_dev_button_click_pushes_a_page_onto_nav_stack() { // Arrange: the "[DEV] Signature" button is bound to the - // MainPageViewModel.OpenSignatureDevCommand [RelayCommand]. + // BlogsViewModel.OpenSignatureDevCommand [RelayCommand]. // The click must push SignaturePage on top of NavRoot. // The ServiceCollection registered in MakeViewModel provides // SignaturePageViewModel so the command can resolve it via diff --git a/src/PostIt/PostIt.Tests/MainPageSaveTests.cs b/src/PostIt/PostIt.Tests/MainPageSaveTests.cs index 0fd5627c8..519cd141d 100644 --- a/src/PostIt/PostIt.Tests/MainPageSaveTests.cs +++ b/src/PostIt/PostIt.Tests/MainPageSaveTests.cs @@ -5,10 +5,11 @@ using Yavsc.Blogspot; using Yavsc.Api.Client; using PostIt.ViewModels; using PostIt.Views; +using PostIt.Views.Blogs; namespace PostIt.Tests; /// -/// Headless UI tests for the "Save" flow in . +/// Headless UI tests for the "Save" flow in . /// The pattern is the one SessionStatusBannerTests /// established: [AvaloniaFact], a /// hosting the page (via a because @@ -40,9 +41,9 @@ public class MainPageSaveTests var recorder = new CallRecorder(); var api = new RecordingYavscApiClient(recorder); var blog = new BlogApiClient(api, "http://localhost/"); - var viewModel = new MainViewModel(blog); + var viewModel = new BlogsViewModel(blog); - var page = new MainPage { DataContext = viewModel }; + var page = new BlogsPage { DataContext = viewModel }; // MainPage is a ContentPage (a Page, not a Control), so it // must be hosted in a navigation surface. The production // MainWindow.axaml uses NavigationPage, and the API is the @@ -63,9 +64,7 @@ public class MainPageSaveTests const string typed = "Mon premier billet"; titleBox.Text = typed; - var saveButton = window.GetVisualDescendants() - .OfType