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