feat(routing): follower fan-out via core RecordFollowers + guardrails (#16) #24

Merged
KofTwentyTwo merged 1 commits from feature/arch-follower-fanout into develop 2026-10-14 12:47:53 +00:00
11 changed files with 383 additions and 20 deletions

No files matched your search

+1 -1
View File
@@ -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
+4 -2
View File
@@ -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).
+11 -3
View File
@@ -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
+3 -1
View File
@@ -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).
+24 -3
View File
@@ -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)
+3 -1
View File
@@ -22,12 +22,14 @@
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<jacoco.version>0.8.15</jacoco.version>
<qbit.guardrails.enforce>true</qbit.guardrails.enforce>
<qbit.guardrails.strictUiApiRoutes>true</qbit.guardrails.strictUiApiRoutes>
</properties>
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.0.0</version>
<version>2.1.0</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -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)
{
@@ -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<AlertMessage.Entry> entries(AlertMessage message)
{
@@ -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<Destination> 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<QRecord> userChannels(QRecord subscription, QRecord preference) throws QException
{
List<QRecord> 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).
@@ -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");
}
@@ -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<Integer> 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<RecordFollower> followers = List.of();
static final List<RecordReference> references = new ArrayList<>();
static boolean failure;
/***************************************************************************
** Assert ALERT dispatch and record lookup context at the SPI boundary.
***************************************************************************/
@Override
public List<RecordFollower> 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);
}
}
}