task-203: /result 幂等契约回放测试(env-gated,真实服务:造 SIMILAR_ASIN RUNNING 任务+内联解析载荷 → parsed-payload 成功 → 同 submissionId/chunk 提交两次均 success:true 且 biz_task_chunk 只落 1 行)

- CI 默认跳过(需 CONTRACT_BASE_URL+CONTRACT_DB_*),设了即在本地/任意副本库跑
- 覆盖 success/idempotent/single-persist(chunk)/chunk 字段/未知任务(live 已验);done=true 封口需真实 LLM 流水线,不在本回放内(见限制说明)
- 本地已用 18080 实例 + schema 副本真跑通过
This commit is contained in:
2026-09-05 08:56:41 +08:00
parent 92bd114e71
commit db0c1c1fdc
@@ -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<String> 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<String> first = post(client, base + "/api/similar-asin/tasks/" + TASK_ID + "/result?user_id=1", body);
HttpResponse<String> 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\":\"<redacted>\",\"apiKey\":\"<redacted>\",\"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<String> 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<String> 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));
}
}