Files
cannamanage/docs/sprint-7/cannamanage-sprint7-testplan.md
Patrick Plate 706a6e257b feat(sprint7): Phase 1 — notifications enhancement + push infrastructure
Phase 1 (Notification Enhancement):
- Extended NotificationType enum (ADMIN_MESSAGE, INFO_BOARD_POST, FORUM_REPLY, FORUM_MENTION)
- Extended StaffPermission enum (SEND_NOTIFICATIONS, MANAGE_INFO_BOARD, MODERATE_FORUM)
- Extended AuditEventType with Sprint 7 events
- Flyway V11: notification_sends + notification_send_recipients tables
- NotificationSend + NotificationSendRecipient entities
- NotificationSendRepository + NotificationSendRecipientRepository
- Extended NotificationService with sendBroadcast() and sendToSelected()
- NotificationComposeController (POST /compose, GET /sends)
- ComposeNotificationRequest DTO

Phase 1B (Push Infrastructure):
- Flyway V12: device_tokens + notification_preferences tables
- DeviceToken entity + DevicePlatform enum
- NotificationPreference entity + NotificationChannel enum
- DeviceTokenRepository + NotificationPreferenceRepository
- DeviceRegistrationService (register/unregister/list devices, max 10 per user)
- NotificationPreferenceService (get/create defaults, update, IN_APP always on)
- NotificationDispatchService (multi-channel fan-out: WebSocket, Web Push, FCM, Email)
- WebPushSender (VAPID-based, simplified for MVP)
- FcmPushSender (graceful degradation if not configured)
- PushPayload DTO
- DeviceRegistrationController (POST/GET/DELETE /devices, GET /vapid-key)
- NotificationPreferenceController (GET/PUT /preferences)
- ConsentType extended (NOTIFICATION_PUSH, NOTIFICATION_EMAIL)
- TargetType enum (ALL, SELECTED)

Frontend:
- Updated sw.js with push event handler + notification click handler
- push-subscription.ts (subscribeToPush, unsubscribe, permission helpers)
- notification-compose.ts service (compose, sends, devices, preferences APIs)
- i18n keys (de.json + en.json) for compose, preferences, push, devices

Configuration:
- application-docker.properties: VAPID + FCM push config properties
- MemberRepository: added findAllActiveUserIds() for broadcast
2026-06-13 19:25:19 +02:00

1477 lines
52 KiB
Markdown

