Files
duempelkas/tests/Duempelkas.Tests/BookingSaveTests.cs
T

144 lines
7.9 KiB
C#

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<Routes>();
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<Routes>();
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<AddEntryDialog>(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<ToastService>().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<Routes> 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<IEntryService>(sp => new ControlledEntries(new EntryService(sp.GetRequiredService<IDbContextFactory<FinanceDbContext>>())));
Context.JSInterop.Mode = JSRuntimeMode.Loose;
using var db = Context.Services.GetRequiredService<IDbContextFactory<FinanceDbContext>>().CreateDbContext();
db.Database.EnsureCreated();
db.Accounts.AddRange(new() { Name = "A-Testkasse" }, new() { Name = "B-Testbank" });
db.SaveChanges();
Entries = (ControlledEntries)Context.Services.GetRequiredService<IEntryService>();
}
public void Dispose() { Context.Dispose(); anchor.Dispose(); }
}
private sealed class ControlledEntries(IEntryService inner) : IEntryService
{
public int WriteCalls;
public Func<Task> BeforeWrite = () => Task.CompletedTask;
public async Task<EntryDto> 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<int> 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<List<EntryDto>> GetEntriesAsync(int accountId, int? year) => inner.GetEntriesAsync(accountId, year);
public Task<List<EntryDto>> GetEntriesAsync(int accountId, bool currentYearOnly) => inner.GetEntriesAsync(accountId, currentYearOnly);
public Task<List<int>> GetEntryYearsAsync(int accountId) => inner.GetEntryYearsAsync(accountId);
public Task<List<int>> 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);
}
}