Files
cannamanage/docs/sprint-8/cannamanage-sprint8-testplan.md
Patrick Plate b22702317a feat(sprint8): Phase 3 — Mitgliederversammlung (assemblies, voting, protocol PDF)
Backend:
- V19 migration: assemblies, assembly_agenda_items, assembly_attendees, assembly_votes, assembly_vote_records
- Enums: AssemblyType, AssemblyStatus, AgendaItemType, VoteType, VoteDecision, VoteResult
- Entities: Assembly, AssemblyAgendaItem, AssemblyAttendee, AssemblyVote, AssemblyVoteRecord
- Repositories: Assembly, AgendaItem, Attendee, Vote, VoteRecord
- AssemblyService: full lifecycle (create, invite, start, attend, vote, quorum, complete)
- AssemblyProtocolService: OpenPDF protocol generation (§147 AO compliant)
- AssemblyController: admin + portal endpoints
- Extended: AuditEventType, NotificationType, StaffPermission

Frontend:
- Assembly service with full API client and TypeScript types
- Admin assemblies list page with create dialog (agenda builder)
- Admin assembly detail page (quorum, agenda, votes, attendees)
- Navigation: Versammlungen with Gavel icon (after Finanzen)

Legal basis: §32-§40 BGB (Mitgliederversammlung), §147 AO (retention)
2026-06-15 08:39:10 +02:00

42 KiB
Raw Permalink Blame History

Sprint 8 Test Plan — Vereinsverwaltung Complete

Date: 2026-06-13 Author: Patrick Plate / Lumen (Architect) Status: Draft v1 Based on: cannamanage-sprint8-plan.md


Test Overview

