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>
200 lines
8.7 KiB
C#
200 lines
8.7 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NSubstitute;
|
|
using Schaad.Accounting.Interfaces;
|
|
using Schaad.Accounting.Models;
|
|
using Schaad.Accounting.Services;
|
|
using Shouldly;
|
|
|
|
namespace Schaad.Accounting.Tests;
|
|
|
|
public class ViewServiceTestShould
|
|
{
|
|
private readonly IAccountRepository accountRepo = Substitute.For<IAccountRepository>();
|
|
private readonly ITransactionRepository transactionRepo = Substitute.For<ITransactionRepository>();
|
|
private readonly IBankTransactionRepository bankTransactionRepo = Substitute.For<IBankTransactionRepository>();
|
|
private readonly IBookingRuleRepository bookingRuleRepo = Substitute.For<IBookingRuleRepository>();
|
|
private readonly ISubclassRepository subclassRepo = Substitute.For<ISubclassRepository>();
|
|
private readonly IFxConverter fxConverter = Substitute.For<IFxConverter>();
|
|
|
|
public ViewServiceTestShould()
|
|
{
|
|
subclassRepo.GetSubClassListAsync().Returns(
|
|
Enumerable.Range(10, 50).Select(n => new SubClass { Number = n, Name = "sub-" + n }).ToList());
|
|
subclassRepo.GetClass(Arg.Any<int>()).Returns("");
|
|
|
|
fxConverter.ConvertToChf(Arg.Any<decimal>(), Arg.Any<string>())
|
|
.Returns(ci => ci.ArgAt<decimal>(0));
|
|
}
|
|
|
|
private ViewService BuildService() =>
|
|
new(accountRepo, bankTransactionRepo, transactionRepo, subclassRepo, bookingRuleRepo, fxConverter, NullLogger<ViewService>.Instance);
|
|
|
|
// --- Balance math ---------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task SumActivaAndPassivaTotalsSeparatelyWhenGettingBalanceView()
|
|
{
|
|
var cash = Account("cash", 1000, "CHF", startBalance: 100m);
|
|
var receivables = Account("recv", 1100, "CHF", startBalance: 50m);
|
|
var payables = Account("pay", 2000, "CHF", startBalance: -200m);
|
|
|
|
accountRepo.GetAccountListAsync().Returns(new List<Account> { cash, receivables, payables });
|
|
transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>());
|
|
|
|
var view = await BuildService().GetBalanceViewAsync();
|
|
|
|
view.ActivaAccountList.Count.ShouldBe(2);
|
|
view.PassivaAccountList.Count.ShouldBe(1);
|
|
view.TotalActivaCHF.ShouldBe(150m);
|
|
view.TotalPassivaCHF.ShouldBe(200m);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApplyTransactionsToStartBalanceWhenComputingAccountBalance()
|
|
{
|
|
var cash = Account("cash", 1000, "CHF", startBalance: 1000m);
|
|
var groceries = Account("groc", 4000, "CHF");
|
|
accountRepo.GetAccountListAsync().Returns(new List<Account> { cash, groceries });
|
|
|
|
transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>
|
|
{
|
|
new() { Id = "t1", OriginAccountId = "cash", TargetAccountId = "groc", Value = 50m, ValueDate = new DateTime(2026, 1, 1) },
|
|
new() { Id = "t2", OriginAccountId = "cash", TargetAccountId = "groc", Value = 30m, ValueDate = new DateTime(2026, 1, 2) },
|
|
});
|
|
|
|
var accounts = await BuildService().GetAccountViewListAsync();
|
|
|
|
accounts.Single(a => a.Id == "cash").Balance.ShouldBe(920m);
|
|
accounts.Single(a => a.Id == "groc").Balance.ShouldBe(80m);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ConvertBalanceToChfWhenAccountUsesForeignCurrency()
|
|
{
|
|
var usd = Account("usd", 1050, "USD", startBalance: 100m);
|
|
accountRepo.GetAccountListAsync().Returns(new List<Account> { usd });
|
|
transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>());
|
|
|
|
fxConverter.ConvertToChf(Arg.Any<decimal>(), "USD")
|
|
.Returns(ci => ci.ArgAt<decimal>(0) * 0.9m);
|
|
|
|
var account = (await BuildService().GetAccountViewListAsync()).Single();
|
|
|
|
account.Balance.ShouldBe(100m);
|
|
account.BalanceCHF.ShouldBe(90m);
|
|
account.StartBalanceCHF.ShouldBe(90m);
|
|
}
|
|
|
|
// --- Bank-transaction auto-matching --------------------------------------
|
|
|
|
[Fact]
|
|
public async Task MatchBankTransactionByBookingRuleWhenLookupTextIsContained()
|
|
{
|
|
var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01");
|
|
var utilities = Account("util", 4200, "CHF");
|
|
accountRepo.GetAccountListAsync().Returns(new List<Account> { checking, utilities });
|
|
bookingRuleRepo.GetBookingRuleListAsync().Returns(new List<BookingRule>
|
|
{
|
|
new() { Id = "r1", LookupText = "electric", AccountId = "util", BookingText = "Strom" }
|
|
});
|
|
bankTransactionRepo.GetBankTransactionListAsync().Returns(new List<BankTransaction>
|
|
{
|
|
new() { Id = "b1", Text = "Monthly Electric Bill", Value = -85m, BankAccountNumber = "CH01",
|
|
ValueDate = new DateTime(2026, 3, 5), BookingDate = new DateTime(2026, 3, 5) }
|
|
});
|
|
transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>());
|
|
|
|
var matched = (await BuildService().MatchOpenBankTransactionsAsync()).Single();
|
|
|
|
matched.Text.ShouldBe("Strom");
|
|
matched.OriginAccountId.ShouldBe("chk");
|
|
matched.TargetAccountId.ShouldBe("util");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreferBookingRuleWithMatchingValueWhenMultipleRulesMatchText()
|
|
{
|
|
var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01");
|
|
var rent = Account("rent", 4100, "CHF");
|
|
var groceries = Account("groc", 4200, "CHF");
|
|
accountRepo.GetAccountListAsync().Returns(new List<Account> { checking, rent, groceries });
|
|
bookingRuleRepo.GetBookingRuleListAsync().Returns(new List<BookingRule>
|
|
{
|
|
new() { Id = "r1", LookupText = "coop", LookupValue = 0m, AccountId = "groc", BookingText = "Groceries" },
|
|
new() { Id = "r2", LookupText = "coop", LookupValue = 1200m, AccountId = "rent", BookingText = "Rent via Coop" }
|
|
});
|
|
bankTransactionRepo.GetBankTransactionListAsync().Returns(new List<BankTransaction>
|
|
{
|
|
new() { Id = "b1", Text = "COOP payment", Value = -1200m, BankAccountNumber = "CH01",
|
|
ValueDate = new DateTime(2026, 3, 1), BookingDate = new DateTime(2026, 3, 1) }
|
|
});
|
|
transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>());
|
|
|
|
var matched = (await BuildService().MatchOpenBankTransactionsAsync()).Single();
|
|
|
|
matched.Text.ShouldBe("Rent via Coop");
|
|
matched.TargetAccountId.ShouldBe("rent");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FallBackToSameAccountsLastMonthWhenNoBookingRuleMatches()
|
|
{
|
|
var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01");
|
|
var salary = Account("sal", 3000, "CHF");
|
|
accountRepo.GetAccountListAsync().Returns(new List<Account> { checking, salary });
|
|
bookingRuleRepo.GetBookingRuleListAsync().Returns(new List<BookingRule>());
|
|
bankTransactionRepo.GetBankTransactionListAsync().Returns(new List<BankTransaction>
|
|
{
|
|
new() { Id = "b2", Text = "Salary Feb", Value = 5000m, BankAccountNumber = "CH01",
|
|
ValueDate = new DateTime(2026, 2, 25), BookingDate = new DateTime(2026, 2, 25) }
|
|
});
|
|
transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>
|
|
{
|
|
new()
|
|
{
|
|
Id = "t1",
|
|
OriginAccountId = "sal",
|
|
TargetAccountId = "chk",
|
|
Value = 5000m,
|
|
Text = "Monatslohn",
|
|
ValueDate = new DateTime(2026, 1, 25),
|
|
BookingDate = new DateTime(2026, 1, 25)
|
|
}
|
|
});
|
|
|
|
var matched = (await BuildService().MatchOpenBankTransactionsAsync()).Single();
|
|
|
|
matched.Text.ShouldBe("Monatslohn");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExcludeIgnoredAndAlreadyBookedWhenGettingOpenBankTransactions()
|
|
{
|
|
bankTransactionRepo.GetBankTransactionListAsync().Returns(new List<BankTransaction>
|
|
{
|
|
new() { Id = "open", Text = "still open", Ignore = false },
|
|
new() { Id = "ignored", Text = "ignore me", Ignore = true },
|
|
new() { Id = "booked", Text = "already booked", Ignore = false }
|
|
});
|
|
transactionRepo.GetTransactionListAsync().Returns(new List<Transaction>
|
|
{
|
|
new() { Id = "t1", BankTransactionId = "booked", OriginAccountId = "a", TargetAccountId = "b", Value = 10 }
|
|
});
|
|
|
|
var open = await BuildService().GetOpenBankTransactionListAsync();
|
|
|
|
open.Count.ShouldBe(1);
|
|
open[0].Id.ShouldBe("open");
|
|
}
|
|
|
|
private static Account Account(string id, int number, string currency, decimal startBalance = 0m, string? bankAccountNumber = null)
|
|
=> new()
|
|
{
|
|
Id = id,
|
|
Number = number,
|
|
Name = id,
|
|
Currency = currency,
|
|
StartBalance = startBalance,
|
|
BankAccountNumber = bankAccountNumber
|
|
};
|
|
}
|