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) { } }