using BudgetApp.Data.Entities; using BudgetApp.Data.Repositories; using BudgetApp.Services; using BudgetApp.Tests.Helpers; using Xunit; namespace BudgetApp.Tests.Services; public class DashboardServiceTests { #region BuildChartData (internal static — testable directly) [Fact] public void BuildChartData_GeneratesLabelsForDateRange() { var start = new DateTime(2025, 1, 1); var end = new DateTime(2025, 1, 5); var accounts = new List(); var transactions = new List(); var result = DashboardService.BuildChartData(accounts, transactions, start, end); Assert.Equal(5, result.Labels.Count); Assert.Equal("Jan 01", result.Labels[0]); Assert.Equal("Jan 05", result.Labels[4]); } [Fact] public void BuildChartData_GeneratesDayTransactionLabelsForDateRange() { var start = new DateTime(2025, 1, 1); var end = new DateTime(2025, 1, 3); var account = new Account { Id = 1, Name = "Current", Color = "#000", CurrencyCode = "GBP" }; var transactions = new List { new() { Description = "Salary", Amount = 1000, Date = new DateTime(2025, 1, 1), Type = TransactionType.Income, AccountId = 1, Account = account }, new() { Description = "Lunch", Amount = 10, Date = new DateTime(2025, 1, 2), Type = TransactionType.Expense, AccountId = 1, Account = account } }; var result = DashboardService.BuildChartData([account], transactions, start, end); Assert.Equal(3, result.DayTransactionLabels.Count); Assert.Single(result.DayTransactionLabels[0]); // Jan 1 — Salary Assert.Contains("+£1,000.00 Salary (Current)", result.DayTransactionLabels[0][0]); Assert.Single(result.DayTransactionLabels[1]); // Jan 2 — Lunch Assert.Contains("-£10.00 Lunch (Current)", result.DayTransactionLabels[1][0]); Assert.Empty(result.DayTransactionLabels[2]); // Jan 3 — empty } [Fact] public void BuildChartData_CalculatesRunningBalance() { var start = new DateTime(2025, 1, 1); var end = new DateTime(2025, 1, 3); var today = DateTime.Today; // Account has current balance of 500 var account = new Account { Id = 1, Name = "Current", Color = "#000", CurrencyCode = "GBP", CurrentBalance = 500 }; // All transactions are in the past or at start var transactions = new List { new() { Description = "Income", Amount = 200, Date = new DateTime(2025, 1, 1), Type = TransactionType.Income, AccountId = 1, Account = account }, new() { Description = "Expense", Amount = 50, Date = new DateTime(2025, 1, 2), Type = TransactionType.Expense, AccountId = 1, Account = account } }; var result = DashboardService.BuildChartData([account], transactions, start, end); Assert.Single(result.Datasets); var data = result.Datasets[0].Data; Assert.Equal(3, data.Count); // The chart works backward from CurrentBalance to find starting point, then re-applies // Since these transactions are far in the past relative to "today", the behavior depends on // the start date vs today comparison. We validate the dataset structure is correct. Assert.Equal("Current", result.Datasets[0].Label); Assert.Equal("#000", result.Datasets[0].Color); Assert.Equal("£", result.Datasets[0].CurrencySymbol); } [Fact] public void BuildChartData_MultipleAccounts_CreatesDatasetPerAccount() { var start = new DateTime(2025, 1, 1); var end = new DateTime(2025, 1, 2); var account1 = new Account { Id = 1, Name = "Current", Color = "#111", CurrencyCode = "GBP", CurrentBalance = 100 }; var account2 = new Account { Id = 2, Name = "Savings", Color = "#222", CurrencyCode = "USD", CurrentBalance = 200 }; var result = DashboardService.BuildChartData([account1, account2], [], start, end); Assert.Equal(2, result.Datasets.Count); Assert.Equal("Current", result.Datasets[0].Label); Assert.Equal("Savings", result.Datasets[1].Label); Assert.Equal("£", result.Datasets[0].CurrencySymbol); Assert.Equal("$", result.Datasets[1].CurrencySymbol); } [Fact] public void BuildChartData_EmptyAccounts_ReturnsEmptyDatasets() { var start = new DateTime(2025, 1, 1); var end = new DateTime(2025, 1, 5); var result = DashboardService.BuildChartData([], [], start, end); Assert.Equal(5, result.Labels.Count); Assert.Empty(result.Datasets); } [Fact] public void BuildChartData_PaidFutureTransaction_ExcludedFromProjection() { var today = DateTime.Today; var start = today.AddDays(-1); var end = today.AddDays(5); // CurrentBalance is manually maintained by the user — MarkAsPaidAsync never touches it var account = new Account { Id = 1, Name = "Current", Color = "#000", CurrencyCode = "GBP", CurrentBalance = 1000 }; var transactions = new List { // Unpaid future expense — should reduce the projected balance new() { Description = "Unpaid Bill", Amount = 100, Date = today.AddDays(3), Type = TransactionType.Expense, AccountId = 1, Account = account, IsPaid = false }, // Paid future expense — excluded from projection; CurrentBalance already reflects it (user-managed) new() { Description = "Paid Bill", Amount = 200, Date = today.AddDays(4), Type = TransactionType.Expense, AccountId = 1, Account = account, IsPaid = true } }; var result = DashboardService.BuildChartData([account], transactions, start, end); var data = result.Datasets[0].Data; // Today's index = 1 (start is today-1) int todayIndex = 1; // Balance is flat at CurrentBalance up to the unpaid bill Assert.Equal(1000m, data[todayIndex]); // Day +3 (index 4): unpaid bill applied → 900 Assert.Equal(900m, data[todayIndex + 3]); // Day +4 (index 5): paid bill is excluded from projection → stays at 900 Assert.Equal(900m, data[todayIndex + 4]); } [Fact] public void BuildChartData_PaidFutureTransaction_ExcludedFromDayTransactionLabels() { var today = DateTime.Today; var start = today; var end = today.AddDays(2); var account = new Account { Id = 1, Name = "Current", Color = "#000", CurrencyCode = "GBP", CurrentBalance = 500 }; var transactions = new List { new() { Description = "Unpaid", Amount = 50, Date = today.AddDays(1), Type = TransactionType.Expense, AccountId = 1, Account = account, IsPaid = false }, new() { Description = "Paid", Amount = 100, Date = today.AddDays(2), Type = TransactionType.Expense, AccountId = 1, Account = account, IsPaid = true } }; var result = DashboardService.BuildChartData([account], transactions, start, end); // Day +1: unpaid transaction appears in labels Assert.Single(result.DayTransactionLabels[1]); Assert.Contains("Unpaid", result.DayTransactionLabels[1][0]); // Day +2: paid transaction is excluded from labels Assert.Empty(result.DayTransactionLabels[2]); } #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 TransactionRepository(context); var accountService = new AccountRepository(context); var categoryService = new CategoryRepository(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 TransactionRepository(context); var accountService = new AccountRepository(context); var categoryService = new CategoryRepository(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 TransactionRepository(context); var accountService = new AccountRepository(context); var categoryService = new CategoryRepository(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 }