diff --git a/BudgetApp.Tests/BudgetApp.Tests.csproj b/BudgetApp.Tests/BudgetApp.Tests.csproj new file mode 100644 index 0000000..3ae0926 --- /dev/null +++ b/BudgetApp.Tests/BudgetApp.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/BudgetApp.Tests/Helpers/TestDbContextFactory.cs b/BudgetApp.Tests/Helpers/TestDbContextFactory.cs new file mode 100644 index 0000000..6418c0c --- /dev/null +++ b/BudgetApp.Tests/Helpers/TestDbContextFactory.cs @@ -0,0 +1,51 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using BudgetApp.Data; + +namespace BudgetApp.Tests.Helpers; + +public sealed class TestDbContextFactory : IDisposable +{ + private readonly SqliteConnection _connection; + + public const string TestUserId = "test-user-id"; + public const string OtherUserId = "other-user-id"; + + public TestDbContextFactory() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + using var context = CreateContext(); + context.Database.EnsureCreated(); + + context.Users.AddRange( + new ApplicationUser + { + Id = TestUserId, + UserName = "test@test.com", + NormalizedUserName = "TEST@TEST.COM", + Email = "test@test.com", + NormalizedEmail = "TEST@TEST.COM" + }, + new ApplicationUser + { + Id = OtherUserId, + UserName = "other@test.com", + NormalizedUserName = "OTHER@TEST.COM", + Email = "other@test.com", + NormalizedEmail = "OTHER@TEST.COM" + }); + context.SaveChanges(); + } + + public ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + return new ApplicationDbContext(options); + } + + public void Dispose() => _connection.Dispose(); +} diff --git a/BudgetApp.Tests/Models/AccountModelTests.cs b/BudgetApp.Tests/Models/AccountModelTests.cs new file mode 100644 index 0000000..e256c0c --- /dev/null +++ b/BudgetApp.Tests/Models/AccountModelTests.cs @@ -0,0 +1,63 @@ +using BudgetApp.Data; +using Xunit; + +namespace BudgetApp.Tests.Models; + +public class AccountModelTests +{ + [Theory] + [InlineData("GBP", "£")] + [InlineData("USD", "$")] + [InlineData("EUR", "€")] + [InlineData("JPY", "¥")] + [InlineData("INR", "₹")] + public void GetSymbol_KnownCurrency_ReturnsSymbol(string code, string expected) + { + var account = new Account { CurrencyCode = code }; + Assert.Equal(expected, account.GetSymbol()); + } + + [Fact] + public void GetSymbol_UnknownCurrency_ReturnsCurrencyCode() + { + var account = new Account { CurrencyCode = "XYZ" }; + Assert.Equal("XYZ", account.GetSymbol()); + } + + [Fact] + public void FormatAmount_FormatsWithSymbolAndTwoDecimals() + { + var account = new Account { CurrencyCode = "GBP" }; + Assert.Equal("£1,234.56", account.FormatAmount(1234.56m)); + } + + [Fact] + public void FormatAmount_NegativeAmount_IncludesMinusSign() + { + var account = new Account { CurrencyCode = "USD" }; + Assert.Equal("$-50.00", account.FormatAmount(-50m)); + } + + [Fact] + public void FormatAmount_Zero_FormatsCorrectly() + { + var account = new Account { CurrencyCode = "EUR" }; + Assert.Equal("€0.00", account.FormatAmount(0m)); + } + + [Fact] + public void CurrencySymbols_ContainsExpectedCount() + { + Assert.Equal(20, Account.CurrencySymbols.Count); + } + + [Fact] + public void DefaultValues_AreCorrect() + { + var account = new Account(); + Assert.Equal(string.Empty, account.Name); + Assert.Equal(0m, account.CurrentBalance); + Assert.Equal("#1b6ec2", account.Color); + Assert.Equal("GBP", account.CurrencyCode); + } +} diff --git a/BudgetApp.Tests/Services/AccountServiceTests.cs b/BudgetApp.Tests/Services/AccountServiceTests.cs new file mode 100644 index 0000000..2556b01 --- /dev/null +++ b/BudgetApp.Tests/Services/AccountServiceTests.cs @@ -0,0 +1,238 @@ +using BudgetApp.Data; +using BudgetApp.Services; +using BudgetApp.Tests.Helpers; +using Xunit; + +namespace BudgetApp.Tests.Services; + +public class AccountServiceTests +{ + [Fact] + public async Task GetAccountsAsync_ReturnsOnlyUserAccounts() + { + using var factory = new TestDbContextFactory(); + using (var ctx = factory.CreateContext()) + { + ctx.Accounts.AddRange( + new Account { Name = "Mine", UserId = TestDbContextFactory.TestUserId }, + new Account { Name = "Theirs", UserId = TestDbContextFactory.OtherUserId }); + await ctx.SaveChangesAsync(); + } + + using var context = factory.CreateContext(); + var service = new AccountService(context); + + var result = await service.GetAccountsAsync(TestDbContextFactory.TestUserId); + + Assert.Single(result); + Assert.Equal("Mine", result[0].Name); + } + + [Fact] + public async Task GetAccountsAsync_OrderedByName() + { + using var factory = new TestDbContextFactory(); + using (var ctx = factory.CreateContext()) + { + ctx.Accounts.AddRange( + new Account { Name = "Zebra", UserId = TestDbContextFactory.TestUserId }, + new Account { Name = "Alpha", UserId = TestDbContextFactory.TestUserId }); + await ctx.SaveChangesAsync(); + } + + using var context = factory.CreateContext(); + var service = new AccountService(context); + + var result = await service.GetAccountsAsync(TestDbContextFactory.TestUserId); + + Assert.Equal("Alpha", result[0].Name); + Assert.Equal("Zebra", result[1].Name); + } + + [Fact] + public async Task GetAccountAsync_ReturnsMatchingAccount() + { + using var factory = new TestDbContextFactory(); + int accountId; + using (var ctx = factory.CreateContext()) + { + var account = new Account { Name = "Test", UserId = TestDbContextFactory.TestUserId }; + ctx.Accounts.Add(account); + await ctx.SaveChangesAsync(); + accountId = account.Id; + } + + using var context = factory.CreateContext(); + var service = new AccountService(context); + + var result = await service.GetAccountAsync(accountId, TestDbContextFactory.TestUserId); + + Assert.NotNull(result); + Assert.Equal("Test", result.Name); + } + + [Fact] + public async Task GetAccountAsync_WrongUser_ReturnsNull() + { + using var factory = new TestDbContextFactory(); + int accountId; + using (var ctx = factory.CreateContext()) + { + var account = new Account { Name = "Test", UserId = TestDbContextFactory.TestUserId }; + ctx.Accounts.Add(account); + await ctx.SaveChangesAsync(); + accountId = account.Id; + } + + using var context = factory.CreateContext(); + var service = new AccountService(context); + + var result = await service.GetAccountAsync(accountId, TestDbContextFactory.OtherUserId); + + Assert.Null(result); + } + + [Fact] + public async Task CreateAccountAsync_AddsToDatabase() + { + using var factory = new TestDbContextFactory(); + using var context = factory.CreateContext(); + var service = new AccountService(context); + + var account = new Account { Name = "New", UserId = TestDbContextFactory.TestUserId, CurrencyCode = "GBP" }; + await service.CreateAccountAsync(account); + + using var verifyCtx = factory.CreateContext(); + Assert.Single(verifyCtx.Accounts); + } + + [Fact] + public async Task UpdateBalanceAsync_UpdatesBalance() + { + using var factory = new TestDbContextFactory(); + int accountId; + using (var ctx = factory.CreateContext()) + { + var account = new Account { Name = "Test", CurrentBalance = 100m, UserId = TestDbContextFactory.TestUserId }; + ctx.Accounts.Add(account); + await ctx.SaveChangesAsync(); + accountId = account.Id; + } + + using var context = factory.CreateContext(); + var service = new AccountService(context); + await service.UpdateBalanceAsync(accountId, TestDbContextFactory.TestUserId, 250m); + + using var verifyCtx = factory.CreateContext(); + var updated = verifyCtx.Accounts.Single(); + Assert.Equal(250m, updated.CurrentBalance); + } + + [Fact] + public async Task UpdateBalanceAsync_WrongUser_DoesNothing() + { + using var factory = new TestDbContextFactory(); + int accountId; + using (var ctx = factory.CreateContext()) + { + var account = new Account { Name = "Test", CurrentBalance = 100m, UserId = TestDbContextFactory.TestUserId }; + ctx.Accounts.Add(account); + await ctx.SaveChangesAsync(); + accountId = account.Id; + } + + using var context = factory.CreateContext(); + var service = new AccountService(context); + await service.UpdateBalanceAsync(accountId, TestDbContextFactory.OtherUserId, 999m); + + using var verifyCtx = factory.CreateContext(); + Assert.Equal(100m, verifyCtx.Accounts.Single().CurrentBalance); + } + + [Fact] + public async Task UpdateAccountDetailsAsync_UpdatesFields() + { + using var factory = new TestDbContextFactory(); + int accountId; + using (var ctx = factory.CreateContext()) + { + var account = new Account { Name = "Old", Color = "#000", CurrencyCode = "GBP", UserId = TestDbContextFactory.TestUserId }; + ctx.Accounts.Add(account); + await ctx.SaveChangesAsync(); + accountId = account.Id; + } + + using var context = factory.CreateContext(); + var service = new AccountService(context); + await service.UpdateAccountDetailsAsync(accountId, TestDbContextFactory.TestUserId, "New", "#fff", "USD"); + + using var verifyCtx = factory.CreateContext(); + var updated = verifyCtx.Accounts.Single(); + Assert.Equal("New", updated.Name); + Assert.Equal("#fff", updated.Color); + Assert.Equal("USD", updated.CurrencyCode); + } + + [Fact] + public async Task UpdateAccountDetailsAsync_WrongUser_DoesNothing() + { + using var factory = new TestDbContextFactory(); + int accountId; + using (var ctx = factory.CreateContext()) + { + var account = new Account { Name = "Original", Color = "#000", CurrencyCode = "GBP", UserId = TestDbContextFactory.TestUserId }; + ctx.Accounts.Add(account); + await ctx.SaveChangesAsync(); + accountId = account.Id; + } + + using var context = factory.CreateContext(); + var service = new AccountService(context); + await service.UpdateAccountDetailsAsync(accountId, TestDbContextFactory.OtherUserId, "Hacked", "#fff", "USD"); + + using var verifyCtx = factory.CreateContext(); + Assert.Equal("Original", verifyCtx.Accounts.Single().Name); + } + + [Fact] + public async Task DeleteAccountAsync_RemovesAccount() + { + using var factory = new TestDbContextFactory(); + int accountId; + using (var ctx = factory.CreateContext()) + { + var account = new Account { Name = "ToDelete", UserId = TestDbContextFactory.TestUserId }; + ctx.Accounts.Add(account); + await ctx.SaveChangesAsync(); + accountId = account.Id; + } + + using var context = factory.CreateContext(); + var service = new AccountService(context); + await service.DeleteAccountAsync(accountId, TestDbContextFactory.TestUserId); + + using var verifyCtx = factory.CreateContext(); + Assert.Empty(verifyCtx.Accounts); + } + + [Fact] + public async Task DeleteAccountAsync_WrongUser_DoesNothing() + { + using var factory = new TestDbContextFactory(); + int accountId; + using (var ctx = factory.CreateContext()) + { + var account = new Account { Name = "Protected", UserId = TestDbContextFactory.TestUserId }; + ctx.Accounts.Add(account); + await ctx.SaveChangesAsync(); + accountId = account.Id; + } + + using var context = factory.CreateContext(); + var service = new AccountService(context); + await service.DeleteAccountAsync(accountId, TestDbContextFactory.OtherUserId); + + using var verifyCtx = factory.CreateContext(); + Assert.Single(verifyCtx.Accounts); + } +} diff --git a/BudgetApp.Tests/Services/CategoryServiceTests.cs b/BudgetApp.Tests/Services/CategoryServiceTests.cs new file mode 100644 index 0000000..f327c77 --- /dev/null +++ b/BudgetApp.Tests/Services/CategoryServiceTests.cs @@ -0,0 +1,275 @@ +using BudgetApp.Data; +using BudgetApp.Services; +using BudgetApp.Tests.Helpers; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace BudgetApp.Tests.Services; + +public class CategoryServiceTests +{ + [Fact] + public async Task GetCategoriesAsync_ReturnsOnlyUserCategories() + { + using var factory = new TestDbContextFactory(); + using (var ctx = factory.CreateContext()) + { + ctx.Categories.AddRange( + new Category { Name = "Mine", UserId = TestDbContextFactory.TestUserId }, + new Category { Name = "Theirs", UserId = TestDbContextFactory.OtherUserId }); + await ctx.SaveChangesAsync(); + } + + using var context = factory.CreateContext(); + var service = new CategoryService(context); + + var result = await service.GetCategoriesAsync(TestDbContextFactory.TestUserId); + + Assert.Single(result); + Assert.Equal("Mine", result[0].Name); + } + + [Fact] + public async Task GetCategoriesAsync_OrderedByName() + { + using var factory = new TestDbContextFactory(); + using (var ctx = factory.CreateContext()) + { + ctx.Categories.AddRange( + new Category { Name = "Zebra", UserId = TestDbContextFactory.TestUserId }, + new Category { Name = "Alpha", UserId = TestDbContextFactory.TestUserId }); + await ctx.SaveChangesAsync(); + } + + using var context = factory.CreateContext(); + var service = new CategoryService(context); + + var result = await service.GetCategoriesAsync(TestDbContextFactory.TestUserId); + + Assert.Equal("Alpha", result[0].Name); + Assert.Equal("Zebra", result[1].Name); + } + + [Fact] + public async Task EnsureDefaultCategoriesAsync_CreatesDefaults_WhenEmpty() + { + using var factory = new TestDbContextFactory(); + using var context = factory.CreateContext(); + var service = new CategoryService(context); + + await service.EnsureDefaultCategoriesAsync(TestDbContextFactory.TestUserId); + + using var verifyCtx = factory.CreateContext(); + var categories = await verifyCtx.Categories + .Where(c => c.UserId == TestDbContextFactory.TestUserId) + .ToListAsync(); + + Assert.Equal(15, categories.Count); + Assert.Contains(categories, c => c.Name == "Groceries" && c.Icon == "🛒"); + Assert.Contains(categories, c => c.Name == "Transport" && c.Icon == "🚗"); + Assert.Contains(categories, c => c.Name == "Salary" && c.Icon == "💰"); + } + + [Fact] + public async Task EnsureDefaultCategoriesAsync_DoesNotDuplicate_WhenCalledAgain() + { + using var factory = new TestDbContextFactory(); + + using (var ctx = factory.CreateContext()) + { + var service = new CategoryService(ctx); + await service.EnsureDefaultCategoriesAsync(TestDbContextFactory.TestUserId); + } + + using (var ctx = factory.CreateContext()) + { + var service = new CategoryService(ctx); + await service.EnsureDefaultCategoriesAsync(TestDbContextFactory.TestUserId); + } + + using var verifyCtx = factory.CreateContext(); + var count = await verifyCtx.Categories + .Where(c => c.UserId == TestDbContextFactory.TestUserId) + .CountAsync(); + + Assert.Equal(15, count); + } + + [Fact] + public async Task EnsureDefaultCategoriesAsync_BackfillsIcons_WhenMissing() + { + using var factory = new TestDbContextFactory(); + using (var ctx = factory.CreateContext()) + { + ctx.Categories.Add(new Category + { + Name = "Groceries", + Icon = "", + UserId = TestDbContextFactory.TestUserId + }); + await ctx.SaveChangesAsync(); + } + + using var context = factory.CreateContext(); + var service = new CategoryService(context); + await service.EnsureDefaultCategoriesAsync(TestDbContextFactory.TestUserId); + + using var verifyCtx = factory.CreateContext(); + var category = await verifyCtx.Categories + .SingleAsync(c => c.Name == "Groceries" && c.UserId == TestDbContextFactory.TestUserId); + + Assert.Equal("🛒", category.Icon); + } + + [Fact] + public async Task EnsureDefaultCategoriesAsync_DoesNotOverwrite_ExistingIcons() + { + using var factory = new TestDbContextFactory(); + using (var ctx = factory.CreateContext()) + { + ctx.Categories.Add(new Category + { + Name = "Groceries", + Icon = "🥕", + UserId = TestDbContextFactory.TestUserId + }); + await ctx.SaveChangesAsync(); + } + + using var context = factory.CreateContext(); + var service = new CategoryService(context); + await service.EnsureDefaultCategoriesAsync(TestDbContextFactory.TestUserId); + + using var verifyCtx = factory.CreateContext(); + var category = await verifyCtx.Categories + .SingleAsync(c => c.Name == "Groceries" && c.UserId == TestDbContextFactory.TestUserId); + + Assert.Equal("🥕", category.Icon); + } + + [Fact] + public async Task EnsureDefaultCategoriesAsync_IsolatedPerUser() + { + using var factory = new TestDbContextFactory(); + + using (var ctx = factory.CreateContext()) + { + var service = new CategoryService(ctx); + await service.EnsureDefaultCategoriesAsync(TestDbContextFactory.TestUserId); + } + + using (var ctx = factory.CreateContext()) + { + var service = new CategoryService(ctx); + await service.EnsureDefaultCategoriesAsync(TestDbContextFactory.OtherUserId); + } + + using var verifyCtx = factory.CreateContext(); + var totalCount = await verifyCtx.Categories.CountAsync(); + Assert.Equal(30, totalCount); + } + + [Fact] + public async Task CreateCategoryAsync_AddsToDatabase() + { + using var factory = new TestDbContextFactory(); + using var context = factory.CreateContext(); + var service = new CategoryService(context); + + await service.CreateCategoryAsync(new Category + { + Name = "Custom", + Icon = "🎯", + UserId = TestDbContextFactory.TestUserId + }); + + using var verifyCtx = factory.CreateContext(); + Assert.Single(verifyCtx.Categories); + } + + [Fact] + public async Task UpdateCategoryAsync_UpdatesNameAndIcon() + { + using var factory = new TestDbContextFactory(); + int categoryId; + using (var ctx = factory.CreateContext()) + { + var category = new Category { Name = "Old", Icon = "📁", UserId = TestDbContextFactory.TestUserId }; + ctx.Categories.Add(category); + await ctx.SaveChangesAsync(); + categoryId = category.Id; + } + + using var context = factory.CreateContext(); + var service = new CategoryService(context); + await service.UpdateCategoryAsync(categoryId, TestDbContextFactory.TestUserId, "New", "🎯"); + + using var verifyCtx = factory.CreateContext(); + var updated = verifyCtx.Categories.Single(); + Assert.Equal("New", updated.Name); + Assert.Equal("🎯", updated.Icon); + } + + [Fact] + public async Task UpdateCategoryAsync_WrongUser_DoesNothing() + { + using var factory = new TestDbContextFactory(); + int categoryId; + using (var ctx = factory.CreateContext()) + { + var category = new Category { Name = "Original", Icon = "📁", UserId = TestDbContextFactory.TestUserId }; + ctx.Categories.Add(category); + await ctx.SaveChangesAsync(); + categoryId = category.Id; + } + + using var context = factory.CreateContext(); + var service = new CategoryService(context); + await service.UpdateCategoryAsync(categoryId, TestDbContextFactory.OtherUserId, "Hacked", "💀"); + + using var verifyCtx = factory.CreateContext(); + Assert.Equal("Original", verifyCtx.Categories.Single().Name); + } + + [Fact] + public async Task DeleteCategoryAsync_RemovesCategory() + { + using var factory = new TestDbContextFactory(); + int categoryId; + using (var ctx = factory.CreateContext()) + { + var category = new Category { Name = "ToDelete", UserId = TestDbContextFactory.TestUserId }; + ctx.Categories.Add(category); + await ctx.SaveChangesAsync(); + categoryId = category.Id; + } + + using var context = factory.CreateContext(); + var service = new CategoryService(context); + await service.DeleteCategoryAsync(categoryId, TestDbContextFactory.TestUserId); + + using var verifyCtx = factory.CreateContext(); + Assert.Empty(verifyCtx.Categories); + } + + [Fact] + public async Task DeleteCategoryAsync_WrongUser_DoesNothing() + { + using var factory = new TestDbContextFactory(); + int categoryId; + using (var ctx = factory.CreateContext()) + { + var category = new Category { Name = "Protected", UserId = TestDbContextFactory.TestUserId }; + ctx.Categories.Add(category); + await ctx.SaveChangesAsync(); + categoryId = category.Id; + } + + using var context = factory.CreateContext(); + var service = new CategoryService(context); + await service.DeleteCategoryAsync(categoryId, TestDbContextFactory.OtherUserId); + + using var verifyCtx = factory.CreateContext(); + Assert.Single(verifyCtx.Categories); + } +} diff --git a/BudgetApp.Tests/Services/DashboardServiceTests.cs b/BudgetApp.Tests/Services/DashboardServiceTests.cs new file mode 100644 index 0000000..d11252e --- /dev/null +++ b/BudgetApp.Tests/Services/DashboardServiceTests.cs @@ -0,0 +1,207 @@ +using BudgetApp.Data; +using BudgetApp.Services; +using BudgetApp.Tests.Helpers; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace BudgetApp.Tests.Services; + +public class DashboardServiceTests +{ + #region BuildChartData (internal static — testable directly) + + [Fact] + public void BuildChartData_GeneratesLabelsForDateRange() + { + var start = new DateTime(2025, 1, 1); + var end = new DateTime(2025, 1, 5); + var accounts = new List(); + var transactions = new List(); + + var result = DashboardService.BuildChartData(accounts, transactions, start, end); + + Assert.Equal(5, result.Labels.Count); + Assert.Equal("Jan 01", result.Labels[0]); + Assert.Equal("Jan 05", result.Labels[4]); + } + + [Fact] + public void BuildChartData_GeneratesDayTransactionLabelsForDateRange() + { + var start = new DateTime(2025, 1, 1); + var end = new DateTime(2025, 1, 3); + var account = new Account { Id = 1, Name = "Current", Color = "#000", CurrencyCode = "GBP" }; + var transactions = new List + { + new() { Description = "Salary", Amount = 1000, Date = new DateTime(2025, 1, 1), Type = TransactionType.Income, AccountId = 1, Account = account }, + new() { Description = "Lunch", Amount = 10, Date = new DateTime(2025, 1, 2), Type = TransactionType.Expense, AccountId = 1, Account = account } + }; + + var result = DashboardService.BuildChartData([account], transactions, start, end); + + Assert.Equal(3, result.DayTransactionLabels.Count); + Assert.Single(result.DayTransactionLabels[0]); // Jan 1 — Salary + Assert.Contains("+£1,000.00 Salary (Current)", result.DayTransactionLabels[0][0]); + Assert.Single(result.DayTransactionLabels[1]); // Jan 2 — Lunch + Assert.Contains("-£10.00 Lunch (Current)", result.DayTransactionLabels[1][0]); + Assert.Empty(result.DayTransactionLabels[2]); // Jan 3 — empty + } + + [Fact] + public void BuildChartData_CalculatesRunningBalance() + { + var start = new DateTime(2025, 1, 1); + var end = new DateTime(2025, 1, 3); + var today = DateTime.Today; + + // Account has current balance of 500 + var account = new Account { Id = 1, Name = "Current", Color = "#000", CurrencyCode = "GBP", CurrentBalance = 500 }; + + // All transactions are in the past or at start + var transactions = new List + { + new() { Description = "Income", Amount = 200, Date = new DateTime(2025, 1, 1), Type = TransactionType.Income, AccountId = 1, Account = account }, + new() { Description = "Expense", Amount = 50, Date = new DateTime(2025, 1, 2), Type = TransactionType.Expense, AccountId = 1, Account = account } + }; + + var result = DashboardService.BuildChartData([account], transactions, start, end); + + Assert.Single(result.Datasets); + var data = result.Datasets[0].Data; + Assert.Equal(3, data.Count); + // The chart works backward from CurrentBalance to find starting point, then re-applies + // Since these transactions are far in the past relative to "today", the behavior depends on + // the start date vs today comparison. We validate the dataset structure is correct. + Assert.Equal("Current", result.Datasets[0].Label); + Assert.Equal("#000", result.Datasets[0].Color); + Assert.Equal("£", result.Datasets[0].CurrencySymbol); + } + + [Fact] + public void BuildChartData_MultipleAccounts_CreatesDatasetPerAccount() + { + var start = new DateTime(2025, 1, 1); + var end = new DateTime(2025, 1, 2); + + var account1 = new Account { Id = 1, Name = "Current", Color = "#111", CurrencyCode = "GBP", CurrentBalance = 100 }; + var account2 = new Account { Id = 2, Name = "Savings", Color = "#222", CurrencyCode = "USD", CurrentBalance = 200 }; + + var result = DashboardService.BuildChartData([account1, account2], [], start, end); + + Assert.Equal(2, result.Datasets.Count); + Assert.Equal("Current", result.Datasets[0].Label); + Assert.Equal("Savings", result.Datasets[1].Label); + Assert.Equal("£", result.Datasets[0].CurrencySymbol); + Assert.Equal("$", result.Datasets[1].CurrencySymbol); + } + + [Fact] + public void BuildChartData_EmptyAccounts_ReturnsEmptyDatasets() + { + var start = new DateTime(2025, 1, 1); + var end = new DateTime(2025, 1, 5); + + var result = DashboardService.BuildChartData([], [], start, end); + + Assert.Equal(5, result.Labels.Count); + Assert.Empty(result.Datasets); + } + + #endregion + + #region GetDashboardDataAsync (integration tests) + + [Fact] + public async Task GetDashboardDataAsync_ReturnsPopulatedData() + { + using var factory = new TestDbContextFactory(); + using (var ctx = factory.CreateContext()) + { + var account = new Account { Name = "Current", UserId = TestDbContextFactory.TestUserId, CurrencyCode = "GBP" }; + ctx.Accounts.Add(account); + ctx.SaveChanges(); + + ctx.Transactions.AddRange( + new Transaction { Description = "Past", Amount = 10, Date = DateTime.Today.AddDays(-5), Type = TransactionType.Expense, AccountId = account.Id }, + new Transaction { Description = "Future", Amount = 20, Date = DateTime.Today.AddDays(5), Type = TransactionType.Income, AccountId = account.Id }); + ctx.SaveChanges(); + } + + using var context = factory.CreateContext(); + var transactionService = new TransactionService(context); + var accountService = new AccountService(context); + var categoryService = new CategoryService(context); + var dashboardService = new DashboardService(transactionService, accountService, categoryService); + + var result = await dashboardService.GetDashboardDataAsync(TestDbContextFactory.TestUserId); + + Assert.NotEmpty(result.Accounts); + Assert.NotEmpty(result.Categories); // defaults seeded + Assert.NotEmpty(result.RecentTransactions); + Assert.NotEmpty(result.UpcomingTransactions); + Assert.NotNull(result.ChartData); + Assert.Equal("£", result.CurrencySymbol); + } + + [Fact] + public async Task GetDashboardDataAsync_SplitsRecentAndUpcoming() + { + using var factory = new TestDbContextFactory(); + using (var ctx = factory.CreateContext()) + { + var account = new Account { Name = "Test", UserId = TestDbContextFactory.TestUserId }; + ctx.Accounts.Add(account); + ctx.SaveChanges(); + + ctx.Transactions.AddRange( + new Transaction { Description = "Yesterday", Amount = 10, Date = DateTime.Today.AddDays(-1), AccountId = account.Id }, + new Transaction { Description = "Today", Amount = 20, Date = DateTime.Today, AccountId = account.Id }, + new Transaction { Description = "Tomorrow", Amount = 30, Date = DateTime.Today.AddDays(1), AccountId = account.Id }); + ctx.SaveChanges(); + } + + using var context = factory.CreateContext(); + var transactionService = new TransactionService(context); + var accountService = new AccountService(context); + var categoryService = new CategoryService(context); + var dashboardService = new DashboardService(transactionService, accountService, categoryService); + + var result = await dashboardService.GetDashboardDataAsync(TestDbContextFactory.TestUserId); + + // Recent = today and before, Upcoming = after today + Assert.All(result.RecentTransactions, t => Assert.True(t.Date.Date <= DateTime.Today)); + Assert.All(result.UpcomingTransactions, t => Assert.True(t.Date.Date > DateTime.Today)); + } + + [Fact] + public async Task GetDashboardDataAsync_WithCustomDateRange_FiltersCorrectly() + { + using var factory = new TestDbContextFactory(); + using (var ctx = factory.CreateContext()) + { + var account = new Account { Name = "Test", UserId = TestDbContextFactory.TestUserId }; + ctx.Accounts.Add(account); + ctx.SaveChanges(); + + ctx.Transactions.AddRange( + new Transaction { Description = "InRange", Amount = 10, Date = DateTime.Today.AddDays(-5), AccountId = account.Id }, + new Transaction { Description = "OutOfRange", Amount = 20, Date = DateTime.Today.AddDays(-60), AccountId = account.Id }); + ctx.SaveChanges(); + } + + using var context = factory.CreateContext(); + var transactionService = new TransactionService(context); + var accountService = new AccountService(context); + var categoryService = new CategoryService(context); + var dashboardService = new DashboardService(transactionService, accountService, categoryService); + + var from = DateTime.Today.AddDays(-10); + var to = DateTime.Today.AddDays(10); + var result = await dashboardService.GetDashboardDataAsync(TestDbContextFactory.TestUserId, from, to); + + Assert.Contains(result.RecentTransactions, t => t.Description == "InRange"); + Assert.DoesNotContain(result.RecentTransactions, t => t.Description == "OutOfRange"); + } + + #endregion +} diff --git a/BudgetApp.Tests/Services/TransactionServiceTests.cs b/BudgetApp.Tests/Services/TransactionServiceTests.cs new file mode 100644 index 0000000..987936e --- /dev/null +++ b/BudgetApp.Tests/Services/TransactionServiceTests.cs @@ -0,0 +1,876 @@ +using BudgetApp.Data; +using BudgetApp.Services; +using BudgetApp.Tests.Helpers; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace BudgetApp.Tests.Services; + +public class TransactionServiceTests +{ + private static Account SeedAccount(ApplicationDbContext ctx, string userId = TestDbContextFactory.TestUserId) + { + var account = new Account { Name = "Test Account", UserId = userId }; + ctx.Accounts.Add(account); + ctx.SaveChanges(); + return account; + } + + #region GetTransactionsAsync + + [Fact] + public async Task GetTransactionsAsync_ReturnsUserTransactions_OrderedByDateDescending() + { + using var factory = new TestDbContextFactory(); + using (var ctx = factory.CreateContext()) + { + var account = SeedAccount(ctx); + ctx.Transactions.AddRange( + new Transaction { Description = "Old", Amount = 10, Date = new DateTime(2025, 1, 1), AccountId = account.Id }, + new Transaction { Description = "New", Amount = 20, Date = new DateTime(2025, 6, 1), AccountId = account.Id }); + ctx.SaveChanges(); + } + + using var context = factory.CreateContext(); + var service = new TransactionService(context); + var result = await service.GetTransactionsAsync(TestDbContextFactory.TestUserId); + + Assert.Equal(2, result.Count); + Assert.Equal("New", result[0].Description); + Assert.Equal("Old", result[1].Description); + } + + [Fact] + public async Task GetTransactionsAsync_ExcludesOtherUserTransactions() + { + using var factory = new TestDbContextFactory(); + using (var ctx = factory.CreateContext()) + { + var mine = SeedAccount(ctx, TestDbContextFactory.TestUserId); + var theirs = SeedAccount(ctx, TestDbContextFactory.OtherUserId); + ctx.Transactions.AddRange( + new Transaction { Description = "Mine", Amount = 10, Date = DateTime.Today, AccountId = mine.Id }, + new Transaction { Description = "Theirs", Amount = 20, Date = DateTime.Today, AccountId = theirs.Id }); + ctx.SaveChanges(); + } + + using var context = factory.CreateContext(); + var service = new TransactionService(context); + var result = await service.GetTransactionsAsync(TestDbContextFactory.TestUserId); + + Assert.Single(result); + Assert.Equal("Mine", result[0].Description); + } + + [Fact] + public async Task GetTransactionsAsync_IncludesRelatedEntities() + { + using var factory = new TestDbContextFactory(); + using (var ctx = factory.CreateContext()) + { + var account = SeedAccount(ctx); + var category = new Category { Name = "Food", UserId = TestDbContextFactory.TestUserId }; + ctx.Categories.Add(category); + ctx.SaveChanges(); + + ctx.Transactions.Add(new Transaction + { + Description = "Lunch", + Amount = 10, + Date = DateTime.Today, + AccountId = account.Id, + CategoryId = category.Id + }); + ctx.SaveChanges(); + } + + using var context = factory.CreateContext(); + var service = new TransactionService(context); + var result = await service.GetTransactionsAsync(TestDbContextFactory.TestUserId); + + Assert.Single(result); + Assert.NotNull(result[0].Account); + Assert.NotNull(result[0].Category); + Assert.Equal("Food", result[0].Category!.Name); + } + + #endregion + + #region GetRecentTransactionsAsync + + [Fact] + public async Task GetRecentTransactionsAsync_ReturnsTransactionsOnOrBeforeToday() + { + using var factory = new TestDbContextFactory(); + using (var ctx = factory.CreateContext()) + { + var account = SeedAccount(ctx); + ctx.Transactions.AddRange( + new Transaction { Description = "Past", Amount = 10, Date = DateTime.Today.AddDays(-1), AccountId = account.Id }, + new Transaction { Description = "Today", Amount = 20, Date = DateTime.Today, AccountId = account.Id }, + new Transaction { Description = "Future", Amount = 30, Date = DateTime.Today.AddDays(1), AccountId = account.Id }); + ctx.SaveChanges(); + } + + using var context = factory.CreateContext(); + var service = new TransactionService(context); + var result = await service.GetRecentTransactionsAsync(TestDbContextFactory.TestUserId, 10); + + Assert.Equal(2, result.Count); + Assert.DoesNotContain(result, t => t.Description == "Future"); + } + + [Fact] + public async Task GetRecentTransactionsAsync_RespectsCount() + { + using var factory = new TestDbContextFactory(); + using (var ctx = factory.CreateContext()) + { + var account = SeedAccount(ctx); + for (int i = 0; i < 10; i++) + { + ctx.Transactions.Add(new Transaction + { + Description = $"T{i}", + Amount = i, + Date = DateTime.Today.AddDays(-i), + AccountId = account.Id + }); + } + ctx.SaveChanges(); + } + + using var context = factory.CreateContext(); + var service = new TransactionService(context); + var result = await service.GetRecentTransactionsAsync(TestDbContextFactory.TestUserId, 3); + + Assert.Equal(3, result.Count); + } + + #endregion + + #region GetUpcomingTransactionsAsync + + [Fact] + public async Task GetUpcomingTransactionsAsync_ReturnsTransactionsAfterToday() + { + using var factory = new TestDbContextFactory(); + using (var ctx = factory.CreateContext()) + { + var account = SeedAccount(ctx); + ctx.Transactions.AddRange( + new Transaction { Description = "Past", Amount = 10, Date = DateTime.Today.AddDays(-1), AccountId = account.Id }, + new Transaction { Description = "Today", Amount = 20, Date = DateTime.Today, AccountId = account.Id }, + new Transaction { Description = "Future", Amount = 30, Date = DateTime.Today.AddDays(1), AccountId = account.Id }); + ctx.SaveChanges(); + } + + using var context = factory.CreateContext(); + var service = new TransactionService(context); + var result = await service.GetUpcomingTransactionsAsync(TestDbContextFactory.TestUserId, 10); + + Assert.Single(result); + Assert.Equal("Future", result[0].Description); + } + + #endregion + + #region GetTransactionsInRangeAsync + + [Fact] + public async Task GetTransactionsInRangeAsync_ReturnsTransactionsInRange() + { + using var factory = new TestDbContextFactory(); + var from = new DateTime(2025, 3, 1); + var to = new DateTime(2025, 3, 31); + + using (var ctx = factory.CreateContext()) + { + var account = SeedAccount(ctx); + ctx.Transactions.AddRange( + new Transaction { Description = "Before", Amount = 10, Date = new DateTime(2025, 2, 28), AccountId = account.Id }, + new Transaction { Description = "Start", Amount = 20, Date = from, AccountId = account.Id }, + new Transaction { Description = "Middle", Amount = 30, Date = new DateTime(2025, 3, 15), AccountId = account.Id }, + new Transaction { Description = "End", Amount = 40, Date = to, AccountId = account.Id }, + new Transaction { Description = "After", Amount = 50, Date = new DateTime(2025, 4, 1), AccountId = account.Id }); + ctx.SaveChanges(); + } + + using var context = factory.CreateContext(); + var service = new TransactionService(context); + var result = await service.GetTransactionsInRangeAsync(TestDbContextFactory.TestUserId, from, to); + + Assert.Equal(3, result.Count); + Assert.DoesNotContain(result, t => t.Description == "Before"); + Assert.DoesNotContain(result, t => t.Description == "After"); + } + + #endregion + + #region GetTransactionsByAccountAsync + + [Fact] + public async Task GetTransactionsByAccountAsync_ReturnsAccountTransactions() + { + using var factory = new TestDbContextFactory(); + int accountId; + using (var ctx = factory.CreateContext()) + { + var account1 = SeedAccount(ctx); + var account2 = new Account { Name = "Other", UserId = TestDbContextFactory.TestUserId }; + ctx.Accounts.Add(account2); + ctx.SaveChanges(); + accountId = account1.Id; + + ctx.Transactions.AddRange( + new Transaction { Description = "A1", Amount = 10, Date = DateTime.Today, AccountId = account1.Id }, + new Transaction { Description = "A2", Amount = 20, Date = DateTime.Today, AccountId = account2.Id }); + ctx.SaveChanges(); + } + + using var context = factory.CreateContext(); + var service = new TransactionService(context); + var result = await service.GetTransactionsByAccountAsync(accountId, TestDbContextFactory.TestUserId); + + Assert.Single(result); + Assert.Equal("A1", result[0].Description); + } + + [Fact] + public async Task GetTransactionsByAccountAsync_WrongUser_ReturnsEmpty() + { + using var factory = new TestDbContextFactory(); + int accountId; + using (var ctx = factory.CreateContext()) + { + var account = SeedAccount(ctx, TestDbContextFactory.TestUserId); + accountId = account.Id; + ctx.Transactions.Add(new Transaction { Description = "T", Amount = 10, Date = DateTime.Today, AccountId = account.Id }); + ctx.SaveChanges(); + } + + using var context = factory.CreateContext(); + var service = new TransactionService(context); + var result = await service.GetTransactionsByAccountAsync(accountId, TestDbContextFactory.OtherUserId); + + Assert.Empty(result); + } + + #endregion + + #region CreateTransactionAsync + + [Fact] + public async Task CreateTransactionAsync_AddsTransaction() + { + using var factory = new TestDbContextFactory(); + int accountId; + using (var ctx = factory.CreateContext()) + { + accountId = SeedAccount(ctx).Id; + } + + using var context = factory.CreateContext(); + var service = new TransactionService(context); + await service.CreateTransactionAsync(new Transaction + { + Description = "Groceries", + Amount = 50, + Date = DateTime.Today, + Type = TransactionType.Expense, + AccountId = accountId + }); + + using var verifyCtx = factory.CreateContext(); + Assert.Single(verifyCtx.Transactions); + } + + [Fact] + public async Task CreateTransactionAsync_NonRecurring_DoesNotCreateChildren() + { + using var factory = new TestDbContextFactory(); + int accountId; + using (var ctx = factory.CreateContext()) + { + accountId = SeedAccount(ctx).Id; + } + + using var context = factory.CreateContext(); + var service = new TransactionService(context); + await service.CreateTransactionAsync(new Transaction + { + Description = "One-off", + Amount = 10, + Date = DateTime.Today, + RecurrenceType = RecurrenceType.None, + AccountId = accountId + }); + + using var verifyCtx = factory.CreateContext(); + Assert.Single(verifyCtx.Transactions); + Assert.Null(verifyCtx.Transactions.Single().ParentTransactionId); + } + + [Fact] + public async Task CreateTransactionAsync_Recurring_CapsEndDateToOneYear() + { + using var factory = new TestDbContextFactory(); + int accountId; + using (var ctx = factory.CreateContext()) + { + accountId = SeedAccount(ctx).Id; + } + + var startDate = new DateTime(2025, 1, 1); + using var context = factory.CreateContext(); + var service = new TransactionService(context); + await service.CreateTransactionAsync(new Transaction + { + Description = "Recurring", + Amount = 100, + Date = startDate, + RecurrenceType = RecurrenceType.Monthly, + RecurrenceEndDate = startDate.AddYears(5), // way beyond 1 year + AccountId = accountId + }); + + using var verifyCtx = factory.CreateContext(); + var parent = await verifyCtx.Transactions + .FirstAsync(t => t.ParentTransactionId == null); + + Assert.Equal(startDate.AddYears(1), parent.RecurrenceEndDate); + } + + [Fact] + public async Task CreateTransactionAsync_Recurring_DefaultsEndDateWhenNull() + { + using var factory = new TestDbContextFactory(); + int accountId; + using (var ctx = factory.CreateContext()) + { + accountId = SeedAccount(ctx).Id; + } + + var startDate = new DateTime(2025, 1, 1); + using var context = factory.CreateContext(); + var service = new TransactionService(context); + await service.CreateTransactionAsync(new Transaction + { + Description = "Recurring", + Amount = 100, + Date = startDate, + RecurrenceType = RecurrenceType.Monthly, + RecurrenceEndDate = null, + AccountId = accountId + }); + + using var verifyCtx = factory.CreateContext(); + var parent = await verifyCtx.Transactions + .FirstAsync(t => t.ParentTransactionId == null); + + Assert.Equal(startDate.AddYears(1), parent.RecurrenceEndDate); + } + + [Fact] + public async Task CreateTransactionAsync_Recurring_MaterializesChildren() + { + using var factory = new TestDbContextFactory(); + int accountId; + using (var ctx = factory.CreateContext()) + { + accountId = SeedAccount(ctx).Id; + } + + var startDate = new DateTime(2025, 1, 1); + using var context = factory.CreateContext(); + var service = new TransactionService(context); + await service.CreateTransactionAsync(new Transaction + { + Description = "Weekly", + Amount = 50, + Date = startDate, + RecurrenceType = RecurrenceType.Weekly, + RecurrenceEndDate = startDate.AddDays(21), // 3 weeks = 3 children + AccountId = accountId + }); + + using var verifyCtx = factory.CreateContext(); + var all = await verifyCtx.Transactions.ToListAsync(); + var parent = all.Single(t => t.ParentTransactionId == null); + var children = all.Where(t => t.ParentTransactionId == parent.Id).ToList(); + + Assert.Equal(3, children.Count); + Assert.All(children, c => + { + Assert.Equal("Weekly", c.Description); + Assert.Equal(50m, c.Amount); + Assert.Equal(RecurrenceType.None, c.RecurrenceType); + Assert.Equal(parent.Id, c.ParentTransactionId); + }); + } + + [Fact] + public async Task CreateTransactionAsync_Recurring_Monthly_MaterializesCorrectDates() + { + using var factory = new TestDbContextFactory(); + int accountId; + using (var ctx = factory.CreateContext()) + { + accountId = SeedAccount(ctx).Id; + } + + var startDate = new DateTime(2025, 1, 15); + using var context = factory.CreateContext(); + var service = new TransactionService(context); + await service.CreateTransactionAsync(new Transaction + { + Description = "Monthly", + Amount = 100, + Date = startDate, + RecurrenceType = RecurrenceType.Monthly, + RecurrenceEndDate = new DateTime(2025, 4, 15), // 3 months → 3 children + AccountId = accountId + }); + + using var verifyCtx = factory.CreateContext(); + var children = await verifyCtx.Transactions + .Where(t => t.ParentTransactionId != null) + .OrderBy(t => t.Date) + .ToListAsync(); + + Assert.Equal(3, children.Count); + Assert.Equal(new DateTime(2025, 2, 15), children[0].Date); + Assert.Equal(new DateTime(2025, 3, 15), children[1].Date); + Assert.Equal(new DateTime(2025, 4, 15), children[2].Date); + } + + #endregion + + #region UpdateTransactionAsync + + [Fact] + public async Task UpdateTransactionAsync_UpdatesAllFields() + { + using var factory = new TestDbContextFactory(); + int transactionId, accountId, newAccountId; + int categoryId; + using (var ctx = factory.CreateContext()) + { + var account = SeedAccount(ctx); + accountId = account.Id; + var newAccount = new Account { Name = "Other", UserId = TestDbContextFactory.TestUserId }; + ctx.Accounts.Add(newAccount); + var category = new Category { Name = "Food", UserId = TestDbContextFactory.TestUserId }; + ctx.Categories.Add(category); + ctx.SaveChanges(); + newAccountId = newAccount.Id; + categoryId = category.Id; + + var transaction = new Transaction + { + Description = "Old", + Amount = 10, + Date = DateTime.Today, + Type = TransactionType.Expense, + AccountId = accountId + }; + ctx.Transactions.Add(transaction); + ctx.SaveChanges(); + transactionId = transaction.Id; + } + + var newDate = DateTime.Today.AddDays(5); + using var context = factory.CreateContext(); + var service = new TransactionService(context); + await service.UpdateTransactionAsync( + transactionId, TestDbContextFactory.TestUserId, + "Updated", 99m, newDate, TransactionType.Income, + newAccountId, categoryId, RecurrenceType.Weekly, newDate.AddMonths(3)); + + using var verifyCtx = factory.CreateContext(); + var updated = verifyCtx.Transactions.Single(); + Assert.Equal("Updated", updated.Description); + Assert.Equal(99m, updated.Amount); + Assert.Equal(newDate, updated.Date); + Assert.Equal(TransactionType.Income, updated.Type); + Assert.Equal(newAccountId, updated.AccountId); + Assert.Equal(categoryId, updated.CategoryId); + Assert.Equal(RecurrenceType.Weekly, updated.RecurrenceType); + Assert.Equal(newDate.AddMonths(3), updated.RecurrenceEndDate); + } + + [Fact] + public async Task UpdateTransactionAsync_WrongUser_DoesNothing() + { + using var factory = new TestDbContextFactory(); + int transactionId; + using (var ctx = factory.CreateContext()) + { + var account = SeedAccount(ctx); + var transaction = new Transaction + { + Description = "Original", + Amount = 10, + Date = DateTime.Today, + AccountId = account.Id + }; + ctx.Transactions.Add(transaction); + ctx.SaveChanges(); + transactionId = transaction.Id; + } + + using var context = factory.CreateContext(); + var service = new TransactionService(context); + await service.UpdateTransactionAsync( + transactionId, TestDbContextFactory.OtherUserId, + "Hacked", 999m, DateTime.Today, TransactionType.Income, 1, null); + + using var verifyCtx = factory.CreateContext(); + Assert.Equal("Original", verifyCtx.Transactions.Single().Description); + } + + #endregion + + #region DeleteTransactionAsync + + [Fact] + public async Task DeleteTransactionAsync_RemovesTransaction() + { + using var factory = new TestDbContextFactory(); + int transactionId; + using (var ctx = factory.CreateContext()) + { + var account = SeedAccount(ctx); + var transaction = new Transaction { Description = "ToDelete", Amount = 10, Date = DateTime.Today, AccountId = account.Id }; + ctx.Transactions.Add(transaction); + ctx.SaveChanges(); + transactionId = transaction.Id; + } + + using var context = factory.CreateContext(); + var service = new TransactionService(context); + await service.DeleteTransactionAsync(transactionId, TestDbContextFactory.TestUserId); + + using var verifyCtx = factory.CreateContext(); + Assert.Empty(verifyCtx.Transactions); + } + + [Fact] + public async Task DeleteTransactionAsync_WrongUser_DoesNothing() + { + using var factory = new TestDbContextFactory(); + int transactionId; + using (var ctx = factory.CreateContext()) + { + var account = SeedAccount(ctx); + var transaction = new Transaction { Description = "Protected", Amount = 10, Date = DateTime.Today, AccountId = account.Id }; + ctx.Transactions.Add(transaction); + ctx.SaveChanges(); + transactionId = transaction.Id; + } + + using var context = factory.CreateContext(); + var service = new TransactionService(context); + await service.DeleteTransactionAsync(transactionId, TestDbContextFactory.OtherUserId); + + using var verifyCtx = factory.CreateContext(); + Assert.Single(verifyCtx.Transactions); + } + + #endregion + + #region DeleteTransactionSeriesAsync + + [Fact] + public async Task DeleteTransactionSeriesAsync_ChildClick_DeletesFromCutoffForward() + { + using var factory = new TestDbContextFactory(); + int childId; + using (var ctx = factory.CreateContext()) + { + var account = SeedAccount(ctx); + var parent = new Transaction + { + Description = "Recurring", + Amount = 100, + Date = new DateTime(2025, 1, 1), + RecurrenceType = RecurrenceType.Monthly, + RecurrenceEndDate = new DateTime(2025, 6, 1), + AccountId = account.Id + }; + ctx.Transactions.Add(parent); + ctx.SaveChanges(); + + // Add children: Feb, Mar, Apr, May, Jun + var children = new List(); + for (int m = 2; m <= 6; m++) + { + children.Add(new Transaction + { + Description = "Recurring", + Amount = 100, + Date = new DateTime(2025, m, 1), + RecurrenceType = RecurrenceType.None, + ParentTransactionId = parent.Id, + AccountId = account.Id + }); + } + ctx.Transactions.AddRange(children); + ctx.SaveChanges(); + + // Click on the April child (cutoff = Apr 1) + childId = children[2].Id; // Apr + } + + using var context = factory.CreateContext(); + var service = new TransactionService(context); + await service.DeleteTransactionSeriesAsync(childId, TestDbContextFactory.TestUserId); + + using var verifyCtx = factory.CreateContext(); + var remaining = await verifyCtx.Transactions.OrderBy(t => t.Date).ToListAsync(); + + // Parent (Jan) + Feb + Mar should remain; Apr, May, Jun deleted + Assert.Equal(3, remaining.Count); + Assert.Contains(remaining, t => t.Date.Month == 1 && t.ParentTransactionId == null); // parent + Assert.Contains(remaining, t => t.Date.Month == 2); + Assert.Contains(remaining, t => t.Date.Month == 3); + } + + [Fact] + public async Task DeleteTransactionSeriesAsync_ChildClick_CapsParentRecurrence() + { + using var factory = new TestDbContextFactory(); + int childId, parentId; + using (var ctx = factory.CreateContext()) + { + var account = SeedAccount(ctx); + var parent = new Transaction + { + Description = "Recurring", + Amount = 100, + Date = new DateTime(2025, 1, 1), + RecurrenceType = RecurrenceType.Monthly, + RecurrenceEndDate = new DateTime(2025, 12, 1), + AccountId = account.Id + }; + ctx.Transactions.Add(parent); + ctx.SaveChanges(); + parentId = parent.Id; + + var child = new Transaction + { + Description = "Recurring", + Amount = 100, + Date = new DateTime(2025, 4, 1), + RecurrenceType = RecurrenceType.None, + ParentTransactionId = parent.Id, + AccountId = account.Id + }; + ctx.Transactions.Add(child); + ctx.SaveChanges(); + childId = child.Id; + } + + using var context = factory.CreateContext(); + var service = new TransactionService(context); + await service.DeleteTransactionSeriesAsync(childId, TestDbContextFactory.TestUserId); + + using var verifyCtx = factory.CreateContext(); + var updatedParent = await verifyCtx.Transactions.FindAsync(parentId); + Assert.NotNull(updatedParent); + Assert.Equal(new DateTime(2025, 3, 31), updatedParent.RecurrenceEndDate); // cutoff - 1 day + } + + [Fact] + public async Task DeleteTransactionSeriesAsync_ParentClick_DeletesParentAndAllChildren() + { + using var factory = new TestDbContextFactory(); + int parentId; + using (var ctx = factory.CreateContext()) + { + var account = SeedAccount(ctx); + var parent = new Transaction + { + Description = "Recurring", + Amount = 100, + Date = new DateTime(2025, 1, 1), + RecurrenceType = RecurrenceType.Monthly, + RecurrenceEndDate = new DateTime(2025, 3, 1), + AccountId = account.Id + }; + ctx.Transactions.Add(parent); + ctx.SaveChanges(); + parentId = parent.Id; + + ctx.Transactions.AddRange( + new Transaction { Description = "Recurring", Amount = 100, Date = new DateTime(2025, 2, 1), RecurrenceType = RecurrenceType.None, ParentTransactionId = parent.Id, AccountId = account.Id }, + new Transaction { Description = "Recurring", Amount = 100, Date = new DateTime(2025, 3, 1), RecurrenceType = RecurrenceType.None, ParentTransactionId = parent.Id, AccountId = account.Id }); + ctx.SaveChanges(); + } + + using var context = factory.CreateContext(); + var service = new TransactionService(context); + await service.DeleteTransactionSeriesAsync(parentId, TestDbContextFactory.TestUserId); + + using var verifyCtx = factory.CreateContext(); + Assert.Empty(verifyCtx.Transactions); + } + + [Fact] + public async Task DeleteTransactionSeriesAsync_WrongUser_DoesNothing() + { + using var factory = new TestDbContextFactory(); + int transactionId; + using (var ctx = factory.CreateContext()) + { + var account = SeedAccount(ctx); + var transaction = new Transaction { Description = "Protected", Amount = 10, Date = DateTime.Today, AccountId = account.Id }; + ctx.Transactions.Add(transaction); + ctx.SaveChanges(); + transactionId = transaction.Id; + } + + using var context = factory.CreateContext(); + var service = new TransactionService(context); + await service.DeleteTransactionSeriesAsync(transactionId, TestDbContextFactory.OtherUserId); + + using var verifyCtx = factory.CreateContext(); + Assert.Single(verifyCtx.Transactions); + } + + #endregion + + #region MaterializeRecurringTransactionsAsync + + [Fact] + public async Task MaterializeRecurringTransactionsAsync_FillsGaps() + { + using var factory = new TestDbContextFactory(); + int parentId; + var startDate = DateTime.Today; + using (var ctx = factory.CreateContext()) + { + var account = SeedAccount(ctx); + var parent = new Transaction + { + Description = "Daily", + Amount = 5, + Date = startDate, + RecurrenceType = RecurrenceType.Daily, + RecurrenceEndDate = startDate.AddDays(10), + AccountId = account.Id + }; + ctx.Transactions.Add(parent); + ctx.SaveChanges(); + parentId = parent.Id; + + // Only 2 children exist (days 1 and 2) + ctx.Transactions.AddRange( + new Transaction { Description = "Daily", Amount = 5, Date = startDate.AddDays(1), RecurrenceType = RecurrenceType.None, ParentTransactionId = parent.Id, AccountId = account.Id }, + new Transaction { Description = "Daily", Amount = 5, Date = startDate.AddDays(2), RecurrenceType = RecurrenceType.None, ParentTransactionId = parent.Id, AccountId = account.Id }); + ctx.SaveChanges(); + } + + using var context = factory.CreateContext(); + var service = new TransactionService(context); + await service.MaterializeRecurringTransactionsAsync(TestDbContextFactory.TestUserId, startDate.AddDays(100)); + + using var verifyCtx = factory.CreateContext(); + var children = await verifyCtx.Transactions + .Where(t => t.ParentTransactionId == parentId) + .ToListAsync(); + + // Days 1-10 should be filled = 10 children + Assert.Equal(10, children.Count); + } + + [Fact] + public async Task MaterializeRecurringTransactionsAsync_RespectsRecurrenceEndDate() + { + using var factory = new TestDbContextFactory(); + int parentId; + var startDate = DateTime.Today; + using (var ctx = factory.CreateContext()) + { + var account = SeedAccount(ctx); + var parent = new Transaction + { + Description = "Weekly", + Amount = 10, + Date = startDate, + RecurrenceType = RecurrenceType.Weekly, + RecurrenceEndDate = startDate.AddDays(14), // 2 weeks + AccountId = account.Id + }; + ctx.Transactions.Add(parent); + ctx.SaveChanges(); + parentId = parent.Id; + } + + using var context = factory.CreateContext(); + var service = new TransactionService(context); + await service.MaterializeRecurringTransactionsAsync(TestDbContextFactory.TestUserId, startDate.AddDays(365)); + + using var verifyCtx = factory.CreateContext(); + var children = await verifyCtx.Transactions + .Where(t => t.ParentTransactionId == parentId) + .ToListAsync(); + + Assert.Equal(2, children.Count); + } + + [Fact] + public async Task MaterializeRecurringTransactionsAsync_IgnoresNonParentRecurring() + { + using var factory = new TestDbContextFactory(); + using (var ctx = factory.CreateContext()) + { + var account = SeedAccount(ctx); + // A non-recurring transaction + ctx.Transactions.Add(new Transaction + { + Description = "Single", + Amount = 10, + Date = DateTime.Today, + RecurrenceType = RecurrenceType.None, + AccountId = account.Id + }); + ctx.SaveChanges(); + } + + using var context = factory.CreateContext(); + var service = new TransactionService(context); + await service.MaterializeRecurringTransactionsAsync(TestDbContextFactory.TestUserId); + + using var verifyCtx = factory.CreateContext(); + Assert.Single(verifyCtx.Transactions); + } + + #endregion + + #region GetNextOccurrence + + [Theory] + [InlineData(RecurrenceType.Daily, 1, 0, 0)] + [InlineData(RecurrenceType.Weekly, 7, 0, 0)] + [InlineData(RecurrenceType.Monthly, 0, 1, 0)] + [InlineData(RecurrenceType.Yearly, 0, 0, 1)] + public void GetNextOccurrence_ReturnsCorrectDate(RecurrenceType type, int expectedDays, int expectedMonths, int expectedYears) + { + var current = new DateTime(2025, 6, 15); + var expected = current.AddDays(expectedDays).AddMonths(expectedMonths).AddYears(expectedYears); + + var result = TransactionService.GetNextOccurrence(current, type); + + Assert.Equal(expected, result); + } + + [Fact] + public void GetNextOccurrence_None_ReturnsMaxValue() + { + var result = TransactionService.GetNextOccurrence(DateTime.Today, RecurrenceType.None); + Assert.Equal(DateTime.MaxValue, result); + } + + #endregion +} diff --git a/BudgetApp.slnx b/BudgetApp.slnx index 7efbdea..b4a26d2 100644 --- a/BudgetApp.slnx +++ b/BudgetApp.slnx @@ -1,3 +1,4 @@ + diff --git a/BudgetApp/BudgetApp.csproj b/BudgetApp/BudgetApp.csproj index fd4c350..40cfd13 100644 --- a/BudgetApp/BudgetApp.csproj +++ b/BudgetApp/BudgetApp.csproj @@ -7,6 +7,10 @@ true + + + + diff --git a/BudgetApp/Services/AccountService.cs b/BudgetApp/Services/AccountService.cs index b0a0d4e..45dd303 100644 --- a/BudgetApp/Services/AccountService.cs +++ b/BudgetApp/Services/AccountService.cs @@ -3,14 +3,9 @@ using BudgetApp.Data; namespace BudgetApp.Services; -public class AccountService +public class AccountService(ApplicationDbContext db) { - private readonly ApplicationDbContext _db; - - public AccountService(ApplicationDbContext db) - { - _db = db; - } + private readonly ApplicationDbContext _db = db; public async Task> GetAccountsAsync(string userId) { diff --git a/BudgetApp/Services/CategoryService.cs b/BudgetApp/Services/CategoryService.cs index 6b9ea64..b8992dd 100644 --- a/BudgetApp/Services/CategoryService.cs +++ b/BudgetApp/Services/CategoryService.cs @@ -3,9 +3,9 @@ using BudgetApp.Data; namespace BudgetApp.Services; -public class CategoryService +public class CategoryService(ApplicationDbContext db) { - private readonly ApplicationDbContext _db; + private readonly ApplicationDbContext _db = db; private static readonly (string Name, string Icon)[] DefaultCategories = [ @@ -26,11 +26,6 @@ public class CategoryService ("Gifts", "🎁") ]; - public CategoryService(ApplicationDbContext db) - { - _db = db; - } - public async Task> GetCategoriesAsync(string userId) { return await _db.Categories diff --git a/BudgetApp/Services/DashboardService.cs b/BudgetApp/Services/DashboardService.cs index 5fa1bb2..b260721 100644 --- a/BudgetApp/Services/DashboardService.cs +++ b/BudgetApp/Services/DashboardService.cs @@ -2,18 +2,11 @@ using BudgetApp.Data; namespace BudgetApp.Services; -public class DashboardService +public class DashboardService(TransactionService transactionService, AccountService accountService, CategoryService categoryService) { - private readonly TransactionService _transactionService; - private readonly AccountService _accountService; - private readonly CategoryService _categoryService; - - public DashboardService(TransactionService transactionService, AccountService accountService, CategoryService categoryService) - { - _transactionService = transactionService; - _accountService = accountService; - _categoryService = categoryService; - } + private readonly TransactionService _transactionService = transactionService; + private readonly AccountService _accountService = accountService; + private readonly CategoryService _categoryService = categoryService; public async Task GetDashboardDataAsync(string userId, DateTime? from = null, DateTime? to = null) { @@ -53,7 +46,7 @@ public class DashboardService }; } - private static ChartData BuildChartData(List accounts, List transactions, DateTime startDate, DateTime endDate) + internal static ChartData BuildChartData(List accounts, List transactions, DateTime startDate, DateTime endDate) { var today = DateTime.Today; var labels = new List(); diff --git a/BudgetApp/Services/TransactionService.cs b/BudgetApp/Services/TransactionService.cs index b0a5bff..1a15d89 100644 --- a/BudgetApp/Services/TransactionService.cs +++ b/BudgetApp/Services/TransactionService.cs @@ -3,14 +3,9 @@ using BudgetApp.Data; namespace BudgetApp.Services; -public class TransactionService +public class TransactionService(ApplicationDbContext db) { - private readonly ApplicationDbContext _db; - - public TransactionService(ApplicationDbContext db) - { - _db = db; - } + private readonly ApplicationDbContext _db = db; public async Task> GetTransactionsAsync(string userId) { @@ -248,7 +243,7 @@ public class TransactionService } } - private static DateTime GetNextOccurrence(DateTime current, RecurrenceType recurrence) => recurrence switch + internal static DateTime GetNextOccurrence(DateTime current, RecurrenceType recurrence) => recurrence switch { RecurrenceType.Daily => current.AddDays(1), RecurrenceType.Weekly => current.AddDays(7), diff --git a/BudgetApp/budgetapp.db-shm b/BudgetApp/budgetapp.db-shm index a1c3173..87f06e1 100644 Binary files a/BudgetApp/budgetapp.db-shm and b/BudgetApp/budgetapp.db-shm differ diff --git a/BudgetApp/budgetapp.db-wal b/BudgetApp/budgetapp.db-wal index 11dd6f9..129b6c6 100644 Binary files a/BudgetApp/budgetapp.db-wal and b/BudgetApp/budgetapp.db-wal differ