feat(notifications): support dismissing notifications in PostIt

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-10-18 12:49:52 +01:00
commit cb8cf9406d
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
12 changed files with 389 additions and 181 deletions

View file

@ -15,10 +15,24 @@
avec lecture/pause, libération du lecteur à la fermeture et accès au avec lecture/pause, libération du lecteur à la fermeture et accès au
navigateur en cas d'indisponibilité de LibVLC. Les autres fichiers et navigateur en cas d'indisponibilité de LibVLC. Les autres fichiers et
les vidéos sur mobile/navigateur s'ouvrent dans le navigateur système. les vidéos sur mobile/navigateur s'ouvrent dans le navigateur système.
* [Yavsc.Api] Contrôleur `DismissClicksApiController` (déplacé depuis
Yavsc.Org) exposant des DTO `DismissedNotification` :
`GET api/v1/dismiss`, `POST api/v1/dismiss/click/{notificationId}`
(idempotent, 404 si la notification n'existe pas) et
`DELETE api/v1/dismiss/click/{notificationId}` ; authentification requise.
* [Yavsc.Api.Client] `NotificationsApiClient.DismissAsync`,
`GetDismissedAsync` et `RestoreAsync`.
* [PostIt] Bouton de fermeture sur chaque notification de l'accueil
qui la démet auprès de Yavsc.Api pour un utilisateur connecté, ou la
masque localement sans appel API pour un visiteur non connecté
(jusqu'au prochain rechargement des notifications).
### Changed ### Changed
* L'administration est maintenant restreinte aux emails configurés dans les paramétres applicatifs de Yavsc.Org (`appsettings-org.json`) * L'administration est maintenant restreinte aux emails configurés dans les paramétres applicatifs de Yavsc.Org (`appsettings-org.json`)
* [Yavsc.Org] L'API `api/v1/dimiss` est supprimée ; le site web démet ses
notifications via `POST /Home/DismissNotification/{id}` (base pour un
utilisateur connecté, session pour un visiteur anonyme).
## [1.0.8-rc25] - unstable ## [1.0.8-rc25] - unstable

View file

@ -90,6 +90,22 @@ public class HomePageNotificationsTests
session.Refresh(); session.Refresh();
Assert.False(transport.WasAnonymous); Assert.False(transport.WasAnonymous);
Assert.EndsWith("/notifications/me", transport.LastPath); Assert.EndsWith("/notifications/me", transport.LastPath);
var note = Note(3, "Dismiss me");
vm.Notifications.Add(note);
Assert.True(vm.DismissNotificationCommand.CanExecute(note));
transport.DismissResponse = Task.FromException(new HttpRequestException("Server unavailable"));
await vm.DismissNotificationCommand.ExecuteAsync(note);
Assert.Contains(note, vm.Notifications);
Assert.Contains("Server unavailable", vm.NotificationsError);
transport.DismissResponse = Task.CompletedTask;
await vm.DismissNotificationCommand.ExecuteAsync(note);
Assert.Equal(HttpMethod.Post, transport.LastMethod);
Assert.Equal("https://business.example/api/v1/dismiss/click/3", transport.LastPath);
Assert.DoesNotContain(note, vm.Notifications);
Assert.Null(vm.NotificationsError);
var privateResponse = new TaskCompletionSource<List<Notification>>(); var privateResponse = new TaskCompletionSource<List<Notification>>();
transport.Response = privateResponse.Task; transport.Response = privateResponse.Task;
var privateLoad = vm.RefreshNotificationsAsync(); var privateLoad = vm.RefreshNotificationsAsync();
@ -112,6 +128,42 @@ public class HomePageNotificationsTests
} }
} }
[Fact]
public async Task Anonymous_dismiss_removes_the_notification_without_an_api_call()
{
var transport = new NotificationTransport();
var vm = CreateViewModel(transport);
vm.Notifications.Add(Note(1, "Keep"));
vm.Notifications.Add(Note(2, "Dismiss me"));
Assert.True(vm.DismissNotificationCommand.CanExecute(vm.Notifications[1]));
Assert.False(vm.DismissNotificationCommand.CanExecute(null));
await vm.DismissNotificationCommand.ExecuteAsync(vm.Notifications[1]);
Assert.Null(transport.LastMethod);
Assert.Null(transport.LastPath);
Assert.Equal(1, Assert.Single(vm.Notifications).Id);
Assert.Null(vm.NotificationsError);
}
[Fact]
public async Task Anonymous_dismiss_of_the_last_notification_updates_the_empty_state()
{
var transport = new NotificationTransport();
var vm = CreateViewModel(transport);
transport.Response = Task.FromResult(new List<Notification> { Note(3, "Public") });
await vm.RefreshNotificationsAsync();
Assert.False(vm.HasNoNotifications);
await vm.DismissNotificationCommand.ExecuteAsync(vm.Notifications[0]);
Assert.Empty(vm.Notifications);
Assert.True(vm.HasNoNotifications);
Assert.Null(vm.NotificationsError);
Assert.Null(transport.LastMethod);
}
[AvaloniaFact] [AvaloniaFact]
public async Task Home_page_loads_on_navigation_and_renders_wrapped_titles_and_bodies() public async Task Home_page_loads_on_navigation_and_renders_wrapped_titles_and_bodies()
{ {
@ -138,6 +190,12 @@ public class HomePageNotificationsTests
var refresh = Assert.Single(page.GetVisualDescendants().OfType<Button>(), var refresh = Assert.Single(page.GetVisualDescendants().OfType<Button>(),
b => b.Command == vm.RefreshNotificationsCommand); b => b.Command == vm.RefreshNotificationsCommand);
Assert.True(refresh.IsEnabled); Assert.True(refresh.IsEnabled);
var dismiss = Assert.Single(page.GetVisualDescendants().OfType<Button>(),
b => b.Command == vm.DismissNotificationCommand);
Assert.Same(vm.Notifications[0], dismiss.CommandParameter);
Assert.Equal("Close", dismiss.Content);
Assert.True(dismiss.IsVisible);
Assert.True(dismiss.IsEnabled);
Assert.Contains(page.GetVisualDescendants().OfType<ScrollViewer>(), Assert.Contains(page.GetVisualDescendants().OfType<ScrollViewer>(),
s => s.Extent.Height > s.Viewport.Height); s => s.Extent.Height > s.Viewport.Height);
@ -145,6 +203,10 @@ public class HomePageNotificationsTests
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Updated title") }); transport.Response = Task.FromResult(new List<Notification> { Note(2, "Updated title") });
await nav.PopAsync(); await nav.PopAsync();
Assert.Equal(2, Assert.Single(vm.Notifications).Id); Assert.Equal(2, Assert.Single(vm.Notifications).Id);
await vm.DismissNotificationCommand.ExecuteAsync(vm.Notifications[0]);
window.UpdateLayout();
Assert.Empty(vm.Notifications);
Assert.Null(transport.LastMethod);
} }
finally finally
{ {
@ -173,10 +235,29 @@ public class HomePageNotificationsTests
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null,
CancellationToken ct = default) CancellationToken ct = default)
{ {
LastMethod = method;
if (method == HttpMethod.Post)
{
LastPath = path;
WasAnonymous = false;
return DismissAsync<T>(path);
}
Assert.Equal(HttpMethod.Get, method); Assert.Equal(HttpMethod.Get, method);
return ReadAsync<T>(path, false); return ReadAsync<T>(path, false);
} }
private async Task<T> DismissAsync<T>(string path)
{
await DismissResponse;
return (T)(object)new DismissedNotification
{
NotificationId = long.Parse(path[(path.LastIndexOf('/') + 1)..])
};
}
public HttpMethod? LastMethod { get; private set; }
public Task DismissResponse { get; set; } = Task.CompletedTask;
private async Task<T> ReadAsync<T>(string path, bool anonymous) private async Task<T> ReadAsync<T>(string path, bool anonymous)
{ {
LastPath = path; LastPath = path;

View file

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.ComponentModel; using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia; using Avalonia;
using Avalonia.Threading; using Avalonia.Threading;
@ -59,6 +60,9 @@ public class HomePageViewModel : ViewModelBase
SessionStatus.PropertyChanged += OnSessionStatusChanged; SessionStatus.PropertyChanged += OnSessionStatusChanged;
RefreshNotificationsCommand = new AsyncRelayCommand(RefreshNotificationsAsync, RefreshNotificationsCommand = new AsyncRelayCommand(RefreshNotificationsAsync,
() => _notificationsClient is not null); () => _notificationsClient is not null);
DismissNotificationCommand = new AsyncRelayCommand<Notification?>(DismissNotificationAsync,
n => n is not null,
AsyncRelayCommandOptions.AllowConcurrentExecutions);
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync); OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync); OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync);
@ -86,6 +90,31 @@ public class HomePageViewModel : ViewModelBase
public IAsyncRelayCommand OpenProviderEstimates { get; } public IAsyncRelayCommand OpenProviderEstimates { get; }
public IAsyncRelayCommand OpenMyFiles { get; } public IAsyncRelayCommand OpenMyFiles { get; }
public IAsyncRelayCommand RefreshNotificationsCommand { get; } public IAsyncRelayCommand RefreshNotificationsCommand { get; }
public IAsyncRelayCommand<Notification?> DismissNotificationCommand { get; }
public async Task DismissNotificationAsync(Notification? notification)
{
if (notification is null)
return;
NotificationsError = null;
try
{
if (SessionStatus.IsLoggedIn)
{
if (_notificationsClient is null)
throw new InvalidOperationException("Notifications client is not available.");
await _notificationsClient.DismissAsync(notification.Id);
}
var shown = Notifications.FirstOrDefault(n => n.Id == notification.Id);
if (shown is not null)
Notifications.Remove(shown);
OnPropertyChanged(nameof(HasNoNotifications));
}
catch (Exception ex)
{
NotificationsError = $"Impossible de masquer la notification : {ex.Message}";
}
}
public async Task RefreshNotificationsAsync() public async Task RefreshNotificationsAsync()
{ {
@ -134,9 +163,15 @@ public class HomePageViewModel : ViewModelBase
// Logout can complete on a worker thread; collections and bindings belong to the UI thread. // Logout can complete on a worker thread; collections and bindings belong to the UI thread.
if (Dispatcher.UIThread.CheckAccess()) if (Dispatcher.UIThread.CheckAccess())
_ = RefreshNotificationsAsync(); OnLoginChanged();
else else
Dispatcher.UIThread.Post(() => _ = RefreshNotificationsAsync()); Dispatcher.UIThread.Post(OnLoginChanged);
}
private void OnLoginChanged()
{
DismissNotificationCommand.NotifyCanExecuteChanged();
_ = RefreshNotificationsAsync();
} }
private async Task OpenActivitiesAsync() private async Task OpenActivitiesAsync()

