KoogleApp/test/Koogle.Tests/Integration/CashBookIntegrationTests.cs

576 lines
21 KiB
C#

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;
/// <summary>
/// Integration tests for CashBook functionality with in-memory database.
/// </summary>
public class CashBookIntegrationTests : IAsyncLifetime
{
private AppDbContext _context = null!;
private CashBookService _cashBookService = null!;
private BookingCategoryService _categoryService = null!;
private Mock<ICurrentClubContext> _clubContextMock = null!;
private Guid _clubId;
private Club _club = null!;
public async Task InitializeAsync()
{
_clubId = Guid.NewGuid();
_clubContextMock = new Mock<ICurrentClubContext>();
_clubContextMock.Setup(c => c.ClubId).Returns(_clubId);
var options = new DbContextOptionsBuilder<AppDbContext>()
.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<IDbContextFactory<AppDbContext>>();
factoryMock.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
.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<IMapper>();
mapperMock.Setup(m => m.Map<BookingCategoryDto>(It.IsAny<BookingCategory>()))
.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<List<BookingCategoryDto>>(It.IsAny<List<BookingCategory>>()))
.Returns((List<BookingCategory> 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<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());
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<InvalidOperationException>()
.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<InvalidOperationException>();
}
#endregion
}
#region Simple Test Repositories
internal class TestBookingCategoryRepository : IBookingCategoryRepository
{
private readonly AppDbContext _context;
public TestBookingCategoryRepository(AppDbContext context) => _context = context;
public async Task<List<BookingCategory>> 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<BookingCategory?> GetByIdAsync(Guid id, CancellationToken ct = default)
=> await _context.BookingCategories.FirstOrDefaultAsync(c => c.Id == id && !c.IsDeleted, ct);
public async Task<BookingCategory?> 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<BookingCategory> AddAsync(BookingCategory entity, CancellationToken ct = default)
{
await _context.BookingCategories.AddAsync(entity, ct);
await _context.SaveChangesAsync(ct);
return entity;
}
public async Task<BookingCategory> 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<List<CashBookEntry>> 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<CashBookEntry?> 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<decimal> 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<CashBookEntry> AddAsync(CashBookEntry entity, CancellationToken ct = default)
{
await _context.CashBookEntries.AddAsync(entity, ct);
await _context.SaveChangesAsync(ct);
return entity;
}
public async Task<List<CashBookEntry>> GetByDayIdAsync(Guid dayId, CancellationToken ct = default)
=> await _context.CashBookEntries.Where(e => e.DayId == dayId && !e.IsDeleted).ToListAsync(ct);
public async Task<bool> 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<List<Person>> GetByClubIdAsync(Guid clubId, CancellationToken ct = default)
=> await _context.Persons.Where(p => p.ClubId == clubId && !p.IsDeleted).ToListAsync(ct);
public Task<Person?> GetByIdAsync(Guid id, CancellationToken ct = default)
=> _context.Persons.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted, ct);
public async Task<Person> AddAsync(Person entity, CancellationToken ct = default)
{
await _context.Persons.AddAsync(entity, ct);
await _context.SaveChangesAsync(ct);
return entity;
}
public async Task<Person> UpdateAsync(Person entity, CancellationToken ct = default)
{
_context.Persons.Update(entity);
await _context.SaveChangesAsync(ct);
return entity;
}
public async Task<bool> 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<Person?> 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<List<Club>> GetAllAsync(CancellationToken ct = default)
=> _context.Clubs.Where(c => !c.IsDeleted).ToListAsync(ct);
public Task<Club?> GetByIdAsync(Guid id, CancellationToken ct = default)
=> _context.Clubs.FirstOrDefaultAsync(c => c.Id == id && !c.IsDeleted, ct);
public async Task<Club> AddAsync(Club entity, CancellationToken ct = default)
{
await _context.Clubs.AddAsync(entity, ct);
await _context.SaveChangesAsync(ct);
return entity;
}
public async Task<Club> UpdateAsync(Club entity, CancellationToken ct = default)
{
_context.Clubs.Update(entity);
await _context.SaveChangesAsync(ct);
return entity;
}
public async Task<bool> 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<Club?> 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<Club?> 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<List<PersonExpense>> GetByClubIdAsync(Guid clubId, CancellationToken ct = default)
=> _context.PersonExpenses.Where(pe => pe.ClubId == clubId && !pe.IsDeleted).ToListAsync(ct);
public Task<PersonExpense?> GetByIdAsync(Guid id, CancellationToken ct = default)
=> _context.PersonExpenses.FirstOrDefaultAsync(pe => pe.Id == id && !pe.IsDeleted, ct);
public Task<List<PersonExpense>> GetByDayIdAsync(Guid dayId, CancellationToken ct = default)
=> _context.PersonExpenses.Where(pe => pe.DayId == dayId && !pe.IsDeleted).ToListAsync(ct);
public Task<List<PersonExpense>> GetByPersonIdAsync(Guid personId, CancellationToken ct = default)
=> _context.PersonExpenses.Where(pe => pe.PersonId == personId && !pe.IsDeleted).ToListAsync(ct);
public async Task<PersonExpense> AddAsync(PersonExpense entity, CancellationToken ct = default)
{
await _context.PersonExpenses.AddAsync(entity, ct);
await _context.SaveChangesAsync(ct);
return entity;
}
public async Task<PersonExpense> UpdateAsync(PersonExpense entity, CancellationToken ct = default)
{
_context.PersonExpenses.Update(entity);
await _context.SaveChangesAsync(ct);
return entity;
}
public async Task<bool> 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<PersonExpense> entities, CancellationToken ct = default)
{
await _context.PersonExpenses.AddRangeAsync(entities, ct);
await _context.SaveChangesAsync(ct);
}
}
#endregion