diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..52d500e18 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,3 @@ +# Conventions du projet + +- Par defaut, mettre a jour [CHANGELOG.md](../CHANGELOG.md) a chaque commit, en respectant sa structure existante et en documentant les changements du commit. Si aucune entree n'est pertinente, expliquer explicitement cette exception avant de committer. diff --git a/.github/workflows/docker-publish-backend.yml b/.github/workflows/docker-publish-backend.yml index d8466bdb8..2d84b767f 100644 --- a/.github/workflows/docker-publish-backend.yml +++ b/.github/workflows/docker-publish-backend.yml @@ -8,8 +8,7 @@ on: jobs: production-deploy: - runs-on: ubuntu-latest - + runs-on: ubuntu-26.04 steps: # 1. Récupération du code - name: Check out the repo @@ -25,8 +24,24 @@ jobs: with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Set up .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 10.0.x + - name: Workloads restore + run: dotnet workload restore - name: Test - run: dotnet test --no-build --verbosity normal --filter="Category!=Platform-Android" + env: + TEST_FAILED: "" + run: make test || export TEST_FAILED=$? + - uses: actions/upload-artifact@v7 + with: + name: my-artifact + path: test-reports/**/* + - name: Fail if tests failed + if: ${{ env.TEST_FAILED != '' && env.TEST_FAILED != '0' }} + run: exit $TEST_FAILED + # 4. Build et Push de l'image de production finale - name: Build and push production image uses: docker/build-push-action@v7 diff --git a/CHANGELOG.md b/CHANGELOG.md index e1a3be3a2..3320ee74f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,53 @@ # Changelog +## [1.0.8-rc25] - unstable +### Added + +* [Yavsc.Api] Notification du performer à la création d'un rendez-vous + depuis PostIt, selon ses préférences de contact, sans supprimer l'envoi Org. +* [Yavsc.Api][Yavsc.Server] Notification du performer à la signature + client d'un devis (JSON ou image), enregistrée pour l'accueil PostIt + et transmise par e-mail et SignalR. +* [Yavsc.Api] Nouveau point d'entrée `GET /api/v1/notifications/me` + pour exposer les notifications publiques et ciblées à PostIt sur la + Home page ; les notifications d'administration restent filtrées. +* [PostIt] Affichage des notifications sur la page d'accueil, avec + actualisation, indicateur de chargement et message d'erreur. Les + notifications publiques sont accessibles sans connexion et la liste + est rechargée lors d'un changement de session. + +### Changed + +* [PostIt] Insertion des liens de fichiers dès leur ajout au brouillon ; + les liens provisoires des nouveaux billets sont résolus au premier + enregistrement. Save conserve l'édition ouverte et est mis en valeur + lorsque le titre, le corps ou les pièces jointes sont modifiés. + +* [Projet] Instructions Copilot persistantes : mise à jour du changelog + par défaut à chaque commit, avec justification explicite des exceptions. + +### Fixed + +* [PostIt.Tests] Les tests du bandeau de session utilisent le bandeau + réellement monté dans la fenêtre headless, au lieu d'un contrôle détaché. + Les fenêtres sont fermées après chaque test et les quatre boutons + de navigation sont vérifiés. + +* [PostIt] Les liens Markdown des pièces jointes utilisent désormais + le même filtre de nom que l'upload serveur, avant l'encodage URL. + Le libellé conserve le nom original, notamment ses accents. + +* [Yavsc.Abstract][Yavsc.Blogs] Encodage des caractères non supportés + dans les noms de fichiers téléversés avec `_` au lieu de `#`, pour + éviter les fragments d'URL et permettre le téléchargement via les + fichiers statiques, notamment pour les noms accentués. + +* [Yavsc.Api][Yavsc.Blogs] Les appels API et la bande passante sont + désormais enregistrés côté serveur pour les requêtes authentifiées : + le middleware de télémétrie est maintenant branché après + l'authentification, ce qui permet de comptabiliser `ApiCalls` et + `BandwidthMb` au lieu d'enregistrer des zéros. ## [1.0.8-rc24] - unstable diff --git a/Dockerfile.backend b/Dockerfile.backend index a4e9a54a9..46c9a982e 100644 --- a/Dockerfile.backend +++ b/Dockerfile.backend @@ -1,36 +1,12 @@ # Utilisation de l'image globale pour le build et l'exécution -FROM pazof/yavsc-build-env:debian12-dotnet10-android36-v1 AS build-env +FROM pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4 AS build-env WORKDIR /src - # 1. Copie de la solution -COPY *.sln ./ -COPY Directory.*.props ./ - -# 2. Copie de l'intégralité des fichiers de projets (.csproj) pour la restauration -COPY src/Yavsc.Org/*.csproj ./src/Yavsc.Org/ -COPY src/Yavsc.Abstract/*.csproj ./src/Yavsc.Abstract/ -COPY src/Yavsc.Server/*.csproj ./src/Yavsc.Server/ -COPY src/Yavsc.Api/*.csproj ./src/Yavsc.Api/ -COPY src/Yavsc.Blogs/*.csproj ./src/Yavsc.Blogs/ -COPY src/cli/*.csproj ./src/cli/ -COPY src/Yavsc.Org.Tests/*.csproj ./src/Yavsc.Org.Tests/ - -# Projets associés à PostIt -COPY src/PostIt/PostIt/*.csproj ./src/PostIt/PostIt/ -COPY src/PostIt/PostIt.Android/*.csproj ./src/PostIt/PostIt.Android/ -COPY src/PostIt/PostIt.Browser/*.csproj ./src/PostIt/PostIt.Browser/ -COPY src/PostIt/PostIt.Desktop/*.csproj ./src/PostIt/PostIt.Desktop/ - -# 4. Copie de l'intégralité du code source COPY . . - +RUN dotnet workload restore # 4. Restauration des dépendances pour tous les projets RUN dotnet restore - -# 5. Copie de la totalité du code source (filtrée par votre .dockerignore) -COPY . . - # 6. Compilation du serveur Web principal RUN dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly diff --git a/Makefile b/Makefile index 1893a910e..04efba0be 100644 --- a/Makefile +++ b/Makefile @@ -5,8 +5,6 @@ DESTDIR=/tmp/yavsc APP_PATH=srv/www/yavsc APP_FULL_PATH=$(DESTDIR)/$(APP_PATH) -include .env - all: dotnet build --nologo diff --git a/contrib/template.appsettings-blogs.json b/contrib/template.appsettings-blogs.json index f522feaca..6f8fa02bc 100644 --- a/contrib/template.appsettings-blogs.json +++ b/contrib/template.appsettings-blogs.json @@ -8,7 +8,7 @@ "CorsAllowedOrigins": [ "https://preprod-api.$DOMAIN" ], - "Blog": "blog" + "UserFilesRoot": "files" }, "ConnectionStrings": { "YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;" diff --git a/contrib/template.appsettings-org.json b/contrib/template.appsettings-org.json index 76203d9e9..c330f5f32 100644 --- a/contrib/template.appsettings-org.json +++ b/contrib/template.appsettings-org.json @@ -30,7 +30,7 @@ "EMail": "paul@pschneider.fr" }, "Quota": 200000000, - "Blog": "blog", + "UserFilesRoot": "files", "Avatars": "avatars", "Bills": "bills", "TempDir": "temp", diff --git a/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs b/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs index 112c11752..741b3863c 100644 --- a/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs +++ b/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs @@ -73,6 +73,14 @@ internal sealed class RecordingYavscApiClient : YavscApiClient public override Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) { _recorder.Calls.Add((method, path, contentFactory)); + if (typeof(T) == typeof(BlogPostDto)) + return Task.FromResult((T)(object)new BlogPostDto + { + Id = 42, + Title = "Mon premier billet", + AuthorId = "tester", + Article = "Contenu du billet de test.", + }); return Task.FromResult(default(T)!); } } diff --git a/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs b/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs index 946d3d28e..c9d7d3aae 100644 --- a/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs +++ b/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs @@ -20,8 +20,13 @@ namespace PostIt.Tests; /// public class BlogAttachmentLinkTests { - [Fact] - public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority() + [Theory] + [InlineData("note.txt", "note.txt")] + [InlineData("INSC_E03686381_SCHNEIDER_000113378AA__Troisi\u00e8me__.PDF", + "INSC_E03686381_SCHNEIDER_000113378AA__Troisi_232me__.PDF")] + [InlineData("rapport final.pdf", "rapport%20final.pdf")] + public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority( + string originalName, string urlName) { const string authority = "https://idp.example.test"; @@ -53,27 +58,59 @@ public class BlogAttachmentLinkTests vm.DraftTitle = "Après"; vm.DraftArticle = "Contenu modifié."; vm.DraftAttachments.Add(new BlogUploadFile( - "note.txt", + originalName, System.Text.Encoding.UTF8.GetBytes("payload test"), "text/plain")); + var expectedUrl = $"{authority}/files/tester/blogs/42/{urlName}"; + Assert.Contains($"- [{originalName}]({expectedUrl})", vm.DraftArticle); + Assert.Empty(recorder.Calls); + var draftBeforeSave = vm.DraftArticle; + 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); + var linkPut = Assert.Single(recorder.Calls, 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 sentStringContent = Assert.IsType(multipartContent.First()); var sentString = await sentStringContent.ReadAsStringAsync(TestContext.Current.CancellationToken); - Assert.Contains($"- [note.txt]({expectedUrl})", sentString); + using var document = System.Text.Json.JsonDocument.Parse(sentString); + var article = document.RootElement.GetProperty("article").GetString(); + Assert.Contains($"- [{originalName}]({expectedUrl})", article); // The regression shape: a relative, authority-less link. - Assert.DoesNotContain("](/files/", sentString); + Assert.DoesNotContain("](/files/", article); + Assert.Equal(draftBeforeSave, vm.DraftArticle); + Assert.NotNull(vm.SelectedPost); + Assert.False(vm.IsDraftModified); + } + + [Fact] + public async Task New_post_resolves_only_the_provisional_link_at_first_save() + { + var recorder = new CallRecorder(); + var api = new RecordingYavscApiClient(recorder); + var vm = new BlogsViewModel(new BlogApiClient(api, "https://blogs.example.test/api/v1/"), + new Settings { Authentication = new AuthenticationSettings { Authority = "https://idp.example.test" } }); + vm.DraftTitle = "Nouveau billet"; + vm.DraftArticle = "Texte conservé."; + vm.DraftAttachments.Add(new BlogUploadFile("Troisi\u00e8me.PDF", new byte[] { 1 })); + + Assert.Contains("postit-attachment:", vm.DraftArticle); + Assert.Empty(recorder.Calls); + await vm.SaveAsync(); + + Assert.Equal("Nouveau billet", vm.DraftTitle); + Assert.Contains("Texte conservé.", vm.DraftArticle); + Assert.Contains("- [Troisi\u00e8me.PDF](https://idp.example.test/files/tester/blogs/42/Troisi_232me.PDF)", + vm.DraftArticle); + Assert.DoesNotContain("postit-attachment:", vm.DraftArticle); + Assert.Equal(42, vm.SelectedPost?.Id); + Assert.Empty(vm.DraftAttachments); + Assert.False(vm.IsDraftModified); + Assert.Single(recorder.Calls, c => c.method == HttpMethod.Post); + Assert.Single(recorder.Calls, c => c.method == HttpMethod.Put); } } diff --git a/src/PostIt/PostIt.Tests/HomePageNotificationsTests.cs b/src/PostIt/PostIt.Tests/HomePageNotificationsTests.cs new file mode 100644 index 000000000..667ea599a --- /dev/null +++ b/src/PostIt/PostIt.Tests/HomePageNotificationsTests.cs @@ -0,0 +1,199 @@ +using System.Net.Http; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.VisualTree; +using PostIt.Services; +using PostIt.ViewModels; +using PostIt.Views; +using Yavsc.Abstract.Models.Messaging; +using Yavsc.Api.Client; + +namespace PostIt.Tests; + +public class HomePageNotificationsTests +{ + [Fact] + public async Task Refresh_loads_public_notifications_from_the_business_host() + { + var transport = new NotificationTransport(); + var vm = CreateViewModel(transport); + transport.Response = Task.FromResult(new List { Note(1, "Information") }); + + await vm.RefreshNotificationsAsync(); + + Assert.Equal("https://business.example/api/v1/notifications", transport.LastPath); + Assert.True(transport.WasAnonymous); + Assert.Equal("Information", Assert.Single(vm.Notifications).title); + Assert.False(vm.IsLoadingNotifications); + Assert.False(vm.HasNoNotifications); + Assert.Null(vm.NotificationsError); + } + + [Fact] + public async Task Refresh_replaces_notifications_and_distinguishes_empty_from_failed() + { + var transport = new NotificationTransport(); + var vm = CreateViewModel(transport); + vm.Notifications.Add(Note(1, "Old")); + await vm.RefreshNotificationsAsync(); + + Assert.Empty(vm.Notifications); + Assert.True(vm.HasNoNotifications); + + transport.Response = Task.FromException>(new HttpRequestException("Server unavailable")); + await vm.RefreshNotificationsAsync(); + + Assert.Empty(vm.Notifications); + Assert.False(vm.HasNoNotifications); + Assert.Contains("Server unavailable", vm.NotificationsError); + Assert.False(vm.IsLoadingNotifications); + + transport.Response = Task.FromResult(new List { Note(2, "Recovered") }); + await vm.RefreshNotificationsAsync(); + Assert.Null(vm.NotificationsError); + Assert.Equal(2, Assert.Single(vm.Notifications).Id); + } + + [Fact] + public async Task Refresh_ignores_an_older_response_after_a_newer_refresh() + { + var transport = new NotificationTransport(); + var vm = CreateViewModel(transport); + var oldResponse = new TaskCompletionSource>(); + transport.Response = oldResponse.Task; + var oldLoad = vm.RefreshNotificationsAsync(); + Assert.True(vm.IsLoadingNotifications); + + transport.Response = Task.FromResult(new List { Note(2, "Current") }); + await vm.RefreshNotificationsAsync(); + oldResponse.SetResult(new List { Note(1, "Stale") }); + await oldLoad; + + Assert.Equal(2, Assert.Single(vm.Notifications).Id); + Assert.False(vm.IsLoadingNotifications); + } + + [AvaloniaFact] + public async Task Login_and_logout_reload_notifications_and_discard_in_flight_private_data() + { + var tokenPath = Path.Combine(Path.GetTempPath(), $"postit-notifications-{Guid.NewGuid():N}.json"); + var settings = new Settings(); + var store = new TokenStore(tokenPath); + store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token", + DateTimeOffset.UtcNow.AddHours(1), null)); + await using var api = new YavscApiClient(settings, store); + try + { + var session = new SessionStatusViewModel { Api = api }; + var transport = new NotificationTransport(); + var vm = CreateViewModel(transport, session); + session.Refresh(); + Assert.False(transport.WasAnonymous); + Assert.EndsWith("/notifications/me", transport.LastPath); + var privateResponse = new TaskCompletionSource>(); + transport.Response = privateResponse.Task; + var privateLoad = vm.RefreshNotificationsAsync(); + Assert.False(transport.WasAnonymous); + Assert.EndsWith("/notifications/me", transport.LastPath); + + vm.Notifications.Add(Note(9, "Private")); + transport.Response = Task.FromResult(new List { Note(2, "Public") }); + session.SetError("Disconnected"); + Assert.Equal(2, Assert.Single(vm.Notifications).Id); + Assert.True(transport.WasAnonymous); + + privateResponse.SetResult(new List { Note(1, "Late private data") }); + await privateLoad; + Assert.Equal(2, Assert.Single(vm.Notifications).Id); + } + finally + { + File.Delete(tokenPath); + } + } + + [AvaloniaFact] + public async Task Home_page_loads_on_navigation_and_renders_wrapped_titles_and_bodies() + { + var transport = new NotificationTransport + { + Response = Task.FromResult(new List { Note(1, "Notification title") }) + }; + var vm = CreateViewModel(transport); + var page = new HomePage { DataContext = vm }; + var nav = new NavigationPage(); + var window = new Window { Content = nav, Width = 320, Height = 450 }; + try + { + window.Show(); + await nav.PushAsync(page); + window.UpdateLayout(); + + Assert.Single(vm.Notifications); + var texts = page.GetVisualDescendants().OfType().ToArray(); + Assert.Contains(texts, t => t.Text == "Notification title" + && t.TextWrapping == Avalonia.Media.TextWrapping.Wrap); + Assert.Contains(texts, t => t.Text == "Notification body" + && t.TextWrapping == Avalonia.Media.TextWrapping.Wrap); + var refresh = Assert.Single(page.GetVisualDescendants().OfType