View file

@ -37,8 +37,15 @@
BorderThickness="1" BorderThickness="1"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"> BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}">
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="{Binding title}" FontWeight="SemiBold" <TextBlock Text="{Binding title}" FontWeight="SemiBold"
TextWrapping="Wrap"/> TextWrapping="Wrap" VerticalAlignment="Center"/>
<Button Grid.Column="1" Margin="8,0,0,0"
Content="{Binding click_action, TargetNullValue=Fermer}"
ToolTip.Tip="Masquer cette notification"
Command="{Binding $parent[ItemsControl].((vm:HomePageViewModel)DataContext).DismissNotificationCommand}"
CommandParameter="{Binding}"/>
</Grid>
<TextBlock Text="{Binding body}" TextWrapping="Wrap"/> <TextBlock Text="{Binding body}" TextWrapping="Wrap"/>
</StackPanel> </StackPanel>
</Border> </Border>

View file

@ -35,6 +35,8 @@ namespace Yavsc
public const string ResourceUsageAdminRoute = "admin"; public const string ResourceUsageAdminRoute = "admin";
public const string NotificationsPath = "notifications"; public const string NotificationsPath = "notifications";
public const string NotificationsCurrentUserRoute = "me"; public const string NotificationsCurrentUserRoute = "me";
public const string NotificationsDismissPath = "dismiss";
public const string NotificationsDismissClickRoute = "click";
public const string ResourceUsageDefaultCurrency = "EUR"; public const string ResourceUsageDefaultCurrency = "EUR";
public const string ResourceUsageCurrentUserDisplayName = "current-user"; public const string ResourceUsageCurrentUserDisplayName = "current-user";
public const string ResourceUsageAdminDisplayName = "administrator"; public const string ResourceUsageAdminDisplayName = "administrator";