ID Description Type Class/File Status
T-01 Fee schedule creation persists correctly Unit FinanceServiceTest
T-02 Fee schedule update marks timestamp Unit FinanceServiceTest
T-03 Fee schedule deactivation hides from listing Unit FinanceServiceTest
T-04 Member fee assignment links correctly Unit FinanceServiceTest
T-05 Fee assignment effective_until ends previous assignment Unit FinanceServiceTest
T-06 Payment recording generates sequential receipt number Unit FinanceServiceTest
T-07 Payment recording calculates correct receipt format CM-YYYY-NNNNNN Unit FinanceServiceTest
T-08 Payment voiding sets voided flag and reason (no delete) Unit FinanceServiceTest
T-09 Voided payment excluded from balance calculation Unit FinanceServiceTest
T-10 Expense recording persists with category Unit FinanceServiceTest
T-11 Expense voiding sets voided flag (no delete) Unit FinanceServiceTest
T-12 Balance calculation: no fee assigned returns zero Unit FinanceServiceTest
T-13 Balance calculation: monthly fee accumulates correctly Unit FinanceServiceTest
T-14 Balance calculation: partial payment shows outstanding Unit FinanceServiceTest
T-15 Balance calculation: overpayment shows credit Unit FinanceServiceTest
T-16 Balance calculation: quarterly fee interval correct Unit FinanceServiceTest
T-17 Balance calculation: one-time fee no accumulation Unit FinanceServiceTest
T-18 Kassenbuch running balance correct across mixed transactions Unit KassenbuchServiceTest
T-19 Kassenbuch chronological ordering (payments + expenses merged) Unit KassenbuchServiceTest
T-20 Kassenbuch opening balance calculated from prior period Unit KassenbuchServiceTest
T-21 Kassenbuch CSV export produces valid format Unit KassenbuchServiceTest
T-22 Kassenbuch excludes voided entries Unit KassenbuchServiceTest
T-23 Receipt PDF generation produces valid PDF bytes Unit ReceiptPdfServiceTest
T-24 Receipt PDF contains member name and amount Unit ReceiptPdfServiceTest
T-25 Receipt PDF formats Euro correctly (comma decimal) Unit ReceiptPdfServiceTest
T-26 Annual report totals match sum of individual transactions Unit FinancialReportServiceTest
T-27 Annual report expense breakdown by category correct Unit FinancialReportServiceTest
T-28 Annual report PDF generation succeeds Unit FinancialReportServiceTest
T-29 Payment reminder detects overdue member (30+ days) Unit PaymentReminderSchedulerTest
T-30 Payment reminder escalates: FIRST → SECOND → FINAL Unit PaymentReminderSchedulerTest
T-31 Payment reminder skips Starter/Trial tier clubs Unit PaymentReminderSchedulerTest
T-32 Payment reminder sends via NotificationDispatchService Unit PaymentReminderSchedulerTest
T-33 Assembly creation persists with all fields Unit AssemblyServiceTest
T-34 Assembly status transition DRAFT → INVITED on sendInvitations Unit AssemblyServiceTest
T-35 Assembly invitation sends to all active members Unit AssemblyServiceTest
T-36 Assembly invitation creates ClubEvent with type GENERAL_ASSEMBLY Unit AssemblyServiceTest
T-37 Assembly notice period warning logged if too short Unit AssemblyServiceTest
T-38 Assembly status transition INVITED → IN_PROGRESS on start Unit AssemblyServiceTest
T-39 Assembly start rejected if status is not INVITED Unit AssemblyServiceTest
T-40 Assembly member check-in records attendance Unit AssemblyServiceTest
T-41 Assembly duplicate check-in rejected (unique constraint) Unit AssemblyServiceTest
T-42 Assembly quorum calculation correct (attendees / total active) Unit AssemblyServiceTest
T-43 Assembly quorum reached when percentage >= threshold Unit AssemblyServiceTest
T-44 Assembly quorum not reached when percentage < threshold Unit AssemblyServiceTest
T-45 Assembly vote creation linked to agenda item Unit AssemblyServiceTest
T-46 Assembly vote result: SIMPLE_MAJORITY — yes > no passes Unit AssemblyServiceTest
T-47 Assembly vote result: TWO_THIRDS — yes >= 2/3 passes Unit AssemblyServiceTest
T-48 Assembly vote result: TWO_THIRDS — yes < 2/3 fails Unit AssemblyServiceTest
T-49 Assembly election records member + position + vote count Unit AssemblyServiceTest
T-50 Assembly election triggers BoardService appointment Unit AssemblyServiceTest
T-51 Assembly completion transitions IN_PROGRESS → COMPLETED Unit AssemblyServiceTest
T-52 Assembly cancellation transitions any status → CANCELLED Unit AssemblyServiceTest
T-53 Assembly agenda items ordered by sort_order Unit AssemblyServiceTest
T-54 Assembly agenda reorder updates sort_order correctly Unit AssemblyServiceTest
T-55 Protocol PDF contains attendance list Unit ProtocolPdfServiceTest
T-56 Protocol PDF contains vote results with counts Unit ProtocolPdfServiceTest
T-57 Protocol PDF contains election results Unit ProtocolPdfServiceTest
T-58 Protocol PDF auto-archived in documents on assembly complete Unit AssemblyServiceTest
T-59 DocumentStorageService stores file in correct path Unit DocumentStorageServiceTest
T-60 DocumentStorageService rejects file > 20MB Unit DocumentStorageServiceTest
T-61 DocumentStorageService rejects disallowed content type Unit DocumentStorageServiceTest
T-62 DocumentStorageService retrieves stored file correctly Unit DocumentStorageServiceTest
T-63 DocumentStorageService deletes file from filesystem Unit DocumentStorageServiceTest
T-64 DocumentService creates metadata record on upload Unit DocumentServiceTest
T-65 DocumentService version increment links to previous version Unit DocumentServiceTest
T-66 DocumentService access level filtering: board-only hidden from members Unit DocumentServiceTest
T-67 DocumentService system documents tagged with correct source Unit DocumentServiceTest
T-68 Board position creation persists correctly Unit BoardServiceTest
T-69 Board member appointment creates active record Unit BoardServiceTest
T-70 Board member end term sets is_active=false and ended_reason Unit BoardServiceTest
T-71 Board current composition returns only active members Unit BoardServiceTest
T-72 Board history returns all members (active + ended) Unit BoardServiceTest
T-73 Board election result ends previous holder and appoints new Unit BoardServiceTest
T-74 Board term expiry scheduler detects terms ending within 30 days Unit BoardTermSchedulerTest
T-75 Board term expiry scheduler sends notification to admins Unit BoardTermSchedulerTest
T-76 Tier enforcement: Starter limited to 2 fee schedules Unit PlanTierServiceTest
T-77 Tier enforcement: Starter limited to 50 expenses/year Unit PlanTierServiceTest
T-78 Tier enforcement: Starter 100MB document storage limit Unit PlanTierServiceTest
T-79 Tier enforcement: Pro allows unlimited fee schedules Unit PlanTierServiceTest
T-80 Finance API requires MANAGE_FINANCES permission Integration FinanceControllerIntegrationTest
T-81 Finance API record payment returns 201 with receipt number Integration FinanceControllerIntegrationTest
T-82 Finance API void payment returns 200 Integration FinanceControllerIntegrationTest
T-83 Finance API balances endpoint returns all members Integration FinanceControllerIntegrationTest
T-84 Finance API kassenbuch date range filter works Integration FinanceControllerIntegrationTest
T-85 Finance API CSV export returns text/csv content type Integration FinanceControllerIntegrationTest
T-86 Finance API annual report returns correct totals Integration FinanceControllerIntegrationTest
T-87 Finance API receipt PDF download returns application/pdf Integration FinanceControllerIntegrationTest
T-88 Portal finance API returns only current member payments Integration PortalFinanceIntegrationTest
T-89 Portal finance API cannot access other member data Integration PortalFinanceIntegrationTest
T-90 Portal finance API receipt download only for own payments Integration PortalFinanceIntegrationTest
T-91 Assembly API requires MANAGE_ASSEMBLY permission Integration AssemblyControllerIntegrationTest
T-92 Assembly API create returns 201 with ID Integration AssemblyControllerIntegrationTest
T-93 Assembly API invite transitions status and sends notifications Integration AssemblyControllerIntegrationTest
T-94 Assembly API check-in returns updated attendance count Integration AssemblyControllerIntegrationTest
T-95 Assembly API quorum endpoint returns correct calculation Integration AssemblyControllerIntegrationTest
T-96 Assembly API vote recording returns result calculation Integration AssemblyControllerIntegrationTest
T-97 Assembly API protocol PDF returns valid PDF Integration AssemblyControllerIntegrationTest
T-98 Assembly API complete transitions status Integration AssemblyControllerIntegrationTest
T-99 Portal assembly API lists upcoming assemblies for member Integration AssemblyControllerIntegrationTest
T-100 Document API upload returns 201 with metadata Integration DocumentControllerIntegrationTest
T-101 Document API upload rejects oversized file (413) Integration DocumentControllerIntegrationTest
T-102 Document API upload rejects invalid content type (415) Integration DocumentControllerIntegrationTest
T-103 Document API download returns correct file Integration DocumentControllerIntegrationTest
T-104 Document API requires MANAGE_DOCUMENTS permission Integration DocumentControllerIntegrationTest
T-105 Portal document API only returns ALL_MEMBERS access level Integration DocumentControllerIntegrationTest
T-106 Portal document API blocks BOARD_ONLY documents Integration DocumentControllerIntegrationTest
T-107 Board API returns current composition Integration BoardControllerIntegrationTest
T-108 Board API appoint requires Admin role Integration BoardControllerIntegrationTest
T-109 Board API positions visible to any authenticated user Integration BoardControllerIntegrationTest
T-110 Tenant isolation: Club A finance data invisible to Club B Integration TenantIsolationFinanceTest
T-111 Tenant isolation: Club A documents invisible to Club B Integration TenantIsolationFinanceTest
T-112 Tenant isolation: Club A assembly invisible to Club B Integration TenantIsolationFinanceTest
T-113 E2E: Admin creates fee schedule, assigns to member E2E finance.spec.ts
T-114 E2E: Admin records payment, receipt number appears E2E finance.spec.ts
T-115 E2E: Admin downloads receipt PDF E2E finance.spec.ts
T-116 E2E: Admin records expense with category E2E finance.spec.ts
T-117 E2E: Kassenbuch shows running balance E2E finance.spec.ts
T-118 E2E: Balance overview shows overdue members E2E finance.spec.ts
T-119 E2E: Portal member sees payment history and balance E2E portal-finance.spec.ts
T-120 E2E: Portal member downloads own receipt E2E portal-finance.spec.ts
T-121 E2E: Admin creates MV with agenda items E2E assemblies.spec.ts
T-122 E2E: Admin sends MV invitations E2E assemblies.spec.ts
T-123 E2E: Admin checks in members at MV E2E assemblies.spec.ts
T-124 E2E: Quorum indicator updates live E2E assemblies.spec.ts
T-125 E2E: Admin records vote result E2E assemblies.spec.ts
T-126 E2E: Admin completes MV and generates protocol E2E assemblies.spec.ts
T-127 E2E: Portal member sees upcoming MV with agenda E2E assemblies.spec.ts
T-128 E2E: Admin uploads document with category E2E documents.spec.ts
T-129 E2E: Admin downloads uploaded document E2E documents.spec.ts
T-130 E2E: Portal member sees public documents only E2E documents.spec.ts
T-131 E2E: Admin assigns board position to member E2E board.spec.ts
T-132 E2E: Portal member sees current board E2E board.spec.ts

Status legend: Pending | Passed | Failed | ⏭️ Skipped


Unit Tests

FinanceServiceTest

File: cannamanage-service/src/test/java/de/cannamanage/service/FinanceServiceTest.java

T-01: Fee schedule creation persists correctly

@Test
void testCreateFeeSchedule_persistsAllFields() {
    FeeSchedule result = financeService.createFeeSchedule(
            tenantId, "Regulär", new BigDecimal("30.00"), FeeInterval.MONTHLY, "Standard-Mitgliedsbeitrag");

    assertNotNull(result.getId());
    assertEquals("Regulär", result.getName());
    assertEquals(new BigDecimal("30.00"), result.getAmount());
    assertEquals(FeeInterval.MONTHLY, result.getInterval());
    assertTrue(result.isActive());
    verify(feeScheduleRepo).save(any(FeeSchedule.class));
}

T-06: Payment recording generates sequential receipt number

@Test
void testRecordPayment_generatesSequentialReceiptNumber() {
    // Setup: last receipt was CM-2026-000003
    when(paymentRepo.findTopByTenantIdAndReceiptNumberStartsWithOrderByReceiptNumberDesc(
            tenantId, "CM-2026-"))
            .thenReturn(Optional.of(paymentWithReceipt("CM-2026-000003")));

    Payment result = financeService.recordPayment(tenantId, memberId,
            new BigDecimal("30.00"), LocalDate.now(), PaymentMethod.BANK_TRANSFER,
            "Beitrag Juli", null, null, null, staffId);

    assertEquals("CM-2026-000004", result.getReceiptNumber());
}

T-08: Payment voiding sets flag and reason (no delete)

@Test
void testVoidPayment_setsVoidedFlagAndReason_neverDeletes() {
    Payment existing = createTestPayment();
    when(paymentRepo.findById(existing.getId())).thenReturn(Optional.of(existing));

    financeService.voidPayment(existing.getId(), "Fehlerhafte Buchung", staffId);

    assertTrue(existing.isVoided());
    assertEquals("Fehlerhafte Buchung", existing.getVoidedReason());
    assertNotNull(existing.getVoidedAt());
    verify(paymentRepo).save(existing);
    verify(paymentRepo, never()).delete(any());
    verify(auditService).log(eq(AuditEventType.PAYMENT_VOIDED), any(), any(), any(), any());
}

T-13: Balance calculation — monthly fee accumulates correctly

