Files
cannamanage/docs/sprint-8/cannamanage-sprint8-testplan.md
T
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

989 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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
```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** |