diff --git a/README.md b/README.md index 410fca1..8b1086f 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ Grant `getNotificationAdminPermissionNames()` to authors. Viewers' permissions i **Upgrading from 0.1.0-RC.1:** changeset 16 renames the delivery log `alerts_notification` to `alerts_delivery`, and changeset 17 drops `alerts_inbox_item` (bell state only). Both are guarded, so a fresh schema and an RC.1 schema both end up the same. -The UI routes are published as the `alerts` extension (`1.0.0`, established prefix `/alerts`), with +The UI routes are published as the `alerts` extension (`1.2.0`, established prefix `/alerts`), with [the OpenAPI fragment](src/main/resources/openapi/qqq-v1/alerts.yaml) and a contract test. See [UI API and webhook migration](docs/UI-API.md) for operation IDs and the complete Next handoff. Summary `inboxHref`, item/alert `actions`, and panel `follow.available` are additive. Existing diff --git a/docs/UI-API.md b/docs/UI-API.md index ffe0fc8..0320120 100644 --- a/docs/UI-API.md +++ b/docs/UI-API.md @@ -1,7 +1,7 @@ # Alerts UI API and webhook migration -The qbit targets QQQ **4.1.0-RC.6** (pending release). `AlertsQBitProducer` registers the -`alerts` `MiddlewareSpecExtension`, version `1.1.0`, with the established `/alerts` prefix. +The qbit targets QQQ **4.1.0-RC.15**, UI API **1.6.0**. `AlertsQBitProducer` registers the +`alerts` `MiddlewareSpecExtension`, version `1.2.0`, with the established `/alerts` prefix. `withRegisterRoutes(false)` opts out of both the extension and webhook registration. The six JSON specs extend `AbstractJsonEndpointSpec`; existing `AlertsApi` logic continues to enforce caller permissions, recipient ownership and alert visibility. @@ -20,11 +20,21 @@ discovers the `alerts` extension so clients need not infer installation from an | GET | `/qqq/v1/alerts/record-panel` | `alertsRecordPanel` | All existing query parameters, selector precedence, paging defaults and clamps, status -codes, JSON content-type requirements, and response keys remain. IDs refer to recipient +codes, JSON content-type requirements, and success response keys remain. IDs refer to recipient rows for inbox operations; legacy `alertId` selectors remain accepted. `all:true` applies only to read/dismiss. Items in a summary omit `body`; page items and banners include it. Nullable fields remain serialized as JSON null. +## UI API extension changes + +UI API extension alerts 1.2.0 (requires QQQ 4.1.0-RC.15, UI API 1.6.0): operationIds, +standard error responses with BasicErrorResponseV1, and extension tags. The six existing +operationIds are preserved. Errors retain their HTTP statuses and now include `error`, +`code`, `message`, and `traceId` (with `fieldErrors` when applicable). `AlertsErrorV1` +remains in components with a deprecation description for compatibility. The core upgrade +also retires the expired Slack shim: capable providers now receive notification +idempotency keys; providers without that capability keep recipient-row deduplication. + ## Next handoff: additive payload fields - Summary adds nullable `preferencesSection`: the user Preferences section name (`notifications`) or null when not registered. Clients can use `/qqq/v1/preferences/{name}` for notification settings. @@ -127,12 +137,15 @@ The supplied Next `main` checkout still has no alerts client; the ## Verification -Use the Maven wrapper with the released QQQ 4.1.0-RC.7 artifacts already installed locally: +Use the Maven wrapper offline against the locally installed migration build: ```sh -./mvnw -o -q verify +./mvnw -o verify -Dqqq.snapshot.version=4.1.99-SNAPSHOT -Denforcer.skipRules=requireReleaseDeps ``` Regenerate intentionally with `-Dtest=AlertsApiContractTest -Dalerts.openapi.update=true test` (with `-o` for an offline build). Normal tests compare the fragment without rewriting it. Checkstyle uses `checkstyle/config.xml` and the repository's existing short Apache headers. + +The Enforcer switch skips only the release-dependency rule for the local SNAPSHOT; +omit it and the version override when building against the released RC.12 BOM. diff --git a/pom.xml b/pom.xml index 86e7a2c..b3506e2 100644 --- a/pom.xml +++ b/pom.xml @@ -260,14 +260,14 @@ - + qqq-snapshot true - 4.1.0-RC.7 + 4.1.0-RC.15 diff --git a/src/main/java/com/kingsrook/qbits/alerts/api/AlertsJsonSpec.java b/src/main/java/com/kingsrook/qbits/alerts/api/AlertsJsonSpec.java index 40c7278..1d267b8 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/api/AlertsJsonSpec.java +++ b/src/main/java/com/kingsrook/qbits/alerts/api/AlertsJsonSpec.java @@ -23,6 +23,7 @@ import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse; import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec; +import com.kingsrook.qqq.middleware.javalin.specs.v1.responses.BasicErrorResponseV1; import com.kingsrook.qqq.openapi.model.Method; import io.javalin.http.Context; import io.javalin.http.HttpStatus; @@ -79,10 +80,10 @@ abstract class AlertsJsonSpec extends AbstractJsonEndpointSpec @Override public List defineAdditionalBasicResponses() { - return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", "AlertsErrorV1"), - new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", "AlertsErrorV1"), - new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", "AlertsErrorV1"), - new BasicResponse(HttpStatus.NOT_FOUND, "Not found", "AlertsErrorV1"))); + return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", BasicErrorResponseV1.class.getSimpleName()), + new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", BasicErrorResponseV1.class.getSimpleName()), + new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", BasicErrorResponseV1.class.getSimpleName()), + new BasicResponse(HttpStatus.NOT_FOUND, "Not found", BasicErrorResponseV1.class.getSimpleName()))); } diff --git a/src/main/java/com/kingsrook/qbits/alerts/api/AlertsSchemas.java b/src/main/java/com/kingsrook/qbits/alerts/api/AlertsSchemas.java index c1b999a..c03eded 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/api/AlertsSchemas.java +++ b/src/main/java/com/kingsrook/qbits/alerts/api/AlertsSchemas.java @@ -300,7 +300,7 @@ public final class AlertsSchemas @Override public Schema toSchema() { - return (new Schema().withType(Type.OBJECT).withDescription("Framework error response.").withProperties(new TreeMap<>(Map.ofEntries( + return (new Schema().withType(Type.OBJECT).withDescription("Deprecated since 1.2.0: errors use BasicErrorResponseV1.").withProperties(new TreeMap<>(Map.ofEntries( Map.entry("error", new Schema().withType(Type.STRING).withNullable(true)))))); } } diff --git a/src/main/java/com/kingsrook/qbits/alerts/api/AlertsUiApi.java b/src/main/java/com/kingsrook/qbits/alerts/api/AlertsUiApi.java index b54b012..455767b 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/api/AlertsUiApi.java +++ b/src/main/java/com/kingsrook/qbits/alerts/api/AlertsUiApi.java @@ -52,7 +52,7 @@ public final class AlertsUiApi public static MiddlewareSpecExtension extension() { return (new MiddlewareSpecExtension().withName("alerts").withPathPrefix("/alerts") - .withSemanticVersion("1.1.0").withDescription("Alerts inbox and record panel") + .withSemanticVersion("1.2.0").withDescription("Alerts inbox and record panel") .withSpec(InboxSummarySpec.class).withSpec(InboxSpec.class) .withSpec(InboxReadSpec.class).withSpec(InboxUnreadSpec.class) .withSpec(InboxDismissSpec.class).withSpec(RecordPanelSpec.class)); diff --git a/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationSlackSender.java b/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationSlackSender.java index 8632579..e320756 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationSlackSender.java +++ b/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationSlackSender.java @@ -47,6 +47,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperat import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderCapability; import com.kingsrook.qqq.backend.core.utils.StringUtils; import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; @@ -232,9 +233,8 @@ public class NotificationSlackSender ** when the provider says the user can't be reached (no Slack connection), ** with no retry; FAILED for anything else. ** - ** STAND-IN(core=QRun-IO/qqq#1194, removeBy=4.1.0-RC.8): RC.6 SendMessageInput has no - ** idempotency key (notify:::slack), so the - ** recipient row's slackStatus is what prevents a resend. + ** Pass the notification idempotency key when the provider supports it. + ** The recipient row's slackStatus also prevents a resend. *******************************************************************************/ private SlackStatus sendExternal(String userId, String subject, List items, Integer notificationId) { @@ -258,6 +258,7 @@ public class NotificationSlackSender { new SendMessageAction().execute(new SendMessageInput() .withMessagingProviderName(runtime.getConfig().getSlackProviderName()) + .withIdempotencyKey(SendMessageAction.capabilities(runtime.getConfig().getSlackProviderName()).contains(MessagingProviderCapability.IDEMPOTENCY) ? key : null) .withTo(new Party().withAddress("user:" + userId)) .withSubject(subject) .withContentList(List.of(new Content().withContentRole(ContentRole.Default.DEFAULT).withBody(text.toString().stripTrailing())))); diff --git a/src/main/resources/openapi/qqq-v1/alerts.yaml b/src/main/resources/openapi/qqq-v1/alerts.yaml index 8b1d190..d250df4 100644 --- a/src/main/resources/openapi/qqq-v1/alerts.yaml +++ b/src/main/resources/openapi/qqq-v1/alerts.yaml @@ -16,7 +16,7 @@ components: type: "string" type: "object" AlertsErrorV1: - description: "Framework error response." + description: "Deprecated since 1.2.0: errors use BasicErrorResponseV1." properties: error: nullable: true @@ -212,17 +212,89 @@ components: type: "integer" type: "array" type: "object" + BasicErrorResponseV1: + description: "The error body of every UI API operation (core and extensions)." + properties: + error: + description: "The error message (the same as message; kept for clients of\ + \ UI API 1.0 to 1.5)." + type: "string" + code: + description: "A stable, machine-readable code. Well-known values: BAD_REQUEST\ + \ (400), UNAUTHENTICATED (401),\nPERMISSION_DENIED (403), NOT_FOUND (404),\ + \ CONFLICT (409), RATE_LIMITED (429), INTERNAL_ERROR (500),\nERROR (other).\ + \ Extensions may add codes; treat an unknown code by the HTTP status.\ + \ Since 1.6.0." + type: "string" + message: + description: "A message to show the user. Since 1.6.0." + type: "string" + fieldErrors: + description: "Errors about particular request fields, when known. Since\ + \ 1.6.0." + items: + properties: + field: + description: "The field (or parameter) name." + type: "string" + message: + description: "The error about that field." + type: "string" + code: + description: "An optional machine-readable code, e.g. REQUIRED, TOO_LONG,\ + \ PATTERN." + type: "string" + type: "object" + type: "array" + traceId: + description: "Identifies this request in the server logs (also the X-QQQ-Trace-Id\ + \ response header); quote it when reporting a problem. Since 1.6.0." + type: "string" + type: "object" securitySchemes: sessionUuidCookie: + description: "The session cookie set by POST /qqq/v1/manageSession (web clients)." in: "cookie" name: "sessionUUID" type: "apiKey" + bearerAuth: + bearerFormat: "JWT or session UUID" + description: "`Authorization: Bearer `, where the token is either\n-\ + \ an OAuth2 access token (a signed JWT) issued by the app's identity provider\ + \ to the app's client,\n or to its native client (`values.nativeClientId`\ + \ in GET /qqq/v1/metaData/authentication), for\n example from the device-authorization\ + \ grant or auth-code + PKCE. It is verified against the\n provider's keys\ + \ (issuer, audience, expiry, signature) and gives the same user and permissions\n\ + \ as signing in on the web. When it expires (401), refresh it at the provider\ + \ and retry; or\n- a session UUID returned by POST /qqq/v1/manageSession,\ + \ which the server keeps refreshed." + scheme: "bearer" + type: "http" + oauth2: + description: "The app's OAuth2 / OpenID Connect provider. The real endpoints\ + \ come from the provider's discovery\ndocument: `{values.baseUrl}.well-known/openid-configuration`,\ + \ with values from\nGET /qqq/v1/metaData/authentication. Native clients use\ + \ `values.nativeClientId` (a public client)\nwith auth-code + PKCE (loopback\ + \ or custom-scheme redirect URI registered at the provider) or the\ndevice-authorization\ + \ grant (`device_authorization_endpoint`, RFC 8628), then send the access\ + \ token\nas `Authorization: Bearer`." + flows: + authorizationCode: + authorizationUrl: "/authorize" + scopes: + email: "Email address" + offline_access: "A refresh token, to renew the access token without signing\ + \ in again" + openid: "OpenID Connect sign-in" + profile: "Name, username and groups (permissions come from the groups)" + tokenUrl: "/token" + type: "oauth2" info: contact: email: "contact@kingsrook.com" description: "Alerts inbox and record panel" title: "QQQ Middleware API extension: alerts" - version: "1.1.0" + version: "1.2.0" openapi: "3.0.3" paths: /qqq/v1/alerts/inbox/summary: @@ -234,25 +306,25 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AlertsErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Invalid input" 401: content: application/json: schema: - $ref: "#/components/schemas/AlertsErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Sign in required" 403: content: application/json: schema: - $ref: "#/components/schemas/AlertsErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Permission denied" 404: content: application/json: schema: - $ref: "#/components/schemas/AlertsErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Not found" 200: content: @@ -260,10 +332,31 @@ paths: schema: $ref: "#/components/schemas/AlertsInboxSummaryV1" description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Conflict with the current state, e.g. an edit conflict or\ + \ an invalid transition (code CONFLICT)." + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Too many requests (code RATE_LIMITED); retry later." security: - sessionUuidCookie: - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" summary: "Read the bell summary" + tags: + - "alerts" /qqq/v1/alerts/inbox: get: description: "Uses the signed-in caller's permissions and record visibility." @@ -304,25 +397,25 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AlertsErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Invalid input" 401: content: application/json: schema: - $ref: "#/components/schemas/AlertsErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Sign in required" 403: content: application/json: schema: - $ref: "#/components/schemas/AlertsErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Permission denied" 404: content: application/json: schema: - $ref: "#/components/schemas/AlertsErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Not found" 200: content: @@ -330,10 +423,31 @@ paths: schema: $ref: "#/components/schemas/AlertsInboxPageV1" description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Conflict with the current state, e.g. an edit conflict or\ + \ an invalid transition (code CONFLICT)." + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Too many requests (code RATE_LIMITED); retry later." security: - sessionUuidCookie: - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" summary: "Page the notification inbox" + tags: + - "alerts" /qqq/v1/alerts/inbox/read: post: description: "Uses the signed-in caller's permissions and record visibility." @@ -349,25 +463,25 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AlertsErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Invalid input" 401: content: application/json: schema: - $ref: "#/components/schemas/AlertsErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Sign in required" 403: content: application/json: schema: - $ref: "#/components/schemas/AlertsErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Permission denied" 404: content: application/json: schema: - $ref: "#/components/schemas/AlertsErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Not found" 200: content: @@ -375,10 +489,31 @@ paths: schema: $ref: "#/components/schemas/AlertsMutationResultV1" description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Conflict with the current state, e.g. an edit conflict or\ + \ an invalid transition (code CONFLICT)." + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Too many requests (code RATE_LIMITED); retry later." security: - sessionUuidCookie: - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" summary: "Mark own notifications read" + tags: + - "alerts" /qqq/v1/alerts/inbox/unread: post: description: "Uses the signed-in caller's permissions and record visibility." @@ -394,25 +529,25 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AlertsErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Invalid input" 401: content: application/json: schema: - $ref: "#/components/schemas/AlertsErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Sign in required" 403: content: application/json: schema: - $ref: "#/components/schemas/AlertsErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Permission denied" 404: content: application/json: schema: - $ref: "#/components/schemas/AlertsErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Not found" 200: content: @@ -420,10 +555,31 @@ paths: schema: $ref: "#/components/schemas/AlertsMutationResultV1" description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Conflict with the current state, e.g. an edit conflict or\ + \ an invalid transition (code CONFLICT)." + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Too many requests (code RATE_LIMITED); retry later." security: - sessionUuidCookie: - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" summary: "Mark own notifications unread" + tags: + - "alerts" /qqq/v1/alerts/inbox/dismiss: post: description: "Uses the signed-in caller's permissions and record visibility." @@ -439,25 +595,25 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AlertsErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Invalid input" 401: content: application/json: schema: - $ref: "#/components/schemas/AlertsErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Sign in required" 403: content: application/json: schema: - $ref: "#/components/schemas/AlertsErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Permission denied" 404: content: application/json: schema: - $ref: "#/components/schemas/AlertsErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Not found" 200: content: @@ -465,10 +621,31 @@ paths: schema: $ref: "#/components/schemas/AlertsMutationResultV1" description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Conflict with the current state, e.g. an edit conflict or\ + \ an invalid transition (code CONFLICT)." + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Too many requests (code RATE_LIMITED); retry later." security: - sessionUuidCookie: - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" summary: "Dismiss own notifications" + tags: + - "alerts" /qqq/v1/alerts/record-panel: get: description: "Uses the signed-in caller's permissions and record visibility." @@ -491,25 +668,25 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AlertsErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Invalid input" 401: content: application/json: schema: - $ref: "#/components/schemas/AlertsErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Sign in required" 403: content: application/json: schema: - $ref: "#/components/schemas/AlertsErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Permission denied" 404: content: application/json: schema: - $ref: "#/components/schemas/AlertsErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Not found" 200: content: @@ -517,7 +694,34 @@ paths: schema: $ref: "#/components/schemas/AlertsRecordPanelV1" description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Conflict with the current state, e.g. an edit conflict or\ + \ an invalid transition (code CONFLICT)." + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Too many requests (code RATE_LIMITED); retry later." security: - sessionUuidCookie: - "N/A" + - bearerAuth: + - "N/A" + - oauth2: + - "openid" + - "profile" + - "email" summary: "Read visible alerts for a record" + tags: + - "alerts" +servers: +- description: "The QQQ server that serves this document (paths include /qqq/v1)." + url: "/" +tags: +- description: "Operations of the alerts extension." + name: "alerts" diff --git a/src/test/java/com/kingsrook/qbits/alerts/AlertsApiContractTest.java b/src/test/java/com/kingsrook/qbits/alerts/AlertsApiContractTest.java index 9821507..e17ba6f 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/AlertsApiContractTest.java +++ b/src/test/java/com/kingsrook/qbits/alerts/AlertsApiContractTest.java @@ -51,12 +51,12 @@ class AlertsApiContractTest extends BaseAlertsTest assertThat(UiApiContract.check(generated, Files.readString(path), null, 1, "mvnw.cmd -Dtest=AlertsApiContractTest -Dalerts.openapi.update=true test")).isEmpty(); var model = version.generateExtensionOpenAPIModel("qqq", extension); - assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("Alerts")); + assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("Alerts") || name.equals("BasicErrorResponseV1")); assertThat(model.getPaths().keySet()).containsExactlyInAnyOrder( "/qqq/v1/alerts/inbox/summary", "/qqq/v1/alerts/inbox", "/qqq/v1/alerts/inbox/read", "/qqq/v1/alerts/inbox/unread", "/qqq/v1/alerts/inbox/dismiss", "/qqq/v1/alerts/record-panel"); assertThat(extension.getSpecs()).hasSize(6); - assertThat(extension.getSemanticVersion()).isEqualTo("1.1.0"); + assertThat(extension.getSemanticVersion()).isEqualTo("1.2.0"); assertThat(generated).contains("alertsInboxSummary", "alertsInboxRead", "alertsInboxUnread", "alertsInboxDismiss", "alertsRecordPanel", "inboxHref", "preferencesSection", "actions", "follow"); assertThat(generated).doesNotContain("/watch", "/ingest/", "/hooks/"); } diff --git a/src/test/java/com/kingsrook/qbits/alerts/AlertsApiHttpTest.java b/src/test/java/com/kingsrook/qbits/alerts/AlertsApiHttpTest.java index 82747a6..4dd07a4 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/AlertsApiHttpTest.java +++ b/src/test/java/com/kingsrook/qbits/alerts/AlertsApiHttpTest.java @@ -224,7 +224,12 @@ class AlertsApiHttpTest extends BaseAlertsTest assertThat(request("POST", base + "/record-panel/watch", "{}", ADMIN, null).statusCode()).isEqualTo(404); HttpResponse invalidJson = request("POST", base + "/inbox/read", "not json", ADMIN, null); assertThat(invalidJson.statusCode()).isEqualTo(400); - assertThat(invalidJson.body()).contains("The request body is not valid JSON."); + Map error = JsonUtils.toObject(invalidJson.body(), Map.class); + assertThat(error).containsKeys("error", "code", "message", "traceId") + .containsEntry("message", "The request body is not valid JSON."); + assertThat(error.get("error")).isEqualTo(error.get("message")); + assertThat(error.get("code")).isNotNull(); + assertThat((String) error.get("traceId")).isNotBlank(); assertThat(request("POST", base + "/inbox/read", null, ADMIN, null).statusCode()).isEqualTo(400); assertThat(request("GET", base + "/inbox/summary", null, "nobody", null).statusCode()).isEqualTo(403); assertThat(request("GET", base + "/record-panel?tableName=widget&recordId=1", null, OUTSIDER, null).statusCode()).isEqualTo(403); diff --git a/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationSlackTest.java b/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationSlackTest.java index 3d2ad9b..427a223 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationSlackTest.java +++ b/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationSlackTest.java @@ -21,6 +21,7 @@ package com.kingsrook.qbits.alerts.notifications; import java.time.Duration; import java.time.Instant; import java.util.List; +import java.util.Set; import java.util.concurrent.CopyOnWriteArrayList; import com.kingsrook.qbits.alerts.AlertsPreferences; import com.kingsrook.qbits.alerts.AlertsQBitConfig; @@ -42,6 +43,7 @@ import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput; import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageOutput; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.metadata.messaging.QMessagingProviderMetaData; +import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderCapability; import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderInterface; import com.kingsrook.qqq.backend.core.modules.messaging.QMessagingProviderDispatcher; import org.junit.jupiter.api.Test; @@ -57,6 +59,7 @@ class NotificationSlackTest extends BaseAlertsTest static final List SENT = new CopyOnWriteArrayList<>(); static volatile String failureMode = "NONE"; + static boolean idempotency; @@ -76,6 +79,17 @@ class NotificationSlackTest extends BaseAlertsTest + /******************************************************************************* + ** Advertise optional provider deduplication. + *******************************************************************************/ + @Override + public Set getCapabilities() + { + return (idempotency ? Set.of(MessagingProviderCapability.IDEMPOTENCY) : Set.of()); + } + + + /******************************************************************************* ** *******************************************************************************/ @@ -107,6 +121,7 @@ class NotificationSlackTest extends BaseAlertsTest qInstance.addMessagingProvider(new QMessagingProviderMetaData().withName("slack").withType("alerts-slack-capture")); SENT.clear(); failureMode = "NONE"; + idempotency = false; } @@ -187,6 +202,7 @@ class NotificationSlackTest extends BaseAlertsTest assertThat(message.getMessagingProviderName()).isEqualTo("slack"); assertThat(message.getTo().getAddress()).isEqualTo("user:viewer-1"); assertThat(message.getSubject()).isEqualTo("Export failed"); + assertThat(message.getIdempotencyKey()).isNull(); assertThat(text(message)).contains("Export failed").contains("Disk full").contains("https://admin.example.com/app/widget"); }); assertThat(slackStatus("viewer-1")).isEqualTo("SENT"); @@ -200,6 +216,22 @@ class NotificationSlackTest extends BaseAlertsTest + /******************************************************************************* + ** Capable providers receive a stable key for immediate notifications. + *******************************************************************************/ + @Test + void testIdempotencyKey() throws Exception + { + idempotency = true; + slackSettings("viewer-1", "JOB", "IMMEDIATE", "WARNING", null); + notifyJob("Export failed", NotificationSeverity.WARNING, "viewer-1"); + assertThat(new NotificationSlackSender(runtime).run().sent()).isEqualTo(1); + assertThat(SENT).singleElement().satisfies(message -> + assertThat(message.getIdempotencyKey()).matches("notify:[0-9]+:viewer-1:slack")); + } + + + /******************************************************************************* ** A category muted in-app gets no recipient row, so no Slack message. *******************************************************************************/