@Test
void testCalculateMemberBalance_monthlyFeeAccumulates() {
    // Member on €30/month since 2026-01-01, today is 2026-07-15 → 7 months due = €210
    MemberFeeAssignment assignment = createAssignment(memberId, scheduleId, LocalDate.of(2026, 1, 1));
    FeeSchedule schedule = createSchedule("Regulär", new BigDecimal("30.00"), FeeInterval.MONTHLY);
    when(feeAssignmentRepo.findActiveFeeAssignment(memberId)).thenReturn(Optional.of(assignment));
    when(feeScheduleRepo.findById(scheduleId)).thenReturn(Optional.of(schedule));
    when(paymentRepo.sumPaymentsForMember(memberId)).thenReturn(Optional.of(new BigDecimal("180.00"))); // 6 months paid

    MemberBalance balance = financeService.calculateMemberBalance(memberId);

    assertEquals(new BigDecimal("210.00"), balance.getTotalDue());
    assertEquals(new BigDecimal("180.00"), balance.getTotalPaid());
    assertEquals(new BigDecimal("30.00"), balance.getOutstanding());
}

T-15: Balance calculation — overpayment shows credit

@Test
void testCalculateMemberBalance_overpaymentShowsCredit() {
    // 3 months due (€90), but paid €120 → credit of -€30 (negative outstanding)
    MemberFeeAssignment assignment = createAssignment(memberId, scheduleId, LocalDate.of(2026, 5, 1));
    FeeSchedule schedule = createSchedule("Regulär", new BigDecimal("30.00"), FeeInterval.MONTHLY);
    when(feeAssignmentRepo.findActiveFeeAssignment(memberId)).thenReturn(Optional.of(assignment));
    when(feeScheduleRepo.findById(scheduleId)).thenReturn(Optional.of(schedule));
    when(paymentRepo.sumPaymentsForMember(memberId)).thenReturn(Optional.of(new BigDecimal("120.00")));

    MemberBalance balance = financeService.calculateMemberBalance(memberId);

    assertEquals(new BigDecimal("-30.00"), balance.getOutstanding()); // credit
}

KassenbuchServiceTest

File: cannamanage-service/src/test/java/de/cannamanage/service/KassenbuchServiceTest.java

T-18: Kassenbuch running balance correct across mixed transactions

@Test
void testGenerateKassenbuch_runningBalanceCorrect() {
    // Opening balance: €500
    // Day 1: +€30 (payment) → €530
    // Day 2: -€85 (expense) → €445
    // Day 3: +€30 (payment) → €475
    List<Payment> payments = List.of(
        createPayment(LocalDate.of(2026, 7, 1), new BigDecimal("30.00")),
        createPayment(LocalDate.of(2026, 7, 3), new BigDecimal("30.00"))
    );
    List<Expense> expenses = List.of(
        createExpense(LocalDate.of(2026, 7, 2), new BigDecimal("85.00"))
    );
    when(paymentRepo.findByTenantAndDateRange(any(), any(), any())).thenReturn(payments);
    when(expenseRepo.findByTenantAndDateRange(any(), any(), any())).thenReturn(expenses);
    mockOpeningBalance(new BigDecimal("500.00"));

    KassenbuchView result = kassenbuchService.generateKassenbuch(tenantId,
            LocalDate.of(2026, 7, 1), LocalDate.of(2026, 7, 31));

    assertEquals(3, result.getEntries().size());
    assertEquals(new BigDecimal("530.00"), result.getEntries().get(0).getRunningBalance());
    assertEquals(new BigDecimal("445.00"), result.getEntries().get(1).getRunningBalance());
    assertEquals(new BigDecimal("475.00"), result.getEntries().get(2).getRunningBalance());
}

T-21: Kassenbuch CSV export produces valid format

@Test
void testExportCsv_validGermanFormat() {
    // Setup kassenbuch with entries
    String csv = kassenbuchService.exportCsv(tenantId, LocalDate.of(2026, 1, 1), LocalDate.of(2026, 12, 31));

    assertTrue(csv.startsWith("Datum;Typ;Beschreibung;Einnahme;Ausgabe;Saldo\n"));
    assertTrue(csv.contains("01.07.2026;Einnahme;"));
    assertTrue(csv.contains("30,00;")); // German decimal format
}

ReceiptPdfServiceTest

File: cannamanage-service/src/test/java/de/cannamanage/service/ReceiptPdfServiceTest.java

T-23: Receipt PDF generation produces valid PDF bytes

@Test
void testGenerateReceipt_producesValidPdf() {
    Payment payment = createTestPayment();
    Member member = createTestMember();
    Club club = createTestClub();

    byte[] pdf = receiptPdfService.generateReceipt(payment, member, club);

    assertNotNull(pdf);
    assertTrue(pdf.length > 0);
    // PDF magic bytes
    assertEquals('%', (char) pdf[0]);
    assertEquals('P', (char) pdf[1]);
    assertEquals('D', (char) pdf[2]);
    assertEquals('F', (char) pdf[3]);
}

T-25: Receipt PDF formats Euro correctly

@Test
void testGenerateReceipt_euroFormatting() {
    Payment payment = createTestPayment(new BigDecimal("1234.50"));
    byte[] pdf = receiptPdfService.generateReceipt(payment, member, club);
    String pdfText = extractTextFromPdf(pdf);

    assertTrue(pdfText.contains("1.234,50")); // German locale formatting
}

AssemblyServiceTest

File: cannamanage-service/src/test/java/de/cannamanage/service/AssemblyServiceTest.java

T-34: Assembly status transition DRAFT → INVITED on sendInvitations

