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>
This commit is contained in:
parent
5d75bbb64d
commit
8926fb1e97
12 changed files with 916 additions and 2 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -41,6 +41,9 @@ ScaffoldingReadMe.txt
|
||||||
*~
|
*~
|
||||||
CodeCoverage/
|
CodeCoverage/
|
||||||
|
|
||||||
|
# JetBrains Rider / ReSharper user-specific settings
|
||||||
|
*.DotSettings.user
|
||||||
|
|
||||||
# MSBuild Binary and Structured Log
|
# MSBuild Binary and Structured Log
|
||||||
*.binlog
|
*.binlog
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,28 +11,78 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Schaad.Accounting.Services"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Schaad.Accounting.Db", "Schaad.Accounting.Db\Schaad.Accounting.Db.csproj", "{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Schaad.Accounting.Db", "Schaad.Accounting.Db\Schaad.Accounting.Db.csproj", "{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Schaad.Accounting.Tests", "Schaad.Accounting.Tests\Schaad.Accounting.Tests.csproj", "{C0A48984-3770-49D7-9371-25351B4E79FF}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Debug|x64 = Debug|x64
|
||||||
|
Debug|x86 = Debug|x86
|
||||||
Release|Any CPU = Release|Any CPU
|
Release|Any CPU = Release|Any CPU
|
||||||
|
Release|x64 = Release|x64
|
||||||
|
Release|x86 = Release|x86
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{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|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.ActiveCfg = Release|Any CPU
|
||||||
{18BB46CF-FD35-6A8A-C11E-7F5405927F50}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{ACC60040-DC4A-6690-7600-44585B2B5508}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{ACC60040-DC4A-6690-7600-44585B2B5508}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{0F1A55C2-5D6B-601E-2BB2-0725B5E4C7DA}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{DC3EEA5C-7075-5F02-00A2-95BCDD87BB59}.Release|Any CPU.Build.0 = 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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|
|
||||||
30
CLAUDE.md
30
CLAUDE.md
|
|
@ -11,11 +11,14 @@ dotnet build
|
||||||
# Run (dev server at http://localhost:5225)
|
# Run (dev server at http://localhost:5225)
|
||||||
cd Schaad.Accounting.UI && dotnet run
|
cd Schaad.Accounting.UI && dotnet run
|
||||||
|
|
||||||
|
# Test
|
||||||
|
dotnet test
|
||||||
|
|
||||||
# Publish
|
# Publish
|
||||||
dotnet publish -c Release
|
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
|
## Architecture
|
||||||
|
|
||||||
|
|
@ -62,3 +65,28 @@ Pages live in `Schaad.Accounting.UI/Components/Pages/`. Each page typically has
|
||||||
### Domain constants
|
### 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`).
|
`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**: `<Subject>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.
|
||||||
|
|
|
||||||
|
|
@ -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 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.
|
- **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.
|
- **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.
|
||||||
|
|
|
||||||
112
Schaad.Accounting.Tests/AccountRepositoryTestShould.cs
Normal file
112
Schaad.Accounting.Tests/AccountRepositoryTestShould.cs
Normal file
|
|
@ -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<ISettingsService>();
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
132
Schaad.Accounting.Tests/ChartServiceTestShould.cs
Normal file
132
Schaad.Accounting.Tests/ChartServiceTestShould.cs
Normal file
|
|
@ -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<ISettingsService>();
|
||||||
|
private readonly IViewService viewService = Substitute.For<IViewService>();
|
||||||
|
|
||||||
|
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<AccountDataset> { AsDataset(groceries), AsDataset(checking) });
|
||||||
|
viewService.GetTransactionViewList().Returns(new List<TransactionDataset>
|
||||||
|
{
|
||||||
|
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<int>());
|
||||||
|
settingsService.DidNotReceive().TrySetYear(Arg.Any<int>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<AccountDataset>
|
||||||
|
{
|
||||||
|
AsDataset(mannenbach), AsDataset(mannenbach2), AsDataset(checking)
|
||||||
|
});
|
||||||
|
viewService.GetTransactionViewList().Returns(new List<TransactionDataset>
|
||||||
|
{
|
||||||
|
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<AccountDataset>
|
||||||
|
{
|
||||||
|
AsDataset(groceries), AsDataset(utilities), AsDataset(checking)
|
||||||
|
});
|
||||||
|
viewService.GetTransactionViewList().Returns(new List<TransactionDataset>
|
||||||
|
{
|
||||||
|
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<AccountDataset>
|
||||||
|
{
|
||||||
|
AsDataset(groceries), AsDataset(utilities), AsDataset(checking)
|
||||||
|
});
|
||||||
|
viewService.GetTransactionViewList().Returns(new List<TransactionDataset>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
106
Schaad.Accounting.Tests/FileServiceTestShould.cs
Normal file
106
Schaad.Accounting.Tests/FileServiceTestShould.cs
Normal file
|
|
@ -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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
Schaad.Accounting.Tests/FormattingTestShould.cs
Normal file
41
Schaad.Accounting.Tests/FormattingTestShould.cs
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
Schaad.Accounting.Tests/RepositoryCacheTestShould.cs
Normal file
58
Schaad.Accounting.Tests/RepositoryCacheTestShould.cs
Normal file
|
|
@ -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<int> { 1, 2 }; });
|
||||||
|
var second = cache.GetOrLoad("key", () => { loaderCalls++; return new List<int> { 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<int> { 1 });
|
||||||
|
var b = cache.GetOrLoad("b", () => new List<int> { 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<int> { 1 }; });
|
||||||
|
cache.Invalidate("k");
|
||||||
|
cache.GetOrLoad("k", () => { loaderCalls++; return new List<int> { 2 }; });
|
||||||
|
|
||||||
|
loaderCalls.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MatchKeysCaseInsensitivelyWhenLookingUp()
|
||||||
|
{
|
||||||
|
var cache = new RepositoryCache();
|
||||||
|
|
||||||
|
cache.GetOrLoad("Path/To/File.xml", () => new List<int> { 1 });
|
||||||
|
var upper = cache.GetOrLoad("PATH/TO/FILE.XML", () => new List<int> { 99 });
|
||||||
|
|
||||||
|
upper.ShouldBe(new[] { 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
35
Schaad.Accounting.Tests/Schaad.Accounting.Tests.csproj
Normal file
35
Schaad.Accounting.Tests/Schaad.Accounting.Tests.csproj
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
|
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||||
|
<PackageReference Include="Shouldly" Version="4.3.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Schaad.Accounting.Common\Schaad.Accounting.Common.csproj" />
|
||||||
|
<ProjectReference Include="..\Schaad.Accounting.Db\Schaad.Accounting.Db.csproj" />
|
||||||
|
<ProjectReference Include="..\Schaad.Accounting.Services\Schaad.Accounting.Services.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Reference Include="Schaad.Finance.Api">
|
||||||
|
<HintPath>..\dlls\Schaad.Finance.Api.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
138
Schaad.Accounting.Tests/TransactionRepositoryTestShould.cs
Normal file
138
Schaad.Accounting.Tests/TransactionRepositoryTestShould.cs
Normal file
|
|
@ -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<ISettingsService>();
|
||||||
|
settingsService.GetDbPath().Returns(dbDir);
|
||||||
|
settingsService.GetLastYearDbPath().Returns(Path.Combine(dbDir, "no-such-dir"));
|
||||||
|
|
||||||
|
accountRepo = Substitute.For<IAccountRepository>();
|
||||||
|
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<string>()).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();
|
||||||
|
}
|
||||||
|
}
|
||||||
211
Schaad.Accounting.Tests/ViewServiceTestShould.cs
Normal file
211
Schaad.Accounting.Tests/ViewServiceTestShould.cs
Normal file
|
|
@ -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<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
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue