using AutoMapper; using Koogle.Application.DTOs; using Koogle.Application.Interfaces; 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; namespace Koogle.Tests.Integration; /// /// Integration tests for CashBook functionality with in-memory database. /// public class CashBookIntegrationTests : IAsyncLifetime { private AppDbContext _context = null!; private CashBookService _cashBookService = null!; private BookingCategoryService _categoryService = null!; private Mock _clubContextMock = null!; private Guid _clubId; private Club _club = null!; public async Task InitializeAsync() { _clubId = Guid.NewGuid(); _clubContextMock = new Mock(); _clubContextMock.Setup(c => c.ClubId).Returns(_clubId); var options = new DbContextOptionsBuilder() .UseInMemoryDatabase($"CashBookIntegration_{Guid.NewGuid()}") .Options; _context = new AppDbContext(options); // Create club _club = new Club { Id = _clubId, Name = "Test Club", LoginName = "testclub", MonthlyMembershipFee = 25m, CreatedAt = DateTime.UtcNow }; await _context.Clubs.AddAsync(_club); await _context.SaveChangesAsync(); // Setup services var mapper = CreateMapper(); var factoryMock = new Mock>(); factoryMock.Setup(f => f.CreateDbContextAsync(It.IsAny())) .ReturnsAsync(() => new AppDbContext(options)); var categoryRepository = new TestBookingCategoryRepository(_context); var entryRepository = new TestCashBookEntryRepository(_context); var personRepository = new TestPersonRepository(_context); var clubRepository = new TestClubRepository(_context); var personExpenseRepository = new TestPersonExpenseRepository(_context); _categoryService = new BookingCategoryService( categoryRepository, _clubContextMock.Object, mapper); _cashBookService = new CashBookService( entryRepository, categoryRepository, personExpenseRepository, personRepository, clubRepository, _clubContextMock.Object, factoryMock.Object, mapper); // Seed system categories await _categoryService.EnsureSystemCategoriesAsync(_clubId); } public async Task DisposeAsync() { await _context.DisposeAsync(); } private static IMapper CreateMapper() { var mapperMock = new Mock(); mapperMock.Setup(m => m.Map(It.IsAny())) .Returns((BookingCategory c) => new BookingCategoryDto { Id = c.Id, Name = c.Name, Description = c.Description, CategoryType = c.CategoryType, IsSystemCategory = c.IsSystemCategory, Color = c.Color, Icon = c.Icon, IsActive = c.IsActive }); mapperMock.Setup(m => m.Map>(It.IsAny>())) .Returns((List cats) => cats.Select(c => new BookingCategoryDto { Id = c.Id, Name = c.Name, Description = c.Description, CategoryType = c.CategoryType, IsSystemCategory = c.IsSystemCategory, Color = c.Color, Icon = c.Icon, IsActive = c.IsActive }).ToList()); 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()); return mapperMock.Object; } #region Category Integration Tests [Fact] public async Task EnsureSystemCategories_CreatesAllSystemCategories() { // Act var categories = await _categoryService.GetAllAsync(); // Assert categories.Should().HaveCount(4); categories.Should().Contain(c => c.Name == "Spielstrafe"); categories.Should().Contain(c => c.Name == "Mitgliedsbeitrag"); categories.Should().Contain(c => c.Name == "Korrekturbuchung"); categories.Should().Contain(c => c.Name == "Saldoanpassung"); } [Fact] public async Task CreateCategory_PersistsToDatabase() { // Arrange var dto = new CreateBookingCategoryDto { Name = "Spenden", Description = "Einnahmen aus Spenden", CategoryType = BookingCategoryType.Income, Color = "#00FF00", Icon = "Favorite" }; // Act var result = await _categoryService.CreateAsync(dto); // Assert result.Should().NotBeNull(); result.Name.Should().Be("Spenden"); result.IsSystemCategory.Should().BeFalse(); // Verify in database var dbCategory = await _context.BookingCategories.FindAsync(result.Id); dbCategory.Should().NotBeNull(); } [Fact] public async Task DeleteSystemCategory_ThrowsException() { // Arrange var categories = await _categoryService.GetAllAsync(); var systemCategory = categories.First(c => c.IsSystemCategory); // Act var act = () => _categoryService.DeleteAsync(systemCategory.Id); // Assert await act.Should().ThrowAsync() .WithMessage("System categories cannot be deleted."); } #endregion #region CashBook Entry Integration Tests [Fact] public async Task CreateEntry_PersistsToDatabase() { // Arrange var categories = await _categoryService.GetAllAsync(); var category = categories.First(); var dto = new CreateCashBookEntryDto { CategoryId = category.Id, EntryType = CashBookEntryType.Income, Amount = 150m, BookingDate = DateTime.Today, Comment = "Test entry" }; // Act var result = await _cashBookService.CreateAsync(dto); // Assert result.Should().NotBeNull(); result.Amount.Should().Be(150m); var dbEntry = await _context.CashBookEntries.FindAsync(result.Id); dbEntry.Should().NotBeNull(); } [Fact] public async Task GetBalance_CalculatesCorrectly() { // Arrange var categories = await _categoryService.GetAllAsync(); var incomeCategory = categories.First(c => c.CategoryType == BookingCategoryType.Income); // Add income entries await _cashBookService.CreateAsync(new CreateCashBookEntryDto { CategoryId = incomeCategory.Id, EntryType = CashBookEntryType.Income, Amount = 100m, BookingDate = DateTime.Today }); await _cashBookService.CreateAsync(new CreateCashBookEntryDto { CategoryId = incomeCategory.Id, EntryType = CashBookEntryType.Income, Amount = 50m, BookingDate = DateTime.Today }); await _cashBookService.CreateAsync(new CreateCashBookEntryDto { CategoryId = incomeCategory.Id, EntryType = CashBookEntryType.Expense, Amount = 30m, BookingDate = DateTime.Today }); // Act var balance = await _cashBookService.GetBalanceAsync(); // Assert - 100 + 50 - 30 = 120 balance.Should().Be(120m); } #endregion #region Report Integration Tests [Fact] public async Task GetReport_ReturnsCorrectSummary() { // Arrange var categories = await _categoryService.GetAllAsync(); var incomeCategory = categories.First(c => c.Name == "Spielstrafe"); var from = DateTime.Today.AddDays(-7); var to = DateTime.Today; await _cashBookService.CreateAsync(new CreateCashBookEntryDto { CategoryId = incomeCategory.Id, EntryType = CashBookEntryType.Income, Amount = 200m, BookingDate = DateTime.Today }); // Act var report = await _cashBookService.GetReportAsync(from, to); // Assert report.TotalIncome.Should().Be(200m); report.TotalExpense.Should().Be(0m); report.IncomeByCategory.Should().ContainSingle(c => c.CategoryName == "Spielstrafe"); } #endregion #region Membership Fee Integration Tests [Fact] public async Task CreateMembershipFees_CreatesEntriesForAllMembers() { // Arrange var members = new[] { new Person { Id = Guid.NewGuid(), ClubId = _clubId, Name = "Max", PersonStatus = PersonStatus.Member, CreatedAt = DateTime.UtcNow }, new Person { Id = Guid.NewGuid(), ClubId = _clubId, Name = "Anna", PersonStatus = PersonStatus.Member, CreatedAt = DateTime.UtcNow }, new Person { Id = Guid.NewGuid(), ClubId = _clubId, Name = "Guest", PersonStatus = PersonStatus.Guest, CreatedAt = DateTime.UtcNow } }; await _context.Persons.AddRangeAsync(members); await _context.SaveChangesAsync(); var dto = new CreateMembershipFeesDto { Year = 2024, Month = 6 }; // Act var result = await _cashBookService.CreateMembershipFeesAsync(dto); // Assert - 2 members, not guest result.Should().HaveCount(2); result.Should().OnlyContain(e => e.Amount == 25m); } [Fact] public async Task CreateMembershipFees_ThrowsOnDuplicate() { // Arrange var member = new Person { Id = Guid.NewGuid(), ClubId = _clubId, Name = "Max", PersonStatus = PersonStatus.Member, CreatedAt = DateTime.UtcNow }; await _context.Persons.AddAsync(member); await _context.SaveChangesAsync(); var dto = new CreateMembershipFeesDto { Year = 2024, Month = 7 }; await _cashBookService.CreateMembershipFeesAsync(dto); // Act var act = () => _cashBookService.CreateMembershipFeesAsync(dto); // Assert await act.Should().ThrowAsync(); } #endregion } #region Simple Test Repositories internal class TestBookingCategoryRepository : IBookingCategoryRepository { private readonly AppDbContext _context; public TestBookingCategoryRepository(AppDbContext context) => _context = context; public async Task> GetByClubIdAsync(Guid clubId, bool includeInactive = false, CancellationToken ct = default) { var query = _context.BookingCategories.Where(c => c.ClubId == clubId && !c.IsDeleted); if (!includeInactive) query = query.Where(c => c.IsActive); return await query.ToListAsync(ct); } public async Task GetByIdAsync(Guid id, CancellationToken ct = default) => await _context.BookingCategories.FirstOrDefaultAsync(c => c.Id == id && !c.IsDeleted, ct); public async Task GetSystemCategoryAsync(Guid clubId, string name, CancellationToken ct = default) => await _context.BookingCategories.FirstOrDefaultAsync(c => c.ClubId == clubId && c.Name == name && c.IsSystemCategory && !c.IsDeleted, ct); public async Task AddAsync(BookingCategory entity, CancellationToken ct = default) { await _context.BookingCategories.AddAsync(entity, ct); await _context.SaveChangesAsync(ct); return entity; } public async Task UpdateAsync(BookingCategory entity, CancellationToken ct = default) { _context.BookingCategories.Update(entity); await _context.SaveChangesAsync(ct); return entity; } } internal class TestCashBookEntryRepository : ICashBookEntryRepository { private readonly AppDbContext _context; public TestCashBookEntryRepository(AppDbContext context) => _context = context; public async Task> GetByClubIdAsync(Guid clubId, DateTime? from, DateTime? to, CancellationToken ct = default) { var query = _context.CashBookEntries .Include(e => e.Category) .Include(e => e.Person) .Where(e => e.ClubId == clubId && !e.IsDeleted); if (from.HasValue) query = query.Where(e => e.BookingDate >= from.Value); if (to.HasValue) query = query.Where(e => e.BookingDate <= to.Value); return await query.OrderByDescending(e => e.BookingDate).ToListAsync(ct); } public async Task GetByIdAsync(Guid id, CancellationToken ct = default) => await _context.CashBookEntries.Include(e => e.Category).Include(e => e.Person).FirstOrDefaultAsync(e => e.Id == id && !e.IsDeleted, ct); public async Task GetBalanceAsync(Guid clubId, DateTime? asOfDate = null, CancellationToken ct = default) { var query = _context.CashBookEntries.Where(e => e.ClubId == clubId && !e.IsDeleted); if (asOfDate.HasValue) query = query.Where(e => e.BookingDate <= asOfDate.Value); var income = await query.Where(e => e.EntryType == CashBookEntryType.Income).SumAsync(e => e.Amount, ct); var expense = await query.Where(e => e.EntryType == CashBookEntryType.Expense).SumAsync(e => e.Amount, ct); return income - expense; } public async Task AddAsync(CashBookEntry entity, CancellationToken ct = default) { await _context.CashBookEntries.AddAsync(entity, ct); await _context.SaveChangesAsync(ct); return entity; } public async Task> GetByDayIdAsync(Guid dayId, CancellationToken ct = default) => await _context.CashBookEntries.Where(e => e.DayId == dayId && !e.IsDeleted).ToListAsync(ct); public async Task ExistsMembershipFeeForMonthAsync(Guid clubId, int year, int month, CancellationToken ct = default) { var startOfMonth = new DateTime(year, month, 1); var endOfMonth = startOfMonth.AddMonths(1).AddDays(-1); return await _context.CashBookEntries .Include(e => e.Category) .AnyAsync(e => e.ClubId == clubId && e.Category.Name == "Mitgliedsbeitrag" && e.BookingDate >= startOfMonth && e.BookingDate <= endOfMonth && !e.IsDeleted, ct); } } internal class TestPersonRepository : IPersonRepository { private readonly AppDbContext _context; public TestPersonRepository(AppDbContext context) => _context = context; public async Task> GetByClubIdAsync(Guid clubId, CancellationToken ct = default) => await _context.Persons.Where(p => p.ClubId == clubId && !p.IsDeleted).ToListAsync(ct); public Task GetByIdAsync(Guid id, CancellationToken ct = default) => _context.Persons.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted, ct); public async Task AddAsync(Person entity, CancellationToken ct = default) { await _context.Persons.AddAsync(entity, ct); await _context.SaveChangesAsync(ct); return entity; } public async Task UpdateAsync(Person entity, CancellationToken ct = default) { _context.Persons.Update(entity); await _context.SaveChangesAsync(ct); return entity; } public async Task DeleteAsync(Guid id, CancellationToken ct = default) { var person = await _context.Persons.FindAsync([id], ct); if (person == null) return false; person.IsDeleted = true; await _context.SaveChangesAsync(ct); return true; } public Task GetByNameAsync(Guid clubId, string personName, Guid? excludeGuid, CancellationToken ct = default) => _context.Persons.FirstOrDefaultAsync(p => p.ClubId == clubId && p.Name == personName && !p.IsDeleted && (excludeGuid == null || p.Id != excludeGuid), ct); } internal class TestClubRepository : IClubRepository { private readonly AppDbContext _context; public TestClubRepository(AppDbContext context) => _context = context; public Task> GetAllAsync(CancellationToken ct = default) => _context.Clubs.Where(c => !c.IsDeleted).ToListAsync(ct); public Task GetByIdAsync(Guid id, CancellationToken ct = default) => _context.Clubs.FirstOrDefaultAsync(c => c.Id == id && !c.IsDeleted, ct); public async Task AddAsync(Club entity, CancellationToken ct = default) { await _context.Clubs.AddAsync(entity, ct); await _context.SaveChangesAsync(ct); return entity; } public async Task UpdateAsync(Club entity, CancellationToken ct = default) { _context.Clubs.Update(entity); await _context.SaveChangesAsync(ct); return entity; } public async Task DeleteAsync(Guid id, CancellationToken ct = default) { var club = await _context.Clubs.FindAsync([id], ct); if (club == null) return false; club.IsDeleted = true; await _context.SaveChangesAsync(ct); return true; } public Task GetByNameAsync(string clubName, Guid? excludeGuid, CancellationToken ct = default) => _context.Clubs.FirstOrDefaultAsync(c => c.Name == clubName && !c.IsDeleted && (excludeGuid == null || c.Id != excludeGuid), ct); public Task GetByLoginNameAsync(string clubLoginName, Guid? excludeGuid, CancellationToken ct = default) => _context.Clubs.FirstOrDefaultAsync(c => c.LoginName == clubLoginName && !c.IsDeleted && (excludeGuid == null || c.Id != excludeGuid), ct); } internal class TestPersonExpenseRepository : IPersonExpenseRepository { private readonly AppDbContext _context; public TestPersonExpenseRepository(AppDbContext context) => _context = context; public Task> GetByClubIdAsync(Guid clubId, CancellationToken ct = default) => _context.PersonExpenses.Where(pe => pe.ClubId == clubId && !pe.IsDeleted).ToListAsync(ct); public Task GetByIdAsync(Guid id, CancellationToken ct = default) => _context.PersonExpenses.FirstOrDefaultAsync(pe => pe.Id == id && !pe.IsDeleted, ct); public Task> GetByDayIdAsync(Guid dayId, CancellationToken ct = default) => _context.PersonExpenses.Where(pe => pe.DayId == dayId && !pe.IsDeleted).ToListAsync(ct); public Task> GetByPersonIdAsync(Guid personId, CancellationToken ct = default) => _context.PersonExpenses.Where(pe => pe.PersonId == personId && !pe.IsDeleted).ToListAsync(ct); public async Task AddAsync(PersonExpense entity, CancellationToken ct = default) { await _context.PersonExpenses.AddAsync(entity, ct); await _context.SaveChangesAsync(ct); return entity; } public async Task UpdateAsync(PersonExpense entity, CancellationToken ct = default) { _context.PersonExpenses.Update(entity); await _context.SaveChangesAsync(ct); return entity; } public async Task DeleteAsync(Guid id, CancellationToken ct = default) { var pe = await _context.PersonExpenses.FindAsync([id], ct); if (pe == null) return false; pe.IsDeleted = true; await _context.SaveChangesAsync(ct); return true; } public async Task AddRangeAsync(IEnumerable entities, CancellationToken ct = default) { await _context.PersonExpenses.AddRangeAsync(entities, ct); await _context.SaveChangesAsync(ct); } } #endregion