Files
label_backend/src/test/java/com/label/blackbox/AbstractBlackBoxTest.java
2026-04-14 20:00:37 +08:00

414 lines
18 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<String> getRaw(String path) {
return restTemplate.getForEntity(url(path), String.class);
}
protected ResponseEntity<Map> get(String path, String token) {
return exchange(path, HttpMethod.GET, null, token, MediaType.APPLICATION_JSON);
}
protected ResponseEntity<Map> delete(String path, String token) {
return exchange(path, HttpMethod.DELETE, null, token, MediaType.APPLICATION_JSON);
}
protected ResponseEntity<Map> postJson(String path, Object body, String token) {
return exchange(path, HttpMethod.POST, body, token, MediaType.APPLICATION_JSON);
}
protected ResponseEntity<Map> putJson(String path, Object body, String token) {
return exchange(path, HttpMethod.PUT, body, token, MediaType.APPLICATION_JSON);
}
protected ResponseEntity<Map> 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<String, Object> 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<Map> postVideoCallback(Map<String, Object> 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<String, Object> body = Map.of(
"companyCode", targetCompanyCode,
"username", username,
"password", password
);
ResponseEntity<Map> response = postJson("/api/auth/login", body, null);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) Objects.requireNonNull(response.getBody()).get("data");
return String.valueOf(data.get("token"));
}
protected Long uploadTextSource(String token) {
ResponseEntity<Map> 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<Map> 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<Map> 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<Map> response, HttpStatus expectedStatus) {
assertThat(response.getStatusCode()).isEqualTo(expectedStatus);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().get("code")).isEqualTo("SUCCESS");
}
protected Long dataId(ResponseEntity<Map> response) {
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) Objects.requireNonNull(response.getBody()).get("data");
return ((Number) data.get("id")).longValue();
}
protected boolean responseContainsId(ResponseEntity<Map> response, Long id) {
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) Objects.requireNonNull(response.getBody()).get("data");
@SuppressWarnings("unchecked")
List<Map<String, Object>> items = (List<Map<String, Object>>) 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<Map> 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<String> 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<Map> adminMe = get("/api/auth/me", adminToken);
ResponseEntity<Map> reviewerMe = get("/api/auth/me", reviewerToken);
if (!adminMe.getStatusCode().is2xxSuccessful() || !reviewerMe.getStatusCode().is2xxSuccessful()) {
this.roleAwareAuthEnabled = false;
return;
}
@SuppressWarnings("unchecked")
Map<String, Object> adminData = (Map<String, Object>) Objects.requireNonNull(adminMe.getBody()).get("data");
@SuppressWarnings("unchecked")
Map<String, Object> reviewerData = (Map<String, Object>) 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) {
}
}