diff --git a/.gitignore b/.gitignore index 8f8c265..b292206 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,9 @@ ScaffoldingReadMe.txt *~ CodeCoverage/ +# JetBrains Rider / ReSharper user-specific settings +*.DotSettings.user + # MSBuild Binary and Structured Log *.binlog diff --git a/Accounting.sln b/Accounting.sln index 3a0542f..ef86d2d 100644 --- a/Accounting.sln +++ b/Accounting.sln @@ -11,28 +11,78 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Schaad.Accounting.Services" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Schaad.Accounting.Db", "Schaad.Accounting.Db\Schaad.Accounting.Db.csproj", "{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Schaad.Accounting.Tests", "Schaad.Accounting.Tests\Schaad.Accounting.Tests.csproj", "{C0A48984-3770-49D7-9371-25351B4E79FF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Debug|Any CPU.Build.0 = Debug|Any CPU + {18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Debug|x64.ActiveCfg = Debug|Any CPU + {18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Debug|x64.Build.0 = Debug|Any CPU + {18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Debug|x86.ActiveCfg = Debug|Any CPU + {18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Debug|x86.Build.0 = Debug|Any CPU {18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Release|Any CPU.ActiveCfg = Release|Any CPU {18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Release|Any CPU.Build.0 = Release|Any CPU + {18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Release|x64.ActiveCfg = Release|Any CPU + {18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Release|x64.Build.0 = Release|Any CPU + {18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Release|x86.ActiveCfg = Release|Any CPU + {18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Release|x86.Build.0 = Release|Any CPU {ACC60040-DC4A-6690-7600-44585B2B5508}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {ACC60040-DC4A-6690-7600-44585B2B5508}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ACC60040-DC4A-6690-7600-44585B2B5508}.Debug|x64.ActiveCfg = Debug|Any CPU + {ACC60040-DC4A-6690-7600-44585B2B5508}.Debug|x64.Build.0 = Debug|Any CPU + {ACC60040-DC4A-6690-7600-44585B2B5508}.Debug|x86.ActiveCfg = Debug|Any CPU + {ACC60040-DC4A-6690-7600-44585B2B5508}.Debug|x86.Build.0 = Debug|Any CPU {ACC60040-DC4A-6690-7600-44585B2B5508}.Release|Any CPU.ActiveCfg = Release|Any CPU {ACC60040-DC4A-6690-7600-44585B2B5508}.Release|Any CPU.Build.0 = Release|Any CPU + {ACC60040-DC4A-6690-7600-44585B2B5508}.Release|x64.ActiveCfg = Release|Any CPU + {ACC60040-DC4A-6690-7600-44585B2B5508}.Release|x64.Build.0 = Release|Any CPU + {ACC60040-DC4A-6690-7600-44585B2B5508}.Release|x86.ActiveCfg = Release|Any CPU + {ACC60040-DC4A-6690-7600-44585B2B5508}.Release|x86.Build.0 = Release|Any CPU {0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Debug|x64.ActiveCfg = Debug|Any CPU + {0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Debug|x64.Build.0 = Debug|Any CPU + {0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Debug|x86.ActiveCfg = Debug|Any CPU + {0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Debug|x86.Build.0 = Debug|Any CPU {0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Release|Any CPU.ActiveCfg = Release|Any CPU {0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Release|Any CPU.Build.0 = Release|Any CPU + {0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Release|x64.ActiveCfg = Release|Any CPU + {0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Release|x64.Build.0 = Release|Any CPU + {0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Release|x86.ActiveCfg = Release|Any CPU + {0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Release|x86.Build.0 = Release|Any CPU {DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Debug|x64.ActiveCfg = Debug|Any CPU + {DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Debug|x64.Build.0 = Debug|Any CPU + {DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Debug|x86.ActiveCfg = Debug|Any CPU + {DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Debug|x86.Build.0 = Debug|Any CPU {DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Release|Any CPU.ActiveCfg = Release|Any CPU {DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Release|Any CPU.Build.0 = Release|Any CPU + {DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Release|x64.ActiveCfg = Release|Any CPU + {DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Release|x64.Build.0 = Release|Any CPU + {DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Release|x86.ActiveCfg = Release|Any CPU + {DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Release|x86.Build.0 = Release|Any CPU + {C0A48984-3770-49D7-9371-25351B4E79FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C0A48984-3770-49D7-9371-25351B4E79FF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C0A48984-3770-49D7-9371-25351B4E79FF}.Debug|x64.ActiveCfg = Debug|Any CPU + {C0A48984-3770-49D7-9371-25351B4E79FF}.Debug|x64.Build.0 = Debug|Any CPU + {C0A48984-3770-49D7-9371-25351B4E79FF}.Debug|x86.ActiveCfg = Debug|Any CPU + {C0A48984-3770-49D7-9371-25351B4E79FF}.Debug|x86.Build.0 = Debug|Any CPU + {C0A48984-3770-49D7-9371-25351B4E79FF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C0A48984-3770-49D7-9371-25351B4E79FF}.Release|Any CPU.Build.0 = Release|Any CPU + {C0A48984-3770-49D7-9371-25351B4E79FF}.Release|x64.ActiveCfg = Release|Any CPU + {C0A48984-3770-49D7-9371-25351B4E79FF}.Release|x64.Build.0 = Release|Any CPU + {C0A48984-3770-49D7-9371-25351B4E79FF}.Release|x86.ActiveCfg = Release|Any CPU + {C0A48984-3770-49D7-9371-25351B4E79FF}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/CLAUDE.md b/CLAUDE.md index e92dd28..dc8e983 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,11 +11,14 @@ dotnet build # Run (dev server at http://localhost:5225) cd Schaad.Accounting.UI && dotnet run +# Test +dotnet test + # Publish dotnet publish -c Release ``` -There are no automated tests or lint commands. +Tests live in `Schaad.Accounting.Tests` (xUnit + NSubstitute + Shouldly). There is no lint command. ## Architecture @@ -62,3 +65,28 @@ Pages live in `Schaad.Accounting.UI/Components/Pages/`. Each page typically has ### Domain constants `ClassIds` in `Schaad.Accounting.Common` defines the Swiss accounting chart-of-accounts classes: Activa=1, Passiva=2, Income=3, Expenses=4. These are the leading digit of an account number (accounts are 4-digit; `Account.Class = Number / 1000`). + +## Testing conventions + +Tests live in `Schaad.Accounting.Tests` and use **xUnit** + **NSubstitute** (mocks) + **Shouldly** (assertions). + +- **File and class name**: `TestShould` — one file per subject under test. Examples: `ViewServiceTestShould.cs`, `TransactionRepositoryTestShould.cs`, `FormattingTestShould.cs`. +- **Test method name**: `DoThisWhenThat` — describes the behavior first, then the condition. Read together the class + method form a sentence: + - `ViewServiceTestShould.SumActivaAndPassivaTotalsSeparatelyWhenGettingBalanceView` + - `TransactionRepositoryTestShould.ReturnNullWhenGettingUnknownTransactionId` +- **Assertions**: use Shouldly (`value.ShouldBe(expected)`, `list.ShouldBeEmpty()`, `x.ShouldBeNull()`, `list.ShouldContain(...)`, `first.ShouldBeSameAs(second)`, etc.). Do **not** use xUnit `Assert.*`. + +Example: + +```csharp +public class FormattingTestShould +{ + [Fact] + public void RoundToTwoDecimalsWhenValueHasMorePrecision() + { + 1.234m.ToFormattedString().ShouldBe("1.23"); + } +} +``` + +Repository tests that hit real XML I/O use a temp directory via `Path.GetTempPath()` and clean up in `IDisposable.Dispose`. Pure service tests use NSubstitute mocks for every dependency and don't touch the filesystem. diff --git a/IMPROVEMENT_PLAN.md b/IMPROVEMENT_PLAN.md index 990cc37..ad9d464 100644 --- a/IMPROVEMENT_PLAN.md +++ b/IMPROVEMENT_PLAN.md @@ -102,4 +102,4 @@ Analysis and phased plan produced 2026-07-02. See conversation history for full - **PR E** — Phase 3 item 22: ChartService cleanup. Promoted ahead of async because it fixes an active correctness bug — `GetAccountExpensesPerMonth(accountId, year)` was mutating `ISettingsService` on the singleton to hop years, with the year parameter always hardcoded to `DateTime.Now.Year`, so the Spendings-over-time chart discarded the user's header year selection. - **PR F** — Phase 3 item 21: precomputed dictionaries in `ViewService` to eliminate O(N·M) `Single(...)` scans and per-account transaction filtering. - **Item 19 (async I/O)** — deferred. After PR D, each XML file is loaded at most once per SignalR circuit, and this app is single-user local Blazor Server. Converting every repository/service method to async would touch ~40 files for negligible user-visible benefit and real regression risk. Revisit if the app is ever hosted for multiple concurrent users. -- **PR G** — Phase 4 (tests) — locks in behavior before further invasive changes. +- **PR G** — Phase 4 items 23 + 24: `Schaad.Accounting.Tests` xUnit + NSubstitute + Shouldly project. Adopts the `xxxTestShould.DoThisWhenThat` naming convention with Shouldly assertions (no xUnit `Assert.*`). Covers `ViewService`, `TransactionRepository`, `Formatting`, `RepositoryCache`, `AccountRepository`, `ChartService`, and `FileService.GetTransactionListCsv` — 38 tests locking in the earlier PRs' behavior. Note: originally shipped as three separate commits (initial project, expanded coverage, Shouldly + naming conversion) and later squashed into one commit at the user's request. diff --git a/Schaad.Accounting.Tests/AccountRepositoryTestShould.cs b/Schaad.Accounting.Tests/AccountRepositoryTestShould.cs new file mode 100644 index 0000000..ad7491f --- /dev/null +++ b/Schaad.Accounting.Tests/AccountRepositoryTestShould.cs @@ -0,0 +1,112 @@ +using NSubstitute; +using Schaad.Accounting.Interfaces; +using Schaad.Accounting.Models; +using Schaad.Accounting.Repositories; +using Shouldly; + +namespace Schaad.Accounting.Tests; + +public class AccountRepositoryTestShould : IDisposable +{ + private readonly string dbDir; + private readonly ISettingsService settingsService; + private readonly AccountRepository sut; + + public AccountRepositoryTestShould() + { + dbDir = Path.Combine(Path.GetTempPath(), "AccountingNext-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dbDir); + + settingsService = Substitute.For(); + settingsService.GetDbPath().Returns(dbDir); + settingsService.GetLastYearDbPath().Returns(Path.Combine(dbDir, "no-such-dir")); + + sut = new AccountRepository(settingsService, new RepositoryCache()); + } + + public void Dispose() + { + if (Directory.Exists(dbDir)) + { + Directory.Delete(dbDir, recursive: true); + } + } + + [Fact] + public void AssignNewIdWhenSavingAccountWithoutId() + { + var account = new Account { Number = 1000, Name = "Cash", Currency = "CHF" }; + + sut.SaveAccount(account); + + var stored = sut.GetAccountList().Single(); + stored.Id.ShouldNotBeNullOrEmpty(); + stored.Name.ShouldBe("Cash"); + } + + [Fact] + public void UpdateInPlaceWhenSavingAccountWithExistingId() + { + sut.SaveAccount(new Account { Number = 1000, Name = "Cash", Currency = "CHF" }); + var id = sut.GetAccountList().Single().Id; + + sut.SaveAccount(new Account { Id = id, Number = 1000, Name = "Cash renamed", Currency = "CHF" }); + + var accounts = sut.GetAccountList(); + accounts.Count.ShouldBe(1); + accounts[0].Name.ShouldBe("Cash renamed"); + } + + [Fact] + public void DefaultCurrencyToChfWhenSavingAccountWithoutCurrency() + { + sut.SaveAccount(new Account { Number = 1000, Name = "Cash", Currency = null! }); + + sut.GetAccountList().Single().Currency.ShouldBe("CHF"); + } + + [Fact] + public void RemoveAccountWhenDeletingById() + { + sut.SaveAccount(new Account { Number = 1000, Name = "Cash", Currency = "CHF" }); + var id = sut.GetAccountList().Single().Id; + + sut.DeleteAccount(id); + + sut.GetAccountList().ShouldBeEmpty(); + } + + [Fact] + public void ReturnAccountWhenLookingUpByBankAccountNumber() + { + sut.SaveAccount(new Account { Number = 1010, Name = "Checking", Currency = "CHF", BankAccountNumber = "CH01" }); + sut.SaveAccount(new Account { Number = 1020, Name = "Savings", Currency = "CHF", BankAccountNumber = "CH02" }); + + sut.GetAccountForBankAccountNumber("CH01").Name.ShouldBe("Checking"); + sut.GetAccountForBankAccountNumber("CH02").Name.ShouldBe("Savings"); + sut.GetAccountForBankAccountNumber("CH99").ShouldBeNull(); + } + + [Fact] + public void UpdateLastBankBalanceWhenSavingBankBalance() + { + sut.SaveAccount(new Account { Number = 1010, Name = "Checking", Currency = "CHF", BankAccountNumber = "CH01" }); + + sut.SaveBankAccountBalance("CH01", 1234.56m); + + sut.GetAccountForBankAccountNumber("CH01").LastBankBalance.ShouldBe(1234.56m); + } + + [Fact] + public void SurviveConstructorReRunWhenAccountsFileAlreadyExists() + { + sut.SaveAccount(new Account { Number = 1000, Name = "Cash", Currency = "CHF" }); + + // Re-construct with the same directory: existing file, no year rollover, no data loss. + var fresh = new AccountRepository(settingsService, new RepositoryCache()); + + var accounts = fresh.GetAccountList(); + accounts.Count.ShouldBe(1); + accounts[0].Name.ShouldBe("Cash"); + } +} diff --git a/Schaad.Accounting.Tests/ChartServiceTestShould.cs b/Schaad.Accounting.Tests/ChartServiceTestShould.cs new file mode 100644 index 0000000..93943c3 --- /dev/null +++ b/Schaad.Accounting.Tests/ChartServiceTestShould.cs @@ -0,0 +1,132 @@ +using NSubstitute; +using Schaad.Accounting.Datasets; +using Schaad.Accounting.Interfaces; +using Schaad.Accounting.Models; +using Schaad.Accounting.Services; +using Shouldly; + +namespace Schaad.Accounting.Tests; + +public class ChartServiceTestShould +{ + private readonly ISettingsService settingsService = Substitute.For(); + private readonly IViewService viewService = Substitute.For(); + + public ChartServiceTestShould() + { + settingsService.GetYear().Returns(2026); + } + + private ChartService BuildService() => new(settingsService, viewService); + + [Fact] + public void HonourSelectedYearWhenBuildingMonthlySeries() + { + settingsService.GetYear().Returns(2023); + var groceries = MakeAccount("groc", 4200); + var checking = MakeAccount("chk", 1010); + viewService.GetAccountViewList().Returns(new List { AsDataset(groceries), AsDataset(checking) }); + viewService.GetTransactionViewList().Returns(new List + { + MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2023, 4, 5)) + }); + + var series = BuildService().GetExpensesPerMonth(); + + series.Count.ShouldBe(1); + series[0].X.ShouldAllBe(x => ((DateOnly)x).Year == 2023); + settingsService.DidNotReceive().SetYear(Arg.Any()); + settingsService.DidNotReceive().TrySetYear(Arg.Any()); + } + + [Fact] + public void GroupExpensesByAccountWhenAllExpensesShareOneSubClass() + { + var mannenbach = MakeAccount("m1", 4000); // SubClass 40 + var mannenbach2 = MakeAccount("m2", 4001); // SubClass 40 (same) + var checking = MakeAccount("chk", 1010); + viewService.GetAccountViewList().Returns(new List + { + AsDataset(mannenbach), AsDataset(mannenbach2), AsDataset(checking) + }); + viewService.GetTransactionViewList().Returns(new List + { + MakeTransaction(checking, mannenbach, value: 10m, valueDate: new DateTime(2026, 1, 1)), + MakeTransaction(checking, mannenbach2, value: 20m, valueDate: new DateTime(2026, 1, 1)) + }); + + var series = BuildService().GetExpensesPerMonth(); + + series.Count.ShouldBe(2); + series.ShouldContain(s => s.Id == "m1"); + series.ShouldContain(s => s.Id == "m2"); + } + + [Fact] + public void GroupExpensesBySubClassWhenMultipleSubClassesAreUsed() + { + var groceries = MakeAccount("groc", 4200); // SubClass 42 + var utilities = MakeAccount("util", 4300); // SubClass 43 + var checking = MakeAccount("chk", 1010); + viewService.GetAccountViewList().Returns(new List + { + AsDataset(groceries), AsDataset(utilities), AsDataset(checking) + }); + viewService.GetTransactionViewList().Returns(new List + { + MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 1)), + MakeTransaction(checking, utilities, value: 30m, valueDate: new DateTime(2026, 2, 1)) + }); + + var series = BuildService().GetExpensesPerMonth(); + + series.Count.ShouldBe(2); + series.ShouldContain(s => s.Id == "42"); // sub-class number as id + series.ShouldContain(s => s.Id == "43"); + } + + [Fact] + public void SkipAccountsWithoutTransactionsWhenBuildingSeries() + { + var groceries = MakeAccount("groc", 4000); // has activity + var utilities = MakeAccount("util", 4001); // same subclass, no activity + var checking = MakeAccount("chk", 1010); + viewService.GetAccountViewList().Returns(new List + { + AsDataset(groceries), AsDataset(utilities), AsDataset(checking) + }); + viewService.GetTransactionViewList().Returns(new List + { + MakeTransaction(checking, groceries, value: 50m, valueDate: new DateTime(2026, 1, 1)) + }); + + var series = BuildService().GetExpensesPerMonth(); + + series.Count.ShouldBe(1); + series[0].Id.ShouldBe("groc"); + } + + private static Account MakeAccount(string id, int number) => new() + { + Id = id, + Number = number, + Name = id, + Currency = "CHF" + }; + + private static AccountDataset AsDataset(Account a) => + new(a, balance: 0, balanceCHF: 0, startBalanceCHF: 0, className: "", subClassName: ""); + + private static TransactionDataset MakeTransaction(Account origin, Account target, decimal value, DateTime valueDate) => + new( + new Transaction + { + Id = Guid.NewGuid().ToString(), + OriginAccountId = origin.Id, + TargetAccountId = target.Id, + Value = value, + ValueDate = valueDate, + BookingDate = valueDate + }, + origin, target); +} diff --git a/Schaad.Accounting.Tests/FileServiceTestShould.cs b/Schaad.Accounting.Tests/FileServiceTestShould.cs new file mode 100644 index 0000000..242be65 --- /dev/null +++ b/Schaad.Accounting.Tests/FileServiceTestShould.cs @@ -0,0 +1,106 @@ +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(); + private readonly IAccountRepository accountRepo = Substitute.For(); + private readonly ITransactionRepository transactionRepo = Substitute.For(); + private readonly IBankTransactionRepository bankTransactionRepo = Substitute.For(); + private readonly IAccountStatementService accountStatementService = Substitute.For(); + private readonly ICreditCardStatementService creditCardStatementService = Substitute.For(); + + 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 + { + 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 + { + // 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 { outgoing }); + + BuildService().GetTransactionListCsv("chk"); + + // Regression: earlier code did `trx.Value *= -1` mid-loop. + outgoing.Value.ShouldBe(42m); + } +} diff --git a/Schaad.Accounting.Tests/FormattingTestShould.cs b/Schaad.Accounting.Tests/FormattingTestShould.cs new file mode 100644 index 0000000..819ceb0 --- /dev/null +++ b/Schaad.Accounting.Tests/FormattingTestShould.cs @@ -0,0 +1,41 @@ +using System.Globalization; +using Shouldly; + +namespace Schaad.Accounting.Tests; + +public class FormattingTestShould +{ + [Theory] + [InlineData(0, "0.00")] + [InlineData(1.5, "1.50")] + [InlineData(-1.5, "-1.50")] + [InlineData(1234.5, "1'234.50")] + [InlineData(1234567.89, "1'234'567.89")] + [InlineData(-1234567.89, "-1'234'567.89")] + public void FormatDecimalWithSwissThousandsSeparatorWhenCalled(decimal value, string expected) + { + value.ToFormattedString().ShouldBe(expected); + } + + [Fact] + public void RoundToTwoDecimalsWhenValueHasMorePrecision() + { + 1.234m.ToFormattedString().ShouldBe("1.23"); + 1.235m.ToFormattedString().ShouldBe("1.24"); + } + + [Fact] + public void IgnoreAmbientCultureWhenFormatting() + { + var previous = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; + 1234.5m.ToFormattedString().ShouldBe("1'234.50"); + } + finally + { + CultureInfo.CurrentCulture = previous; + } + } +} diff --git a/Schaad.Accounting.Tests/RepositoryCacheTestShould.cs b/Schaad.Accounting.Tests/RepositoryCacheTestShould.cs new file mode 100644 index 0000000..776044d --- /dev/null +++ b/Schaad.Accounting.Tests/RepositoryCacheTestShould.cs @@ -0,0 +1,58 @@ +using Schaad.Accounting.Repositories; +using Shouldly; + +namespace Schaad.Accounting.Tests; + +public class RepositoryCacheTestShould +{ + [Fact] + public void CallLoaderOnceWhenKeyIsRequestedRepeatedly() + { + var cache = new RepositoryCache(); + var loaderCalls = 0; + + var first = cache.GetOrLoad("key", () => { loaderCalls++; return new List { 1, 2 }; }); + var second = cache.GetOrLoad("key", () => { loaderCalls++; return new List { 99 }; }); + + loaderCalls.ShouldBe(1); + second.ShouldBeSameAs(first); + second.ShouldBe(new[] { 1, 2 }); + } + + [Fact] + public void KeepEntriesSeparateWhenKeysDiffer() + { + var cache = new RepositoryCache(); + + var a = cache.GetOrLoad("a", () => new List { 1 }); + var b = cache.GetOrLoad("b", () => new List { 2 }); + + b.ShouldNotBeSameAs(a); + a.ShouldBe(new[] { 1 }); + b.ShouldBe(new[] { 2 }); + } + + [Fact] + public void ReloadFromLoaderWhenKeyIsInvalidated() + { + var cache = new RepositoryCache(); + var loaderCalls = 0; + + cache.GetOrLoad("k", () => { loaderCalls++; return new List { 1 }; }); + cache.Invalidate("k"); + cache.GetOrLoad("k", () => { loaderCalls++; return new List { 2 }; }); + + loaderCalls.ShouldBe(2); + } + + [Fact] + public void MatchKeysCaseInsensitivelyWhenLookingUp() + { + var cache = new RepositoryCache(); + + cache.GetOrLoad("Path/To/File.xml", () => new List { 1 }); + var upper = cache.GetOrLoad("PATH/TO/FILE.XML", () => new List { 99 }); + + upper.ShouldBe(new[] { 1 }); + } +} diff --git a/Schaad.Accounting.Tests/Schaad.Accounting.Tests.csproj b/Schaad.Accounting.Tests/Schaad.Accounting.Tests.csproj new file mode 100644 index 0000000..48d0beb --- /dev/null +++ b/Schaad.Accounting.Tests/Schaad.Accounting.Tests.csproj @@ -0,0 +1,35 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + ..\dlls\Schaad.Finance.Api.dll + + + + diff --git a/Schaad.Accounting.Tests/TransactionRepositoryTestShould.cs b/Schaad.Accounting.Tests/TransactionRepositoryTestShould.cs new file mode 100644 index 0000000..a980738 --- /dev/null +++ b/Schaad.Accounting.Tests/TransactionRepositoryTestShould.cs @@ -0,0 +1,138 @@ +using NSubstitute; +using Schaad.Accounting.Interfaces; +using Schaad.Accounting.Models; +using Schaad.Accounting.Repositories; +using Shouldly; + +namespace Schaad.Accounting.Tests; + +public class TransactionRepositoryTestShould : IDisposable +{ + private readonly string dbDir; + private readonly ISettingsService settingsService; + private readonly IAccountRepository accountRepo; + private readonly RepositoryCache cache; + private readonly TransactionRepository sut; + + public TransactionRepositoryTestShould() + { + dbDir = Path.Combine(Path.GetTempPath(), "AccountingNext-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dbDir); + + settingsService = Substitute.For(); + settingsService.GetDbPath().Returns(dbDir); + settingsService.GetLastYearDbPath().Returns(Path.Combine(dbDir, "no-such-dir")); + + accountRepo = Substitute.For(); + cache = new RepositoryCache(); + sut = new TransactionRepository(settingsService, cache, accountRepo); + } + + public void Dispose() + { + if (Directory.Exists(dbDir)) + { + Directory.Delete(dbDir, recursive: true); + } + } + + [Fact] + public void PreserveValueWhenRoundTrippingNonFxTransaction() + { + accountRepo.GetAccount("chk").Returns(new Account { Id = "chk", Currency = "CHF" }); + accountRepo.GetAccount("groc").Returns(new Account { Id = "groc", Currency = "CHF" }); + + sut.SaveTransaction(new Transaction + { + OriginAccountId = "chk", + TargetAccountId = "groc", + Value = 42m, + Text = "Groceries", + ValueDate = new DateTime(2026, 5, 10), + BookingDate = new DateTime(2026, 5, 10) + }); + + var stored = sut.GetTransactionList().Single(); + var loaded = sut.GetTransaction(stored.Id); + + loaded.ShouldNotBeNull(); + loaded.Value.ShouldBe(42m); + loaded.FxRate.ShouldBeNull(); // Non-FX transactions have FxRate cleared + } + + [Fact] + public void StoreInChfAndReturnInAccountCurrencyWhenTransactionIsFx() + { + // User enters 100 USD at rate 0.9 CHF/USD -> stored as 90 CHF; read back as 100 USD. + accountRepo.GetAccount("chk").Returns(new Account { Id = "chk", Currency = "CHF" }); + accountRepo.GetAccount("usd").Returns(new Account { Id = "usd", Currency = "USD" }); + + sut.SaveTransaction(new Transaction + { + OriginAccountId = "chk", + TargetAccountId = "usd", + Value = 100m, + FxRate = 0.9m, + Text = "USD transfer", + ValueDate = new DateTime(2026, 5, 10), + BookingDate = new DateTime(2026, 5, 10) + }); + + var stored = sut.GetTransactionList().Single(); + stored.Value.ShouldBe(90m); // stored in CHF + + var loaded = sut.GetTransaction(stored.Id); + loaded.ShouldNotBeNull(); + loaded.Value.ShouldBe(100m); // display currency + loaded.FxRate.ShouldBe(0.9m); + } + + [Fact] + public void ReturnNullWhenGettingUnknownTransactionId() + { + sut.GetTransaction("no-such-id").ShouldBeNull(); + } + + [Fact] + public void NotMutateStoredValueWhenGettingFxTransactionTwice() + { + accountRepo.GetAccount("chk").Returns(new Account { Id = "chk", Currency = "CHF" }); + accountRepo.GetAccount("usd").Returns(new Account { Id = "usd", Currency = "USD" }); + + sut.SaveTransaction(new Transaction + { + OriginAccountId = "chk", + TargetAccountId = "usd", + Value = 100m, + FxRate = 0.9m, + Text = "USD transfer", + ValueDate = new DateTime(2026, 5, 10), + BookingDate = new DateTime(2026, 5, 10) + }); + + var stored = sut.GetTransactionList().Single(); + var id = stored.Id; + + // Get twice; if Get mutated the stored entity, the second call would divide by FxRate again. + var first = sut.GetTransaction(id); + var second = sut.GetTransaction(id); + + first.ShouldNotBeNull(); + first.Value.ShouldBe(100m); + second.ShouldNotBeNull(); + second.Value.ShouldBe(100m); + } + + [Fact] + public void RemoveTransactionWhenDeletingById() + { + accountRepo.GetAccount(Arg.Any()).Returns(new Account { Currency = "CHF" }); + + sut.SaveTransaction(new Transaction { OriginAccountId = "a", TargetAccountId = "b", Value = 1m, Text = "x", ValueDate = DateTime.Today }); + var id = sut.GetTransactionList().Single().Id; + + sut.DeleteTransaction(id); + + sut.GetTransactionList().ShouldBeEmpty(); + } +} diff --git a/Schaad.Accounting.Tests/ViewServiceTestShould.cs b/Schaad.Accounting.Tests/ViewServiceTestShould.cs new file mode 100644 index 0000000..d8aa4de --- /dev/null +++ b/Schaad.Accounting.Tests/ViewServiceTestShould.cs @@ -0,0 +1,211 @@ +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(); + private readonly ITransactionRepository transactionRepo = Substitute.For(); + private readonly IBankTransactionRepository bankTransactionRepo = Substitute.For(); + private readonly IBookingRuleRepository bookingRuleRepo = Substitute.For(); + private readonly ISubclassRepository subclassRepo = Substitute.For(); + private readonly IFxService fxService = Substitute.For(); + private readonly ISettingsService settingsService = Substitute.For(); + + 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()).Returns(""); + settingsService.GetSettings().Returns(new SettingsDataset { DataPath = "", FixerIoApiKey = "" }); + + // Passthrough FX by default (CHF-only). Individual tests can override. + fxService.ConvertCurrency(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => ci.ArgAt(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 { cash, receivables, payables }); + transactionRepo.GetTransactionList().Returns(new List()); + + 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 { cash, groceries }); + + // Debit cash 50, credit groceries 50 (spending) + transactionRepo.GetTransactionList().Returns(new List + { + 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 { usd }); + transactionRepo.GetTransactionList().Returns(new List()); + + // 1 USD = 0.90 CHF + fxService.ConvertCurrency(Arg.Any(), "USD", "CHF", Arg.Any()) + .Returns(ci => ci.ArgAt(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 { checking, utilities }); + bookingRuleRepo.GetBookingRuleList().Returns(new List + { + new() { Id = "r1", LookupText = "electric", AccountId = "util", BookingText = "Strom" } + }); + bankTransactionRepo.GetBankTransactionList().Returns(new List + { + 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()); + + 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 { checking, rent, groceries }); + bookingRuleRepo.GetBookingRuleList().Returns(new List + { + 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 + { + 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()); + + 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 { checking, salary }); + bookingRuleRepo.GetBookingRuleList().Returns(new List()); + bankTransactionRepo.GetBankTransactionList().Returns(new List + { + 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 + { + 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 + { + 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 + { + 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 + }; +}