AccountingNext/Schaad.Accounting.Tests/TransactionRepositoryTestShould.cs
Claudio Schaad f9084dc074 PR P: full async I/O top to bottom (item 19)
Every repository, service, Blazor page/dialog, and test now uses
async/await. Single atomic diff; the codebase does not compile in
intermediate states.

- BaseRepository: LoadListAsync/LoadAsync/SaveAsync return Task<T>;
  per-file locks use SemaphoreSlim so waiters can await; Save
  serialises to a MemoryStream sync (XmlSerializer has no async
  form), then File.WriteAllBytesAsync + sync File.Move.
- RepositoryCache.GetOrLoadAsync takes a Func<Task<List<T>>>.
- All 7 repository interfaces + implementations async.
- All service interfaces + implementations async (except vendor
  IFxService and stateless IFxConverter / SettingsService).
- Every Blazor OnInitializedAsync switches to await base.
- Test suite fully async, 42 tests pass.

AccountRepository.EnsureAccountsFile keeps two .GetAwaiter().GetResult()
bridges because it runs from the constructor.

Null-render guard follow-up (folded in):
Blazor now renders the component once with fields at their initial
values while OnInitializedAsync awaits — so fields declared `= null!`
are actually null on that first render and things like
`accounts.GroupBy(...)` throw ArgumentNullException. Fixed across
Transactions, BalanceReport, BalanceSheetReport, ProfitLossReport,
DetailReport, Assets, Spendings, SpendingsOverTime, TransactionDialog,
TransactionSplitDialog, and BookingRuleDialog:
- Collection fields initialise to [] so first-render loops are empty.
- Single-object data fields become nullable; the razor wraps
  consumption in `@if (field is null) { <p>Lädt…</p> return; }`.
- <PlotlyChart> guarded behind a null check on config/layout/data so
  Plotly.Blazor's @bind doesn't see nulls.
- Header/footer strings initialise to "" instead of null!.

Architectural hygiene on a single-user local Blazor Server app: the
observed win is one File.ReadAllBytesAsync and one
File.WriteAllBytesAsync per Load/Save, and after PR D each file is
loaded at most once per SignalR circuit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-03 18:30:44 +02:00

173 lines
5.8 KiB
C#

