From 975992e6066d52b110ec551518a35b3aafa3a4bd Mon Sep 17 00:00:00 2001 From: liuhy Date: Sun, 26 Jul 2026 20:01:49 -0700 Subject: [PATCH 1/2] [Studio] Harden data source management --- .../studio/settings/DataSourceTestDTO.java | 3 + .../studio/settings/DataSourceVO.java | 4 + .../studio/settings/SettingsController.java | 6 +- .../settings/SettingsControllerTest.java | 51 +++++++++ .../settings/__tests__/DataSourceTab.test.tsx | 102 ++++++++++++++++++ web/src/pages/settings/index.tsx | 21 ++-- 6 files changed, 175 insertions(+), 12 deletions(-) create mode 100644 web/src/pages/settings/__tests__/DataSourceTab.test.tsx diff --git a/server/src/main/java/com/rocketmq/studio/settings/DataSourceTestDTO.java b/server/src/main/java/com/rocketmq/studio/settings/DataSourceTestDTO.java index b17b8dce..dced0eb7 100644 --- a/server/src/main/java/com/rocketmq/studio/settings/DataSourceTestDTO.java +++ b/server/src/main/java/com/rocketmq/studio/settings/DataSourceTestDTO.java @@ -16,6 +16,7 @@ */ package com.rocketmq.studio.settings; +import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -26,7 +27,9 @@ @NoArgsConstructor @AllArgsConstructor public class DataSourceTestDTO { + @NotBlank(message = "url is required") private String url; + @NotBlank(message = "type is required") private String type; private String auth; } diff --git a/server/src/main/java/com/rocketmq/studio/settings/DataSourceVO.java b/server/src/main/java/com/rocketmq/studio/settings/DataSourceVO.java index c2d1164a..45a14d0c 100644 --- a/server/src/main/java/com/rocketmq/studio/settings/DataSourceVO.java +++ b/server/src/main/java/com/rocketmq/studio/settings/DataSourceVO.java @@ -16,6 +16,7 @@ */ package com.rocketmq.studio.settings; +import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -27,8 +28,11 @@ @AllArgsConstructor public class DataSourceVO { private String key; + @NotBlank(message = "name is required") private String name; + @NotBlank(message = "type is required") private String type; + @NotBlank(message = "url is required") private String url; private String auth; private String status; diff --git a/server/src/main/java/com/rocketmq/studio/settings/SettingsController.java b/server/src/main/java/com/rocketmq/studio/settings/SettingsController.java index a84923de..594487ed 100644 --- a/server/src/main/java/com/rocketmq/studio/settings/SettingsController.java +++ b/server/src/main/java/com/rocketmq/studio/settings/SettingsController.java @@ -52,12 +52,12 @@ public Result> listDataSources() { } @PostMapping("/datasources/create") - public Result createDataSource(@RequestBody DataSourceVO dataSource) { + public Result createDataSource(@Valid @RequestBody DataSourceVO dataSource) { return Result.ok(settingsService.createDataSource(dataSource)); } @PostMapping("/datasources/update") - public Result updateDataSource(@RequestBody DataSourceVO dataSource) { + public Result updateDataSource(@Valid @RequestBody DataSourceVO dataSource) { return Result.ok(settingsService.updateDataSource(dataSource)); } @@ -68,7 +68,7 @@ public Result deleteDataSource(@RequestParam String key) { } @PostMapping("/datasources/test") - public Result testDataSource(@RequestBody DataSourceTestDTO request) { + public Result testDataSource(@Valid @RequestBody DataSourceTestDTO request) { return Result.ok(settingsService.testDataSource(request)); } } diff --git a/server/src/test/java/com/rocketmq/studio/settings/SettingsControllerTest.java b/server/src/test/java/com/rocketmq/studio/settings/SettingsControllerTest.java index f82bb1ad..1a90e821 100644 --- a/server/src/test/java/com/rocketmq/studio/settings/SettingsControllerTest.java +++ b/server/src/test/java/com/rocketmq/studio/settings/SettingsControllerTest.java @@ -203,6 +203,23 @@ void createDataSourceShouldReturnCreatedSource() throws Exception { .andExpect(jsonPath("$.data.status", is("connected"))); } + @Test + void createDataSourceShouldRejectMissingUrl() throws Exception { + mockMvc.perform(post("/api/settings/datasources/create") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "name": "New DS", + "type": "prometheus" + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code", is(400))) + .andExpect(jsonPath("$.message", is("url is required"))); + + verifyNoInteractions(settingsService); + } + @Test void updateDataSourceShouldReturnUpdatedSource() throws Exception { DataSourceVO input = DataSourceVO.builder().key("ds-1").name("Updated DS").type("rocketmq") @@ -218,6 +235,24 @@ void updateDataSourceShouldReturnUpdatedSource() throws Exception { .andExpect(jsonPath("$.data.name", is("Updated DS"))); } + @Test + void updateDataSourceShouldRejectMissingName() throws Exception { + mockMvc.perform(post("/api/settings/datasources/update") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "key": "ds-1", + "type": "rocketmq", + "url": "updated:9876" + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code", is(400))) + .andExpect(jsonPath("$.message", is("name is required"))); + + verifyNoInteractions(settingsService); + } + @Test void deleteDataSourceShouldReturnSuccess() throws Exception { doNothing().when(settingsService).deleteDataSource("ds-1"); @@ -250,4 +285,20 @@ void testDataSourceShouldReturnTestResult() throws Exception { .andExpect(jsonPath("$.data.success", is(true))) .andExpect(jsonPath("$.data.message", is("Connection successful"))); } + + @Test + void testDataSourceShouldRejectMissingType() throws Exception { + mockMvc.perform(post("/api/settings/datasources/test") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "url": "localhost:9876" + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code", is(400))) + .andExpect(jsonPath("$.message", is("type is required"))); + + verifyNoInteractions(settingsService); + } } diff --git a/web/src/pages/settings/__tests__/DataSourceTab.test.tsx b/web/src/pages/settings/__tests__/DataSourceTab.test.tsx new file mode 100644 index 00000000..83af6d92 --- /dev/null +++ b/web/src/pages/settings/__tests__/DataSourceTab.test.tsx @@ -0,0 +1,102 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { App } from 'antd'; +import type { DataSource } from '../../../api/settings'; +import { listDataSources, testDataSource } from '../../../api/settings'; +import { DataSourceTab } from '../index'; + +vi.mock('../../../api/settings', () => ({ + createDataSource: vi.fn(), + deleteDataSource: vi.fn(), + getGeneralSettings: vi.fn(), + listDataSources: vi.fn(), + saveGeneralSettings: vi.fn(), + testDataSource: vi.fn(), + updateDataSource: vi.fn(), +})); + +const sources: DataSource[] = [ + { + key: 'prom-prod', + name: 'Prometheus prod', + type: 'Prometheus', + url: 'http://prometheus:9090', + auth: 'None', + status: 'healthy', + }, + { + key: 'thanos-dr', + name: 'Thanos DR', + type: 'Thanos', + url: 'http://thanos:10902', + auth: 'Bearer Token', + status: 'healthy', + }, +]; + +beforeAll(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}); + +describe('DataSourceTab', () => { + beforeEach(() => { + vi.mocked(listDataSources).mockResolvedValue(sources); + }); + + it('shows connection test loading only on the clicked row', async () => { + let resolveTest: (value: { success: boolean; message: string }) => void = () => undefined; + vi.mocked(testDataSource).mockReturnValue( + new Promise((resolve) => { + resolveTest = resolve; + }), + ); + + const user = userEvent.setup(); + render( + + + , + ); + + await screen.findByText('Prometheus prod'); + const buttons = screen.getAllByRole('button', { name: /测试连接/ }); + await user.click(buttons[0]); + + await waitFor(() => { + expect(buttons[0]).toHaveClass('ant-btn-loading'); + expect(buttons[1]).not.toHaveClass('ant-btn-loading'); + }); + + resolveTest({ success: true, message: 'ok' }); + }); +}); diff --git a/web/src/pages/settings/index.tsx b/web/src/pages/settings/index.tsx index 9344eea0..434a03b4 100644 --- a/web/src/pages/settings/index.tsx +++ b/web/src/pages/settings/index.tsx @@ -237,13 +237,13 @@ const GeneralSettingsTab = () => { // ─── Data Source Tab ──────────────────────────────────────────────────────── -const DataSourceTab = () => { +export const DataSourceTab = () => { const [dataSources, setDataSources] = useState([]); const [loading, setLoading] = useState(true); const [modalOpen, setModalOpen] = useState(false); const [editingDataSource, setEditingDataSource] = useState(null); const [dsForm] = Form.useForm(); - const [testing, setTesting] = useState(false); + const [testingKey, setTestingKey] = useState(null); const [submitting, setSubmitting] = useState(false); useEffect(() => { @@ -264,8 +264,11 @@ const DataSourceTab = () => { }; }, []); - const handleTestConnection = async (data: Pick) => { - setTesting(true); + const handleTestConnection = async ( + data: Pick, + key: string, + ) => { + setTestingKey(key); try { const result = await testDataSource(data); if (result.success) message.success(result.message); @@ -273,7 +276,7 @@ const DataSourceTab = () => { } catch { message.error('连接测试失败,请稍后重试'); } finally { - setTesting(false); + setTestingKey(null); } }; @@ -347,8 +350,8 @@ const DataSourceTab = () => { type="link" size="small" icon={} - loading={testing} - onClick={() => void handleTestConnection(record)} + loading={testingKey === record.key} + onClick={() => void handleTestConnection(record, record.key)} > 测试连接 @@ -447,11 +450,11 @@ const DataSourceTab = () => {