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

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

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

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

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

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

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

@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