View file

@ -0,0 +1,11 @@
namespace Yavsc.Abstract.Models.Messaging
{
/// <summary>
/// Wire format of a notification dismissed by the current user.
/// The user is always implied by the authenticated caller.
/// </summary>
public sealed class DismissedNotification
{
public long NotificationId { get; set; }
}
}

View file

@ -8,7 +8,8 @@ using Yavsc.Abstract.Models.Messaging;
namespace Yavsc.Api.Client; namespace Yavsc.Api.Client;
/// <summary> /// <summary>
/// Thin HTTP client for the notifications exposed to PostIt on the home page. /// Thin HTTP client for the notifications exposed to PostIt on the home page,
/// and for their dismissal by the current user.
/// </summary> /// </summary>
public sealed class NotificationsApiClient public sealed class NotificationsApiClient
{ {
@ -48,6 +49,33 @@ public sealed class NotificationsApiClient
public Task<List<Notification>> GetPublicAsync(CancellationToken ct = default) public Task<List<Notification>> GetPublicAsync(CancellationToken ct = default)
=> _api.GetAnonymousAsync<List<Notification>>(Absolute(PathPrefix), ct); => _api.GetAnonymousAsync<List<Notification>>(Absolute(PathPrefix), ct);
public Task<List<DismissedNotification>> GetDismissedAsync(CancellationToken ct = default)
{
return _api.CallAsync<List<DismissedNotification>>(
HttpMethod.Get,
Absolute(Constants.NotificationsDismissPath),
ct: ct);
}
public Task<DismissedNotification> DismissAsync(long notificationId, CancellationToken ct = default)
{
return _api.CallAsync<DismissedNotification>(
HttpMethod.Post,
Absolute(DismissClickPath(notificationId)),
ct: ct);
}
public Task RestoreAsync(long notificationId, CancellationToken ct = default)
{
return _api.CallAsync(
HttpMethod.Delete,
Absolute(DismissClickPath(notificationId)),
ct: ct);
}
private static string DismissClickPath(long notificationId)
=> $"{Constants.NotificationsDismissPath}/{Constants.NotificationsDismissClickRoute}/{notificationId}";
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString(); private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
private Uri ResolveBusinessBaseAddress() private Uri ResolveBusinessBaseAddress()

View file

@ -0,0 +1,76 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Yavsc.Abstract.Models.Messaging;
using Yavsc.Models;
using Yavsc.Models.Messaging;
using Yavsc.Server.Helpers;
namespace Yavsc.ApiControllers.Administration;
/// <summary>
/// Notifications dismissed by the authenticated user.
/// </summary>
[Authorize]
[Produces("application/json")]
[Route(Constants.APIPrefix + "/" + Constants.NotificationsDismissPath)]
public sealed class DismissClicksApiController : Controller
{
private readonly ApplicationDbContext _dbContext;
public DismissClicksApiController(ApplicationDbContext dbContext)
{
_dbContext = dbContext;
}
[HttpGet]
public async Task<ActionResult<List<DismissedNotification>>> GetDismissed()
{
var userId = User.GetUserId();
var dismissed = await _dbContext.DismissClicked
.AsNoTracking()
.Where(d => d.UserId == userId)
.OrderBy(d => d.NotificationId)
.Select(d => new DismissedNotification { NotificationId = d.NotificationId })
.ToListAsync();
return Ok(dismissed);
}
/// <summary>
/// Dismisses a notification for the current user. Idempotent.
/// </summary>
[HttpPost(Constants.NotificationsDismissClickRoute + "/{notificationId:long}")]
public async Task<ActionResult<DismissedNotification>> Click(long notificationId)
{
var userId = User.GetUserId();
if (!await _dbContext.Notification.AnyAsync(n => n.Id == notificationId))
return NotFound();
var exists = await _dbContext.DismissClicked
.AnyAsync(d => d.UserId == userId && d.NotificationId == notificationId);
if (!exists)
{
_dbContext.DismissClicked.Add(new DismissClicked { UserId = userId, NotificationId = notificationId });
await _dbContext.SaveChangesAsync(userId);
}
return Ok(new DismissedNotification { NotificationId = notificationId });
}
/// <summary>
/// Restores a previously dismissed notification for the current user.
/// </summary>
[HttpDelete(Constants.NotificationsDismissClickRoute + "/{notificationId:long}")]
public async Task<IActionResult> Restore(long notificationId)
{
var userId = User.GetUserId();
var dismissed = await _dbContext.DismissClicked
.SingleOrDefaultAsync(d => d.UserId == userId && d.NotificationId == notificationId);
if (dismissed is null)
return NotFound();
_dbContext.DismissClicked.Remove(dismissed);
await _dbContext.SaveChangesAsync(userId);
return NoContent();
}
}

View file

@ -1,174 +0,0 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Yavsc.Models;
using Yavsc.Models.Messaging;
using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route(Constants.APIPrefix + "/v1/dimiss")]
public class DimissClicksApiController : Controller
{
private readonly ApplicationDbContext _context;
public DimissClicksApiController(ApplicationDbContext context)
{
_context = context;
}
// GET: api/DimissClicksApi
[HttpGet]
public IEnumerable<DismissClicked> GetDismissClicked()
{
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
return _context.DismissClicked.Where(d=>d.UserId == uid);
}
[HttpGet("click/{noteid}"),AllowAnonymous]
public async Task<IActionResult> Click(long noteid )
{
if (User.IsSignedIn())
return await PostDismissClicked(new DismissClicked { NotificationId= noteid, UserId = User.GetUserId()});
await HttpContext.Session.LoadAsync();
var clicked = HttpContext.Session.GetString("clicked");
if (clicked == null) {
HttpContext.Session.SetString("clicked",noteid.ToString());
} else HttpContext.Session.SetString("clicked",$"{clicked}:{noteid}");
await HttpContext.Session.CommitAsync();
return Ok();
}
// GET: api/DimissClicksApi/5
[HttpGet("{id}", Name = "GetDismissClicked")]
public async Task<IActionResult> GetDismissClicked([FromRoute] string id)
{
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (uid != id) return new ChallengeResult();
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
DismissClicked DismissClicked = await _context.DismissClicked.SingleAsync(m => m.UserId == id);
if (DismissClicked == null)
{
return NotFound();
}
return Ok(DismissClicked);
}
// PUT: api/DimissClicksApi/5
[HttpPut("{id}")]
public async Task<IActionResult> PutDismissClicked([FromRoute] string id, [FromBody] DismissClicked DismissClicked)
{
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (uid != id || uid != DismissClicked.UserId) return new ChallengeResult();
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
if (id != DismissClicked.UserId)
{
return BadRequest();
}
_context.Entry(DismissClicked).State = EntityState.Modified;
try
{
await _context.SaveChangesAsync(User.GetUserId());
}
catch (DbUpdateConcurrencyException)
{
if (!DismissClickedExists(id))
{
return NotFound();
}
else
{
throw;
}
}
return new StatusCodeResult(StatusCodes.Status204NoContent);
}
// POST: api/DimissClicksApi
[HttpPost]
public async Task<IActionResult> PostDismissClicked([FromBody] DismissClicked DismissClicked)
{
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (uid != DismissClicked.UserId) return new ChallengeResult();
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
_context.DismissClicked.Add(DismissClicked);
try
{
await _context.SaveChangesAsync(User.GetUserId());
}
catch (DbUpdateException)
{
if (DismissClickedExists(DismissClicked.UserId))
{
return new StatusCodeResult(StatusCodes.Status409Conflict);
}
else
{
throw;
}
}
return CreatedAtRoute("GetDismissClicked", new { id = DismissClicked.UserId }, DismissClicked);
}
// DELETE: api/DimissClicksApi/5
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteDismissClicked([FromRoute] string id)
{
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (!User.IsInRole("Administrator"))
if (uid != id) return new ChallengeResult();
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
DismissClicked DismissClicked = await _context.DismissClicked.SingleAsync(m => m.UserId == id);
if (DismissClicked == null)
{
return NotFound();
}
_context.DismissClicked.Remove(DismissClicked);
await _context.SaveChangesAsync(User.GetUserId());
return Ok(DismissClicked);
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_context.Dispose();
}
base.Dispose(disposing);
}
private bool DismissClickedExists(string id)
{
return _context.DismissClicked.Count(e => e.UserId == id) > 0;
}
}
}

