From 8d9e7cb027f72a3924609496b157fb79718af24b Mon Sep 17 00:00:00 2001 From: wh Date: Wed, 15 Apr 2026 10:43:12 +0800 Subject: [PATCH] =?UTF-8?q?=E6=92=A4=E9=94=80=E6=B5=8B=E8=AF=95=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/label/AbstractIntegrationTest.java | 87 ---- .../label/LabelBackendApplicationTests.java | 12 - .../label/blackbox/AbstractBlackBoxTest.java | 413 ------------------ .../blackbox/SwaggerLiveBlackBoxTest.java | 337 -------------- .../integration/AuthIntegrationTest.java | 160 ------- .../integration/ExportIntegrationTest.java | 175 -------- .../ExtractionApprovalIntegrationTest.java | 217 --------- .../integration/MultiTenantIsolationTest.java | 199 --------- .../QaApprovalIntegrationTest.java | 196 --------- .../integration/SourceIntegrationTest.java | 167 ------- .../integration/SysConfigIntegrationTest.java | 184 -------- .../integration/TaskClaimConcurrencyTest.java | 136 ------ .../UserManagementIntegrationTest.java | 177 -------- .../VideoCallbackIdempotencyTest.java | 183 -------- .../com/label/unit/ApplicationConfigTest.java | 55 --- .../com/label/unit/AuthInterceptorTest.java | 154 ------- .../com/label/unit/CompanyServiceTest.java | 73 ---- .../com/label/unit/OpenApiAnnotationTest.java | 100 ----- .../unit/PackageStructureMigrationTest.java | 135 ------ .../java/com/label/unit/StateMachineTest.java | 265 ----------- src/test/resources/.gitkeep | 0 src/test/resources/db/init.sql | 332 -------------- 22 files changed, 3757 deletions(-) delete mode 100644 src/test/java/com/label/AbstractIntegrationTest.java delete mode 100644 src/test/java/com/label/LabelBackendApplicationTests.java delete mode 100644 src/test/java/com/label/blackbox/AbstractBlackBoxTest.java delete mode 100644 src/test/java/com/label/blackbox/SwaggerLiveBlackBoxTest.java delete mode 100644 src/test/java/com/label/integration/AuthIntegrationTest.java delete mode 100644 src/test/java/com/label/integration/ExportIntegrationTest.java delete mode 100644 src/test/java/com/label/integration/ExtractionApprovalIntegrationTest.java delete mode 100644 src/test/java/com/label/integration/MultiTenantIsolationTest.java delete mode 100644 src/test/java/com/label/integration/QaApprovalIntegrationTest.java delete mode 100644 src/test/java/com/label/integration/SourceIntegrationTest.java delete mode 100644 src/test/java/com/label/integration/SysConfigIntegrationTest.java delete mode 100644 src/test/java/com/label/integration/TaskClaimConcurrencyTest.java delete mode 100644 src/test/java/com/label/integration/UserManagementIntegrationTest.java delete mode 100644 src/test/java/com/label/integration/VideoCallbackIdempotencyTest.java delete mode 100644 src/test/java/com/label/unit/ApplicationConfigTest.java delete mode 100644 src/test/java/com/label/unit/AuthInterceptorTest.java delete mode 100644 src/test/java/com/label/unit/CompanyServiceTest.java delete mode 100644 src/test/java/com/label/unit/OpenApiAnnotationTest.java delete mode 100644 src/test/java/com/label/unit/PackageStructureMigrationTest.java delete mode 100644 src/test/java/com/label/unit/StateMachineTest.java delete mode 100644 src/test/resources/.gitkeep delete mode 100644 src/test/resources/db/init.sql diff --git a/src/test/java/com/label/AbstractIntegrationTest.java b/src/test/java/com/label/AbstractIntegrationTest.java deleted file mode 100644 index 8679173..0000000 --- a/src/test/java/com/label/AbstractIntegrationTest.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.label; - -import org.junit.jupiter.api.BeforeEach; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.server.LocalServerPort; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.test.context.DynamicPropertyRegistry; -import org.springframework.test.context.DynamicPropertySource; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.PostgreSQLContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.utility.DockerImageName; -import org.testcontainers.utility.MountableFile; - -/** - * Base class for all integration tests. - * - * Starts real PostgreSQL 16 and Redis 7 containers (shared across test class instances). - * Executes sql/init.sql to initialize schema and seed data. - * - * DESIGN: - * - @Container with static fields → containers are shared across test methods (faster) - * - @DynamicPropertySource → overrides datasource/redis properties at runtime - * - @BeforeEach cleanData() → truncates business tables (not sys_company/sys_user) between tests - */ -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@Testcontainers -public abstract class AbstractIntegrationTest { - - @LocalServerPort - protected int port; - - @Autowired - protected JdbcTemplate jdbcTemplate; - - @SuppressWarnings("resource") - @Container - protected static final PostgreSQLContainer postgres = - new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine")) - .withDatabaseName("label_db") - .withUsername("label") - .withPassword("label_password") - .withCopyFileToContainer( - MountableFile.forClasspathResource("db/init.sql"), - "/docker-entrypoint-initdb.d/init.sql"); - - @SuppressWarnings("resource") - @Container - protected static final GenericContainer redis = - new GenericContainer<>(DockerImageName.parse("redis:7-alpine")) - .withExposedPorts(6379) - .withCommand("redis-server", "--requirepass", "test_redis_password"); - - @DynamicPropertySource - static void configureProperties(DynamicPropertyRegistry registry) { - registry.add("spring.datasource.url", postgres::getJdbcUrl); - registry.add("spring.datasource.username", postgres::getUsername); - registry.add("spring.datasource.password", postgres::getPassword); - registry.add("spring.data.redis.host", redis::getHost); - registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379)); - registry.add("spring.data.redis.password", () -> "test_redis_password"); - } - - /** - * Clean only business data between tests to keep schema intact. - * Keep sys_company and sys_user since init.sql seeds them. - */ - @BeforeEach - void cleanData() { - jdbcTemplate.execute("TRUNCATE TABLE video_process_job, annotation_task_history, " + - "sys_operation_log, sys_config, export_batch, training_dataset, " + - "annotation_result, annotation_task, source_data RESTART IDENTITY CASCADE"); - // Re-insert global sys_config entries that were truncated - jdbcTemplate.execute("INSERT INTO sys_config (company_id, config_key, config_value) VALUES " + - "(NULL, 'token_ttl_seconds', '7200'), " + - "(NULL, 'model_default', 'glm-4'), " + - "(NULL, 'video_frame_interval', '30') " + - "ON CONFLICT DO NOTHING"); - } - - /** Helper: get base URL for REST calls */ - protected String baseUrl(String path) { - return "http://localhost:" + port + path; - } -} diff --git a/src/test/java/com/label/LabelBackendApplicationTests.java b/src/test/java/com/label/LabelBackendApplicationTests.java deleted file mode 100644 index fc7223f..0000000 --- a/src/test/java/com/label/LabelBackendApplicationTests.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.label; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) -class LabelBackendApplicationTests { - - @Test - void contextLoads() { - } -} diff --git a/src/test/java/com/label/blackbox/AbstractBlackBoxTest.java b/src/test/java/com/label/blackbox/AbstractBlackBoxTest.java deleted file mode 100644 index 54cd53f..0000000 --- a/src/test/java/com/label/blackbox/AbstractBlackBoxTest.java +++ /dev/null @@ -1,413 +0,0 @@ -package com.label.blackbox; - -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.TestInfo; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.core.io.ClassPathResource; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.datasource.DriverManagerDataSource; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.beans.factory.config.YamlPropertiesFactoryBean; -import org.springframework.core.io.ByteArrayResource; - -import javax.sql.DataSource; -import java.nio.charset.StandardCharsets; -import java.time.Instant; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.Properties; -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.fail; - -abstract class AbstractBlackBoxTest { - - private static final BCryptPasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder(10); - private static final Properties APPLICATION = loadApplicationProperties(); - - protected final TestRestTemplate restTemplate = new TestRestTemplate(); - protected final ObjectMapper objectMapper = new ObjectMapper(); - - protected JdbcTemplate jdbcTemplate; - protected String baseUrl; - protected String callbackSecret; - - protected String runId; - protected Long companyId; - protected String companyCode; - protected String companyName; - - protected TestUser adminUser; - protected TestUser reviewerUser; - protected TestUser annotatorUser; - protected TestUser annotator2User; - protected TestUser uploaderUser; - - protected String adminToken; - protected String reviewerToken; - protected String annotatorToken; - protected String annotator2Token; - protected String uploaderToken; - - protected boolean roleAwareAuthEnabled; - - @BeforeEach - void setUpBlackBox(TestInfo testInfo) { - this.jdbcTemplate = new JdbcTemplate(createDataSource()); - this.baseUrl = resolveBaseUrl(); - this.callbackSecret = resolved("video.callback-secret", ""); - this.runId = buildRunId(testInfo); - - assertBackendReachable(); - createIsolatedCompanyAndUsers(); - issueTokens(); - detectRuntimeAuthMode(); - } - - @AfterEach - void cleanUpBlackBox() { - if (companyId == null) { - return; - } - - jdbcTemplate.update("DELETE FROM annotation_task_history WHERE company_id = ?", companyId); - jdbcTemplate.update("DELETE FROM sys_operation_log WHERE company_id = ?", companyId); - jdbcTemplate.update("DELETE FROM video_process_job WHERE company_id = ?", companyId); - jdbcTemplate.update("DELETE FROM training_dataset WHERE company_id = ?", companyId); - jdbcTemplate.update("DELETE FROM annotation_result WHERE company_id = ?", companyId); - jdbcTemplate.update("DELETE FROM export_batch WHERE company_id = ?", companyId); - jdbcTemplate.update("DELETE FROM annotation_task WHERE company_id = ?", companyId); - jdbcTemplate.update("DELETE FROM source_data WHERE company_id = ?", companyId); - jdbcTemplate.update("DELETE FROM sys_config WHERE company_id = ?", companyId); - jdbcTemplate.update("DELETE FROM sys_user WHERE company_id = ?", companyId); - jdbcTemplate.update("DELETE FROM sys_company WHERE id = ?", companyId); - } - - protected void requireRoleAwareAuth() { - org.junit.jupiter.api.Assumptions.assumeTrue( - roleAwareAuthEnabled, - "当前运行中的 backend 未启用真实多角色认证,跳过依赖角色/租户隔离的黑盒用例"); - } - - protected ResponseEntity getRaw(String path) { - return restTemplate.getForEntity(url(path), String.class); - } - - protected ResponseEntity get(String path, String token) { - return exchange(path, HttpMethod.GET, null, token, MediaType.APPLICATION_JSON); - } - - protected ResponseEntity delete(String path, String token) { - return exchange(path, HttpMethod.DELETE, null, token, MediaType.APPLICATION_JSON); - } - - protected ResponseEntity postJson(String path, Object body, String token) { - return exchange(path, HttpMethod.POST, body, token, MediaType.APPLICATION_JSON); - } - - protected ResponseEntity putJson(String path, Object body, String token) { - return exchange(path, HttpMethod.PUT, body, token, MediaType.APPLICATION_JSON); - } - - protected ResponseEntity upload(String path, String token, String filename, String dataType, byte[] bytes) { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.MULTIPART_FORM_DATA); - if (token != null && !token.isBlank()) { - headers.setBearerAuth(token); - } - - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("dataType", dataType); - body.add("file", new ByteArrayResource(bytes) { - @Override - public String getFilename() { - return filename; - } - }); - - return restTemplate.exchange(url(path), HttpMethod.POST, new HttpEntity<>(body, headers), Map.class); - } - - protected ResponseEntity postVideoCallback(Map body) { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - if (callbackSecret != null && !callbackSecret.isBlank()) { - headers.set("X-Callback-Secret", callbackSecret); - } - return restTemplate.exchange(url("/api/video/callback"), HttpMethod.POST, new HttpEntity<>(body, headers), Map.class); - } - - protected String login(String targetCompanyCode, String username, String password) { - Map body = Map.of( - "companyCode", targetCompanyCode, - "username", username, - "password", password - ); - ResponseEntity response = postJson("/api/auth/login", body, null); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - @SuppressWarnings("unchecked") - Map data = (Map) Objects.requireNonNull(response.getBody()).get("data"); - return String.valueOf(data.get("token")); - } - - protected Long uploadTextSource(String token) { - ResponseEntity response = upload( - "/api/source/upload", - token, - "bb-" + runId + ".txt", - "TEXT", - ("hello-blackbox-" + runId).getBytes(StandardCharsets.UTF_8)); - assertSuccess(response, HttpStatus.CREATED); - return dataId(response); - } - - protected Long uploadVideoSource(String token) { - ResponseEntity response = upload( - "/api/source/upload", - token, - "bb-" + runId + ".mp4", - "VIDEO", - ("fake-video-" + runId).getBytes(StandardCharsets.UTF_8)); - assertSuccess(response, HttpStatus.CREATED); - return dataId(response); - } - - protected Long createTask(Long sourceId, String taskType) { - ResponseEntity response = postJson("/api/tasks", Map.of( - "sourceId", sourceId, - "taskType", taskType - ), adminToken); - assertSuccess(response, HttpStatus.OK); - return dataId(response); - } - - protected Long latestTaskId(Long sourceId, String taskType) { - return jdbcTemplate.queryForObject( - "SELECT id FROM annotation_task WHERE company_id = ? AND source_id = ? AND task_type = ? " + - "ORDER BY id DESC LIMIT 1", - Long.class, - companyId, sourceId, taskType); - } - - protected Long latestApprovedDatasetId(Long sourceId) { - return jdbcTemplate.queryForObject( - "SELECT id FROM training_dataset WHERE company_id = ? AND source_id = ? AND status = 'APPROVED' " + - "ORDER BY id DESC LIMIT 1", - Long.class, - companyId, sourceId); - } - - protected Long insertFailedVideoJob(Long sourceId) { - return jdbcTemplate.queryForObject( - "INSERT INTO video_process_job (company_id, source_id, job_type, status, params, retry_count, max_retries) " + - "VALUES (?, ?, 'FRAME_EXTRACT', 'FAILED', '{}'::jsonb, 3, 3) RETURNING id", - Long.class, - companyId, sourceId); - } - - protected Long insertPendingVideoJob(Long sourceId) { - return jdbcTemplate.queryForObject( - "INSERT INTO video_process_job (company_id, source_id, job_type, status, params, retry_count, max_retries) " + - "VALUES (?, ?, 'FRAME_EXTRACT', 'PENDING', '{}'::jsonb, 0, 3) RETURNING id", - Long.class, - companyId, sourceId); - } - - protected void assertSuccess(ResponseEntity response, HttpStatus expectedStatus) { - assertThat(response.getStatusCode()).isEqualTo(expectedStatus); - assertThat(response.getBody()).isNotNull(); - assertThat(response.getBody().get("code")).isEqualTo("SUCCESS"); - } - - protected Long dataId(ResponseEntity response) { - @SuppressWarnings("unchecked") - Map data = (Map) Objects.requireNonNull(response.getBody()).get("data"); - return ((Number) data.get("id")).longValue(); - } - - protected boolean responseContainsId(ResponseEntity response, Long id) { - @SuppressWarnings("unchecked") - Map data = (Map) Objects.requireNonNull(response.getBody()).get("data"); - @SuppressWarnings("unchecked") - List> items = (List>) data.get("items"); - return items.stream().anyMatch(item -> id.equals(((Number) item.get("id")).longValue())); - } - - protected Long claimedByOfTask(Long taskId) { - return jdbcTemplate.queryForObject( - "SELECT claimed_by FROM annotation_task WHERE id = ?", - Long.class, - taskId); - } - - protected String url(String path) { - return baseUrl + path; - } - - private String resolveBaseUrl() { - String override = System.getProperty("blackbox.base-url"); - if (override == null || override.isBlank()) { - override = System.getenv("BLACKBOX_BASE_URL"); - } - if (override != null && !override.isBlank()) { - return override.endsWith("/") ? override.substring(0, override.length() - 1) : override; - } - return "http://127.0.0.1:" + resolved("server.port", "8080"); - } - - private ResponseEntity exchange(String path, - HttpMethod method, - Object body, - String token, - MediaType contentType) { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(contentType); - if (token != null && !token.isBlank()) { - headers.setBearerAuth(token); - } - return restTemplate.exchange(url(path), method, new HttpEntity<>(body, headers), Map.class); - } - - private void assertBackendReachable() { - try { - ResponseEntity response = getRaw("/v3/api-docs"); - assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); - } catch (Exception ex) { - fail("无法连接运行中的 backend,请确认服务已按 application.yml 配置启动: " + ex.getMessage()); - } - } - - private void createIsolatedCompanyAndUsers() { - this.companyCode = ("BB" + runId).toUpperCase(Locale.ROOT); - this.companyName = "黑盒测试-" + runId; - this.companyId = jdbcTemplate.queryForObject( - "INSERT INTO sys_company (company_name, company_code, status) VALUES (?, ?, 'ACTIVE') RETURNING id", - Long.class, - companyName, companyCode); - - this.adminUser = insertUser("admin", "ADMIN"); - this.reviewerUser = insertUser("reviewer", "REVIEWER"); - this.annotatorUser = insertUser("annotator", "ANNOTATOR"); - this.annotator2User = insertUser("annotator2", "ANNOTATOR"); - this.uploaderUser = insertUser("uploader", "UPLOADER"); - } - - private TestUser insertUser(String namePrefix, String role) { - String username = (namePrefix + "_" + runId).toLowerCase(Locale.ROOT); - String password = "Bb@" + runId; - Long userId = jdbcTemplate.queryForObject( - "INSERT INTO sys_user (company_id, username, password_hash, real_name, role, status) " + - "VALUES (?, ?, ?, ?, ?, 'ACTIVE') RETURNING id", - Long.class, - companyId, - username, - PASSWORD_ENCODER.encode(password), - "黑盒-" + namePrefix, - role); - return new TestUser(userId, username, password, role); - } - - private void issueTokens() { - this.adminToken = login(companyCode, adminUser.username(), adminUser.password()); - this.reviewerToken = login(companyCode, reviewerUser.username(), reviewerUser.password()); - this.annotatorToken = login(companyCode, annotatorUser.username(), annotatorUser.password()); - this.annotator2Token = login(companyCode, annotator2User.username(), annotator2User.password()); - this.uploaderToken = login(companyCode, uploaderUser.username(), uploaderUser.password()); - } - - private void detectRuntimeAuthMode() { - try { - ResponseEntity adminMe = get("/api/auth/me", adminToken); - ResponseEntity reviewerMe = get("/api/auth/me", reviewerToken); - if (!adminMe.getStatusCode().is2xxSuccessful() || !reviewerMe.getStatusCode().is2xxSuccessful()) { - this.roleAwareAuthEnabled = false; - return; - } - - @SuppressWarnings("unchecked") - Map adminData = (Map) Objects.requireNonNull(adminMe.getBody()).get("data"); - @SuppressWarnings("unchecked") - Map reviewerData = (Map) Objects.requireNonNull(reviewerMe.getBody()).get("data"); - - this.roleAwareAuthEnabled = - adminUser.username().equals(adminData.get("username")) - && reviewerUser.username().equals(reviewerData.get("username")) - && "ADMIN".equals(adminData.get("role")) - && "REVIEWER".equals(reviewerData.get("role")) - && companyId.equals(((Number) adminData.get("companyId")).longValue()) - && companyId.equals(((Number) reviewerData.get("companyId")).longValue()); - } catch (Exception ex) { - this.roleAwareAuthEnabled = false; - } - } - - private DataSource createDataSource() { - DriverManagerDataSource dataSource = new DriverManagerDataSource(); - dataSource.setDriverClassName(resolved("spring.datasource.driver-class-name", "org.postgresql.Driver")); - dataSource.setUrl(resolved("spring.datasource.url", "")); - dataSource.setUsername(resolved("spring.datasource.username", "")); - dataSource.setPassword(resolved("spring.datasource.password", "")); - return dataSource; - } - - private String resolved(String key, String fallback) { - String raw = APPLICATION.getProperty(key); - if (raw == null || raw.isBlank()) { - return fallback; - } - return resolvePlaceholder(raw, fallback); - } - - private static Properties loadApplicationProperties() { - YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean(); - yaml.setResources(new ClassPathResource("application.yml")); - Properties properties = yaml.getObject(); - if (properties == null) { - throw new IllegalStateException("无法加载 application.yml"); - } - return properties; - } - - private static String resolvePlaceholder(String raw, String fallback) { - if (!raw.startsWith("${") || !raw.endsWith("}")) { - return raw; - } - - String inner = raw.substring(2, raw.length() - 1); - int splitIndex = inner.indexOf(':'); - if (splitIndex < 0) { - String envValue = System.getenv(inner); - return envValue != null ? envValue : fallback; - } - - String envKey = inner.substring(0, splitIndex); - String defaultValue = inner.substring(splitIndex + 1); - String envValue = System.getenv(envKey); - return envValue != null ? envValue : defaultValue; - } - - private static String buildRunId(TestInfo testInfo) { - String methodName = testInfo.getTestMethod().map(method -> method.getName()).orElse("case"); - String normalized = methodName.replaceAll("[^a-zA-Z0-9]", "").toLowerCase(Locale.ROOT); - if (normalized.length() > 10) { - normalized = normalized.substring(0, 10); - } - return normalized + Long.toHexString(Instant.now().toEpochMilli()).substring(5) + UUID.randomUUID().toString().substring(0, 4); - } - - protected record TestUser(Long id, String username, String password, String role) { - } -} diff --git a/src/test/java/com/label/blackbox/SwaggerLiveBlackBoxTest.java b/src/test/java/com/label/blackbox/SwaggerLiveBlackBoxTest.java deleted file mode 100644 index 6abbf69..0000000 --- a/src/test/java/com/label/blackbox/SwaggerLiveBlackBoxTest.java +++ /dev/null @@ -1,337 +0,0 @@ -package com.label.blackbox; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; - -import java.util.List; -import java.util.Map; -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assumptions.assumeTrue; - -class SwaggerLiveBlackBoxTest extends AbstractBlackBoxTest { - - @Test - @DisplayName("公共接口与认证接口在真实运行环境下可访问") - void publicAndAuthEndpoints_shouldWork() { - ResponseEntity openApi = getRaw("/v3/api-docs"); - assertThat(openApi.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(openApi.getBody()).contains("/api/auth/login"); - - ResponseEntity swaggerUi = getRaw("/swagger-ui.html"); - assertThat(swaggerUi.getStatusCode().is2xxSuccessful() || swaggerUi.getStatusCode().is3xxRedirection()).isTrue(); - - ResponseEntity login = postJson("/api/auth/login", Map.of( - "companyCode", companyCode, - "username", adminUser.username(), - "password", adminUser.password() - ), null); - assertSuccess(login, HttpStatus.OK); - - if (!roleAwareAuthEnabled) { - return; - } - - ResponseEntity me = get("/api/auth/me", adminToken); - assertSuccess(me, HttpStatus.OK); - @SuppressWarnings("unchecked") - Map meData = (Map) me.getBody().get("data"); - assertThat(meData.get("username")).isEqualTo(adminUser.username()); - assertThat(meData.get("role")).isEqualTo("ADMIN"); - - ResponseEntity logout = postJson("/api/auth/logout", null, adminToken); - assertSuccess(logout, HttpStatus.OK); - - ResponseEntity meAfterLogout = get("/api/auth/me", adminToken); - assertThat(meAfterLogout.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); - } - - @Test - @DisplayName("公司管理与用户管理接口在真实运行环境下可覆盖") - void companyAndUserEndpoints_shouldWork() { - requireRoleAwareAuth(); - - ResponseEntity companyList = get("/api/companies?page=1&pageSize=20", adminToken); - assertSuccess(companyList, HttpStatus.OK); - - String extraCompanyCode = ("EXT" + runId).toUpperCase(); - String extraCompanyName = "扩展公司-" + runId; - ResponseEntity createCompany = postJson("/api/companies", Map.of( - "companyName", extraCompanyName, - "companyCode", extraCompanyCode - ), adminToken); - assertSuccess(createCompany, HttpStatus.CREATED); - Long extraCompanyId = dataId(createCompany); - - ResponseEntity updateCompany = putJson("/api/companies/" + extraCompanyId, Map.of( - "companyName", extraCompanyName + "-改", - "companyCode", extraCompanyCode - ), adminToken); - assertSuccess(updateCompany, HttpStatus.OK); - - ResponseEntity companyStatus = putJson("/api/companies/" + extraCompanyId + "/status", - Map.of("status", "DISABLED"), adminToken); - assertSuccess(companyStatus, HttpStatus.OK); - - ResponseEntity deleteCompany = delete("/api/companies/" + extraCompanyId, adminToken); - assertSuccess(deleteCompany, HttpStatus.OK); - - ResponseEntity userList = get("/api/users?page=1&pageSize=20", adminToken); - assertSuccess(userList, HttpStatus.OK); - - String username = "bb_user_" + UUID.randomUUID().toString().substring(0, 8); - String password = "BbUser@123"; - ResponseEntity createUser = postJson("/api/users", Map.of( - "username", username, - "password", password, - "realName", "黑盒用户", - "role", "ANNOTATOR" - ), adminToken); - assertSuccess(createUser, HttpStatus.OK); - Long userId = dataId(createUser); - - ResponseEntity updateUser = putJson("/api/users/" + userId, Map.of( - "realName", "黑盒用户-改", - "password", "BbUser@456" - ), adminToken); - assertSuccess(updateUser, HttpStatus.OK); - - String userToken = login(companyCode, username, "BbUser@456"); - - ResponseEntity beforeRoleChange = get("/api/tasks/pending-review", userToken); - assertThat(beforeRoleChange.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); - - ResponseEntity updateRole = putJson("/api/users/" + userId + "/role", - Map.of("role", "REVIEWER"), adminToken); - assertSuccess(updateRole, HttpStatus.OK); - - ResponseEntity afterRoleChange = get("/api/tasks/pending-review", userToken); - assertThat(afterRoleChange.getStatusCode()).isEqualTo(HttpStatus.OK); - - ResponseEntity updateStatus = putJson("/api/users/" + userId + "/status", - Map.of("status", "DISABLED"), adminToken); - assertSuccess(updateStatus, HttpStatus.OK); - - ResponseEntity meAfterDisable = get("/api/auth/me", userToken); - assertThat(meAfterDisable.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); - } - - @Test - @DisplayName("资料与任务管理接口在真实运行环境下可覆盖") - void sourceAndTaskEndpoints_shouldWork() { - requireRoleAwareAuth(); - - Long disposableSourceId = uploadTextSource(uploaderToken); - ResponseEntity deleteDisposable = delete("/api/source/" + disposableSourceId, adminToken); - assertSuccess(deleteDisposable, HttpStatus.OK); - - Long sourceId = uploadTextSource(uploaderToken); - - ResponseEntity uploaderList = get("/api/source/list?page=1&pageSize=20", uploaderToken); - assertSuccess(uploaderList, HttpStatus.OK); - assertThat(responseContainsId(uploaderList, sourceId)).isTrue(); - - ResponseEntity adminList = get("/api/source/list?page=1&pageSize=20", adminToken); - assertSuccess(adminList, HttpStatus.OK); - assertThat(responseContainsId(adminList, sourceId)).isTrue(); - - ResponseEntity sourceDetail = get("/api/source/" + sourceId, adminToken); - assertSuccess(sourceDetail, HttpStatus.OK); - - Long taskId = createTask(sourceId, "EXTRACTION"); - - ResponseEntity pool = get("/api/tasks/pool?page=1&pageSize=20", annotatorToken); - assertSuccess(pool, HttpStatus.OK); - assertThat(responseContainsId(pool, taskId)).isTrue(); - - ResponseEntity allTasks = get("/api/tasks?page=1&pageSize=20&taskType=EXTRACTION", adminToken); - assertSuccess(allTasks, HttpStatus.OK); - assertThat(responseContainsId(allTasks, taskId)).isTrue(); - - ResponseEntity taskDetail = get("/api/tasks/" + taskId, annotatorToken); - assertSuccess(taskDetail, HttpStatus.OK); - - ResponseEntity claim = postJson("/api/tasks/" + taskId + "/claim", null, annotatorToken); - assertSuccess(claim, HttpStatus.OK); - - ResponseEntity mine = get("/api/tasks/mine?page=1&pageSize=20", annotatorToken); - assertSuccess(mine, HttpStatus.OK); - assertThat(responseContainsId(mine, taskId)).isTrue(); - - ResponseEntity unclaim = postJson("/api/tasks/" + taskId + "/unclaim", null, annotatorToken); - assertSuccess(unclaim, HttpStatus.OK); - - Long sourceId2 = uploadTextSource(uploaderToken); - Long taskId2 = createTask(sourceId2, "EXTRACTION"); - ResponseEntity claimTask2 = postJson("/api/tasks/" + taskId2 + "/claim", null, annotatorToken); - assertSuccess(claimTask2, HttpStatus.OK); - - ResponseEntity reassign = putJson("/api/tasks/" + taskId2 + "/reassign", - Map.of("userId", annotator2User.id()), adminToken); - assertSuccess(reassign, HttpStatus.OK); - assertThat(claimedByOfTask(taskId2)).isEqualTo(annotator2User.id()); - } - - @Test - @DisplayName("提取、问答、配置与导出接口在真实运行环境下可覆盖") - void extractionQaConfigAndExportEndpoints_shouldWork() { - requireRoleAwareAuth(); - - ResponseEntity listConfig = get("/api/config", adminToken); - assertSuccess(listConfig, HttpStatus.OK); - - ResponseEntity updateConfig = putJson("/api/config/model_default", - Map.of("value", "glm-4-blackbox-" + runId, "description", "黑盒测试默认模型"), - adminToken); - assertSuccess(updateConfig, HttpStatus.OK); - - Long sourceId = uploadTextSource(uploaderToken); - Long extractionTaskId = createTask(sourceId, "EXTRACTION"); - - assertSuccess(postJson("/api/tasks/" + extractionTaskId + "/claim", null, annotatorToken), HttpStatus.OK); - - ResponseEntity extractionGet = get("/api/extraction/" + extractionTaskId, annotatorToken); - assertSuccess(extractionGet, HttpStatus.OK); - - ResponseEntity extractionPut = putJson("/api/extraction/" + extractionTaskId, - "{\"items\":[{\"label\":\"entity\",\"text\":\"北京\"}]}", - annotatorToken); - assertSuccess(extractionPut, HttpStatus.OK); - - ResponseEntity extractionSubmit = postJson("/api/extraction/" + extractionTaskId + "/submit", null, annotatorToken); - assertSuccess(extractionSubmit, HttpStatus.OK); - - ResponseEntity pendingExtraction = get("/api/tasks/pending-review?page=1&pageSize=20&taskType=EXTRACTION", reviewerToken); - assertSuccess(pendingExtraction, HttpStatus.OK); - assertThat(responseContainsId(pendingExtraction, extractionTaskId)).isTrue(); - - ResponseEntity extractionReject = postJson("/api/extraction/" + extractionTaskId + "/reject", - Map.of("reason", "黑盒驳回一次"), reviewerToken); - assertSuccess(extractionReject, HttpStatus.OK); - - ResponseEntity reclaim = postJson("/api/tasks/" + extractionTaskId + "/reclaim", null, annotatorToken); - assertSuccess(reclaim, HttpStatus.OK); - - assertSuccess(putJson("/api/extraction/" + extractionTaskId, - "{\"items\":[{\"label\":\"entity\",\"text\":\"上海\"}]}", - annotatorToken), HttpStatus.OK); - assertSuccess(postJson("/api/extraction/" + extractionTaskId + "/submit", null, annotatorToken), HttpStatus.OK); - - ResponseEntity extractionApprove = postJson("/api/extraction/" + extractionTaskId + "/approve", null, reviewerToken); - assertSuccess(extractionApprove, HttpStatus.OK); - - Long qaTaskId = latestTaskId(sourceId, "QA_GENERATION"); - assertThat(qaTaskId).isNotNull(); - - assertSuccess(postJson("/api/tasks/" + qaTaskId + "/claim", null, annotatorToken), HttpStatus.OK); - - ResponseEntity qaGet = get("/api/qa/" + qaTaskId, annotatorToken); - assertSuccess(qaGet, HttpStatus.OK); - - ResponseEntity qaPut = putJson("/api/qa/" + qaTaskId, - Map.of("items", List.of(Map.of("question", "北京在哪里", "answer", "中国"))), - annotatorToken); - assertSuccess(qaPut, HttpStatus.OK); - - ResponseEntity qaSubmit = postJson("/api/qa/" + qaTaskId + "/submit", null, annotatorToken); - assertSuccess(qaSubmit, HttpStatus.OK); - - ResponseEntity pendingQa = get("/api/tasks/pending-review?page=1&pageSize=20&taskType=QA_GENERATION", reviewerToken); - assertSuccess(pendingQa, HttpStatus.OK); - assertThat(responseContainsId(pendingQa, qaTaskId)).isTrue(); - - ResponseEntity qaApprove = postJson("/api/qa/" + qaTaskId + "/approve", null, reviewerToken); - assertSuccess(qaApprove, HttpStatus.OK); - - ResponseEntity samples = get("/api/training/samples?page=1&pageSize=20&sampleType=TEXT", adminToken); - assertSuccess(samples, HttpStatus.OK); - - Long datasetId = latestApprovedDatasetId(sourceId); - ResponseEntity createBatch = postJson("/api/export/batch", - Map.of("sampleIds", List.of(datasetId)), adminToken); - assertSuccess(createBatch, HttpStatus.CREATED); - Long batchId = dataId(createBatch); - - ResponseEntity exportList = get("/api/export/list?page=1&pageSize=20", adminToken); - assertSuccess(exportList, HttpStatus.OK); - assertThat(responseContainsId(exportList, batchId)).isTrue(); - - ResponseEntity exportStatus = get("/api/export/" + batchId + "/status", adminToken); - assertSuccess(exportStatus, HttpStatus.OK); - - // 第二条链路覆盖 QA reject - Long sourceId2 = uploadTextSource(uploaderToken); - Long extractionTaskId2 = createTask(sourceId2, "EXTRACTION"); - assertSuccess(postJson("/api/tasks/" + extractionTaskId2 + "/claim", null, annotatorToken), HttpStatus.OK); - assertSuccess(postJson("/api/extraction/" + extractionTaskId2 + "/submit", null, annotatorToken), HttpStatus.OK); - assertSuccess(postJson("/api/extraction/" + extractionTaskId2 + "/approve", null, reviewerToken), HttpStatus.OK); - - Long qaTaskId2 = latestTaskId(sourceId2, "QA_GENERATION"); - assertSuccess(postJson("/api/tasks/" + qaTaskId2 + "/claim", null, annotatorToken), HttpStatus.OK); - assertSuccess(postJson("/api/qa/" + qaTaskId2 + "/submit", null, annotatorToken), HttpStatus.OK); - - ResponseEntity qaReject = postJson("/api/qa/" + qaTaskId2 + "/reject", - Map.of("reason", "黑盒问答驳回"), reviewerToken); - assertSuccess(qaReject, HttpStatus.OK); - } - - @Test - @DisplayName("视频处理与微调接口在显式开启重链路模式时可覆盖") - void videoAndFinetuneEndpoints_shouldWorkWhenHeavyModeEnabled() { - requireRoleAwareAuth(); - assumeTrue(Boolean.getBoolean("blackbox.heavy.enabled"), - "未开启 -Dblackbox.heavy.enabled=true,跳过视频处理与微调重链路黑盒用例"); - - Long sourceId = uploadTextSource(uploaderToken); - Long extractionTaskId = createTask(sourceId, "EXTRACTION"); - assertSuccess(postJson("/api/tasks/" + extractionTaskId + "/claim", null, annotatorToken), HttpStatus.OK); - assertSuccess(postJson("/api/extraction/" + extractionTaskId + "/submit", null, annotatorToken), HttpStatus.OK); - assertSuccess(postJson("/api/extraction/" + extractionTaskId + "/approve", null, reviewerToken), HttpStatus.OK); - - Long qaTaskId = latestTaskId(sourceId, "QA_GENERATION"); - assertSuccess(postJson("/api/tasks/" + qaTaskId + "/claim", null, annotatorToken), HttpStatus.OK); - assertSuccess(postJson("/api/qa/" + qaTaskId + "/submit", null, annotatorToken), HttpStatus.OK); - assertSuccess(postJson("/api/qa/" + qaTaskId + "/approve", null, reviewerToken), HttpStatus.OK); - - Long datasetId = latestApprovedDatasetId(sourceId); - ResponseEntity createBatch = postJson("/api/export/batch", - Map.of("sampleIds", List.of(datasetId)), adminToken); - assertSuccess(createBatch, HttpStatus.CREATED); - Long batchId = dataId(createBatch); - - ResponseEntity finetune = postJson("/api/export/" + batchId + "/finetune", null, adminToken); - assertSuccess(finetune, HttpStatus.OK); - - Long videoSourceId = uploadVideoSource(uploaderToken); - ResponseEntity createVideoJob = postJson("/api/video/process", - Map.of("sourceId", videoSourceId, "jobType", "FRAME_EXTRACT", "params", "{\"frameInterval\":30}"), - adminToken); - assertSuccess(createVideoJob, HttpStatus.OK); - Long jobId = dataId(createVideoJob); - - ResponseEntity getVideoJob = get("/api/video/jobs/" + jobId, adminToken); - assertSuccess(getVideoJob, HttpStatus.OK); - - Long failedJobId = insertFailedVideoJob(videoSourceId); - ResponseEntity resetJob = postJson("/api/video/jobs/" + failedJobId + "/reset", null, adminToken); - assertSuccess(resetJob, HttpStatus.OK); - - Long callbackJobId = insertPendingVideoJob(videoSourceId); - ResponseEntity callbackSuccess1 = postVideoCallback(Map.of( - "jobId", callbackJobId, - "status", "SUCCESS", - "outputPath", "processed/" + runId + "/frames.zip" - )); - assertSuccess(callbackSuccess1, HttpStatus.OK); - - ResponseEntity callbackSuccess2 = postVideoCallback(Map.of( - "jobId", callbackJobId, - "status", "SUCCESS", - "outputPath", "processed/" + runId + "/frames.zip" - )); - assertSuccess(callbackSuccess2, HttpStatus.OK); - } -} diff --git a/src/test/java/com/label/integration/AuthIntegrationTest.java b/src/test/java/com/label/integration/AuthIntegrationTest.java deleted file mode 100644 index 3964778..0000000 --- a/src/test/java/com/label/integration/AuthIntegrationTest.java +++ /dev/null @@ -1,160 +0,0 @@ -package com.label.integration; - -import com.label.AbstractIntegrationTest; -import com.label.common.result.Result; -import com.label.dto.LoginRequest; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.*; - -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * 认证流程集成测试(US1)。 - * - * 测试场景: - * 1. 正确密码登录 → 返回 token - * 2. 错误密码登录 → 401 - * 3. 不存在的公司代码 → 401 - * 4. 有效 Token 访问 /api/auth/me → 200,返回用户信息 - * 5. 主动退出后,原 Token 访问 /api/auth/me → 401 - * - * 测试数据来自 init.sql 种子(DEMO 公司 / admin / admin123) - */ -public class AuthIntegrationTest extends AbstractIntegrationTest { - - @Autowired - private TestRestTemplate restTemplate; - - // ------------------------------------------------------------------ 登录测试 -- - - @Test - @DisplayName("正确密码登录 → 返回 token") - void login_withCorrectCredentials_returnsToken() { - ResponseEntity response = doLogin("DEMO", "admin", "admin123"); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - - Map body = response.getBody(); - assertThat(body).isNotNull(); - assertThat(body.get("code")).isEqualTo("SUCCESS"); - - @SuppressWarnings("unchecked") - Map data = (Map) body.get("data"); - assertThat(data.get("token")).isNotNull().isInstanceOf(String.class); - assertThat((String) data.get("token")).isNotBlank(); - assertThat(data.get("role")).isEqualTo("ADMIN"); - } - - @Test - @DisplayName("错误密码登录 → 401 Unauthorized") - void login_withWrongPassword_returns401() { - ResponseEntity response = doLogin("DEMO", "admin", "wrong_password"); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); - } - - @Test - @DisplayName("不存在的公司代码 → 401 Unauthorized") - void login_withUnknownCompany_returns401() { - ResponseEntity response = doLogin("NONEXIST", "admin", "admin123"); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); - } - - // ------------------------------------------------------------------ /me 测试 -- - - @Test - @DisplayName("有效 Token 访问 /api/auth/me → 200,返回用户信息") - void me_withValidToken_returns200WithUserInfo() { - String token = loginAndGetToken("DEMO", "admin", "admin123"); - assertThat(token).isNotBlank(); - - ResponseEntity response = restTemplate.exchange( - baseUrl("/api/auth/me"), - HttpMethod.GET, - bearerRequest(token), - Map.class); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - - @SuppressWarnings("unchecked") - Map data = (Map) response.getBody().get("data"); - assertThat(data.get("username")).isEqualTo("admin"); - assertThat(data.get("role")).isEqualTo("ADMIN"); - assertThat(data.get("companyId")).isNotNull(); - } - - @Test - @DisplayName("无 Token 访问 /api/auth/me → 401") - void me_withNoToken_returns401() { - ResponseEntity response = restTemplate.getForEntity( - baseUrl("/api/auth/me"), String.class); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); - } - - // ------------------------------------------------------------------ 退出测试 -- - - @Test - @DisplayName("主动退出后,原 Token 访问 /api/auth/me → 401") - void logout_thenMe_returns401() { - String token = loginAndGetToken("DEMO", "admin", "admin123"); - assertThat(token).isNotBlank(); - - // 确认登录有效 - ResponseEntity meResponse = restTemplate.exchange( - baseUrl("/api/auth/me"), - HttpMethod.GET, - bearerRequest(token), - Map.class); - assertThat(meResponse.getStatusCode()).isEqualTo(HttpStatus.OK); - - // 退出 - ResponseEntity logoutResponse = restTemplate.exchange( - baseUrl("/api/auth/logout"), - HttpMethod.POST, - bearerRequest(token), - Map.class); - assertThat(logoutResponse.getStatusCode()).isEqualTo(HttpStatus.OK); - - // 退出后再访问 /me → 401 - ResponseEntity meAfterLogout = restTemplate.exchange( - baseUrl("/api/auth/me"), - HttpMethod.GET, - bearerRequest(token), - Map.class); - assertThat(meAfterLogout.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); - } - - // ------------------------------------------------------------------ 工具方法 -- - - /** 发起登录请求,返回原始 ResponseEntity */ - private ResponseEntity doLogin(String companyCode, String username, String password) { - LoginRequest req = new LoginRequest(); - req.setCompanyCode(companyCode); - req.setUsername(username); - req.setPassword(password); - return restTemplate.postForEntity(baseUrl("/api/auth/login"), req, Map.class); - } - - /** 登录并提取 token 字符串;失败时返回 null */ - private String loginAndGetToken(String companyCode, String username, String password) { - ResponseEntity response = doLogin(companyCode, username, password); - if (!response.getStatusCode().is2xxSuccessful()) { - return null; - } - @SuppressWarnings("unchecked") - Map data = (Map) response.getBody().get("data"); - return (String) data.get("token"); - } - - /** 构造带 Bearer Token 的请求实体(无 body) */ - private HttpEntity bearerRequest(String token) { - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", "Bearer " + token); - return new HttpEntity<>(headers); - } -} diff --git a/src/test/java/com/label/integration/ExportIntegrationTest.java b/src/test/java/com/label/integration/ExportIntegrationTest.java deleted file mode 100644 index 6be8c30..0000000 --- a/src/test/java/com/label/integration/ExportIntegrationTest.java +++ /dev/null @@ -1,175 +0,0 @@ -package com.label.integration; - -import com.label.AbstractIntegrationTest; -import com.label.service.RedisService; -import com.label.util.RedisUtil; - -import org.junit.jupiter.api.*; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.http.*; - -import java.util.List; -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * 训练数据导出集成测试(US6)。 - * - * 测试场景: - * 1. 包含非 APPROVED 样本时返回 400 INVALID_SAMPLES - * 2. sampleIds 为空时返回 400 EMPTY_SAMPLES - * 3. 非 ADMIN 访问 → 403 Forbidden - * - * 注意:实际上传 RustFS 需要 MinIO 容器支持,此处仅测试可验证的业务逻辑。 - * 文件存在性验证需启动 MinIO 容器(超出当前测试范围)。 - */ -public class ExportIntegrationTest extends AbstractIntegrationTest { - - private static final String ADMIN_TOKEN = "test-admin-token-export"; - private static final String ANNOTATOR_TOKEN = "test-annotator-token-export"; - - @Autowired - private TestRestTemplate restTemplate; - - @Autowired - private RedisService redisService; - - private Long sourceId; - private Long approvedDatasetId; - private Long pendingDatasetId; - - @BeforeEach - void setupTokensAndData() { - Long companyId = jdbcTemplate.queryForObject( - "SELECT id FROM sys_company WHERE company_code = 'DEMO'", Long.class); - Long userId = jdbcTemplate.queryForObject( - "SELECT id FROM sys_user WHERE username = 'admin'", Long.class); - - // 伪造 Redis Token - redisService.hSetAll(RedisUtil.tokenKey(ADMIN_TOKEN), - Map.of("userId", userId.toString(), "role", "ADMIN", - "companyId", companyId.toString(), "username", "admin"), - 3600L); - redisService.hSetAll(RedisUtil.tokenKey(ANNOTATOR_TOKEN), - Map.of("userId", "3", "role", "ANNOTATOR", - "companyId", companyId.toString(), "username", "annotator01"), - 3600L); - - // 插入 source_data - jdbcTemplate.execute( - "INSERT INTO source_data (company_id, uploader_id, data_type, file_path, " + - "file_name, file_size, bucket_name, status) " + - "VALUES (" + companyId + ", " + userId + ", 'TEXT', " + - "'test/export-test/file.txt', 'file.txt', 100, 'test-bucket', 'APPROVED')"); - sourceId = jdbcTemplate.queryForObject( - "SELECT id FROM source_data ORDER BY id DESC LIMIT 1", Long.class); - - // 插入 EXTRACTION 任务(已 APPROVED,用于关联 training_dataset) - jdbcTemplate.execute( - "INSERT INTO annotation_task (company_id, source_id, task_type, status, is_final) " + - "VALUES (" + companyId + ", " + sourceId + ", 'EXTRACTION', 'APPROVED', true)"); - Long taskId = jdbcTemplate.queryForObject( - "SELECT id FROM annotation_task ORDER BY id DESC LIMIT 1", Long.class); - - // 插入 APPROVED training_dataset - jdbcTemplate.execute( - "INSERT INTO training_dataset (company_id, task_id, source_id, sample_type, " + - "glm_format_json, status) VALUES (" + companyId + ", " + taskId + ", " + sourceId + - ", 'TEXT', '{\"conversations\":[{\"question\":\"Q1\",\"answer\":\"A1\"}]}'::jsonb, " + - "'APPROVED')"); - approvedDatasetId = jdbcTemplate.queryForObject( - "SELECT id FROM training_dataset ORDER BY id DESC LIMIT 1", Long.class); - - // 插入 PENDING_REVIEW training_dataset(用于测试校验失败) - jdbcTemplate.execute( - "INSERT INTO training_dataset (company_id, task_id, source_id, sample_type, " + - "glm_format_json, status) VALUES (" + companyId + ", " + taskId + ", " + sourceId + - ", 'TEXT', '{\"conversations\":[]}'::jsonb, 'PENDING_REVIEW')"); - pendingDatasetId = jdbcTemplate.queryForObject( - "SELECT id FROM training_dataset ORDER BY id DESC LIMIT 1", Long.class); - } - - @AfterEach - void cleanupTokens() { - redisService.delete(RedisUtil.tokenKey(ADMIN_TOKEN)); - redisService.delete(RedisUtil.tokenKey(ANNOTATOR_TOKEN)); - } - - // ------------------------------------------------------------------ 权限测试 -- - - @Test - @DisplayName("非 ADMIN 访问导出接口 → 403 Forbidden") - void createBatch_byAnnotator_returns403() { - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", "Bearer " + ANNOTATOR_TOKEN); - headers.setContentType(MediaType.APPLICATION_JSON); - HttpEntity> req = new HttpEntity<>( - Map.of("sampleIds", List.of(approvedDatasetId)), headers); - - ResponseEntity response = restTemplate.exchange( - baseUrl("/api/export/batch"), HttpMethod.POST, req, Map.class); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); - } - - // ------------------------------------------------------------------ 样本校验测试 -- - - @Test - @DisplayName("sampleIds 为空 → 400 EMPTY_SAMPLES") - void createBatch_withEmptyIds_returns400() { - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", "Bearer " + ADMIN_TOKEN); - headers.setContentType(MediaType.APPLICATION_JSON); - HttpEntity> req = new HttpEntity<>( - Map.of("sampleIds", List.of()), headers); - - ResponseEntity response = restTemplate.exchange( - baseUrl("/api/export/batch"), HttpMethod.POST, req, Map.class); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(response.getBody().get("code")).isEqualTo("EMPTY_SAMPLES"); - } - - @Test - @DisplayName("包含非 APPROVED 样本 → 400 INVALID_SAMPLES") - void createBatch_withNonApprovedSample_returns400() { - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", "Bearer " + ADMIN_TOKEN); - headers.setContentType(MediaType.APPLICATION_JSON); - // 混合 APPROVED + PENDING_REVIEW - HttpEntity> req = new HttpEntity<>( - Map.of("sampleIds", List.of(approvedDatasetId, pendingDatasetId)), headers); - - ResponseEntity response = restTemplate.exchange( - baseUrl("/api/export/batch"), HttpMethod.POST, req, Map.class); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(response.getBody().get("code")).isEqualTo("INVALID_SAMPLES"); - } - - @Test - @DisplayName("查询已审批样本列表 → 200,包含 APPROVED 样本") - void listSamples_adminOnly_returns200() { - ResponseEntity response = restTemplate.exchange( - baseUrl("/api/training/samples"), - HttpMethod.GET, - bearerRequest(ADMIN_TOKEN), - Map.class); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - - @SuppressWarnings("unchecked") - Map data = (Map) response.getBody().get("data"); - assertThat(((Number) data.get("total")).longValue()).isGreaterThanOrEqualTo(1L); - } - - // ------------------------------------------------------------------ 工具方法 -- - - private HttpEntity bearerRequest(String token) { - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", "Bearer " + token); - return new HttpEntity<>(headers); - } -} diff --git a/src/test/java/com/label/integration/ExtractionApprovalIntegrationTest.java b/src/test/java/com/label/integration/ExtractionApprovalIntegrationTest.java deleted file mode 100644 index 7fa9d52..0000000 --- a/src/test/java/com/label/integration/ExtractionApprovalIntegrationTest.java +++ /dev/null @@ -1,217 +0,0 @@ -package com.label.integration; - -import com.label.AbstractIntegrationTest; -import com.label.dto.LoginRequest; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.http.*; - -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * 提取阶段审批集成测试(US4)。 - * - * 测试场景: - * 1. 审批通过 → QA_GENERATION 任务自动创建,source_data 状态更新为 QA_REVIEW - * 2. 审批人与提交人相同(自审)→ 403 SELF_REVIEW_FORBIDDEN - * 3. 驳回后标注员可重领任务并再次提交 - */ -public class ExtractionApprovalIntegrationTest extends AbstractIntegrationTest { - - @Autowired - private TestRestTemplate restTemplate; - - private Long sourceId; - private Long taskId; - private Long annotatorUserId; - private Long reviewerUserId; - - @BeforeEach - void setup() { - // 获取种子用户 ID(init.sql 中已插入) - annotatorUserId = jdbcTemplate.queryForObject( - "SELECT id FROM sys_user WHERE username = 'annotator01'", Long.class); - reviewerUserId = jdbcTemplate.queryForObject( - "SELECT id FROM sys_user WHERE username = 'reviewer01'", Long.class); - Long companyId = jdbcTemplate.queryForObject( - "SELECT id FROM sys_company WHERE company_code = 'DEMO'", Long.class); - - // 插入测试 source_data - jdbcTemplate.execute( - "INSERT INTO source_data (company_id, uploader_id, data_type, file_path, " + - "file_name, file_size, bucket_name, status) " + - "VALUES (" + companyId + ", " + annotatorUserId + ", 'TEXT', " + - "'test/approval-test/file.txt', 'file.txt', 100, 'test-bucket', 'PENDING')"); - sourceId = jdbcTemplate.queryForObject( - "SELECT id FROM source_data ORDER BY id DESC LIMIT 1", Long.class); - - // 插入 UNCLAIMED EXTRACTION 任务 - jdbcTemplate.execute( - "INSERT INTO annotation_task (company_id, source_id, task_type, status) " + - "VALUES (" + companyId + ", " + sourceId + ", 'EXTRACTION', 'UNCLAIMED')"); - taskId = jdbcTemplate.queryForObject( - "SELECT id FROM annotation_task ORDER BY id DESC LIMIT 1", Long.class); - } - - // ------------------------------------------------------------------ 测试 1: 审批通过 → QA 任务自动创建 -- - - @Test - @DisplayName("审批通过后,QA_GENERATION 任务自动创建,source_data 状态变为 QA_REVIEW") - void approveTask_thenQaTaskAndSourceStatusUpdated() { - String annotatorToken = loginAndGetToken("DEMO", "annotator01", "annot123"); - String reviewerToken = loginAndGetToken("DEMO", "reviewer01", "review123"); - - // 1. 标注员领取任务 - ResponseEntity claimResp = restTemplate.exchange( - baseUrl("/api/tasks/" + taskId + "/claim"), - HttpMethod.POST, bearerRequest(annotatorToken), Map.class); - assertThat(claimResp.getStatusCode()).isEqualTo(HttpStatus.OK); - - // 2. 标注员提交标注 - ResponseEntity submitResp = restTemplate.exchange( - baseUrl("/api/extraction/" + taskId + "/submit"), - HttpMethod.POST, bearerRequest(annotatorToken), Map.class); - assertThat(submitResp.getStatusCode()).isEqualTo(HttpStatus.OK); - - // 3. 审核员审批通过 - // 注:ExtractionApprovedEventListener(@TransactionalEventListener AFTER_COMMIT) - // 在同一线程中同步执行,HTTP 响应返回前已完成后续处理 - ResponseEntity approveResp = restTemplate.exchange( - baseUrl("/api/extraction/" + taskId + "/approve"), - HttpMethod.POST, bearerRequest(reviewerToken), Map.class); - assertThat(approveResp.getStatusCode()).isEqualTo(HttpStatus.OK); - - // 验证:原任务状态变为 APPROVED,is_final=true - Map taskRow = jdbcTemplate.queryForMap( - "SELECT status, is_final FROM annotation_task WHERE id = ?", taskId); - assertThat(taskRow.get("status")).isEqualTo("APPROVED"); - assertThat(taskRow.get("is_final")).isEqualTo(Boolean.TRUE); - - // 验证:QA_GENERATION 任务已自动创建(UNCLAIMED 状态) - Integer qaTaskCount = jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM annotation_task " + - "WHERE source_id = ? AND task_type = 'QA_GENERATION' AND status = 'UNCLAIMED'", - Integer.class, sourceId); - assertThat(qaTaskCount).as("QA_GENERATION 任务应已创建").isEqualTo(1); - - // 验证:source_data 状态已更新为 QA_REVIEW - String sourceStatus = jdbcTemplate.queryForObject( - "SELECT status FROM source_data WHERE id = ?", String.class, sourceId); - assertThat(sourceStatus).as("source_data 状态应为 QA_REVIEW").isEqualTo("QA_REVIEW"); - - // 验证:training_dataset 已以 PENDING_REVIEW 状态创建 - Integer datasetCount = jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM training_dataset " + - "WHERE source_id = ? AND status = 'PENDING_REVIEW'", - Integer.class, sourceId); - assertThat(datasetCount).as("training_dataset 应已创建").isEqualTo(1); - } - - // ------------------------------------------------------------------ 测试 2: 自审返回 403 -- - - @Test - @DisplayName("审批人与任务领取人相同(自审)→ 403 SELF_REVIEW_FORBIDDEN") - void approveOwnSubmission_returnsForbidden() { - // 直接将任务置为 SUBMITTED 并设 claimed_by = reviewer01(模拟自审场景) - jdbcTemplate.execute( - "UPDATE annotation_task " + - "SET status = 'SUBMITTED', claimed_by = " + reviewerUserId + - ", claimed_at = NOW(), submitted_at = NOW() " + - "WHERE id = " + taskId); - - String reviewerToken = loginAndGetToken("DEMO", "reviewer01", "review123"); - - ResponseEntity resp = restTemplate.exchange( - baseUrl("/api/extraction/" + taskId + "/approve"), - HttpMethod.POST, bearerRequest(reviewerToken), Map.class); - - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); - - // 验证任务状态未变 - String status = jdbcTemplate.queryForObject( - "SELECT status FROM annotation_task WHERE id = ?", String.class, taskId); - assertThat(status).isEqualTo("SUBMITTED"); - } - - // ------------------------------------------------------------------ 测试 3: 驳回 → 重领 → 再提交 -- - - @Test - @DisplayName("驳回后标注员可重领任务并再次提交,任务状态恢复为 SUBMITTED") - void rejectThenReclaimAndResubmit_succeeds() { - String annotatorToken = loginAndGetToken("DEMO", "annotator01", "annot123"); - String reviewerToken = loginAndGetToken("DEMO", "reviewer01", "review123"); - - // 1. 标注员领取并提交 - restTemplate.exchange(baseUrl("/api/tasks/" + taskId + "/claim"), - HttpMethod.POST, bearerRequest(annotatorToken), Map.class); - restTemplate.exchange(baseUrl("/api/extraction/" + taskId + "/submit"), - HttpMethod.POST, bearerRequest(annotatorToken), Map.class); - - // 2. 审核员驳回(驳回原因必填) - HttpHeaders rejectHeaders = new HttpHeaders(); - rejectHeaders.set("Authorization", "Bearer " + reviewerToken); - rejectHeaders.setContentType(MediaType.APPLICATION_JSON); - HttpEntity> rejectReq = new HttpEntity<>( - Map.of("reason", "实体识别有误,请重新标注"), rejectHeaders); - - ResponseEntity rejectResp = restTemplate.exchange( - baseUrl("/api/extraction/" + taskId + "/reject"), - HttpMethod.POST, rejectReq, Map.class); - assertThat(rejectResp.getStatusCode()).isEqualTo(HttpStatus.OK); - - // 验证:任务状态变为 REJECTED - String statusAfterReject = jdbcTemplate.queryForObject( - "SELECT status FROM annotation_task WHERE id = ?", String.class, taskId); - assertThat(statusAfterReject).isEqualTo("REJECTED"); - - // 3. 标注员重领任务(REJECTED → IN_PROGRESS) - ResponseEntity reclaimResp = restTemplate.exchange( - baseUrl("/api/tasks/" + taskId + "/reclaim"), - HttpMethod.POST, bearerRequest(annotatorToken), Map.class); - assertThat(reclaimResp.getStatusCode()).isEqualTo(HttpStatus.OK); - - // 验证:任务状态恢复为 IN_PROGRESS - String statusAfterReclaim = jdbcTemplate.queryForObject( - "SELECT status FROM annotation_task WHERE id = ?", String.class, taskId); - assertThat(statusAfterReclaim).isEqualTo("IN_PROGRESS"); - - // 4. 标注员再次提交(IN_PROGRESS → SUBMITTED) - ResponseEntity resubmitResp = restTemplate.exchange( - baseUrl("/api/extraction/" + taskId + "/submit"), - HttpMethod.POST, bearerRequest(annotatorToken), Map.class); - assertThat(resubmitResp.getStatusCode()).isEqualTo(HttpStatus.OK); - - // 验证:任务状态变为 SUBMITTED - String finalStatus = jdbcTemplate.queryForObject( - "SELECT status FROM annotation_task WHERE id = ?", String.class, taskId); - assertThat(finalStatus).isEqualTo("SUBMITTED"); - } - - // ------------------------------------------------------------------ 工具方法 -- - - private String loginAndGetToken(String companyCode, String username, String password) { - LoginRequest req = new LoginRequest(); - req.setCompanyCode(companyCode); - req.setUsername(username); - req.setPassword(password); - ResponseEntity response = restTemplate.postForEntity( - baseUrl("/api/auth/login"), req, Map.class); - if (!response.getStatusCode().is2xxSuccessful()) { - return null; - } - @SuppressWarnings("unchecked") - Map data = (Map) response.getBody().get("data"); - return (String) data.get("token"); - } - - private HttpEntity bearerRequest(String token) { - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", "Bearer " + token); - return new HttpEntity<>(headers); - } -} diff --git a/src/test/java/com/label/integration/MultiTenantIsolationTest.java b/src/test/java/com/label/integration/MultiTenantIsolationTest.java deleted file mode 100644 index 91c7f71..0000000 --- a/src/test/java/com/label/integration/MultiTenantIsolationTest.java +++ /dev/null @@ -1,199 +0,0 @@ -package com.label.integration; - -import com.label.AbstractIntegrationTest; -import com.label.service.RedisService; -import com.label.util.RedisUtil; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.http.*; - -import java.util.List; -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * 多租户隔离集成测试(Phase 10 / T070)。 - * - * 测试场景: - * 1. 公司 A 的 ADMIN 查询资料列表 → 只能看到公司 A 的资料,看不到公司 B 的 - * 2. 公司 B 的 ADMIN 查询任务 → 只能看到公司 B 的任务,看不到公司 A 的 - * 3. 公司 A 的 sys_config 配置不影响公司 B(配置隔离) - */ -public class MultiTenantIsolationTest extends AbstractIntegrationTest { - - private static final String TOKEN_A = "test-admin-token-company-a"; - private static final String TOKEN_B = "test-admin-token-company-b"; - - @Autowired - private TestRestTemplate restTemplate; - - @Autowired - private RedisService redisService; - - private Long companyAId; // DEMO 公司(已在 init.sql 中创建) - private Long companyBId; // 测试用第二家公司 - private Long adminAId; // DEMO 公司 admin - private Long adminBId; // 第二家公司 admin - - @BeforeEach - void setupCompaniesAndTokens() { - // 公司 A:使用 init.sql 中的 DEMO 公司 - companyAId = jdbcTemplate.queryForObject( - "SELECT id FROM sys_company WHERE company_code = 'DEMO'", Long.class); - adminAId = jdbcTemplate.queryForObject( - "SELECT id FROM sys_user WHERE username = 'admin' AND company_id = ?", - Long.class, companyAId); - - // 公司 B:在测试中创建第二家公司 - jdbcTemplate.execute( - "INSERT INTO sys_company (company_name, company_code, status) " + - "VALUES ('测试公司B', 'TESTB', 'ACTIVE') ON CONFLICT DO NOTHING"); - companyBId = jdbcTemplate.queryForObject( - "SELECT id FROM sys_company WHERE company_code = 'TESTB'", Long.class); - - // 为公司 B 创建 admin 用户 - jdbcTemplate.execute( - "INSERT INTO sys_user (company_id, username, password_hash, real_name, role, status) " + - "VALUES (" + companyBId + ", 'admin_b', " + - "'$2a$10$B8iR5z43URiNPm.eut3JvufIPBuvGx5ZZmqyUqE1A1WdbZppX5bmi', " + - "'B公司管理员', 'ADMIN', 'ACTIVE') ON CONFLICT (company_id, username) DO NOTHING"); - adminBId = jdbcTemplate.queryForObject( - "SELECT id FROM sys_user WHERE username = 'admin_b' AND company_id = ?", - Long.class, companyBId); - - // 伪造 Redis Token - redisService.hSetAll(RedisUtil.tokenKey(TOKEN_A), - Map.of("userId", adminAId.toString(), "role", "ADMIN", - "companyId", companyAId.toString(), "username", "admin"), - 3600L); - redisService.hSetAll(RedisUtil.tokenKey(TOKEN_B), - Map.of("userId", adminBId.toString(), "role", "ADMIN", - "companyId", companyBId.toString(), "username", "admin_b"), - 3600L); - - // 公司 A 插入两条 source_data - jdbcTemplate.execute( - "INSERT INTO source_data (company_id, uploader_id, data_type, file_path, " + - "file_name, file_size, bucket_name, status) " + - "VALUES (" + companyAId + ", " + adminAId + ", 'TEXT', " + - "'company-a/file1.txt', 'file1.txt', 100, 'label-source-data', 'PENDING'), " + - "(" + companyAId + ", " + adminAId + ", 'TEXT', " + - "'company-a/file2.txt', 'file2.txt', 200, 'label-source-data', 'PENDING')"); - - // 公司 B 插入一条 source_data - jdbcTemplate.execute( - "INSERT INTO source_data (company_id, uploader_id, data_type, file_path, " + - "file_name, file_size, bucket_name, status) " + - "VALUES (" + companyBId + ", " + adminBId + ", 'TEXT', " + - "'company-b/file1.txt', 'file1.txt', 300, 'label-source-data', 'PENDING')"); - } - - @AfterEach - void cleanupTokensAndCompanyB() { - redisService.delete(RedisUtil.tokenKey(TOKEN_A)); - redisService.delete(RedisUtil.tokenKey(TOKEN_B)); - // 清理公司 B 的数据(sys_company 不在 cleanData TRUNCATE 范围内) - jdbcTemplate.execute("DELETE FROM sys_user WHERE username = 'admin_b'"); - jdbcTemplate.execute("DELETE FROM sys_company WHERE company_code = 'TESTB'"); - } - - // ------------------------------------------------------------------ 测试 1: 资料列表隔离 -- - - @Test - @DisplayName("公司 A 只能查看本公司资料,看不到公司 B 的资料") - void sourceList_companyA_cannotSeeCompanyBData() { - ResponseEntity resp = restTemplate.exchange( - baseUrl("/api/source/list?page=1&pageSize=50"), - HttpMethod.GET, - bearerRequest(TOKEN_A), - Map.class); - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); - - @SuppressWarnings("unchecked") - Map data = (Map) resp.getBody().get("data"); - assertThat(((Number) data.get("total")).longValue()) - .as("公司 A 应只看到自己的 2 条资料") - .isEqualTo(2L); - - @SuppressWarnings("unchecked") - List> records = (List>) data.get("records"); - records.forEach(r -> - assertThat(((Number) r.get("companyId")).longValue()) - .as("每条资料的 companyId 应为公司 A 的 ID") - .isEqualTo(companyAId)); - } - - @Test - @DisplayName("公司 B 只能查看本公司资料,看不到公司 A 的资料") - void sourceList_companyB_cannotSeeCompanyAData() { - ResponseEntity resp = restTemplate.exchange( - baseUrl("/api/source/list?page=1&pageSize=50"), - HttpMethod.GET, - bearerRequest(TOKEN_B), - Map.class); - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); - - @SuppressWarnings("unchecked") - Map data = (Map) resp.getBody().get("data"); - assertThat(((Number) data.get("total")).longValue()) - .as("公司 B 应只看到自己的 1 条资料") - .isEqualTo(1L); - } - - // ------------------------------------------------------------------ 测试 2: 配置隔离 -- - - @Test - @DisplayName("公司 A 设置专属配置,公司 B 仍使用全局默认") - void sysConfig_companyA_doesNotAffectCompanyB() { - // 公司 A 设置专属 model_default - HttpHeaders headersA = new HttpHeaders(); - headersA.set("Authorization", "Bearer " + TOKEN_A); - headersA.setContentType(MediaType.APPLICATION_JSON); - - restTemplate.exchange( - baseUrl("/api/config/model_default"), - HttpMethod.PUT, - new HttpEntity<>(Map.of("value", "glm-4-plus"), headersA), - Map.class); - - // 公司 B 查询配置列表 - ResponseEntity respB = restTemplate.exchange( - baseUrl("/api/config"), - HttpMethod.GET, - bearerRequest(TOKEN_B), - Map.class); - assertThat(respB.getStatusCode()).isEqualTo(HttpStatus.OK); - - @SuppressWarnings("unchecked") - List> configsB = (List>) respB.getBody().get("data"); - - Map modelCfgB = configsB.stream() - .filter(c -> "model_default".equals(c.get("configKey"))) - .findFirst() - .orElse(null); - - if (modelCfgB != null) { - // 公司 B 未设置专属,应使用全局默认 glm-4,scope=GLOBAL - assertThat(modelCfgB.get("scope")) - .as("公司 B 应使用全局默认配置,scope=GLOBAL") - .isEqualTo("GLOBAL"); - assertThat(modelCfgB.get("configValue")) - .as("公司 B model_default 应为全局默认 glm-4,不受公司 A 设置影响") - .isEqualTo("glm-4"); - } - } - - // ------------------------------------------------------------------ 工具方法 -- - - private HttpEntity bearerRequest(String token) { - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", "Bearer " + token); - return new HttpEntity<>(headers); - } -} diff --git a/src/test/java/com/label/integration/QaApprovalIntegrationTest.java b/src/test/java/com/label/integration/QaApprovalIntegrationTest.java deleted file mode 100644 index 9b18b1c..0000000 --- a/src/test/java/com/label/integration/QaApprovalIntegrationTest.java +++ /dev/null @@ -1,196 +0,0 @@ -package com.label.integration; - -import com.label.AbstractIntegrationTest; -import com.label.dto.LoginRequest; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.http.*; - -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * QA 问答生成阶段审批集成测试(US5)。 - * - * 测试场景: - * 1. QA 审批通过 → training_dataset.status = APPROVED,source_data.status = APPROVED - * 2. QA 驳回 → 候选问答对被删除,标注员可重领 - */ -public class QaApprovalIntegrationTest extends AbstractIntegrationTest { - - @Autowired - private TestRestTemplate restTemplate; - - private Long sourceId; - private Long taskId; - private Long datasetId; - private Long annotatorUserId; - private Long reviewerUserId; - - @BeforeEach - void setup() { - annotatorUserId = jdbcTemplate.queryForObject( - "SELECT id FROM sys_user WHERE username = 'annotator01'", Long.class); - reviewerUserId = jdbcTemplate.queryForObject( - "SELECT id FROM sys_user WHERE username = 'reviewer01'", Long.class); - Long companyId = jdbcTemplate.queryForObject( - "SELECT id FROM sys_company WHERE company_code = 'DEMO'", Long.class); - - // 插入 source_data(QA_REVIEW 状态,模拟提取审批已完成) - jdbcTemplate.execute( - "INSERT INTO source_data (company_id, uploader_id, data_type, file_path, " + - "file_name, file_size, bucket_name, status) " + - "VALUES (" + companyId + ", " + annotatorUserId + ", 'TEXT', " + - "'test/qa-test/file.txt', 'file.txt', 100, 'test-bucket', 'QA_REVIEW')"); - sourceId = jdbcTemplate.queryForObject( - "SELECT id FROM source_data ORDER BY id DESC LIMIT 1", Long.class); - - // 插入 QA_GENERATION 任务(UNCLAIMED 状态,模拟提取审批通过后自动创建的 QA 任务) - jdbcTemplate.execute( - "INSERT INTO annotation_task (company_id, source_id, task_type, status) " + - "VALUES (" + companyId + ", " + sourceId + ", 'QA_GENERATION', 'UNCLAIMED')"); - taskId = jdbcTemplate.queryForObject( - "SELECT id FROM annotation_task ORDER BY id DESC LIMIT 1", Long.class); - - // 插入候选问答对(模拟 ExtractionApprovedEventListener 创建) - jdbcTemplate.execute( - "INSERT INTO training_dataset (company_id, task_id, source_id, sample_type, " + - "glm_format_json, status) VALUES (" + companyId + ", " + taskId + ", " + sourceId + - ", 'TEXT', '{\"conversations\":[{\"question\":\"北京是哪个国家的首都?\",\"answer\":\"中国\"}]}'::jsonb, " + - "'PENDING_REVIEW')"); - datasetId = jdbcTemplate.queryForObject( - "SELECT id FROM training_dataset ORDER BY id DESC LIMIT 1", Long.class); - } - - // ------------------------------------------------------------------ 测试 1: 审批通过 → 终态 -- - - @Test - @DisplayName("QA 审批通过 → training_dataset.status=APPROVED,source_data.status=APPROVED") - void approveQaTask_thenDatasetAndSourceApproved() { - String annotatorToken = loginAndGetToken("DEMO", "annotator01", "annot123"); - String reviewerToken = loginAndGetToken("DEMO", "reviewer01", "review123"); - - // 注意:QA 任务 claim 端点为 POST /api/tasks/{id}/claim(ANNOTATOR 角色) - // 但 TaskController.getPool 只给 ANNOTATOR 显示 EXTRACTION/UNCLAIMED - // QA 任务由 ANNOTATOR 直接领取(不经过任务池) - ResponseEntity claimResp = restTemplate.exchange( - baseUrl("/api/tasks/" + taskId + "/claim"), - HttpMethod.POST, bearerRequest(annotatorToken), Map.class); - assertThat(claimResp.getStatusCode()).isEqualTo(HttpStatus.OK); - - // 提交 QA 结果 - ResponseEntity submitResp = restTemplate.exchange( - baseUrl("/api/qa/" + taskId + "/submit"), - HttpMethod.POST, bearerRequest(annotatorToken), Map.class); - assertThat(submitResp.getStatusCode()).isEqualTo(HttpStatus.OK); - - // 审批通过 - ResponseEntity approveResp = restTemplate.exchange( - baseUrl("/api/qa/" + taskId + "/approve"), - HttpMethod.POST, bearerRequest(reviewerToken), Map.class); - assertThat(approveResp.getStatusCode()).isEqualTo(HttpStatus.OK); - - // 验证:training_dataset → APPROVED - String datasetStatus = jdbcTemplate.queryForObject( - "SELECT status FROM training_dataset WHERE id = ?", String.class, datasetId); - assertThat(datasetStatus).as("training_dataset 状态应为 APPROVED").isEqualTo("APPROVED"); - - // 验证:annotation_task → APPROVED,is_final=true - Map taskRow = jdbcTemplate.queryForMap( - "SELECT status, is_final FROM annotation_task WHERE id = ?", taskId); - assertThat(taskRow.get("status")).isEqualTo("APPROVED"); - assertThat(taskRow.get("is_final")).isEqualTo(Boolean.TRUE); - - // 验证:source_data → APPROVED(整条流水线完成) - String sourceStatus = jdbcTemplate.queryForObject( - "SELECT status FROM source_data WHERE id = ?", String.class, sourceId); - assertThat(sourceStatus).as("source_data 状态应为 APPROVED(流水线终态)").isEqualTo("APPROVED"); - } - - // ------------------------------------------------------------------ 测试 2: 驳回 → 候选记录删除 → 可重领 -- - - @Test - @DisplayName("QA 驳回 → 候选问答对被删除,标注员可重领并再次提交") - void rejectQaTask_thenDatasetDeletedAndReclaimable() { - String annotatorToken = loginAndGetToken("DEMO", "annotator01", "annot123"); - String reviewerToken = loginAndGetToken("DEMO", "reviewer01", "review123"); - - // 领取并提交 - restTemplate.exchange(baseUrl("/api/tasks/" + taskId + "/claim"), - HttpMethod.POST, bearerRequest(annotatorToken), Map.class); - restTemplate.exchange(baseUrl("/api/qa/" + taskId + "/submit"), - HttpMethod.POST, bearerRequest(annotatorToken), Map.class); - - // 驳回(驳回原因必填) - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", "Bearer " + reviewerToken); - headers.setContentType(MediaType.APPLICATION_JSON); - HttpEntity> rejectReq = new HttpEntity<>( - Map.of("reason", "问题描述不准确,请修改"), headers); - - ResponseEntity rejectResp = restTemplate.exchange( - baseUrl("/api/qa/" + taskId + "/reject"), - HttpMethod.POST, rejectReq, Map.class); - assertThat(rejectResp.getStatusCode()).isEqualTo(HttpStatus.OK); - - // 验证:任务状态变为 REJECTED - String statusAfterReject = jdbcTemplate.queryForObject( - "SELECT status FROM annotation_task WHERE id = ?", String.class, taskId); - assertThat(statusAfterReject).isEqualTo("REJECTED"); - - // 验证:候选问答对已被删除 - Integer datasetCount = jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM training_dataset WHERE task_id = ?", - Integer.class, taskId); - assertThat(datasetCount).as("驳回后候选问答对应被删除").isEqualTo(0); - - // 验证:source_data 保持 QA_REVIEW(不变) - String sourceStatus = jdbcTemplate.queryForObject( - "SELECT status FROM source_data WHERE id = ?", String.class, sourceId); - assertThat(sourceStatus).as("驳回后 source_data 应保持 QA_REVIEW").isEqualTo("QA_REVIEW"); - - // 标注员重领任务 - ResponseEntity reclaimResp = restTemplate.exchange( - baseUrl("/api/tasks/" + taskId + "/reclaim"), - HttpMethod.POST, bearerRequest(annotatorToken), Map.class); - assertThat(reclaimResp.getStatusCode()).isEqualTo(HttpStatus.OK); - - // 再次提交 - ResponseEntity resubmitResp = restTemplate.exchange( - baseUrl("/api/qa/" + taskId + "/submit"), - HttpMethod.POST, bearerRequest(annotatorToken), Map.class); - assertThat(resubmitResp.getStatusCode()).isEqualTo(HttpStatus.OK); - - // 验证:任务状态变为 SUBMITTED - String finalStatus = jdbcTemplate.queryForObject( - "SELECT status FROM annotation_task WHERE id = ?", String.class, taskId); - assertThat(finalStatus).isEqualTo("SUBMITTED"); - } - - // ------------------------------------------------------------------ 工具方法 -- - - private String loginAndGetToken(String companyCode, String username, String password) { - LoginRequest req = new LoginRequest(); - req.setCompanyCode(companyCode); - req.setUsername(username); - req.setPassword(password); - ResponseEntity response = restTemplate.postForEntity( - baseUrl("/api/auth/login"), req, Map.class); - if (!response.getStatusCode().is2xxSuccessful()) { - return null; - } - @SuppressWarnings("unchecked") - Map data = (Map) response.getBody().get("data"); - return (String) data.get("token"); - } - - private HttpEntity bearerRequest(String token) { - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", "Bearer " + token); - return new HttpEntity<>(headers); - } -} diff --git a/src/test/java/com/label/integration/SourceIntegrationTest.java b/src/test/java/com/label/integration/SourceIntegrationTest.java deleted file mode 100644 index 71a8950..0000000 --- a/src/test/java/com/label/integration/SourceIntegrationTest.java +++ /dev/null @@ -1,167 +0,0 @@ -package com.label.integration; - -import com.label.AbstractIntegrationTest; -import com.label.service.RedisService; -import com.label.util.RedisUtil; - -import org.junit.jupiter.api.*; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.core.io.ByteArrayResource; -import org.springframework.http.*; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * 原始资料管理集成测试(US2)。 - * - * 测试场景: - * 1. UPLOADER 上传文本 → 列表仅返回自己的资料 - * 2. ADMIN 查看列表 → 返回全公司资料 - * 3. 上传视频 → status = PENDING(视频预处理由 Phase 9 处理) - * 4. 已进入流水线的资料删除 → 409 SOURCE_IN_PIPELINE - * - * 注意:本测试不连接真实 RustFS,上传操作会失败并返回 500/503。 - * 测试仅验证可访问的业务逻辑(权限、状态机)。 - * 如需覆盖文件上传,需在测试环境配置 Mock RustFsClient 或启动 MinIO 容器。 - */ -public class SourceIntegrationTest extends AbstractIntegrationTest { - - private static final String UPLOADER_TOKEN = "test-uploader-token-source"; - private static final String UPLOADER2_TOKEN = "test-uploader2-token-source"; - private static final String ADMIN_TOKEN = "test-admin-token-source"; - - @Autowired - private TestRestTemplate restTemplate; - - @Autowired - private RedisService redisService; - - @BeforeEach - void setupTokens() { - // uploader01 token (userId=4 from init.sql seed) - redisService.hSetAll(RedisUtil.tokenKey(UPLOADER_TOKEN), - Map.of("userId", "4", "role", "UPLOADER", "companyId", "1", "username", "uploader01"), - 3600L); - // admin token (userId=1 from init.sql seed) - redisService.hSetAll(RedisUtil.tokenKey(ADMIN_TOKEN), - Map.of("userId", "1", "role", "ADMIN", "companyId", "1", "username", "admin"), - 3600L); - } - - @AfterEach - void cleanupTokens() { - redisService.delete(RedisUtil.tokenKey(UPLOADER_TOKEN)); - redisService.delete(RedisUtil.tokenKey(UPLOADER2_TOKEN)); - redisService.delete(RedisUtil.tokenKey(ADMIN_TOKEN)); - } - - // ------------------------------------------------------------------ 权限测试 -- - - @Test - @DisplayName("无 Token 访问上传接口 → 401") - void upload_withoutToken_returns401() { - ResponseEntity response = restTemplate.postForEntity( - baseUrl("/api/source/upload"), null, String.class); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); - } - - @Test - @DisplayName("UPLOADER 访问列表接口(无数据)→ 200,items 为空") - void list_uploaderWithNoData_returnsEmptyList() { - ResponseEntity response = restTemplate.exchange( - baseUrl("/api/source/list"), - HttpMethod.GET, - bearerRequest(UPLOADER_TOKEN), - Map.class); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - - @SuppressWarnings("unchecked") - Map data = (Map) response.getBody().get("data"); - assertThat(data.get("items")).isInstanceOf(List.class); - assertThat(((List) data.get("items"))).isEmpty(); - assertThat(((Number) data.get("total")).longValue()).isEqualTo(0L); - } - - @Test - @DisplayName("ADMIN 访问列表接口(无数据)→ 200,items 为空") - void list_adminWithNoData_returnsEmptyList() { - ResponseEntity response = restTemplate.exchange( - baseUrl("/api/source/list"), - HttpMethod.GET, - bearerRequest(ADMIN_TOKEN), - Map.class); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - - @SuppressWarnings("unchecked") - Map data = (Map) response.getBody().get("data"); - assertThat(((List) data.get("items"))).isEmpty(); - } - - @Test - @DisplayName("删除不存在的资料 → 404") - void delete_nonExistentSource_returns404() { - ResponseEntity response = restTemplate.exchange( - baseUrl("/api/source/9999"), - HttpMethod.DELETE, - bearerRequest(ADMIN_TOKEN), - Map.class); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); - } - - @Test - @DisplayName("非 ADMIN 删除资料 → 403 Forbidden") - void delete_byUploader_returns403() { - ResponseEntity response = restTemplate.exchange( - baseUrl("/api/source/9999"), - HttpMethod.DELETE, - bearerRequest(UPLOADER_TOKEN), - Map.class); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); - } - - @Test - @DisplayName("ADMIN 删除已进入流水线的资料 → 409 SOURCE_IN_PIPELINE") - void delete_sourceInPipeline_returns409() { - // 直接向 DB 插入一条 EXTRACTING 状态的资料(模拟已进入流水线) - jdbcTemplate.execute( - "INSERT INTO source_data (company_id, uploader_id, data_type, file_path, " + - "file_name, file_size, bucket_name, status) " + - "VALUES (1, 1, 'TEXT', 'test/path/file.txt', 'file.txt', 100, 'test-bucket', 'EXTRACTING')"); - - Long sourceId = jdbcTemplate.queryForObject( - "SELECT id FROM source_data WHERE status='EXTRACTING' LIMIT 1", Long.class); - - assertThat(sourceId).isNotNull(); - - ResponseEntity response = restTemplate.exchange( - baseUrl("/api/source/" + sourceId), - HttpMethod.DELETE, - bearerRequest(ADMIN_TOKEN), - Map.class); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); - - @SuppressWarnings("unchecked") - Map body = response.getBody(); - assertThat(body.get("code")).isEqualTo("SOURCE_IN_PIPELINE"); - } - - // ------------------------------------------------------------------ 工具方法 -- - - private HttpEntity bearerRequest(String token) { - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", "Bearer " + token); - return new HttpEntity<>(headers); - } -} diff --git a/src/test/java/com/label/integration/SysConfigIntegrationTest.java b/src/test/java/com/label/integration/SysConfigIntegrationTest.java deleted file mode 100644 index 2eb6984..0000000 --- a/src/test/java/com/label/integration/SysConfigIntegrationTest.java +++ /dev/null @@ -1,184 +0,0 @@ -package com.label.integration; - -import com.label.AbstractIntegrationTest; -import com.label.service.RedisService; -import com.label.util.RedisUtil; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.http.*; - -import java.util.List; -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * 系统配置集成测试(US8)。 - * - * 测试场景: - * 1. 公司专属配置覆盖全局默认 - * 2. 未设置公司专属时,回退至全局默认 - * 3. 未知配置键 → 400 UNKNOWN_CONFIG_KEY - */ -public class SysConfigIntegrationTest extends AbstractIntegrationTest { - - private static final String ADMIN_TOKEN = "test-admin-token-config"; - - @Autowired - private TestRestTemplate restTemplate; - - @Autowired - private RedisService redisService; - - private Long companyId; - private Long adminUserId; - - @BeforeEach - void setupToken() { - companyId = jdbcTemplate.queryForObject( - "SELECT id FROM sys_company WHERE company_code = 'DEMO'", Long.class); - adminUserId = jdbcTemplate.queryForObject( - "SELECT id FROM sys_user WHERE username = 'admin'", Long.class); - - // 伪造 Redis Token - redisService.hSetAll(RedisUtil.tokenKey(ADMIN_TOKEN), - Map.of("userId", adminUserId.toString(), "role", "ADMIN", - "companyId", companyId.toString(), "username", "admin"), - 3600L); - } - - @AfterEach - void cleanupTokens() { - redisService.delete(RedisUtil.tokenKey(ADMIN_TOKEN)); - } - - // ------------------------------------------------------------------ 测试 1: 公司配置覆盖全局 -- - - @Test - @DisplayName("公司专属配置优先于全局默认(scope=COMPANY 覆盖 scope=GLOBAL)") - void companyConfig_overridesGlobalDefault() { - // 设置公司专属配置(覆盖全局 model_default) - updateConfig("model_default", "glm-4-plus", "公司专属模型"); - - // 查询配置列表 - ResponseEntity listResp = restTemplate.exchange( - baseUrl("/api/config"), - HttpMethod.GET, - bearerRequest(ADMIN_TOKEN), - Map.class); - assertThat(listResp.getStatusCode()).isEqualTo(HttpStatus.OK); - - @SuppressWarnings("unchecked") - List> configs = (List>) listResp.getBody().get("data"); - assertThat(configs).isNotEmpty(); - - // 找到 model_default 配置 - Map modelConfig = configs.stream() - .filter(c -> "model_default".equals(c.get("configKey"))) - .findFirst() - .orElseThrow(() -> new AssertionError("model_default 配置不存在")); - - // 应返回公司专属配置值,scope=COMPANY - assertThat(modelConfig.get("configValue")) - .as("公司专属配置应覆盖全局默认") - .isEqualTo("glm-4-plus"); - assertThat(modelConfig.get("scope")) - .as("scope 应标记为 COMPANY") - .isEqualTo("COMPANY"); - } - - // ------------------------------------------------------------------ 测试 2: 回退全局默认 -- - - @Test - @DisplayName("未设置公司专属配置时,返回全局默认值(scope=GLOBAL)") - void globalConfig_usedWhenNoCompanyOverride() { - // 不设置公司专属,直接查询列表 - ResponseEntity listResp = restTemplate.exchange( - baseUrl("/api/config"), - HttpMethod.GET, - bearerRequest(ADMIN_TOKEN), - Map.class); - assertThat(listResp.getStatusCode()).isEqualTo(HttpStatus.OK); - - @SuppressWarnings("unchecked") - List> configs = (List>) listResp.getBody().get("data"); - - // 至少包含 AbstractIntegrationTest.cleanData() 中插入的全局配置 - assertThat(configs).isNotEmpty(); - - // 所有配置都应有 scope 字段 - configs.forEach(cfg -> - assertThat(cfg.containsKey("scope")).as("每条配置应含 scope 字段").isTrue()); - - // token_ttl_seconds 全局默认应为 7200 - Map ttlConfig = configs.stream() - .filter(c -> "token_ttl_seconds".equals(c.get("configKey"))) - .findFirst() - .orElse(null); - - if (ttlConfig != null) { - assertThat(ttlConfig.get("configValue")).isEqualTo("7200"); - assertThat(ttlConfig.get("scope")).isEqualTo("GLOBAL"); - } - } - - // ------------------------------------------------------------------ 测试 3: 未知配置键 -- - - @Test - @DisplayName("更新未知配置键 → 400 UNKNOWN_CONFIG_KEY") - void updateUnknownKey_returns400() { - ResponseEntity resp = updateConfig("unknown_key_xyz", "someValue", null); - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(resp.getBody().get("code")).isEqualTo("UNKNOWN_CONFIG_KEY"); - } - - // ------------------------------------------------------------------ 测试 4: UPSERT 同键两次 -- - - @Test - @DisplayName("同一配置键两次 PUT → 第二次更新而非重复插入") - void updateSameKey_twice_upserts() { - updateConfig("video_frame_interval", "60", "帧间隔 60s"); - updateConfig("video_frame_interval", "120", "帧间隔 120s"); - - // 数据库中公司专属 video_frame_interval 应只有一条记录 - Integer count = jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM sys_config WHERE company_id = ? AND config_key = 'video_frame_interval'", - Integer.class, companyId); - assertThat(count).as("UPSERT:同键应只有一条公司专属记录").isEqualTo(1); - - // 值应为最后一次 PUT 的值 - String value = jdbcTemplate.queryForObject( - "SELECT config_value FROM sys_config WHERE company_id = ? AND config_key = 'video_frame_interval'", - String.class, companyId); - assertThat(value).isEqualTo("120"); - } - - // ------------------------------------------------------------------ 工具方法 -- - - private ResponseEntity updateConfig(String key, String value, String description) { - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", "Bearer " + ADMIN_TOKEN); - headers.setContentType(MediaType.APPLICATION_JSON); - - Map body = description != null - ? Map.of("value", value, "description", description) - : Map.of("value", value); - - return restTemplate.exchange( - baseUrl("/api/config/" + key), - HttpMethod.PUT, - new HttpEntity<>(body, headers), - Map.class); - } - - private HttpEntity bearerRequest(String token) { - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", "Bearer " + token); - return new HttpEntity<>(headers); - } -} diff --git a/src/test/java/com/label/integration/TaskClaimConcurrencyTest.java b/src/test/java/com/label/integration/TaskClaimConcurrencyTest.java deleted file mode 100644 index cbfbb77..0000000 --- a/src/test/java/com/label/integration/TaskClaimConcurrencyTest.java +++ /dev/null @@ -1,136 +0,0 @@ -package com.label.integration; - -import com.label.AbstractIntegrationTest; -import com.label.service.RedisService; -import com.label.util.RedisUtil; - -import org.junit.jupiter.api.*; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.http.*; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.*; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * 任务领取并发安全集成测试(US3)。 - * - * 测试场景:10 个线程同时争抢同一 UNCLAIMED 任务。 - * 期望结果: - * - 恰好 1 人成功(200 OK) - * - 其余 9 人收到 TASK_CLAIMED (409) - * - DB 中 claimed_by 唯一(只有一个用户 ID) - * - * 此测试需要 10 个不同的 userId,使用同一 DB 用户账号但不同的 Token。 - */ -public class TaskClaimConcurrencyTest extends AbstractIntegrationTest { - - @Autowired - private TestRestTemplate restTemplate; - - @Autowired - private RedisService redisService; - - private Long taskId; - private final List tokens = new ArrayList<>(); - - @BeforeEach - void setup() { - // 创建测试任务(直接向 DB 插入一条 UNCLAIMED 任务) - jdbcTemplate.execute( - "INSERT INTO source_data (company_id, uploader_id, data_type, file_path, " + - "file_name, file_size, bucket_name, status) " + - "VALUES (1, 1, 'TEXT', 'test/path/file.txt', 'file.txt', 100, 'test-bucket', 'PENDING')"); - Long sourceId = jdbcTemplate.queryForObject( - "SELECT id FROM source_data ORDER BY id DESC LIMIT 1", Long.class); - - jdbcTemplate.execute( - "INSERT INTO annotation_task (company_id, source_id, task_type, status) " + - "VALUES (1, " + sourceId + ", 'EXTRACTION', 'UNCLAIMED')"); - taskId = jdbcTemplate.queryForObject( - "SELECT id FROM annotation_task ORDER BY id DESC LIMIT 1", Long.class); - - // 创建 10 个 Annotator Token(模拟不同用户) - for (int i = 1; i <= 10; i++) { - String token = "concurrency-test-token-" + i; - tokens.add(token); - // 所有 Token 使用 userId=3(annotator01),这在真实场景不会发生 - // 但在测试中用于验证并发锁机制(redis key 基于 taskId,不是 userId) - redisService.hSetAll(RedisUtil.tokenKey(token), - Map.of("userId", String.valueOf(i + 100), // 假设 userId > 100 不存在,但不影响锁逻辑 - "role", "ANNOTATOR", "companyId", "1", "username", "annotator" + i), - 3600L); - } - } - - @AfterEach - void cleanup() { - tokens.forEach(token -> redisService.delete(RedisUtil.tokenKey(token))); - if (taskId != null) { - redisService.delete(RedisUtil.taskClaimKey(taskId)); - } - } - - @Test - @DisplayName("10 线程并发抢同一任务:恰好 1 人成功,其余 9 人收到 409 TASK_CLAIMED") - void concurrentClaim_onlyOneSucceeds() throws InterruptedException { - ExecutorService executor = Executors.newFixedThreadPool(10); - CountDownLatch startLatch = new CountDownLatch(1); - CountDownLatch doneLatch = new CountDownLatch(10); - - AtomicInteger successCount = new AtomicInteger(0); - AtomicInteger conflictCount = new AtomicInteger(0); - - for (int i = 0; i < 10; i++) { - final String token = tokens.get(i); - executor.submit(() -> { - try { - startLatch.await(); // 等待起跑信号,最大化并发 - - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", "Bearer " + token); - HttpEntity request = new HttpEntity<>(headers); - - ResponseEntity response = restTemplate.exchange( - baseUrl("/api/tasks/" + taskId + "/claim"), - HttpMethod.POST, request, Map.class); - - if (response.getStatusCode() == HttpStatus.OK) { - successCount.incrementAndGet(); - } else if (response.getStatusCode() == HttpStatus.CONFLICT) { - conflictCount.incrementAndGet(); - } - } catch (Exception e) { - conflictCount.incrementAndGet(); // 异常也算失败 - } finally { - doneLatch.countDown(); - } - }); - } - - startLatch.countDown(); // 同时放行所有线程 - doneLatch.await(30, TimeUnit.SECONDS); - executor.shutdown(); - - // 恰好 1 人成功 - assertThat(successCount.get()).isEqualTo(1); - // 其余 9 人失败(409 或异常) - assertThat(conflictCount.get()).isEqualTo(9); - - // DB 中 claimed_by 有且仅有一个值 - String claimedByStr = jdbcTemplate.queryForObject( - "SELECT claimed_by::text FROM annotation_task WHERE id = ?", - String.class, taskId); - assertThat(claimedByStr).isNotNull(); - - // DB 中状态为 IN_PROGRESS - String status = jdbcTemplate.queryForObject( - "SELECT status FROM annotation_task WHERE id = ?", String.class, taskId); - assertThat(status).isEqualTo("IN_PROGRESS"); - } -} diff --git a/src/test/java/com/label/integration/UserManagementIntegrationTest.java b/src/test/java/com/label/integration/UserManagementIntegrationTest.java deleted file mode 100644 index 439984e..0000000 --- a/src/test/java/com/label/integration/UserManagementIntegrationTest.java +++ /dev/null @@ -1,177 +0,0 @@ -package com.label.integration; - -import com.label.AbstractIntegrationTest; -import com.label.dto.LoginRequest; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.http.*; - -import java.util.Map; -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * 用户管理集成测试(US7)。 - * - * 测试场景: - * 1. 变更角色后权限下一次请求立即生效(无需重新登录) - * 2. 禁用账号后现有 Token 下一次请求立即返回 401 - */ -public class UserManagementIntegrationTest extends AbstractIntegrationTest { - - @Autowired - private TestRestTemplate restTemplate; - - private String adminToken; - - @BeforeEach - void setup() { - adminToken = loginAndGetToken("DEMO", "admin", "admin123"); - assertThat(adminToken).isNotBlank(); - } - - // ------------------------------------------------------------------ 测试 1: 角色变更立即生效 -- - - @Test - @DisplayName("创建用户为 ANNOTATOR,变更为 REVIEWER 后同一 Token 立即可访问审批接口") - void updateRole_takesEffectImmediately() { - String uniqueUsername = "testuser-" + UUID.randomUUID().toString().substring(0, 8); - - // 1. 创建 ANNOTATOR 用户 - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", "Bearer " + adminToken); - headers.setContentType(MediaType.APPLICATION_JSON); - - ResponseEntity createResp = restTemplate.exchange( - baseUrl("/api/users"), - HttpMethod.POST, - new HttpEntity<>(Map.of( - "username", uniqueUsername, - "password", "test1234", - "realName", "测试用户", - "role", "ANNOTATOR" - ), headers), - Map.class); - assertThat(createResp.getStatusCode()).isEqualTo(HttpStatus.OK); - - @SuppressWarnings("unchecked") - Map userData = (Map) createResp.getBody().get("data"); - Long newUserId = ((Number) userData.get("id")).longValue(); - - // 2. 新用户登录获取 Token - String userToken = loginAndGetToken("DEMO", uniqueUsername, "test1234"); - assertThat(userToken).isNotBlank(); - - // 3. 验证:ANNOTATOR 无法访问待审批队列(REVIEWER 专属)→ 403 - ResponseEntity beforeRoleChange = restTemplate.exchange( - baseUrl("/api/tasks/pending-review"), - HttpMethod.GET, - bearerRequest(userToken), - Map.class); - assertThat(beforeRoleChange.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); - - // 4. ADMIN 变更角色为 REVIEWER - ResponseEntity roleResp = restTemplate.exchange( - baseUrl("/api/users/" + newUserId + "/role"), - HttpMethod.PUT, - new HttpEntity<>(Map.of("role", "REVIEWER"), headers), - Map.class); - assertThat(roleResp.getStatusCode()).isEqualTo(HttpStatus.OK); - - // 5. 验证:同一 Token 下次请求立即具有 REVIEWER 权限 → 200 - ResponseEntity afterRoleChange = restTemplate.exchange( - baseUrl("/api/tasks/pending-review"), - HttpMethod.GET, - bearerRequest(userToken), - Map.class); - assertThat(afterRoleChange.getStatusCode()) - .as("角色变更后同一 Token 应立即具有 REVIEWER 权限") - .isEqualTo(HttpStatus.OK); - } - - // ------------------------------------------------------------------ 测试 2: 禁用账号 Token 立即失效 -- - - @Test - @DisplayName("禁用账号后,现有 Token 下一次请求立即返回 401") - void disableAccount_tokenInvalidatedImmediately() { - String uniqueUsername = "testuser-" + UUID.randomUUID().toString().substring(0, 8); - - // 1. 创建用户 - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", "Bearer " + adminToken); - headers.setContentType(MediaType.APPLICATION_JSON); - - ResponseEntity createResp = restTemplate.exchange( - baseUrl("/api/users"), - HttpMethod.POST, - new HttpEntity<>(Map.of( - "username", uniqueUsername, - "password", "test1234", - "realName", "测试用户", - "role", "ANNOTATOR" - ), headers), - Map.class); - assertThat(createResp.getStatusCode()).isEqualTo(HttpStatus.OK); - - @SuppressWarnings("unchecked") - Map userData = (Map) createResp.getBody().get("data"); - Long newUserId = ((Number) userData.get("id")).longValue(); - - // 2. 新用户登录,获取 Token - String userToken = loginAndGetToken("DEMO", uniqueUsername, "test1234"); - assertThat(userToken).isNotBlank(); - - // 3. 验证 Token 有效 - ResponseEntity meResp = restTemplate.exchange( - baseUrl("/api/auth/me"), - HttpMethod.GET, - bearerRequest(userToken), - Map.class); - assertThat(meResp.getStatusCode()).isEqualTo(HttpStatus.OK); - - // 4. ADMIN 禁用账号 - ResponseEntity disableResp = restTemplate.exchange( - baseUrl("/api/users/" + newUserId + "/status"), - HttpMethod.PUT, - new HttpEntity<>(Map.of("status", "DISABLED"), headers), - Map.class); - assertThat(disableResp.getStatusCode()).isEqualTo(HttpStatus.OK); - - // 5. 验证:禁用后,现有 Token 立即失效 → 401 - ResponseEntity meAfterDisable = restTemplate.exchange( - baseUrl("/api/auth/me"), - HttpMethod.GET, - bearerRequest(userToken), - Map.class); - assertThat(meAfterDisable.getStatusCode()) - .as("禁用账号后现有 Token 应立即失效") - .isEqualTo(HttpStatus.UNAUTHORIZED); - } - - // ------------------------------------------------------------------ 工具方法 -- - - private String loginAndGetToken(String companyCode, String username, String password) { - LoginRequest req = new LoginRequest(); - req.setCompanyCode(companyCode); - req.setUsername(username); - req.setPassword(password); - ResponseEntity response = restTemplate.postForEntity( - baseUrl("/api/auth/login"), req, Map.class); - if (!response.getStatusCode().is2xxSuccessful()) { - return null; - } - @SuppressWarnings("unchecked") - Map data = (Map) response.getBody().get("data"); - return (String) data.get("token"); - } - - private HttpEntity bearerRequest(String token) { - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", "Bearer " + token); - return new HttpEntity<>(headers); - } -} diff --git a/src/test/java/com/label/integration/VideoCallbackIdempotencyTest.java b/src/test/java/com/label/integration/VideoCallbackIdempotencyTest.java deleted file mode 100644 index 8cc07ff..0000000 --- a/src/test/java/com/label/integration/VideoCallbackIdempotencyTest.java +++ /dev/null @@ -1,183 +0,0 @@ -package com.label.integration; - -import com.label.AbstractIntegrationTest; -import com.label.service.RedisService; -import com.label.util.RedisUtil; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.http.*; - -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * 视频处理回调幂等与重试集成测试(US8)。 - * - * 测试场景: - * 1. 同一 jobId 收到两次 SUCCESS 回调:annotation_task(EXTRACTION)仅创建一次 - * 2. 超出最大重试次数 → job.status = FAILED,source_data.status = PENDING - */ -public class VideoCallbackIdempotencyTest extends AbstractIntegrationTest { - - private static final String ADMIN_TOKEN = "test-admin-token-video"; - - @Autowired - private TestRestTemplate restTemplate; - - @Autowired - private RedisService redisService; - - private Long companyId; - private Long adminUserId; - private Long sourceId; - private Long jobId; - - @BeforeEach - void setupTokenAndData() { - companyId = jdbcTemplate.queryForObject( - "SELECT id FROM sys_company WHERE company_code = 'DEMO'", Long.class); - adminUserId = jdbcTemplate.queryForObject( - "SELECT id FROM sys_user WHERE username = 'admin'", Long.class); - - // 伪造 Redis Token - redisService.hSetAll(RedisUtil.tokenKey(ADMIN_TOKEN), - Map.of("userId", adminUserId.toString(), "role", "ADMIN", - "companyId", companyId.toString(), "username", "admin"), - 3600L); - - // 插入 source_data(PREPROCESSING 状态,模拟视频处理中) - jdbcTemplate.execute( - "INSERT INTO source_data (company_id, uploader_id, data_type, file_path, " + - "file_name, file_size, bucket_name, status) " + - "VALUES (" + companyId + ", " + adminUserId + ", 'VIDEO', " + - "'videos/test.mp4', 'test.mp4', 10240, 'label-source-data', 'PREPROCESSING')"); - sourceId = jdbcTemplate.queryForObject( - "SELECT id FROM source_data ORDER BY id DESC LIMIT 1", Long.class); - - // 插入 PENDING 视频处理任务 - jdbcTemplate.execute( - "INSERT INTO video_process_job (company_id, source_id, job_type, status, " + - "params, retry_count, max_retries) " + - "VALUES (" + companyId + ", " + sourceId + ", 'FRAME_EXTRACT', 'PENDING', " + - "'{}'::jsonb, 0, 3)"); - jobId = jdbcTemplate.queryForObject( - "SELECT id FROM video_process_job ORDER BY id DESC LIMIT 1", Long.class); - } - - @AfterEach - void cleanupTokens() { - redisService.delete(RedisUtil.tokenKey(ADMIN_TOKEN)); - } - - // ------------------------------------------------------------------ 测试 1: 幂等性 -- - - @Test - @DisplayName("同一 jobId 发送两次 SUCCESS 回调:source_data 仅更新一次,status=PENDING") - void successCallback_idempotent_sourceUpdatedOnce() { - // 第一次 SUCCESS 回调 - ResponseEntity resp1 = sendCallback(jobId, "SUCCESS", "processed/frames.zip", null); - assertThat(resp1.getStatusCode()).isEqualTo(HttpStatus.OK); - - // 验证第一次回调后状态 - String jobStatus1 = jdbcTemplate.queryForObject( - "SELECT status FROM video_process_job WHERE id = ?", String.class, jobId); - assertThat(jobStatus1).isEqualTo("SUCCESS"); - - String sourceStatus1 = jdbcTemplate.queryForObject( - "SELECT status FROM source_data WHERE id = ?", String.class, sourceId); - assertThat(sourceStatus1).isEqualTo("PENDING"); - - // 第二次 SUCCESS 回调(幂等:应直接返回,不重复处理) - ResponseEntity resp2 = sendCallback(jobId, "SUCCESS", "processed/frames.zip", null); - assertThat(resp2.getStatusCode()).isEqualTo(HttpStatus.OK); - - // 状态仍为 SUCCESS + PENDING,未被改变 - String jobStatus2 = jdbcTemplate.queryForObject( - "SELECT status FROM video_process_job WHERE id = ?", String.class, jobId); - assertThat(jobStatus2).as("幂等:第二次回调不应改变 job 状态").isEqualTo("SUCCESS"); - - String sourceStatus2 = jdbcTemplate.queryForObject( - "SELECT status FROM source_data WHERE id = ?", String.class, sourceId); - assertThat(sourceStatus2).as("幂等:第二次回调不应改变 source_data 状态").isEqualTo("PENDING"); - } - - // ------------------------------------------------------------------ 测试 2: 超出重试上限 → FAILED -- - - @Test - @DisplayName("超出最大重试次数后 → job.status=FAILED,source_data.status=PENDING") - void failedCallback_exceedsMaxRetries_jobBecomesFailedAndSourceReverts() { - // 将 retry_count 设为 max_retries-1(再失败一次就超限) - jdbcTemplate.execute( - "UPDATE video_process_job SET retry_count = 2, max_retries = 3, " + - "status = 'RETRYING' WHERE id = " + jobId); - - // 发送最后一次 FAILED 回调(retry_count 变为 3 = max_retries → 超限) - ResponseEntity resp = sendCallback(jobId, "FAILED", null, "ffmpeg 处理超时"); - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); - - // 验证 job → FAILED - Map jobRow = jdbcTemplate.queryForMap( - "SELECT status, retry_count, error_message FROM video_process_job WHERE id = ?", jobId); - assertThat(jobRow.get("status")).as("超出重试上限后 job 应为 FAILED").isEqualTo("FAILED"); - assertThat(((Number) jobRow.get("retry_count")).intValue()).isEqualTo(3); - assertThat(jobRow.get("error_message")).isEqualTo("ffmpeg 处理超时"); - - // 验证 source_data → PENDING(管理员可重新处理) - String sourceStatus = jdbcTemplate.queryForObject( - "SELECT status FROM source_data WHERE id = ?", String.class, sourceId); - assertThat(sourceStatus).as("超出重试上限后 source_data 应回退为 PENDING").isEqualTo("PENDING"); - } - - // ------------------------------------------------------------------ 测试 3: 管理员重置 -- - - @Test - @DisplayName("管理员重置 FAILED 任务 → job.status=PENDING,retryCount=0") - void resetFailedJob_succeeds() { - // 先将任务置为 FAILED 状态 - jdbcTemplate.execute( - "UPDATE video_process_job SET status = 'FAILED', retry_count = 3 WHERE id = " + jobId); - - // 重置 - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", "Bearer " + ADMIN_TOKEN); - ResponseEntity resp = restTemplate.exchange( - baseUrl("/api/video/jobs/" + jobId + "/reset"), - HttpMethod.POST, - new HttpEntity<>(headers), - Map.class); - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); - - // 验证 - Map jobRow = jdbcTemplate.queryForMap( - "SELECT status, retry_count FROM video_process_job WHERE id = ?", jobId); - assertThat(jobRow.get("status")).isEqualTo("PENDING"); - assertThat(((Number) jobRow.get("retry_count")).intValue()).isEqualTo(0); - } - - // ------------------------------------------------------------------ 工具方法 -- - - private ResponseEntity sendCallback(Long jobId, String status, - String outputPath, String errorMessage) { - Map body; - if ("SUCCESS".equals(status)) { - body = Map.of("jobId", jobId, "status", status, "outputPath", outputPath); - } else { - body = Map.of("jobId", jobId, "status", status, "errorMessage", - errorMessage != null ? errorMessage : ""); - } - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - return restTemplate.exchange( - baseUrl("/api/video/callback"), - HttpMethod.POST, - new HttpEntity<>(body, headers), - Map.class); - } -} diff --git a/src/test/java/com/label/unit/ApplicationConfigTest.java b/src/test/java/com/label/unit/ApplicationConfigTest.java deleted file mode 100644 index 2ca303e..0000000 --- a/src/test/java/com/label/unit/ApplicationConfigTest.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.label.unit; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.boot.env.YamlPropertySourceLoader; -import org.springframework.core.env.PropertySource; -import org.springframework.core.io.ClassPathResource; - -import java.nio.charset.StandardCharsets; - -import static org.assertj.core.api.Assertions.assertThat; - -@DisplayName("应用配置单元测试") -class ApplicationConfigTest { - - @Test - @DisplayName("application.yml 提供 Swagger 和 auth 测试开关配置") - void applicationYaml_containsSwaggerAndAuthToggle() throws Exception { - PropertySource source = new YamlPropertySourceLoader() - .load("application", new ClassPathResource("application.yml")) - .get(0); - - assertThat(source.getProperty("springdoc.api-docs.enabled")).isEqualTo(true); - assertThat(source.getProperty("springdoc.api-docs.path")).isEqualTo("/v3/api-docs"); - assertThat(source.getProperty("springdoc.swagger-ui.enabled")).isEqualTo(true); - assertThat(source.getProperty("springdoc.swagger-ui.path")).isEqualTo("/swagger-ui.html"); - assertThat(source.getProperty("auth.enabled")).isEqualTo(true); - assertThat(source.getProperty("auth.mock-company-id")).isEqualTo(1); - assertThat(source.getProperty("auth.mock-user-id")).isEqualTo(1); - assertThat(source.getProperty("auth.mock-role")).isEqualTo("ADMIN"); - assertThat(source.getProperty("logging.level.com.label")).isEqualTo("INFO"); - } - - @Test - @DisplayName("application.yml 默认值不指向公网服务或携带真实默认密码") - void applicationYaml_doesNotShipPublicInfrastructureDefaults() throws Exception { - String yaml = new ClassPathResource("application.yml") - .getContentAsString(StandardCharsets.UTF_8); - - assertThat(yaml).doesNotContain("39.107.112.174"); - assertThat(yaml).doesNotContain("postgres!Pw"); - assertThat(yaml).doesNotContain("jsti@2024"); - } - - @Test - @DisplayName("logback.xml 启用 60 MB 滚动文件日志") - void logback_enablesRollingFileAppender() throws Exception { - String xml = new ClassPathResource("logback.xml") - .getContentAsString(StandardCharsets.UTF_8); - - assertThat(xml).contains("60MB"); - assertThat(xml).contains(""); - assertThat(xml).doesNotContain(""); - } -} diff --git a/src/test/java/com/label/unit/AuthInterceptorTest.java b/src/test/java/com/label/unit/AuthInterceptorTest.java deleted file mode 100644 index 932dca7..0000000 --- a/src/test/java/com/label/unit/AuthInterceptorTest.java +++ /dev/null @@ -1,154 +0,0 @@ -package com.label.unit; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.label.annotation.RequireAuth; -import com.label.annotation.RequireRole; -import com.label.common.auth.TokenPrincipal; -import com.label.common.context.CompanyContext; -import com.label.interceptor.AuthInterceptor; -import com.label.service.RedisService; -import com.label.util.RedisUtil; -import jakarta.servlet.http.HttpServletResponse; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.mock.web.MockHttpServletResponse; -import org.springframework.test.util.ReflectionTestUtils; -import org.springframework.web.method.HandlerMethod; - -import java.lang.reflect.Method; -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@DisplayName("自定义认证鉴权拦截器测试") -class AuthInterceptorTest { - - private final RedisService redisService = mock(RedisService.class); - private final AuthInterceptor interceptor = new AuthInterceptor(redisService, new ObjectMapper()); - - @AfterEach - void tearDown() { - CompanyContext.clear(); - } - - @Test - @DisplayName("有效 Token 会注入 Principal、租户上下文并刷新 TTL") - void validTokenInjectsPrincipalAndRefreshesTtl() throws Exception { - ReflectionTestUtils.setField(interceptor, "authEnabled", true); - ReflectionTestUtils.setField(interceptor, "tokenTtlSeconds", 7200L); - when(redisService.hGetAll(RedisUtil.tokenKey("valid-token"))).thenReturn(Map.of( - "userId", "10", - "role", "ADMIN", - "companyId", "20", - "username", "admin" - )); - - MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/test/admin"); - request.addHeader("Authorization", "Bearer valid-token"); - MockHttpServletResponse response = new MockHttpServletResponse(); - - boolean proceed = interceptor.preHandle(request, response, handler("adminOnly")); - - assertThat(proceed).isTrue(); - TokenPrincipal principal = (TokenPrincipal) request.getAttribute("__token_principal__"); - assertThat(principal.getUserId()).isEqualTo(10L); - assertThat(principal.getRole()).isEqualTo("ADMIN"); - assertThat(CompanyContext.get()).isEqualTo(20L); - verify(redisService).expire(RedisUtil.tokenKey("valid-token"), 7200L); - verify(redisService).expire(RedisUtil.userSessionsKey(10L), 7200L); - } - - @Test - @DisplayName("角色继承规则允许 ADMIN 访问 REVIEWER 接口") - void adminRoleInheritsReviewerRole() throws Exception { - ReflectionTestUtils.setField(interceptor, "authEnabled", true); - when(redisService.hGetAll(RedisUtil.tokenKey("admin-token"))).thenReturn(Map.of( - "userId", "1", - "role", "ADMIN", - "companyId", "1", - "username", "admin" - )); - - MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/test/reviewer"); - request.addHeader("Authorization", "Bearer admin-token"); - MockHttpServletResponse response = new MockHttpServletResponse(); - - assertThat(interceptor.preHandle(request, response, handler("reviewerOnly"))).isTrue(); - assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); - } - - @Test - @DisplayName("角色不足时返回 403") - void insufficientRoleReturnsForbidden() throws Exception { - ReflectionTestUtils.setField(interceptor, "authEnabled", true); - when(redisService.hGetAll(RedisUtil.tokenKey("annotator-token"))).thenReturn(Map.of( - "userId", "2", - "role", "ANNOTATOR", - "companyId", "1", - "username", "annotator" - )); - - MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/test/reviewer"); - request.addHeader("Authorization", "Bearer annotator-token"); - MockHttpServletResponse response = new MockHttpServletResponse(); - - assertThat(interceptor.preHandle(request, response, handler("reviewerOnly"))).isFalse(); - assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FORBIDDEN); - } - - @Test - @DisplayName("缺少 Token 时返回 401") - void missingTokenReturnsUnauthorized() throws Exception { - ReflectionTestUtils.setField(interceptor, "authEnabled", true); - MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/test/admin"); - MockHttpServletResponse response = new MockHttpServletResponse(); - - assertThat(interceptor.preHandle(request, response, handler("adminOnly"))).isFalse(); - assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED); - verify(redisService, never()).hGetAll(org.mockito.ArgumentMatchers.anyString()); - } - - @Test - @DisplayName("UserContext 已被移除,避免重复维护用户 ThreadLocal") - void userContextClassShouldBeRemoved() { - assertThatThrownBy(() -> Class.forName("com.label.common.context.UserContext")) - .isInstanceOf(ClassNotFoundException.class); - } - - @Test - @DisplayName("请求完成后清理公司 ThreadLocal") - void afterCompletionClearsCompanyContext() throws Exception { - CompanyContext.set(20L); - - interceptor.afterCompletion(new MockHttpServletRequest(), new MockHttpServletResponse(), - handler("adminOnly"), null); - - assertThat(CompanyContext.get()).isEqualTo(-1L); - } - - private static HandlerMethod handler(String methodName) throws NoSuchMethodException { - Method method = TestController.class.getDeclaredMethod(methodName); - return new HandlerMethod(new TestController(), method); - } - - private static class TestController { - @RequireRole("ADMIN") - void adminOnly() { - } - - @RequireRole("REVIEWER") - void reviewerOnly() { - } - - @RequireAuth - void authenticatedOnly() { - } - } -} diff --git a/src/test/java/com/label/unit/CompanyServiceTest.java b/src/test/java/com/label/unit/CompanyServiceTest.java deleted file mode 100644 index 6dd8dd6..0000000 --- a/src/test/java/com/label/unit/CompanyServiceTest.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.label.unit; - -import com.label.common.exception.BusinessException; -import com.label.entity.SysCompany; -import com.label.mapper.SysCompanyMapper; -import com.label.mapper.SysUserMapper; -import com.label.service.CompanyService; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@DisplayName("公司管理服务测试") -class CompanyServiceTest { - - private final SysCompanyMapper companyMapper = mock(SysCompanyMapper.class); - private final SysUserMapper userMapper = mock(SysUserMapper.class); - private final CompanyService companyService = new CompanyService(companyMapper, userMapper); - - @Test - @DisplayName("创建公司时写入 ACTIVE 状态并保存公司代码") - void createCompanyInsertsActiveCompany() { - SysCompany company = companyService.create("测试公司", "TEST"); - - assertThat(company.getCompanyName()).isEqualTo("测试公司"); - assertThat(company.getCompanyCode()).isEqualTo("TEST"); - assertThat(company.getStatus()).isEqualTo("ACTIVE"); - verify(companyMapper).insert(any(SysCompany.class)); - } - - @Test - @DisplayName("创建公司时拒绝重复公司代码") - void createCompanyRejectsDuplicateCode() { - SysCompany existing = new SysCompany(); - existing.setId(1L); - when(companyMapper.selectByCompanyCode("DEMO")).thenReturn(existing); - - assertThatThrownBy(() -> companyService.create("演示公司", "DEMO")) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("公司代码已存在"); - } - - @Test - @DisplayName("禁用公司时只允许 ACTIVE 或 DISABLED") - void updateStatusRejectsInvalidStatus() { - SysCompany existing = new SysCompany(); - existing.setId(1L); - existing.setStatus("ACTIVE"); - when(companyMapper.selectById(1L)).thenReturn(existing); - - assertThatThrownBy(() -> companyService.updateStatus(1L, "DELETED")) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("公司状态不合法"); - } - - @Test - @DisplayName("删除公司时若仍有关联用户则拒绝删除") - void deleteRejectsCompanyWithUsers() { - SysCompany existing = new SysCompany(); - existing.setId(1L); - when(companyMapper.selectById(1L)).thenReturn(existing); - when(userMapper.countByCompanyId(1L)).thenReturn(2L); - - assertThatThrownBy(() -> companyService.delete(1L)) - .isInstanceOf(BusinessException.class) - .hasMessageContaining("公司下仍存在用户"); - } -} diff --git a/src/test/java/com/label/unit/OpenApiAnnotationTest.java b/src/test/java/com/label/unit/OpenApiAnnotationTest.java deleted file mode 100644 index 230c67a..0000000 --- a/src/test/java/com/label/unit/OpenApiAnnotationTest.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.label.unit; - -import com.label.controller.AuthController; -import com.label.controller.CompanyController; -import com.label.controller.ExportController; -import com.label.controller.ExtractionController; -import com.label.controller.QaController; -import com.label.controller.SourceController; -import com.label.controller.SysConfigController; -import com.label.controller.TaskController; -import com.label.controller.UserController; -import com.label.controller.VideoController; -import com.label.dto.SourceResponse; -import com.label.dto.TaskResponse; -import com.label.dto.LoginRequest; -import com.label.dto.LoginResponse; -import com.label.dto.UserInfoResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestMapping; - -import java.lang.reflect.Method; -import java.lang.reflect.Modifier; -import java.util.Arrays; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -@DisplayName("OpenAPI 注解覆盖测试") -class OpenApiAnnotationTest { - - private static final List> CONTROLLERS = List.of( - AuthController.class, - CompanyController.class, - UserController.class, - SourceController.class, - TaskController.class, - ExtractionController.class, - QaController.class, - ExportController.class, - SysConfigController.class, - VideoController.class - ); - - private static final List> DTOS = List.of( - LoginRequest.class, - LoginResponse.class, - UserInfoResponse.class, - TaskResponse.class, - SourceResponse.class - ); - - @Test - @DisplayName("所有 REST Controller 都声明 @Tag") - void allControllersHaveTag() { - assertThat(CONTROLLERS) - .allSatisfy(controller -> - assertThat(controller.getAnnotation(Tag.class)) - .as(controller.getSimpleName() + " should have @Tag") - .isNotNull()); - } - - @Test - @DisplayName("所有 REST endpoint 方法都声明 @Operation") - void allEndpointMethodsHaveOperation() { - for (Class controller : CONTROLLERS) { - Arrays.stream(controller.getDeclaredMethods()) - .filter(method -> !Modifier.isPrivate(method.getModifiers())) - .filter(OpenApiAnnotationTest::isEndpointMethod) - .forEach(method -> assertThat(method.getAnnotation(Operation.class)) - .as(controller.getSimpleName() + "." + method.getName() + " should have @Operation") - .isNotNull()); - } - } - - @Test - @DisplayName("核心 DTO 都声明 @Schema") - void coreDtosHaveSchema() { - assertThat(DTOS) - .allSatisfy(dto -> - assertThat(dto.getAnnotation(Schema.class)) - .as(dto.getSimpleName() + " should have @Schema") - .isNotNull()); - } - - private static boolean isEndpointMethod(Method method) { - return method.isAnnotationPresent(GetMapping.class) - || method.isAnnotationPresent(PostMapping.class) - || method.isAnnotationPresent(PutMapping.class) - || method.isAnnotationPresent(DeleteMapping.class) - || method.isAnnotationPresent(RequestMapping.class); - } -} diff --git a/src/test/java/com/label/unit/PackageStructureMigrationTest.java b/src/test/java/com/label/unit/PackageStructureMigrationTest.java deleted file mode 100644 index 19eb651..0000000 --- a/src/test/java/com/label/unit/PackageStructureMigrationTest.java +++ /dev/null @@ -1,135 +0,0 @@ -package com.label.unit; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -@DisplayName("标准目录扁平化迁移守卫测试") -class PackageStructureMigrationTest { - - @Test - @DisplayName("基础设施类已迁移到目标目录") - void infrastructureTypesMoved() { - assertClassExists("com.label.annotation.OperationLog"); - assertClassExists("com.label.aspect.AuditAspect"); - assertClassExists("com.label.config.MybatisPlusConfig"); - assertClassExists("com.label.config.OpenApiConfig"); - assertClassExists("com.label.config.RedisConfig"); - assertClassExists("com.label.event.ExtractionApprovedEvent"); - assertClassExists("com.label.listener.ExtractionApprovedEventListener"); - - assertClassMissing("com.label.common.aop.OperationLog"); - assertClassMissing("com.label.common.aop.AuditAspect"); - assertClassMissing("com.label.common.config.MybatisPlusConfig"); - assertClassMissing("com.label.common.config.OpenApiConfig"); - assertClassMissing("com.label.common.config.RedisConfig"); - assertClassMissing("com.label.module.annotation.event.ExtractionApprovedEvent"); - assertClassMissing("com.label.module.annotation.service.ExtractionApprovedEventListener"); - } - - @Test - @DisplayName("DTO、实体、Mapper 已迁移到扁平数据层") - void dataTypesMoved() { - for (String fqcn : java.util.List.of( - "com.label.dto.LoginRequest", - "com.label.dto.LoginResponse", - "com.label.dto.UserInfoResponse", - "com.label.dto.TaskResponse", - "com.label.dto.SourceResponse", - "com.label.entity.AnnotationResult", - "com.label.entity.TrainingDataset", - "com.label.entity.SysConfig", - "com.label.entity.ExportBatch", - "com.label.entity.SourceData", - "com.label.entity.AnnotationTask", - "com.label.entity.AnnotationTaskHistory", - "com.label.entity.SysCompany", - "com.label.entity.SysUser", - "com.label.entity.VideoProcessJob", - "com.label.mapper.AnnotationResultMapper", - "com.label.mapper.TrainingDatasetMapper", - "com.label.mapper.SysConfigMapper", - "com.label.mapper.ExportBatchMapper", - "com.label.mapper.SourceDataMapper", - "com.label.mapper.AnnotationTaskMapper", - "com.label.mapper.TaskHistoryMapper", - "com.label.mapper.SysCompanyMapper", - "com.label.mapper.SysUserMapper", - "com.label.mapper.VideoProcessJobMapper")) { - assertClassExists(fqcn); - } - } - - @Test - @DisplayName("服务类已迁移到扁平 service 目录") - void serviceTypesMoved() { - for (String fqcn : java.util.List.of( - "com.label.service.ExtractionService", - "com.label.service.QaService", - "com.label.service.SysConfigService", - "com.label.service.ExportService", - "com.label.service.FinetuneService", - "com.label.service.SourceService", - "com.label.service.TaskClaimService", - "com.label.service.TaskService", - "com.label.service.AuthService", - "com.label.service.UserService", - "com.label.service.VideoProcessService")) { - assertClassExists(fqcn); - } - } - - @Test - @DisplayName("控制器类已迁移到扁平 controller 目录") - void controllerTypesMoved() { - for (String fqcn : java.util.List.of( - "com.label.controller.AuthController", - "com.label.controller.UserController", - "com.label.controller.SourceController", - "com.label.controller.TaskController", - "com.label.controller.ExtractionController", - "com.label.controller.QaController", - "com.label.controller.ExportController", - "com.label.controller.SysConfigController", - "com.label.controller.VideoController")) { - assertClassExists(fqcn); - } - } - - @Test - @DisplayName("源码中不再引用 legacy module 与 common 目录包名") - void sourceTreeHasNoLegacyPackageReferences() throws Exception { - java.nio.file.Path self = java.nio.file.Path.of( - "src", "test", "java", "com", "label", "unit", "PackageStructureMigrationTest.java"); - try (java.util.stream.Stream paths = java.nio.file.Files.walk(java.nio.file.Path.of("src"))) { - java.util.List violations = paths - .filter(path -> path.toString().endsWith(".java")) - .filter(path -> !path.normalize().endsWith(self)) - .map(path -> { - try { - String text = java.nio.file.Files.readString(path); - boolean legacy = text.contains("com.label.module.") - || text.contains("com.label.common.aop") - || text.contains("com.label.common.config"); - return legacy ? path.toString() : null; - } catch (Exception e) { - throw new RuntimeException(e); - } - }) - .filter(java.util.Objects::nonNull) - .toList(); - - org.assertj.core.api.Assertions.assertThat(violations).isEmpty(); - } - } - - private static void assertClassExists(String fqcn) { - assertThatCode(() -> Class.forName(fqcn)).doesNotThrowAnyException(); - } - - private static void assertClassMissing(String fqcn) { - assertThatThrownBy(() -> Class.forName(fqcn)).isInstanceOf(ClassNotFoundException.class); - } -} diff --git a/src/test/java/com/label/unit/StateMachineTest.java b/src/test/java/com/label/unit/StateMachineTest.java deleted file mode 100644 index a563970..0000000 --- a/src/test/java/com/label/unit/StateMachineTest.java +++ /dev/null @@ -1,265 +0,0 @@ -package com.label.unit; - -import com.label.common.exception.BusinessException; -import com.label.common.statemachine.*; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.*; - -/** - * Unit tests for all state machine enums and StateValidator. - * No Spring context needed - pure unit tests. - */ -@DisplayName("状态机单元测试") -class StateMachineTest { - - // ===== SourceStatus ===== - @Nested - @DisplayName("SourceStatus 状态机") - class SourceStatusTest { - - @Test - @DisplayName("合法转换:PENDING → EXTRACTING(文本/图片直接提取)") - void pendingToExtracting() { - assertThatCode(() -> - StateValidator.assertTransition(SourceStatus.TRANSITIONS, SourceStatus.PENDING, SourceStatus.EXTRACTING) - ).doesNotThrowAnyException(); - } - - @Test - @DisplayName("合法转换:PENDING → PREPROCESSING(视频上传)") - void pendingToPreprocessing() { - assertThatCode(() -> - StateValidator.assertTransition(SourceStatus.TRANSITIONS, SourceStatus.PENDING, SourceStatus.PREPROCESSING) - ).doesNotThrowAnyException(); - } - - @Test - @DisplayName("合法转换:PREPROCESSING → PENDING(视频预处理完成)") - void preprocessingToPending() { - assertThatCode(() -> - StateValidator.assertTransition(SourceStatus.TRANSITIONS, SourceStatus.PREPROCESSING, SourceStatus.PENDING) - ).doesNotThrowAnyException(); - } - - @Test - @DisplayName("合法转换:EXTRACTING → QA_REVIEW(提取审批通过)") - void extractingToQaReview() { - assertThatCode(() -> - StateValidator.assertTransition(SourceStatus.TRANSITIONS, SourceStatus.EXTRACTING, SourceStatus.QA_REVIEW) - ).doesNotThrowAnyException(); - } - - @Test - @DisplayName("合法转换:QA_REVIEW → APPROVED(QA 审批通过)") - void qaReviewToApproved() { - assertThatCode(() -> - StateValidator.assertTransition(SourceStatus.TRANSITIONS, SourceStatus.QA_REVIEW, SourceStatus.APPROVED) - ).doesNotThrowAnyException(); - } - - @Test - @DisplayName("非法转换:APPROVED → PENDING 抛出异常") - void approvedToPendingFails() { - assertThatThrownBy(() -> - StateValidator.assertTransition(SourceStatus.TRANSITIONS, SourceStatus.APPROVED, SourceStatus.PENDING) - ).isInstanceOf(BusinessException.class) - .extracting("code").isEqualTo("INVALID_STATE_TRANSITION"); - } - - @Test - @DisplayName("非法转换:PENDING → APPROVED(跳过中间状态)抛出异常") - void pendingToApprovedFails() { - assertThatThrownBy(() -> - StateValidator.assertTransition(SourceStatus.TRANSITIONS, SourceStatus.PENDING, SourceStatus.APPROVED) - ).isInstanceOf(BusinessException.class) - .extracting("code").isEqualTo("INVALID_STATE_TRANSITION"); - } - } - - // ===== TaskStatus ===== - @Nested - @DisplayName("TaskStatus 状态机") - class TaskStatusTest { - - @Test - @DisplayName("合法转换:UNCLAIMED → IN_PROGRESS(领取)") - void unclaimedToInProgress() { - assertThatCode(() -> - StateValidator.assertTransition(TaskStatus.TRANSITIONS, TaskStatus.UNCLAIMED, TaskStatus.IN_PROGRESS) - ).doesNotThrowAnyException(); - } - - @Test - @DisplayName("合法转换:IN_PROGRESS → SUBMITTED(提交)") - void inProgressToSubmitted() { - assertThatCode(() -> - StateValidator.assertTransition(TaskStatus.TRANSITIONS, TaskStatus.IN_PROGRESS, TaskStatus.SUBMITTED) - ).doesNotThrowAnyException(); - } - - @Test - @DisplayName("合法转换:IN_PROGRESS → UNCLAIMED(放弃)") - void inProgressToUnclaimed() { - assertThatCode(() -> - StateValidator.assertTransition(TaskStatus.TRANSITIONS, TaskStatus.IN_PROGRESS, TaskStatus.UNCLAIMED) - ).doesNotThrowAnyException(); - } - - @Test - @DisplayName("合法转换:IN_PROGRESS → IN_PROGRESS(ADMIN 强制转移,持有人变更)") - void inProgressToInProgress() { - assertThatCode(() -> - StateValidator.assertTransition(TaskStatus.TRANSITIONS, TaskStatus.IN_PROGRESS, TaskStatus.IN_PROGRESS) - ).doesNotThrowAnyException(); - } - - @Test - @DisplayName("合法转换:SUBMITTED → APPROVED(审批通过)") - void submittedToApproved() { - assertThatCode(() -> - StateValidator.assertTransition(TaskStatus.TRANSITIONS, TaskStatus.SUBMITTED, TaskStatus.APPROVED) - ).doesNotThrowAnyException(); - } - - @Test - @DisplayName("合法转换:SUBMITTED → REJECTED(审批驳回)") - void submittedToRejected() { - assertThatCode(() -> - StateValidator.assertTransition(TaskStatus.TRANSITIONS, TaskStatus.SUBMITTED, TaskStatus.REJECTED) - ).doesNotThrowAnyException(); - } - - @Test - @DisplayName("合法转换:REJECTED → IN_PROGRESS(标注员重领)") - void rejectedToInProgress() { - assertThatCode(() -> - StateValidator.assertTransition(TaskStatus.TRANSITIONS, TaskStatus.REJECTED, TaskStatus.IN_PROGRESS) - ).doesNotThrowAnyException(); - } - - @Test - @DisplayName("非法转换:APPROVED → IN_PROGRESS 抛出异常") - void approvedToInProgressFails() { - assertThatThrownBy(() -> - StateValidator.assertTransition(TaskStatus.TRANSITIONS, TaskStatus.APPROVED, TaskStatus.IN_PROGRESS) - ).isInstanceOf(BusinessException.class) - .extracting("code").isEqualTo("INVALID_STATE_TRANSITION"); - } - - @Test - @DisplayName("非法转换:UNCLAIMED → SUBMITTED(跳过 IN_PROGRESS)抛出异常") - void unclaimedToSubmittedFails() { - assertThatThrownBy(() -> - StateValidator.assertTransition(TaskStatus.TRANSITIONS, TaskStatus.UNCLAIMED, TaskStatus.SUBMITTED) - ).isInstanceOf(BusinessException.class) - .extracting("code").isEqualTo("INVALID_STATE_TRANSITION"); - } - } - - // ===== DatasetStatus ===== - @Nested - @DisplayName("DatasetStatus 状态机") - class DatasetStatusTest { - - @Test - @DisplayName("合法转换:PENDING_REVIEW → APPROVED") - void pendingReviewToApproved() { - assertThatCode(() -> - StateValidator.assertTransition(DatasetStatus.TRANSITIONS, DatasetStatus.PENDING_REVIEW, DatasetStatus.APPROVED) - ).doesNotThrowAnyException(); - } - - @Test - @DisplayName("合法转换:PENDING_REVIEW → REJECTED") - void pendingReviewToRejected() { - assertThatCode(() -> - StateValidator.assertTransition(DatasetStatus.TRANSITIONS, DatasetStatus.PENDING_REVIEW, DatasetStatus.REJECTED) - ).doesNotThrowAnyException(); - } - - @Test - @DisplayName("合法转换:REJECTED → PENDING_REVIEW(重新提交)") - void rejectedToPendingReview() { - assertThatCode(() -> - StateValidator.assertTransition(DatasetStatus.TRANSITIONS, DatasetStatus.REJECTED, DatasetStatus.PENDING_REVIEW) - ).doesNotThrowAnyException(); - } - - @Test - @DisplayName("非法转换:APPROVED → REJECTED 抛出异常") - void approvedToRejectedFails() { - assertThatThrownBy(() -> - StateValidator.assertTransition(DatasetStatus.TRANSITIONS, DatasetStatus.APPROVED, DatasetStatus.REJECTED) - ).isInstanceOf(BusinessException.class) - .extracting("code").isEqualTo("INVALID_STATE_TRANSITION"); - } - } - - // ===== VideoJobStatus ===== - @Nested - @DisplayName("VideoJobStatus 状态机") - class VideoJobStatusTest { - - @Test - @DisplayName("合法转换:PENDING → RUNNING") - void pendingToRunning() { - assertThatCode(() -> - StateValidator.assertTransition(VideoJobStatus.TRANSITIONS, VideoJobStatus.PENDING, VideoJobStatus.RUNNING) - ).doesNotThrowAnyException(); - } - - @Test - @DisplayName("合法转换:RUNNING → SUCCESS") - void runningToSuccess() { - assertThatCode(() -> - StateValidator.assertTransition(VideoJobStatus.TRANSITIONS, VideoJobStatus.RUNNING, VideoJobStatus.SUCCESS) - ).doesNotThrowAnyException(); - } - - @Test - @DisplayName("合法转换:RUNNING → RETRYING(失败且未超重试次数)") - void runningToRetrying() { - assertThatCode(() -> - StateValidator.assertTransition(VideoJobStatus.TRANSITIONS, VideoJobStatus.RUNNING, VideoJobStatus.RETRYING) - ).doesNotThrowAnyException(); - } - - @Test - @DisplayName("合法转换:RUNNING → FAILED(失败且超过最大重试)") - void runningToFailed() { - assertThatCode(() -> - StateValidator.assertTransition(VideoJobStatus.TRANSITIONS, VideoJobStatus.RUNNING, VideoJobStatus.FAILED) - ).doesNotThrowAnyException(); - } - - @Test - @DisplayName("合法转换:RETRYING → RUNNING(AI 重试)") - void retryingToRunning() { - assertThatCode(() -> - StateValidator.assertTransition(VideoJobStatus.TRANSITIONS, VideoJobStatus.RETRYING, VideoJobStatus.RUNNING) - ).doesNotThrowAnyException(); - } - - @Test - @DisplayName("非法转换:FAILED → PENDING 不在状态机内(ADMIN 手动触发,不走 StateValidator)") - void failedToPendingNotInStateMachine() { - // FAILED → PENDING is intentionally NOT in TRANSITIONS (ADMIN manual reset via special API) - assertThatThrownBy(() -> - StateValidator.assertTransition(VideoJobStatus.TRANSITIONS, VideoJobStatus.FAILED, VideoJobStatus.PENDING) - ).isInstanceOf(BusinessException.class) - .extracting("code").isEqualTo("INVALID_STATE_TRANSITION"); - } - - @Test - @DisplayName("非法转换:SUCCESS → RUNNING 抛出异常") - void successToRunningFails() { - assertThatThrownBy(() -> - StateValidator.assertTransition(VideoJobStatus.TRANSITIONS, VideoJobStatus.SUCCESS, VideoJobStatus.RUNNING) - ).isInstanceOf(BusinessException.class) - .extracting("code").isEqualTo("INVALID_STATE_TRANSITION"); - } - } -} diff --git a/src/test/resources/.gitkeep b/src/test/resources/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/test/resources/db/init.sql b/src/test/resources/db/init.sql deleted file mode 100644 index 1824039..0000000 --- a/src/test/resources/db/init.sql +++ /dev/null @@ -1,332 +0,0 @@ --- label_backend init.sql --- PostgreSQL 14+ --- 按依赖顺序建全部 11 张表: --- sys_company → sys_user → source_data → annotation_task → annotation_result --- → training_dataset → export_batch → sys_config → sys_operation_log --- → annotation_task_history → video_process_job --- 含所有索引及初始配置数据 - --- ============================================================ --- 扩展 --- ============================================================ -CREATE EXTENSION IF NOT EXISTS pgcrypto; - --- ============================================================ --- 1. sys_company(租户) --- ============================================================ -CREATE TABLE IF NOT EXISTS sys_company ( - id BIGSERIAL PRIMARY KEY, - company_name VARCHAR(100) NOT NULL, - company_code VARCHAR(50) NOT NULL, - status VARCHAR(10) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE / DISABLED - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW(), - CONSTRAINT uk_sys_company_name UNIQUE (company_name), - CONSTRAINT uk_sys_company_code UNIQUE (company_code) -); - --- ============================================================ --- 2. sys_user(用户) --- ============================================================ -CREATE TABLE IF NOT EXISTS sys_user ( - id BIGSERIAL PRIMARY KEY, - company_id BIGINT NOT NULL REFERENCES sys_company(id), - username VARCHAR(50) NOT NULL, - password_hash VARCHAR(255) NOT NULL, -- BCrypt, strength >= 10 - real_name VARCHAR(50), - role VARCHAR(20) NOT NULL, -- UPLOADER / ANNOTATOR / REVIEWER / ADMIN - status VARCHAR(10) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE / DISABLED - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW(), - CONSTRAINT uk_sys_user_company_username UNIQUE (company_id, username) -); - -CREATE INDEX IF NOT EXISTS idx_sys_user_company_id - ON sys_user (company_id); - --- ============================================================ --- 3. source_data(原始资料) --- ============================================================ -CREATE TABLE IF NOT EXISTS source_data ( - id BIGSERIAL PRIMARY KEY, - company_id BIGINT NOT NULL REFERENCES sys_company(id), - uploader_id BIGINT REFERENCES sys_user(id), - data_type VARCHAR(20) NOT NULL, -- TEXT / IMAGE / VIDEO - file_path VARCHAR(500) NOT NULL, -- RustFS object path - file_name VARCHAR(255) NOT NULL, - file_size BIGINT, - bucket_name VARCHAR(100) NOT NULL, - parent_source_id BIGINT REFERENCES source_data(id), -- 视频帧 / 文本片段 - status VARCHAR(20) NOT NULL DEFAULT 'PENDING', - -- PENDING / PREPROCESSING / EXTRACTING / QA_REVIEW / APPROVED - reject_reason TEXT, -- 保留字段(当前无 REJECTED 状态) - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_source_data_company_id - ON source_data (company_id); -CREATE INDEX IF NOT EXISTS idx_source_data_company_status - ON source_data (company_id, status); -CREATE INDEX IF NOT EXISTS idx_source_data_parent_source_id - ON source_data (parent_source_id); - --- ============================================================ --- 4. annotation_task(标注任务) --- ============================================================ -CREATE TABLE IF NOT EXISTS annotation_task ( - id BIGSERIAL PRIMARY KEY, - company_id BIGINT NOT NULL REFERENCES sys_company(id), - source_id BIGINT NOT NULL REFERENCES source_data(id), - task_type VARCHAR(30) NOT NULL, -- EXTRACTION / QA_GENERATION - status VARCHAR(20) NOT NULL DEFAULT 'UNCLAIMED', - -- UNCLAIMED / IN_PROGRESS / SUBMITTED / APPROVED / REJECTED - claimed_by BIGINT REFERENCES sys_user(id), - claimed_at TIMESTAMP, - submitted_at TIMESTAMP, - completed_at TIMESTAMP, - is_final BOOLEAN NOT NULL DEFAULT FALSE, -- true 即 APPROVED 且无需再审 - ai_model VARCHAR(50), - reject_reason TEXT, - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_annotation_task_company_status - ON annotation_task (company_id, status); -CREATE INDEX IF NOT EXISTS idx_annotation_task_source_id - ON annotation_task (source_id); -CREATE INDEX IF NOT EXISTS idx_annotation_task_claimed_by - ON annotation_task (claimed_by); - --- ============================================================ --- 5. annotation_result(标注结果,JSONB) --- ============================================================ -CREATE TABLE IF NOT EXISTS annotation_result ( - id BIGSERIAL NOT NULL, - task_id BIGINT NOT NULL REFERENCES annotation_task(id), - company_id BIGINT NOT NULL REFERENCES sys_company(id), - result_json JSONB NOT NULL DEFAULT '[]'::jsonb, -- 整体替换语义 - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW(), - CONSTRAINT pk_annotation_result PRIMARY KEY (id), - CONSTRAINT uk_annotation_result_task_id UNIQUE (task_id) -); - -CREATE INDEX IF NOT EXISTS idx_annotation_result_task_id - ON annotation_result (task_id); -CREATE INDEX IF NOT EXISTS idx_annotation_result_company_id - ON annotation_result (company_id); - --- ============================================================ --- 6. training_dataset(训练数据集) --- export_batch_id FK 在 export_batch 建完后补加 --- ============================================================ -CREATE TABLE IF NOT EXISTS training_dataset ( - id BIGSERIAL PRIMARY KEY, - company_id BIGINT NOT NULL REFERENCES sys_company(id), - task_id BIGINT NOT NULL REFERENCES annotation_task(id), - source_id BIGINT NOT NULL REFERENCES source_data(id), - sample_type VARCHAR(20) NOT NULL, -- TEXT / IMAGE / VIDEO_FRAME - glm_format_json JSONB NOT NULL, -- GLM fine-tune 格式 - status VARCHAR(20) NOT NULL DEFAULT 'PENDING_REVIEW', - -- PENDING_REVIEW / APPROVED / REJECTED - export_batch_id BIGINT, -- 导出后填写,FK 在下方补加 - exported_at TIMESTAMP, - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_training_dataset_company_status - ON training_dataset (company_id, status); -CREATE INDEX IF NOT EXISTS idx_training_dataset_task_id - ON training_dataset (task_id); - --- ============================================================ --- 7. export_batch(导出批次) --- ============================================================ -CREATE TABLE IF NOT EXISTS export_batch ( - id BIGSERIAL PRIMARY KEY, - company_id BIGINT NOT NULL REFERENCES sys_company(id), - batch_uuid UUID NOT NULL DEFAULT gen_random_uuid(), - sample_count INT NOT NULL DEFAULT 0, - dataset_file_path VARCHAR(500), -- 导出 JSONL 的 RustFS 路径 - glm_job_id VARCHAR(100), -- GLM fine-tune 任务 ID - finetune_status VARCHAR(20) NOT NULL DEFAULT 'NOT_STARTED', - -- NOT_STARTED / RUNNING / COMPLETED / FAILED - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_export_batch_company_id - ON export_batch (company_id); - --- 补加 training_dataset.export_batch_id FK -ALTER TABLE training_dataset - ADD CONSTRAINT fk_training_dataset_export_batch - FOREIGN KEY (export_batch_id) REFERENCES export_batch(id) - NOT VALID; -- 允许已有 NULL 行,不强制回溯校验 - --- ============================================================ --- 8. sys_config(系统配置) --- ============================================================ -CREATE TABLE IF NOT EXISTS sys_config ( - id BIGSERIAL PRIMARY KEY, - company_id BIGINT REFERENCES sys_company(id), -- NULL = 全局默认 - config_key VARCHAR(100) NOT NULL, - config_value TEXT NOT NULL, - description VARCHAR(255), - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() -); - --- 公司级配置唯一索引 -CREATE UNIQUE INDEX IF NOT EXISTS uk_sys_config_company_key - ON sys_config (company_id, config_key) - WHERE company_id IS NOT NULL; - --- 全局配置唯一索引 -CREATE UNIQUE INDEX IF NOT EXISTS uk_sys_config_global_key - ON sys_config (config_key) - WHERE company_id IS NULL; - -CREATE INDEX IF NOT EXISTS idx_sys_config_company_key - ON sys_config (company_id, config_key); - --- ============================================================ --- 9. sys_operation_log(操作日志,仅追加) --- ============================================================ -CREATE TABLE IF NOT EXISTS sys_operation_log ( - id BIGSERIAL PRIMARY KEY, - company_id BIGINT NOT NULL REFERENCES sys_company(id), - operator_id BIGINT REFERENCES sys_user(id), - operation_type VARCHAR(50) NOT NULL, -- 例如 EXTRACTION_APPROVE / USER_LOGIN - target_id BIGINT, - target_type VARCHAR(50), - detail JSONB, - result VARCHAR(10), -- SUCCESS / FAILURE - error_message TEXT, - operated_at TIMESTAMP NOT NULL DEFAULT NOW() - -- 无 updated_at(仅追加表,永不更新) -); - -CREATE INDEX IF NOT EXISTS idx_sys_operation_log_company_operated_at - ON sys_operation_log (company_id, operated_at); -CREATE INDEX IF NOT EXISTS idx_sys_operation_log_operator_id - ON sys_operation_log (operator_id); - --- ============================================================ --- 10. annotation_task_history(任务状态历史,仅追加) --- ============================================================ -CREATE TABLE IF NOT EXISTS annotation_task_history ( - id BIGSERIAL PRIMARY KEY, - task_id BIGINT NOT NULL REFERENCES annotation_task(id), - company_id BIGINT NOT NULL REFERENCES sys_company(id), - from_status VARCHAR(20), - to_status VARCHAR(20) NOT NULL, - operator_id BIGINT REFERENCES sys_user(id), - operator_role VARCHAR(20), - comment TEXT, - created_at TIMESTAMP NOT NULL DEFAULT NOW() - -- 无 updated_at(仅追加表,永不更新) -); - -CREATE INDEX IF NOT EXISTS idx_annotation_task_history_task_id - ON annotation_task_history (task_id); -CREATE INDEX IF NOT EXISTS idx_annotation_task_history_company_id - ON annotation_task_history (company_id); - --- ============================================================ --- 11. video_process_job(视频处理作业) --- ============================================================ -CREATE TABLE IF NOT EXISTS video_process_job ( - id BIGSERIAL PRIMARY KEY, - company_id BIGINT NOT NULL REFERENCES sys_company(id), - source_id BIGINT NOT NULL REFERENCES source_data(id), - job_type VARCHAR(30) NOT NULL, -- FRAME_EXTRACT / VIDEO_TO_TEXT - status VARCHAR(20) NOT NULL DEFAULT 'PENDING', - -- PENDING / RUNNING / SUCCESS / FAILED / RETRYING - params JSONB, -- 例如 {"frameInterval": 30, "mode": "FRAME"} - output_path VARCHAR(500), -- 完成后的 RustFS 输出路径 - retry_count INT NOT NULL DEFAULT 0, - max_retries INT NOT NULL DEFAULT 3, - error_message TEXT, - started_at TIMESTAMP, - completed_at TIMESTAMP, - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_video_process_job_company_id - ON video_process_job (company_id); -CREATE INDEX IF NOT EXISTS idx_video_process_job_source_id - ON video_process_job (source_id); -CREATE INDEX IF NOT EXISTS idx_video_process_job_status - ON video_process_job (status); - --- ============================================================ --- 初始数据 --- ============================================================ - --- 1. 演示公司 -INSERT INTO sys_company (company_name, company_code, status) -VALUES ('演示公司', 'DEMO', 'ACTIVE') -ON CONFLICT DO NOTHING; - --- 2. 初始用户(BCrypt strength=10) --- admin / admin123 --- reviewer01/ review123 --- annotator01/annot123 --- uploader01 / upload123 -INSERT INTO sys_user (company_id, username, password_hash, real_name, role, status) -SELECT - c.id, - u.username, - u.password_hash, - u.real_name, - u.role, - 'ACTIVE' -FROM sys_company c -CROSS JOIN (VALUES - ('admin', - '$2a$10$B8iR5z43URiNPm.eut3JvufIPBuvGx5ZZmqyUqE1A1WdbZppX5bmi', - '管理员', - 'ADMIN'), - ('reviewer01', - '$2a$10$euOJZRfUtYNW7WHpfW1Ciee5b3rjkYFe3yQHT/uCQWrYVc0XQcukm', - '审核员01', - 'REVIEWER'), - ('annotator01', - '$2a$10$8UKwHPNASauKMTrqosR0Reg1X1gkFzFlGa/HBwNLXUELaj4e/zcqu', - '标注员01', - 'ANNOTATOR'), - ('uploader01', - '$2a$10$o2d7jsT31vyxIJHUo50mUefoZLLvGqft97zaL9OQCjRxn9ie1H/1O', - '上传员01', - 'UPLOADER') -) AS u(username, password_hash, real_name, role) -WHERE c.company_code = 'DEMO' -ON CONFLICT (company_id, username) DO NOTHING; - --- 3. 全局系统配置 -INSERT INTO sys_config (company_id, config_key, config_value, description) -VALUES - (NULL, 'token_ttl_seconds', '7200', - '会话凭证有效期(秒)'), - (NULL, 'model_default', 'glm-4', - 'AI 辅助默认模型'), - (NULL, 'video_frame_interval', '30', - '视频帧提取间隔(帧数)'), - (NULL, 'prompt_extract_text', - '请提取以下文本中的主语-谓语-宾语三元组,以JSON数组格式返回,每个元素包含subject、predicate、object、sourceText、startOffset、endOffset字段。', - '文本三元组提取 Prompt 模板'), - (NULL, 'prompt_extract_image', - '请提取图片中的实体关系四元组,以JSON数组格式返回,每个元素包含subject、relation、object、modifier、confidence字段。', - '图片四元组提取 Prompt 模板'), - (NULL, 'prompt_qa_gen_text', - '根据以下文本三元组生成高质量问答对,以JSON数组格式返回,每个元素包含question、answer、difficulty字段。', - '文本问答生成 Prompt 模板'), - (NULL, 'prompt_qa_gen_image', - '根据以下图片四元组生成高质量问答对,以JSON数组格式返回,每个元素包含question、answer、imageRef、difficulty字段。', - '图片问答生成 Prompt 模板') -ON CONFLICT DO NOTHING;