Add tests

This commit is contained in:
rossmill76 committed 2026-03-01 19:00:23 +00:00
1 parent d5127c7405
commit 88aaaecb1a
15 files changed
+1755 -34

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.3" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\BudgetApp\BudgetApp.csproj" />
</ItemGroup>
</Project>
@@ -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<ApplicationDbContext>()
.UseSqlite(_connection)
.Options;
return new ApplicationDbContext(options);
}
public void Dispose() => _connection.Dispose();
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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<Account>();
var transactions = new List<Transaction>();
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<Transaction>
{
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<Transaction>
{
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
}
@@ -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<Transaction>();
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
}