From 106273983255c1a01ea8746426244aedb8136669 Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 20 Mar 2026 00:49:27 +0100 Subject: [PATCH] feat: implement dashboard service and controller with tests (TDD) --- .../DashboardServiceTests.cs | 161 ++++++++++++++++++ .../Controllers/DashboardController.cs | 30 ++++ .../Services/DashboardService.cs | 16 +- 3 files changed, 199 insertions(+), 8 deletions(-) create mode 100644 src/AccountTracking.Api.Tests/DashboardServiceTests.cs create mode 100644 src/AccountTracking.Api/Controllers/DashboardController.cs diff --git a/src/AccountTracking.Api.Tests/DashboardServiceTests.cs b/src/AccountTracking.Api.Tests/DashboardServiceTests.cs new file mode 100644 index 0000000..8e9252c --- /dev/null +++ b/src/AccountTracking.Api.Tests/DashboardServiceTests.cs @@ -0,0 +1,161 @@ +using AccountTracking.Api.Data; +using AccountTracking.Api.Models; +using AccountTracking.Api.Services; +using Microsoft.EntityFrameworkCore; + +namespace AccountTracking.Api.Tests; + +public class DashboardServiceTests +{ + private static AppDbContext CreateDb(params Transaction[] seed) + { + var opts = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var db = new AppDbContext(opts); + db.Transactions.AddRange(seed); + db.SaveChanges(); + return db; + } + + private static Transaction Tx(string date, decimal amount, decimal balance, + string category = "Test", string txId = "") => + new() + { + BookingDate = DateOnly.Parse(date), + Amount = amount, + Balance = balance, + Category = category, + TransactionId = txId == "" ? Guid.NewGuid().ToString() : txId, + Currency = "CZK", + AccountNumber = "test" + }; + + // ── Summary ────────────────────────────────────────────────────────────── + + [Fact] + public async Task Summary_YearOnly_ReturnsTotalsForWholeYear() + { + using var db = CreateDb( + Tx("2025-01-10", -1000, 9000), + Tx("2025-06-15", -2000, 7000), + Tx("2025-03-01", 5000, 12000), + Tx("2024-12-31", -500, 9500) // different year — excluded + ); + var svc = new DashboardService(db); + + var result = await svc.GetSummaryAsync(2025, null); + + Assert.Equal(3000m, result.TotalSpent); // 1000 + 2000 + Assert.Equal(5000m, result.TotalIncome); + } + + [Fact] + public async Task Summary_YearAndMonth_ReturnsTotalsForMonth() + { + using var db = CreateDb( + Tx("2025-03-10", -1000, 9000), + Tx("2025-03-20", -500, 8500), + Tx("2025-04-01", -800, 7700) // different month — excluded + ); + var svc = new DashboardService(db); + + var result = await svc.GetSummaryAsync(2025, 3); + + Assert.Equal(1500m, result.TotalSpent); + Assert.Equal(0m, result.TotalIncome); + } + + // ── SpendingByCategory ─────────────────────────────────────────────────── + + [Fact] + public async Task SpendingByCategory_ReturnsAbsoluteValuesSortedDescending() + { + using var db = CreateDb( + Tx("2025-03-01", -100, 900, "A"), + Tx("2025-03-02", -300, 600, "B"), + Tx("2025-03-03", -200, 400, "A") + ); + var svc = new DashboardService(db); + + var result = await svc.GetSpendingByCategoryAsync(2025, 3); + + Assert.Equal(2, result.Count); + Assert.Equal("A", result[0].Category); + Assert.Equal(300m, result[0].Total); // 100 + 200 + Assert.Equal("B", result[1].Category); + Assert.Equal(300m, result[1].Total); + } + + [Fact] + public async Task SpendingByCategory_ExcludesPositiveAmounts() + { + using var db = CreateDb( + Tx("2025-03-01", -500, 9500, "Potraviny"), + Tx("2025-03-02", 200, 9700, "Potraviny") // refund — excluded + ); + var svc = new DashboardService(db); + + var result = await svc.GetSpendingByCategoryAsync(2025, 3); + + Assert.Single(result); + Assert.Equal(500m, result[0].Total); + } + + // ── MonthlyBalances ────────────────────────────────────────────────────── + + [Fact] + public async Task MonthlyBalances_ReturnsLastBalancePerMonth() + { + using var db = CreateDb( + Tx("2025-01-10", -100, 900), + Tx("2025-01-20", -200, 700), // last in Jan — use this balance + Tx("2025-03-05", -150, 550) // March — Feb omitted (no transactions) + ); + var svc = new DashboardService(db); + + var result = await svc.GetMonthlyBalancesAsync(2025); + + Assert.Equal(2, result.Count); + Assert.Equal(1, result[0].Month); + Assert.Equal(700m, result[0].ClosingBalance); + Assert.Equal(3, result[1].Month); + Assert.Equal(550m, result[1].ClosingBalance); + } + + // ── CumulativeSpending ─────────────────────────────────────────────────── + + [Fact] + public async Task CumulativeSpending_FillsGapsAndAccumulates() + { + using var db = CreateDb( + Tx("2025-03-01", -100, 900), + Tx("2025-03-03", -200, 700), // gap on day 2 + Tx("2025-03-03", -50, 650) // two transactions on same day + ); + var svc = new DashboardService(db); + + var result = await svc.GetCumulativeSpendingAsync(2025, 3); + + // Day 1: 100, Day 2: filled = 100, Day 3: 100 + 250 = 350 + Assert.Equal(3, result.Count); + Assert.Equal(1, result[0].Day); Assert.Equal(100m, result[0].CumulativeSpent); + Assert.Equal(2, result[1].Day); Assert.Equal(100m, result[1].CumulativeSpent); + Assert.Equal(3, result[2].Day); Assert.Equal(350m, result[2].CumulativeSpent); + } + + [Fact] + public async Task CumulativeSpending_ExcludesPositiveAmounts() + { + using var db = CreateDb( + Tx("2025-03-01", -100, 900), + Tx("2025-03-01", 500, 1400) // income — not counted + ); + var svc = new DashboardService(db); + + var result = await svc.GetCumulativeSpendingAsync(2025, 3); + + Assert.Single(result); + Assert.Equal(100m, result[0].CumulativeSpent); + } +} diff --git a/src/AccountTracking.Api/Controllers/DashboardController.cs b/src/AccountTracking.Api/Controllers/DashboardController.cs new file mode 100644 index 0000000..814e160 --- /dev/null +++ b/src/AccountTracking.Api/Controllers/DashboardController.cs @@ -0,0 +1,30 @@ +using AccountTracking.Api.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace AccountTracking.Api.Controllers; + +[ApiController] +[Route("api/dashboard")] +[Authorize] +public class DashboardController : ControllerBase +{ + private readonly DashboardService _svc; + public DashboardController(DashboardService svc) => _svc = svc; + + [HttpGet("summary")] + public async Task Summary([FromQuery] int year, [FromQuery] int? month) + => Ok(await _svc.GetSummaryAsync(year, month)); + + [HttpGet("spending-by-category")] + public async Task SpendingByCategory([FromQuery] int year, [FromQuery] int? month) + => Ok(await _svc.GetSpendingByCategoryAsync(year, month)); + + [HttpGet("monthly-balances")] + public async Task MonthlyBalances([FromQuery] int year) + => Ok(await _svc.GetMonthlyBalancesAsync(year)); + + [HttpGet("cumulative-spending")] + public async Task CumulativeSpending([FromQuery] int year, [FromQuery] int month) + => Ok(await _svc.GetCumulativeSpendingAsync(year, month)); +} diff --git a/src/AccountTracking.Api/Services/DashboardService.cs b/src/AccountTracking.Api/Services/DashboardService.cs index 5032516..064585d 100644 --- a/src/AccountTracking.Api/Services/DashboardService.cs +++ b/src/AccountTracking.Api/Services/DashboardService.cs @@ -39,20 +39,20 @@ public class DashboardService(AppDbContext db) public async Task> GetMonthlyBalancesAsync(int year) { - // For each month: get the balance of the transaction with the latest booking_date - // (highest id as tiebreaker for same-date rows) + // Fetch minimal columns, then group in memory to get closing balance per month + // (latest booking_date, highest id as tiebreaker) var rows = await db.Transactions .Where(t => t.BookingDate.Year == year) + .Select(t => new { t.BookingDate, t.Id, t.Balance }) + .ToListAsync(); + + return rows .GroupBy(t => t.BookingDate.Month) .Select(g => new MonthlyBalanceDto( g.Key, - g.OrderByDescending(t => t.BookingDate) - .ThenByDescending(t => t.Id) - .First().Balance)) + g.OrderByDescending(t => t.BookingDate).ThenByDescending(t => t.Id).First().Balance)) .OrderBy(r => r.Month) - .ToListAsync(); - - return rows; + .ToList(); } public async Task> GetCumulativeSpendingAsync(int year, int month)