@Test
void testSendInvitations_transitionsDraftToInvited() {
    GeneralAssembly assembly = createDraftAssembly();
    when(assemblyRepo.findById(assembly.getId())).thenReturn(Optional.of(assembly));
    when(memberRepo.countByTenantIdAndStatus(tenantId, MemberStatus.ACTIVE)).thenReturn(50L);

    assemblyService.sendInvitations(assembly.getId());

    assertEquals(AssemblyStatus.INVITED, assembly.getStatus());
    assertNotNull(assembly.getInvitationSentAt());
    verify(notificationDispatchService).broadcast(eq(tenantId), eq(NotificationType.ASSEMBLY_INVITATION), any(), any());
    verify(eventService).createEvent(any(), any(), any(), eq(EventType.GENERAL_ASSEMBLY), any(), any(), any(), any(), any());
}

T-39: Assembly start rejected if status is not INVITED

@Test
void testStartAssembly_rejectedIfNotInvited() {
    GeneralAssembly assembly = createDraftAssembly(); // still DRAFT
    when(assemblyRepo.findById(assembly.getId())).thenReturn(Optional.of(assembly));

    assertThrows(IllegalStateException.class,
            () -> assemblyService.startAssembly(assembly.getId()));
}

T-42: Assembly quorum calculation correct

@Test
void testGetQuorumStatus_calculatesCorrectly() {
    GeneralAssembly assembly = createAssemblyWithQuorum(50); // 50% required
    when(assemblyRepo.findById(assembly.getId())).thenReturn(Optional.of(assembly));
    when(attendanceRepo.countByAssemblyId(assembly.getId())).thenReturn(30L);
    when(memberRepo.countByTenantIdAndStatus(tenantId, MemberStatus.ACTIVE)).thenReturn(72L);

    QuorumStatus status = assemblyService.getQuorumStatus(assembly.getId());

    assertEquals(30L, status.getAttendeesPresent());
    assertEquals(72L, status.getTotalActiveMembers());
    assertEquals(41, status.getCurrentPercentage()); // 30/72 = 41%
    assertEquals(50, status.getRequiredPercentage());
    assertFalse(status.isQuorumReached()); // 41 < 50
}

T-46: Assembly vote SIMPLE_MAJORITY — yes > no passes

@Test
void testRecordVoteResult_simpleMajority_passes() {
    AssemblyVote vote = createVote(VoteType.SIMPLE_MAJORITY, 50);
    when(voteRepo.findById(vote.getId())).thenReturn(Optional.of(vote));

    assemblyService.recordVoteResult(vote.getId(), 25, 10, 5); // yes=25, no=10, abstain=5

    assertEquals(VoteResult.ACCEPTED, vote.getResult());
    assertEquals(25, vote.getYesCount());
    assertEquals(10, vote.getNoCount());
    assertEquals(5, vote.getAbstainCount());
}

T-48: Assembly vote TWO_THIRDS — yes < 2/3 fails

@Test
void testRecordVoteResult_twoThirds_fails() {
    AssemblyVote vote = createVote(VoteType.TWO_THIRDS, 67);
    when(voteRepo.findById(vote.getId())).thenReturn(Optional.of(vote));

    assemblyService.recordVoteResult(vote.getId(), 20, 15, 5); // 20/(20+15) = 57% < 67%

    assertEquals(VoteResult.REJECTED, vote.getResult());
}

DocumentStorageServiceTest

File: cannamanage-service/src/test/java/de/cannamanage/service/DocumentStorageServiceTest.java

T-60: DocumentStorageService rejects file > 20MB

@Test
void testStore_rejectsOversizedFile() {
    MultipartFile bigFile = createMockFile("big.pdf", "application/pdf", 21 * 1024 * 1024); // 21MB

    assertThrows(FileTooLargeException.class,
            () -> storageService.store(tenantId, documentId, bigFile));
}

T-61: DocumentStorageService rejects disallowed content type

@Test
void testStore_rejectsDisallowedContentType() {
    MultipartFile exeFile = createMockFile("virus.exe", "application/x-msdownload", 1024);

    assertThrows(UnsupportedFileTypeException.class,
            () -> storageService.store(tenantId, documentId, exeFile));
}

BoardServiceTest

File: cannamanage-service/src/test/java/de/cannamanage/service/BoardServiceTest.java

T-69: Board member appointment creates active record

@Test
void testAppointMember_createsActiveRecord() {
    BoardPosition position = createPosition("Kassenwart", true);
    when(positionRepo.findById(position.getId())).thenReturn(Optional.of(position));

    BoardMember result = boardService.appointMember(position.getId(), memberId,
            LocalDate.of(2026, 7, 1), LocalDate.of(2028, 7, 1), assemblyId);

    assertTrue(result.isActive());
    assertEquals(memberId, result.getMemberId());
    assertEquals(position.getId(), result.getPositionId());
    assertEquals(LocalDate.of(2026, 7, 1), result.getTermStart());
    verify(auditService).log(eq(AuditEventType.BOARD_MEMBER_APPOINTED), any(), any(), any(), any());
}

T-73: Board election result ends previous holder and appoints new

@Test
void testApplyElectionResult_endsPreviousAndApointsNew() {
    BoardMember previousHolder = createActiveBoardMember(oldMemberId, positionId);
    when(boardMemberRepo.findByPositionIdAndIsActiveTrue(positionId))
            .thenReturn(Optional.of(previousHolder));

    AssemblyElection election = createElection(positionId, newMemberId, 38,
            LocalDate.of(2026, 7, 1), LocalDate.of(2028, 7, 1));

    boardService.applyElectionResult(election);

    assertFalse(previousHolder.isActive());
    assertEquals("VOTED_OUT", previousHolder.getEndedReason());
    verify(boardMemberRepo).save(argThat(bm -> bm.getMemberId().equals(newMemberId) && bm.isActive()));
}

