From 1ddbf470dea635aeb7ac30e6f54b214ea42a3a66 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 19:48:26 -0500 Subject: [PATCH 1/2] fix(preferences): real core user-preferences SPI on QQQ 4.1.0-RC.7 RC.1-RC.3 failed to start on RC.7: the reflection adapter (written against the #1162 stand-in) called a withSection signature RC.7 doesn't have. Replace it with SlackPreferencesProvider on the real SPI (section, connections list, Connect, Install, test message and Disconnect actions, read-only store), registered only when the core classes exist. Startup test validates the instance on RC.7. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- pom.xml | 2 +- .../qbits/slack/SlackQBitProducer.java | 23 +- .../slack/preferences/SlackPreferences.java | 15 ++ .../preferences/SlackPreferencesAdapter.java | 66 ------ .../preferences/SlackPreferencesProvider.java | 206 ++++++++++++++++++ .../kingsrook/qbits/slack/SlackAppTest.java | 4 +- .../qbits/slack/SlackPreferencesTest.java | 139 ++++++++++++ .../qbits/slack/SlackStartupTest.java | 49 +++++ 9 files changed, 434 insertions(+), 72 deletions(-) delete mode 100644 src/main/java/com/kingsrook/qbits/slack/preferences/SlackPreferencesAdapter.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/preferences/SlackPreferencesProvider.java create mode 100644 src/test/java/com/kingsrook/qbits/slack/SlackPreferencesTest.java create mode 100644 src/test/java/com/kingsrook/qbits/slack/SlackStartupTest.java diff --git a/README.md b/README.md index 6eb5699..b643836 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Design: [docs/DESIGN.md](docs/DESIGN.md). ## Requirements - Java 21 -- QQQ 4.1 (currently built against 4.1.0-RC.6) +- QQQ 4.1 (currently built against 4.1.0-RC.7) - A Slack app created from [docs/slack-app-manifest.yaml](docs/slack-app-manifest.yaml) ## Install diff --git a/pom.xml b/pom.xml index 965ca3a..1f8f579 100644 --- a/pom.xml +++ b/pom.xml @@ -213,7 +213,7 @@ true - 4.1.0-RC.6 + 4.1.0-RC.7 diff --git a/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java b/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java index c1daabe..a536b28 100644 --- a/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java @@ -150,7 +150,10 @@ public class SlackQBitProducer implements QBitMetaDataProducer com.kingsrook.qbits.slack.delivery.SlackDeliveryDestination.register(); qInstance.addApp(com.kingsrook.qbits.slack.app.SlackApp.app(qInstance.getTables())); com.kingsrook.qbits.slack.app.SlackApp.widgets().forEach(qInstance::addWidget); - com.kingsrook.qbits.slack.preferences.SlackPreferencesAdapter.register(qInstance); + if(isPreferencesAvailable()) + { + com.kingsrook.qbits.slack.preferences.SlackPreferencesProvider.register(qInstance); + } com.kingsrook.qbits.slack.oauth.SlackOAuthRouteProvider.register(qInstance); com.kingsrook.qbits.slack.interactivity.SlackActions.registerBuiltIns(qInstance, getQBitConfig()); try @@ -168,6 +171,24 @@ public class SlackQBitProducer implements QBitMetaDataProducer + /******************************************************************************* + ** Guard only class availability; registration errors must still surface. + *******************************************************************************/ + private static boolean isPreferencesAvailable() + { + try + { + Class.forName("com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData", false, SlackQBitProducer.class.getClassLoader()); + return (true); + } + catch(ClassNotFoundException | LinkageError e) + { + return (false); + } + } + + + /******************************************************************************* ** The config. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/slack/preferences/SlackPreferences.java b/src/main/java/com/kingsrook/qbits/slack/preferences/SlackPreferences.java index 6e21583..133719e 100644 --- a/src/main/java/com/kingsrook/qbits/slack/preferences/SlackPreferences.java +++ b/src/main/java/com/kingsrook/qbits/slack/preferences/SlackPreferences.java @@ -74,6 +74,21 @@ public class SlackPreferences return (new Section("slack", "Slack", true, connections, List.copyOf(actions))); } + /******************************************************************************* + ** Resolve a visible workspace label without loading token fields. + *******************************************************************************/ + public static String workspaceLabel(Integer workspaceId) throws QException + { + List rows = SlackPermissionChecker.workspaceLookup(() -> new QueryAction().execute(new QueryInput(SlackTables.WORKSPACE) + .withFilter(SlackStore.equal("id", workspaceId)).withFieldNamesToInclude(Set.of("teamName", "teamId"))).getRecords()); + if(rows.isEmpty()) + { + return ("Workspace " + workspaceId); + } + String name = rows.get(0).getValueString("teamName"); + return (name == null || name.isBlank() ? rows.get(0).getValueString("teamId") : name); + } + /******************************************************************************* ** Start signed, single-use OAuth for the current user only. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/slack/preferences/SlackPreferencesAdapter.java b/src/main/java/com/kingsrook/qbits/slack/preferences/SlackPreferencesAdapter.java deleted file mode 100644 index 472f5be..0000000 --- a/src/main/java/com/kingsrook/qbits/slack/preferences/SlackPreferencesAdapter.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2025. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ - * - * 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 - * - * https://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.slack.preferences; - -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; - -/******************************************************************************* - ** STAND-IN(core=QRun-IO/qqq#1162, removeBy=4.1.0-RC.8) - ** RC.6 has no preferences SPI. Keep its anticipated registration boundary here, - ** and fail explicitly on an incompatible future SPI rather than silently losing - ** the section. Connections actions remain available on RC.6. - *******************************************************************************/ -public final class SlackPreferencesAdapter -{ - public static final String CORE_TYPE = "com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData"; - - /******************************************************************************* - ** Utility class. - *******************************************************************************/ - private SlackPreferencesAdapter() - { - } - - /******************************************************************************* - ** No linkage to missing core classes in the RC.6 artifact. - *******************************************************************************/ - public static boolean register(QInstance instance) throws QException - { - try - { - Class type = Class.forName(CORE_TYPE); - Object metadata = type.getMethod("ofOrWithNew", QInstance.class).invoke(null, instance); - type.getMethod("withSection", String.class, String.class, QCodeReference.class) - .invoke(metadata, "slack", "Slack", new QCodeReference(SlackPreferences.class)); - return (true); - } - catch(ClassNotFoundException e) - { - return (false); - } - catch(ReflectiveOperationException e) - { - throw (new QException("Update SlackPreferencesAdapter for the installed core preferences SPI.", e)); - } - } -} diff --git a/src/main/java/com/kingsrook/qbits/slack/preferences/SlackPreferencesProvider.java b/src/main/java/com/kingsrook/qbits/slack/preferences/SlackPreferencesProvider.java new file mode 100644 index 0000000..6c146c0 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/preferences/SlackPreferencesProvider.java @@ -0,0 +1,206 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * 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 + * + * https://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.slack.preferences; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import com.kingsrook.qbits.slack.metadata.SlackTables; +import com.kingsrook.qbits.slack.oauth.SlackConnect; +import com.kingsrook.qbits.slack.outbox.SlackSendResult; +import com.kingsrook.qbits.slack.permissions.SlackPermissionChecker; +import com.kingsrook.qbits.slack.permissions.SlackPermissions; +import com.kingsrook.qbits.slack.processes.SlackProcesses; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException; +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.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; +import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionRequest; +import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionResult; +import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionScope; +import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceChange; +import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceListMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceProviderInterface; +import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRequestContext; +import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRow; +import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceSectionMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceValues; +import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData; + +/******************************************************************************* + ** Personal Slack connections through the released core preferences SPI. + *******************************************************************************/ +public class SlackPreferencesProvider implements UserPreferenceProviderInterface +{ + private static final List FIELDS = List.of("workspace", "status", "connectedAt"); + + /******************************************************************************* + ** Register real metadata; configuration errors are intentionally not caught. + *******************************************************************************/ + public static void register(QInstance instance) + { + UserPreferencesMetaData.ofOrWithNew(instance).withSection(new SlackSectionMetaData() + .withName("slack").withLabel("Slack").withIcon(new QIcon("forum")).withShowInUserMenu(true) + .withPermissionName(SlackPermissions.CONNECT).withProvider(new QCodeReference(SlackPreferencesProvider.class)) + .withList(new UserPreferenceListMetaData().withName("connections").withLabel("Connections").withRowLabel("Connection") + .withField(new QFieldMetaData("workspace", QFieldType.STRING).withLabel("Workspace").withIsEditable(false)) + .withField(new QFieldMetaData("status", QFieldType.STRING).withLabel("Status").withIsEditable(false)) + .withField(new QFieldMetaData("connectedAt", QFieldType.DATE_TIME).withLabel("Connected at").withIsEditable(false))) + .withAction(action(SlackProcesses.CONNECT, "Connect Slack", UserPreferenceActionScope.SECTION)) + .withAction(action(SlackProcesses.INSTALL, "Install for my organization", UserPreferenceActionScope.SECTION)) + .withAction(action(SlackProcesses.TEST_MESSAGE, "Send me a test message", UserPreferenceActionScope.ROW)) + .withAction(action(SlackProcesses.DISCONNECT, "Disconnect", UserPreferenceActionScope.ROW) + .withIsDestructive(true).withConfirmationText("Disconnect this Slack connection?"))); + } + + /******************************************************************************* + ** Row actions target only the connections list. + *******************************************************************************/ + private static UserPreferenceActionMetaData action(String name, String label, UserPreferenceActionScope scope) + { + return (new UserPreferenceActionMetaData().withName(name).withLabel(label).withScope(scope) + .withListName(scope == UserPreferenceActionScope.ROW ? "connections" : null)); + } + + /******************************************************************************* + ** RC.7 has no per-action permission property. Filter a fresh view for the + ** current session without mutating shared metadata or other users' actions. + *******************************************************************************/ + public static class SlackSectionMetaData extends UserPreferenceSectionMetaData + { + /******************************************************************************* + ** With no session, expose all registrations for startup validation. + *******************************************************************************/ + @Override + public List getActions() + { + return (QContext.getQSession() == null ? super.getActions() : super.getActions().stream() + .filter(action -> permitted(action.getName())).toList()); + } + } + + /******************************************************************************* + ** Check permissions even for direct provider calls inside core's trusted scope. + *******************************************************************************/ + private static boolean permitted(String action) + { + if(!SlackPermissionChecker.has(SlackPermissions.CONNECT)) + { + return (false); + } + return (switch(action) + { + case SlackProcesses.CONNECT, SlackProcesses.DISCONNECT -> true; + case SlackProcesses.INSTALL -> SlackPermissionChecker.has(SlackPermissions.INSTALL); + case SlackProcesses.TEST_MESSAGE -> SlackPermissionChecker.has(SlackPermissions.SEND); + default -> false; + }); + } + + /******************************************************************************* + ** Owner-scoped services provide only non-secret connection metadata. + *******************************************************************************/ + @Override + public UserPreferenceValues load(UserPreferenceRequestContext context) throws QException + { + SlackPreferences.Section section = new SlackPreferences().section(); + List rows = new ArrayList<>(); + Set rowActions = section.actions().stream() + .filter(name -> Set.of(SlackProcesses.DISCONNECT, SlackProcesses.TEST_MESSAGE).contains(name)) + .collect(Collectors.toSet()); + for(QRecord connection : section.connections()) + { + String workspace = SlackPreferences.workspaceLabel(connection.getValueInteger("workspaceId")); + Map values = new LinkedHashMap<>(); + values.put("workspace", workspace); + values.put("status", connection.getValue("status")); + values.put("connectedAt", connection.getValue("connectedAt")); + rows.add(new UserPreferenceRow().withKey(connection.getValueString("id")).withLabel(workspace).withValues(values) + .withReadOnlyFieldNames(FIELDS).withReadOnlyReason("Use the connection actions to make changes.") + .withActionNames("REVOKED".equals(connection.getValueString("status")) ? Set.of() : rowActions)); + } + UserPreferenceValues values = new UserPreferenceValues(); + values.getLists().put("connections", rows); + return (values); + } + + /******************************************************************************* + ** Even an empty PATCH is rejected: this section has no editable settings. + *******************************************************************************/ + @Override + public void store(UserPreferenceRequestContext context, UserPreferenceChange change) throws QException + { + throw (new QBadRequestException("Slack connections: can't be changed. Use the connection actions.")); + } + + /******************************************************************************* + ** RC.7 has no redirect result; return the authorize URL in its message field. + ** Row services retain ownership checks even after core has checked the row. + *******************************************************************************/ + @Override + public UserPreferenceActionResult runAction(UserPreferenceRequestContext context, UserPreferenceActionRequest request) throws QException + { + String action = request.getActionName(); + if(!permitted(action)) + { + throw (new QBadRequestException("This action isn't available.")); + } + if(SlackProcesses.CONNECT.equals(action) || SlackProcesses.INSTALL.equals(action)) + { + return (new UserPreferenceActionResult().withMessage(SlackPreferences.connect(SlackProcesses.INSTALL.equals(action)))); + } + Integer id; + try + { + id = Integer.valueOf(request.getRowKey()); + } + catch(NumberFormatException e) + { + throw (new QBadRequestException("This action isn't available for this row.")); + } + QRecord connection = SlackConnect.visible(SlackTables.CONNECTION, id); + if(!context.getUserId().equals(connection.getValueString("userId"))) + { + throw (new QBadRequestException("This action isn't available for this row.")); + } + if(SlackProcesses.DISCONNECT.equals(action)) + { + SlackConnect.disconnect(id); + return (new UserPreferenceActionResult().withMessage("Slack disconnected.")); + } + SlackSendResult result = SlackPreferences.testMessage(id); + if(result.status() == SlackSendResult.Status.REFUSED) + { + throw (new QBadRequestException(String.join("; ", result.reasons()))); + } + return (new UserPreferenceActionResult().withMessage("Test message queued.").withFocusRowKey(request.getRowKey())); + } +} diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackAppTest.java b/src/test/java/com/kingsrook/qbits/slack/SlackAppTest.java index 8b08a85..d8cb53e 100644 --- a/src/test/java/com/kingsrook/qbits/slack/SlackAppTest.java +++ b/src/test/java/com/kingsrook/qbits/slack/SlackAppTest.java @@ -32,7 +32,6 @@ import com.kingsrook.qbits.slack.metadata.SlackTables; import com.kingsrook.qbits.slack.permissions.SlackPermissionChecker; import com.kingsrook.qbits.slack.permissions.SlackPermissions; import com.kingsrook.qbits.slack.preferences.SlackPreferences; -import com.kingsrook.qbits.slack.preferences.SlackPreferencesAdapter; import com.kingsrook.qbits.slack.processes.SlackProcesses; import com.kingsrook.qbits.slack.security.SlackSecurity; import com.kingsrook.qbits.slack.support.SlackStore; @@ -297,7 +296,7 @@ class SlackAppTest } /******************************************************************************* - ** RC.6 skips registration; personal section exposes only permitted actions. + ** Personal section exposes only permitted actions. *******************************************************************************/ @Test void preferencesAndConnect() throws Exception @@ -305,7 +304,6 @@ class SlackAppTest try(SlackTestEnv env = SlackTestEnv.create(false, c -> c.withPublicBaseUrl("http://localhost:8099") .withSecretResolver(name -> "SLACK_TOKEN_KEY".equals(name) ? SlackTokenCipherTest.KEY_ONE : "demo-secret"))) { - assertThat(SlackPreferencesAdapter.register(env.instance())).isFalse(); int workspace = env.workspace("T", null, "xoxb-demo", "B"); env.connection("alice", workspace, "UA", null); env.connection("bob", workspace, "UB", null); diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackPreferencesTest.java b/src/test/java/com/kingsrook/qbits/slack/SlackPreferencesTest.java new file mode 100644 index 0000000..860d12c --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/slack/SlackPreferencesTest.java @@ -0,0 +1,139 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * 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 + * + * https://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.slack; + +import java.util.Map; +import java.util.Set; +import com.kingsrook.qbits.slack.metadata.SlackTables; +import com.kingsrook.qbits.slack.permissions.SlackPermissions; +import com.kingsrook.qbits.slack.processes.SlackProcesses; +import com.kingsrook.qbits.slack.security.SlackSecurity; +import com.kingsrook.qbits.slack.support.SlackStore; +import com.kingsrook.qqq.backend.core.actions.preferences.UserPreferencesAction; +import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException; +import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionMetaData; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/******************************************************************************* + ** Exercise the released SPI through the same core action used by the UI API. + *******************************************************************************/ +class SlackPreferencesTest +{ + /******************************************************************************* + ** Even administrators see only their own connections; no tokens are exposed. + *******************************************************************************/ + @Test + void personalReadOnlyConnections() throws Exception + { + try(SlackTestEnv env = SlackTestEnv.create(false, config -> {})) + { + int workspace = env.workspace("T", null, "xoxb-test", "BOT"); + int alice = env.connection("alice", workspace, "UA", "xoxp-test"); + int bob = env.connection("bob", workspace, "UB", null); + var connectedAt = java.time.Instant.parse("2026-10-14T12:00:00Z"); + SlackStore.update(SlackTables.CONNECTION, new QRecord().withValue("id", alice).withValue("connectedAt", connectedAt), null); + var session = env.asOwner("alice").withPermissions(SlackPermissions.CONNECT, SlackPermissions.ADMIN); + SlackSecurity.applyToSession(session, "alice", true); + var action = new UserPreferencesAction(); + var result = action.getSection("slack"); + var rows = result.getValues().getLists().get("connections"); + assertThat(rows).hasSize(1); + assertThat(rows.get(0).getKey()).isEqualTo(String.valueOf(alice)); + assertThat(rows.get(0).getValues()).containsOnlyKeys("workspace", "status", "connectedAt") + .containsEntry("workspace", "Team T").containsEntry("status", "ACTIVE").containsEntry("connectedAt", connectedAt); + assertThat(result.getSection().getLists().get(0).getFields()).allSatisfy(field -> assertThat(field.getIsEditable()).isFalse()); + assertThatThrownBy(() -> action.patchSection("slack", Map.of("lists", Map.of("connections", + java.util.List.of(Map.of("key", String.valueOf(alice), "values", Map.of("status", "REVOKED"))))))) + .isInstanceOf(QBadRequestException.class).hasMessageContaining("can't be changed"); + assertThatThrownBy(() -> action.patchSection("slack", Map.of())).isInstanceOf(QBadRequestException.class).hasMessageContaining("can't be changed"); + for(String name : Set.of(SlackProcesses.DISCONNECT, SlackProcesses.TEST_MESSAGE)) + { + assertThatThrownBy(() -> action.runAction("slack", name, Map.of("rowKey", String.valueOf(bob)))) + .isInstanceOf(QBadRequestException.class).hasMessageContaining("isn't available for this row"); + } + env.asOwner("bob").withPermissions(SlackPermissions.CONNECT); + assertThat(action.getSection("slack").getValues().getLists().get("connections")) + .extracting(row -> row.getKey()).containsExactly(String.valueOf(bob)); + } + } + + /******************************************************************************* + ** Action visibility is session-specific and forbidden actions cannot be invoked. + *******************************************************************************/ + @Test + void permissionFilteredActionsAndAuthorizeUrls() throws Exception + { + try(SlackTestEnv env = SlackTestEnv.create(false, config -> config.withPublicBaseUrl("http://localhost:8099") + .withSecretResolver(name -> "SLACK_TOKEN_KEY".equals(name) ? SlackTokenCipherTest.KEY_ONE : "demo-secret"))) + { + int workspace = env.workspace("T", null, "xoxb-test", "BOT"); + int own = env.connection("alice", workspace, "UA", null); + var session = env.asOwner("alice"); + session.setPermissions(Set.of(SlackPermissions.CONNECT)); + var action = new UserPreferencesAction(); + assertThat(action.getSection("slack").getSection().getActions()).extracting(UserPreferenceActionMetaData::getName) + .containsExactly(SlackProcesses.CONNECT, SlackProcesses.DISCONNECT); + assertThat(action.getSection("slack").getValues().getLists().get("connections").get(0).getActionNames()) + .containsExactly(SlackProcesses.DISCONNECT); + assertThatThrownBy(() -> action.runAction("slack", SlackProcesses.INSTALL, Map.of())).isInstanceOf(QNotFoundException.class); + assertThatThrownBy(() -> action.runAction("slack", SlackProcesses.TEST_MESSAGE, Map.of("rowKey", String.valueOf(own)))) + .isInstanceOf(QNotFoundException.class); + assertThat(action.runAction("slack", SlackProcesses.CONNECT, Map.of()).getMessage()).startsWith("https://slack.com/oauth/v2/authorize?").contains("state="); + session.setPermissions(Set.of(SlackPermissions.CONNECT, SlackPermissions.INSTALL, SlackPermissions.SEND)); + assertThat(action.getSection("slack").getSection().getActions()).extracting(UserPreferenceActionMetaData::getName) + .contains(SlackProcesses.INSTALL, SlackProcesses.TEST_MESSAGE); + assertThat(action.runAction("slack", SlackProcesses.INSTALL, Map.of()).getMessage()).contains("slack.com/oauth/v2/authorize", "scope=chat"); + session.setPermissions(Set.of(SlackPermissions.CONNECT)); + assertThat(action.getSection("slack").getSection().getActions()).extracting(UserPreferenceActionMetaData::getName) + .doesNotContain(SlackProcesses.INSTALL, SlackProcesses.TEST_MESSAGE); + session.setPermissions(Set.of(SlackPermissions.VIEW)); + assertThat(action.listSections()).isEmpty(); + assertThatThrownBy(() -> action.getSection("slack")).isInstanceOf(QNotFoundException.class); + } + } + + /******************************************************************************* + ** Test sends enqueue only; disconnect removes the owner's access and actions. + *******************************************************************************/ + @Test + void rowActions() throws Exception + { + try(SlackTestEnv env = SlackTestEnv.create(false, config -> {})) + { + int workspace = env.workspace("T", null, "xoxb-test", "BOT"); + int own = env.connection("alice", workspace, "UA", null); + env.asOwner("alice").withPermissions(SlackPermissions.CONNECT); + var action = new UserPreferencesAction(); + var body = Map.of("rowKey", String.valueOf(own)); + assertThat(action.runAction("slack", SlackProcesses.TEST_MESSAGE, body).getMessage()).contains("queued"); + assertThat(SlackH2Support.query(SlackTables.MESSAGE)).hasSize(1); + assertThat(env.slack().requests("chat.postMessage")).isEmpty(); + var result = action.runAction("slack", SlackProcesses.DISCONNECT, body); + assertThat(result.getSection().getValues().getLists().get("connections").get(0).getValues()).containsEntry("status", "REVOKED"); + assertThat(result.getSection().getValues().getLists().get("connections").get(0).getActionNames()).isEmpty(); + assertThatThrownBy(() -> action.runAction("slack", SlackProcesses.TEST_MESSAGE, body)).isInstanceOf(QBadRequestException.class); + } + } +} diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackStartupTest.java b/src/test/java/com/kingsrook/qbits/slack/SlackStartupTest.java new file mode 100644 index 0000000..7424bdc --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/slack/SlackStartupTest.java @@ -0,0 +1,49 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * 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 + * + * https://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.slack; + +import com.kingsrook.qqq.backend.core.instances.QInstanceValidator; +import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + +/******************************************************************************* + ** Startup must use the released core preferences SPI. + *******************************************************************************/ +class SlackStartupTest +{ + /******************************************************************************* + ** Produce into a host instance and validate the registered section. + *******************************************************************************/ + @Test + void registersPreferencesOnRc7() throws Exception + { + var instance = SlackH2Support.instance(SlackH2Support.backend(), config -> {}); + new QInstanceValidator().validate(instance); + assertThat(UserPreferencesMetaData.of(instance).getSections()) + .anySatisfy(section -> + { + assertThat(section.getName()).isEqualTo("slack"); + assertThat(section.getPermissionName()).isEqualTo("slack.connect"); + assertThat(section.getShowInUserMenu()).isTrue(); + }); + } +} -- 2.54.0 From 37524048bab819edc0d81390afde9450275c83d0 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 19:48:26 -0500 Subject: [PATCH 2/2] refactor(bridge): core #1194 rich messaging fields on RC.7, drop the stand-in The QBIT_SLACK provider declares its capabilities and returns status, message id and reasons through core SendMessageOutput; idempotency keys and templates come from SendMessageInput. Co-Authored-By: Claude Opus 5.5 --- docs/DESIGN.md | 23 ++-- .../bridge/SlackOutboxMessagingProvider.java | 85 +++++++++------ .../qbits/slack/SlackProviderTest.java | 102 ++++++++++++++---- 3 files changed, 150 insertions(+), 60 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index ee10e11..8bfa621 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -230,14 +230,15 @@ have changed since the message was queued. Then it decrypts **only** the token o There is no addressing by email, and no raw Slack ids without a workspace. `MultiParty` gives one outbox row per recipient. An unknown or unauthorized target is REFUSED with a reason, never an exception. -- **Capabilities** (core #1194): `TEMPLATES`, `IDEMPOTENCY`, `CATEGORIES`, `LINKED_RECORDS`, `ASYNC_DELIVERY` - and `MESSAGE_LOOKUP`. -- **Output:** `QUEUED` with the id, `DUPLICATE` with the earlier id, or `REFUSED` or `SUPPRESSED` with reasons. +- **Capabilities** (core #1194): `TEMPLATES`, `IDEMPOTENCY`, `CATEGORIES`, `LINKED_RECORDS`, `ASYNC_DELIVERY`. +- **Output:** `QUEUED` with the id, `DUPLICATE` with the earlier id, or `REFUSED` with reasons. + For multiple recipients, `messageId` contains the accepted IDs (a list when more than one), and + idempotency keys are scoped by recipient. A partial refusal returns `REFUSED` with accepted IDs; + otherwise the status is `QUEUED` if any row is new, or `DUPLICATE` if all already existed. - **Content:** `subject` becomes the bold first line and the notification text. Content items are mrkdwn, escaped. A Block Kit JSON body needs `slack.send.blocks`. A template wins. -- **Dependency:** QQQ 4.1.0-RC.6 has the plain provider SPI. #1194's rich fields arrive in RC.7. - - Until then the bridge maps the RC.6 fields, and `SlackApi` carries the rest. - - The gap is marked `STAND-IN(core=#1194, removeBy=RC.8)`. +- **Dependency:** QQQ 4.1.0-RC.7. The bridge maps template name, variables, idempotency key, + category, linked record and sender name directly to the outbox. Locale remains advisory and is ignored. ### 4.3 Public API `com.kingsrook.qbits.slack.api` (mirrors `EmailApi`) @@ -529,7 +530,13 @@ qbit-slack's own Preferences section (core #1162) has: - the list of Connections, read-only; - the actions **Connect Slack**, **Disconnect** and **Send me a test message**; -- for tenant admins, **Install for my organization**. +- with `slack.workspace.install`, **Install for my organization**. + +The section requires `slack.connect`, appears in the user menu, and rejects PATCH requests. +Actions are filtered per session; test messages also require `slack.send`. RC.7 has no action-level +permission property, so the section returns a filtered metadata view without mutating its shared +registrations. Connect and Install return their authorize URL in `UserPreferenceActionResult.message`; +RC.7 has no external-link or redirect result field, so this does not automatically navigate the client. Without qbit-alerts, callers use `SendMessageAction` (`slack`, `me` or `user:`) directly. @@ -694,7 +701,7 @@ DDL is `docs/ddl/slack-postgres.sql`. kof22 applies it as a Liquibase changeset. **Phase 1f: app, home and preferences section.** -- Built on core #1162 (RC.7). Until then the work is behind a stand-in. +- Built directly on the core #1162 preferences SPI (RC.7), with a class-availability guard for registration. - Screenshots: home, a message and a connection, light and dark, at 390 and 1440 px. **Phase 1g: qbit-alerts routing (section 10).** A separate qbit-alerts PR. diff --git a/src/main/java/com/kingsrook/qbits/slack/bridge/SlackOutboxMessagingProvider.java b/src/main/java/com/kingsrook/qbits/slack/bridge/SlackOutboxMessagingProvider.java index c024995..306969c 100644 --- a/src/main/java/com/kingsrook/qbits/slack/bridge/SlackOutboxMessagingProvider.java +++ b/src/main/java/com/kingsrook/qbits/slack/bridge/SlackOutboxMessagingProvider.java @@ -21,21 +21,23 @@ package com.kingsrook.qbits.slack.bridge; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; +import java.util.Set; +import java.util.UUID; import com.kingsrook.qbits.slack.outbox.SlackSendRequest; import com.kingsrook.qbits.slack.outbox.SlackSendResult; import com.kingsrook.qbits.slack.outbox.SlackService; import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; -import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.actions.messaging.Content; import com.kingsrook.qqq.backend.core.model.actions.messaging.MultiParty; import com.kingsrook.qqq.backend.core.model.actions.messaging.Party; 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.actions.messaging.SendMessageStatus; +import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderCapability; import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderInterface; -import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; /******************************************************************************* @@ -43,22 +45,15 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; ** producer under the config's bridgeProviderName), so any process sends with ** core SendMessageAction and depends only on core (docs/DESIGN.md 4.2). ** - ** How the QQQ 4.1.0-RC.6 input maps: - ** - to: a Party's address is a Slack address (me, user:<id>, - ** channel:<workspace>/<channel>, channel:<name>, #<name>, - ** group:<name>); a MultiParty gives one outbox row per recipient; - ** - subject: the bold first line of the message; - ** - contentList: the bodies, one per line, as plain text (the service escapes it); - ** - the owner is the current session's user, so an unconnected user, another - ** user's channel or another tenant's workspace is refused. - ** - ** A refusal is a QUserFacingException with the reason. RC.6 has no REFUSED or - ** QUEUED status on its output and no idempotency field, so the status is only - ** logged here. + ** RC.7 rich input maps directly to the outbox request. The owner remains the + ** session user. For multiple recipients, messageId is a list of accepted IDs, + ** and idempotency keys are scoped by recipient. Any refusal yields REFUSED + ** with reasons; accepted recipients remain queued. Otherwise the aggregate is + ** QUEUED if any row is new, or DUPLICATE if all already existed. *******************************************************************************/ public class SlackOutboxMessagingProvider implements MessagingProviderInterface { - private static final QLogger LOG = QLogger.getLogger(SlackOutboxMessagingProvider.class); + @@ -74,49 +69,75 @@ public class SlackOutboxMessagingProvider implements MessagingProviderInterface /******************************************************************************* - ** Queue one outbox row per recipient. Anything refused is reported after the - ** others were queued, as one QUserFacingException. + ** Declare only capabilities implemented by the outbox bridge. + *******************************************************************************/ + @Override + public Set getCapabilities() + { + return (Set.of(MessagingProviderCapability.TEMPLATES, MessagingProviderCapability.IDEMPOTENCY, + MessagingProviderCapability.CATEGORIES, MessagingProviderCapability.LINKED_RECORDS, MessagingProviderCapability.ASYNC_DELIVERY)); + } + + + + /******************************************************************************* + ** Queue one outbox row per recipient and return the core outcome explicitly. + ** Locale is advisory in core and has no Slack outbox equivalent. *******************************************************************************/ @Override public SendMessageOutput sendMessage(SendMessageInput input) throws QException { if(input.getAttachmentList() != null && !input.getAttachmentList().isEmpty()) { - throw (new QUserFacingException("Slack attachments aren't supported yet")); + return (refused("Slack attachments aren't supported yet")); } List addresses = new ArrayList<>(); collect(input.getTo(), addresses); + addresses = addresses.stream().distinct().toList(); if(addresses.isEmpty()) { - throw (new QUserFacingException("A Slack recipient is required")); + return (refused("A Slack recipient is required")); } String text = compose(input); - List refusals = new ArrayList<>(); + ArrayList ids = new ArrayList<>(); + boolean queued = false; for(String address : addresses) { - SlackSendResult result = SlackService.enqueue(new SlackSendRequest().withTo(address).withText(text)); + String key = input.getIdempotencyKey(); + if(key != null && addresses.size() > 1) + { + key += ":" + UUID.nameUUIDFromBytes(address.getBytes(StandardCharsets.UTF_8)); + } + SlackSendResult result = SlackService.enqueue(new SlackSendRequest().withTo(address).withText(text) + .withTemplateName(input.getTemplateName()).withVariables(input.getVariables()).withIdempotencyKey(key) + .withCategory(input.getCategory()).withSenderName(input.getSenderName()) + .withLinkedRecord(input.getLinkedTableName(), input.getLinkedRecordId() == null ? null : input.getLinkedRecordId().toString())); if(result.status() == SlackSendResult.Status.REFUSED) { refusals.addAll(result.reasons()); } else { - /////////////////////////////////////////////////////////////////////////////////// - // STAND-IN(core=QRun-IO/qqq#1194, removeBy=4.1.0-RC.8): RC.6's SendMessageOutput // - // has no status or message id fields, so the outcome is only logged here // - /////////////////////////////////////////////////////////////////////////////////// - LOG.debug("Slack message handed to the outbox", logPair("status", result.status().name()), logPair("messageId", result.messageId())); + ids.add(result.messageId()); + queued |= result.status() == SlackSendResult.Status.QUEUED; } } + return (new SendMessageOutput().withMessageId(ids.isEmpty() ? null : ids.size() == 1 ? ids.get(0) : ids) + .withStatus(!refusals.isEmpty() ? SendMessageStatus.REFUSED : queued ? SendMessageStatus.QUEUED : SendMessageStatus.DUPLICATE) + .withReasons(refusals)); + } - if(!refusals.isEmpty()) - { - throw (new QUserFacingException(String.join("; ", refusals))); - } - return (new SendMessageOutput()); + + + /******************************************************************************* + ** Refusal before any outbox row is created. + *******************************************************************************/ + private static SendMessageOutput refused(String reason) + { + return (new SendMessageOutput().withStatus(SendMessageStatus.REFUSED).withReasons(List.of(reason))); } diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackProviderTest.java b/src/test/java/com/kingsrook/qbits/slack/SlackProviderTest.java index c0b9f8b..1a31f2f 100644 --- a/src/test/java/com/kingsrook/qbits/slack/SlackProviderTest.java +++ b/src/test/java/com/kingsrook/qbits/slack/SlackProviderTest.java @@ -22,33 +22,36 @@ package com.kingsrook.qbits.slack; import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.slack.api.SlackApi; +import com.kingsrook.qbits.slack.api.SlackApiTemplate; import com.kingsrook.qbits.slack.bridge.SlackOutboxMessagingProvider; import com.kingsrook.qbits.slack.bridge.SlackOutboxMessagingProviderMetaData; import com.kingsrook.qbits.slack.metadata.SlackTables; import com.kingsrook.qbits.slack.processes.SlackProcesses; import com.kingsrook.qqq.backend.core.actions.messaging.SendMessageAction; -import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; import com.kingsrook.qqq.backend.core.model.actions.messaging.Attachment; import com.kingsrook.qqq.backend.core.model.actions.messaging.Content; import com.kingsrook.qqq.backend.core.model.actions.messaging.MultiParty; import com.kingsrook.qqq.backend.core.model.actions.messaging.Party; 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.actions.messaging.SendMessageStatus; import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderCapability; import com.kingsrook.qqq.backend.core.modules.messaging.QMessagingProviderDispatcher; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; /******************************************************************************* ** The core messaging provider: core SendMessageAction with the provider named ** "slack" queues rows for the current user, and its refusals reach the caller - ** as user-facing exceptions. The scheduled dispatch process delivers them. + ** as structured outcomes. The scheduled dispatch process delivers them. *******************************************************************************/ class SlackProviderTest { @@ -105,7 +108,8 @@ class SlackProviderTest { env.asOwner("userA").withPermissions("slack.message.body"); SendMessageOutput output = new SendMessageAction().execute(input(new Party().withAddress("me"))); - assertThat(output).isNotNull(); + assertThat(output.getStatus()).isEqualTo(SendMessageStatus.QUEUED); + assertThat(output.getMessageId()).isNotNull(); List rows = SlackH2Support.query(SlackTables.MESSAGE); assertThat(rows).hasSize(1); @@ -136,33 +140,91 @@ class SlackProviderTest /******************************************************************************* - ** A refusal is a user-facing exception with the reason: an unconnected user, - ** a channel the user isn't in, a missing recipient and an attachment. - ** Recipients that can be queued still are. + ** Refusals use RC.7 status and reasons; partially accepted recipients retain IDs. *******************************************************************************/ @Test - void testRefusalsAreUserFacingExceptions() throws Exception + void testRefusalsAreStructured() throws Exception { env.asOwner("userC"); - assertThatThrownBy(() -> new SendMessageAction().execute(input(new Party().withAddress("me")))) - .isInstanceOf(QUserFacingException.class).hasMessageContaining("Connect Slack"); - + assertRefused(input(new Party().withAddress("me")), "Connect Slack"); env.asOwner("userA"); env.slack().members("C2", "UOTHER"); - assertThatThrownBy(() -> new SendMessageAction().execute(input(new Party().withAddress("channel:" + workspaceId + "/C2")))) - .isInstanceOf(QUserFacingException.class).hasMessageContaining("aren't a member"); - assertThatThrownBy(() -> new SendMessageAction().execute(input(null))) - .isInstanceOf(QUserFacingException.class).hasMessageContaining("recipient"); - assertThatThrownBy(() -> new SendMessageAction().execute(input(new Party().withAddress("me")).withAttachment(new Attachment()))) - .isInstanceOf(QUserFacingException.class).hasMessageContaining("attachments"); - - assertThatThrownBy(() -> new SendMessageAction().execute(input(new MultiParty().withParty(new Party().withAddress("me")).withParty(new Party().withAddress("garbage"))))) - .isInstanceOf(QUserFacingException.class).hasMessageContaining("isn't recognized"); + assertRefused(input(new Party().withAddress("channel:" + workspaceId + "/C2")), "aren't a member"); + assertRefused(input(null), "recipient"); + assertRefused(input(new Party().withAddress("me")).withAttachment(new Attachment()), "attachments"); + SendMessageOutput partial = new SendMessageAction().execute(input(new MultiParty().withParty(new Party().withAddress("me")) + .withParty(new Party().withAddress("garbage")))); + assertThat(partial.getStatus()).isEqualTo(SendMessageStatus.REFUSED); + assertThat(partial.getReasons().toString()).contains("isn't recognized"); + assertThat(partial.getMessageId()).isNotNull(); assertThat(SlackH2Support.query(SlackTables.MESSAGE)).hasSize(1); } + /******************************************************************************* + ** Assert both the status and useful reason through core's real action. + *******************************************************************************/ + private void assertRefused(SendMessageInput input, String reason) throws Exception + { + SendMessageOutput result = new SendMessageAction().execute(input); + assertThat(result.getStatus()).isEqualTo(SendMessageStatus.REFUSED); + assertThat(result.getReasons().toString()).contains(reason); + assertThat(result.getMessageId()).isNull(); + } + + + + /******************************************************************************* + ** Rich fields reach the outbox and repeat sends report the original row ID. + *******************************************************************************/ + @Test + void richInputAndDeduplication() throws Exception + { + env.asSystem(); + SlackApi.ensureTemplate(new SlackApiTemplate("hello", "Hello", "Hello {{name}}", null, + "[{\"name\":\"name\",\"type\":\"STRING\"}]", "TRANSACTIONAL")); + env.asOwner("userA").withPermissions("slack.message.body"); + assertThat(SendMessageAction.capabilities("slack")).containsExactlyInAnyOrder(MessagingProviderCapability.TEMPLATES, + MessagingProviderCapability.IDEMPOTENCY, MessagingProviderCapability.CATEGORIES, + MessagingProviderCapability.LINKED_RECORDS, MessagingProviderCapability.ASYNC_DELIVERY); + SendMessageInput input = input(new Party().withAddress("me")).withTemplateName("hello").withVariables(Map.of("name", "Alice")) + .withIdempotencyKey("rich-input").withCategory("TRANSACTIONAL").withSenderName("Reports") + .withLinkedRecord(SlackTables.WORKSPACE, workspaceId).withLocale("en-US"); + SendMessageOutput first = new SendMessageAction().execute(input); + assertThat(first.getStatus()).isEqualTo(SendMessageStatus.QUEUED); + SendMessageOutput second = new SendMessageAction().execute(input); + assertThat(second.getStatus()).isEqualTo(SendMessageStatus.DUPLICATE); + assertThat(second.getMessageId()).isEqualTo(first.getMessageId()); + var rows = SlackH2Support.query(SlackTables.MESSAGE); + assertThat(rows).hasSize(1); + assertThat(rows.get(0).getValues()).containsEntry("templateName", "hello").containsEntry("category", "TRANSACTIONAL") + .containsEntry("senderName", "Reports").containsEntry("linkedTableName", SlackTables.WORKSPACE) + .containsEntry("linkedRecordId", String.valueOf(workspaceId)).containsEntry("text", "Hello Alice"); + } + + + + /******************************************************************************* + ** One key must not collapse multiple recipients onto the first recipient's row. + *******************************************************************************/ + @Test + void multiPartyIdempotency() throws Exception + { + env.asOwner("userA"); + SendMessageInput input = input(new MultiParty().withParty(new Party().withAddress("me")) + .withParty(new Party().withAddress("channel:" + workspaceId + "/C1"))).withIdempotencyKey("multi-input"); + SendMessageOutput first = new SendMessageAction().execute(input); + assertThat(first.getStatus()).isEqualTo(SendMessageStatus.QUEUED); + assertThat((List) first.getMessageId()).hasSize(2); + SendMessageOutput second = new SendMessageAction().execute(input); + assertThat(second.getStatus()).isEqualTo(SendMessageStatus.DUPLICATE); + assertThat(second.getMessageId()).isEqualTo(first.getMessageId()); + assertThat(SlackH2Support.query(SlackTables.MESSAGE)).hasSize(2); + } + + + /******************************************************************************* ** The provider type is registered with core's dispatcher, the producer ** registers the provider under the configured name, and the dispatch process -- 2.54.0