diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 52d500e18..000000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,3 +0,0 @@ -# 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 2d84b767f..d8466bdb8 100644 --- a/.github/workflows/docker-publish-backend.yml +++ b/.github/workflows/docker-publish-backend.yml @@ -8,7 +8,8 @@ on: jobs: production-deploy: - runs-on: ubuntu-26.04 + runs-on: ubuntu-latest + steps: # 1. Récupération du code - name: Check out the repo @@ -24,24 +25,8 @@ 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 - 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 - + run: dotnet test --no-build --verbosity normal --filter="Category!=Platform-Android" # 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 68f0bc6b9..16ae2b264 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,100 +1,10 @@ # Changelog -## [1.0.8-rc26] - unstable - -### Added - -* [PostIt] Vue de lecture des billets par défaut, avec titre, auteur, - date et contenu Markdown défilant ; l'édition et la création restent - accessibles par des actions explicites, sans perdre le brouillon en - passant de la lecture à l'édition. -* [PostIt] Résolution des liens et images relatifs depuis - `/files//`, reconnaissance des fichiers - utilisateur sur cette origine et détection du type MIME par HTTP. -* [PostIt] Page vidéo dédiée utilisant LibVLCSharp.Avalonia sur desktop, - avec lecture/pause, libération du lecteur à la fermeture et accès au - navigateur en cas d'indisponibilité de LibVLC. Les autres fichiers et - les vidéos sur mobile/navigateur s'ouvrent dans le navigateur système. - -### Changed - -* L'administration est maintenant restreinte aux emails configurés dans les paramétres applicatifs de Yavsc.Org (`appsettings-org.json`) - -## [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 - -### Added - -* [PostIt] Reconnection automatique aux salons de discussion au reconnect - -### Changed - -* [PostIt] La liste des billets et l'éditeur Markdown se partagent - l'espace disponible et défilent séparément ; un séparateur permet - de redimensionner les deux zones. - -### Fixed - -* [PostIt] Défilement des articles longs en édition et en aperçu : - suppression de la limite de hauteur qui masquait le contenu Markdown - et du défilement imbriqué autour d'AvaloniaEdit. Six tests headless - couvrent les barres automatiques, le redimensionnement et le - défilement indépendant de la liste. ## [1.0.8-rc23] - unstable ### Added -* [PostIt] Badge « Publié » à côté du titre des billets publiés dans la - liste, mis à jour lors de la publication ou du retour en brouillon. * [PostIt] Gestion native du compte inspirée de `/Manage` : modification du nom d'utilisateur, du nom complet et de l'adresse, téléversement d'avatar, confirmation e-mail, préférence de courriel mensuel, diff --git a/Dockerfile.backend b/Dockerfile.backend index 46c9a982e..a4e9a54a9 100644 --- a/Dockerfile.backend +++ b/Dockerfile.backend @@ -1,12 +1,36 @@ # Utilisation de l'image globale pour le build et l'exécution -FROM pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4 AS build-env +FROM pazof/yavsc-build-env:debian12-dotnet10-android36-v1 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 04efba0be..1893a910e 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,8 @@ DESTDIR=/tmp/yavsc APP_PATH=srv/www/yavsc APP_FULL_PATH=$(DESTDIR)/$(APP_PATH) +include .env + all: dotnet build --nologo diff --git a/README.md b/README.md index 79e711258..1cee99bbb 100644 --- a/README.md +++ b/README.md @@ -232,7 +232,7 @@ L'impact de l'usage d'un nouveau nom d’environnement d'execution, à l'heure d * ~/Views/Home/Index.cshtml * ~/Views/Home/About.cshtml -# 👥 Crédits & Co-auteurs +# 👥 Crédits & Co-autoraClaudt Ce projet a été développé de manière hybride : - **Conception, architecture et revue de code :** Paul Schneider diff --git a/contrib/blog-edit.png b/contrib/blog-edit.png deleted file mode 100644 index 4175f5153..000000000 Binary files a/contrib/blog-edit.png and /dev/null differ diff --git a/contrib/blog-view.png b/contrib/blog-view.png deleted file mode 100644 index 04625dbc9..000000000 Binary files a/contrib/blog-view.png and /dev/null differ diff --git a/contrib/template.appsettings-blogs.json b/contrib/template.appsettings-blogs.json index 6f8fa02bc..f522feaca 100644 --- a/contrib/template.appsettings-blogs.json +++ b/contrib/template.appsettings-blogs.json @@ -8,7 +8,7 @@ "CorsAllowedOrigins": [ "https://preprod-api.$DOMAIN" ], - "UserFilesRoot": "files" + "Blog": "blog" }, "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 c330f5f32..76203d9e9 100644 --- a/contrib/template.appsettings-org.json +++ b/contrib/template.appsettings-org.json @@ -30,7 +30,7 @@ "EMail": "paul@pschneider.fr" }, "Quota": 200000000, - "UserFilesRoot": "files", + "Blog": "blog", "Avatars": "avatars", "Bills": "bills", "TempDir": "temp", diff --git a/doc/architecture/postit.md b/doc/architecture/postit.md index 439bb8f70..70f3f2fdc 100644 --- a/doc/architecture/postit.md +++ b/doc/architecture/postit.md @@ -40,51 +40,6 @@ spécifiques (IntentFilter Android, `app.manifest` Desktop). Toute la logique (VM, services, navigation, settings, OIDC) est dans le code-base partagé. -## Lecture des billets et fichiers utilisateur - -La page des blogs affiche par défaut une vue de lecture : titre, login -de l'auteur, date de modification et contenu Markdown dans une zone -défilante de largeur limitée. **Modifier** ouvre l'éditeur existant et -**Nouveau billet** initialise explicitement un brouillon. **Lire** affiche -le contenu enregistré sans effacer les modifications non enregistrées ; -un nouveau billet ne peut pas remplacer un brouillon modifié non sauvé. - -La base des ressources est dérivée de `Settings.Authentication.Authority` -(le déploiement Yavsc.Org), jamais de `BlogsApiUrl` : -`https:///files//`. Le repli sur `AuthorId` -conserve la compatibilité avec les anciens DTO dépourvus d'auteur détaillé. -Un lien `photo.jpg` ou `blogs/42/video.mp4` est relatif à ce répertoire ; -un lien `/files/paul/photo.jpg` conserve son chemin depuis la racine Org. -Les liens absolus ne sont pas réécrits. Les images Markdown `![texte](url)` -sont affichées dans le billet et leur résolveur n'accepte que HTTP(S), -sans accès aux fichiers locaux du poste. -Le résolveur charge chaque image dans un flux mémoire repositionnable -initialement à la position zéro, nécessaire au décodage du lecteur Markdown. - -Le lecteur utilise `Markdown.Avalonia.Tight` 12.0.0-a3 (version compatible -Avalonia 12) pour déléguer les hyperliens à une commande PostIt. L'éditeur -conserve `MarkdownViewer.Core` : son gestionnaire intégré ouvre le navigateur -avant d'émettre l'événement de lien et ne permet donc pas ce routage. - -Au clic sur un fichier sous `/files//...` de la même origine Org -(schéma, hôte et port), `BlogResourceService` sonde `Content-Type` avec -`HEAD`, puis un `GET` limité par `Range: bytes=0-0` si le serveur refuse -`HEAD` (405/501). Le corps n'est pas téléchargé pour la détection. Une -extension connue sert de repli uniquement en l'absence de type exploitable -(type absent ou `application/octet-stream`). Les erreurs HTTP/réseau sont -affichées dans la barre de statut, sans les masquer par une déduction MIME. - -Les types `video/*` ouvrent `VideoPage` via `App.PushPageAsync` sur desktop. -Cette page contient un `LibVLCSharp.Avalonia.VideoView`, lecture/pause et -une action d'ouverture externe. Le lecteur et ses ressources natives sont -arrêtés et libérés à la sortie de la page. **LibVLC doit être installé** -sur le poste (par exemple `libvlc5` et les plugins VLC sur Linux ; les -bibliothèques natives adaptées sont nécessaires sous Windows/macOS). -Une absence de LibVLC ou une erreur de décodage est signalée dans la page. -Sur Android et Browser, où ce contrôle natif desktop n'est pas disponible, -les vidéos sont ouvertes dans le navigateur, comme les autres fichiers -(images liées, PDF, téléchargements) et les liens externes. - ## ViewLocator custo Le `ViewLocator` (cf. `src/PostIt/PostIt/ViewLocator.cs`) est un @@ -171,20 +126,6 @@ le DI est construit. Ordre, dans cet ordre : > à cette table, *ne pas* ajouter de registration pour > `Settings` ailleurs que la ligne `AddSingleton(settings)`. -## Défilement de la page des billets - -La page `BlogsPage` utilise une grille à hauteur contrainte : l'en-tête -et les commandes restent visibles, tandis que la liste des billets et -l'éditeur se partagent l'espace disponible. Un `GridSplitter` permet -de modifier cette répartition. La liste et l'article défilent séparément. - -Dans `MarkdownEditorControl`, AvaloniaEdit gère son propre défilement ; -l'aperçu Markdown est placé dans un `ScrollViewer`. Ne pas limiter -la hauteur du `MarkdownViewer` lui-même : son contenu doit pouvoir -dépasser le viewport pour que les barres automatiques s'activent. -Ne pas placer cette grille dans un `StackPanel` vertical ou un -`ScrollViewer` de page, qui lui donnerait une hauteur de mesure illimitée. - ## Navigation Le host de navigation est un `NavigationPage x:Name="NavRoot"` diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props index f0b523488..a353e4b3a 100644 --- a/src/PostIt/Directory.Packages.props +++ b/src/PostIt/Directory.Packages.props @@ -21,7 +21,6 @@ - diff --git a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj index 1d429de95..46df5025f 100644 --- a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj +++ b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj @@ -24,4 +24,4 @@ - + \ No newline at end of file diff --git a/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs b/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs index 741b3863c..112c11752 100644 --- a/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs +++ b/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs @@ -73,14 +73,6 @@ 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 c9d7d3aae..946d3d28e 100644 --- a/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs +++ b/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs @@ -20,13 +20,8 @@ namespace PostIt.Tests; /// public class BlogAttachmentLinkTests { - [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) + [Fact] + public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority() { const string authority = "https://idp.example.test"; @@ -58,59 +53,27 @@ public class BlogAttachmentLinkTests vm.DraftTitle = "Après"; vm.DraftArticle = "Contenu modifié."; vm.DraftAttachments.Add(new BlogUploadFile( - originalName, + "note.txt", 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(); - var linkPut = Assert.Single(recorder.Calls, c => c.method == HttpMethod.Put); + // 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 sentStringContent = Assert.IsType(multipartContent.First()); + var expectedUrl = $"{authority}/files/tester/blogs/42/note.txt"; + var sentStringContent = multipartContent.First() as StringContent ; var sentString = await sentStringContent.ReadAsStringAsync(TestContext.Current.CancellationToken); - using var document = System.Text.Json.JsonDocument.Parse(sentString); - var article = document.RootElement.GetProperty("article").GetString(); - Assert.Contains($"- [{originalName}]({expectedUrl})", article); + Assert.Contains($"- [note.txt]({expectedUrl})", sentString); // The regression shape: a relative, authority-less link. - 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); + Assert.DoesNotContain("](/files/", sentString); } } diff --git a/src/PostIt/PostIt.Tests/BlogReaderTests.cs b/src/PostIt/PostIt.Tests/BlogReaderTests.cs deleted file mode 100644 index b56e88ccf..000000000 --- a/src/PostIt/PostIt.Tests/BlogReaderTests.cs +++ /dev/null @@ -1,346 +0,0 @@ -using System.Net; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Controls.Templates; -using Avalonia.Headless.XUnit; -using Avalonia.Logging; -using Avalonia.Threading; -using Avalonia.VisualTree; -using Markdown.Avalonia; -using ColorTextBlock.Avalonia; -using PostIt.Models; -using PostIt.Services; -using PostIt.ViewModels; -using PostIt.Views; -using PostIt.Views.Blogs; -using Yavsc.Api.Client; -using Yavsc.Blogspot; -using SkiaSharp; - -namespace PostIt.Tests; - -public class BlogReaderTests -{ - private static BlogPostDto Post(string article = "Texte du billet.") => new() - { - Id = 42, - Title = "Un billet à lire", - Article = article, - AuthorId = "user-id-not-login", - Author = new BlogPostAuthorDto { UserName = "paul" }, - DateModified = DateTime.UtcNow - }; - - private static BlogsViewModel CreateViewModel(BlogResourceService? resources = null) - => new(new BlogApiClient(new RecordingYavscApiClient(new CallRecorder()), "https://blogs.example.test/api/v1/"), - new Settings { Authentication = new AuthenticationSettings { Authority = "https://org.example.test" } }, - resources: resources); - - [Fact] - public void Selecting_a_post_defaults_to_reading_and_uses_the_author_login() - { - var vm = CreateViewModel(); - vm.SelectedPost = Post(); - Assert.False(vm.IsEditing); - Assert.Equal("paul", vm.ReaderAuthor); - Assert.Equal("https://org.example.test/files/paul/", vm.ReaderBaseUrl); - Assert.Equal("Texte du billet.", vm.ReaderArticle); - } - - [Fact] - public void Reading_does_not_publish_or_discard_unsaved_editor_changes() - { - var vm = CreateViewModel(); - vm.SelectedPost = Post(); - vm.EditPostCommand.Execute(null); - vm.DraftArticle = "Modifications non enregistrées"; - vm.ReadPostCommand.Execute(null); - Assert.False(vm.IsEditing); - Assert.Equal("Texte du billet.", vm.ReaderArticle); - Assert.Equal("Modifications non enregistrées", vm.DraftArticle); - Assert.True(vm.IsDraftModified); - vm.EditPostCommand.Execute(null); - Assert.True(vm.IsEditing); - Assert.Equal("Modifications non enregistrées", vm.DraftArticle); - } - - [Fact] - public void New_post_is_an_explicit_action_and_protects_unsaved_changes() - { - var vm = CreateViewModel(); - vm.SelectedPost = Post(); - vm.NewPostCommand.Execute(null); - Assert.True(vm.IsEditing); - Assert.Null(vm.SelectedPost); - Assert.Empty(vm.DraftTitle); - Assert.Empty(vm.DraftArticle!); - vm.DraftTitle = "Nouveau brouillon"; - vm.NewPostCommand.Execute(null); - Assert.Equal("Nouveau brouillon", vm.DraftTitle); - Assert.Equal(StatusSeverity.Warning, vm.ActionStatus.Severity); - } - - [AvaloniaFact] - public async Task Reader_hides_editor_and_wires_hyperlinks_to_the_application() - { - var vm = CreateViewModel(); - await vm.InitializeAsync(); - vm.SelectedPost = Post("[Vidéo](clip.mp4)"); - var page = new BlogsPage { DataContext = vm }; - var nav = new NavigationPage(); - await nav.PushAsync(page); - var window = new Window { Width = 800, Height = 700, Content = nav }; - try - { - window.Show(); - Dispatcher.UIThread.RunJobs(); - window.UpdateLayout(); - Assert.True(page.FindControl("PostReader")!.IsVisible); - Assert.False(page.FindControl("PostEditor")!.IsVisible); - Assert.False(page.SaveButton.IsVisible); - var reader = page.FindControl("ArticleReader")!; - Assert.Equal(vm.ReaderArticle, reader.Markdown); - Assert.Equal(vm.ReaderBaseUrl, reader.AssetPathRoot); - Assert.Same(vm.OpenResourceCommand, Assert.IsAssignableFrom(reader.Engine).HyperlinkCommand); - vm.EditPostCommand.Execute(null); - Assert.False(page.FindControl("PostReader")!.IsVisible); - Assert.True(page.FindControl("PostEditor")!.IsVisible); - Assert.True(page.SaveButton.IsVisible); - } - finally - { - window.Close(); - } - } - - [AvaloniaFact] - public async Task Reader_initialization_and_empty_selection_do_not_report_binding_errors() - { - var vm = CreateViewModel(); - await vm.InitializeAsync(); - var post = Post(); - vm.Posts.Add(post); - vm.FilteredPosts.Add(post); - var previousSink = Logger.Sink; - var sink = new RecordingLogSink(); - Logger.Sink = sink; - Window? window = null; - try - { - var page = new BlogsPage(); - var reader = page.FindControl("ArticleReader")!; - var engine = Assert.IsAssignableFrom(reader.Engine); - Assert.NotNull(engine.HyperlinkCommand); - Assert.False(engine.HyperlinkCommand.CanExecute("https://example.test/")); - page.DataContext = vm; - var nav = new NavigationPage(); - await nav.PushAsync(page); - window = new Window { Width = 800, Height = 700, Content = nav }; - window.Show(); - Dispatcher.UIThread.RunJobs(); - Assert.Empty(vm.ReaderTitle); - Assert.Null(vm.ReaderDateModified); - Assert.Same(vm.OpenResourceCommand, engine.HyperlinkCommand); - Assert.IsType(reader.Plugins.PathResolver); - - vm.SelectedPost = post; - Dispatcher.UIThread.RunJobs(); - Assert.Equal(post.Title, vm.ReaderTitle); - Assert.Equal(post.DateModified, vm.ReaderDateModified); - - vm.SelectedPost = null; - page.DataContext = null; - Dispatcher.UIThread.RunJobs(); - Assert.Empty(vm.ReaderTitle); - Assert.Null(vm.ReaderDateModified); - Assert.NotNull(engine.HyperlinkCommand); - Assert.False(engine.HyperlinkCommand.CanExecute("https://example.test/")); - Assert.DoesNotContain(sink.Entries, entry => entry.Area == LogArea.Binding); - } - finally - { - window?.Close(); - Logger.Sink = previousSink; - } - } - - [AvaloniaFact] - public async Task Image_http_failure_is_logged_with_its_url_and_is_not_swallowed() - { - const string url = "https://org.example.test/files/paul/absent.jpg"; - using var client = new HttpClient(new BlogResourceServiceTests.StubHandler( - _ => new HttpResponseMessage(HttpStatusCode.NotFound))); - var resolver = new BlogImageResolver(client); - var previousSink = Logger.Sink; - var sink = new RecordingLogSink(); - Logger.Sink = sink; - try - { - await Assert.ThrowsAsync(() => resolver.ResolveImageResource(url)); - var error = Assert.Single(sink.Entries, entry => entry.Area == "BlogImages"); - Assert.Contains(url, error.Values); - } - finally - { - Logger.Sink = previousSink; - } - } - - private sealed class RecordingLogSink : ILogSink - { - public List<(string Area, object?[] Values)> Entries { get; } = []; - public bool IsEnabled(LogEventLevel level, string area) => level >= LogEventLevel.Warning; - public void Log(LogEventLevel level, string area, object? source, string messageTemplate) - => Entries.Add((area, [])); - public void Log(LogEventLevel level, string area, object? source, string messageTemplate, params object?[] values) - => Entries.Add((area, values)); - } - - [AvaloniaFact] - public async Task Long_reading_content_scrolls_and_clears_when_selection_is_removed() - { - var vm = CreateViewModel(); - await vm.InitializeAsync(); - vm.SelectedPost = Post(string.Join("\n\n", Enumerable.Range(0, 100).Select(i => $"Paragraphe {i}."))); - var page = new BlogsPage { DataContext = vm }; - var nav = new NavigationPage(); - await nav.PushAsync(page); - var window = new Window { Width = 800, Height = 700, Content = nav }; - try - { - window.Show(); - Dispatcher.UIThread.RunJobs(); - window.UpdateLayout(); - var reader = page.FindControl("ArticleReader")!; - var scroll = reader.GetVisualDescendants().OfType().Single(); - Assert.InRange(scroll.Viewport.Height, 1, window.ClientSize.Height); - Assert.True(scroll.Extent.Height > scroll.Viewport.Height); - scroll.ScrollToEnd(); - Assert.True(scroll.Offset.Y > 0); - vm.SelectedPost = null; - Dispatcher.UIThread.RunJobs(); - Assert.Empty(reader.Markdown!); - } - finally - { - window.Close(); - } - } - - [Theory] - [InlineData("https://yavsc.pschneider.fr/files/paul/blogs/14/IMG-20260923-WA0001.jpg")] - [InlineData("/files/paul/blogs/14/IMG-20260923-WA0001.jpg")] - [InlineData("blogs/14/IMG-20260923-WA0001.jpg")] - public async Task Embedded_jpeg_stream_is_seekable_at_the_start_for_markdown_decoding(string url) - { - using var source = new SKBitmap(32, 24); - source.Erase(SKColors.CornflowerBlue); - using var encoded = source.Encode(SKEncodedImageFormat.Jpeg, 90); - var jpeg = encoded.ToArray(); - using var client = new HttpClient(new BlogResourceServiceTests.StubHandler(request => - { - Assert.Equal("https://yavsc.pschneider.fr/files/paul/blogs/14/IMG-20260923-WA0001.jpg", - request.RequestUri!.AbsoluteUri); - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StreamContent(new NonSeekableStream(jpeg)) - }; - })); - var resolver = new BlogImageResolver(client) - { - AssetPathRoot = "https://yavsc.pschneider.fr/files/paul/" - }; - await using var stream = await resolver.ResolveImageResource(url); - Assert.NotNull(stream); - Assert.True(stream.CanSeek); - Assert.Equal(0, stream.Position); - using var decoded = SKBitmap.Decode(stream); - Assert.NotNull(decoded); - Assert.Equal(32, decoded.Width); - Assert.Equal(24, decoded.Height); - } - - private sealed class NonSeekableStream(byte[] bytes) : Stream - { - private readonly MemoryStream _inner = new(bytes, writable: false); - public override bool CanRead => _inner.CanRead; - public override bool CanSeek => false; - public override bool CanWrite => false; - public override long Length => throw new NotSupportedException(); - public override long Position - { - get => throw new NotSupportedException(); - set => throw new NotSupportedException(); - } - public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count); - public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) - => _inner.ReadAsync(buffer, cancellationToken); - public override void Flush() => throw new NotSupportedException(); - public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); - public override void SetLength(long value) => throw new NotSupportedException(); - public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); - protected override void Dispose(bool disposing) - { - if (disposing) - _inner.Dispose(); - base.Dispose(disposing); - } - } - - [AvaloniaFact] - public async Task Clicking_a_user_video_pushes_a_dedicated_video_viewmodel() - { - using var client = new HttpClient(new BlogResourceServiceTests.StubHandler(request => - { - Assert.Equal("https://org.example.test/files/paul/clip.mp4", request.RequestUri!.AbsoluteUri); - var response = new HttpResponseMessage(HttpStatusCode.OK); - response.Content.Headers.ContentType = new("video/mp4"); - return response; - })); - var vm = CreateViewModel(new BlogResourceService(client)); - vm.SelectedPost = Post(); - var app = (App)Application.Current!; - var root = new MainView(); - app.AttachMainWindow(root); - var template = new FuncDataTemplate((_, _) => new ContentPage()); - app.DataTemplates.Insert(0, template); - try - { - var page = new BlogsPage { DataContext = vm }; - var reader = page.FindControl("ArticleReader")!; - Dispatcher.UIThread.RunJobs(); - var engine = Assert.IsAssignableFrom(reader.Engine); - var link = Assert.IsType(Assert.Single(engine.ParseGamutInline("[Vidéo](clip.mp4)"))); - Assert.NotNull(link.Command); - link.Command(link.CommandParameter!); - Assert.NotNull(vm.OpenResourceCommand.ExecutionTask); - await vm.OpenResourceCommand.ExecutionTask; - var target = Assert.IsType(root.NavRoot.NavigationStack.Last().DataContext); - Assert.Equal("https://org.example.test/files/paul/clip.mp4", target.Source.AbsoluteUri); - Assert.Equal("video/mp4", target.MimeType); - } - finally - { - app.DataTemplates.Remove(template); - } - } - - [AvaloniaFact] - public void Video_viewmodel_resolves_to_a_page_with_a_vlc_control() - { - var vm = new VideoPageViewModel(new Uri("https://org.example.test/files/paul/clip.mp4"), "video/mp4"); - var page = Assert.IsType(new ViewLocator().Build(vm)); - Assert.NotNull(page.FindControl("Video")); - } - - [Fact] - public async Task Broken_resource_reports_an_error_and_releases_busy_state() - { - using var client = new HttpClient(new BlogResourceServiceTests.StubHandler(_ => new(HttpStatusCode.NotFound))); - var vm = CreateViewModel(new BlogResourceService(client)); - vm.SelectedPost = Post(); - await vm.OpenResourceCommand.ExecuteAsync("absent.mp4"); - Assert.False(vm.IsBusy); - Assert.Equal(StatusSeverity.Error, vm.ActionStatus.Severity); - } -} diff --git a/src/PostIt/PostIt.Tests/BlogResourceServiceTests.cs b/src/PostIt/PostIt.Tests/BlogResourceServiceTests.cs deleted file mode 100644 index 0682dc6f2..000000000 --- a/src/PostIt/PostIt.Tests/BlogResourceServiceTests.cs +++ /dev/null @@ -1,137 +0,0 @@ -using System.Net; -using System.Net.Http.Headers; -using PostIt.Services; - -namespace PostIt.Tests; - -public class BlogResourceServiceTests -{ - private const string Authority = "https://yavsc.pschneider.fr"; - - [Theory] - [InlineData("IMG-20260923-WA0001.jpg", Authority + "/files/paul/IMG-20260923-WA0001.jpg")] - [InlineData("blogs/42/vidéo.mp4", Authority + "/files/paul/blogs/42/vid%C3%A9o.mp4")] - [InlineData("/files/alice/video.webm", Authority + "/files/alice/video.webm")] - [InlineData(Authority + "/files/paul/photo.jpg", Authority + "/files/paul/photo.jpg")] - [InlineData("https://elsewhere.test/page", "https://elsewhere.test/page")] - [InlineData("//elsewhere.test/page", "https://elsewhere.test/page")] - public void Resolves_links_against_org_user_login_not_blogs_host(string link, string expected) - => Assert.Equal(expected, BlogResourceService.Resolve(link, Authority + "/connect/", "paul").AbsoluteUri); - - [Fact] - public void Encodes_login_and_keeps_trailing_slash() - => Assert.Equal(Authority + "/files/paul%20test/", - BlogResourceService.GetUserBaseUri(Authority, "paul test").AbsoluteUri); - - [Theory] - [InlineData(Authority + "/files/paul/movie.mp4", true)] - [InlineData(Authority + "/files/alice/blogs/42/movie.mp4?download=1", true)] - [InlineData(Authority + "/files/paul/", false)] - [InlineData(Authority + "/files/paul", false)] - [InlineData(Authority + "/files/", false)] - [InlineData(Authority + "/files-other/paul/movie.mp4", false)] - [InlineData(Authority + "/page.mp4", false)] - [InlineData("https://blogs.pschneider.fr/files/paul/movie.mp4", false)] - [InlineData("https://yavsc.pschneider.fr.evil.test/files/paul/movie.mp4", false)] - [InlineData("https://yavsc.pschneider.fr:8443/files/paul/movie.mp4", false)] - [InlineData("http://yavsc.pschneider.fr/files/paul/movie.mp4", false)] - [InlineData("https://user@yavsc.pschneider.fr/files/paul/movie.mp4", false)] - public void Recognizes_only_files_on_the_configured_org_origin(string link, bool expected) - => Assert.Equal(expected, BlogResourceService.IsUserResource(new Uri(link), Authority)); - - [Theory] - [InlineData("javascript:alert(1)")] - [InlineData("file:///tmp/video.mp4")] - [InlineData("data:video/mp4;base64,AAAA")] - public void Rejects_non_web_links(string link) - => Assert.Throws(() => BlogResourceService.Resolve(link, Authority, "paul")); - - [Theory] - [InlineData("video/webm", "movie.jpg", "video/webm")] - [InlineData("image/jpeg", "movie.mp4", "image/jpeg")] - [InlineData("application/octet-stream", "movie.MP4", "video/mp4")] - [InlineData(null, "movie.webm", "video/webm")] - [InlineData(null, "IMG-20260923-WA0001.jpg", "image/jpeg")] - [InlineData(null, "file.unknown", "application/octet-stream")] - public async Task Prefers_server_mime_with_extension_fallback(string? mime, string file, string expected) - { - using var client = new HttpClient(new StubHandler(request => - { - Assert.Equal(HttpMethod.Head, request.Method); - return Response(HttpStatusCode.OK, mime); - })); - var service = new BlogResourceService(client); - Assert.Equal(expected, await service.GetMimeTypeAsync(new Uri($"{Authority}/files/paul/{file}?v=1"), - TestContext.Current.CancellationToken)); - } - - [Theory] - [InlineData(HttpStatusCode.MethodNotAllowed)] - [InlineData(HttpStatusCode.NotImplemented)] - public async Task Falls_back_to_a_ranged_get_when_head_is_not_supported(HttpStatusCode status) - { - var calls = 0; - using var client = new HttpClient(new StubHandler(request => - { - calls++; - if (request.Method == HttpMethod.Head) - return Response(status, null); - Assert.Equal(HttpMethod.Get, request.Method); - Assert.Equal("bytes=0-0", request.Headers.Range?.ToString()); - return Response(HttpStatusCode.PartialContent, "video/mp4"); - })); - Assert.Equal("video/mp4", await new BlogResourceService(client) - .GetMimeTypeAsync(new Uri($"{Authority}/files/paul/movie"), TestContext.Current.CancellationToken)); - Assert.Equal(2, calls); - } - - [Theory] - [InlineData(HttpStatusCode.NotFound)] - [InlineData(HttpStatusCode.Unauthorized)] - [InlineData(HttpStatusCode.InternalServerError)] - public async Task Does_not_disguise_http_errors_as_a_mime_guess(HttpStatusCode status) - { - using var client = new HttpClient(new StubHandler(_ => Response(status, null))); - await Assert.ThrowsAsync(() => new BlogResourceService(client) - .GetMimeTypeAsync(new Uri($"{Authority}/files/paul/movie.mp4"), TestContext.Current.CancellationToken)); - } - - [Theory] - [InlineData("photo.jpg", Authority + "/files/paul/photo.jpg")] - [InlineData("/files/alice/photo.jpg", Authority + "/files/alice/photo.jpg")] - [InlineData(Authority + "/files/alice/photo.jpg", Authority + "/files/alice/photo.jpg")] - public async Task Inline_images_use_the_same_user_base_and_support_root_relative_paths(string link, string expected) - { - using var client = new HttpClient(new StubHandler(request => - { - Assert.Equal(expected, request.RequestUri!.AbsoluteUri); - return Response(HttpStatusCode.OK, "image/jpeg"); - })); - var resolver = new BlogImageResolver(client) { AssetPathRoot = Authority + "/files/paul/" }; - await using var stream = await resolver.ResolveImageResource(link); - Assert.NotNull(stream); - } - - [Theory] - [InlineData("file:///etc/passwd")] - [InlineData("data:image/png;base64,AAAA")] - public async Task Inline_images_cannot_read_local_files_or_non_web_schemes(string link) - { - var resolver = new BlogImageResolver { AssetPathRoot = Authority + "/files/paul/" }; - await Assert.ThrowsAsync(() => resolver.ResolveImageResource(link)); - } - - private static HttpResponseMessage Response(HttpStatusCode status, string? mime) - { - var response = new HttpResponseMessage(status) { Content = new ByteArrayContent([]) }; - if (mime is not null) - response.Content.Headers.ContentType = new MediaTypeHeaderValue(mime); - return response; - } - - internal sealed class StubHandler(Func respond) : HttpMessageHandler - { - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - => Task.FromResult(respond(request)); - } -} diff --git a/src/PostIt/PostIt.Tests/HomePageNotificationsTests.cs b/src/PostIt/PostIt.Tests/HomePageNotificationsTests.cs deleted file mode 100644 index 667ea599a..000000000 --- a/src/PostIt/PostIt.Tests/HomePageNotificationsTests.cs +++ /dev/null @@ -1,199 +0,0 @@ -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