414 lines
18 KiB
Java
414 lines
18 KiB
Java
|
|
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) {
|
|||
|
|
}
|
|||
|
|
}
|