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>
211 lines
9.2 KiB
C#
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
|
|
};
|
|
}
|