黑盒测试用例
This commit is contained in:
413
src/test/java/com/label/blackbox/AbstractBlackBoxTest.java
Normal file
413
src/test/java/com/label/blackbox/AbstractBlackBoxTest.java
Normal 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) {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user