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