diff --git a/.forgejo/workflows/buildAndTest.yml b/.forgejo/workflows/buildAndTest.yml index ea58d2fef..67b743fb5 100644 --- a/.forgejo/workflows/buildAndTest.yml +++ b/.forgejo/workflows/buildAndTest.yml @@ -19,18 +19,19 @@ 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 cd _src @@ -40,12 +41,17 @@ 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 -n10 \ + - 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 ($?)!" + --filter="Category!=Platform-Android" diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index a72f92bd6..7b17bba42 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: @@ -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 b7813f604..056cf69d2 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ appsettings-*.*.json generated/ *.tmp +tmp/ DataDir/ *.tests.trx 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/.vscode/launch.json b/.vscode/launch.json index dc8d3c68d..d5088f01d 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,10 +36,10 @@ "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", @@ -37,12 +53,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..71384cd8c 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -47,7 +47,10 @@ "group": "build", "isBuildCommand": true, "isTestCommand": false, - "isBackground": true + "isBackground": true, + "options": { + "cwd": "${workspaceFolder}" + } }, { "label": "test blogs backend", @@ -63,6 +66,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 +101,21 @@ "kind": "build" }, "isBackground": true + }, + { + "label": "dotnet: build-postit-desktop", + "type": "process", + "isBuildCommand": true, + "isTestCommand": false, + "isBackground": true, + "command": "dotnet", + "args": ["build", "/property:GenerateFullPaths=true"], + "options": { + "cwd": "src/PostIt/PostIt.Desktop" + }, + "group": { + "kind": "build" + } } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 111c1491a..3f8253c70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,45 @@ # Changelog +## [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 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/Makefile b/Makefile index 08f0461d2..d99fe5e5e 100644 --- a/Makefile +++ b/Makefile @@ -48,70 +48,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/contrib/Makefile b/contrib/Makefile index 79145668f..4e4ef4df6 100644 --- a/contrib/Makefile +++ b/contrib/Makefile @@ -95,6 +95,15 @@ showConfig: @echo CONFIGURATION: $(CONFIGURATION) @echo BASEAPPDIR: $(BASEAPPDIR) +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 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/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/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props index 2f27340e9..e8804a8bf 100644 --- a/src/PostIt/Directory.Packages.props +++ b/src/PostIt/Directory.Packages.props @@ -1,35 +1,31 @@ - + - - true - 12.1.1 - - - - - - - - - - - - - - - - - - - - - - - - - - - + + true + 12.1.1 + + + + + + + + + + + + + + + + + + + + + + + + \ 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..a6d04cb33 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-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf + 1.1.0-feat-sign-estimate.1 diff --git a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj index 4534a2940..ff2f3f2b8 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-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf + 1.1.0-feat-sign-estimate.1 diff --git a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj index 948e726c5..36ed1251e 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-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf + 1.1.0-feat-sign-estimate.1 app.manifest 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..db70e25fa --- /dev/null +++ b/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs @@ -0,0 +1,270 @@ +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/"); + return new EstimateEditionPageViewModel(query ?? SampleQuery(), client); + } + + [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); + + Assert.Equal("https://business.example/api/v1/estimate", api.LastPath); + Assert.Equal(HttpMethod.Post, api.LastMethod); + + var payload = Assert.IsType(api.LastBody); + 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 payload = Assert.IsType(api.LastBody); + 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; } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + LastMethod = method; + LastPath = path; + LastBody = 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) + { + 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; + } +} diff --git a/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs b/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs new file mode 100644 index 000000000..6158b4132 --- /dev/null +++ b/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs @@ -0,0 +1,134 @@ +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 vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client); + + await vm.InitializeAsync(); + + Assert.Contains("https://business.example/api/v1/estimate/asclient", api.Paths); + Assert.Equal("Mes devis à valider", 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 vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider); + + await vm.InitializeAsync(); + + Assert.Contains("https://business.example/api/v1/estimate/asprovider", api.Paths); + Assert.Equal("Mes devis en attente", 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 vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider); + + 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 vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client); + + 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/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