using Bunit; using Duempelkas.App.Components; using Duempelkas.App.Components.Dialogs; using Duempelkas.App.Services; using Duempelkas.App.Services.Models; using Duempelkas.Domain.Enums; using Duempelkas.Infrastructure; using Duempelkas.Infrastructure.Persistence; using Duempelkas.Infrastructure.Services; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Xunit; namespace Duempelkas.Tests; public class BookingSaveTests { [Theory] [InlineData(false)] [InlineData(true)] public async Task RepeatedSubmission_StoresOnce_ThenAllowsDeliberateNewBooking(bool transfer) { using var fixture = new Fixture(); var page = fixture.Context.Render(); page.WaitForElement(".account-card").Click(); page.WaitForElement(".summary-section"); OpenAndFill(page, transfer); var delayed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); fixture.Entries.BeforeWrite = () => delayed.Task; var pending = page.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty); page.WaitForAssertion(() => Assert.Contains("Wird gespeichert", page.Markup)); Assert.True(page.Find("button[type=submit]").HasAttribute("disabled")); // Directly deliver repeated submit events, including ones a disabled UI would suppress. await page.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty); await page.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty); Assert.Equal(1, fixture.Entries.WriteCalls); delayed.SetResult(); await pending; page.WaitForAssertion(() => Assert.Contains("gespeichert", page.Find(".toast-success").TextContent)); Assert.Single(await fixture.Entries.GetEntriesAsync(1, (int?)null)); Assert.Single(page.FindAll("tr.entry-recent")); if (transfer) Assert.Single(await fixture.Entries.GetEntriesAsync(2, (int?)null)); fixture.Entries.BeforeWrite = () => Task.CompletedTask; OpenAndFill(page, transfer); await page.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty); page.WaitForAssertion(() => Assert.Empty(page.FindAll("form"))); Assert.Equal(2, (await fixture.Entries.GetEntriesAsync(1, (int?)null)).Count); } [Theory] [InlineData(false)] [InlineData(true)] public async Task Failure_PreservesInputs_AndRetryWorks(bool transfer) { using var fixture = new Fixture(); var page = fixture.Context.Render(); page.WaitForElement(".account-card").Click(); page.WaitForElement(".summary-section"); OpenAndFill(page, transfer); fixture.Entries.BeforeWrite = () => Task.FromException(new IOException("Simulierter Speicherfehler")); await page.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty); Assert.Contains("Eingaben bleiben erhalten", page.Markup); Assert.Empty(page.FindAll(".toast-success")); Assert.Equal("Testbuchung", page.Find(transfer ? "#transfer-title" : "#entry-title").GetAttribute("value")); Assert.Equal("12,50", page.Find(transfer ? "#transfer-amount" : "#entry-amount").GetAttribute("value")); Assert.Empty(await fixture.Entries.GetEntriesAsync(1, (int?)null)); fixture.Entries.BeforeWrite = () => Task.CompletedTask; await page.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty); page.WaitForAssertion(() => Assert.Contains("gespeichert", page.Markup)); Assert.Single(await fixture.Entries.GetEntriesAsync(1, (int?)null)); } [Fact] public async Task NotificationFailureAfterCommit_RetriesRefreshWithoutWritingAgain() { using var fixture = new Fixture(); var notifications = 0; var dialog = fixture.Context.Render(p => p .Add(x => x.AccountId, 1) .Add(x => x.OnSave, (BookingSaved _) => { if (++notifications == 1) throw new IOException("Refresh fehlgeschlagen"); })); dialog.Find("#entry-title").Input("Testbuchung"); dialog.Find("#entry-amount").Input("12,50"); await dialog.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty); Assert.Contains(fixture.Context.Services.GetRequiredService().Messages, t => t.Message.Contains("Die Buchung ist gespeichert")); await dialog.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty); Assert.Equal(1, fixture.Entries.WriteCalls); Assert.Equal(2, notifications); Assert.Single(await fixture.Entries.GetEntriesAsync(1, (int?)null)); } private static void OpenAndFill(IRenderedComponent page, bool transfer) { page.FindAll("button").Single(b => b.TextContent.Contains(transfer ? "Geld zwischen Konten" : "Einnahme oder Ausgabe")).Click(); if (transfer) page.Find("#transfer-account").Change("2"); page.Find(transfer ? "#transfer-title" : "#entry-title").Input("Testbuchung"); page.Find(transfer ? "#transfer-amount" : "#entry-amount").Input("12,50"); } private sealed class Fixture : IDisposable { public BunitContext Context { get; } = new(); public ControlledEntries Entries { get; } private readonly SqliteConnection anchor; public Fixture() { var connection = $"Data Source=save-ui-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; anchor = new SqliteConnection(connection); anchor.Open(); Context.Services.AddInfrastructure(connection); Context.Services.AddSingleton(sp => new ControlledEntries(new EntryService(sp.GetRequiredService>()))); Context.JSInterop.Mode = JSRuntimeMode.Loose; using var db = Context.Services.GetRequiredService>().CreateDbContext(); db.Database.EnsureCreated(); db.Accounts.AddRange(new() { Name = "A-Testkasse" }, new() { Name = "B-Testbank" }); db.SaveChanges(); Entries = (ControlledEntries)Context.Services.GetRequiredService(); } public void Dispose() { Context.Dispose(); anchor.Dispose(); } } private sealed class ControlledEntries(IEntryService inner) : IEntryService { public int WriteCalls; public Func BeforeWrite = () => Task.CompletedTask; public async Task CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount) { WriteCalls++; await BeforeWrite(); return await inner.CreateEntryAsync(accountId, type, date, title, amount); } public async Task CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount) { WriteCalls++; await BeforeWrite(); return await inner.CreateTransferAsync(sourceAccountId, targetAccountId, date, title, amount); } public Task> GetEntriesAsync(int accountId, int? year) => inner.GetEntriesAsync(accountId, year); public Task> GetEntriesAsync(int accountId, bool currentYearOnly) => inner.GetEntriesAsync(accountId, currentYearOnly); public Task> GetEntryYearsAsync(int accountId) => inner.GetEntryYearsAsync(accountId); public Task> GetAllEntryYearsAsync() => inner.GetAllEntryYearsAsync(); public Task DeleteEntryAsync(int entryId) => inner.DeleteEntryAsync(entryId); public Task RestoreEntryAsync(int entryId) => inner.RestoreEntryAsync(entryId); public Task UpdateEntryAsync(int entryId, DateTime date, string title, decimal amount) => inner.UpdateEntryAsync(entryId, date, title, amount); public Task UpdateTransferAsync(int entryId, int newLinkedAccountId, DateTime date, string title, decimal amount) => inner.UpdateTransferAsync(entryId, newLinkedAccountId, date, title, amount); } }