Add tests
This commit is contained in:
1 parent
d5127c7405
commit
88aaaecb1a
15 files changed
+1755
-34
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
<Solution>
|
||||
<Project Path="BudgetApp.Tests/BudgetApp.Tests.csproj" />
|
||||
<Project Path="BudgetApp/BudgetApp.csproj" />
|
||||
</Solution>
|
||||
@@ -7,6 +7,10 @@
|
||||
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="BudgetApp.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.3">
|
||||
|
||||
@@ -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<List<Account>> GetAccountsAsync(string userId)
|
||||
{
|
||||
|
||||
@@ -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<List<Category>> GetCategoriesAsync(string userId)
|
||||
{
|
||||
return await _db.Categories
|
||||
|
||||
@@ -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<DashboardData> GetDashboardDataAsync(string userId, DateTime? from = null, DateTime? to = null)
|
||||
{
|
||||
@@ -53,7 +46,7 @@ public class DashboardService
|
||||
};
|
||||
}
|
||||
|
||||
private static ChartData BuildChartData(List<Account> accounts, List<Transaction> transactions, DateTime startDate, DateTime endDate)
|
||||
internal static ChartData BuildChartData(List<Account> accounts, List<Transaction> transactions, DateTime startDate, DateTime endDate)
|
||||
{
|
||||
var today = DateTime.Today;
|
||||
var labels = new List<string>();
|
||||
|
||||
@@ -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<List<Transaction>> 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),
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Reference in new issue
Block a user