using AutoMapper; using Koogle.Application.DTOs; using Koogle.Application.Services; using Koogle.Domain.Entities; using Koogle.Domain.Enums; using Koogle.Domain.Interfaces; using KoogleApp.Data; using Microsoft.EntityFrameworkCore; using Moq; using FluentAssertions; using Koogle.Application.Interfaces; namespace Koogle.Tests.Unit.Services; /// /// Unit tests for CashBookService. /// public class CashBookServiceTests : IDisposable { private readonly Mock _entryRepositoryMock; private readonly Mock _categoryRepositoryMock; private readonly Mock _personExpenseRepositoryMock; private readonly Mock _personRepositoryMock; private readonly Mock _clubRepositoryMock; private readonly Mock _clubContextMock; private readonly Mock _mapperMock; private readonly AppDbContext _context; private readonly CashBookService _sut; private readonly Guid _clubId = Guid.NewGuid(); public CashBookServiceTests() { _entryRepositoryMock = new Mock(); _categoryRepositoryMock = new Mock(); _personExpenseRepositoryMock = new Mock(); _personRepositoryMock = new Mock(); _clubRepositoryMock = new Mock(); _clubContextMock = new Mock(); _mapperMock = new Mock(); _clubContextMock.Setup(c => c.ClubId).Returns(_clubId); var options = new DbContextOptionsBuilder() .UseInMemoryDatabase($"CashBookTest_{Guid.NewGuid()}") .Options; _context = new AppDbContext(options); var factoryMock = new Mock>(); factoryMock.Setup(f => f.CreateDbContextAsync(It.IsAny())) .ReturnsAsync(() => new AppDbContext(options)); SetupMapper(); _sut = new CashBookService( _entryRepositoryMock.Object, _categoryRepositoryMock.Object, _personExpenseRepositoryMock.Object, _personRepositoryMock.Object, _clubRepositoryMock.Object, _clubContextMock.Object, factoryMock.Object, _mapperMock.Object); } public void Dispose() { _context.Dispose(); } private void SetupMapper() { _mapperMock.Setup(m => m.Map(It.IsAny())) .Returns((CashBookEntry e) => new CashBookEntryDto { Id = e.Id, CategoryId = e.CategoryId, CategoryName = e.Category?.Name ?? "", EntryType = e.EntryType, Amount = e.Amount, BookingDate = e.BookingDate, Comment = e.Comment, PersonId = e.PersonId, PersonName = e.Person?.Name, CreatedAt = e.CreatedAt }); _mapperMock.Setup(m => m.Map>(It.IsAny>())) .Returns((List entries) => entries.Select(e => new CashBookEntryDto { Id = e.Id, CategoryId = e.CategoryId, CategoryName = e.Category?.Name ?? "", EntryType = e.EntryType, Amount = e.Amount, BookingDate = e.BookingDate, Comment = e.Comment, PersonId = e.PersonId, PersonName = e.Person?.Name, CreatedAt = e.CreatedAt }).ToList()); } #region GetEntriesAsync Tests [Fact] public async Task GetEntriesAsync_ReturnsEmptyList_WhenNoEntriesExist() { // Arrange _entryRepositoryMock.Setup(r => r.GetByClubIdAsync(_clubId, null, null, It.IsAny())) .ReturnsAsync([]); // Act var result = await _sut.GetEntriesAsync(); // Assert result.Should().BeEmpty(); } [Fact] public async Task GetEntriesAsync_ReturnsEntries_WithDateFilter() { // Arrange var from = new DateTime(2024, 1, 1); var to = new DateTime(2024, 12, 31); _entryRepositoryMock.Setup(r => r.GetByClubIdAsync(_clubId, from, to, It.IsAny())) .ReturnsAsync([]); // Act await _sut.GetEntriesAsync(from, to); // Assert _entryRepositoryMock.Verify(r => r.GetByClubIdAsync(_clubId, from, to, It.IsAny()), Times.Once); } #endregion #region GetByIdAsync Tests [Fact] public async Task GetByIdAsync_ReturnsNull_WhenEntryNotFound() { // Arrange var id = Guid.NewGuid(); _entryRepositoryMock.Setup(r => r.GetByIdAsync(id, It.IsAny())) .ReturnsAsync((CashBookEntry?)null); // Act var result = await _sut.GetByIdAsync(id); // Assert result.Should().BeNull(); } [Fact] public async Task GetByIdAsync_ReturnsNull_WhenEntryBelongsToDifferentClub() { // Arrange var entry = CreateEntry(clubId: Guid.NewGuid()); _entryRepositoryMock.Setup(r => r.GetByIdAsync(entry.Id, It.IsAny())) .ReturnsAsync(entry); // Act var result = await _sut.GetByIdAsync(entry.Id); // Assert result.Should().BeNull(); } [Fact] public async Task GetByIdAsync_ReturnsEntry_WhenExists() { // Arrange var entry = CreateEntry(); _entryRepositoryMock.Setup(r => r.GetByIdAsync(entry.Id, It.IsAny())) .ReturnsAsync(entry); // Act var result = await _sut.GetByIdAsync(entry.Id); // Assert result.Should().NotBeNull(); result!.Id.Should().Be(entry.Id); } #endregion #region CreateAsync Tests [Fact] public async Task CreateAsync_CreatesEntry_WithCorrectData() { // Arrange var categoryId = Guid.NewGuid(); var dto = new CreateCashBookEntryDto { CategoryId = categoryId, EntryType = CashBookEntryType.Income, Amount = 100m, BookingDate = DateTime.Today, Comment = "Test entry" }; CashBookEntry? captured = null; _entryRepositoryMock.Setup(r => r.AddAsync(It.IsAny(), It.IsAny())) .Callback((e, _) => captured = e) .ReturnsAsync((CashBookEntry e, CancellationToken _) => e); _entryRepositoryMock.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync((Guid id, CancellationToken _) => captured); // Act await _sut.CreateAsync(dto); // Assert captured.Should().NotBeNull(); captured!.ClubId.Should().Be(_clubId); captured.Amount.Should().Be(100m); captured.EntryType.Should().Be(CashBookEntryType.Income); } #endregion #region GetBalanceAsync Tests [Fact] public async Task GetBalanceAsync_ReturnsBalance_FromRepository() { // Arrange var expectedBalance = 1500.50m; _entryRepositoryMock.Setup(r => r.GetBalanceAsync(_clubId, null, It.IsAny())) .ReturnsAsync(expectedBalance); // Act var result = await _sut.GetBalanceAsync(); // Assert result.Should().Be(expectedBalance); } [Fact] public async Task GetBalanceAsync_PassesDate_ToRepository() { // Arrange var asOfDate = new DateTime(2024, 6, 30); _entryRepositoryMock.Setup(r => r.GetBalanceAsync(_clubId, asOfDate, It.IsAny())) .ReturnsAsync(0m); // Act await _sut.GetBalanceAsync(asOfDate); // Assert _entryRepositoryMock.Verify(r => r.GetBalanceAsync(_clubId, asOfDate, It.IsAny()), Times.Once); } #endregion #region GetReportAsync Tests [Fact] public async Task GetReportAsync_CalculatesBalances_Correctly() { // Arrange var from = new DateTime(2024, 1, 1); var to = new DateTime(2024, 1, 31); var openingBalance = 1000m; var entries = new List { CreateEntry(amount: 500, type: CashBookEntryType.Income), CreateEntry(amount: 200, type: CashBookEntryType.Expense), CreateEntry(amount: 300, type: CashBookEntryType.Income) }; _entryRepositoryMock.Setup(r => r.GetByClubIdAsync(_clubId, from, to, It.IsAny())) .ReturnsAsync(entries); _entryRepositoryMock.Setup(r => r.GetBalanceAsync(_clubId, from.AddDays(-1), It.IsAny())) .ReturnsAsync(openingBalance); // Act var result = await _sut.GetReportAsync(from, to); // Assert result.OpeningBalance.Should().Be(1000m); result.TotalIncome.Should().Be(800m); // 500 + 300 result.TotalExpense.Should().Be(200m); result.ClosingBalance.Should().Be(1600m); // 1000 + 800 - 200 } #endregion #region MembershipFeesExistAsync Tests [Fact] public async Task MembershipFeesExistAsync_ReturnsTrue_WhenExists() { // Arrange _entryRepositoryMock.Setup(r => r.ExistsMembershipFeeForMonthAsync(_clubId, 2024, 6, It.IsAny())) .ReturnsAsync(true); // Act var result = await _sut.MembershipFeesExistAsync(2024, 6); // Assert result.Should().BeTrue(); } [Fact] public async Task MembershipFeesExistAsync_ReturnsFalse_WhenNotExists() { // Arrange _entryRepositoryMock.Setup(r => r.ExistsMembershipFeeForMonthAsync(_clubId, 2024, 6, It.IsAny())) .ReturnsAsync(false); // Act var result = await _sut.MembershipFeesExistAsync(2024, 6); // Assert result.Should().BeFalse(); } #endregion #region CreateMembershipFeesAsync Tests [Fact] public async Task CreateMembershipFeesAsync_ThrowsException_WhenFeesAlreadyExist() { // Arrange var dto = new CreateMembershipFeesDto { Year = 2024, Month = 6 }; _entryRepositoryMock.Setup(r => r.ExistsMembershipFeeForMonthAsync(_clubId, 2024, 6, It.IsAny())) .ReturnsAsync(true); // Act var act = () => _sut.CreateMembershipFeesAsync(dto); // Assert await act.Should().ThrowAsync() .WithMessage("Membership fees for 6/2024 already exist."); } [Fact] public async Task CreateMembershipFeesAsync_ThrowsException_WhenFeeAmountIsZero() { // Arrange var dto = new CreateMembershipFeesDto { Year = 2024, Month = 6 }; var club = new Club { Id = _clubId, MonthlyMembershipFee = 0 }; _entryRepositoryMock.Setup(r => r.ExistsMembershipFeeForMonthAsync(_clubId, 2024, 6, It.IsAny())) .ReturnsAsync(false); _clubRepositoryMock.Setup(r => r.GetByIdAsync(_clubId, It.IsAny())) .ReturnsAsync(club); // Act var act = () => _sut.CreateMembershipFeesAsync(dto); // Assert await act.Should().ThrowAsync() .WithMessage("Membership fee amount must be greater than zero."); } [Fact] public async Task CreateMembershipFeesAsync_CreatesEntries_ForAllActiveMembers() { // Arrange var dto = new CreateMembershipFeesDto { Year = 2024, Month = 6 }; var club = new Club { Id = _clubId, MonthlyMembershipFee = 25m }; var category = new BookingCategory { Id = Guid.NewGuid(), Name = "Mitgliedsbeitrag" }; var members = new List { new() { Id = Guid.NewGuid(), Name = "Member 1", PersonStatus = PersonStatus.Member }, new() { Id = Guid.NewGuid(), Name = "Member 2", PersonStatus = PersonStatus.Member }, new() { Id = Guid.NewGuid(), Name = "Guest", PersonStatus = PersonStatus.Guest } }; _entryRepositoryMock.Setup(r => r.ExistsMembershipFeeForMonthAsync(_clubId, 2024, 6, It.IsAny())) .ReturnsAsync(false); _clubRepositoryMock.Setup(r => r.GetByIdAsync(_clubId, It.IsAny())) .ReturnsAsync(club); _categoryRepositoryMock.Setup(r => r.GetSystemCategoryAsync(_clubId, "Mitgliedsbeitrag", It.IsAny())) .ReturnsAsync(category); _personRepositoryMock.Setup(r => r.GetByClubIdAsync(_clubId, It.IsAny())) .ReturnsAsync(members); _entryRepositoryMock.Setup(r => r.AddAsync(It.IsAny(), It.IsAny())) .ReturnsAsync((CashBookEntry e, CancellationToken _) => e); // Act var result = await _sut.CreateMembershipFeesAsync(dto); // Assert - only 2 members, not guest _entryRepositoryMock.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); } [Fact] public async Task CreateMembershipFeesAsync_UsesOverrideAmount_WhenProvided() { // Arrange var dto = new CreateMembershipFeesDto { Year = 2024, Month = 6, OverrideAmount = 30m }; var club = new Club { Id = _clubId, MonthlyMembershipFee = 25m }; var category = new BookingCategory { Id = Guid.NewGuid(), Name = "Mitgliedsbeitrag" }; var members = new List { new() { Id = Guid.NewGuid(), Name = "Member 1", PersonStatus = PersonStatus.Member } }; CashBookEntry? captured = null; _entryRepositoryMock.Setup(r => r.ExistsMembershipFeeForMonthAsync(_clubId, 2024, 6, It.IsAny())) .ReturnsAsync(false); _clubRepositoryMock.Setup(r => r.GetByIdAsync(_clubId, It.IsAny())) .ReturnsAsync(club); _categoryRepositoryMock.Setup(r => r.GetSystemCategoryAsync(_clubId, "Mitgliedsbeitrag", It.IsAny())) .ReturnsAsync(category); _personRepositoryMock.Setup(r => r.GetByClubIdAsync(_clubId, It.IsAny())) .ReturnsAsync(members); _entryRepositoryMock.Setup(r => r.AddAsync(It.IsAny(), It.IsAny())) .Callback((e, _) => captured = e) .ReturnsAsync((CashBookEntry e, CancellationToken _) => e); // Act await _sut.CreateMembershipFeesAsync(dto); // Assert - uses override amount 30, not club default 25 captured.Should().NotBeNull(); captured!.Amount.Should().Be(30m); } #endregion #region Helper Methods private CashBookEntry CreateEntry( Guid? clubId = null, decimal amount = 100m, CashBookEntryType type = CashBookEntryType.Income) { return new CashBookEntry { Id = Guid.NewGuid(), ClubId = clubId ?? _clubId, CategoryId = Guid.NewGuid(), EntryType = type, Amount = amount, BookingDate = DateTime.Today, CreatedAt = DateTime.UtcNow, Category = new BookingCategory { Name = "Test" } }; } #endregion }