# Sprint 7 Test Plan — Communication & Community
**Date:** 2026-06-13
**Author:** Patrick Plate / Lumen (Architect)
**Status:** Draft v1
**Based on:** `cannamanage-sprint7-plan.md`
---
## Test Overview
| ID | Description | Type | Class/File | Status |
|----|-------------|------|-----------|--------|
| T-01 | NotificationService broadcast sends to all members | Unit | `NotificationServiceBroadcastTest` | ⬜ |
| T-02 | NotificationService targeted send to selected members | Unit | `NotificationServiceBroadcastTest` | ⬜ |
| T-03 | Broadcast WebSocket push delivered per user | Unit | `NotificationServiceBroadcastTest` | ⬜ |
| T-04 | NotificationSend record created with correct metadata | Unit | `NotificationServiceBroadcastTest` | ⬜ |
| T-05 | Info Board post creation persists correctly | Unit | `InfoBoardServiceTest` | ⬜ |
| T-06 | Info Board pin toggle works | Unit | `InfoBoardServiceTest` | ⬜ |
| T-07 | Info Board archive soft-deletes (no hard delete) | Unit | `InfoBoardServiceTest` | ⬜ |
| T-08 | Info Board post creation triggers member notification | Unit | `InfoBoardServiceTest` | ⬜ |
| T-09 | Info Board category creation with tier limit enforcement | Unit | `InfoBoardServiceTest` | ⬜ |
| T-10 | Info Board listing returns pinned first, then by date | Unit | `InfoBoardServiceTest` | ⬜ |
| T-11 | Forum topic creation persists correctly | Unit | `ForumServiceTest` | ⬜ |
| T-12 | Forum reply increments reply count and updates last_reply_at | Unit | `ForumServiceTest` | ⬜ |
| T-13 | Forum edit rejected after 30-minute window | Unit | `ForumServiceTest` | ⬜ |
| T-14 | Forum locked topic blocks new replies | Unit | `ForumServiceTest` | ⬜ |
| T-15 | Forum delete replaces body with placeholder | Unit | `ForumServiceTest` | ⬜ |
| T-16 | Forum report creation and resolution | Unit | `ForumServiceTest` | ⬜ |
| T-17 | Forum reaction toggle (add/remove) | Unit | `ForumServiceTest` | ⬜ |
| T-18 | Forum reply notification sent to topic author | Unit | `ForumServiceTest` | ⬜ |
| T-19 | Forum reaction unique constraint (one per user per target) | Unit | `ForumServiceTest` | ⬜ |
| T-20 | FileStorageService stores and retrieves file correctly | Unit | `FileStorageServiceTest` | ⬜ |
| T-21 | FileStorageService rejects oversized files | Unit | `FileStorageServiceTest` | ⬜ |
| T-22 | FileStorageService rejects disallowed content types | Unit | `FileStorageServiceTest` | ⬜ |
| T-23 | Compose endpoint requires SEND_NOTIFICATIONS permission | Integration | `NotificationComposeControllerIntegrationTest` | ⬜ |
| T-24 | Compose endpoint validates request (title required, etc.) | Integration | `NotificationComposeControllerIntegrationTest` | ⬜ |
| T-25 | Compose endpoint sends to ALL members successfully | Integration | `NotificationComposeControllerIntegrationTest` | ⬜ |
| T-26 | Info Board POST requires MANAGE_INFO_BOARD permission | Integration | `InfoBoardControllerIntegrationTest` | ⬜ |
| T-27 | Info Board GET accessible to any authenticated user | Integration | `InfoBoardControllerIntegrationTest` | ⬜ |
| T-28 | Info Board category filter returns correct posts | Integration | `InfoBoardControllerIntegrationTest` | ⬜ |
| T-29 | Forum topic creation accessible to any member | Integration | `ForumControllerIntegrationTest` | ⬜ |
| T-30 | Forum moderation endpoints require MODERATE_FORUM | Integration | `ForumControllerIntegrationTest` | ⬜ |
| T-31 | Forum edit after 30 min returns 403 | Integration | `ForumControllerIntegrationTest` | ⬜ |
| T-32 | Forum Starter tier access denied | Integration | `ForumControllerIntegrationTest` | ⬜ |
| T-33 | Tenant isolation: Club A data invisible to Club B | Integration | `TenantIsolationTest` | ⬜ |
| T-34 | Tenant isolation: Info Board posts scoped to tenant | Integration | `TenantIsolationTest` | ⬜ |
| T-35 | Tenant isolation: Forum topics scoped to tenant | Integration | `TenantIsolationTest` | ⬜ |
| T-36 | E2E: Admin creates info board post, portal member sees it | E2E | `info-board.spec.ts` | ⬜ |
| T-37 | E2E: Admin composes broadcast notification, member bell updates | E2E | `notification-compose.spec.ts` | ⬜ |
| T-38 | E2E: Member creates forum topic and replies | E2E | `forum.spec.ts` | ⬜ |
| T-39 | E2E: Moderator locks topic, reply is blocked | E2E | `forum.spec.ts` | ⬜ |
| T-40 | E2E: Member reports post, moderator resolves | E2E | `forum.spec.ts` | ⬜ |
| T-41 | Device registration creates token record | Unit | `DeviceRegistrationServiceTest` | ⬜ |
| T-42 | Device registration upserts on duplicate token | Unit | `DeviceRegistrationServiceTest` | ⬜ |
| T-43 | Device registration enforces max 10 devices per user | Unit | `DeviceRegistrationServiceTest` | ⬜ |
| T-44 | Device unregistration deletes token | Unit | `DeviceRegistrationServiceTest` | ⬜ |
| T-45 | Notification preferences auto-created on first access | Unit | `NotificationPreferenceServiceTest` | ⬜ |
| T-46 | IN_APP channel cannot be disabled | Unit | `NotificationPreferenceServiceTest` | ⬜ |
| T-47 | Preference update enables/disables channels correctly | Unit | `NotificationPreferenceServiceTest` | ⬜ |
| T-48 | NotificationDispatchService fans out to all enabled channels | Unit | `NotificationDispatchServiceTest` | ⬜ |
| T-49 | Dispatch skips channels with no registered devices | Unit | `NotificationDispatchServiceTest` | ⬜ |
| T-50 | Dispatch skips disabled channels | Unit | `NotificationDispatchServiceTest` | ⬜ |
| T-51 | WebPushSender sends valid VAPID payload | Unit | `WebPushSenderTest` | ⬜ |
| T-52 | FcmPushSender handles expired token gracefully | Unit | `FcmPushSenderTest` | ⬜ |
| T-53 | Device registration API returns 201 on valid request | Integration | `DeviceRegistrationControllerIntegrationTest` | ⬜ |
| T-54 | Device registration API rejects unauthenticated | Integration | `DeviceRegistrationControllerIntegrationTest` | ⬜ |
| T-55 | Notification preferences API returns defaults for new user | Integration | `NotificationPreferenceControllerIntegrationTest` | ⬜ |
| T-56 | Multi-channel dispatch sends Web Push on broadcast | Integration | `NotificationDispatchIntegrationTest` | ⬜ |
| T-57 | E2E: Portal member enables Web Push, receives native notification | E2E | `web-push.spec.ts` | ⬜ |
| T-58 | EventService creates event and triggers notification | Unit | `EventServiceTest` | ⬜ |
| T-59 | EventService RSVP enforces max attendees | Unit | `EventServiceTest` | ⬜ |
| T-60 | EventService RSVP upsert (change response) | Unit | `EventServiceTest` | ⬜ |
| T-61 | EventService recurring event expansion (weekly) | Unit | `EventServiceTest` | ⬜ |
| T-62 | EventService recurring event expansion (monthly) | Unit | `EventServiceTest` | ⬜ |
| T-63 | EventService recurring event respects end date | Unit | `EventServiceTest` | ⬜ |
| T-64 | EventService iCal generation (single event) | Unit | `EventServiceTest` | ⬜ |
| T-65 | EventService iCal generation (recurring event with RRULE) | Unit | `EventServiceTest` | ⬜ |
| T-66 | EventService cancel event notifies RSVP'd members | Unit | `EventServiceTest` | ⬜ |
| T-67 | EventReminderScheduler sends reminders 24h before | Unit | `EventReminderSchedulerTest` | ⬜ |
| T-68 | EventReminderScheduler only notifies ACCEPTED/MAYBE | Unit | `EventReminderSchedulerTest` | ⬜ |
| T-69 | Event API POST requires MANAGE_INFO_BOARD permission | Integration | `EventControllerIntegrationTest` | ⬜ |
| T-70 | Event API GET date range returns correct events | Integration | `EventControllerIntegrationTest` | ⬜ |
| T-71 | Event API RSVP returns 409 when event full | Integration | `EventControllerIntegrationTest` | ⬜ |
| T-72 | Event API iCal returns valid text/calendar response | Integration | `EventControllerIntegrationTest` | ⬜ |
| T-73 | Portal events API returns only upcoming events for member's club | Integration | `EventControllerIntegrationTest` | ⬜ |
| T-74 | E2E: Admin creates event, portal member RSVPs | E2E | `events.spec.ts` | ⬜ |
| T-75 | E2E: Calendar month view shows event dots | E2E | `events.spec.ts` | ⬜ |
| T-76 | E2E: Member downloads iCal file | E2E | `events.spec.ts` | ⬜ |
Status legend: ⬜ Pending | ✅ Passed | ❌ Failed | ⏭️ Skipped
---
## Unit Tests
### `NotificationServiceBroadcastTest`
**File:** `cannamanage-service/src/test/java/de/cannamanage/service/NotificationServiceBroadcastTest.java`
**Setup:**
- Mock `NotificationRepository`, `NotificationSendRepository`, `SimpMessagingTemplate`, `MemberRepository`
- Test tenant: fixed UUID
- Test members: 5 mock active members with UUIDs
---
#### T-01: Broadcast sends to all members
**Method:** `testSendBroadcast_AllMembers_NotificationsCreatedForEach()`
| # | Input | Expected |
|---|-------|----------|
| a | title="Test", message="Hello", 5 active members | 5 Notification entities saved |
| b | title="Test", message="Hello", 0 active members | 0 notifications, no error |
**Verification:**
- `notificationRepository.saveAll()` called with list of 5
- Each notification has correct `userId`, `type=ADMIN_MESSAGE`, `title`, `message`
- `NotificationSend` record has `targetType=ALL`, `targetCount=5`
---
#### T-02: Targeted send to selected members
**Method:** `testSendToSelected_SpecificMembers_OnlyThoseReceive()`
| # | Input | Expected |
|---|-------|----------|
| a | recipientIds=[user1, user2], 5 total members | 2 notifications created |
| b | recipientIds=[nonExistentId] | 0 notifications, graceful handling |
| c | recipientIds=[] (empty list) | Exception or 0 notifications |
**Verification:**
- Only specified recipients receive notification
- `NotificationSend.targetType=SELECTED`, `targetCount=2`
---
#### T-03: Broadcast WebSocket push delivered per user
**Method:** `testSendBroadcast_WebSocketPush_SentToEachUser()`
| # | Input | Expected |
|---|-------|----------|
| a | 3 members, broadcast | `messagingTemplate.convertAndSendToUser()` called 3 times |
**Verification:**
- Each call targets the correct `userId.toString()` destination
- Payload contains notification id, type, title, message
---
#### T-04: NotificationSend metadata record created
**Method:** `testSendBroadcast_NotificationSendRecord_CreatedCorrectly()`
| # | Input | Expected |
|---|-------|----------|
| a | author=adminId, title="News", ALL | NotificationSend with authorId, title, targetType=ALL, targetCount matches member count |
---
### `InfoBoardServiceTest`
**File:** `cannamanage-service/src/test/java/de/cannamanage/service/InfoBoardServiceTest.java`
**Setup:**
- Mock `InfoBoardPostRepository`, `InfoBoardCategoryRepository`, `NotificationService`, `SubscriptionRepository`
- Test categories: 2 pre-existing categories
---
#### T-05: Post creation persists correctly
**Method:** `testCreatePost_ValidInput_PostPersisted()`
| # | Input | Expected |
|---|-------|----------|
| a | title="New Strain!", body="We have...", categoryId=valid, authorId=staff | Post saved with all fields, pinned=false, archived=false |
| b | title=null | Validation error (NullPointerException or constraint violation) |
| c | categoryId=nonExistent | Exception (category not found) |
---
#### T-06: Pin toggle
**Method:** `testTogglePin_ExistingPost_PinFlipped()`
| # | Input | Expected |
|---|-------|----------|
| a | Post with pinned=false | After toggle: pinned=true |
| b | Post with pinned=true | After toggle: pinned=false |
| c | Non-existent postId | Exception (not found) |
---
#### T-07: Archive soft-deletes
**Method:** `testArchivePost_ExistingPost_ArchivedNotDeleted()`
| # | Input | Expected |
|---|-------|----------|
| a | Active post | archived=true, entity still in DB |
| b | Already archived post | No error, remains archived |
**Verification:**
- `repository.delete()` NOT called
- `post.setArchived(true)` and `repository.save()` called
---
#### T-08: Post creation triggers notification
**Method:** `testCreatePost_TriggersNotification_BroadcastSent()`
| # | Input | Expected |
|---|-------|----------|
| a | Create post with title="Event" | `notificationService.sendBroadcast()` called with type=INFO_BOARD_POST |
---
#### T-09: Category creation tier limit
**Method:** `testCreateCategory_StarterTierLimit_RejectedAfter3()`
| # | Input | Expected |
|---|-------|----------|
| a | Starter tier, 2 existing categories, create 3rd | Allowed |
| b | Starter tier, 3 existing categories, create 4th | Rejected (tier limit) |
| c | Pro tier, 10 existing categories, create 11th | Allowed (unlimited) |
---
#### T-10: Listing order (pinned first)
**Method:** `testListPosts_PinnedFirst_ThenByDate()`
| # | Input | Expected |
|---|-------|----------|
| a | 1 pinned (old), 2 unpinned (new) | Order: pinned, newest unpinned, oldest unpinned |
| b | All unpinned | Ordered by publishedAt DESC |
| c | includeArchived=false | Archived posts excluded |
---
### `ForumServiceTest`
**File:** `cannamanage-service/src/test/java/de/cannamanage/service/ForumServiceTest.java`
**Setup:**
- Mock `ForumTopicRepository`, `ForumReplyRepository`, `ForumReportRepository`, `ForumReactionRepository`, `ForumCategoryRepository`, `NotificationService`, `SubscriptionRepository`
- Test user IDs: authorId, replyerId, moderatorId
---
#### T-11: Topic creation
**Method:** `testCreateTopic_ValidInput_TopicPersisted()`
| # | Input | Expected |
|---|-------|----------|
| a | title="Growing tips", body="How do you...", categoryId=valid | Topic saved, replyCount=0, locked=false, pinned=false |
| b | title=blank | Validation error |
| c | categoryId=invalid | Exception (category not found) |
---
#### T-12: Reply increments denormalized fields
**Method:** `testCreateReply_ValidInput_TopicFieldsUpdated()`
| # | Input | Expected |
|---|-------|----------|
| a | Reply to topic with replyCount=3 | replyCount becomes 4, lastReplyAt updated, lastReplyBy=replier |
| b | First reply to topic | replyCount=1, lastReplyAt set |
**Verification:**
- `topic.setReplyCount(topic.getReplyCount() + 1)`
- `topic.setLastReplyAt(Instant.now())`
- `topic.setLastReplyBy(replyerId)`
- Both reply and topic saved
---
#### T-13: Edit rejected after 30-minute window
**Method:** `testEditTopic_After30Minutes_Rejected()`
| # | Input | Expected |
|---|-------|----------|
| a | Topic created 5 min ago, author edits | Allowed, body updated |
| b | Topic created 31 min ago, author edits | Rejected with exception |
| c | Topic created 29 min ago, different user edits | Rejected (not author) |
---
#### T-14: Locked topic blocks replies
**Method:** `testCreateReply_LockedTopic_Rejected()`
| # | Input | Expected |
|---|-------|----------|
| a | Reply to locked topic | Exception (topic locked) |
| b | Reply to unlocked topic | Allowed |
---
#### T-15: Delete replaces body
**Method:** `testDeleteTopic_ModeratorAction_BodyReplaced()`
| # | Input | Expected |
|---|-------|----------|
| a | Delete topic by moderator | body becomes "[Beitrag entfernt]", metadata preserved |
| b | Delete reply by moderator | reply.body becomes "[Beitrag entfernt]", editedAt set |
**Verification:**
- Entity NOT removed from DB
- Body text replaced
- Author ID preserved (for audit)
---
#### T-16: Report creation and resolution
**Method:** `testReportContent_AndResolve()`
| # | Input | Expected |
|---|-------|----------|
| a | Report topic with reason | ForumReport created, resolved=false |
| b | Resolve report | resolved=true, resolvedBy=moderator, resolvedAt set |
| c | Report already-reported content | Duplicate report allowed (different reporter) |
---
#### T-17: Reaction toggle
**Method:** `testAddReaction_Toggle_AddsAndRemoves()`
| # | Input | Expected |
|---|-------|----------|
| a | Add THUMBSUP to topic | ForumReaction saved |
| b | Add same reaction again (same user, same target) | Reaction removed (toggle) |
| c | Add different reaction (same user, same target) | Both reactions exist |
---
#### T-18: Reply notification to topic author
**Method:** `testCreateReply_NotifiesTopicAuthor()`
| # | Input | Expected |
|---|-------|----------|
| a | User B replies to User A's topic | Notification sent to User A, type=FORUM_REPLY |
| b | User A replies to own topic | No notification (don't notify self) |
---
#### T-19: Reaction unique constraint
**Method:** `testAddReaction_DuplicateViolation_HandledGracefully()`
| # | Input | Expected |
|---|-------|----------|
| a | Same user, same target, same reaction type twice | DataIntegrityViolationException caught, treated as toggle |
---
### `FileStorageServiceTest`
**File:** `cannamanage-service/src/test/java/de/cannamanage/service/FileStorageServiceTest.java`
**Setup:**
- Temp directory for file storage
- Max size: 10 MB
- Allowed types: image/png, image/jpeg, application/pdf
---
#### T-20: File stored and retrieved
**Method:** `testStoreFile_ValidPdf_StoredAndRetrievable()`
| # | Input | Expected |
|---|-------|----------|
| a | 1 MB PDF file, tenantId, postId | File written to `{tempDir}/{tenantId}/{postId}/{filename}` |
| b | Retrieve stored file | Same bytes returned |
---
#### T-21: Oversized file rejected
**Method:** `testStoreFile_Oversized_Rejected()`
| # | Input | Expected |
|---|-------|----------|
| a | 11 MB file | Exception (file too large) |
| b | 10 MB file (exactly at limit) | Allowed |
---
#### T-22: Disallowed content type rejected
**Method:** `testStoreFile_DisallowedType_Rejected()`
| # | Input | Expected |
|---|-------|----------|
| a | application/javascript file | Exception (type not allowed) |
| b | text/html file | Exception |
| c | image/png file | Allowed |
---
## Integration Tests
### `NotificationComposeControllerIntegrationTest`
**File:** `cannamanage-api/src/test/java/de/cannamanage/api/controller/NotificationComposeControllerIntegrationTest.java`
**Setup:** `@SpringBootTest` with test PostgreSQL (TestContainers or H2), authenticated staff user
---
#### T-23: Permission check
**Method:** `testCompose_WithoutPermission_Returns403()`
| # | Scenario | Expected |
|---|----------|----------|
| a | Staff WITHOUT `SEND_NOTIFICATIONS` calls POST /compose | 403 Forbidden |
| b | Staff WITH `SEND_NOTIFICATIONS` calls POST /compose | 200 OK |
| c | Portal member (non-staff) calls POST /compose | 403 Forbidden |
---
#### T-24: Request validation
**Method:** `testCompose_InvalidRequest_Returns400()`
| # | Scenario | Expected |
|---|----------|----------|
| a | Missing title | 400 Bad Request |
| b | Missing message | 400 Bad Request |
| c | targetType=SELECTED but no recipientIds | 400 Bad Request |
| d | Valid request | 200 OK |
---
#### T-25: Broadcast creates notifications for all
**Method:** `testCompose_BroadcastAll_NotificationsCreated()`
| # | Scenario | Expected |
|---|----------|----------|
| a | 3 active members in club, broadcast | 3 notification records in DB, NotificationSend with targetCount=3 |
---
### `InfoBoardControllerIntegrationTest`
**File:** `cannamanage-api/src/test/java/de/cannamanage/api/controller/InfoBoardControllerIntegrationTest.java`
---
#### T-26: POST requires permission
**Method:** `testCreatePost_WithoutPermission_Returns403()`
| # | Scenario | Expected |
|---|----------|----------|
| a | Staff WITHOUT `MANAGE_INFO_BOARD` | 403 |
| b | Staff WITH `MANAGE_INFO_BOARD` | 201 Created |
| c | ADMIN role (implicit all permissions) | 201 Created |
---
#### T-27: GET accessible to all authenticated
**Method:** `testGetPosts_AnyAuthenticated_Returns200()`
| # | Scenario | Expected |
|---|----------|----------|
| a | Staff user (any) | 200 with post list |
| b | Portal member | 200 with post list |
| c | Unauthenticated | 401 |
---
#### T-28: Category filter
**Method:** `testGetPosts_FilterByCategory_CorrectResults()`
| # | Scenario | Expected |
|---|----------|----------|
| a | 3 posts in "Events", 2 in "Rules", filter=Events | 3 posts returned |
| b | Filter with non-existent categoryId | Empty list (not 404) |
| c | No filter | All non-archived posts |
---
### `ForumControllerIntegrationTest`
**File:** `cannamanage-api/src/test/java/de/cannamanage/api/controller/ForumControllerIntegrationTest.java`
---
#### T-29: Topic creation by any member
**Method:** `testCreateTopic_AnyMember_Allowed()`
| # | Scenario | Expected |
|---|----------|----------|
| a | Portal member creates topic | 201 Created |
| b | Staff creates topic | 201 Created |
| c | Unauthenticated | 401 |
---
#### T-30: Moderation requires permission
**Method:** `testModeration_WithoutPermission_Returns403()`
| # | Scenario | Expected |
|---|----------|----------|
| a | Regular member calls PUT /topics/{id}/lock | 403 |
| b | Staff WITH `MODERATE_FORUM` calls PUT /topics/{id}/lock | 200 |
| c | Regular member calls GET /reports | 403 |
| d | Staff WITH `MODERATE_FORUM` calls GET /reports | 200 |
---
#### T-31: Edit after 30 min rejected via API
**Method:** `testEditTopic_After30Min_Returns403()`
| # | Scenario | Expected |
|---|----------|----------|
| a | Edit topic created 31 min ago (use fixed clock or manipulate createdAt) | 403 |
| b | Edit topic created 5 min ago by same author | 200 |
---
#### T-32: Starter tier can't access forum
**Method:** `testForumAccess_StarterTier_Returns403()`
| # | Scenario | Expected |
|---|----------|----------|
| a | Club on Starter tier, member accesses /forum/topics | 403 with upgrade message |
| b | Club on Pro tier, same request | 200 |
| c | Club on Enterprise tier | 200 |
---
### `TenantIsolationTest`
**File:** `cannamanage-api/src/test/java/de/cannamanage/api/controller/TenantIsolationTest.java`
---
#### T-33: General tenant isolation
**Method:** `testTenantIsolation_CrossClubInvisible()`
**Setup:** Two clubs (Club A, Club B) with their own users, data in all Sprint 7 tables.
| # | Scenario | Expected |
|---|----------|----------|
| a | Club A user queries notifications | Only Club A notifications returned |
| b | Club B user queries info board | Only Club B posts returned |
| c | Club A user queries forum topics | Only Club A topics returned |
---
#### T-34: Info Board tenant isolation
**Method:** `testInfoBoard_TenantIsolation()`
| # | Scenario | Expected |
|---|----------|----------|
| a | Club A creates post, Club B lists posts | Club B doesn't see Club A's post |
| b | Club B tries to access Club A's post by ID | 404 (filtered by tenant) |
---
#### T-35: Forum tenant isolation
**Method:** `testForum_TenantIsolation()`
| # | Scenario | Expected |
|---|----------|----------|
| a | Club A creates topic, Club B lists topics | Club B doesn't see it |
| b | Club A creates reply, Club B tries to access topic | 404 |
| c | Club B tries to report Club A's content | 404 (can't even see it) |
---
## Playwright E2E Tests
### `info-board.spec.ts`
**File:** `cannamanage-frontend/e2e/info-board.spec.ts`
---
#### T-36: Full info board lifecycle
```typescript
test('admin creates post, portal member sees it', async ({ page }) => {
// 1. Login as admin
// 2. Navigate to /info-board
// 3. Click "New Post"
// 4. Fill: title="Club Event Saturday", category=Events, body="Join us at..."
// 5. Submit
// 6. Verify post appears in admin list
// 7. Switch to portal member session
// 8. Navigate to portal dashboard
// 9. Verify "Club Event Saturday" appears in announcements widget
// 10. Click "View All" → verify full announcements page shows the post
})
test('admin pins post, pinned appears first', async ({ page }) => {
// 1. Create 2 posts (newer one first)
// 2. Pin the older post
// 3. Verify pinned post appears at top of list
// 4. Verify pin icon visible
})
test('admin archives post, post disappears from list', async ({ page }) => {
// 1. Create post
// 2. Archive it
// 3. Verify it's gone from default listing
// 4. Toggle "show archived" → verify it appears
})
```
---
### `notification-compose.spec.ts`
**File:** `cannamanage-frontend/e2e/notification-compose.spec.ts`
---
#### T-37: Broadcast notification flow
```typescript
test('admin composes broadcast, member sees notification', async ({ page }) => {
// 1. Login as admin
// 2. Navigate to /settings/notifications/compose
// 3. Select "All Members" radio
// 4. Fill title="Important Update"
// 5. Fill message="Please read..."
// 6. Click Send
// 7. Verify success message
// 8. Navigate to /settings/notifications → verify in history
// 9. Switch to portal member session
// 10. Verify notification bell shows unread badge
// 11. Click bell → verify "Important Update" in dropdown
// 12. Mark as read → badge clears
})
test('targeted notification only reaches selected members', async ({ page }) => {
// 1. Login as admin
// 2. Compose notification, select "Selected Members"
// 3. Pick 1 specific member
// 4. Send
// 5. Check that member → sees notification
// 6. Check other member → does NOT see notification
})
```
---
### `forum.spec.ts`
**File:** `cannamanage-frontend/e2e/forum.spec.ts`
---
#### T-38: Member creates topic and replies
```typescript
test('member creates topic and another member replies', async ({ page }) => {
// 1. Login as member A (portal)
// 2. Navigate to /portal/forum
// 3. Select a category
// 4. Click "New Topic"
// 5. Fill title="Best soil mix?", body="What do you recommend..."
// 6. Submit → verify topic appears in category list
// 7. Switch to member B
// 8. Navigate to same topic
// 9. Write reply "I use coco coir..."
// 10. Submit → verify reply appears under topic
// 11. Verify topic list shows replyCount=1
// 12. Switch to member A → verify FORUM_REPLY notification received
})
```
---
#### T-39: Moderator locks topic
```typescript
test('moderator locks topic, reply blocked', async ({ page }) => {
// 1. Setup: topic exists with replies
// 2. Login as staff with MODERATE_FORUM
// 3. Navigate to topic
// 4. Click moderation dropdown → "Lock Topic"
// 5. Verify lock icon appears
// 6. Switch to regular member
// 7. Navigate to same topic
// 8. Verify reply editor is disabled/hidden
// 9. Verify message "This topic is locked"
})
```
---
#### T-40: Report and moderation flow
```typescript
test('member reports post, moderator resolves', async ({ page }) => {
// 1. Login as member
// 2. Navigate to a topic with a reply
// 3. Click "Report" on the reply
// 4. Fill reason="Spam content"
// 5. Submit report → verify confirmation
// 6. Switch to staff with MODERATE_FORUM
// 7. Navigate to /forum/moderation
// 8. Verify report appears in queue (reported content, reason, reporter)
// 9. Click "Resolve" (with action: delete)
// 10. Verify reply body replaced with "[Beitrag entfernt]"
// 11. Verify report marked as resolved
})
```
---
## Test Data Requirements
### Database fixtures needed
| Entity | Test data |
|--------|-----------|
| Club A | Starter tier, 3 members, 1 admin, 1 staff |
| Club B | Pro tier, 5 members, 1 admin, 2 staff (one with MODERATE_FORUM, one without) |
| Categories (Info Board) | "Events", "Rules", "General" for Club B |
| Categories (Forum) | "Growing Tips", "General Discussion", "Events" for Club B |
| Info Board Posts | 5 posts (2 pinned, 1 archived, 2 regular) for Club B |
| Forum Topics | 3 topics (1 pinned, 1 locked, 1 regular) for Club B |
| Forum Replies | 5 replies across topics |
| Notifications | Existing system notifications for baseline verification |
### Test users
| User | Role | Club | Permissions |
|------|------|------|-------------|
| admin-a | ADMIN | Club A | All (implicit) |
| staff-a | STAFF | Club A | VIEW_MEMBER_LIST only |
| member-a1 | MEMBER | Club A | Portal access |
| admin-b | ADMIN | Club B | All (implicit) |
| staff-b-mod | STAFF | Club B | MODERATE_FORUM, MANAGE_INFO_BOARD, SEND_NOTIFICATIONS |
| staff-b-limited | STAFF | Club B | VIEW_MEMBER_LIST only |
| member-b1 | MEMBER | Club B | Portal access |
| member-b2 | MEMBER | Club B | Portal access |
---
## Phase 1B: Multi-Channel Push Notification Tests
### `DeviceRegistrationServiceTest`
**File:** `cannamanage-service/src/test/java/de/cannamanage/service/DeviceRegistrationServiceTest.java`
**Setup:**
- Mock `DeviceTokenRepository`
- Test user with UUID
- Test tenant context
---
#### T-41: Device registration creates token record
**Method:** `testRegisterDevice_ValidInput_TokenCreated()`
| # | Input | Expected |
|---|-------|----------|
| a | platform=WEB, token=validSubscriptionJSON, deviceName="Chrome" | DeviceToken saved with correct fields |
| b | platform=ANDROID, token=fcmToken | DeviceToken saved with platform=ANDROID |
| c | platform=IOS, token=apnsToken | DeviceToken saved with platform=IOS |
**Verification:**
- `deviceTokenRepository.save()` called with correct entity
- `lastUsedAt` set to current time
- `tenantId` set from tenant context
---
#### T-42: Device registration upserts on duplicate token
**Method:** `testRegisterDevice_DuplicateToken_UpdatesLastUsedAt()`
| # | Input | Expected |
|---|-------|----------|
| a | Same user, same token registered twice | No new record created, `lastUsedAt` updated |
| b | Different user, same token | New record created (different user owns this device — shouldn't happen but handle gracefully) |
**Verification:**
- `findByUserIdAndToken()` checked first
- If exists: update `lastUsedAt` only
- If not exists: insert new record
---
#### T-43: Device registration enforces max 10 devices per user
**Method:** `testRegisterDevice_MaxDevicesExceeded_Rejected()`
| # | Input | Expected |
|---|-------|----------|
| a | User has 9 devices, registers 10th | Allowed |
| b | User has 10 devices, registers 11th | Rejected with exception (max devices reached) |
| c | User has 10 devices, re-registers existing token | Allowed (upsert, not new) |
---
#### T-44: Device unregistration deletes token
**Method:** `testUnregisterDevice_OwnDevice_Deleted()`
| # | Input | Expected |
|---|-------|----------|
| a | User unregisters own device token | Token deleted |
| b | User tries to unregister another user's token | Rejected (not owner) |
| c | User unregisters non-existent token ID | 404 (not found) |
---
### `NotificationPreferenceServiceTest`
**File:** `cannamanage-service/src/test/java/de/cannamanage/service/NotificationPreferenceServiceTest.java`
**Setup:**
- Mock `NotificationPreferenceRepository`
- Test user with UUID
---
#### T-45: Preferences auto-created on first access
**Method:** `testGetPreferences_NewUser_DefaultsCreated()`
| # | Input | Expected |
|---|-------|----------|
| a | User with no existing preferences | 4 records created: IN_APP=true, EMAIL=false, WEB_PUSH=false, MOBILE_PUSH=false |
| b | User with existing preferences | Existing records returned (no duplicates) |
**Verification:**
- `repository.findByUserId()` returns empty → create defaults
- `repository.saveAll()` called with 4 preference entities
- Returned DTO shows all channels with correct defaults
---
#### T-46: IN_APP channel cannot be disabled
**Method:** `testUpdatePreferences_DisableInApp_Rejected()`
| # | Input | Expected |
|---|-------|----------|
| a | Update IN_APP to enabled=false | Exception or ignored (IN_APP remains true) |
| b | Update EMAIL to enabled=false | Allowed |
| c | Update WEB_PUSH to enabled=true | Allowed |
**Verification:**
- After any update attempt, IN_APP preference always has `enabled=true`
- Other channels freely togglable
---
#### T-47: Preference update enables/disables channels
**Method:** `testUpdatePreferences_ValidChannels_Updated()`
| # | Input | Expected |
|---|-------|----------|
| a | Enable WEB_PUSH | WEB_PUSH.enabled=true, others unchanged |
| b | Disable EMAIL (previously enabled) | EMAIL.enabled=false |
| c | Enable MOBILE_PUSH | MOBILE_PUSH.enabled=true |
---
### `NotificationDispatchServiceTest`
**File:** `cannamanage-service/src/test/java/de/cannamanage/service/NotificationDispatchServiceTest.java`
**Setup:**
- Mock `NotificationPreferenceService`, `DeviceTokenRepository`, `WebPushSender`, `FcmPushSender`, `EmailService`
- Test user with preferences and device tokens
---
#### T-48: Dispatch fans out to all enabled channels
**Method:** `testDispatch_AllChannelsEnabled_AllSendersCalled()`
| # | Input | Expected |
|---|-------|----------|
| a | User has WEB_PUSH=true + 1 web token, EMAIL=true, MOBILE_PUSH=true + 1 FCM token | WebPushSender called 1x, EmailService called 1x, FcmPushSender called 1x |
| b | User has 3 web tokens, all channels enabled | WebPushSender called 3x (once per token) |
**Verification:**
- Each sender receives correct `PushPayload` with title, body, type, url
- Payload data map contains notification-specific fields (e.g., `topicId`, `distributionId`)
---
#### T-49: Dispatch skips channels with no registered devices
**Method:** `testDispatch_NoDeviceTokens_PushSkipped()`
| # | Input | Expected |
|---|-------|----------|
| a | WEB_PUSH=true but no web tokens registered | WebPushSender NOT called |
| b | MOBILE_PUSH=true but no mobile tokens | FcmPushSender NOT called |
| c | EMAIL=true, user has email address | EmailService still called (email doesn't need device token) |
---
#### T-50: Dispatch skips disabled channels
**Method:** `testDispatch_ChannelDisabled_SenderNotCalled()`
| # | Input | Expected |
|---|-------|----------|
| a | WEB_PUSH=false, user has web tokens | WebPushSender NOT called |
| b | EMAIL=false | EmailService NOT called |
| c | MOBILE_PUSH=false, user has FCM tokens | FcmPushSender NOT called |
---
### `WebPushSenderTest`
**File:** `cannamanage-service/src/test/java/de/cannamanage/service/push/WebPushSenderTest.java`
**Setup:**
- Mock HTTP client (or use WireMock)
- Valid VAPID keys (test keys)
- Valid Web Push subscription JSON
---
#### T-51: WebPushSender sends valid VAPID payload
**Method:** `testSend_ValidSubscription_PayloadSent()`
| # | Input | Expected |
|---|-------|----------|
| a | Valid subscription JSON with endpoint + keys | HTTP POST to subscription endpoint with encrypted payload |
| b | Invalid subscription JSON (malformed) | Logged as warning, no exception thrown |
| c | Subscription endpoint returns 410 (Gone) | Token should be marked for removal |
**Verification:**
- Payload is encrypted per Web Push protocol (RFC 8291)
- VAPID auth header present
- TTL header set
---
### `FcmPushSenderTest`
**File:** `cannamanage-service/src/test/java/de/cannamanage/service/push/FcmPushSenderTest.java`
**Setup:**
- Mock `FirebaseMessaging`
- Valid FCM token
---
#### T-52: FcmPushSender handles expired token gracefully
**Method:** `testSend_ExpiredToken_TokenRemoved()`
| # | Input | Expected |
|---|-------|----------|
| a | Valid token, send succeeds | No side effects beyond successful send |
| b | Token that returns UNREGISTERED error | Token deleted from `device_tokens` table |
| c | Network error | Logged as error, no token deletion, no exception propagated |
**Verification:**
- On `MessagingErrorCode.UNREGISTERED`: `deviceTokenRepo.deleteByToken()` called
- On success: nothing extra happens
- On other errors: logged but not thrown (dispatch shouldn't fail the main transaction)
---
### `DeviceRegistrationControllerIntegrationTest`
**File:** `cannamanage-api/src/test/java/de/cannamanage/api/controller/DeviceRegistrationControllerIntegrationTest.java`
**Setup:** `@SpringBootTest` with test DB, authenticated portal member
---
#### T-53: Device registration returns 201
**Method:** `testRegisterDevice_ValidRequest_Returns201()`
| # | Scenario | Expected |
|---|----------|----------|
| a | POST `/api/v1/notifications/devices` with valid body | 201 Created, device token in DB |
| b | POST same token again | 200 OK (upsert), `lastUsedAt` updated |
| c | Missing required field (platform) | 400 Bad Request |
---
#### T-54: Device registration rejects unauthenticated
**Method:** `testRegisterDevice_Unauthenticated_Returns401()`
| # | Scenario | Expected |
|---|----------|----------|
| a | No auth token | 401 Unauthorized |
| b | Expired auth token | 401 Unauthorized |
| c | Valid portal member token | 201 Created |
---
### `NotificationPreferenceControllerIntegrationTest`
**File:** `cannamanage-api/src/test/java/de/cannamanage/api/controller/NotificationPreferenceControllerIntegrationTest.java`
---
#### T-55: Preferences API returns defaults for new user
**Method:** `testGetPreferences_NewUser_ReturnsDefaults()`
| # | Scenario | Expected |
|---|----------|----------|
| a | GET `/api/v1/notifications/preferences` for user with no prefs | 200 with default prefs (IN_APP=true, rest false) |
| b | PUT update EMAIL=true, then GET | EMAIL shows enabled=true |
| c | PUT with IN_APP=false | 200 OK but IN_APP remains true in response |
---
### `NotificationDispatchIntegrationTest`
**File:** `cannamanage-api/src/test/java/de/cannamanage/api/controller/NotificationDispatchIntegrationTest.java`
**Setup:** `@SpringBootTest`, user with Web Push enabled + registered device token, mocked WebPushSender
---
#### T-56: Multi-channel dispatch on broadcast
**Method:** `testBroadcast_WebPushEnabled_PushSent()`
| # | Scenario | Expected |
|---|----------|----------|
| a | Admin sends broadcast, member has WEB_PUSH=true + token | WebPushSender called for that member |
| b | Admin sends broadcast, member has WEB_PUSH=false | WebPushSender NOT called |
| c | Admin sends broadcast, member has WEB_PUSH=true but no token | WebPushSender NOT called |
---
### `web-push.spec.ts` (E2E)
**File:** `cannamanage-frontend/e2e/web-push.spec.ts`
---
#### T-57: Portal member enables Web Push
```typescript
test('portal member enables push notifications', async ({ page, context }) => {
// 1. Grant notification permission (Playwright context.grantPermissions)
await context.grantPermissions(['notifications'])
// 2. Login as portal member
// 3. Verify push permission prompt appears (or navigate to notification settings)
// 4. Click "Enable Push Notifications"
// 5. Verify device registered via API (check /api/v1/notifications/devices returns 1 device)
// 6. Verify preference updated (WEB_PUSH=true in /api/v1/notifications/preferences)
// 7. Admin sends broadcast notification
// 8. Verify service worker received push event (check via page.evaluate on SW registration)
})
test('portal member disables push via preferences', async ({ page }) => {
// 1. Login as member with push already enabled
// 2. Navigate to notification settings
// 3. Toggle Web Push off
// 4. Verify API called to update preference
// 5. Admin sends broadcast
// 6. Verify NO push received (preference disabled)
})
```
---
### `EventServiceTest`
**File:** `cannamanage-service/src/test/java/de/cannamanage/service/EventServiceTest.java`
**Setup:**
- Mock `ClubEventRepository`, `EventRsvpRepository`, `NotificationService`, `InfoBoardService`, `MemberRepository`
- Test club: fixed UUID with 10 active members
- Test event: meeting type, 5 max attendees
---
#### T-58: Event creation triggers notification
**Method:** `testCreateEvent_ValidInput_EventPersistedAndNotificationSent()`
| # | Input | Expected |
|---|-------|----------|
| a | title="Erntefest", type=HARVEST_FESTIVAL, startAt=tomorrow | Event saved, `notificationService.sendBroadcast()` called with type=EVENT_CREATED |
| b | title=null | Validation error |
| c | startAt in the past | Validation error (cannot create event in past) |
**Verification:**
- `repository.save()` called with correct fields
- `notificationService.sendBroadcast()` called with EVENT_CREATED type
- `infoBoardService.createPost()` called (auto-post to info board)
---
#### T-59: RSVP enforces max attendees
**Method:** `testRsvp_EventFull_AcceptedRejected()`
| # | Input | Expected |
|---|-------|----------|
| a | maxAttendees=5, 4 ACCEPTED, new RSVP ACCEPTED | Allowed (5th spot) |
| b | maxAttendees=5, 5 ACCEPTED, new RSVP ACCEPTED | Rejected with EVENT_FULL error |
| c | maxAttendees=5, 5 ACCEPTED, new RSVP DECLINED | Allowed (decline always works) |
| d | maxAttendees=5, 5 ACCEPTED, new RSVP MAYBE | Allowed (maybe always works) |
| e | maxAttendees=null (unlimited) | Always allowed |
**Verification:**
- Count query checks current ACCEPTED count vs maxAttendees
- DECLINED and MAYBE never blocked
---
#### T-60: RSVP upsert (change response)
**Method:** `testRsvp_ExistingRsvp_StatusUpdated()`
| # | Input | Expected |
|---|-------|----------|
| a | Existing RSVP=ACCEPTED, change to DECLINED | Status updated, respondedAt updated |
| b | Existing RSVP=DECLINED, change to ACCEPTED (event not full) | Status updated to ACCEPTED |
| c | Existing RSVP=DECLINED, change to ACCEPTED (event full) | Rejected with EVENT_FULL |
| d | No existing RSVP, new ACCEPTED | New record created |
**Verification:**
- `rsvpRepository.findByEventIdAndMemberId()` used for upsert check
- `respondedAt` updated on change
---
#### T-61: Recurring event expansion (weekly)
**Method:** `testExpandRecurring_Weekly_CorrectOccurrences()`
| # | Input | Expected |
|---|-------|----------|
| a | WEEKLY from Jan 1 to Jan 31 | 5 occurrences (Jan 1, 8, 15, 22, 29) |
| b | WEEKLY from Jan 1, query range Feb 1-28 | 4 occurrences in February |
| c | WEEKLY with recurrenceEndDate=Jan 15 | 3 occurrences (Jan 1, 8, 15) |
---
#### T-62: Recurring event expansion (monthly)
**Method:** `testExpandRecurring_Monthly_CorrectOccurrences()`
| # | Input | Expected |
|---|-------|----------|
| a | MONTHLY from Jan 15, query range Jan-June | 6 occurrences (15th of each month) |
| b | MONTHLY from Jan 31, query range Jan-April | Handles month-end correctly (Jan 31, Feb 28, Mar 31, Apr 30) |
---
#### T-63: Recurring event respects end date
**Method:** `testExpandRecurring_EndDate_StopsAtBoundary()`
| # | Input | Expected |
|---|-------|----------|
| a | WEEKLY from Jan 1, endDate=Jan 20 | 3 occurrences (Jan 1, 8, 15) — not Jan 22 |
| b | MONTHLY from Jan 1, endDate=Mar 1 | 3 occurrences (Jan 1, Feb 1, Mar 1) |
| c | BIWEEKLY from Jan 1, endDate=Feb 15 | 4 occurrences (Jan 1, 15, 29, Feb 12) |
---
#### T-64: iCal generation (single event)
**Method:** `testGenerateIcal_SingleEvent_ValidVCalendar()`
| # | Input | Expected |
|---|-------|----------|
| a | Event with title, location, start/end | Valid VCALENDAR with VEVENT containing DTSTART, DTEND, SUMMARY, LOCATION |
| b | Event without location | VCALENDAR without LOCATION field |
| c | Event without endAt | VCALENDAR without DTEND field |
**Verification:**
- Output starts with `BEGIN:VCALENDAR` and ends with `END:VCALENDAR`
- Contains `PRODID:-//CannaManage//Events//EN`
- UID format: `{eventId}@cannamanage.de`
- Date format: `yyyyMMdd'T'HHmmss'Z'` (UTC)
---
#### T-65: iCal generation (recurring event with RRULE)
**Method:** `testGenerateIcal_RecurringEvent_ContainsRRule()`
| # | Input | Expected |
|---|-------|----------|
| a | WEEKLY recurring, no end date | Contains `RRULE:FREQ=WEEKLY` |
| b | MONTHLY recurring, endDate=2026-12-31 | Contains `RRULE:FREQ=MONTHLY;UNTIL=20261231T000000Z` |
| c | BIWEEKLY recurring | Contains `RRULE:FREQ=WEEKLY;INTERVAL=2` |
---
#### T-66: Cancel event notifies RSVP'd members
**Method:** `testCancelEvent_WithRsvps_NotifiesAcceptedAndMaybe()`
| # | Input | Expected |
|---|-------|----------|
| a | 3 ACCEPTED, 2 DECLINED, 1 MAYBE | Notification sent to 4 (3 ACCEPTED + 1 MAYBE), not the 2 DECLINED |
| b | 0 RSVPs | No notifications sent, event deleted |
**Verification:**
- `notificationService.sendToSelected()` called with correct recipient list
- Notification type = EVENT_CANCELLED
- Event deleted from repository
---
### `EventReminderSchedulerTest`
**File:** `cannamanage-service/src/test/java/de/cannamanage/service/EventReminderSchedulerTest.java`
**Setup:**
- Mock `ClubEventRepository`, `EventRsvpRepository`, `NotificationService`
- Fixed clock for deterministic time assertions
---
#### T-67: Sends reminders 24h before
**Method:** `testSendReminders_EventIn24Hours_ReminderSent()`
| # | Input | Expected |
|---|-------|----------|
| a | Event starts in 24.5 hours | Reminder sent (within 24-25h window) |
| b | Event starts in 23 hours | No reminder (already past window) |
| c | Event starts in 26 hours | No reminder (too far in future) |
**Verification:**
- `eventRepository.findByStartAtBetween(now+24h, now+25h)` used for window query
- Notification sent with type EVENT_REMINDER
---
#### T-68: Only notifies ACCEPTED and MAYBE
**Method:** `testSendReminders_MixedRsvps_OnlyAcceptedAndMaybeNotified()`
| # | Input | Expected |
|---|-------|----------|
| a | 2 ACCEPTED, 1 MAYBE, 3 DECLINED | 3 reminders sent (ACCEPTED + MAYBE) |
| b | All DECLINED | 0 reminders sent |
| c | No RSVPs | 0 reminders sent |
---
### `EventControllerIntegrationTest`
**File:** `cannamanage-api/src/test/java/de/cannamanage/api/controller/EventControllerIntegrationTest.java`
**Setup:**
- `@SpringBootTest` with `WebEnvironment.RANDOM_PORT`
- Test DB with club, members, staff accounts
- JWT tokens for admin (with MANAGE_INFO_BOARD) and regular member
---
#### T-69: POST requires MANAGE_INFO_BOARD permission
```java
@Test void createEvent_WithoutPermission_Returns403()
@Test void createEvent_WithPermission_Returns201()
@Test void createEvent_Unauthenticated_Returns401()
```
---
#### T-70: GET date range returns correct events
```java
@Test void listEvents_DateRange_ReturnsOnlyEventsInRange()
@Test void listEvents_EmptyRange_ReturnsEmpty()
@Test void listEvents_IncludesRecurringExpansions()
```
**Setup:** Seed 5 events across different months.
---
#### T-71: RSVP returns 409 when event full
```java
@Test void rsvp_EventFull_Returns409WithEventFullError()
@Test void rsvp_EventNotFull_Returns200()
@Test void rsvp_DeclineWhenFull_Returns200()
```
---
#### T-72: iCal returns valid text/calendar response
```java
@Test void getIcal_ValidEvent_ReturnsTextCalendarContentType()
@Test void getIcal_NonExistentEvent_Returns404()
```
**Verification:**
- Response header: `Content-Type: text/calendar; charset=utf-8`
- Response header: `Content-Disposition: attachment; filename="event-{id}.ics"`
- Body starts with `BEGIN:VCALENDAR`
---
#### T-73: Portal events returns upcoming for member's club
```java
@Test void portalEvents_AuthenticatedMember_ReturnsUpcomingForTheirClub()
@Test void portalEvents_MemberOfDifferentClub_ReturnsEmpty()
@Test void portalEvents_PastEventsExcluded()
```
---
### `events.spec.ts` (E2E)
**File:** `cannamanage-frontend/e2e/events.spec.ts`
---
#### T-74: Admin creates event, portal member RSVPs
```typescript
test('admin creates event and member RSVPs', async ({ page }) => {
// 1. Login as admin
// 2. Navigate to /calendar
// 3. Click "New Event" button
// 4. Fill form: title="Erntefest", type=HARVEST_FESTIVAL, date=next Saturday, location="Vereinshaus"
// 5. Submit
// 6. Verify event appears in calendar grid (dot on date)
// 7. Logout, login as portal member
// 8. Navigate to /portal/events
// 9. Verify "Erntefest" appears in upcoming events list
// 10. Click event → detail page
// 11. Click "Zusage" button
// 12. Verify RSVP status updated to "Zugesagt"
// 13. Verify attendee count shows "1 Zusage"
})
```
---
#### T-75: Calendar month view shows event dots
```typescript
test('calendar grid shows dots for events', async ({ page }) => {
// 1. Login as admin
// 2. Create 3 events on different dates this month via API
// 3. Navigate to /calendar
// 4. Verify month grid visible (7-column layout)
// 5. Verify 3 date cells have event dot indicators
// 6. Click on a date with an event
// 7. Verify event card shown in event list below/beside calendar
})
```
---
#### T-76: Member downloads iCal file
```typescript
test('member downloads ical file for event', async ({ page }) => {
// 1. Login as portal member
// 2. Navigate to event detail page
// 3. Click "Zum Kalender hinzufügen" (iCal download button)
// 4. Verify download triggered (check download event)
// 5. Verify downloaded file has .ics extension
// 6. Verify file content starts with "BEGIN:VCALENDAR"
})
```
---
## Test Coverage Matrix
| Component | Unit | Integration | E2E | Total |
|-----------|------|-------------|-----|-------|
| NotificationService (broadcast) | 4 | 3 | 2 | 9 |
| InfoBoardService | 6 | 3 | 3 | 12 |
| ForumService | 9 | 4 | 3 | 16 |
| FileStorageService | 3 | 0 | 0 | 3 |
| EventService | 9 | 5 | 3 | 17 |
| EventReminderScheduler | 2 | 0 | 0 | 2 |
| DeviceRegistrationService | 4 | 2 | 0 | 6 |
| NotificationPreferenceService | 3 | 1 | 0 | 4 |
| NotificationDispatchService | 3 | 1 | 0 | 4 |
| WebPushSender | 1 | 0 | 1 | 2 |
| FcmPushSender | 1 | 0 | 0 | 1 |
| Tenant Isolation | 0 | 3 | 0 | 3 |
| **Total** | **45** | **22** | **9 scenarios** | **76 test cases** |
---
## Edge Cases to Cover
| # | Edge Case | Where Tested | Why It Matters |
|---|-----------|-------------|---------------|
| 1 | Broadcast to club with 0 members | T-01b | No NPE, graceful empty operation |
| 2 | Forum edit at exactly 30 minutes | T-13 | Boundary condition — should still be allowed at 30:00, rejected at 30:01 |
| 3 | Concurrent reactions on same post | T-19 | Unique constraint handling under concurrency |
| 4 | Archived info board post accessed directly by ID | T-27 | Should still return (archived flag visible) or 404 depending on policy |
| 5 | Self-reply notification suppressed | T-18b | Author shouldn't get notified for their own replies |
| 6 | File upload with path traversal filename | T-20 | Security: `../../etc/passwd` as filename must be sanitized |
| 7 | HTML/XSS in markdown body | Integration | Body stored as-is (markdown), rendered safely on frontend |
| 8 | Very long post body (100KB+) | Integration | DB TEXT column handles it; API may want to set max length |
| 9 | Delete topic with 100+ replies | T-15 | Cascading soft-delete performance |
| 10 | Notification fan-out to 500+ members | T-01 | Batch INSERT performance, no N+1 queries |
| 11 | Expired Web Push subscription (410 Gone) | T-51c | Stale subscriptions must be auto-removed, not retry forever |
| 12 | FCM token expired (UNREGISTERED) | T-52b | Stale mobile tokens auto-removed from device registry |
| 13 | User with 10+ devices tries to register 11th | T-43b | Prevent abuse; return meaningful error |
| 14 | Web Push dispatch to user with 0 web tokens but enabled preference | T-49a | Graceful no-op, no NPE or error |
| 15 | Malformed Web Push subscription JSON in device_tokens | T-51b | Sender logs warning, skips token, doesn't crash dispatch |
| 16 | FCM disabled (`push.fcm.enabled=false`) but mobile tokens exist | T-52 | FcmPushSender bean not loaded; dispatch skips mobile channel |
| 17 | Concurrent device registrations for same user+token | T-42 | Unique constraint handled via upsert, no duplicate records |
| 18 | RSVP ACCEPTED when event is exactly at max_attendees | T-59b | Boundary: count==max must reject |
| 19 | Recurring event expansion across DST boundary | T-61 | Events at same local time must not drift by 1 hour |
| 20 | Monthly recurrence on 31st for months with <31 days | T-62b | Feb 31 → Feb 28, Apr 31 → Apr 30 |
| 21 | Cancel event with 100+ RSVPs | T-66 | Notification fan-out performance for cancellation |
| 22 | iCal with special characters in title/description | T-64 | Commas, semicolons, newlines must be escaped per RFC 5545 |
| 23 | Concurrent RSVPs for last spot | T-59/T-71 | Unique constraint + count check must be atomic (no overbooking) |
| 24 | Event reminder scheduler fires twice (idempotency) | T-67 | 1-hour window query ensures no duplicate reminders |
| 25 | Create event in the past | T-58c | Validation must reject start_at before current time |
---
## Non-Functional Test Checklist
| # | Check | How |
|---|-------|-----|
| 1 | Broadcast to 500 members < 5 seconds | Load test with 500 member fixtures |
| 2 | Forum topic listing with 1000 topics < 500ms | Seed data + measure response time |
| 3 | No N+1 queries on topic listing | Enable Hibernate SQL logging in test, verify query count |
| 4 | WebSocket message delivered < 1s after DB persist | Timing assertion in E2E test |
| 5 | File upload 10 MB completes < 10s | Timed integration test |
| 6 | Concurrent forum posts don't corrupt reply_count | Parallel thread test with optimistic locking verification |
| 7 | Push dispatch doesn't block main notification transaction | Verify `@Async` execution; main broadcast returns before push completes |
| 8 | Web Push delivery to 50 subscriptions < 10s | Bulk send test with mock push endpoint |
| 9 | FCM batch send to 100 tokens < 5s | Firebase Admin SDK supports batch; measure throughput |
| 10 | Stale token cleanup job processes 1000 tokens < 30s | Scheduled job performance with large dataset |