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>
173 lines
5.8 KiB
C#
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));
|
|
}
|
|
}
|