144 lines
7.9 KiB
C#
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);
|
|
}
|
|
}
|