Files
Budgeteer/BudgetApp.Tests/Services/TransactionServiceTests.cs
T
2026-03-01 19:00:23 +00:00

877 lines
32 KiB
C#

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
}