using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
using Schaad.Accounting.Repositories;
using Shouldly;
namespace Schaad.Accounting.Tests;
public class TransactionRepositoryTestShould : IDisposable
{
private readonly string dbDir;
private readonly ISettingsService settingsService;
private readonly IAccountRepository accountRepo;
private readonly RepositoryCache cache;
private readonly TransactionRepository sut;
public TransactionRepositoryTestShould()
{
dbDir = Path.Combine(Path.GetTempPath(), "AccountingNext-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dbDir);
settingsService = Substitute.For<ISettingsService>();
settingsService.GetDbPath().Returns(dbDir);
settingsService.GetLastYearDbPath().Returns(Path.Combine(dbDir, "no-such-dir"));
accountRepo = Substitute.For<IAccountRepository>();
cache = new RepositoryCache();
sut = new TransactionRepository(settingsService, cache, accountRepo, NullLogger<TransactionRepository>.Instance);
}
public void Dispose()
{
if (Directory.Exists(dbDir))
{
Directory.Delete(dbDir, recursive: true);
}
}
[Fact]
public async Task PreserveValueWhenRoundTrippingNonFxTransaction()
{
accountRepo.GetAccountAsync("chk").Returns(new Account { Id = "chk", Currency = "CHF" });
accountRepo.GetAccountAsync("groc").Returns(new Account { Id = "groc", Currency = "CHF" });
await sut.SaveTransactionAsync(new Transaction
{
OriginAccountId = "chk",
TargetAccountId = "groc",
Value = 42m,
Text = "Groceries",
ValueDate = new DateTime(2026, 5, 10),
BookingDate = new DateTime(2026, 5, 10)
});
var stored = (await sut.GetTransactionListAsync()).Single();
var loaded = await sut.GetTransactionAsync(stored.Id);
loaded.ShouldNotBeNull();
loaded.Value.ShouldBe(42m);
loaded.FxRate.ShouldBeNull();
}
[Fact]
public async Task StoreInChfAndReturnInAccountCurrencyWhenTransactionIsFx()
{
accountRepo.GetAccountAsync("chk").Returns(new Account { Id = "chk", Currency = "CHF" });
accountRepo.GetAccountAsync("usd").Returns(new Account { Id = "usd", Currency = "USD" });
await sut.SaveTransactionAsync(new Transaction
{
OriginAccountId = "chk",
TargetAccountId = "usd",
Value = 100m,
FxRate = 0.9m,
Text = "USD transfer",
ValueDate = new DateTime(2026, 5, 10),
BookingDate = new DateTime(2026, 5, 10)
});
var stored = (await sut.GetTransactionListAsync()).Single();
stored.Value.ShouldBe(90m);
var loaded = await sut.GetTransactionAsync(stored.Id);
loaded.ShouldNotBeNull();
loaded.Value.ShouldBe(100m);
loaded.FxRate.ShouldBe(0.9m);
}
[Fact]
public async Task ReturnNullWhenGettingUnknownTransactionId()
{
(await sut.GetTransactionAsync("no-such-id")).ShouldBeNull();
}
[Fact]
public async Task NotMutateStoredValueWhenGettingFxTransactionTwice()
{
accountRepo.GetAccountAsync("chk").Returns(new Account { Id = "chk", Currency = "CHF" });
accountRepo.GetAccountAsync("usd").Returns(new Account { Id = "usd", Currency = "USD" });
await sut.SaveTransactionAsync(new Transaction
{
OriginAccountId = "chk",
TargetAccountId = "usd",
Value = 100m,
FxRate = 0.9m,
Text = "USD transfer",
ValueDate = new DateTime(2026, 5, 10),
BookingDate = new DateTime(2026, 5, 10)
});
var stored = (await sut.GetTransactionListAsync()).Single();
var id = stored.Id;
var first = await sut.GetTransactionAsync(id);
var second = await sut.GetTransactionAsync(id);
first.ShouldNotBeNull();
first.Value.ShouldBe(100m);
second.ShouldNotBeNull();
second.Value.ShouldBe(100m);
}
[Fact]
public async Task RemoveTransactionWhenDeletingById()
{
accountRepo.GetAccountAsync(Arg.Any<string>()).Returns(new Account { Currency = "CHF" });
await sut.SaveTransactionAsync(new Transaction { OriginAccountId = "a", TargetAccountId = "b", Value = 1m, Text = "x", ValueDate = DateTime.Today });
var id = (await sut.GetTransactionListAsync()).Single().Id;
await sut.DeleteTransactionAsync(id);
(await sut.GetTransactionListAsync()).ShouldBeEmpty();
}
[Fact]
public async Task PreserveRelatedPartyWhenRoundTrippingTransaction()
{
accountRepo.GetAccountAsync(Arg.Any<string>()).Returns(new Account { Currency = "CHF" });
await sut.SaveTransactionAsync(new Transaction
{
OriginAccountId = "a",
TargetAccountId = "b",
Value = 10m,
Text = "Rent",
RelatedParty = "ACME Property AG",
ValueDate = new DateTime(2026, 4, 1),
BookingDate = new DateTime(2026, 4, 1)
});
(await sut.GetTransactionListAsync()).Single().RelatedParty.ShouldBe("ACME Property AG");
}
[Fact]
public async Task DefaultBookingDateToValueDateWhenBookingDateIsUnset()
{
accountRepo.GetAccountAsync(Arg.Any<string>()).Returns(new Account { Currency = "CHF" });
await sut.SaveTransactionAsync(new Transaction
{
OriginAccountId = "a",
TargetAccountId = "b",
Value = 10m,
Text = "x",
ValueDate = new DateTime(2026, 4, 15)
});
(await sut.GetTransactionListAsync()).Single().BookingDate.ShouldBe(new DateTime(2026, 4, 15));
}
}