diff --git a/CHANGELOG.md b/CHANGELOG.md index eaeca5faa..9b897b724 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ * [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 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