Phase 1 (Notification Enhancement): - Extended NotificationType enum (ADMIN_MESSAGE, INFO_BOARD_POST, FORUM_REPLY, FORUM_MENTION) - Extended StaffPermission enum (SEND_NOTIFICATIONS, MANAGE_INFO_BOARD, MODERATE_FORUM) - Extended AuditEventType with Sprint 7 events - Flyway V11: notification_sends + notification_send_recipients tables - NotificationSend + NotificationSendRecipient entities - NotificationSendRepository + NotificationSendRecipientRepository - Extended NotificationService with sendBroadcast() and sendToSelected() - NotificationComposeController (POST /compose, GET /sends) - ComposeNotificationRequest DTO Phase 1B (Push Infrastructure): - Flyway V12: device_tokens + notification_preferences tables - DeviceToken entity + DevicePlatform enum - NotificationPreference entity + NotificationChannel enum - DeviceTokenRepository + NotificationPreferenceRepository - DeviceRegistrationService (register/unregister/list devices, max 10 per user) - NotificationPreferenceService (get/create defaults, update, IN_APP always on) - NotificationDispatchService (multi-channel fan-out: WebSocket, Web Push, FCM, Email) - WebPushSender (VAPID-based, simplified for MVP) - FcmPushSender (graceful degradation if not configured) - PushPayload DTO - DeviceRegistrationController (POST/GET/DELETE /devices, GET /vapid-key) - NotificationPreferenceController (GET/PUT /preferences) - ConsentType extended (NOTIFICATION_PUSH, NOTIFICATION_EMAIL) - TargetType enum (ALL, SELECTED) Frontend: - Updated sw.js with push event handler + notification click handler - push-subscription.ts (subscribeToPush, unsubscribe, permission helpers) - notification-compose.ts service (compose, sends, devices, preferences APIs) - i18n keys (de.json + en.json) for compose, preferences, push, devices Configuration: - application-docker.properties: VAPID + FCM push config properties - MemberRepository: added findAllActiveUserIds() for broadcast
52 KiB
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 NotificationSendrecord hastargetType=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 calledpost.setArchived(true)andrepository.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
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
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
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
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
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 entitylastUsedAtset to current timetenantIdset 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
lastUsedAtonly - 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 defaultsrepository.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
PushPayloadwith 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
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 fieldsnotificationService.sendBroadcast()called with EVENT_CREATED typeinfoBoardService.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 checkrespondedAtupdated 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:VCALENDARand ends withEND: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:
@SpringBootTestwithWebEnvironment.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
@Test void createEvent_WithoutPermission_Returns403()
@Test void createEvent_WithPermission_Returns201()
@Test void createEvent_Unauthenticated_Returns401()
T-70: GET date range returns correct events
@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
@Test void rsvp_EventFull_Returns409WithEventFullError()
@Test void rsvp_EventNotFull_Returns200()
@Test void rsvp_DeclineWhenFull_Returns200()
T-72: iCal returns valid text/calendar response
@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
@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
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
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
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 |