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