KoogleApp/test/Koogle.Tests/Unit/Services/CashBookServiceTests.cs

449 lines
15 KiB
C#

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;
/// <summary>
/// Unit tests for CashBookService.
/// </summary>
public class CashBookServiceTests : IDisposable
{
private readonly Mock<ICashBookEntryRepository> _entryRepositoryMock;
private readonly Mock<IBookingCategoryRepository> _categoryRepositoryMock;
private readonly Mock<IPersonExpenseRepository> _personExpenseRepositoryMock;
private readonly Mock<IPersonRepository> _personRepositoryMock;
private readonly Mock<IClubRepository> _clubRepositoryMock;
private readonly Mock<ICurrentClubContext> _clubContextMock;
private readonly Mock<IMapper> _mapperMock;
private readonly AppDbContext _context;
private readonly CashBookService _sut;
private readonly Guid _clubId = Guid.NewGuid();
public CashBookServiceTests()
{
_entryRepositoryMock = new Mock<ICashBookEntryRepository>();
_categoryRepositoryMock = new Mock<IBookingCategoryRepository>();
_personExpenseRepositoryMock = new Mock<IPersonExpenseRepository>();
_personRepositoryMock = new Mock<IPersonRepository>();
_clubRepositoryMock = new Mock<IClubRepository>();
_clubContextMock = new Mock<ICurrentClubContext>();
_mapperMock = new Mock<IMapper>();
_clubContextMock.Setup(c => c.ClubId).Returns(_clubId);
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"CashBookTest_{Guid.NewGuid()}")
.Options;
_context = new AppDbContext(options);
var factoryMock = new Mock<IDbContextFactory<AppDbContext>>();
factoryMock.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
.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<CashBookEntryDto>(It.IsAny<CashBookEntry>()))
.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<List<CashBookEntryDto>>(It.IsAny<List<CashBookEntry>>()))
.Returns((List<CashBookEntry> 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<CancellationToken>()))
.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<CancellationToken>()))
.ReturnsAsync([]);
// Act
await _sut.GetEntriesAsync(from, to);
// Assert
_entryRepositoryMock.Verify(r => r.GetByClubIdAsync(_clubId, from, to, It.IsAny<CancellationToken>()), 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<CancellationToken>()))
.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<CancellationToken>()))
.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<CancellationToken>()))
.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<CashBookEntry>(), It.IsAny<CancellationToken>()))
.Callback<CashBookEntry, CancellationToken>((e, _) => captured = e)
.ReturnsAsync((CashBookEntry e, CancellationToken _) => e);
_entryRepositoryMock.Setup(r => r.GetByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.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<CancellationToken>()))
.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<CancellationToken>()))
.ReturnsAsync(0m);
// Act
await _sut.GetBalanceAsync(asOfDate);
// Assert
_entryRepositoryMock.Verify(r => r.GetBalanceAsync(_clubId, asOfDate, It.IsAny<CancellationToken>()), 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<CashBookEntry>
{
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<CancellationToken>()))
.ReturnsAsync(entries);
_entryRepositoryMock.Setup(r => r.GetBalanceAsync(_clubId, from.AddDays(-1), It.IsAny<CancellationToken>()))
.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<CancellationToken>()))
.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<CancellationToken>()))
.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<CancellationToken>()))
.ReturnsAsync(true);
// Act
var act = () => _sut.CreateMembershipFeesAsync(dto);
// Assert
await act.Should().ThrowAsync<InvalidOperationException>()
.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<CancellationToken>()))
.ReturnsAsync(false);
_clubRepositoryMock.Setup(r => r.GetByIdAsync(_clubId, It.IsAny<CancellationToken>()))
.ReturnsAsync(club);
// Act
var act = () => _sut.CreateMembershipFeesAsync(dto);
// Assert
await act.Should().ThrowAsync<InvalidOperationException>()
.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<Person>
{
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<CancellationToken>()))
.ReturnsAsync(false);
_clubRepositoryMock.Setup(r => r.GetByIdAsync(_clubId, It.IsAny<CancellationToken>()))
.ReturnsAsync(club);
_categoryRepositoryMock.Setup(r => r.GetSystemCategoryAsync(_clubId, "Mitgliedsbeitrag", It.IsAny<CancellationToken>()))
.ReturnsAsync(category);
_personRepositoryMock.Setup(r => r.GetByClubIdAsync(_clubId, It.IsAny<CancellationToken>()))
.ReturnsAsync(members);
_entryRepositoryMock.Setup(r => r.AddAsync(It.IsAny<CashBookEntry>(), It.IsAny<CancellationToken>()))
.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<CashBookEntry>(), It.IsAny<CancellationToken>()), 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<Person>
{
new() { Id = Guid.NewGuid(), Name = "Member 1", PersonStatus = PersonStatus.Member }
};
CashBookEntry? captured = null;
_entryRepositoryMock.Setup(r => r.ExistsMembershipFeeForMonthAsync(_clubId, 2024, 6, It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
_clubRepositoryMock.Setup(r => r.GetByIdAsync(_clubId, It.IsAny<CancellationToken>()))
.ReturnsAsync(club);
_categoryRepositoryMock.Setup(r => r.GetSystemCategoryAsync(_clubId, "Mitgliedsbeitrag", It.IsAny<CancellationToken>()))
.ReturnsAsync(category);
_personRepositoryMock.Setup(r => r.GetByClubIdAsync(_clubId, It.IsAny<CancellationToken>()))
.ReturnsAsync(members);
_entryRepositoryMock.Setup(r => r.AddAsync(It.IsAny<CashBookEntry>(), It.IsAny<CancellationToken>()))
.Callback<CashBookEntry, CancellationToken>((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
}