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>
106 lines
5.2 KiB
C#
106 lines
5.2 KiB
C#
using System.Text;
|
|
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()
|
|
{
|
|
// Registering the ISO-8859-1 code page provider so the CSV encoding call works
|
|
// in the test host (which does not include legacy encodings by default).
|
|
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
|
}
|
|
|
|
private FileService BuildService() => new(
|
|
settingsService, accountRepo, transactionRepo, bankTransactionRepo,
|
|
accountStatementService, creditCardStatementService);
|
|
|
|
[Fact]
|
|
public void EmitHeaderRowAndRunningBalanceWhenExportingTransactionsCsv()
|
|
{
|
|
var checking = new Account { Id = "chk", Number = 1010, Name = "Checking", Currency = "CHF", StartBalance = 1000m };
|
|
var groceries = new Account { Id = "groc", Number = 4200, Name = "Groceries", Currency = "CHF" };
|
|
var salary = new Account { Id = "sal", Number = 3000, Name = "Salary", Currency = "CHF" };
|
|
accountRepo.GetAccount("chk").Returns(checking);
|
|
transactionRepo.GetTransactionList().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 = BuildService().GetTransactionListCsv("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");
|
|
// Debit line (outgoing) — value in Belastung column, balance drops
|
|
lines[2].ShouldBe("05.01.2026;05.01.2026;Coop;50.00;;950.00");
|
|
// Credit line (incoming) — value in Gutschrift column, balance rises
|
|
lines[3].ShouldBe("25.01.2026;25.01.2026;Salary;;5'000.00;5'950.00");
|
|
}
|
|
|
|
[Fact]
|
|
public void OrderTransactionsByBookingDateThenValueDateThenValueWhenExporting()
|
|
{
|
|
var checking = new Account { Id = "chk", Number = 1010, Name = "Checking", Currency = "CHF", StartBalance = 0m };
|
|
accountRepo.GetAccount("chk").Returns(checking);
|
|
transactionRepo.GetTransactionList().Returns(new List<Transaction>
|
|
{
|
|
// Deliberately unordered
|
|
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(BuildService().GetTransactionListCsv("chk"));
|
|
var lines = csv.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
|
|
|
|
// Skip header + start balance rows
|
|
lines[2].ShouldContain("earlier small");
|
|
lines[3].ShouldContain("earlier large");
|
|
lines[4].ShouldContain("later");
|
|
}
|
|
|
|
[Fact]
|
|
public void NotMutateTransactionValueWhenExportingCsv()
|
|
{
|
|
var checking = new Account { Id = "chk", Number = 1010, Name = "Checking", Currency = "CHF" };
|
|
accountRepo.GetAccount("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.GetTransactionList().Returns(new List<Transaction> { outgoing });
|
|
|
|
BuildService().GetTransactionListCsv("chk");
|
|
|
|
// Regression: earlier code did `trx.Value *= -1` mid-loop.
|
|
outgoing.Value.ShouldBe(42m);
|
|
}
|
|
}
|