449 lines
15 KiB
C#
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
|
|
}
|