b22702317a
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)
989 lines
42 KiB
Markdown
989 lines
42 KiB
Markdown
# 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** |
|