mono, AtomicInteger subscriptions) {
+ return mono.doOnSubscribe(s -> subscriptions.incrementAndGet());
+ }
+}
diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/model/DatasourceJsCodeDisplayNamePinTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/model/DatasourceJsCodeDisplayNamePinTest.java
new file mode 100644
index 0000000000..999bdf36e9
--- /dev/null
+++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/model/DatasourceJsCodeDisplayNamePinTest.java
@@ -0,0 +1,33 @@
+package org.lowcoder.domain.datasource.model;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.Locale;
+
+import org.junit.jupiter.api.Test;
+import org.lowcoder.sdk.util.LocaleUtils;
+
+/**
+ * Pins the plan section 9 row "Datasource.getDisplayName(JS_CODE_ID) returns the INTERNAL_SERVER_ERROR text; key
+ * JS_CODE_DATASOURCE_NAME in no .properties" (task L3-7). The key is missing from every messages file, so
+ * {@code LocaleUtils.getMessage} falls back to the INTERNAL_SERVER_ERROR text. A fix (adding the key) changes this
+ * test on purpose.
+ */
+class DatasourceJsCodeDisplayNamePinTest {
+
+ private static final String MISSING_KEY = "JS_CODE_DATASOURCE_NAME";
+ private static final String INTERNAL_SERVER_ERROR_KEY = "INTERNAL_SERVER_ERROR";
+
+ @Test
+ void pinsTheJsCodeDatasourceDisplayNameBeingTheInternalServerErrorText() {
+ for (Locale locale : new Locale[] {Locale.ENGLISH, Locale.CHINESE}) {
+ String name = Datasource.getDisplayName(Datasource.JS_CODE_ID, locale);
+ String internalError = LocaleUtils.getMessage(locale, INTERNAL_SERVER_ERROR_KEY);
+ System.out.println("[DatasourceJsCodeDisplayNamePinTest] " + locale + " JS code name=[" + name + "]");
+ assertThat(name).isEqualTo(internalError).isNotBlank();
+ }
+ assertThat(Datasource.getDisplayName(Datasource.JS_CODE_ID, Locale.ENGLISH))
+ .isEqualTo("Oops! Service is busy, please try again later.");
+ System.out.println("[DatasourceJsCodeDisplayNamePinTest] pinned: key " + MISSING_KEY + " is missing");
+ }
+}
diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/model/DatasourceModelLogicTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/model/DatasourceModelLogicTest.java
new file mode 100644
index 0000000000..cd4dc77ccb
--- /dev/null
+++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/model/DatasourceModelLogicTest.java
@@ -0,0 +1,98 @@
+package org.lowcoder.domain.datasource.model;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.util.Locale;
+import java.util.NoSuchElementException;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.lowcoder.sdk.util.LocaleUtils;
+
+/**
+ * Decisions of the datasource model (unit U4a, task L3-7): display names, the legacy predicates, the status default
+ * and the creation-source table. {@code mergeWith} and the shared static datasources are pinned in
+ * DatasourceServiceImplTest (plan section 9 rows) and are not repeated here.
+ */
+class DatasourceModelLogicTest {
+
+ private static final String UNKNOWN_ID = "someDatasourceId";
+ private static final String CHINESE_REST_NAME = "休息查询";
+
+ private static Datasource datasource(String type, DatasourceCreationSource source) {
+ Datasource datasource = new Datasource();
+ datasource.setType(type);
+ datasource.setCreationSource(source.getValue());
+ return datasource;
+ }
+
+ /** Catches: the REST and GraphQL quick datasources swapping their message keys, or the locale being ignored. */
+ @Test
+ void displayNameOfTheQuickDatasourcesComesFromTheirOwnMessageKeyPerLocale() {
+ String restEn = Datasource.getDisplayName(Datasource.QUICK_REST_API_ID, Locale.ENGLISH);
+ String graphQlEn = Datasource.getDisplayName(Datasource.QUICK_GRAPHQL_ID, Locale.ENGLISH);
+ String restZh = Datasource.getDisplayName(Datasource.QUICK_REST_API_ID, Locale.CHINESE);
+ System.out.println("[DatasourceModelLogicTest] display names: rest=" + restEn + " graphql=" + graphQlEn + " restZh=" + restZh);
+
+ assertThat(restEn).isEqualTo(LocaleUtils.getMessage(Locale.ENGLISH, "QUICK_REST_DATASOURCE_NAME")).isEqualTo("REST Query");
+ assertThat(graphQlEn).isEqualTo(LocaleUtils.getMessage(Locale.ENGLISH, "QUICK_GRAPHQL_DATASOURCE_NAME")).isEqualTo("GraphQL Query");
+ assertThat(restZh).isEqualTo(CHINESE_REST_NAME).isNotEqualTo(restEn);
+ }
+
+ /** Catches: an ordinary datasource id being given a quick datasource's display name instead of the empty string. */
+ @Test
+ void displayNameOfAnyOtherIdIsEmpty() {
+ assertThat(Datasource.getDisplayName(UNKNOWN_ID, Locale.ENGLISH)).isEmpty();
+ assertThat(Datasource.getDisplayName(null, Locale.ENGLISH)).isEmpty();
+ System.out.println("[DatasourceModelLogicTest] unknown and null ids render as empty");
+ }
+
+ /** Catches: the two legacy predicates confused, or the creation source not being part of the decision. */
+ @ParameterizedTest(name = "type={0} source={1} -> legacyQuickRest={2} legacyLowcoder={3}")
+ @CsvSource({
+ "restApi,LEGACY_WORKSPACE_PREDEFINED,true,false",
+ "graphql,LEGACY_WORKSPACE_PREDEFINED,false,true",
+ "restApi,USER_CREATED,false,false",
+ "graphql,USER_CREATED,false,false",
+ "restApi,SYSTEM_STATIC,false,false"
+ })
+ void legacyPredicatesNeedBothTheTypeAndTheLegacyCreationSource(String type, DatasourceCreationSource source,
+ boolean legacyQuickRest, boolean legacyLowcoder) {
+ Datasource datasource = datasource(type, source);
+ System.out.println("[DatasourceModelLogicTest] " + type + "/" + source + " -> quickRest="
+ + datasource.isLegacyQuickRestApi() + " lowcoder=" + datasource.isLegacyLowcoderApi());
+
+ assertThat(datasource.isLegacyQuickRestApi()).isEqualTo(legacyQuickRest);
+ assertThat(datasource.isLegacyLowcoderApi()).isEqualTo(legacyLowcoder);
+ }
+
+ /** Catches: a datasource without a stored status being treated as deleted or null. */
+ @Test
+ void statusDefaultsToNormalAndKeepsAnExplicitValue() {
+ Datasource datasource = new Datasource();
+ assertThat(datasource.getDatasourceStatus()).isEqualTo(DatasourceStatus.NORMAL);
+
+ datasource.setDatasourceStatus(DatasourceStatus.DELETED);
+ assertThat(datasource.getDatasourceStatus()).isEqualTo(DatasourceStatus.DELETED);
+ System.out.println("[DatasourceModelLogicTest] status default NORMAL, explicit DELETED kept");
+ }
+
+ /** Catches: a wrong value table. Note {@code fromValue} is an instance method of the enum, not static. */
+ @ParameterizedTest
+ @CsvSource({"0,USER_CREATED", "1,CLONE_FROM_TEMPLATE", "2,LEGACY_WORKSPACE_PREDEFINED", "3,SYSTEM_STATIC"})
+ void creationSourceFromValueRoundTrips(int value, DatasourceCreationSource expected) {
+ DatasourceCreationSource found = DatasourceCreationSource.USER_CREATED.fromValue(value);
+ System.out.println("[DatasourceModelLogicTest] fromValue(" + value + ") = " + found);
+ assertThat(found).isEqualTo(expected);
+ assertThat(found.getValue()).isEqualTo(value);
+ }
+
+ /** Catches: an unknown stored value silently mapping to a default source. */
+ @Test
+ void creationSourceFromAnUnknownValueThrows() {
+ assertThatThrownBy(() -> DatasourceCreationSource.USER_CREATED.fromValue(99))
+ .isInstanceOf(NoSuchElementException.class);
+ }
+}
diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/repository/DatasourcePersistenceContractTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/repository/DatasourcePersistenceContractTest.java
new file mode 100644
index 0000000000..a69a3e7df5
--- /dev/null
+++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/repository/DatasourcePersistenceContractTest.java
@@ -0,0 +1,149 @@
+package org.lowcoder.domain.datasource.repository;
+
+import org.junit.jupiter.api.Test;
+import org.lowcoder.domain.datasource.model.Datasource;
+import org.lowcoder.domain.datasource.model.DatasourceDO;
+import org.lowcoder.domain.datasource.service.JsDatasourceHelper;
+import org.lowcoder.domain.encryption.EncryptionService;
+import org.lowcoder.sdk.contract.BoundarySites;
+import org.lowcoder.sdk.contract.ConfigBinding;
+import org.lowcoder.sdk.contract.GoldenJson;
+import org.lowcoder.sdk.contract.JavaValueWalker;
+import org.lowcoder.sdk.contract.QueryResults;
+import org.lowcoder.sdk.contract.RepresentativeInput;
+import org.lowcoder.sdk.models.DatasourceConnectionConfig;
+import org.lowcoder.sdk.models.JsDatasourceConnectionConfig;
+import org.lowcoder.sdk.plugin.graphql.GraphQLDatasourceConfig;
+import org.lowcoder.sdk.plugin.mysql.MysqlDatasourceConfig;
+import org.lowcoder.sdk.plugin.restapi.RestApiDatasourceConfig;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+import reactor.core.publisher.Mono;
+
+import java.math.BigDecimal;
+import java.time.Duration;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Supplier;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Group {@code persistence-roundtrip} (docs/API_PAYLOAD_TEST_PLAN.md §4.10, task T9.4): before a data source is stored,
+ * {@code DatasourceRepository.save} encrypts its connection config and converts it into the stored {@code Map} as
+ * {@code fromJsonMap(toJson(encryptedConfig))}. The real repository saves one data source per config of
+ * {@link #CONFIGS}, with mocked collaborators, and the {@code Map} handed to the store is pinned: the class of every
+ * value ({@link JavaValueWalker}) and the text the production mapper writes for it, in {@value #REPORT}. The SDK config
+ * classes are bound by their {@code buildFrom} from the forms of {@value #INPUT}, copies of forms of their T8.1
+ * {@code config-binding} input fixtures (the test-jar carries only the contract helpers, not those fixtures); the JS
+ * plugin config holds the §4.6 representative input and a password parameter.
+ *
+ * Key order: the stored map of a bean config (the SDK classes) has its members in the order the production mapper
+ * writes the bean, which the JSON contract leaves out (plan §1.2) and which, for getter-only properties such as
+ * {@code RestApiDatasourceConfig#getAuthType} and {@code #isOauth2InheritFromLogin}, the JVM does not fix (plan §9
+ * O18): the pinned key order of these two flipped between two full builds. So their shape keeps the classes but not
+ * the key order, and their written text is compared with member order only reported ({@code CanonicalJson}). The JS
+ * plugin config is a map, whose stored key order is its own and is pinned.
+ *
+ *
Limits: encryption is a stand-in that marks the text it is given ({@value #ENCRYPTED_PREFIX}), since the real
+ * cipher is random; the plugin configs of the plugin modules are not on this module's classpath, and only the stored map
+ * is pinned, not its reading back ({@code DatasourceMetaInfoService.resolveDetailConfig}).
+ */
+class DatasourcePersistenceContractTest {
+
+ static final String REPORT = "persistence-roundtrip/DatasourceRepository.storedConfigs.json";
+ static final String INPUT = "persistence-roundtrip/DatasourceRepository.configs.input.json";
+ static final String ENCRYPTED_PREFIX = "encrypted:";
+ static final String SHAPE_KEY = "shape";
+ static final String WRITTEN_KEY = "written";
+ static final String CONFIG_CLASS_KEY = "configClass";
+ static final String JS_PLUGIN_TYPE = "contractJsPlugin";
+ static final Duration TIMEOUT = Duration.ofSeconds(20);
+ static final Map> CONFIGS = configs();
+
+ private static final GoldenJson GOLDEN = GoldenJson.forModule();
+
+ @BoundarySites({
+ "lowcoder-domain/src/main/java/org/lowcoder/domain/datasource/repository/DatasourceRepository.java#DatasourceRepository.encryptDataAndConvertToDataObject#toJson#1",
+ "lowcoder-domain/src/main/java/org/lowcoder/domain/datasource/repository/DatasourceRepository.java#DatasourceRepository.encryptDataAndConvertToDataObject#fromJsonMap#1"})
+ @Test
+ void storedConfigsAsPinned() {
+ Map report = new LinkedHashMap<>();
+ CONFIGS.forEach((name, config) -> report.put(name, stored(config.get())));
+ String actual = ConfigBinding.write(report);
+ System.out.println("[DatasourcePersistenceContractTest] " + CONFIGS.size() + " configs\n" + actual);
+ GOLDEN.assertJson(REPORT, actual);
+ }
+
+ /** The detail config map {@code DatasourceRepository.save} hands to the store for {@code config}, or the error. */
+ private static Map stored(DatasourceConnectionConfig config) {
+ DatasourceDORepository store = mock(DatasourceDORepository.class);
+ when(store.save(any(DatasourceDO.class))).thenReturn(Mono.empty());
+ JsDatasourceHelper jsDatasourceHelper = mock(JsDatasourceHelper.class);
+ when(jsDatasourceHelper.fillPluginDefinition(any(Datasource.class))).thenReturn(Mono.empty());
+ EncryptionService encryptionService = mock(EncryptionService.class);
+ when(encryptionService.encryptString(any())).thenAnswer(call -> ENCRYPTED_PREFIX + call.getArgument(0));
+ DatasourceRepository repository = new DatasourceRepository();
+ ReflectionTestUtils.setField(repository, "repository", store);
+ ReflectionTestUtils.setField(repository, "jsDatasourceHelper", jsDatasourceHelper);
+ ReflectionTestUtils.setField(repository, "encryptionService", encryptionService);
+
+ Datasource datasource = new Datasource();
+ datasource.setName("contract");
+ datasource.setDetailConfig(config);
+ Map outcome = new LinkedHashMap<>();
+ outcome.put(CONFIG_CLASS_KEY, config.getClass().getName());
+ try {
+ repository.save(datasource).block(TIMEOUT);
+ ArgumentCaptor saved = ArgumentCaptor.forClass(DatasourceDO.class);
+ verify(store).save(saved.capture());
+ Map detailConfig = saved.getValue().getDetailConfig();
+ Map shape = JavaValueWalker.shape(detailConfig);
+ if (!(config instanceof Map)) {
+ // a bean config's stored map has the members in the mapper's write order (see the class comment)
+ shape.remove(JavaValueWalker.KEY_ORDER_KEY);
+ }
+ outcome.put(SHAPE_KEY, shape);
+ outcome.put(WRITTEN_KEY, QueryResults.written(detailConfig));
+ } catch (RuntimeException e) {
+ outcome.put(QueryResults.ERROR_KEY, ConfigBinding.errorText(e));
+ }
+ return outcome;
+ }
+
+ private static Map> configs() {
+ Map> configs = new LinkedHashMap<>();
+ configs.put("mysql", () -> MysqlDatasourceConfig.buildFrom(form("mysql")));
+ configs.put("restApiBasicAuth", () -> RestApiDatasourceConfig.buildFrom(form("restApiBasicAuth")));
+ configs.put("restApiBearerToken", () -> RestApiDatasourceConfig.buildFrom(form("restApiBearerToken")));
+ configs.put("graphQLBasicAuth", () -> GraphQLDatasourceConfig.buildFrom(form("graphQLBasicAuth")));
+ configs.put("jsPlugin", DatasourcePersistenceContractTest::jsConfig);
+ return configs;
+ }
+
+ /** A JS plugin config: a password parameter of its definition, a dynamic password, and the §4.6 representative input. */
+ private static DatasourceConnectionConfig jsConfig() {
+ JsDatasourceConnectionConfig config = new JsDatasourceConnectionConfig();
+ config.put("apiKey", "s3crét \"quoted\"");
+ config.put("limit", 3_000_000_001L);
+ config.put("ratio", new BigDecimal("1.50"));
+ config.put("representative", RepresentativeInput.map());
+ Map dynamicPassword = new LinkedHashMap<>();
+ dynamicPassword.put("key", "token");
+ dynamicPassword.put("type", "password");
+ config.put("dynamicParamsDef", List.of(dynamicPassword));
+ config.put("dynamicParamsConfig", new LinkedHashMap<>(Map.of("token", "t0ken")));
+ config.setType(JS_PLUGIN_TYPE);
+ config.setDefinition(Map.of("dataSourceConfig", Map.of("params", List.of(Map.of("key", "apiKey", "type", "password")))));
+ return config;
+ }
+
+ /** One input form of {@value #INPUT}, read by the production mapper. */
+ private static Map form(String name) {
+ return ConfigBinding.forms(GOLDEN.read(INPUT)).get(name);
+ }
+}
diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/service/JsDatasourceHelperTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/service/JsDatasourceHelperTest.java
new file mode 100644
index 0000000000..739b44060c
--- /dev/null
+++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/service/JsDatasourceHelperTest.java
@@ -0,0 +1,258 @@
+package org.lowcoder.domain.datasource.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.lowcoder.domain.datasource.model.Datasource;
+import org.lowcoder.domain.plugin.client.DatasourcePluginClient;
+import org.lowcoder.domain.plugin.client.dto.DatasourcePluginDefinition;
+import org.lowcoder.domain.plugin.client.dto.GetPluginDynamicConfigRequestDTO;
+import org.lowcoder.domain.plugin.service.DatasourceMetaInfoService;
+import org.lowcoder.sdk.models.DatasourceConnectionConfig;
+import org.lowcoder.sdk.models.JsDatasourceConnectionConfig;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+/**
+ * {@code JsDatasourceHelper} (unit U6, task L3-6): when the plugin definition is fetched, and where dynamic
+ * datasource-config and query-config results are stored. Mockito client and meta info service, injected by field.
+ */
+class JsDatasourceHelperTest {
+
+ private static final String TYPE = "L3-6-js-plugin";
+
+ private DatasourceMetaInfoService metaInfoService;
+ private DatasourcePluginClient pluginClient;
+ private JsDatasourceHelper helper;
+
+ @BeforeEach
+ void setUp() {
+ metaInfoService = mock(DatasourceMetaInfoService.class);
+ pluginClient = mock(DatasourcePluginClient.class);
+ helper = new JsDatasourceHelper();
+ ReflectionTestUtils.setField(helper, "datasourceMetaInfoService", metaInfoService);
+ ReflectionTestUtils.setField(helper, "datasourcePluginClient", pluginClient);
+ when(metaInfoService.isJsDatasourcePlugin(TYPE)).thenReturn(true);
+ when(metaInfoService.isJavaDatasourcePlugin(TYPE)).thenReturn(false);
+ }
+
+ private static DatasourcePluginDefinition definition(Map entries) {
+ DatasourcePluginDefinition definition = new DatasourcePluginDefinition();
+ definition.putAll(entries);
+ return definition;
+ }
+
+ private static DatasourcePluginDefinition datasourceExtraDynamic() {
+ return definition(Map.of("dataSourceConfig", Map.of("extra", Map.of("type", "dynamic"))));
+ }
+
+ private static DatasourcePluginDefinition queryConfigDynamic() {
+ return definition(Map.of("queryConfig", Map.of("type", "dynamic")));
+ }
+
+ /** A datasource whose definition, config definition and config type are all present: nothing to fetch. */
+ private static Datasource completeDatasource(DatasourcePluginDefinition definition, JsDatasourceConnectionConfig config) {
+ config.setDefinition(definition);
+ config.setType(TYPE);
+ Datasource datasource = Datasource.builder().id("ds").type(TYPE).detailConfig(config).build();
+ datasource.setPluginDefinition(definition);
+ return datasource;
+ }
+
+ // ---------------------------------------------------------------- fillPluginDefinition
+
+ /** Catches a fetch for a Java plugin (:29) or for a config that is not a JS config (:30). */
+ @Test
+ void fillPluginDefinition_javaPluginOrNonJsConfig_makesNoClientCall() {
+ Datasource javaPlugin = Datasource.builder().id("ds").type("L3-6-java").detailConfig(new JsDatasourceConnectionConfig()).build();
+ StepVerifier.create(helper.fillPluginDefinition(javaPlugin)).verifyComplete();
+
+ Datasource otherConfig = Datasource.builder().id("ds").type(TYPE).detailConfig(mock(DatasourceConnectionConfig.class)).build();
+ StepVerifier.create(helper.fillPluginDefinition(otherConfig)).verifyComplete();
+
+ verifyNoInteractions(pluginClient);
+ System.out.println("[JsDatasourceHelperTest] Java plugin / non-JS config -> no plugin definition fetched");
+ }
+
+ /** Catches a repeated plugin-definition fetch (:31): with everything present nothing is fetched. */
+ @Test
+ void fillPluginDefinition_withEverythingPresent_makesNoClientCall() {
+ Datasource datasource = completeDatasource(definition(Map.of("id", "x")), new JsDatasourceConnectionConfig());
+
+ StepVerifier.create(helper.fillPluginDefinition(datasource)).verifyComplete();
+
+ verifyNoInteractions(pluginClient);
+ System.out.println("[JsDatasourceHelperTest] complete datasource -> no fetch");
+ }
+
+ /**
+ * Catches a config left without its definition or type (:31, :35-37): when any one of the datasource's plugin
+ * definition, the config's definition or the config's type is missing, the definition is fetched once and stored
+ * on the datasource and on the config, with the type.
+ */
+ @ParameterizedTest(name = "missing part #{0}")
+ @ValueSource(ints = {0, 1, 2})
+ void fillPluginDefinition_whenAnyPartIsMissing_fetchesOnceAndStoresEverywhere(int missing) {
+ DatasourcePluginDefinition existing = definition(Map.of("id", "existing"));
+ JsDatasourceConnectionConfig config = new JsDatasourceConnectionConfig();
+ Datasource datasource = Datasource.builder().id("ds").type(TYPE).detailConfig(config).build();
+ if (missing != 0) {
+ datasource.setPluginDefinition(existing);
+ }
+ if (missing != 1) {
+ config.setDefinition(existing);
+ }
+ if (missing != 2) {
+ config.setType(TYPE);
+ }
+ DatasourcePluginDefinition fetched = definition(Map.of("id", "fetched"));
+ when(pluginClient.getDatasourcePluginDefinition(TYPE)).thenReturn(Mono.just(fetched));
+
+ StepVerifier.create(helper.fillPluginDefinition(datasource)).verifyComplete();
+
+ verify(pluginClient).getDatasourcePluginDefinition(TYPE);
+ assertThat(datasource.getPluginDefinition()).isSameAs(fetched);
+ assertThat(config.getDefinition()).isSameAs(fetched);
+ assertThat(config.getType()).isEqualTo(TYPE);
+ System.out.println("[JsDatasourceHelperTest] part #" + missing + " missing -> fetched and stored on datasource and config");
+ }
+
+ /** Catches stale or partial data being stored when the client returns nothing. */
+ @Test
+ void fillPluginDefinition_emptyClientResult_storesNothing() {
+ JsDatasourceConnectionConfig config = new JsDatasourceConnectionConfig();
+ Datasource datasource = Datasource.builder().id("ds").type(TYPE).detailConfig(config).build();
+ when(pluginClient.getDatasourcePluginDefinition(TYPE)).thenReturn(Mono.empty());
+
+ StepVerifier.create(helper.fillPluginDefinition(datasource)).verifyComplete();
+
+ assertThat(datasource.getPluginDefinition()).isNull();
+ assertThat(config.getDefinition()).isNull();
+ assertThat(config.getType()).isNull();
+ System.out.println("[JsDatasourceHelperTest] empty client result -> nothing stored");
+ }
+
+ // ---------------------------------------------------------------- processDynamicDatasourceConfigExtra
+
+ /** Catches dynamic calls for Java plugins (:46) and for definitions that are not dynamic (:52). */
+ @Test
+ void processDynamicDatasourceConfigExtra_javaPluginOrNonDynamicDefinition_makesNoDynamicCall() {
+ when(metaInfoService.isJavaDatasourcePlugin("L3-6-java")).thenReturn(true);
+ Datasource javaPlugin = Datasource.builder().id("ds").type("L3-6-java").build();
+ javaPlugin.setPluginDefinition(datasourceExtraDynamic()); // even a dynamic definition must be ignored for Java plugins
+ StepVerifier.create(helper.processDynamicDatasourceConfigExtra(javaPlugin)).verifyComplete();
+
+ Datasource staticDefinition = completeDatasource(definition(Map.of("id", "static")), new JsDatasourceConnectionConfig());
+ StepVerifier.create(helper.processDynamicDatasourceConfigExtra(staticDefinition)).verifyComplete();
+
+ verify(pluginClient, never()).getPluginDynamicConfig(any());
+ verify(pluginClient, never()).getDatasourcePluginDefinition(any());
+ System.out.println("[JsDatasourceHelperTest] Java plugin / static definition -> no dynamic config call");
+ }
+
+ /** Catches the dynamic result stored under the wrong key (:66) or the wrong path or config being sent (:57-58). */
+ @Test
+ void processDynamicDatasourceConfigExtra_dynamicDefinition_storesASingleResultUnderExtra() {
+ JsDatasourceConnectionConfig config = new JsDatasourceConnectionConfig();
+ Datasource datasource = completeDatasource(datasourceExtraDynamic(), config);
+ when(pluginClient.getPluginDynamicConfig(any())).thenReturn(Mono.just(List.of((Object) Map.of("options", "a,b"))));
+
+ StepVerifier.create(helper.processDynamicDatasourceConfigExtra(datasource)).verifyComplete();
+
+ ArgumentCaptor> request = ArgumentCaptor.forClass(List.class);
+ verify(pluginClient).getPluginDynamicConfig(request.capture());
+ assertThat(request.getValue()).hasSize(1);
+ GetPluginDynamicConfigRequestDTO dto = request.getValue().get(0);
+ assertThat(dto.getPluginName()).isEqualTo(TYPE);
+ assertThat(dto.getPath()).isEqualTo("$.dataSourceConfig.extra");
+ assertThat(dto.getDataSourceConfig()).isSameAs(config);
+ assertThat(config.get("extra")).isEqualTo(Map.of("options", "a,b"));
+ System.out.println("[JsDatasourceHelperTest] dynamic datasource config: one result stored under 'extra'");
+ }
+
+ /** Catches ambiguous or empty results being applied (:62): only exactly one result is stored. */
+ @ParameterizedTest(name = "{0} results")
+ @ValueSource(ints = {0, 2})
+ void processDynamicDatasourceConfigExtra_notExactlyOneResult_changesNothing(int results) {
+ JsDatasourceConnectionConfig config = new JsDatasourceConnectionConfig();
+ Datasource datasource = completeDatasource(datasourceExtraDynamic(), config);
+ when(pluginClient.getPluginDynamicConfig(any())).thenReturn(Mono.just(results == 0 ? List.of() : List.of((Object) "one", "two")));
+
+ StepVerifier.create(helper.processDynamicDatasourceConfigExtra(datasource)).verifyComplete();
+
+ assertThat(config).doesNotContainKey("extra");
+ System.out.println("[JsDatasourceHelperTest] " + results + " results -> config unchanged");
+ }
+
+ // ---------------------------------------------------------------- processDynamicQueryConfig
+
+ /** Catches dynamic query config calls for Java plugins (:74) and for definitions that are not dynamic (:80). */
+ @Test
+ void processDynamicQueryConfig_javaPluginOrNonDynamicDefinition_makesNoDynamicCall() {
+ when(metaInfoService.isJavaDatasourcePlugin("L3-6-java")).thenReturn(true);
+ Datasource javaPlugin = Datasource.builder().id("ds").type("L3-6-java").build();
+ javaPlugin.setPluginDefinition(queryConfigDynamic()); // even a dynamic definition must be ignored for Java plugins
+ StepVerifier.create(helper.processDynamicQueryConfig(javaPlugin)).verifyComplete();
+
+ Datasource staticDefinition = completeDatasource(definition(Map.of("id", "static")), new JsDatasourceConnectionConfig());
+ StepVerifier.create(helper.processDynamicQueryConfig(staticDefinition)).verifyComplete();
+
+ verify(pluginClient, never()).getPluginDynamicConfigSafely(any());
+ System.out.println("[JsDatasourceHelperTest] Java plugin / static query config -> no dynamic call");
+ }
+
+ /**
+ * Catches the wrong path (:85), the unsafe client call being used (:88) and the result stored in the wrong place
+ * (:92): the safe variant is used, and a single result replaces "queryConfig" in the plugin definition.
+ */
+ @Test
+ void processDynamicQueryConfig_dynamicDefinition_usesTheSafeClientAndStoresTheResultInTheDefinition() {
+ JsDatasourceConnectionConfig config = new JsDatasourceConnectionConfig();
+ DatasourcePluginDefinition definition = queryConfigDynamic();
+ Datasource datasource = completeDatasource(definition, config);
+ Object resolved = Map.of("type", "static", "fields", List.of("a"));
+ when(pluginClient.getPluginDynamicConfigSafely(any())).thenReturn(Mono.just(List.of(resolved)));
+
+ StepVerifier.create(helper.processDynamicQueryConfig(datasource)).verifyComplete();
+
+ ArgumentCaptor> request = ArgumentCaptor.forClass(List.class);
+ verify(pluginClient).getPluginDynamicConfigSafely(request.capture());
+ verify(pluginClient, never()).getPluginDynamicConfig(any());
+ GetPluginDynamicConfigRequestDTO dto = request.getValue().get(0);
+ assertThat(dto.getPluginName()).isEqualTo(TYPE);
+ assertThat(dto.getPath()).isEqualTo("$.queryConfig");
+ assertThat(dto.getDataSourceConfig()).isSameAs(config);
+ assertThat(definition.get("queryConfig")).isSameAs(resolved);
+ System.out.println("[JsDatasourceHelperTest] dynamic query config: safe client used, result stored in the definition");
+ }
+
+ /** Catches ambiguous or empty results being applied to the definition (:90). */
+ @ParameterizedTest(name = "{0} results")
+ @ValueSource(ints = {0, 2})
+ void processDynamicQueryConfig_notExactlyOneResult_leavesTheDefinitionUnchanged(int results) {
+ DatasourcePluginDefinition definition = queryConfigDynamic();
+ Object before = definition.get("queryConfig");
+ Datasource datasource = completeDatasource(definition, new JsDatasourceConnectionConfig());
+ when(pluginClient.getPluginDynamicConfigSafely(any())).thenReturn(Mono.just(results == 0 ? List.of() : List.of((Object) "one", "two")));
+
+ StepVerifier.create(helper.processDynamicQueryConfig(datasource)).verifyComplete();
+
+ assertThat(definition.get("queryConfig")).isSameAs(before);
+ System.out.println("[JsDatasourceHelperTest] " + results + " results -> definition unchanged");
+ }
+}
diff --git a/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/service/impl/ClientBasedConnectionPoolBehaviourTest.java b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/service/impl/ClientBasedConnectionPoolBehaviourTest.java
new file mode 100644
index 0000000000..6dbbc748bc
--- /dev/null
+++ b/server/api-service/lowcoder-domain/src/test/java/org/lowcoder/domain/datasource/service/impl/ClientBasedConnectionPoolBehaviourTest.java
@@ -0,0 +1,343 @@
+package org.lowcoder.domain.datasource.service.impl;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.ToDoubleFunction;
+import java.util.stream.StreamSupport;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.lowcoder.domain.datasource.model.ClientBasedDatasourceConnectionHolder;
+import org.lowcoder.domain.datasource.model.Datasource;
+import org.lowcoder.domain.datasource.service.impl.ClientBasedConnectionPool.ClientBasedDatasourceCacheKey;
+import org.lowcoder.domain.plugin.DatasourceMetaInfo;
+import org.lowcoder.domain.plugin.service.DatasourceMetaInfoService;
+import org.lowcoder.infra.perf.PerfEvent;
+import org.lowcoder.infra.perf.PerfHelper;
+import org.lowcoder.sdk.exception.BizError;
+import org.lowcoder.sdk.exception.BizException;
+import org.lowcoder.sdk.exception.PluginCommonError;
+import org.lowcoder.sdk.exception.PluginException;
+import org.lowcoder.sdk.models.DatasourceConnectionConfig;
+import org.lowcoder.sdk.plugin.common.DatasourceConnector;
+import org.lowcoder.sdk.plugin.common.sql.HikariPerfWrapper;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import com.google.common.cache.LoadingCache;
+
+import io.micrometer.core.instrument.Tag;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+/**
+ * {@code ClientBasedConnectionPool} (unit U5, task L3-5): the behaviours the existing
+ * {@code org.lowcoder.impl.ClientBasedConnectionPoolTest} does not cover: gauges, error mapping, perf counters,
+ * destruction on update, {@code info}, key equality and two failure behaviours. Mockito connector and
+ * {@link PerfHelper}; datasource ids are unique to this class and the shared static Hikari map is cleaned after every
+ * test (invalidating the cache removes the entries).
+ */
+@SuppressWarnings({"rawtypes", "unchecked"})
+class ClientBasedConnectionPoolBehaviourTest {
+
+ private static final String TYPE_A = "L3-5-cb-type-a";
+ private static final String TYPE_B = "L3-5-cb-type-b";
+ private static final String ID_PREFIX = "L3-5-cb-";
+ private static final Instant V1 = Instant.parse("2026-02-01T00:00:00Z");
+ private static final Instant V2 = V1.plusSeconds(60);
+
+ private DatasourceMetaInfoService metaInfoService;
+ private PerfHelper perf;
+ private DatasourceConnector connector;
+ private ClientBasedConnectionPool pool;
+ private final AtomicInteger sequence = new AtomicInteger();
+
+ @BeforeEach
+ void setUp() {
+ metaInfoService = mock(DatasourceMetaInfoService.class);
+ perf = mock(PerfHelper.class);
+ connector = mock(DatasourceConnector.class);
+ when(metaInfoService.getDatasourceConnector(any())).thenReturn(connector);
+ when(connector.doCreateConnection(any())).thenAnswer(invocation -> Mono.just("connection-" + sequence.incrementAndGet()));
+ when(connector.destroyConnection(any())).thenReturn(Mono.empty());
+ pool = new ClientBasedConnectionPool(metaInfoService, perf);
+ }
+
+ @AfterEach
+ void releaseConnectionsAndCheckTheSharedMap() {
+ cache().invalidateAll();
+ Map shared = hikariMap();
+ assertThat(shared.keySet()).as("Hikari wrappers left behind by this class")
+ .noneMatch(key -> key.id() != null && key.id().startsWith(ID_PREFIX));
+ }
+
+ private LoadingCache cache() {
+ return (LoadingCache) ReflectionTestUtils.getField(pool, "cache");
+ }
+
+ private static Map hikariMap() {
+ return (Map) ReflectionTestUtils.getField(ClientBasedConnectionPool.class, "HIKARI_PERF_WRAPPER_MAP");
+ }
+
+ private Datasource datasource(String idSuffix, String type, Instant version) {
+ Datasource datasource = new Datasource();
+ datasource.setId(ID_PREFIX + idSuffix);
+ datasource.setType(type);
+ datasource.setUpdatedAt(version);
+ datasource.setDetailConfig(mock(DatasourceConnectionConfig.class));
+ return datasource;
+ }
+
+ private ClientBasedDatasourceConnectionHolder connection(Datasource datasource) {
+ return (ClientBasedDatasourceConnectionHolder) pool.getOrCreateConnection(datasource).block(Duration.ofSeconds(5));
+ }
+
+ private static HikariPerfWrapper wrapper(int active, int idle, int waiting, int total) {
+ HikariPerfWrapper wrapper = mock(HikariPerfWrapper.class);
+ when(wrapper.getActiveConnections()).thenReturn(active);
+ when(wrapper.getIdleConnections()).thenReturn(idle);
+ when(wrapper.getWaitingConnections()).thenReturn(waiting);
+ when(wrapper.getTotalConnections()).thenReturn(total);
+ Properties datasourceProperties = new Properties();
+ datasourceProperties.setProperty("jdbcUrl", "jdbc:test");
+ when(wrapper.getDatasourceProperties()).thenReturn(datasourceProperties);
+ when(wrapper.getHealthCheckProperties()).thenReturn(new Properties());
+ return wrapper;
+ }
+
+ private static Map tags(Iterable> tags) {
+ Map map = new HashMap<>();
+ StreamSupport.stream(tags.spliterator(), false).map(Tag.class::cast).forEach(tag -> map.put(tag.getKey(), tag.getValue()));
+ return map;
+ }
+
+ // ---------------------------------------------------------------- init gauges
+
+ /**
+ * Catches gauges registered for the wrong pool type (:67), a size gauge counting other types (:72), and Hikari
+ * gauges reading the wrong counter or type (:82, :85-88): the captured gauge functions are applied to real data.
+ */
+ @Test
+ void init_registersTheGaugesAndTheirFunctionsCountPerType() {
+ DatasourceMetaInfo clientBased = DatasourceMetaInfo.builder().type(TYPE_A).connectionPool(ClientBasedConnectionPool.class).build();
+ DatasourceMetaInfo tokenBased = DatasourceMetaInfo.builder().type(TYPE_B).connectionPool(TokenBasedConnectionPool.class).build();
+ when(metaInfoService.getJavaBasedSupportedDatasourceMetaInfos()).thenReturn(List.of(clientBased, tokenBased));
+
+ pool.init();
+
+ ArgumentCaptor events = ArgumentCaptor.forClass(PerfEvent.class);
+ ArgumentCaptor tagCaptor = ArgumentCaptor.forClass(Iterable.class);
+ ArgumentCaptor