PaymentReminderSchedulerTest

File: cannamanage-service/src/test/java/de/cannamanage/service/PaymentReminderSchedulerTest.java

T-29: Payment reminder detects overdue member

@Test
void testCheckOverduePayments_detectsOverdueMember() {
    when(clubRepo.findAllActive()).thenReturn(List.of(testClub));
    when(planTierService.getClubTier(testClub.getId())).thenReturn(PlanTier.PRO);
    MemberBalance overdue = createOverdueBalance(memberId, 45); // 45 days overdue
    when(financeService.getOverdueMembers(tenantId)).thenReturn(List.of(overdue));

    reminderScheduler.checkOverduePayments();

    verify(notificationDispatchService).sendToMember(eq(memberId),
            eq(NotificationType.PAYMENT_REMINDER), any(), any());
    verify(paymentReminderRepo).save(argThat(r -> r.getReminderType() == ReminderType.SECOND));
}

T-31: Payment reminder skips Starter/Trial tier clubs

@Test
void testCheckOverduePayments_skipsStarterTier() {
    when(clubRepo.findAllActive()).thenReturn(List.of(testClub));
    when(planTierService.getClubTier(testClub.getId())).thenReturn(PlanTier.STARTER);

    reminderScheduler.checkOverduePayments();

    verify(financeService, never()).getOverdueMembers(any());
    verify(notificationDispatchService, never()).sendToMember(any(), any(), any(), any());
}

BoardTermSchedulerTest

File: cannamanage-service/src/test/java/de/cannamanage/service/BoardTermSchedulerTest.java

T-74: Board term expiry scheduler detects terms ending within 30 days

@Test
void testCheckExpiringTerms_detectsExpiringTerm() {
    BoardMember expiring = createBoardMember(memberId, positionId, LocalDate.now().plusDays(15));
    when(boardMemberRepo.findByTermEndBeforeAndIsActiveTrue(any())).thenReturn(List.of(expiring));

    boardTermScheduler.checkExpiringTerms();

    verify(notificationDispatchService).sendToAdmins(eq(tenantId),
            eq(NotificationType.BOARD_TERM_EXPIRING), any(), any());
}

Integration Tests

FinanceControllerIntegrationTest

File: cannamanage-api/src/test/java/de/cannamanage/api/FinanceControllerIntegrationTest.java

T-80: Finance API requires MANAGE_FINANCES permission

@Test
@WithMockStaff(permissions = {}) // no permissions
void testFinanceEndpoint_requiresPermission_returns403() throws Exception {
    mockMvc.perform(get("/api/finance/payments"))
            .andExpect(status().isForbidden());
}

@Test
@WithMockStaff(permissions = {"MANAGE_FINANCES"})
void testFinanceEndpoint_withPermission_returns200() throws Exception {
    mockMvc.perform(get("/api/finance/payments"))
            .andExpect(status().isOk());
}

T-81: Finance API record payment returns 201

@Test
@WithMockStaff(permissions = {"MANAGE_FINANCES"})
void testRecordPayment_returns201WithReceiptNumber() throws Exception {
    String json = """
        {
            "memberId": "%s",
            "amount": 30.00,
            "paymentDate": "2026-07-01",
            "paymentMethod": "BANK_TRANSFER",
            "reference": "Beitrag Juli 2026"
        }
        """.formatted(testMemberId);

    mockMvc.perform(post("/api/finance/payments")
            .contentType(MediaType.APPLICATION_JSON)
            .content(json))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.receiptNumber").exists())
            .andExpect(jsonPath("$.receiptNumber").value(startsWith("CM-2026-")));
}

AssemblyControllerIntegrationTest

File: cannamanage-api/src/test/java/de/cannamanage/api/AssemblyControllerIntegrationTest.java

T-95: Assembly API quorum endpoint returns correct calculation

@Test
@WithMockStaff(permissions = {"MANAGE_ASSEMBLY"})
void testQuorumEndpoint_returnsCorrectCalculation() throws Exception {
    // Setup: assembly exists, 5 of 10 members checked in, quorum = 50%
    UUID assemblyId = createAssemblyAndCheckInMembers(5, 10);

    mockMvc.perform(get("/api/assemblies/{id}/quorum", assemblyId))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.attendeesPresent").value(5))
            .andExpect(jsonPath("$.totalActiveMembers").value(10))
            .andExpect(jsonPath("$.currentPercentage").value(50))
            .andExpect(jsonPath("$.quorumReached").value(true));
}

DocumentControllerIntegrationTest

File: cannamanage-api/src/test/java/de/cannamanage/api/DocumentControllerIntegrationTest.java

T-100: Document API upload returns 201

@Test
@WithMockStaff(permissions = {"MANAGE_DOCUMENTS"})
void testUploadDocument_returns201() throws Exception {
    MockMultipartFile file = new MockMultipartFile("file", "satzung.pdf",
            "application/pdf", "fake pdf content".getBytes());
    MockMultipartFile metadata = new MockMultipartFile("metadata", "",
            "application/json", """
            {"title": "Vereinssatzung 2026", "categoryId": "%s", "accessLevel": "ALL_MEMBERS"}
            """.formatted(testCategoryId).getBytes());

    mockMvc.perform(multipart("/api/documents").file(file).file(metadata))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.title").value("Vereinssatzung 2026"))
            .andExpect(jsonPath("$.filename").value("satzung.pdf"))
            .andExpect(jsonPath("$.accessLevel").value("ALL_MEMBERS"));
}

