From af4a638b8169f7b98d387e7244b4af79a67897b5 Mon Sep 17 00:00:00 2001 From: AJ Date: Sun, 26 Oct 2025 00:01:28 -0400 Subject: [PATCH] Test: add comprehensive unit test project for services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add MoneyMap.Tests project with xUnit tests for all new services: - AccountServiceTests: Account retrieval, stats, validation, deletion - CardServiceTests: Card retrieval, stats, validation, deletion - MerchantServiceTests: Merchant CRUD operations - ReferenceDataServiceTests: Reference data retrieval - TransactionServiceTests: Duplicate detection, retrieval, deletion - TransactionStatisticsServiceTests: Statistics calculations - ReceiptMatchingServiceTests: Receipt-to-transaction matching logic - DbContextHelper: In-memory database context factory for test isolation Uses xUnit, Moq, and EF Core InMemory database. Solution file updated to include test project. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- MoneyMap.Tests/MoneyMap.Tests.csproj | 27 ++ .../Services/AccountServiceTests.cs | 237 +++++++++++++ MoneyMap.Tests/Services/CardServiceTests.cs | 232 ++++++++++++ .../Services/MerchantServiceTests.cs | 189 ++++++++++ .../Services/ReceiptMatchingServiceTests.cs | 330 ++++++++++++++++++ .../Services/ReferenceDataServiceTests.cs | 221 ++++++++++++ .../Services/TransactionServiceTests.cs | 229 ++++++++++++ .../TransactionStatisticsServiceTests.cs | 188 ++++++++++ MoneyMap.Tests/TestHelpers/DbContextHelper.cs | 20 ++ MoneyMap.sln | 6 + 10 files changed, 1679 insertions(+) create mode 100644 MoneyMap.Tests/MoneyMap.Tests.csproj create mode 100644 MoneyMap.Tests/Services/AccountServiceTests.cs create mode 100644 MoneyMap.Tests/Services/CardServiceTests.cs create mode 100644 MoneyMap.Tests/Services/MerchantServiceTests.cs create mode 100644 MoneyMap.Tests/Services/ReceiptMatchingServiceTests.cs create mode 100644 MoneyMap.Tests/Services/ReferenceDataServiceTests.cs create mode 100644 MoneyMap.Tests/Services/TransactionServiceTests.cs create mode 100644 MoneyMap.Tests/Services/TransactionStatisticsServiceTests.cs create mode 100644 MoneyMap.Tests/TestHelpers/DbContextHelper.cs diff --git a/MoneyMap.Tests/MoneyMap.Tests.csproj b/MoneyMap.Tests/MoneyMap.Tests.csproj new file mode 100644 index 0000000..3b7f223 --- /dev/null +++ b/MoneyMap.Tests/MoneyMap.Tests.csproj @@ -0,0 +1,27 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/MoneyMap.Tests/Services/AccountServiceTests.cs b/MoneyMap.Tests/Services/AccountServiceTests.cs new file mode 100644 index 0000000..498ebfa --- /dev/null +++ b/MoneyMap.Tests/Services/AccountServiceTests.cs @@ -0,0 +1,237 @@ +using MoneyMap.Models; +using MoneyMap.Services; +using MoneyMap.Tests.TestHelpers; +using Xunit; + +namespace MoneyMap.Tests.Services; + +public class AccountServiceTests +{ + [Fact] + public async Task GetAllAccountsWithStatsAsync_ReturnsAccountsWithTransactionCounts() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new AccountService(context); + + var account1 = new Account + { + Id = 1, + Institution = "Bank A", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "John Doe" + }; + var account2 = new Account + { + Id = 2, + Institution = "Bank B", + AccountType = AccountType.Savings, + Last4 = "5678", + Owner = "Jane Smith" + }; + context.Accounts.AddRange(account1, account2); + + var transaction1 = new Transaction + { + Date = DateTime.Now, + Amount = -50.00m, + Name = "Test", + Memo = "Test", + AccountId = 1 + }; + var transaction2 = new Transaction + { + Date = DateTime.Now, + Amount = -25.00m, + Name = "Test", + Memo = "Test", + AccountId = 1 + }; + context.Transactions.AddRange(transaction1, transaction2); + await context.SaveChangesAsync(); + + // Act + var result = await service.GetAllAccountsWithStatsAsync(); + + // Assert + Assert.Equal(2, result.Count); + var account1Stats = result.First(a => a.Id == 1); + Assert.Equal(2, account1Stats.TransactionCount); + var account2Stats = result.First(a => a.Id == 2); + Assert.Equal(0, account2Stats.TransactionCount); + } + + [Fact] + public async Task CanDeleteAccountAsync_ReturnsFalse_WhenAccountHasTransactions() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new AccountService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var transaction = new Transaction + { + Date = DateTime.Now, + Amount = -50.00m, + Name = "Test", + Memo = "Test", + AccountId = 1 + }; + context.Transactions.Add(transaction); + await context.SaveChangesAsync(); + + // Act + var result = await service.CanDeleteAccountAsync(1); + + // Assert + Assert.False(result.CanDelete); + Assert.Contains("transaction", result.Reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CanDeleteAccountAsync_ReturnsTrue_WhenAccountHasNoTransactions() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new AccountService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + await context.SaveChangesAsync(); + + // Act + var result = await service.CanDeleteAccountAsync(1); + + // Assert + Assert.True(result.CanDelete); + } + + [Fact] + public async Task DeleteAccountAsync_SuccessfullyDeletesAccount_WhenNoTransactions() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new AccountService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + await context.SaveChangesAsync(); + + // Act + var result = await service.DeleteAccountAsync(1); + + // Assert + Assert.True(result.Success); + Assert.Empty(context.Accounts); + } + + [Fact] + public async Task DeleteAccountAsync_FailsToDelete_WhenAccountHasTransactions() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new AccountService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var transaction = new Transaction + { + Date = DateTime.Now, + Amount = -50.00m, + Name = "Test", + Memo = "Test", + AccountId = 1 + }; + context.Transactions.Add(transaction); + await context.SaveChangesAsync(); + + // Act + var result = await service.DeleteAccountAsync(1); + + // Assert + Assert.False(result.Success); + Assert.Single(context.Accounts); + } + + [Fact] + public async Task GetAccountDetailsAsync_ReturnsFullDetails_WithCardsAndCounts() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new AccountService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var card = new Card + { + Id = 1, + AccountId = 1, + Issuer = "VISA", + Last4 = "9999", + Owner = "Test Owner" + }; + context.Cards.Add(card); + + var transaction = new Transaction + { + Date = DateTime.Now, + Amount = -50.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + CardId = 1 + }; + context.Transactions.Add(transaction); + await context.SaveChangesAsync(); + + // Act + var result = await service.GetAccountDetailsAsync(1); + + // Assert + Assert.NotNull(result); + Assert.Equal(1, result.Account.Id); + Assert.Single(result.Cards); + Assert.Equal(1, result.Cards[0].TransactionCount); + Assert.Equal(1, result.TransactionCount); + } +} diff --git a/MoneyMap.Tests/Services/CardServiceTests.cs b/MoneyMap.Tests/Services/CardServiceTests.cs new file mode 100644 index 0000000..2e574d1 --- /dev/null +++ b/MoneyMap.Tests/Services/CardServiceTests.cs @@ -0,0 +1,232 @@ +using MoneyMap.Models; +using MoneyMap.Services; +using MoneyMap.Tests.TestHelpers; +using Xunit; + +namespace MoneyMap.Tests.Services; + +public class CardServiceTests +{ + [Fact] + public async Task GetAllCardsWithStatsAsync_ReturnsCardsWithTransactionCounts() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new CardService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var card1 = new Card + { + Id = 1, + AccountId = 1, + Issuer = "VISA", + Last4 = "1111", + Owner = "John Doe" + }; + var card2 = new Card + { + Id = 2, + AccountId = 1, + Issuer = "Mastercard", + Last4 = "2222", + Owner = "Jane Smith" + }; + context.Cards.AddRange(card1, card2); + + var transaction = new Transaction + { + Date = DateTime.Now, + Amount = -50.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + CardId = 1 + }; + context.Transactions.Add(transaction); + await context.SaveChangesAsync(); + + // Act + var result = await service.GetAllCardsWithStatsAsync(); + + // Assert + Assert.Equal(2, result.Count); + var card1Stats = result.First(c => c.Card.Id == 1); + Assert.Equal(1, card1Stats.TransactionCount); + var card2Stats = result.First(c => c.Card.Id == 2); + Assert.Equal(0, card2Stats.TransactionCount); + } + + [Fact] + public async Task CanDeleteCardAsync_ReturnsFalse_WhenCardHasTransactions() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new CardService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var card = new Card + { + Id = 1, + AccountId = 1, + Issuer = "VISA", + Last4 = "9999", + Owner = "Test Owner" + }; + context.Cards.Add(card); + + var transaction = new Transaction + { + Date = DateTime.Now, + Amount = -50.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + CardId = 1 + }; + context.Transactions.Add(transaction); + await context.SaveChangesAsync(); + + // Act + var result = await service.CanDeleteCardAsync(1); + + // Assert + Assert.False(result.CanDelete); + Assert.Contains("transaction", result.Reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CanDeleteCardAsync_ReturnsTrue_WhenCardHasNoTransactions() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new CardService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var card = new Card + { + Id = 1, + AccountId = 1, + Issuer = "VISA", + Last4 = "9999", + Owner = "Test Owner" + }; + context.Cards.Add(card); + await context.SaveChangesAsync(); + + // Act + var result = await service.CanDeleteCardAsync(1); + + // Assert + Assert.True(result.CanDelete); + } + + [Fact] + public async Task DeleteCardAsync_SuccessfullyDeletesCard_WhenNoTransactions() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new CardService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var card = new Card + { + Id = 1, + AccountId = 1, + Issuer = "VISA", + Last4 = "9999", + Owner = "Test Owner" + }; + context.Cards.Add(card); + await context.SaveChangesAsync(); + + // Act + var result = await service.DeleteCardAsync(1); + + // Assert + Assert.True(result.Success); + Assert.Empty(context.Cards); + } + + [Fact] + public async Task DeleteCardAsync_FailsToDelete_WhenCardHasTransactions() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new CardService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var card = new Card + { + Id = 1, + AccountId = 1, + Issuer = "VISA", + Last4 = "9999", + Owner = "Test Owner" + }; + context.Cards.Add(card); + + var transaction = new Transaction + { + Date = DateTime.Now, + Amount = -50.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + CardId = 1 + }; + context.Transactions.Add(transaction); + await context.SaveChangesAsync(); + + // Act + var result = await service.DeleteCardAsync(1); + + // Assert + Assert.False(result.Success); + Assert.Single(context.Cards); + } +} diff --git a/MoneyMap.Tests/Services/MerchantServiceTests.cs b/MoneyMap.Tests/Services/MerchantServiceTests.cs new file mode 100644 index 0000000..985352d --- /dev/null +++ b/MoneyMap.Tests/Services/MerchantServiceTests.cs @@ -0,0 +1,189 @@ +using MoneyMap.Models; +using MoneyMap.Services; +using MoneyMap.Tests.TestHelpers; +using Xunit; + +namespace MoneyMap.Tests.Services; + +public class MerchantServiceTests +{ + [Fact] + public async Task GetOrCreateAsync_CreatesNewMerchant_WhenDoesNotExist() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new MerchantService(context); + + // Act + var result = await service.GetOrCreateAsync("Walmart"); + + // Assert + Assert.NotNull(result); + Assert.Equal("Walmart", result.Name); + Assert.Single(context.Merchants); + } + + [Fact] + public async Task GetOrCreateAsync_ReturnsExistingMerchant_WhenExists() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new MerchantService(context); + + var existingMerchant = new Merchant { Id = 1, Name = "Walmart" }; + context.Merchants.Add(existingMerchant); + await context.SaveChangesAsync(); + + // Act + var result = await service.GetOrCreateAsync("Walmart"); + + // Assert + Assert.NotNull(result); + Assert.Equal(1, result.Id); + Assert.Single(context.Merchants); + } + + [Fact] + public async Task GetAllMerchantsWithStatsAsync_ReturnsMerchantsWithStats() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new MerchantService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var merchant1 = new Merchant { Id = 1, Name = "Walmart" }; + var merchant2 = new Merchant { Id = 2, Name = "Target" }; + context.Merchants.AddRange(merchant1, merchant2); + + var transaction = new Transaction + { + Date = DateTime.Now, + Amount = -50.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + MerchantId = 1 + }; + context.Transactions.Add(transaction); + await context.SaveChangesAsync(); + + // Act + var result = await service.GetAllMerchantsWithStatsAsync(); + + // Assert + Assert.Equal(2, result.Count); + var walmart = result.First(m => m.Name == "Walmart"); + Assert.Equal(1, walmart.TransactionCount); + var target = result.First(m => m.Name == "Target"); + Assert.Equal(0, target.TransactionCount); + } + + [Fact] + public async Task UpdateMerchantAsync_SuccessfullyUpdatesName() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new MerchantService(context); + + var merchant = new Merchant { Id = 1, Name = "Walmart" }; + context.Merchants.Add(merchant); + await context.SaveChangesAsync(); + + // Act + var result = await service.UpdateMerchantAsync(1, "Walmart Supercenter"); + + // Assert + Assert.True(result.Success); + var updated = await context.Merchants.FindAsync(1); + Assert.Equal("Walmart Supercenter", updated!.Name); + } + + [Fact] + public async Task UpdateMerchantAsync_FailsWhenDuplicateName() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new MerchantService(context); + + var merchant1 = new Merchant { Id = 1, Name = "Walmart" }; + var merchant2 = new Merchant { Id = 2, Name = "Target" }; + context.Merchants.AddRange(merchant1, merchant2); + await context.SaveChangesAsync(); + + // Act + var result = await service.UpdateMerchantAsync(1, "Target"); + + // Assert + Assert.False(result.Success); + Assert.Contains("already exists", result.Message); + } + + [Fact] + public async Task DeleteMerchantAsync_SuccessfullyDeletes() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new MerchantService(context); + + var merchant = new Merchant { Id = 1, Name = "Walmart" }; + context.Merchants.Add(merchant); + await context.SaveChangesAsync(); + + // Act + var result = await service.DeleteMerchantAsync(1); + + // Assert + Assert.True(result.Success); + Assert.Empty(context.Merchants); + } + + [Fact] + public async Task DeleteMerchantAsync_ReportsTransactionAndMappingCounts() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new MerchantService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var merchant = new Merchant { Id = 1, Name = "Walmart" }; + context.Merchants.Add(merchant); + + var transaction = new Transaction + { + Date = DateTime.Now, + Amount = -50.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + MerchantId = 1 + }; + context.Transactions.Add(transaction); + await context.SaveChangesAsync(); + + // Act + var result = await service.DeleteMerchantAsync(1); + + // Assert + Assert.True(result.Success); + Assert.Equal(1, result.TransactionCount); + Assert.Contains("1 transaction", result.Message); + } +} diff --git a/MoneyMap.Tests/Services/ReceiptMatchingServiceTests.cs b/MoneyMap.Tests/Services/ReceiptMatchingServiceTests.cs new file mode 100644 index 0000000..9b74428 --- /dev/null +++ b/MoneyMap.Tests/Services/ReceiptMatchingServiceTests.cs @@ -0,0 +1,330 @@ +using MoneyMap.Models; +using MoneyMap.Services; +using MoneyMap.Tests.TestHelpers; +using Xunit; + +namespace MoneyMap.Tests.Services; + +public class ReceiptMatchingServiceTests +{ + [Fact] + public async Task GetTransactionIdsWithReceiptsAsync_ReturnsTransactionIds() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new ReceiptMatchingService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var transaction1 = new Transaction + { + Id = 1, + Date = DateTime.Now, + Amount = -50.00m, + Name = "Store A", + Memo = "Test", + AccountId = 1 + }; + var transaction2 = new Transaction + { + Id = 2, + Date = DateTime.Now, + Amount = -30.00m, + Name = "Store B", + Memo = "Test", + AccountId = 1 + }; + context.Transactions.AddRange(transaction1, transaction2); + + var receipt = new Receipt + { + Id = 1, + TransactionId = 1, + FileName = "receipt.pdf", + ContentType = "application/pdf", + StoragePath = "/receipts/receipt.pdf", + FileSizeBytes = 1024, + FileHashSha256 = "hash123", + UploadedAtUtc = DateTime.UtcNow + }; + context.Receipts.Add(receipt); + await context.SaveChangesAsync(); + + // Act + var result = await service.GetTransactionIdsWithReceiptsAsync(); + + // Assert + Assert.Single(result); + Assert.Contains(1L, result); + Assert.DoesNotContain(2L, result); + } + + [Fact] + public async Task FindMatchingTransactionsAsync_FiltersByDateRange() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new ReceiptMatchingService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var receiptDate = new DateTime(2025, 1, 15); + + var withinRange = new Transaction + { + Id = 1, + Date = receiptDate.AddDays(2), // Within +/- 3 days + Amount = -50.00m, + Name = "Store A", + Memo = "Test", + AccountId = 1 + }; + var outsideRange = new Transaction + { + Id = 2, + Date = receiptDate.AddDays(5), // Outside +/- 3 days + Amount = -50.00m, + Name = "Store B", + Memo = "Test", + AccountId = 1 + }; + context.Transactions.AddRange(withinRange, outsideRange); + await context.SaveChangesAsync(); + + var criteria = new ReceiptMatchCriteria + { + ReceiptDate = receiptDate, + ExcludeTransactionIds = new HashSet() + }; + + // Act + var result = await service.FindMatchingTransactionsAsync(criteria); + + // Assert + Assert.Single(result); + Assert.Equal(1, result[0].Id); + } + + [Fact] + public async Task FindMatchingTransactionsAsync_FiltersByAmountTolerance() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new ReceiptMatchingService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var receiptDate = DateTime.Now; + var receiptTotal = 100.00m; + + var withinTolerance = new Transaction + { + Id = 1, + Date = receiptDate, + Amount = -105.00m, // Within 10% tolerance + Name = "Store A", + Memo = "Test", + AccountId = 1 + }; + var outsideTolerance = new Transaction + { + Id = 2, + Date = receiptDate, + Amount = -150.00m, // Outside 10% tolerance + Name = "Store B", + Memo = "Test", + AccountId = 1 + }; + context.Transactions.AddRange(withinTolerance, outsideTolerance); + await context.SaveChangesAsync(); + + var criteria = new ReceiptMatchCriteria + { + ReceiptDate = receiptDate, + Total = receiptTotal, + ExcludeTransactionIds = new HashSet() + }; + + // Act + var result = await service.FindMatchingTransactionsAsync(criteria); + + // Assert + Assert.Single(result); + Assert.Equal(1, result[0].Id); + Assert.True(result[0].IsCloseAmount); + } + + [Fact] + public async Task FindMatchingTransactionsAsync_MarksExactAmountMatch() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new ReceiptMatchingService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var receiptDate = DateTime.Now; + var receiptTotal = 100.00m; + + var exactMatch = new Transaction + { + Id = 1, + Date = receiptDate, + Amount = -100.00m, // Exact match + Name = "Store A", + Memo = "Test", + AccountId = 1 + }; + context.Transactions.Add(exactMatch); + await context.SaveChangesAsync(); + + var criteria = new ReceiptMatchCriteria + { + ReceiptDate = receiptDate, + Total = receiptTotal, + ExcludeTransactionIds = new HashSet() + }; + + // Act + var result = await service.FindMatchingTransactionsAsync(criteria); + + // Assert + Assert.Single(result); + Assert.True(result[0].IsExactAmount); + Assert.False(result[0].IsCloseAmount); + } + + [Fact] + public async Task FindMatchingTransactionsAsync_ExcludesTransactionsWithReceipts() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new ReceiptMatchingService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var receiptDate = DateTime.Now; + + var transaction1 = new Transaction + { + Id = 1, + Date = receiptDate, + Amount = -50.00m, + Name = "Store A", + Memo = "Test", + AccountId = 1 + }; + var transaction2 = new Transaction + { + Id = 2, + Date = receiptDate, + Amount = -50.00m, + Name = "Store B", + Memo = "Test", + AccountId = 1 + }; + context.Transactions.AddRange(transaction1, transaction2); + await context.SaveChangesAsync(); + + var criteria = new ReceiptMatchCriteria + { + ReceiptDate = receiptDate, + ExcludeTransactionIds = new HashSet { 1 } // Exclude transaction 1 + }; + + // Act + var result = await service.FindMatchingTransactionsAsync(criteria); + + // Assert + Assert.Single(result); + Assert.Equal(2, result[0].Id); + } + + [Fact] + public async Task FindMatchingTransactionsAsync_UsesDueDateForBills() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new ReceiptMatchingService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var receiptDate = new DateTime(2025, 1, 1); + var dueDate = new DateTime(2025, 1, 15); + + // Transaction on due date + 3 days (should match for bills) + var transaction = new Transaction + { + Id = 1, + Date = dueDate.AddDays(3), + Amount = -50.00m, + Name = "Utility Company", + Memo = "Test", + AccountId = 1 + }; + context.Transactions.Add(transaction); + await context.SaveChangesAsync(); + + var criteria = new ReceiptMatchCriteria + { + ReceiptDate = receiptDate, + DueDate = dueDate, // Bill with due date + ExcludeTransactionIds = new HashSet() + }; + + // Act + var result = await service.FindMatchingTransactionsAsync(criteria); + + // Assert + Assert.Single(result); + Assert.Equal(1, result[0].Id); + } +} diff --git a/MoneyMap.Tests/Services/ReferenceDataServiceTests.cs b/MoneyMap.Tests/Services/ReferenceDataServiceTests.cs new file mode 100644 index 0000000..22bd9db --- /dev/null +++ b/MoneyMap.Tests/Services/ReferenceDataServiceTests.cs @@ -0,0 +1,221 @@ +using MoneyMap.Models; +using MoneyMap.Services; +using MoneyMap.Tests.TestHelpers; +using Xunit; + +namespace MoneyMap.Tests.Services; + +public class ReferenceDataServiceTests +{ + [Fact] + public async Task GetAvailableCategoriesAsync_ReturnsDistinctSortedCategories() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new ReferenceDataService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var transaction1 = new Transaction + { + Date = DateTime.Now, + Amount = -50.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + Category = "Groceries" + }; + var transaction2 = new Transaction + { + Date = DateTime.Now, + Amount = -30.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + Category = "Gas" + }; + var transaction3 = new Transaction + { + Date = DateTime.Now, + Amount = -20.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + Category = "Groceries" // Duplicate + }; + context.Transactions.AddRange(transaction1, transaction2, transaction3); + await context.SaveChangesAsync(); + + // Act + var result = await service.GetAvailableCategoriesAsync(); + + // Assert + Assert.Equal(2, result.Count); + Assert.Contains("Groceries", result); + Assert.Contains("Gas", result); + Assert.Equal("Gas", result[0]); // Alphabetically sorted + Assert.Equal("Groceries", result[1]); + } + + [Fact] + public async Task GetAvailableCategoriesAsync_ExcludesEmptyCategories() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new ReferenceDataService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var transaction1 = new Transaction + { + Date = DateTime.Now, + Amount = -50.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + Category = "Groceries" + }; + var transaction2 = new Transaction + { + Date = DateTime.Now, + Amount = -30.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + Category = "" // Empty + }; + var transaction3 = new Transaction + { + Date = DateTime.Now, + Amount = -20.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + Category = " " // Whitespace + }; + context.Transactions.AddRange(transaction1, transaction2, transaction3); + await context.SaveChangesAsync(); + + // Act + var result = await service.GetAvailableCategoriesAsync(); + + // Assert + Assert.Single(result); + Assert.Equal("Groceries", result[0]); + } + + [Fact] + public async Task GetAvailableMerchantsAsync_ReturnsSortedMerchants() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new ReferenceDataService(context); + + var merchant1 = new Merchant { Name = "Walmart" }; + var merchant2 = new Merchant { Name = "Amazon" }; + var merchant3 = new Merchant { Name = "Target" }; + context.Merchants.AddRange(merchant1, merchant2, merchant3); + await context.SaveChangesAsync(); + + // Act + var result = await service.GetAvailableMerchantsAsync(); + + // Assert + Assert.Equal(3, result.Count); + Assert.Equal("Amazon", result[0].Name); // Alphabetically sorted + Assert.Equal("Target", result[1].Name); + Assert.Equal("Walmart", result[2].Name); + } + + [Fact] + public async Task GetAvailableCardsAsync_ReturnsSortedCards() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new ReferenceDataService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var card1 = new Card + { + AccountId = 1, + Issuer = "VISA", + Last4 = "3333", + Owner = "Bob" + }; + var card2 = new Card + { + AccountId = 1, + Issuer = "Mastercard", + Last4 = "1111", + Owner = "Alice" + }; + context.Cards.AddRange(card1, card2); + await context.SaveChangesAsync(); + + // Act + var result = await service.GetAvailableCardsAsync(); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("Alice", result[0].Owner); // Sorted by owner + Assert.Equal("Bob", result[1].Owner); + } + + [Fact] + public async Task GetAvailableAccountsAsync_ReturnsSortedAccounts() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new ReferenceDataService(context); + + var account1 = new Account + { + Institution = "Wells Fargo", + AccountType = AccountType.Checking, + Last4 = "5678", + Owner = "Test" + }; + var account2 = new Account + { + Institution = "Bank of America", + AccountType = AccountType.Savings, + Last4 = "1234", + Owner = "Test" + }; + context.Accounts.AddRange(account1, account2); + await context.SaveChangesAsync(); + + // Act + var result = await service.GetAvailableAccountsAsync(); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("Bank of America", result[0].Institution); // Sorted by institution + Assert.Equal("Wells Fargo", result[1].Institution); + } +} diff --git a/MoneyMap.Tests/Services/TransactionServiceTests.cs b/MoneyMap.Tests/Services/TransactionServiceTests.cs new file mode 100644 index 0000000..1ad8e2e --- /dev/null +++ b/MoneyMap.Tests/Services/TransactionServiceTests.cs @@ -0,0 +1,229 @@ +using MoneyMap.Models; +using MoneyMap.Services; +using MoneyMap.Tests.TestHelpers; +using Xunit; + +namespace MoneyMap.Tests.Services; + +public class TransactionServiceTests +{ + [Fact] + public async Task IsDuplicateAsync_ReturnsFalse_WhenTransactionDoesNotExist() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new TransactionService(context); + + var transaction = new Transaction + { + Date = DateTime.Now, + Amount = -50.00m, + Name = "Test Store", + Memo = "Test purchase", + AccountId = 1, + CardId = 1 + }; + + // Act + var result = await service.IsDuplicateAsync(transaction); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task IsDuplicateAsync_ReturnsTrue_WhenExactDuplicateExists() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new TransactionService(context); + + // Add an account first (required for transaction) + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var existingTransaction = new Transaction + { + Date = new DateTime(2025, 1, 15), + Amount = -50.00m, + Name = "Test Store", + Memo = "Test purchase", + AccountId = 1, + CardId = 1 + }; + context.Transactions.Add(existingTransaction); + await context.SaveChangesAsync(); + + var duplicateTransaction = new Transaction + { + Date = new DateTime(2025, 1, 15), + Amount = -50.00m, + Name = "Test Store", + Memo = "Test purchase", + AccountId = 1, + CardId = 1 + }; + + // Act + var result = await service.IsDuplicateAsync(duplicateTransaction); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task IsDuplicateAsync_ReturnsFalse_WhenAmountDiffers() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new TransactionService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var existingTransaction = new Transaction + { + Date = new DateTime(2025, 1, 15), + Amount = -50.00m, + Name = "Test Store", + Memo = "Test purchase", + AccountId = 1, + CardId = 1 + }; + context.Transactions.Add(existingTransaction); + await context.SaveChangesAsync(); + + var differentTransaction = new Transaction + { + Date = new DateTime(2025, 1, 15), + Amount = -51.00m, // Different amount + Name = "Test Store", + Memo = "Test purchase", + AccountId = 1, + CardId = 1 + }; + + // Act + var result = await service.IsDuplicateAsync(differentTransaction); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task GetTransactionByIdAsync_ReturnsNull_WhenTransactionDoesNotExist() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new TransactionService(context); + + // Act + var result = await service.GetTransactionByIdAsync(999); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task GetTransactionByIdAsync_ReturnsTransaction_WhenExists() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new TransactionService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var transaction = new Transaction + { + Id = 1, + Date = DateTime.Now, + Amount = -50.00m, + Name = "Test Store", + Memo = "Test purchase", + AccountId = 1 + }; + context.Transactions.Add(transaction); + await context.SaveChangesAsync(); + + // Act + var result = await service.GetTransactionByIdAsync(1); + + // Assert + Assert.NotNull(result); + Assert.Equal("Test Store", result.Name); + Assert.Equal(-50.00m, result.Amount); + } + + [Fact] + public async Task DeleteTransactionAsync_ReturnsTrue_WhenTransactionExists() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new TransactionService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var transaction = new Transaction + { + Id = 1, + Date = DateTime.Now, + Amount = -50.00m, + Name = "Test Store", + Memo = "Test purchase", + AccountId = 1 + }; + context.Transactions.Add(transaction); + await context.SaveChangesAsync(); + + // Act + var result = await service.DeleteTransactionAsync(1); + + // Assert + Assert.True(result); + Assert.Empty(context.Transactions); + } + + [Fact] + public async Task DeleteTransactionAsync_ReturnsFalse_WhenTransactionDoesNotExist() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new TransactionService(context); + + // Act + var result = await service.DeleteTransactionAsync(999); + + // Assert + Assert.False(result); + } +} diff --git a/MoneyMap.Tests/Services/TransactionStatisticsServiceTests.cs b/MoneyMap.Tests/Services/TransactionStatisticsServiceTests.cs new file mode 100644 index 0000000..68d13db --- /dev/null +++ b/MoneyMap.Tests/Services/TransactionStatisticsServiceTests.cs @@ -0,0 +1,188 @@ +using MoneyMap.Models; +using MoneyMap.Services; +using MoneyMap.Tests.TestHelpers; +using Xunit; + +namespace MoneyMap.Tests.Services; + +public class TransactionStatisticsServiceTests +{ + [Fact] + public async Task CalculateStatsAsync_ReturnsCorrectStatistics() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new TransactionStatisticsService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var debit1 = new Transaction + { + Date = DateTime.Now, + Amount = -50.00m, + Name = "Store A", + Memo = "Purchase", + AccountId = 1 + }; + var debit2 = new Transaction + { + Date = DateTime.Now, + Amount = -30.00m, + Name = "Store B", + Memo = "Purchase", + AccountId = 1 + }; + var credit = new Transaction + { + Date = DateTime.Now, + Amount = 100.00m, + Name = "Deposit", + Memo = "Paycheck", + AccountId = 1 + }; + context.Transactions.AddRange(debit1, debit2, credit); + await context.SaveChangesAsync(); + + var query = context.Transactions.AsQueryable(); + + // Act + var result = await service.CalculateStatsAsync(query); + + // Assert + Assert.Equal(3, result.Count); + Assert.Equal(-80.00m, result.TotalDebits); + Assert.Equal(100.00m, result.TotalCredits); + Assert.Equal(20.00m, result.NetAmount); + } + + [Fact] + public async Task GetCategorizationStatsAsync_ReturnsCorrectCounts() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new TransactionStatisticsService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var categorized1 = new Transaction + { + Date = DateTime.Now, + Amount = -50.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + Category = "Groceries" + }; + var categorized2 = new Transaction + { + Date = DateTime.Now, + Amount = -30.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + Category = "Gas" + }; + var uncategorized = new Transaction + { + Date = DateTime.Now, + Amount = -20.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + Category = "" + }; + context.Transactions.AddRange(categorized1, categorized2, uncategorized); + await context.SaveChangesAsync(); + + // Act + var result = await service.GetCategorizationStatsAsync(); + + // Assert + Assert.Equal(3, result.TotalTransactions); + Assert.Equal(2, result.Categorized); + Assert.Equal(1, result.Uncategorized); + } + + [Fact] + public async Task GetCardStatsForAccountAsync_ReturnsStatsForLinkedCards() + { + // Arrange + using var context = DbContextHelper.CreateInMemoryContext(); + var service = new TransactionStatisticsService(context); + + var account = new Account + { + Id = 1, + Institution = "Test Bank", + AccountType = AccountType.Checking, + Last4 = "1234", + Owner = "Test Owner" + }; + context.Accounts.Add(account); + + var card1 = new Card + { + Id = 1, + AccountId = 1, + Issuer = "VISA", + Last4 = "1111", + Owner = "Test" + }; + var card2 = new Card + { + Id = 2, + AccountId = 1, + Issuer = "Mastercard", + Last4 = "2222", + Owner = "Test" + }; + context.Cards.AddRange(card1, card2); + + var transaction1 = new Transaction + { + Date = DateTime.Now, + Amount = -50.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + CardId = 1 + }; + var transaction2 = new Transaction + { + Date = DateTime.Now, + Amount = -30.00m, + Name = "Test", + Memo = "Test", + AccountId = 1, + CardId = 1 + }; + context.Transactions.AddRange(transaction1, transaction2); + await context.SaveChangesAsync(); + + // Act + var result = await service.GetCardStatsForAccountAsync(1); + + // Assert + Assert.Equal(2, result.Count); + var card1Stats = result.First(c => c.Card.Id == 1); + Assert.Equal(2, card1Stats.TransactionCount); + var card2Stats = result.First(c => c.Card.Id == 2); + Assert.Equal(0, card2Stats.TransactionCount); + } +} diff --git a/MoneyMap.Tests/TestHelpers/DbContextHelper.cs b/MoneyMap.Tests/TestHelpers/DbContextHelper.cs new file mode 100644 index 0000000..780d34f --- /dev/null +++ b/MoneyMap.Tests/TestHelpers/DbContextHelper.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; + +namespace MoneyMap.Tests.TestHelpers; + +public static class DbContextHelper +{ + /// + /// Creates an in-memory database context for testing. + /// Each call creates a unique database to ensure test isolation. + /// + public static MoneyMapContext CreateInMemoryContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + return new MoneyMapContext(options); + } +} diff --git a/MoneyMap.sln b/MoneyMap.sln index 8576927..a914c76 100644 --- a/MoneyMap.sln +++ b/MoneyMap.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 17.14.36429.23 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MoneyMap", "MoneyMap\MoneyMap.csproj", "{B273A467-3592-4675-B1EC-C41C9CE455DB}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MoneyMap.Tests", "MoneyMap.Tests\MoneyMap.Tests.csproj", "{4CAD4283-4E2D-B998-4839-03B72BDDBEF5}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +17,10 @@ Global {B273A467-3592-4675-B1EC-C41C9CE455DB}.Debug|Any CPU.Build.0 = Debug|Any CPU {B273A467-3592-4675-B1EC-C41C9CE455DB}.Release|Any CPU.ActiveCfg = Release|Any CPU {B273A467-3592-4675-B1EC-C41C9CE455DB}.Release|Any CPU.Build.0 = Release|Any CPU + {4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4CAD4283-4E2D-B998-4839-03B72BDDBEF5}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE