# 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** |