T-106: Portal document API blocks BOARD_ONLY documents

@Test
@WithMockPortalMember
void testPortalDocuments_blocksBoardOnly() throws Exception {
    // Upload a BOARD_ONLY document
    createDocument("Mietvertrag", DocumentAccessLevel.BOARD_ONLY);

    mockMvc.perform(get("/api/portal/documents"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$[?(@.title == 'Mietvertrag')]").doesNotExist());
}

TenantIsolationFinanceTest

File: cannamanage-api/src/test/java/de/cannamanage/api/TenantIsolationFinanceTest.java

T-110: Tenant isolation — Club A finance data invisible to Club B

@Test
void testTenantIsolation_financeData() throws Exception {
    // Create payment in Club A
    Payment payment = createPaymentForClub(clubATenantId, memberAId);

    // Login as Club B staff
    mockMvc.perform(get("/api/finance/payments")
            .with(asStaffOfClub(clubBTenantId)))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$", hasSize(0))); // Club A payment not visible
}

E2E Tests (Playwright)

finance.spec.ts

File: cannamanage-frontend/e2e/finance.spec.ts

T-113: Admin creates fee schedule, assigns to member

test('Admin creates fee schedule and assigns to member', async ({ page }) => {
    await loginAsAdmin(page);
    await page.goto('/settings/finance');

    // Create fee schedule
    await page.click('text=Neuer Beitragsplan');
    await page.fill('[name="name"]', 'Regulär');
    await page.fill('[name="amount"]', '30');
    await page.selectOption('[name="interval"]', 'MONTHLY');
    await page.click('text=Speichern');
    await expect(page.locator('text=Regulär')).toBeVisible();
    await expect(page.locator('text=30,00 €/Monat')).toBeVisible();

    // Assign to member
    await page.goto('/finance/balances');
    await page.click('text=Max Mustermann');
    await page.click('text=Beitragsplan zuweisen');
    await page.selectOption('[name="feeSchedule"]', 'Regulär');
    await page.click('text=Zuweisen');
    await expect(page.locator('text=Regulär (30,00 €/Monat)')).toBeVisible();
});

T-114: Admin records payment, receipt number appears

test('Admin records payment and receipt number is generated', async ({ page }) => {
    await loginAsAdmin(page);
    await page.goto('/finance/payments');

    await page.click('text=Zahlung erfassen');
    await page.selectOption('[name="member"]', 'Max Mustermann');
    await page.fill('[name="amount"]', '30');
    await page.fill('[name="paymentDate"]', '2026-07-01');
    await page.selectOption('[name="paymentMethod"]', 'BANK_TRANSFER');
    await page.fill('[name="reference"]', 'Beitrag Juli');
    await page.click('text=Erfassen');

    await expect(page.locator('text=CM-2026-')).toBeVisible();
    await expect(page.locator('text=30,00 €')).toBeVisible();
});

T-117: Kassenbuch shows running balance

test('Kassenbuch displays running balance correctly', async ({ page }) => {
    await loginAsAdmin(page);
    await page.goto('/finance');

    // Verify Kassenbuch tab shows entries with running balance column
    await page.click('text=Kassenbuch');
    const rows = page.locator('table tbody tr');
    await expect(rows).toHaveCount.greaterThan(0);

    // Each row should have a Saldo column
    const firstRowBalance = rows.first().locator('td:last-child');
    await expect(firstRowBalance).toContainText('€');
});

assemblies.spec.ts

File: cannamanage-frontend/e2e/assemblies.spec.ts

T-121: Admin creates MV with agenda items

test('Admin creates MV with agenda items', async ({ page }) => {
    await loginAsAdmin(page);
    await page.goto('/assemblies/new');

    await page.fill('[name="title"]', 'Ordentliche MV 2026');
    await page.fill('[name="scheduledAt"]', '2026-08-15T19:00');
    await page.fill('[name="location"]', 'Vereinsheim, Musterstraße 1');
    await page.fill('[name="quorumPercentage"]', '50');

    // Add agenda items
    await page.click('text=TOP hinzufügen');
    await page.fill('[name="agendaTitle"]', 'Begrüßung und Feststellung der Beschlussfähigkeit');
    await page.click('text=TOP hinzufügen');
    await page.fill('[name="agendaTitle"]', 'Bericht des Vorstands');
    await page.click('text=TOP hinzufügen');
    await page.fill('[name="agendaTitle"]', 'Bericht des Kassenwarts');
    await page.click('text=TOP hinzufügen');
    await page.fill('[name="agendaTitle"]', 'Entlastung des Vorstands');

    await page.click('text=MV erstellen');
    await expect(page.locator('text=Ordentliche MV 2026')).toBeVisible();
    await expect(page.locator('text=ENTWURF')).toBeVisible();
});

T-126: Admin completes MV and generates protocol

test('Full MV lifecycle: complete and generate protocol', async ({ page }) => {
    await loginAsAdmin(page);
    const assemblyId = await createMvViaApi();
    await sendInvitationsViaApi(assemblyId);
    await startMvViaApi(assemblyId);
    await checkInMembersViaApi(assemblyId, 5);

    await page.goto(`/assemblies/${assemblyId}/live`);

    // Record a vote
    await page.click('text=Abstimmungen');
    await page.click('text=Neue Abstimmung');
    await page.fill('[name="voteTitle"]', 'Entlastung des Vorstands');
    await page.fill('[name="yesCount"]', '4');
    await page.fill('[name="noCount"]', '0');
    await page.fill('[name="abstainCount"]', '1');
    await page.click('text=Ergebnis speichern');
    await expect(page.locator('text=ANGENOMMEN')).toBeVisible();

    // Complete MV
    await page.click('text=MV beenden');
    await page.click('text=Bestätigen');
    await expect(page.locator('text=ABGESCHLOSSEN')).toBeVisible();

    // Generate and verify protocol
    await page.click('text=Protokoll generieren');
    const download = await page.waitForEvent('download');
    expect(download.suggestedFilename()).toContain('protokoll');
    expect(download.suggestedFilename()).toEndWith('.pdf');
});

documents.spec.ts

File: cannamanage-frontend/e2e/documents.spec.ts

T-128: Admin uploads document with category

test('Admin uploads document with category', async ({ page }) => {
    await loginAsAdmin(page);
    await page.goto('/documents');

    await page.click('text=Dokument hochladen');
    await page.fill('[name="title"]', 'Vereinssatzung 2026');
    await page.selectOption('[name="category"]', 'Satzung');
    await page.selectOption('[name="accessLevel"]', 'ALL_MEMBERS');

    const fileInput = page.locator('input[type="file"]');
    await fileInput.setInputFiles('e2e/fixtures/test-document.pdf');

    await page.click('text=Hochladen');
    await expect(page.locator('text=Vereinssatzung 2026')).toBeVisible();
    await expect(page.locator('text=Satzung')).toBeVisible();
});

T-130: Portal member sees public documents only

test('Portal member sees only public documents', async ({ page }) => {
    await loginAsMember(page);
    await page.goto('/portal/documents');

    // Public document should be visible
    await expect(page.locator('text=Vereinssatzung 2026')).toBeVisible();

    // Board-only document should NOT be visible
    await expect(page.locator('text=Mietvertrag')).not.toBeVisible();
});

portal-finance.spec.ts

File: cannamanage-frontend/e2e/portal-finance.spec.ts

T-119: Portal member sees payment history and balance

test('Portal member sees their payment history and balance', async ({ page }) => {
    await loginAsMember(page);
    await page.goto('/portal/finance');

    // Balance card
    await expect(page.locator('[data-testid="balance-card"]')).toBeVisible();
    await expect(page.locator('text=Aktuell')).toBeVisible(); // or outstanding amount

    // Payment history table
    const payments = page.locator('[data-testid="payment-row"]');
    await expect(payments).toHaveCount.greaterThan(0);
});

T-120: Portal member downloads own receipt

test('Portal member downloads receipt PDF', async ({ page }) => {
    await loginAsMember(page);
    await page.goto('/portal/finance');

    const downloadButton = page.locator('[data-testid="download-receipt"]').first();
    await downloadButton.click();

    const download = await page.waitForEvent('download');
    expect(download.suggestedFilename()).toMatch(/quittung.*\.pdf/);
});

board.spec.ts

File: cannamanage-frontend/e2e/board.spec.ts

T-131: Admin assigns board position to member

test('Admin assigns board position to member', async ({ page }) => {
    await loginAsAdmin(page);
    await page.goto('/board');

    await page.click('text=Position besetzen');
    await page.selectOption('[name="position"]', '1. Vorsitzende/r');
    await page.selectOption('[name="member"]', 'Max Mustermann');
    await page.fill('[name="termStart"]', '2026-07-01');
    await page.fill('[name="termEnd"]', '2028-07-01');
    await page.click('text=Ernennen');

    await expect(page.locator('text=1. Vorsitzende/r')).toBeVisible();
    await expect(page.locator('text=Max Mustermann')).toBeVisible();
    await expect(page.locator('text=01.07.2026  01.07.2028')).toBeVisible();
});

Test Data Requirements

Seed Data for Tests

Entity Values
Fee Schedules "Regulär" €30/month, "Ermäßigt" €15/month, "Ehrenmitglied" €0/year
Expense Categories Miete, Strom, Cannabis-Einkauf, Anbaumaterial, Versicherung, Verwaltung, Sonstiges
Document Categories Satzung, Protokolle, Verträge, Versicherungen, Behördliche Genehmigungen, Sonstiges
Board Positions 1. Vorsitzende/r, 2. Vorsitzende/r, Kassenwart/in, Schriftführer/in, Beisitzer/in
Members At least 10 active members for quorum tests
Payments Varied history: some current, some overdue, some overpaid

Test Fixtures (E2E)

  • e2e/fixtures/test-document.pdf — small valid PDF for upload tests
  • e2e/fixtures/large-file.bin — 21MB file for rejection tests
  • e2e/fixtures/test-receipt.jpg — JPEG for expense receipt tests

Test Coverage Summary

Component Unit Integration E2E Total
FinanceService 17 8 6 31
KassenbuchService 5 0 1 6
ReceiptPdfService 3 1 1 5
FinancialReportService 3 1 0 4
PaymentReminderScheduler 4 0 0 4
AssemblyService 22 9 7 38
ProtocolPdfService 3 1 1 5
DocumentStorageService 5 0 0 5
DocumentService 4 7 3 14
BoardService 6 3 2 11
BoardTermScheduler 2 0 0 2
PlanTierService (new rules) 4 0 0 4
Tenant isolation 0 3 0 3
Total 78 33 21 132