Replace the hand-rolled model.Copy(target) pattern in every SaveXxx with FindIndex -> in-place replace (or Add on new). Move the two hidden defaults out of the deleted Copy bodies: Currency = "CHF" default now lives in AccountRepository.SaveAccount; BookingDate = ValueDate default now lives in TransactionRepository.SaveTransaction. Delete Copy from Account, BookingRule, BookingText, SubClass, and SplitPredefinition. Keep Transaction.Clone() (renamed from Copy, and now includes RelatedParty) for the defensive copy in GetTransaction and ViewService.WithDisplaySign. Fixes a pre-existing bug where Transaction.Copy silently dropped RelatedParty on every update save. 42 tests total, all passing. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
178 lines
5.9 KiB
C#
178 lines
5.9 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
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, NullLogger<TransactionRepository>.Instance);
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
[Fact]
|
|
public void PreserveRelatedPartyWhenRoundTrippingTransaction()
|
|
{
|
|
accountRepo.GetAccount(Arg.Any<string>()).Returns(new Account { Currency = "CHF" });
|
|
|
|
sut.SaveTransaction(new Transaction
|
|
{
|
|
OriginAccountId = "a",
|
|
TargetAccountId = "b",
|
|
Value = 10m,
|
|
Text = "Rent",
|
|
RelatedParty = "ACME Property AG",
|
|
ValueDate = new DateTime(2026, 4, 1),
|
|
BookingDate = new DateTime(2026, 4, 1)
|
|
});
|
|
|
|
// Regression: earlier Transaction.Copy dropped RelatedParty on every save,
|
|
// so the round-trip lost it.
|
|
sut.GetTransactionList().Single().RelatedParty.ShouldBe("ACME Property AG");
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultBookingDateToValueDateWhenBookingDateIsUnset()
|
|
{
|
|
accountRepo.GetAccount(Arg.Any<string>()).Returns(new Account { Currency = "CHF" });
|
|
|
|
sut.SaveTransaction(new Transaction
|
|
{
|
|
OriginAccountId = "a",
|
|
TargetAccountId = "b",
|
|
Value = 10m,
|
|
Text = "x",
|
|
ValueDate = new DateTime(2026, 4, 15)
|
|
// BookingDate left at default (DateTime.MinValue)
|
|
});
|
|
|
|
sut.GetTransactionList().Single().BookingDate.ShouldBe(new DateTime(2026, 4, 15));
|
|
}
|
|
}
|