diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinResultContractReplayTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinResultContractReplayTest.java new file mode 100644 index 00000000..af972188 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinResultContractReplayTest.java @@ -0,0 +1,102 @@ +package com.nanri.aiimage.modules.similarasin.service; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Assumptions; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.Statement; +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * task-203:/result 幂等契约回放(真实服务,env-gated)。 + * + * 依赖运行中的应用与可写本地库(复现生产 schema):设 CONTRACT_BASE_URL、CONTRACT_DB_URL、 + * CONTRACT_DB_USER、CONTRACT_DB_PASSWORD 后启用;未设则跳过(保证 CI 全绿)。 + * 流程:造 SIMILAR_ASIN RUNNING 任务 + 内联解析载荷 → GET parsed-payload 验证成功 → + * POST /result 同 submissionId/chunk 两次 → 断言两次 success:true 且 biz_task_chunk 只落 1 行(幂等)。 + * done=true 封口/终态因需真实 LLM 流水线,不在本回放覆盖(见 commit 说明)。 + */ +class SimilarAsinResultContractReplayTest { + + private static final long TASK_ID = 9000001L; + private static final String SUBMISSION = "contract-replay-001"; + + @Test + void duplicateSubmitResultIsIdempotent() throws Exception { + String base = System.getenv("CONTRACT_BASE_URL"); + String dbUrl = System.getenv("CONTRACT_DB_URL"); + String dbUser = System.getenv("CONTRACT_DB_USER"); + String dbPass = System.getenv("CONTRACT_DB_PASSWORD"); + Assumptions.assumeTrue(base != null && dbUrl != null && dbUser != null, + "未设 CONTRACT_BASE_URL / CONTRACT_DB_*,跳过真实服务回放"); + + seedTask(dbUrl, dbUser, dbPass); + HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); + + // parsed-payload 成功返回全量载荷 + HttpResponse parsed = get(client, base + "/api/similar-asin/tasks/" + TASK_ID + "/parsed-payload?user_id=1"); + assertEquals(200, parsed.statusCode()); + assertTrue(parsed.body().contains("\"success\":true"), "parsed-payload 应 success:true: " + parsed.body()); + assertTrue(parsed.body().contains("B0FAKETEST123"), "应含解析行数据"); + + // 同 submissionId/chunk 提交两次 + String body = "{\"submissionId\":\"" + SUBMISSION + "\",\"chunkIndex\":0,\"chunkTotal\":1,\"done\":false," + + "\"error\":null,\"groups\":[{\"name\":\"g\",\"items\":[{\"rowIndex\":1,\"asin\":\"B0FAKETEST123\"," + + "\"status\":\"SUCCESS\"}]}]}"; + HttpResponse first = post(client, base + "/api/similar-asin/tasks/" + TASK_ID + "/result?user_id=1", body); + HttpResponse second = post(client, base + "/api/similar-asin/tasks/" + TASK_ID + "/result?user_id=1", body); + assertEquals(200, first.statusCode()); + assertEquals(200, second.statusCode()); + assertTrue(first.body().contains("\"success\":true"), "首次提交应成功: " + first.body()); + assertTrue(second.body().contains("\"success\":true"), "重复提交应幂等成功: " + second.body()); + + // 幂等:只落一份 chunk + assertEquals(1, countChunks(dbUrl, dbUser, dbPass, TASK_ID), "同 submissionId/chunk 应只落 1 行"); + } + + private static void seedTask(String dbUrl, String user, String pass) throws Exception { + String payload = "{\"aiPrompt\":\"\",\"apiKey\":\"\",\"imgSwitch\":false," + + "\"categorySwitch\":false,\"sourceFiles\":[],\"headers\":[\"asin\",\"keyword\"]," + + "\"items\":[{\"rowIndex\":1,\"asin\":\"B0FAKETEST123\",\"keyword\":\"ring\"}]," + + "\"groups\":[],\"allItems\":[{\"rowIndex\":1,\"asin\":\"B0FAKETEST123\",\"keyword\":\"ring\"}]}"; + try (Connection c = DriverManager.getConnection(dbUrl, user, pass); + Statement s = c.createStatement()) { + s.executeUpdate("INSERT INTO biz_file_task (id,task_no,module_type,task_mode,status,created_at,updated_at,user_id,owner_instance_id,result_json) " + + "VALUES (" + TASK_ID + ",'REPLAY-" + TASK_ID + "','SIMILAR_ASIN','workbook','RUNNING',NOW(),NOW(),1,'local-smoke','" + + payload.replace("'", "''") + "') ON DUPLICATE KEY UPDATE module_type='SIMILAR_ASIN',status='RUNNING',user_id=1,result_json=VALUES(result_json)"); + s.executeUpdate("DELETE FROM biz_task_chunk WHERE task_id=" + TASK_ID); + } + } + + private static int countChunks(String dbUrl, String user, String pass, long taskId) throws Exception { + try (Connection c = DriverManager.getConnection(dbUrl, user, pass); + Statement s = c.createStatement(); + var rs = s.executeQuery("SELECT COUNT(*) FROM biz_task_chunk WHERE task_id=" + taskId)) { + rs.next(); + return rs.getInt(1); + } + } + + private static HttpResponse get(HttpClient client, String url) throws Exception { + return client.send(HttpRequest.newBuilder(URI.create(url)).GET() + .timeout(Duration.ofSeconds(15)).build(), + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + } + + private static HttpResponse post(HttpClient client, String url, String body) throws Exception { + return client.send(HttpRequest.newBuilder(URI.create(url)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) + .timeout(Duration.ofSeconds(15)).build(), + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + } +}