feat(routing): follower fan-out via core RecordFollowers + guardrails (#16) #24
No files matched your search
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user