diff --git a/.forgejo/workflows/buildAndTest.yml b/.forgejo/workflows/buildAndTest.yml index ea58d2fef..64e522bab 100644 --- a/.forgejo/workflows/buildAndTest.yml +++ b/.forgejo/workflows/buildAndTest.yml @@ -31,6 +31,7 @@ jobs: steps: - name: Clone yavsc run: | + set -e cd /src git clone https://forgejo.pschneider.fr/notazof/yavsc.git _src cd _src @@ -40,12 +41,14 @@ 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 -n1 \ - 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 ($?)!" + --logger "xunit;LogFileName=test-results.xml" diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index a72f92bd6..911fc831d 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -175,6 +175,12 @@ jobs: run: | cd /src/_src dotnet restore + - name: Test + run: | + cd /src/_src && dotnet test \ + --verbosity normal \ + --filter="Category!=Platform-Android" \ + --logger "xunit;LogFileName=test-results.xml" - name: Build de PostIt.Android ARM64 run: | @@ -200,6 +206,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/.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/tasks.json b/.vscode/tasks.json index a30c9c065..f5bcc21a2 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", 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/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/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/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/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 - public MainViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null) + public BlogsViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null) { SettingsModel = new Settings(); BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ; @@ -427,6 +464,7 @@ public partial class MainViewModel : ViewModelBase, IActionStatusViewModel // Mirror publication state too. Defaults to false on // null selection so a fresh draft starts unpublished. DraftIsPublished = value?.IsPublished ?? false; + DraftAttachments.Clear(); UpdateCommandStates(); } @@ -508,4 +546,55 @@ public partial class MainViewModel : ViewModelBase, IActionStatusViewModel IsLoaded = true; } } + + private bool TryAppendAttachmentLinks(BlogPostDto post, IReadOnlyCollection attachments) + { + if (attachments.Count == 0) + return false; + + var ownerSegment = post.Author?.UserName; + if (string.IsNullOrWhiteSpace(ownerSegment)) + ownerSegment = post.AuthorId; + + if (string.IsNullOrWhiteSpace(ownerSegment)) + return false; + + var article = DraftArticle ?? string.Empty; + var links = new List(); + + foreach (var attachment in attachments) + { + var relativePath = $"{EscapePathSegment(ownerSegment)}/blogs/{post.Id}/{EscapePathSegment(attachment.FileName)}"; + var fileUrl = ResolveUserFileUrl(relativePath); + var markdownLine = $"- [{attachment.FileName}]({fileUrl})"; + + if (!article.Contains(markdownLine, StringComparison.Ordinal)) + links.Add(markdownLine); + } + + if (links.Count == 0) + return false; + + var prefix = article.Length == 0 + ? "" + : (article.EndsWith("\n", StringComparison.Ordinal) ? "\n" : "\n\n"); + + DraftArticle = article + prefix + string.Join("\n", links); + return true; + } + + private string ResolveUserFileUrl(string relativePath) + { + var authority = Settings?.Authentication?.Authority; + if (!string.IsNullOrWhiteSpace(authority) + && Uri.TryCreate(authority, UriKind.Absolute, out var baseUri)) + { + return FileServerUrlHelpers.GetUserFilesUri(baseUri, relativePath).ToString(); + } + + return $"{Yavsc.Constants.UserFilesPath}/{relativePath}"; + } + + private static string EscapePathSegment(string segment) + => Uri.EscapeDataString(segment); } diff --git a/src/PostIt/PostIt/ViewModels/Commands/RdvViewModel.cs b/src/PostIt/PostIt/ViewModels/Commands/RdvViewModel.cs index 7a7d17df6..4f9d457a5 100644 --- a/src/PostIt/PostIt/ViewModels/Commands/RdvViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Commands/RdvViewModel.cs @@ -12,6 +12,9 @@ namespace PostIt.ViewModels.Commands; public partial class RdvViewModel : BillingCommandPageViewModel { + private long? _existingLocationId; + private bool _hydratingExistingQuery; + public override string SupportMessage => "ComplĂ©tez les informations du rendez-vous puis postez la commande."; [ObservableProperty] @@ -56,6 +59,7 @@ public partial class RdvViewModel : BillingCommandPageViewModel protected override void ApplyExistingQuery(BillingQueryDetailsDto existingQuery) { + _hydratingExistingQuery = true; ExistingQueryId = existingQuery.Id; CommandStatus = existingQuery.Status; Consent = existingQuery.Consent; @@ -70,11 +74,18 @@ public partial class RdvViewModel : BillingCommandPageViewModel if (existingQuery.Location is not null) { + _existingLocationId = existingQuery.Location.Id; Address = existingQuery.Location.Address ?? string.Empty; SuggestedAddress = string.Empty; Latitude = existingQuery.Location.Latitude; Longitude = existingQuery.Location.Longitude; } + else + { + _existingLocationId = null; + } + + _hydratingExistingQuery = false; this.SetInfoStatus($"Commande #{existingQuery.Id} chargĂ©e."); } @@ -117,20 +128,22 @@ public partial class RdvViewModel : BillingCommandPageViewModel } } - protected static object BuildLocationPayload(string address, double? latitude, double? longitude) + protected static BillingLocationDto BuildLocationPayload(string address, double? latitude, double? longitude, long? locationId = null) { if (latitude.HasValue && longitude.HasValue) { - return new + return new BillingLocationDto { + Id = locationId, Address = address, Latitude = latitude.Value, Longitude = longitude.Value, }; } - return new + return new BillingLocationDto { + Id = locationId, Address = address, }; } @@ -223,6 +236,30 @@ public partial class RdvViewModel : BillingCommandPageViewModel OnPropertyChanged(nameof(EventDateSelection)); } + partial void OnAddressChanged(string value) + { + if (_hydratingExistingQuery) + return; + + _existingLocationId = null; + } + + partial void OnLatitudeChanged(double? value) + { + if (_hydratingExistingQuery) + return; + + _existingLocationId = null; + } + + partial void OnLongitudeChanged(double? value) + { + if (_hydratingExistingQuery) + return; + + _existingLocationId = null; + } + protected override async Task SubmitAsync() { @@ -257,7 +294,7 @@ public partial class RdvViewModel : BillingCommandPageViewModel try { var address = Address.Trim(); - var locationPayload = BuildLocationPayload(address, Latitude, Longitude); + var locationPayload = BuildLocationPayload(address, Latitude, Longitude, IsEditingExisting ? _existingLocationId : null); var payload = new BillingQueryDetailsDto { @@ -270,12 +307,7 @@ public partial class RdvViewModel : BillingCommandPageViewModel Status = CommandStatus, Reason = Reason.Trim(), AdditionalInfo = string.IsNullOrWhiteSpace(AdditionalInfo) ? string.Empty : AdditionalInfo.Trim(), - Location = new BillingLocationDto - { - Address = address, - Latitude = Latitude, - Longitude = Longitude, - } + Location = locationPayload }; if (IsEditingExisting) diff --git a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs index 830be4dde..fd2ff1091 100644 --- a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs @@ -5,6 +5,7 @@ using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.DependencyInjection; using PostIt.Helpers; using PostIt.Services; +using Yavsc.Api.Client; namespace PostIt.ViewModels; public class HomePageViewModel : ViewModelBase @@ -30,10 +31,12 @@ public class HomePageViewModel : ViewModelBase SessionStatus = sessionStatus; OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync); - + OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync); + OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync); } - public IAsyncRelayCommand OpenBlogs { get; } = new AsyncRelayCommand(App.PushBlogsPageAsync); + public IAsyncRelayCommand OpenBlogs { get; } public IAsyncRelayCommand OpenActivities { get; } + public IAsyncRelayCommand OpenProviderRequests { get; } private async Task OpenActivitiesAsync() { @@ -48,6 +51,27 @@ public class HomePageViewModel : ViewModelBase await app.PushPageAsync(vm); } + private async Task OpenProviderRequestsAsync() + { + var app = (App?)Application.Current; + if (app is null) + { + throw new InvalidOperationException("Application PostIt indisponible."); + } + + var billingClient = app.ServiceProvider?.GetRequiredService(); + if (billingClient is null) + { + throw new InvalidOperationException("Client billing indisponible."); + } + + var estimateClient = app.ServiceProvider?.GetRequiredService(); + + var vm = new ProviderOngoingRequestsPageViewModel(billingClient, Settings, estimateClient); + await vm.InitializeAsync(); + await app.PushPageAsync(vm); + } + /// /// Avalonia designer constructor. Builds a self-contained VM /// with a freshly-constructed Settings so the XAML preview can diff --git a/src/PostIt/PostIt/ViewModels/Layout/StatusNotice.cs b/src/PostIt/PostIt/ViewModels/Layout/StatusNotice.cs index 972c64350..c87af390c 100644 --- a/src/PostIt/PostIt/ViewModels/Layout/StatusNotice.cs +++ b/src/PostIt/PostIt/ViewModels/Layout/StatusNotice.cs @@ -23,8 +23,8 @@ public sealed class StatusNotice (Glyph, Background, BorderBrush, Foreground) = severity switch { - StatusSeverity.Error => ("!", "#FDECEA", "#C62828", "#7F1D1D"), - StatusSeverity.Warning => ("~", "#FFF8E1", "#E6A700", "#7C4A03"), + StatusSeverity.Error => ("!", "#7F1D1D", "#C62828", "#e1f0f6"), + StatusSeverity.Warning => ("~", "#7C4A03", "#E6A700", "#eaeaea"), _ => ("i", "#E8F0FE", "#5B8DEF", "#1E3A8A"), }; } diff --git a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs index 0864c1416..6b6ed5d4f 100644 --- a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs +++ b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs @@ -15,7 +15,7 @@ namespace PostIt.ViewModels; public partial class Settings : ViewModelBase { - const string SettingsFileName = "postit-settings.json"; + public string SettingsFileName {get; private set;} = "postit-settings.json"; [ObservableProperty] public partial AuthenticationSettings Authentication { get; set; } = new(); @@ -32,6 +32,9 @@ public partial class Settings : ViewModelBase [ObservableProperty] public partial string SearchText { get; set; } = string.Empty; + [ObservableProperty] + public partial string ProviderOngoingRequestsSortOption { get; set; } = string.Empty; + [ObservableProperty] [JsonIgnore] public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret."); @@ -62,6 +65,7 @@ public partial class Settings : ViewModelBase partial void OnBlogsApiUrlChanged(string value) => MarkDirty(); partial void OnApiUrlChanged(string value) => MarkDirty(); partial void OnSearchTextChanged(string value) => MarkDirty(); + partial void OnProviderOngoingRequestsSortOptionChanged(string value) => MarkDirty(); /// /// Authentication can be reassigned wholesale by @@ -247,12 +251,18 @@ public partial class Settings : ViewModelBase return; } } - + if (Environment.GetEnvironmentVariable("POSTIT_SETTINGS_JSON") is string envJson + && !string.IsNullOrWhiteSpace(envJson)) + { + Console.WriteLine("🔎 Loading settings from POSTIT_SETTINGS_JSON environment variable."); + ApplyJson(envJson, "POSTIT_SETTINGS_JSON"); + Loaded = true; + return; + } string configDir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "PostIt" -); - Directory.CreateDirectory(configDir); + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "PostIt" + ); string configPath = Path.Combine(configDir, SettingsFileName); @@ -336,7 +346,7 @@ public partial class Settings : ViewModelBase // → our overridden dispatcher-safe marshaller below. else lock (_mutationGate) { - var legacyApiUrl = TryReadLegacyApiUrl(json); + var legacyApiUrl = TryReadApiUrl(json); this.Authentication = settings.Authentication; this.DarkMode = settings.DarkMode; this.BlogsApiUrl = !string.IsNullOrWhiteSpace(settings.BlogsApiUrl) @@ -346,6 +356,7 @@ public partial class Settings : ViewModelBase ? settings.ApiUrl : this.ApiUrl; this.SearchText = settings.SearchText ?? string.Empty; + this.ProviderOngoingRequestsSortOption = settings.ProviderOngoingRequestsSortOption ?? string.Empty; if (!(settings.Authentication is null)) { this.Authentication = new AuthenticationSettings(); @@ -391,7 +402,7 @@ public partial class Settings : ViewModelBase } } - private static string? TryReadLegacyApiUrl(string json) + private static string? TryReadApiUrl(string json) { try { @@ -424,6 +435,7 @@ public partial class Settings : ViewModelBase this.BlogsApiUrl = "https://blogs.pschneider.fr/api/v1/"; this.ApiUrl = "https://api.pschneider.fr/api/v1/"; this.SearchText = string.Empty; + this.ProviderOngoingRequestsSortOption = string.Empty; } /// @@ -466,7 +478,7 @@ public partial class Settings : ViewModelBase UnixFileMode.UserRead | UnixFileMode.UserWrite); IsDirty = false; SetActionStatus("Parametres sauvegardes.", StatusSeverity.Info); - + Console.WriteLine($"đŸ’Ÿ Settings saved to {configPath}"); } catch (Exception ex) diff --git a/src/PostIt/PostIt/Views/Activity/CommandFormsPage.axaml b/src/PostIt/PostIt/Views/Activity/CommandFormsPage.axaml index f44c098bd..2565092e5 100644 --- a/src/PostIt/PostIt/Views/Activity/CommandFormsPage.axaml +++ b/src/PostIt/PostIt/Views/Activity/CommandFormsPage.axaml @@ -34,13 +34,13 @@