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)
42 KiB
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 testse2e/fixtures/large-file.bin— 21MB file for rejection testse2e/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 |