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>
98 lines
4.7 KiB
C#
98 lines
4.7 KiB
C#
using System.Text;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NSubstitute;
|
|
using Schaad.Accounting.Interfaces;
|
|
using Schaad.Accounting.Models;
|
|
using Schaad.Accounting.Services;
|
|
using Schaad.Finance.Api;
|
|
using Shouldly;
|
|
|
|
namespace Schaad.Accounting.Tests;
|
|
|
|
public class FileServiceTestShould
|
|
{
|
|
private readonly ISettingsService settingsService = Substitute.For<ISettingsService>();
|
|
private readonly IAccountRepository accountRepo = Substitute.For<IAccountRepository>();
|
|
private readonly ITransactionRepository transactionRepo = Substitute.For<ITransactionRepository>();
|
|
private readonly IBankTransactionRepository bankTransactionRepo = Substitute.For<IBankTransactionRepository>();
|
|
private readonly IAccountStatementService accountStatementService = Substitute.For<IAccountStatementService>();
|
|
private readonly ICreditCardStatementService creditCardStatementService = Substitute.For<ICreditCardStatementService>();
|
|
|
|
public FileServiceTestShould()
|
|
{
|
|
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
|
}
|
|
|
|
private FileService BuildService() => new(
|
|
settingsService, accountRepo, transactionRepo, bankTransactionRepo,
|
|
accountStatementService, creditCardStatementService, NullLogger<FileService>.Instance);
|
|
|
|
[Fact]
|
|
public async Task EmitHeaderRowAndRunningBalanceWhenExportingTransactionsCsv()
|
|
{
|
|
var checking = new Account { Id = "chk", Number = 1010, Name = "Checking", Currency = "CHF", StartBalance = 1000m };
|
|
accountRepo.GetAccountAsync("chk").Returns(checking);
|
|
transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>
|
|
{
|
|
new()
|
|
{
|
|
Id = "t1", OriginAccountId = "chk", TargetAccountId = "groc", Value = 50m, Text = "Coop",
|
|
BookingDate = new DateTime(2026, 1, 5), ValueDate = new DateTime(2026, 1, 5)
|
|
},
|
|
new()
|
|
{
|
|
Id = "t2", OriginAccountId = "sal", TargetAccountId = "chk", Value = 5000m, Text = "Salary",
|
|
BookingDate = new DateTime(2026, 1, 25), ValueDate = new DateTime(2026, 1, 25)
|
|
}
|
|
});
|
|
|
|
var bytes = await BuildService().GetTransactionListCsvAsync("chk");
|
|
var csv = Encoding.GetEncoding("ISO-8859-1").GetString(bytes);
|
|
var lines = csv.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
|
|
|
|
lines[0].ShouldBe("Buchungsdatum;Valuta;Buchungstext;Belastung;Gutschrift;Saldo CHF");
|
|
lines[1].ShouldBe(";;Startsaldo;;;1000");
|
|
lines[2].ShouldBe("05.01.2026;05.01.2026;Coop;50.00;;950.00");
|
|
lines[3].ShouldBe("25.01.2026;25.01.2026;Salary;;5'000.00;5'950.00");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OrderTransactionsByBookingDateThenValueDateThenValueWhenExporting()
|
|
{
|
|
var checking = new Account { Id = "chk", Number = 1010, Name = "Checking", Currency = "CHF", StartBalance = 0m };
|
|
accountRepo.GetAccountAsync("chk").Returns(checking);
|
|
transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>
|
|
{
|
|
new() { OriginAccountId = "chk", TargetAccountId = "x", Value = 10m, Text = "later",
|
|
BookingDate = new DateTime(2026, 2, 1), ValueDate = new DateTime(2026, 2, 1) },
|
|
new() { OriginAccountId = "chk", TargetAccountId = "x", Value = 5m, Text = "earlier small",
|
|
BookingDate = new DateTime(2026, 1, 15), ValueDate = new DateTime(2026, 1, 15) },
|
|
new() { OriginAccountId = "chk", TargetAccountId = "x", Value = 20m, Text = "earlier large",
|
|
BookingDate = new DateTime(2026, 1, 15), ValueDate = new DateTime(2026, 1, 15) }
|
|
});
|
|
|
|
var csv = Encoding.GetEncoding("ISO-8859-1").GetString(await BuildService().GetTransactionListCsvAsync("chk"));
|
|
var lines = csv.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
|
|
|
|
lines[2].ShouldContain("earlier small");
|
|
lines[3].ShouldContain("earlier large");
|
|
lines[4].ShouldContain("later");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NotMutateTransactionValueWhenExportingCsv()
|
|
{
|
|
var checking = new Account { Id = "chk", Number = 1010, Name = "Checking", Currency = "CHF" };
|
|
accountRepo.GetAccountAsync("chk").Returns(checking);
|
|
var outgoing = new Transaction
|
|
{
|
|
OriginAccountId = "chk", TargetAccountId = "x", Value = 42m, Text = "test",
|
|
BookingDate = new DateTime(2026, 1, 1), ValueDate = new DateTime(2026, 1, 1)
|
|
};
|
|
transactionRepo.GetTransactionListAsync().Returns(new List<Transaction> { outgoing });
|
|
|
|
await BuildService().GetTransactionListCsvAsync("chk");
|
|
|
|
outgoing.Value.ShouldBe(42m);
|
|
}
|
|
}
|