AccountingNext/Schaad.Accounting.Tests/ViewServiceTestShould.cs
Claudio Schaad 8926fb1e97 PR G: test project with xxxTestShould / DoThisWhenThat / Shouldly
Add Schaad.Accounting.Tests (xUnit + NSubstitute + Shouldly, net9.0)
wired into Accounting.sln with a direct Reference to
Schaad.Finance.Api.dll so it flows into the test binary.

Naming convention: file/class is <Subject>TestShould, each test method
is DoThisWhenThat. Reads as a sentence:
"ViewService test should sum activa and passiva totals separately when
getting balance view". All assertions use Shouldly (.ShouldBe,
.ShouldBeNull, .ShouldContain, ...) rather than xUnit Assert.*.

38 tests across seven files:

- ViewServiceTestShould: balance math (activa/passiva totals, per-
  account balance from start balance + credits - debits, FX conversion
  to CHF) and bank-transaction auto-matching (booking-rule text,
  value-matching preference, same-accounts-last-month fallback,
  open-transaction filter).
- TransactionRepositoryTestShould: FX round-trip against a temp XML
  directory, unknown-id -> null, and the mutation-on-read regression
  from PR A (a second Get on the same FX transaction used to divide
  by FxRate again).
- FormattingTestShould: Swiss thousands separator, two-decimal
  rounding, culture independence.
- RepositoryCacheTestShould: loader called once, per-key isolation,
  invalidation forces reload, case-insensitive keys.
- AccountRepositoryTestShould: id assignment, in-place update,
  currency defaulting, delete, bank-account lookup, bank-balance
  update, and constructor re-run against an existing file.
- ChartServiceTestShould: honours settingsService.GetYear() without
  mutating it (locks in PR E), account vs sub-class grouping
  heuristic, skips empty accounts.
- FileServiceTestShould: CSV header + running balance for debit and
  credit lines, ordering by BookingDate/ValueDate/Value, no in-place
  value mutation (locks in PR A).

CLAUDE.md gains a `dotnet test` line. IMPROVEMENT_PLAN.md notes that
this landed as three PRs (G, H, I) and was squashed on request.

Also add `*.DotSettings.user` to .gitignore so Rider's per-user
solution settings don't get accidentally staged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 21:25:48 +02:00

211 lines
9.2 KiB
C#

