feat: dashboard info tile UI with edit dialog, quick-add confirmation, tests

This commit is contained in:
Jannis committed 2026-10-10 18:55:09 +02:00
1 parent 545162479c
commit 8dc923cd6e
10 files changed
+525 -4

No files matched your search

@@ -0,0 +1,62 @@
@using FamilyPlanner.Domain.Dashboard
@using FamilyPlanner.Shared.Dashboard
@inject IDashboardApi Api
<MudDialog>
<DialogContent>
@if (_error is not null)
{
<MudAlert Severity="Severity.Error" Class="mb-3" role="alert">@_error</MudAlert>
}
<MudTextField @bind-Value="_title" Label="Titel" Variant="Variant.Outlined" Margin="Margin.Dense" MaxLength="DashboardNote.TitleMaxLength"
Class="fp-note-title" Error="@(_errors.ContainsKey("Title"))" ErrorText="@_errors.GetValueOrDefault("Title")" />
<MudTextField @bind-Value="_text" Label="Text" Variant="Variant.Outlined" Margin="Margin.Dense" Lines="4" Sizing="InputSizing.Auto" MaxLength="DashboardNote.TextMaxLength"
Class="fp-note-text mt-3" Counter="DashboardNote.TextMaxLength" Error="@(_errors.ContainsKey("Text"))" ErrorText="@_errors.GetValueOrDefault("Text")" />
</DialogContent>
<DialogActions>
<MudButton OnClick="@(() => Dialog?.Cancel())">Abbrechen</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync" Disabled="_busy" Class="fp-note-save">Speichern</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance? Dialog { get; set; }
/// <summary>Zu bearbeitende Notiz; <c>null</c> legt eine neue an.</summary>
[Parameter]
public DashboardNoteDto? Note { get; set; }
private string _title = string.Empty;
private string _text = string.Empty;
private Dictionary<string, string> _errors = new(StringComparer.OrdinalIgnoreCase);
private string? _error;
private bool _busy;
protected override void OnInitialized() => (_title, _text) = (Note?.Title ?? string.Empty, Note?.Text ?? string.Empty);
private async Task SaveAsync()
{
_error = null;
var request = new SaveDashboardNoteRequest(_title.Trim(), _text.Trim());
var validation = new SaveDashboardNoteRequestValidator().Validate(request);
_errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => string.Join(" ", g.Select(e => e.ErrorMessage)), StringComparer.OrdinalIgnoreCase);
if (!validation.IsValid)
{
return;
}
_busy = true;
var result = Note is null ? await Api.CreateNoteAsync(request) : await Api.UpdateNoteAsync(Note.Id, request);
_busy = false;
if (result.Problem is { } problem)
{
_error = problem.Message;
return;
}
Dialog?.Close(DialogResult.Ok(result.Value));
}
}
+95 -1
View File
@@ -7,6 +7,7 @@
@inject IShoppingApi ShoppingApi
@inject ILiveConnection Live
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject TimeProvider TimeProvider
<PageTitle>Übersicht – Familienplaner</PageTitle>
@@ -284,6 +285,52 @@ else
</MudCard>
</MudItem>
}
@if (_data.Notes is { } notes && (notes.Items.Count > 0 || notes.CanEdit))
{
<MudItem xs="12" sm="6" lg="4">
<MudCard Outlined="true" Class="fp-tile fp-dash-notes">
<MudCardHeader>
<CardHeaderAvatar><MudIcon Icon="@Icons.Material.Filled.Info" Color="Color.Primary" /></CardHeaderAvatar>
<CardHeaderContent><MudText Typo="Typo.h6" HtmlTag="h2">Infos</MudText></CardHeaderContent>
</MudCardHeader>
<MudCardContent>
@if (notes.Items.Count == 0)
{
<MudText Typo="Typo.body2" Class="fp-dash-empty">Noch keine Infos – z. B. Wlan-Passwort oder Ferientermine.</MudText>
}
else
{
<ul class="fp-dash-list fp-dash-notes-list">
@foreach (var note in notes.Items)
{
<li class="fp-dash-note">
<span class="fp-dash-label fp-dash-note-title">@note.Title</span>
<span class="fp-dash-main fp-dash-note-text">@note.Text</span>
@if (notes.CanEdit)
{
<span class="fp-dash-note-actions">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => EditNoteAsync(note))"
aria-label="@($"{note.Title} bearbeiten")" Class="fp-note-edit" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" OnClick="@(() => DeleteNoteAsync(note))"
aria-label="@($"{note.Title} löschen")" Class="fp-note-delete" />
</span>
}
</li>
}
</ul>
}
</MudCardContent>
@if (notes.CanEdit)
{
<MudCardActions>
<MudButton Variant="Variant.Text" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => EditNoteAsync(null))"
Class="fp-note-add">Info hinzufügen</MudButton>
</MudCardActions>
}
</MudCard>
</MudItem>
}
</MudGrid>
}
@@ -403,6 +450,36 @@ else
private Task OnChatRead(Guid _, Guid __, DateTimeOffset ___) => ScheduleRefreshAsync();
private async Task EditNoteAsync(DashboardNoteDto? note)
{
var dialog = await DialogService.ShowAsync<DashboardNoteDialog>(
note is null ? "Info hinzufügen" : "Info bearbeiten",
new DialogParameters<DashboardNoteDialog> { { d => d.Note, note } },
new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true, CloseOnEscapeKey = true });
if (await dialog.Result is { Canceled: false })
{
await RefreshAsync();
}
}
private async Task DeleteNoteAsync(DashboardNoteDto note)
{
var confirmed = await DialogService.ShowMessageBoxAsync("Info löschen?", $"„{note.Title}“ wird für alle entfernt.", yesText: "Löschen", cancelText: "Abbrechen");
if (confirmed != true)
{
return;
}
var result = await DashboardApi.DeleteNoteAsync(note.Id);
if (!result.Succeeded)
{
Snackbar.Add(result.Problem?.Message ?? "Die Info konnte nicht gelöscht werden.", Severity.Warning);
return;
}
await RefreshAsync();
}
private Task OnQuickAddKeyAsync(KeyboardEventArgs args) => args.Key == "Enter" ? QuickAddAsync() : Task.CompletedTask;
private async Task QuickAddAsync()
@@ -416,7 +493,24 @@ else
_adding = true;
try
{
var result = await ShoppingApi.AddItemAsync(listId, new FamilyPlanner.Shared.Shopping.AddShoppingItemRequest(Guid.CreateVersion7(), text));
var request = new FamilyPlanner.Shared.Shopping.AddShoppingItemRequest(Guid.CreateVersion7(), text);
var result = await ShoppingApi.AddItemAsync(listId, request);
if (result.Problem is { Status: 409, NeedsConfirmation: true })
{
// Plausibilitätsprüfung für Kinder: nachfragen und mit Bestätigung erneut senden.
var confirmed = await DialogService.ShowMessageBoxAsync(
"Wirklich hinzufügen?",
$"„{text}“ sieht nicht nach einem Artikel aus. Soll es trotzdem auf die Einkaufsliste?",
yesText: "Ja, hinzufügen",
cancelText: "Nein");
if (confirmed != true)
{
return;
}
result = await ShoppingApi.AddItemAsync(listId, request with { Confirm = true });
}
if (result.Succeeded)
{
_quickText = null;
@@ -1,3 +1,4 @@
using System.Net.Http.Json;
using FamilyPlanner.Shared.Dashboard;
namespace FamilyPlanner.Web.Client.Services;
@@ -5,10 +6,27 @@ namespace FamilyPlanner.Web.Client.Services;
public interface IDashboardApi
{
Task<ApiResult<DashboardDto>> GetAsync(CancellationToken ct = default);
Task<ApiResult<DashboardNoteDto>> CreateNoteAsync(SaveDashboardNoteRequest request, CancellationToken ct = default);
Task<ApiResult<DashboardNoteDto>> UpdateNoteAsync(Guid id, SaveDashboardNoteRequest request, CancellationToken ct = default);
Task<ApiResult<bool>> DeleteNoteAsync(Guid id, CancellationToken ct = default);
}
public sealed class DashboardApi(HttpClient http) : IDashboardApi
{
private const string NotesBase = "api/dashboard/notes";
public Task<ApiResult<DashboardDto>> GetAsync(CancellationToken ct = default) =>
ApiCall.SendAsync<DashboardDto>(() => http.GetAsync("api/dashboard", ct), ct);
public Task<ApiResult<DashboardNoteDto>> CreateNoteAsync(SaveDashboardNoteRequest request, CancellationToken ct = default) =>
ApiCall.SendAsync<DashboardNoteDto>(() => http.PostAsJsonAsync(NotesBase, request, ct), ct);
public Task<ApiResult<DashboardNoteDto>> UpdateNoteAsync(Guid id, SaveDashboardNoteRequest request, CancellationToken ct = default) =>
ApiCall.SendAsync<DashboardNoteDto>(() => http.PutAsJsonAsync($"{NotesBase}/{id}", request, ct), ct);
public Task<ApiResult<bool>> DeleteNoteAsync(Guid id, CancellationToken ct = default) =>
ApiCall.SendAsync(() => http.DeleteAsync($"{NotesBase}/{id}", ct), ct);
}
+9
View File
@@ -189,6 +189,15 @@ body:has(.fp-app) .app-loading {
color: var(--mud-palette-text-secondary);
}
.fp-dash-note-text {
white-space: pre-wrap;
}
.fp-dash-note-actions {
grid-column: 2;
justify-self: end;
}
/* Einkaufsliste */
.fp-items {
list-style: none;
@@ -0,0 +1,161 @@
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Authorization;
using FamilyPlanner.Shared.Auth;
using FamilyPlanner.Shared.Dashboard;
using FamilyPlanner.Web.Client.Auth;
using FamilyPlanner.Web.Client.Pages;
using FamilyPlanner.Web.Client.Services;
using FamilyPlanner.Web.Tests.Shopping;
using FamilyPlanner.Web.Tests.Tasks;
using Microsoft.Extensions.DependencyInjection;
using MudBlazor.Services;
namespace FamilyPlanner.Web.Tests.Dashboard;
/// <summary>Allgemeine Info-Kachel der Startseite (Freitext-Notizen, Eltern pflegen).</summary>
public sealed class DashboardNotesTests : BunitContext
{
private static readonly DateOnly Today = new(2026, 10, 12);
private readonly FakeDashboardApi _api = new();
private readonly FakeShoppingApi _shopping = new();
public DashboardNotesTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
Home.RefreshDelay = TimeSpan.FromMilliseconds(10);
Services.AddMudServices();
Services.AddSingleton<IDashboardApi>(_api);
Services.AddSingleton<IShoppingApi>(_shopping);
Services.AddSingleton<ILiveConnection, FakeLiveConnection>();
Services.AddSingleton<IAuthService, NoopAuthService>();
Services.AddSingleton<ApiAuthenticationStateProvider>();
Services.AddSingleton<TimeProvider>(new FixedTimeProvider(new DateTimeOffset(2026, 10, 12, 7, 30, 0, TimeSpan.Zero)));
}
private void SignInAs(string role) =>
Services.GetRequiredService<ApiAuthenticationStateProvider>().SetUser(new CurrentUserDto(
Guid.NewGuid(), "test", "Mia", null, "#1E88E5", [role], false, null, null, PermissionMatrix.Effective([role])));
private void Seed(bool canEdit, params DashboardNoteDto[] notes) =>
_api.Data = new DashboardDto(Today, null, null, null, null, null, null, Notes: new DashboardNotesDto(notes, canEdit));
[Fact]
public void Everyone_sees_the_notes_but_only_parents_get_edit_buttons()
{
Seed(canEdit: false, new DashboardNoteDto(Guid.NewGuid(), "Wlan-Passwort", "Sommer2026"));
SignInAs(Roles.Child);
var cut = Render<DashboardHost>();
Assert.Equal("Wlan-Passwort", cut.Find(".fp-dash-note-title").TextContent);
Assert.Equal("Sommer2026", cut.Find(".fp-dash-note-text").TextContent);
Assert.Empty(cut.FindAll(".fp-note-edit"));
Assert.Empty(cut.FindAll(".fp-note-add"));
}
[Fact]
public void Tile_is_hidden_without_notes_for_non_editors()
{
Seed(canEdit: false);
SignInAs(Roles.Guest);
var cut = Render<DashboardHost>();
Assert.Empty(cut.FindAll(".fp-dash-notes"));
}
[Fact]
public void Tile_is_shown_for_parents_even_without_notes()
{
Seed(canEdit: true);
SignInAs(Roles.Family);
var cut = Render<DashboardHost>();
Assert.NotEmpty(cut.FindAll(".fp-dash-notes"));
Assert.NotEmpty(cut.FindAll(".fp-note-add"));
}
[Fact]
public void Note_text_is_rendered_as_plain_text()
{
Seed(canEdit: false, new DashboardNoteDto(Guid.NewGuid(), "<b>Titel</b>", "<script>alert(1)</script>"));
SignInAs(Roles.Family);
var cut = Render<DashboardHost>();
Assert.Equal("<b>Titel</b>", cut.Find(".fp-dash-note-title").TextContent);
Assert.Empty(cut.FindAll(".fp-dash-notes script"));
Assert.Empty(cut.FindAll(".fp-dash-note-title b"));
}
[Fact]
public void Parent_can_add_a_note_through_the_dialog()
{
Seed(canEdit: true);
SignInAs(Roles.Family);
var cut = Render<DashboardHost>();
cut.Find(".fp-note-add").Click();
cut.WaitForElement(".fp-note-title input").Change("Ferien");
cut.Find(".fp-note-text textarea").Change("bis 7. November");
cut.Find(".fp-note-save").Click();
cut.WaitForAssertion(() => Assert.Equal("Ferien", cut.Find(".fp-dash-note-title").TextContent));
var created = Assert.Single(_api.Created);
Assert.Equal(("Ferien", "bis 7. November"), (created.Title, created.Text));
}
[Fact]
public void Dialog_validates_before_saving()
{
Seed(canEdit: true);
SignInAs(Roles.Family);
var cut = Render<DashboardHost>();
cut.Find(".fp-note-add").Click();
cut.WaitForElement(".fp-note-save").Click();
cut.WaitForAssertion(() => Assert.Contains("Bitte einen Titel eingeben.", cut.Markup));
Assert.Empty(_api.Created);
}
[Fact]
public void Parent_can_edit_and_delete_a_note()
{
var note = new DashboardNoteDto(Guid.NewGuid(), "Wlan", "alt");
Seed(canEdit: true, note);
SignInAs(Roles.Admin);
var cut = Render<DashboardHost>();
cut.Find(".fp-note-edit").Click();
cut.WaitForElement(".fp-note-text textarea").Change("neu");
cut.Find(".fp-note-save").Click();
cut.WaitForAssertion(() => Assert.Equal("neu", cut.Find(".fp-dash-note-text").TextContent));
Assert.Equal((note.Id, "Wlan", "neu"), (_api.Updated[0].Id, _api.Updated[0].Request.Title, _api.Updated[0].Request.Text));
cut.Find(".fp-note-delete").Click();
cut.WaitForElement(".mud-message-box button.mud-button-filled, .mud-dialog button.mud-button-text");
cut.FindAll("button").Single(b => b.TextContent.Trim() == "Löschen" && b.ClassList.Contains("mud-button-root") && !b.ClassList.Contains("fp-note-delete")).Click();
cut.WaitForAssertion(() => Assert.Equal([note.Id], _api.DeletedNotes));
}
[Fact]
public void Quick_add_asks_children_when_the_server_wants_a_confirmation()
{
_shopping.RejectSuspiciousWithoutConfirm = true;
_api.Data = new DashboardDto(Today, null, null, new DashboardShoppingDto(FakeShoppingApi.SupermarketId, "Supermarkt", 0, 0, true), null, null, null);
SignInAs(Roles.Child);
var cut = Render<DashboardHost>();
var input = cut.Find(".fp-dash-quickadd input");
input.Input("asdf");
input.KeyUp(new Microsoft.AspNetCore.Components.Web.KeyboardEventArgs { Key = "Enter" });
cut.WaitForAssertion(() => Assert.Contains("sieht nicht nach einem Artikel aus", cut.Markup));
Assert.Empty(_shopping.Items);
cut.FindAll("button").Single(b => b.TextContent.Trim() == "Ja, hinzufügen").Click();
cut.WaitForAssertion(() => Assert.Single(_shopping.Items));
Assert.True(_shopping.AddRequests[^1].Confirm);
}
}
@@ -16,4 +16,38 @@ internal sealed class FakeDashboardApi : IDashboardApi
Calls++;
return Task.FromResult(Offline ? ApiResult<DashboardDto>.Fail(ApiProblem.Offline) : ApiResult<DashboardDto>.Ok(Data));
}
public List<SaveDashboardNoteRequest> Created { get; } = [];
public List<(Guid Id, SaveDashboardNoteRequest Request)> Updated { get; } = [];
public List<Guid> DeletedNotes { get; } = [];
private List<DashboardNoteDto> Notes => [.. Data.Notes?.Items ?? []];
private void SetNotes(List<DashboardNoteDto> notes) =>
Data = Data with { Notes = new DashboardNotesDto(notes, Data.Notes?.CanEdit ?? false) };
public Task<ApiResult<DashboardNoteDto>> CreateNoteAsync(SaveDashboardNoteRequest request, CancellationToken ct = default)
{
Created.Add(request);
var note = new DashboardNoteDto(Guid.NewGuid(), request.Title, request.Text);
SetNotes([.. Notes, note]);
return Task.FromResult(ApiResult<DashboardNoteDto>.Ok(note));
}
public Task<ApiResult<DashboardNoteDto>> UpdateNoteAsync(Guid id, SaveDashboardNoteRequest request, CancellationToken ct = default)
{
Updated.Add((id, request));
var note = new DashboardNoteDto(id, request.Title, request.Text);
SetNotes(Notes.Select(n => n.Id == id ? note : n).ToList());
return Task.FromResult(ApiResult<DashboardNoteDto>.Ok(note));
}
public Task<ApiResult<bool>> DeleteNoteAsync(Guid id, CancellationToken ct = default)
{
DeletedNotes.Add(id);
SetNotes(Notes.Where(n => n.Id != id).ToList());
return Task.FromResult(ApiResult<bool>.Ok(true));
}
}
@@ -28,15 +28,20 @@ internal sealed class FakeShoppingApi : IShoppingApi
public int AddCalls { get; private set; }
/// <summary>Wie die Api bei Kindern: verdächtige Einträge werden ohne <c>Confirm</c> mit 409 abgelehnt.</summary>
public bool RejectSuspiciousWithoutConfirm { get; set; }
public List<AddShoppingItemRequest> AddRequests { get; } = [];
/// <summary>Ids, deren Löschung mit 403 abgelehnt wird.</summary>
public HashSet<Guid> ForbiddenDeletes { get; } = [];
private int _version;
public ShoppingItemDto Seed(string name, string category, bool done = false, Guid? createdBy = null, Guid? listId = null)
public ShoppingItemDto Seed(string name, string category, bool done = false, Guid? createdBy = null, Guid? listId = null, string? createdByName = null)
{
var item = new ShoppingItemDto(Guid.NewGuid(), listId ?? SupermarketId, name, null, category, done,
done ? DateTimeOffset.UtcNow : null, createdBy, DateTimeOffset.UtcNow.AddMinutes(-Items.Count), NextVersion());
done ? DateTimeOffset.UtcNow : null, createdBy, DateTimeOffset.UtcNow.AddMinutes(-Items.Count), NextVersion(), createdByName);
Items[item.Id] = item;
return item;
}
@@ -60,7 +65,21 @@ internal sealed class FakeShoppingApi : IShoppingApi
public Task<ApiResult<List<ShoppingItemDto>>> GetItemsAsync(Guid listId, CancellationToken ct = default) =>
Result(() => Items.Values.Where(i => i.ListId == listId).ToList());
public Task<ApiResult<ShoppingItemDto>> AddItemAsync(Guid listId, AddShoppingItemRequest request, CancellationToken ct = default) =>
public Task<ApiResult<ShoppingItemDto>> AddItemAsync(Guid listId, AddShoppingItemRequest request, CancellationToken ct = default)
{
if (!Offline)
{
AddRequests.Add(request);
if (RejectSuspiciousWithoutConfirm && !request.Confirm && ShoppingPlausibility.IsSuspicious(request.Text))
{
return Task.FromResult(ApiResult<ShoppingItemDto>.Fail(new ApiProblem { Status = 409, NeedsConfirmation = true, Detail = "Rückfrage nötig" }));
}
}
return AddItemCoreAsync(listId, request);
}
private Task<ApiResult<ShoppingItemDto>> AddItemCoreAsync(Guid listId, AddShoppingItemRequest request) =>
Result<ShoppingItemDto>(() =>
{
AddCalls++;
@@ -0,0 +1,5 @@
@* Testhülle: Dialog-Provider + Einkaufsseite, damit Rückfragen gerendert werden. *@
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
<FamilyPlanner.Web.Client.Pages.Shopping />
@@ -138,6 +138,78 @@ public sealed class ShoppingPageTests : BunitContext
Assert.Contains("Brot", cut.Find(".fp-item-name").TextContent);
}
[Fact]
public void Item_shows_who_added_it()
{
_api.Seed("Milch", ShoppingCategories.Dairy, createdBy: UserId, createdByName: "Mia");
_api.Seed("Brot", ShoppingCategories.Bakery);
SignInAs(Roles.Family);
var cut = Render<ShoppingPage>();
var hints = cut.FindAll(".fp-item-addedby").Select(h => h.TextContent.Trim()).ToList();
Assert.Equal(["hinzugefügt von Mia"], hints);
}
[Fact]
public void Child_is_asked_before_nonsense_is_added_and_can_decline()
{
SignInAs(Roles.Child);
var cut = Render<ShoppingHost>();
cut.Find("#shopping-input").Input("asdfgh");
cut.Find("form.fp-quick-add").Submit();
cut.WaitForAssertion(() => Assert.Contains("sieht nicht nach einem Artikel aus", cut.Markup));
Assert.Empty(_api.Items);
cut.FindAll("button").Single(b => b.TextContent.Trim() == "Nein").Click();
cut.WaitForAssertion(() => Assert.DoesNotContain("sieht nicht nach einem Artikel aus", cut.Markup));
Assert.Empty(_api.Items);
Assert.Equal("asdfgh", cut.Find("#shopping-input").GetAttribute("value"));
}
[Fact]
public void Child_can_confirm_nonsense_and_it_is_sent_with_confirm()
{
SignInAs(Roles.Child);
var cut = Render<ShoppingHost>();
cut.Find("#shopping-input").Input("asdfgh");
cut.Find("form.fp-quick-add").Submit();
cut.WaitForAssertion(() => Assert.Contains("sieht nicht nach einem Artikel aus", cut.Markup));
cut.FindAll("button").Single(b => b.TextContent.Trim() == "Ja, hinzufügen").Click();
cut.WaitForAssertion(() => Assert.Equal("Asdfgh", Assert.Single(_api.Items.Values).Name));
Assert.True(Assert.Single(_api.AddRequests).Confirm);
}
[Fact]
public void Child_is_not_asked_for_normal_items()
{
SignInAs(Roles.Child);
var cut = Render<ShoppingHost>();
cut.Find("#shopping-input").Input("Milch");
cut.Find("form.fp-quick-add").Submit();
cut.WaitForAssertion(() => Assert.Single(_api.Items));
Assert.DoesNotContain("sieht nicht nach einem Artikel aus", cut.Markup);
}
[Fact]
public void Parents_are_never_asked()
{
SignInAs(Roles.Family);
var cut = Render<ShoppingHost>();
cut.Find("#shopping-input").Input("asdfgh");
cut.Find("form.fp-quick-add").Submit();
cut.WaitForAssertion(() => Assert.Single(_api.Items));
Assert.DoesNotContain("sieht nicht nach einem Artikel aus", cut.Markup);
}
private sealed class NoopAuthService : IAuthService
{
public Task<CurrentUserDto?> GetCurrentUserAsync(CancellationToken cancellationToken = default) => Task.FromResult<CurrentUserDto?>(null);
@@ -182,4 +182,51 @@ public sealed class ShoppingStoreTests
Assert.Equal(ShoppingCategories.Other, _api.Items.Values.Single().Category);
Assert.Equal(ShoppingCategories.Other, store.Items.Single().Category);
}
[Fact]
public async Task Server_confirmation_request_is_asked_and_resent_with_confirm()
{
_api.RejectSuspiciousWithoutConfirm = true;
var store = CreateStore();
var asked = new List<string>();
store.ConfirmSuspicious = text =>
{
asked.Add(text);
return Task.FromResult(true);
};
await store.InitializeAsync();
await store.AddAsync("asdf", UserId);
Assert.Equal(["asdf"], asked);
Assert.Equal(["Asdf"], _api.Items.Values.Select(i => i.Name));
Assert.True(_api.AddRequests[^1].Confirm);
Assert.Equal(0, store.PendingCount);
}
[Fact]
public async Task Declined_confirmation_drops_the_entry()
{
_api.RejectSuspiciousWithoutConfirm = true;
var store = CreateStore();
store.ConfirmSuspicious = _ => Task.FromResult(false);
await store.InitializeAsync();
await store.AddAsync("asdf", UserId);
Assert.Empty(_api.Items);
Assert.Empty(store.Items);
Assert.Equal(0, store.PendingCount);
}
[Fact]
public async Task Entries_confirmed_up_front_are_sent_with_confirm()
{
var store = CreateStore();
await store.InitializeAsync();
await store.AddAsync("asdf", UserId, "Mia", confirmed: true);
Assert.True(Assert.Single(_api.AddRequests).Confirm);
}
}