BaseRepository takes an ILogger via its constructor and logs the failing file path and exception in Save's catch block before rethrowing. Every concrete repository takes ILogger<ThisRepo> and passes it up so failures are categorised by repository type. Pulls Microsoft.Extensions.Logging.Abstractions into the Db project. TransactionRepositoryTestShould and AccountRepositoryTestShould pass NullLogger<T>.Instance. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
139 lines
4.7 KiB
C#
139 lines
4.7 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();
|
|
}
|
|
}
|