View file

@ -95,6 +95,36 @@ namespace Yavsc.Controllers
} }
return View(toShow); return View(toShow);
} }
/// <summary>
/// Web-site dismissal of a notification: persisted for signed-in users,
/// kept in the session for anonymous visitors.
/// API clients use <c>POST api/v1/dismiss/click/{id}</c> on Yavsc.Api.
/// </summary>
[HttpPost]
public async Task<IActionResult> DismissNotification(long id)
{
if (!await _dbContext.Notification.AnyAsync(n => n.Id == id))
return NotFound();
var uid = User.GetUserId();
if (uid != null)
{
if (!await _dbContext.DismissClicked.AnyAsync(d => d.UserId == uid && d.NotificationId == id))
{
_dbContext.DismissClicked.Add(new Yavsc.Models.Messaging.DismissClicked { UserId = uid, NotificationId = id });
await _dbContext.SaveChangesAsync(uid);
}
return NoContent();
}
await HttpContext.Session.LoadAsync();
var clicked = HttpContext.Session.GetString("clicked");
HttpContext.Session.SetString("clicked", clicked == null ? id.ToString() : $"{clicked}:{id}");
await HttpContext.Session.CommitAsync();
return NoContent();
}
public async Task<IActionResult> About() public async Task<IActionResult> About()
{ {
return View("About"); return View("About");

View file

@ -2,8 +2,8 @@
var notifClick = var notifClick =
function(nid) { function(nid) {
if (nid > 0) { if (nid > 0) {
$.get({ $.post({
url: '/api/v1/dimiss/click/' + nid, url: '/Home/DismissNotification/' + nid,
success: $('div[data-nid='+nid+']').remove() success: $('div[data-nid='+nid+']').remove()
}); });
} }

View file

@ -0,0 +1,98 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Abstract.Models.Messaging;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Models;
using Yavsc.Tests.Shared;
namespace Yavsc.Api.Test;
[Collection("Yavsc Api")]
public sealed class DismissClicksApiControllerTests(ApiWebServerFixture fixture) : IClassFixture<ApiWebServerFixture>
{
private HttpClient NewClient(string? subject = "alice")
{
var http = new HttpClient(new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
})
{
BaseAddress = new Uri(fixture.BaseAddress)
};
if (subject != null)
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, "api"));
return http;
}
private async Task<long> SeedNotificationAsync()
{
fixture.ResetAndSeedActivityGraph();
using var scope = fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var note = new Notification { title = "Info", body = "Corps", click_action = "Fermer" };
db.Notification.Add(note);
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
return note.Id;
}
[Fact]
public async Task Click_dismisses_once_and_hides_the_notification_for_the_user()
{
var id = await SeedNotificationAsync();
var ct = TestContext.Current.CancellationToken;
using var http = NewClient();
for (var i = 0; i < 2; i++)
{
var response = await http.PostAsync($"/api/v1/dismiss/click/{id}", null, ct);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var dto = await response.Content.ReadFromJsonAsync<DismissedNotification>(ct);
Assert.Equal(id, dto!.NotificationId);
}
var dismissed = await http.GetFromJsonAsync<List<DismissedNotification>>("/api/v1/dismiss", ct);
Assert.Equal(id, Assert.Single(dismissed!).NotificationId);
var visible = await http.GetFromJsonAsync<List<Notification>>("/api/v1/notifications/me", ct);
Assert.DoesNotContain(visible!, n => n.Id == id);
using (var scope = fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.Equal(1, await db.DismissClicked.CountAsync(d => d.UserId == "alice" && d.NotificationId == id, ct));
}
var restore = await http.DeleteAsync($"/api/v1/dismiss/click/{id}", ct);
Assert.Equal(HttpStatusCode.NoContent, restore.StatusCode);
visible = await http.GetFromJsonAsync<List<Notification>>("/api/v1/notifications/me", ct);
Assert.Contains(visible!, n => n.Id == id);
}
[Fact]
public async Task Click_on_an_unknown_notification_returns_not_found()
{
var id = await SeedNotificationAsync();
using var http = NewClient();
var response = await http.PostAsync($"/api/v1/dismiss/click/{id + 1000}", null,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task Click_requires_authentication()
{
var id = await SeedNotificationAsync();
using var http = NewClient(null);
var response = await http.PostAsync($"/api/v1/dismiss/click/{id}", null,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}