package com.label.integration; import com.label.AbstractIntegrationTest; import com.label.module.user.dto.LoginRequest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.http.*; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; /** * 提取阶段审批集成测试(US4)。 * * 测试场景: * 1. 审批通过 → QA_GENERATION 任务自动创建,source_data 状态更新为 QA_REVIEW * 2. 审批人与提交人相同(自审)→ 403 SELF_REVIEW_FORBIDDEN * 3. 驳回后标注员可重领任务并再次提交 */ public class ExtractionApprovalIntegrationTest extends AbstractIntegrationTest { @Autowired private TestRestTemplate restTemplate; private Long sourceId; private Long taskId; private Long annotatorUserId; private Long reviewerUserId; @BeforeEach void setup() { // 获取种子用户 ID(init.sql 中已插入) annotatorUserId = jdbcTemplate.queryForObject( "SELECT id FROM sys_user WHERE username = 'annotator01'", Long.class); reviewerUserId = jdbcTemplate.queryForObject( "SELECT id FROM sys_user WHERE username = 'reviewer01'", Long.class); Long companyId = jdbcTemplate.queryForObject( "SELECT id FROM sys_company WHERE company_code = 'DEMO'", Long.class); // 插入测试 source_data jdbcTemplate.execute( "INSERT INTO source_data (company_id, uploader_id, data_type, file_path, " + "file_name, file_size, bucket_name, status) " + "VALUES (" + companyId + ", " + annotatorUserId + ", 'TEXT', " + "'test/approval-test/file.txt', 'file.txt', 100, 'test-bucket', 'PENDING')"); sourceId = jdbcTemplate.queryForObject( "SELECT id FROM source_data ORDER BY id DESC LIMIT 1", Long.class); // 插入 UNCLAIMED EXTRACTION 任务 jdbcTemplate.execute( "INSERT INTO annotation_task (company_id, source_id, task_type, status) " + "VALUES (" + companyId + ", " + sourceId + ", 'EXTRACTION', 'UNCLAIMED')"); taskId = jdbcTemplate.queryForObject( "SELECT id FROM annotation_task ORDER BY id DESC LIMIT 1", Long.class); } // ------------------------------------------------------------------ 测试 1: 审批通过 → QA 任务自动创建 -- @Test @DisplayName("审批通过后,QA_GENERATION 任务自动创建,source_data 状态变为 QA_REVIEW") void approveTask_thenQaTaskAndSourceStatusUpdated() { String annotatorToken = loginAndGetToken("DEMO", "annotator01", "annot123"); String reviewerToken = loginAndGetToken("DEMO", "reviewer01", "review123"); // 1. 标注员领取任务 ResponseEntity claimResp = restTemplate.exchange( baseUrl("/api/tasks/" + taskId + "/claim"), HttpMethod.POST, bearerRequest(annotatorToken), Map.class); assertThat(claimResp.getStatusCode()).isEqualTo(HttpStatus.OK); // 2. 标注员提交标注 ResponseEntity submitResp = restTemplate.exchange( baseUrl("/api/extraction/" + taskId + "/submit"), HttpMethod.POST, bearerRequest(annotatorToken), Map.class); assertThat(submitResp.getStatusCode()).isEqualTo(HttpStatus.OK); // 3. 审核员审批通过 // 注:ExtractionApprovedEventListener(@TransactionalEventListener AFTER_COMMIT) // 在同一线程中同步执行,HTTP 响应返回前已完成后续处理 ResponseEntity approveResp = restTemplate.exchange( baseUrl("/api/extraction/" + taskId + "/approve"), HttpMethod.POST, bearerRequest(reviewerToken), Map.class); assertThat(approveResp.getStatusCode()).isEqualTo(HttpStatus.OK); // 验证:原任务状态变为 APPROVED,is_final=true Map taskRow = jdbcTemplate.queryForMap( "SELECT status, is_final FROM annotation_task WHERE id = ?", taskId); assertThat(taskRow.get("status")).isEqualTo("APPROVED"); assertThat(taskRow.get("is_final")).isEqualTo(Boolean.TRUE); // 验证:QA_GENERATION 任务已自动创建(UNCLAIMED 状态) Integer qaTaskCount = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM annotation_task " + "WHERE source_id = ? AND task_type = 'QA_GENERATION' AND status = 'UNCLAIMED'", Integer.class, sourceId); assertThat(qaTaskCount).as("QA_GENERATION 任务应已创建").isEqualTo(1); // 验证:source_data 状态已更新为 QA_REVIEW String sourceStatus = jdbcTemplate.queryForObject( "SELECT status FROM source_data WHERE id = ?", String.class, sourceId); assertThat(sourceStatus).as("source_data 状态应为 QA_REVIEW").isEqualTo("QA_REVIEW"); // 验证:training_dataset 已以 PENDING_REVIEW 状态创建 Integer datasetCount = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM training_dataset " + "WHERE source_id = ? AND status = 'PENDING_REVIEW'", Integer.class, sourceId); assertThat(datasetCount).as("training_dataset 应已创建").isEqualTo(1); } // ------------------------------------------------------------------ 测试 2: 自审返回 403 -- @Test @DisplayName("审批人与任务领取人相同(自审)→ 403 SELF_REVIEW_FORBIDDEN") void approveOwnSubmission_returnsForbidden() { // 直接将任务置为 SUBMITTED 并设 claimed_by = reviewer01(模拟自审场景) jdbcTemplate.execute( "UPDATE annotation_task " + "SET status = 'SUBMITTED', claimed_by = " + reviewerUserId + ", claimed_at = NOW(), submitted_at = NOW() " + "WHERE id = " + taskId); String reviewerToken = loginAndGetToken("DEMO", "reviewer01", "review123"); ResponseEntity resp = restTemplate.exchange( baseUrl("/api/extraction/" + taskId + "/approve"), HttpMethod.POST, bearerRequest(reviewerToken), Map.class); assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); // 验证任务状态未变 String status = jdbcTemplate.queryForObject( "SELECT status FROM annotation_task WHERE id = ?", String.class, taskId); assertThat(status).isEqualTo("SUBMITTED"); } // ------------------------------------------------------------------ 测试 3: 驳回 → 重领 → 再提交 -- @Test @DisplayName("驳回后标注员可重领任务并再次提交,任务状态恢复为 SUBMITTED") void rejectThenReclaimAndResubmit_succeeds() { String annotatorToken = loginAndGetToken("DEMO", "annotator01", "annot123"); String reviewerToken = loginAndGetToken("DEMO", "reviewer01", "review123"); // 1. 标注员领取并提交 restTemplate.exchange(baseUrl("/api/tasks/" + taskId + "/claim"), HttpMethod.POST, bearerRequest(annotatorToken), Map.class); restTemplate.exchange(baseUrl("/api/extraction/" + taskId + "/submit"), HttpMethod.POST, bearerRequest(annotatorToken), Map.class); // 2. 审核员驳回(驳回原因必填) HttpHeaders rejectHeaders = new HttpHeaders(); rejectHeaders.set("Authorization", "Bearer " + reviewerToken); rejectHeaders.setContentType(MediaType.APPLICATION_JSON); HttpEntity> rejectReq = new HttpEntity<>( Map.of("reason", "实体识别有误,请重新标注"), rejectHeaders); ResponseEntity rejectResp = restTemplate.exchange( baseUrl("/api/extraction/" + taskId + "/reject"), HttpMethod.POST, rejectReq, Map.class); assertThat(rejectResp.getStatusCode()).isEqualTo(HttpStatus.OK); // 验证:任务状态变为 REJECTED String statusAfterReject = jdbcTemplate.queryForObject( "SELECT status FROM annotation_task WHERE id = ?", String.class, taskId); assertThat(statusAfterReject).isEqualTo("REJECTED"); // 3. 标注员重领任务(REJECTED → IN_PROGRESS) ResponseEntity reclaimResp = restTemplate.exchange( baseUrl("/api/tasks/" + taskId + "/reclaim"), HttpMethod.POST, bearerRequest(annotatorToken), Map.class); assertThat(reclaimResp.getStatusCode()).isEqualTo(HttpStatus.OK); // 验证:任务状态恢复为 IN_PROGRESS String statusAfterReclaim = jdbcTemplate.queryForObject( "SELECT status FROM annotation_task WHERE id = ?", String.class, taskId); assertThat(statusAfterReclaim).isEqualTo("IN_PROGRESS"); // 4. 标注员再次提交(IN_PROGRESS → SUBMITTED) ResponseEntity resubmitResp = restTemplate.exchange( baseUrl("/api/extraction/" + taskId + "/submit"), HttpMethod.POST, bearerRequest(annotatorToken), Map.class); assertThat(resubmitResp.getStatusCode()).isEqualTo(HttpStatus.OK); // 验证:任务状态变为 SUBMITTED String finalStatus = jdbcTemplate.queryForObject( "SELECT status FROM annotation_task WHERE id = ?", String.class, taskId); assertThat(finalStatus).isEqualTo("SUBMITTED"); } // ------------------------------------------------------------------ 工具方法 -- private String loginAndGetToken(String companyCode, String username, String password) { LoginRequest req = new LoginRequest(); req.setCompanyCode(companyCode); req.setUsername(username); req.setPassword(password); ResponseEntity response = restTemplate.postForEntity( baseUrl("/api/auth/login"), req, Map.class); if (!response.getStatusCode().is2xxSuccessful()) { return null; } @SuppressWarnings("unchecked") Map data = (Map) response.getBody().get("data"); return (String) data.get("token"); } private HttpEntity bearerRequest(String token) { HttpHeaders headers = new HttpHeaders(); headers.set("Authorization", "Bearer " + token); return new HttpEntity<>(headers); } }