using NSubstitute;
using Schaad.Accounting.Datasets;
using Schaad.Accounting.Interfaces;
using Schaad.Accounting.Models;
using Schaad.Accounting.Services;
using Schaad.Finance.Api;
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 IFxService fxService = Substitute.For<IFxService>();
private readonly ISettingsService settingsService = Substitute.For<ISettingsService>();
public ViewServiceTestShould()
{
// Cover every subclass number a test might reference (Account.SubClass = Number / 100).
subclassRepo.GetSubClassList().Returns(
Enumerable.Range(10, 50).Select(n => new SubClass { Number = n, Name = "sub-" + n }).ToList());
subclassRepo.GetClass(Arg.Any<int>()).Returns("");
settingsService.GetSettings().Returns(new SettingsDataset { DataPath = "", FixerIoApiKey = "" });
// Passthrough FX by default (CHF-only). Individual tests can override.
fxService.ConvertCurrency(Arg.Any<decimal>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.Returns(ci => ci.ArgAt<decimal>(0));
}
private ViewService BuildService() =>
new(accountRepo, bankTransactionRepo, transactionRepo, subclassRepo, bookingRuleRepo, fxService, settingsService);
// --- Balance math ---------------------------------------------------------
[Fact]
public void 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.GetAccountList().Returns(new List<Account> { cash, receivables, payables });
transactionRepo.GetTransactionList().Returns(new List<Transaction>());
var view = BuildService().GetBalanceView();
view.ActivaAccountList.Count.ShouldBe(2);
view.PassivaAccountList.Count.ShouldBe(1);
view.TotalActivaCHF.ShouldBe(150m);
view.TotalPassivaCHF.ShouldBe(200m);
}
[Fact]
public void ApplyTransactionsToStartBalanceWhenComputingAccountBalance()
{
var cash = Account("cash", 1000, "CHF", startBalance: 1000m);
var groceries = Account("groc", 4000, "CHF");
accountRepo.GetAccountList().Returns(new List<Account> { cash, groceries });
// Debit cash 50, credit groceries 50 (spending)
transactionRepo.GetTransactionList().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 = BuildService().GetAccountViewList();
accounts.Single(a => a.Id == "cash").Balance.ShouldBe(920m); // 1000 - 80
accounts.Single(a => a.Id == "groc").Balance.ShouldBe(80m);
}
[Fact]
public void ConvertBalanceToChfWhenAccountUsesForeignCurrency()
{
var usd = Account("usd", 1050, "USD", startBalance: 100m);
accountRepo.GetAccountList().Returns(new List<Account> { usd });
transactionRepo.GetTransactionList().Returns(new List<Transaction>());
// 1 USD = 0.90 CHF
fxService.ConvertCurrency(Arg.Any<decimal>(), "USD", "CHF", Arg.Any<string>())
.Returns(ci => ci.ArgAt<decimal>(0) * 0.9m);
var account = BuildService().GetAccountViewList().Single();
account.Balance.ShouldBe(100m); // account currency
account.BalanceCHF.ShouldBe(90m); // FX-converted
account.StartBalanceCHF.ShouldBe(90m);
}
// --- Bank-transaction auto-matching --------------------------------------
[Fact]
public void MatchBankTransactionByBookingRuleWhenLookupTextIsContained()
{
var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01");
var utilities = Account("util", 4200, "CHF");
accountRepo.GetAccountList().Returns(new List<Account> { checking, utilities });
bookingRuleRepo.GetBookingRuleList().Returns(new List<BookingRule>
{
new() { Id = "r1", LookupText = "electric", AccountId = "util", BookingText = "Strom" }
});
bankTransactionRepo.GetBankTransactionList().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.GetTransactionList().Returns(new List<Transaction>());
var matched = BuildService().MatchOpenBankTransactions().Single();
matched.Text.ShouldBe("Strom");
// negative value => bank account is origin; rule fills the target
matched.OriginAccountId.ShouldBe("chk");
matched.TargetAccountId.ShouldBe("util");
}
[Fact]
public void PreferBookingRuleWithMatchingValueWhenMultipleRulesMatchText()
{
var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01");
var rent = Account("rent", 4100, "CHF");
var groceries = Account("groc", 4200, "CHF");
accountRepo.GetAccountList().Returns(new List<Account> { checking, rent, groceries });
bookingRuleRepo.GetBookingRuleList().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.GetBankTransactionList().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.GetTransactionList().Returns(new List<Transaction>());
var matched = BuildService().MatchOpenBankTransactions().Single();
matched.Text.ShouldBe("Rent via Coop");
matched.TargetAccountId.ShouldBe("rent");
}
[Fact]
public void FallBackToSameAccountsLastMonthWhenNoBookingRuleMatches()
{
var checking = Account("chk", 1010, "CHF", bankAccountNumber: "CH01");
var salary = Account("sal", 3000, "CHF");
accountRepo.GetAccountList().Returns(new List<Account> { checking, salary });
bookingRuleRepo.GetBookingRuleList().Returns(new List<BookingRule>());
bankTransactionRepo.GetBankTransactionList().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) }
});
// A prior-month booking with same accounts + value should be picked up
transactionRepo.GetTransactionList().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 = BuildService().MatchOpenBankTransactions().Single();
matched.Text.ShouldBe("Monatslohn");
}
[Fact]
public void ExcludeIgnoredAndAlreadyBookedWhenGettingOpenBankTransactions()
{
bankTransactionRepo.GetBankTransactionList().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.GetTransactionList().Returns(new List<Transaction>
{
new() { Id = "t1", BankTransactionId = "booked", OriginAccountId = "a", TargetAccountId = "b", Value = 10 }
});
var open = BuildService().GetOpenBankTransactionList();
open.Count.ShouldBe(1);
open[0].Id.ShouldBe("open");
}
// --- Helpers -------------------------------------------------------------
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
};
}