黑盒测试用例

This commit is contained in:
wh
2026-04-14 20:00:37 +08:00
parent 999856e110
commit b0e2b3c81a
5 changed files with 1724 additions and 594 deletions

View File

@@ -0,0 +1,413 @@
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) {
}
}