# 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 ```java @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 ```java @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) ```java @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 ```java @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 ```java @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 ```java @Test void testGenerateKassenbuch_runningBalanceCorrect() { // Opening balance: €500 // Day 1: +€30 (payment) → €530 // Day 2: -€85 (expense) → €445 // Day 3: +€30 (payment) → €475 List payments = List.of( createPayment(LocalDate.of(2026, 7, 1), new BigDecimal("30.00")), createPayment(LocalDate.of(2026, 7, 3), new BigDecimal("30.00")) ); List 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 ```java @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 ```java @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 ```java @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 ```java @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 ```java @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 ```java @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 ```java @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 ```java @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 ```java @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 ```java @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 ```java @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 ```java @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 ```java @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 ```java @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 ```java @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 ```java @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 ```java @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 ```java @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 ```java @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 ```java @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 ```java @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 ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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** |