From 3d56ccc40332f17324cc33b54d4ff27383570bce Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 07:43:46 -0500 Subject: [PATCH] feat(routing): fan record alerts out to followers via core RecordFollowers An alert about a record now also reaches the record's followers, from core RecordFollowers.get(instance, ref, FollowEvent.ALERT) (QRun-IO/qqq#1164, QQQ 4.1.0-RC.6). Followers are deduplicated with existing recipients by user and channel. They must pass the source-record permission and lock checks, and they get the same preferences and suppression as any other recipient. With no provider registered, routing is unchanged. There is no dependency on qbit-collaboration, and the #1164 stand-in marker is gone. Also adopt qbit-build-parent 2.1.0 with the guardrails enforced and strict raw-route mode. Comments that only used the word "stand-in" are reworded. Refs #16 (architecture review QRun-IO/qqq#1150, finding F12). Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- CHANGELOG.md | 6 +- README.md | 14 +- docs/NOTIFICATIONS.md | 4 +- docs/UI-API.md | 27 +- pom.xml | 4 +- .../qbits/alerts/delivery/DeliveryWorker.java | 2 +- .../delivery/channels/ExternalChannels.java | 2 +- .../qbits/alerts/engine/AlertRouter.java | 64 ++++- .../alerts/delivery/ExternalChannelsTest.java | 6 +- .../alerts/engine/FollowerFanoutTest.java | 272 ++++++++++++++++++ 11 files changed, 383 insertions(+), 20 deletions(-) create mode 100644 src/test/java/com/kingsrook/qbits/alerts/engine/FollowerFanoutTest.java diff --git a/AGENTS.md b/AGENTS.md index 74ef0ad..5ac9274 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ Packages: ## Dependencies - Java 21. -- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.6` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1. +- Parent `com.kingsrook:qbit-build-parent:2.1.0` (qqq 4.0.0), with architecture guardrails enforced and strict UI API route tests enabled. The default-active `qqq-snapshot` profile imports released `qqq-bom-pom:4.1.0-RC.6` ahead of it, as in qbit-quick-search (ADR-0007). Build normally without a version override. Remove that activation when the parent moves to 4.1. - Tests: JUnit 5, AssertJ. ## Release diff --git a/CHANGELOG.md b/CHANGELOG.md index c4a1e51..5b2305d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,12 @@ All notable changes to this project are documented here. The format follows Keep ## [0.1.0] - Unreleased ### Changed -- Target QQQ `4.1.0-RC.6` for the UI extension API; local verification uses a command-line version override only. +- Target released QQQ `4.1.0-RC.6`; verify normally with `./mvnw -B verify` (no version override). +- Adopt `qbit-build-parent:2.1.0` with architecture guardrail enforcement and strict UI API routes enabled for tests. Dependency, extension contract and stand-in checks run in the normal build. +- Add record followers to alert routing through core `RecordFollowers.get(instance, ref, FollowEvent.ALERT)` (#16, QRun-IO/qqq#1164). Deduplicate by user/channel with subscriptions and other record refs; require source-record read access under the follower's session, including record locks. Followers use the existing alert visibility, preferences and suppression pipeline without a dependency on qbit-collaboration. Missing/disabled/failing providers preserve subscription routing. - Register six JSON UI specs as extension `alerts` at the established `/alerts` prefix, preserving methods, paths and existing payload fields. Publish the OpenAPI fragment with a regeneration/contract test and strict server coverage (#13, F2). - Add summary `inboxHref`, notification operation actions, alert process actions with complete hrefs, and record-panel `follow.available` (#13, F20). Follow discovery reads core extension metadata without a collaboration dependency or URL. -- Remove the record-panel watch route and handler. Existing RECORD subscriptions and the deprecated `watching` response remain readable without a migration; new panel follows belong to collaboration. Follower fan-out waits for core #1164 (#16, F12). +- Remove the record-panel watch route and handler. Existing RECORD subscriptions and the deprecated `watching` response remain readable without a migration; new panel follows belong to collaboration (#16, F12). - Move Alertmanager ingest to `/qqq/hooks/alerts/alertmanager`. The old path is an opt-in alias (`legacyAlertmanagerPath`, default false) for one minor release, incompatible with strict raw-route mode. Each alias call carries deprecation/sunset headers and logs a warning; authentication is shared (#14, F7). - Run Checkstyle 14.3.0 in Maven validate (including tests); align the license template with this repository's existing headers and correct pre-existing switch/import formatting so the style check passes. - Alert types and sink registration use core's alerts SPI (introduced in QQQ 4.1.0-RC.5); host `QAlerts.raise/resolve/heartbeat` calls reach the installed qbit through core instance metadata (qbit-alerts#15, F3). diff --git a/README.md b/README.md index d82af2b..24560c2 100644 --- a/README.md +++ b/README.md @@ -56,8 +56,11 @@ hrefs, `notificationsHref`, `alertsHref`, `canManage` and deprecated `watching` Record following belongs to collaboration: `POST /alerts/record-panel/watch` is removed. Old RECORD subscriptions remain readable without migration. The panel creates no subscriptions; -Next uses its collaboration module when `follow.available` is true. Follower fan-out waits -for core QRun-IO/qqq#1164. +Next uses its collaboration module when `follow.available` is true. Alerts with active record +refs also reach followers supplied by core's instance-level followers provider (`FollowEvent.ALERT`), +when FOLLOW is enabled and the follower can read the source record. Followers and subscribers +share recipient deduplication, channel preferences and suppression. No collaboration qbit dependency +is required; without a provider, subscription behavior is unchanged. See [follower routing](docs/UI-API.md#record-follower-routing). The old Alertmanager path is available for one minor release only via `withLegacyAlertmanagerPath(true)` (default false). It requires non-strict UI route mode, @@ -67,12 +70,17 @@ a warning. Both paths use the same bearer-token check. Strict hosts use the cano ## Requirements - Java 21. -- QQQ 4.1 (targets `4.1.0-RC.6`; release pending). +- QQQ `4.1.0-RC.6` (released on Maven Central). - An RDBMS backend. - A scheduler, for monitors and email delivery. - A messaging provider, for email. - `qqq-middleware-javalin`, for the `/qqq/v1/alerts` routes (optional). +The build uses `qbit-build-parent:2.1.0` with architecture guardrails and strict UI API route +tests enabled. Run `./mvnw -B verify` (`.\mvnw.cmd -B verify` on Windows), without a snapshot +override. The default-active `qqq-snapshot` profile retains its historical name but selects +the released RC.6 BOM ahead of the parent's QQQ 4.0 BOM. + ## Install ```java diff --git a/docs/NOTIFICATIONS.md b/docs/NOTIFICATIONS.md index b608469..9182f20 100644 --- a/docs/NOTIFICATIONS.md +++ b/docs/NOTIFICATIONS.md @@ -161,7 +161,7 @@ QAlerts.notify(new NotificationSignal("Your export failed") The qbit implements core's `notify` override in `AlertsSinkAdapter` and `QbitAlertsSink`; signals use the existing bounded queue and `NotificationService` for audience resolution, inbox recipients, scheduling and category/email preferences. Alert subscription channels - and their quiet-hour suppression keep their existing behavior. + keep their existing behavior. Quiet-hour scheduling for alerts remains unimplemented (see README). - **Breaking migration:** the local `spi` package and `AlertsRuntime.sink()` are removed. Change imports to core, call `QAlerts.notify(signal)` in the producer's `QContext`, and rebuild. qbit-easypost-tracking#16 uses core. The runtime's `getSink()` supports adapter @@ -265,3 +265,5 @@ recipients) whose `expireAt` is more than 7 days past, plus SENT or RETRACTED no The implemented UI routes and additive action/follow metadata are specified in [UI-API.md](UI-API.md), with the generated fragment at `src/main/resources/openapi/qqq-v1/alerts.yaml`. Record following belongs to collaboration; the old alerts panel watch route is removed. +Record-linked alerts fan out through core's RC.6 followers provider with source-record access +checks and the ordinary alert preference/suppression pipeline; see [record follower routing](UI-API.md#record-follower-routing). diff --git a/docs/UI-API.md b/docs/UI-API.md index ef210db..5c637b8 100644 --- a/docs/UI-API.md +++ b/docs/UI-API.md @@ -58,9 +58,26 @@ and scope subscriptions; existing record-scoped watch rows are deprecated and re `watching` boolean is retained for compatibility and reports only those legacy rows; it must not be used as collaboration follow state. -There is no follower fan-out yet. `AlertRouter.destinations` contains -`STAND-IN(core=QRun-IO/qqq#1164, removeBy=4.1.0-RC.7)` at the point where follower destinations -will join the existing recipient visibility and suppression pipeline. +## Record follower routing + +For every active record ref on an alert, `AlertRouter.destinations` calls RC.6's +`RecordFollowers.get(instance, ref, FollowEvent.ALERT)`. Register a core +`RecordFollowersProviderInterface` through instance `RecordFeaturesMetaData.withFollowersProvider` +and enable `RecordFeature.FOLLOW` for the source table (or by instance default). The provider +selects followers for ALERT; core recommends treating ALERT like CHANGE unless the provider +has a finer preference. The alerts qbit does not depend on qbit-collaboration. + +Each follower must have a known automated recipient session and be able to query the source +record through its table permissions and record security locks. Missing records and unknown +or denied access do not add follower destinations. The query uses the source table's configured +primary key. Eligible followers join the existing destination map, deduplicated by user and +channel across subscriptions and record refs. Existing subscription destinations take precedence. + +Followers use ordinary in-app/email settings, vacation pause for external delivery, alert +visibility checks, type notification switches, mutes, flapping, channel limits and duplicate +suppression. Quiet-hour scheduling remains unimplemented for all alert recipients, as listed +in the README; follower routing does not introduce a separate delivery policy. With no provider, +FOLLOW disabled, or a provider failure, existing subscription routing is unchanged. ## Alertmanager machine webhook @@ -70,6 +87,10 @@ JSON request and `{raised, resolved, ignored}` response. No QQQ user session is The previous **POST `/qqq/v1/alerts/ingest/alertmanager`** is an opt-in raw alias: +The architecture guardrail's static G3 scan reports this intentional raw UI-prefix route +as an advisory. Runtime strict-mode coverage rejects the alias; only the migration test +starts a standalone Javalin instance with it enabled. Normal application tests remain strict. + ```java new AlertsQBitConfig() .withLegacyAlertmanagerPath(true) diff --git a/pom.xml b/pom.xml index 7572037..39b553f 100644 --- a/pom.xml +++ b/pom.xml @@ -22,12 +22,14 @@ 21 21 0.8.15 + true + true com.kingsrook qbit-build-parent - 2.0.0 + 2.1.0 com.kingsrook.qbits diff --git a/src/main/java/com/kingsrook/qbits/alerts/delivery/DeliveryWorker.java b/src/main/java/com/kingsrook/qbits/alerts/delivery/DeliveryWorker.java index 94307e2..cfadf9c 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/delivery/DeliveryWorker.java +++ b/src/main/java/com/kingsrook/qbits/alerts/delivery/DeliveryWorker.java @@ -334,7 +334,7 @@ public class DeliveryWorker /******************************************************************************* - ** The stand-in alert a TEST message describes. + ** The sample alert a TEST message describes. *******************************************************************************/ private static QRecord testAlert(Instant now) { diff --git a/src/main/java/com/kingsrook/qbits/alerts/delivery/channels/ExternalChannels.java b/src/main/java/com/kingsrook/qbits/alerts/delivery/channels/ExternalChannels.java index 307e7dd..5e4a4f1 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/delivery/channels/ExternalChannels.java +++ b/src/main/java/com/kingsrook/qbits/alerts/delivery/channels/ExternalChannels.java @@ -391,7 +391,7 @@ public final class ExternalChannels /******************************************************************************* - ** The message's entries (a TEST message gets one stand-in entry). + ** The message's entries (a TEST message gets one sample entry). *******************************************************************************/ static List entries(AlertMessage message) { diff --git a/src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java b/src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java index 889228f..36b3f91 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java +++ b/src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java @@ -45,14 +45,21 @@ import com.kingsrook.qbits.alerts.model.AlertsSubscription; import com.kingsrook.qbits.alerts.model.AlertsTeamMember; import com.kingsrook.qbits.alerts.model.AlertsType; import com.kingsrook.qbits.alerts.model.AlertsUserPreference; +import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFollowers; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; +import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType; import com.kingsrook.qqq.backend.core.alerts.AlertSeverity; import com.kingsrook.qqq.backend.core.context.CapturedContext; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.collaboration.FollowEvent; +import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFollower; +import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReference; import com.kingsrook.qqq.backend.core.model.session.QSession; import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher; import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleInterface; @@ -303,7 +310,7 @@ public class AlertRouter /******************************************************************************* ** Every destination for an alert: subscribed users (directly or through a ** team) with their in-app and email channels (or the subscription's - ** chosen channels), and subscribed channels. + ** chosen channels), subscribed channels, and readable record followers. *******************************************************************************/ List destinations(AlertTarget target, AlertSeverity severity, DeliveryKind kind) throws QException { @@ -353,8 +360,24 @@ public class AlertRouter } } } - // TODO STAND-IN(core=QRun-IO/qqq#1164, removeBy=4.1.0-RC.7): merge record followers - // from RecordFollowersProviderInterface here, before the existing visibility/suppression checks. + for(String recordKey : target.recordKeys()) + { + String[] parts = recordKey.split(":", 2); + RecordReference reference = new RecordReference(parts[0], parts[1]); + for(RecordFollower follower : RecordFollowers.get(runtime.getQInstance(), reference, FollowEvent.ALERT)) + { + String userId = follower == null ? null : follower.getUserId(); + if(!StringUtils.hasContent(userId) || !canReadRecord(userId, reference)) + { + continue; + } + QRecord preference = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", userId)); + for(QRecord channel : userChannels(null, preference)) + { + destinations.putIfAbsent("user:" + userId + ":" + channel.getValue("id"), new Destination(userId, channel, null, null, preference)); + } + } + } return (new ArrayList<>(destinations.values())); } @@ -367,7 +390,7 @@ public class AlertRouter private List userChannels(QRecord subscription, QRecord preference) throws QException { List channels = new ArrayList<>(); - String overrides = subscription.getValueString("channelOverrideIds"); + String overrides = subscription == null ? null : subscription.getValueString("channelOverrideIds"); if(StringUtils.hasContent(overrides)) { for(String id : overrides.split("[,\\s]+")) @@ -518,6 +541,39 @@ public class AlertRouter + /******************************************************************************* + ** Followers must be able to read the source record, including its record locks. + ** Reuse recipient sessions; unknown access fails closed before adding channels. + *******************************************************************************/ + private boolean canReadRecord(String userId, RecordReference reference) + { + try + { + QSession session = automatedSession(userId); + if(session == null || !CollectionUtils.nullSafeHasContents(session.getPermissions())) + { + return (false); + } + QInstance instance = runtime.getQInstance(); + AlertsSecurity.applyToSession(instance, session); + boolean[] readable = new boolean[1]; + QContext.withTemporaryContext(new CapturedContext(instance, session), () -> + { + String primaryKey = instance.getTable(reference.tableName()).getPrimaryKeyField(); + readable[0] = PermissionsHelper.hasTablePermission(new AbstractActionInput(), reference.tableName(), TablePermissionSubType.READ) + && AlertsDb.first(reference.tableName(), AlertsDb.eq(primaryKey, reference.primaryKey())) != null; + }); + return (readable[0]); + } + catch(Exception e) + { + LOG.debug("Could not verify follower source-record access", e, logPair("userId", userId)); + return (false); + } + } + + + /******************************************************************************* ** A session for a user, from the instance's authentication module (null ** when it can't make one). diff --git a/src/test/java/com/kingsrook/qbits/alerts/delivery/ExternalChannelsTest.java b/src/test/java/com/kingsrook/qbits/alerts/delivery/ExternalChannelsTest.java index 19db70b..2544265 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/delivery/ExternalChannelsTest.java +++ b/src/test/java/com/kingsrook/qbits/alerts/delivery/ExternalChannelsTest.java @@ -237,9 +237,9 @@ class ExternalChannelsTest List resolvedBody = JsonUtils.toObject(received.get(1).body(), List.class); assertThat(((Map) resolvedBody.get(0)).get("endsAt")).isEqualTo("2026-10-12T11:00:00Z"); - ////////////////////////////////////////////////////// - // a test message is one stand-in alert, already over // - ////////////////////////////////////////////////////// + //////////////////////////////////////////////////// + // a test message is one sample alert, already over // + //////////////////////////////////////////////////// alertmanager.deliver(new AlertMessage("TEST: hello", "t", "h", "INFO", "TEST", List.of(), "https://x/app/alerts_alert"), channel, null, secrets); assertThat(received.get(2).body()).contains("alerts.test"); } diff --git a/src/test/java/com/kingsrook/qbits/alerts/engine/FollowerFanoutTest.java b/src/test/java/com/kingsrook/qbits/alerts/engine/FollowerFanoutTest.java new file mode 100644 index 0000000..6e89048 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/alerts/engine/FollowerFanoutTest.java @@ -0,0 +1,272 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.alerts.engine; + + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qbits.alerts.AlertsSecurity; +import com.kingsrook.qbits.alerts.BaseAlertsTest; +import com.kingsrook.qbits.alerts.model.AlertsDelivery; +import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient; +import com.kingsrook.qbits.alerts.model.AlertsSubscription; +import com.kingsrook.qbits.alerts.model.AlertsUserPreference; +import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFollowersProviderInterface; +import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef; +import com.kingsrook.qqq.backend.core.alerts.AlertSeverity; +import com.kingsrook.qqq.backend.core.alerts.AlertSignal; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.collaboration.FollowEvent; +import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature; +import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFollower; +import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReference; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Core followers enter the normal routing pipeline without a collaboration qbit. + *******************************************************************************/ +class FollowerFanoutTest extends BaseAlertsTest +{ + /******************************************************************************* + ** Keep provider state isolated between tests; absence is the default. + *******************************************************************************/ + @BeforeEach + void resetProvider() + { + Provider.followers = List.of(); + Provider.references.clear(); + Provider.failure = false; + } + + + + /******************************************************************************* + ** Register the optional core SPI in instance metadata, with FOLLOW enabled. + *******************************************************************************/ + private void followers(String... userIds) + { + Provider.followers = java.util.Arrays.stream(userIds).map(id -> new RecordFollower().withUserId(id).withNotifyOnChange(true)).toList(); + RecordFeaturesMetaData.ofOrWithNew(qInstance).withEnabled(RecordFeature.FOLLOW, true) + .withFollowersProvider(new QCodeReference(Provider.class)); + } + + + + /******************************************************************************* + ** A normal subscriber must retain their destination when also following. + *******************************************************************************/ + private void subscribe(String userId) throws QException + { + runtime.asSystem(() -> AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord() + .withValue("subscriberType", "USER").withValue("userId", userId) + .withValue("scopeType", "ALL").withValue("minSeverity", "WARNING").withValue("enabled", true))); + } + + + + /******************************************************************************* + ** Use a real source row, without a subject-table visibility lock on the alert. + *******************************************************************************/ + private void raise(Integer... widgetIds) throws QException + { + AlertSignal signal = new AlertSignal("test.followers").withTitle("Widget needs attention").withSeverity(AlertSeverity.ERROR) + .withDedupKey(java.util.Arrays.toString(widgetIds)); + for(Integer widgetId : widgetIds) + { + signal.withRecordRef(AlertRecordRef.of(WIDGET, widgetId)); + } + runtime.getEngine().raise(signal); + } + + + + /******************************************************************************* + ** Followers from multiple refs and an existing subscription receive only once. + *******************************************************************************/ + @Test + void followersJoinSubscribersOnce() throws Exception + { + List widgets = insertWidgets("BROKEN", "BROKEN"); + followers(VIEWER, "viewer-2", VIEWER); + subscribe(VIEWER); + raise(widgets.toArray(Integer[]::new)); + + assertThat(Provider.references).containsExactly(new RecordReference(WIDGET, widgets.get(0).toString()), new RecordReference(WIDGET, widgets.get(1).toString())); + assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).extracting(row -> row.getValueString("userId")).containsExactlyInAnyOrder(VIEWER, "viewer-2"); + assertThat(all(AlertsDelivery.TABLE_NAME)).hasSize(2).allSatisfy(row -> assertThat(row.getValueString("status")).isEqualTo("SENT")); + assertThat(where(AlertsDelivery.TABLE_NAME, AlertsDb.eq("recipientUserId", VIEWER))).singleElement() + .satisfies(row -> assertThat(row.getValue("subscriptionId")).isNotNull()); + assertThat(QContext.getQSession().getUser().getIdReference()).isEqualTo(ADMIN); + } + + + + /******************************************************************************* + ** Table access and a known recipient session are required, even for the inbox. + *******************************************************************************/ + @Test + void inaccessibleAndUnknownFollowersAreExcluded() throws Exception + { + followers(VIEWER, OUTSIDER, "unknown-user", ""); + raise(insertWidgets("BROKEN").get(0)); + assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).extracting(row -> row.getValueString("userId")).containsExactly(VIEWER); + assertThat(all(AlertsDelivery.TABLE_NAME)).extracting(row -> row.getValueString("recipientUserId")).containsExactly(VIEWER); + } + + + + /******************************************************************************* + ** Table permission alone is insufficient when the source has record locks. + *******************************************************************************/ + @Test + void sourceRecordLocksAreEnforced() throws Exception + { + Integer widget = insertWidgets("BROKEN").get(0); + runtime.asSystem(() -> AlertsDb.update(WIDGET, new QRecord().withValue("id", widget).withValue("name", VIEWER))); + qInstance.getTable(WIDGET).withRecordSecurityLock(AlertsSecurity.ownerLock("name")); + followers(VIEWER, "viewer-2"); + raise(widget); + assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).extracting(row -> row.getValueString("userId")).containsExactly(VIEWER); + } + + + + /******************************************************************************* + ** No provider preserves subscription behavior, even with a source record. + *******************************************************************************/ + @Test + void noProviderPreservesSubscribers() throws Exception + { + subscribe(VIEWER); + raise(insertWidgets("BROKEN").get(0)); + assertThat(Provider.references).isEmpty(); + assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).extracting(row -> row.getValueString("userId")).containsExactly(VIEWER); + } + + + + /******************************************************************************* + ** Recordless alerts never ask for followers. + *******************************************************************************/ + @Test + void noRecordDoesNotFanOut() throws Exception + { + followers(VIEWER); + raise(); + assertThat(Provider.references).isEmpty(); + assertThat(all(AlertsDelivery.TABLE_NAME)).isEmpty(); + } + + + + /******************************************************************************* + ** A disabled core FOLLOW feature leaves existing recipients intact. + *******************************************************************************/ + @Test + void disabledFollowPreservesSubscribers() throws Exception + { + followers("viewer-2"); + subscribe(VIEWER); + Integer widget = insertWidgets("BROKEN").get(0); + RecordFeaturesMetaData.ofOrWithNew(qInstance.getTable(WIDGET)).withEnabled(RecordFeature.FOLLOW, false); + raise(widget); + assertThat(Provider.references).isEmpty(); + assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).extracting(row -> row.getValueString("userId")).containsExactly(VIEWER); + } + + + + /******************************************************************************* + ** Core isolates provider failures so normal subscribers still receive the alert. + *******************************************************************************/ + @Test + void providerFailurePreservesSubscribers() throws Exception + { + followers("viewer-2"); + subscribe(VIEWER); + Provider.failure = true; + raise(insertWidgets("BROKEN").get(0)); + assertThat(Provider.references).hasSize(1); + assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).extracting(row -> row.getValueString("userId")).containsExactly(VIEWER); + } + + + + /******************************************************************************* + ** Followers use ordinary in-app/email preferences and vacation suppression. + *******************************************************************************/ + @Test + void followerPreferencesAndPauseApply() throws Exception + { + followers(VIEWER, "viewer-2", "viewer-3"); + runtime.asSystem(() -> + { + AlertsDb.insert(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("userId", VIEWER).withValue("inAppEnabled", false).withValue("emailEnabled", false)); + AlertsDb.insert(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("userId", "viewer-2").withValue("inAppEnabled", true) + .withValue("emailEnabled", true).withValue("emailAddress", "follower@example.com").withValue("pausedUntil", clock.instant().plus(Duration.ofDays(1)))); + AlertsDb.insert(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("userId", "viewer-3").withValue("inAppEnabled", false) + .withValue("emailEnabled", true).withValue("emailAddress", "other@example.com")); + return (null); + }); + raise(insertWidgets("BROKEN").get(0)); + assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).extracting(row -> row.getValueString("userId")).containsExactly("viewer-2"); + assertThat(all(AlertsDelivery.TABLE_NAME)).hasSize(3); + assertThat(where(AlertsDelivery.TABLE_NAME, AlertsDb.eq("suppressedReason", "USER_PAUSED"))).singleElement() + .satisfies(row -> assertThat(row.getValueString("recipientUserId")).isEqualTo("viewer-2")); + assertThat(where(AlertsDelivery.TABLE_NAME, AlertsDb.eq("recipientUserId", "viewer-3"))).singleElement() + .satisfies(row -> assertThat(row.getValueString("status")).isEqualTo("PENDING")); + } + + + + /******************************************************************************* + ** A core-only provider; the event preference decision belongs to the provider. + *******************************************************************************/ + public static class Provider implements RecordFollowersProviderInterface + { + static List followers = List.of(); + static final List references = new ArrayList<>(); + static boolean failure; + + + + /*************************************************************************** + ** Assert ALERT dispatch and record lookup context at the SPI boundary. + ***************************************************************************/ + @Override + public List getFollowers(QInstance instance, RecordReference reference, FollowEvent event) throws QException + { + assertThat(instance).isSameAs(QContext.getQInstance()); + assertThat(event).isEqualTo(FollowEvent.ALERT); + references.add(reference); + if(failure) + { + throw (new QException("Test provider unavailable")); + } + return (followers); + } + } +} -- 2.54.0