diff --git a/backend-java/src/test/java/com/nanri/aiimage/config/PythonContractFixtureTest.java b/backend-java/src/test/java/com/nanri/aiimage/config/PythonContractFixtureTest.java new file mode 100644 index 00000000..53d724cc --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/config/PythonContractFixtureTest.java @@ -0,0 +1,115 @@ +package com.nanri.aiimage.config; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * task-202:Python-contract 夹具准备契约。 + * 校验 python-contract/*.json 存在、为合法 JSON、顶层字段与真实 DTO 对齐、已脱敏(无真实凭据)、 + * 可复现;字段来源与脱敏口径见同目录 README.md。 + */ +class PythonContractFixtureTest { + + private static final Path DIR = Path.of(System.getProperty("user.dir"), + "src/test/resources/python-contract"); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private JsonNode json(String name) throws IOException { + return MAPPER.readTree(Files.readAllBytes(DIR.resolve(name))); + } + + private static Set keys(JsonNode node) { + List names = new ArrayList<>(); + node.fieldNames().forEachRemaining(names::add); + return Set.copyOf(names); + } + + @Test + void fixtureResultRequestExists() throws IOException { + JsonNode n = json("result.request.json"); + assertEquals("sim-20260905-0001", n.path("submissionId").asText()); + } + + @Test + void fixtureItemsExists() throws IOException { + JsonNode n = json("items.response.json"); + assertTrue(n.path("items").isArray() && n.path("items").size() >= 1); + } + + @Test + void fixtureParsedPayloadExists() throws IOException { + JsonNode n = json("parsed_payload.response.json"); + assertTrue(n.has("allItems") && n.has("groups") && n.has("items")); + } + + @Test + void fixtureHeartbeatExists() throws IOException { + JsonNode req = json("heartbeat.request.json"); + JsonNode resp = json("heartbeat.response.json"); + assertTrue(req.has("moduleType") && req.has("phase")); + assertTrue(resp.has("alive") && resp.has("status")); + } + + @Test + void fixtureSanitizedNoRealCredentials() throws IOException { + String all = String.join("\n", List.of( + Files.readString(DIR.resolve("parsed_payload.response.json")), + Files.readString(DIR.resolve("result.request.json")))); + assertFalse(all.contains("WTFrb"), "不得出现真实 DB/内部凭据"); + assertFalse(all.toLowerCase().contains("bearer "), "不得出现真实 token"); + JsonNode parsed = json("parsed_payload.response.json"); + assertEquals("", parsed.path("apiKey").asText(), "apiKey 必须占位"); + assertFalse(parsed.path("aiPrompt").asText().toLowerCase().contains("真实"), + "aiPrompt 不得含真实内容"); + } + + @Test + void fixtureShapeValidJson() throws IOException { + for (String name : List.of("heartbeat.request.json", "heartbeat.response.json", + "items.response.json", "result.request.json", "parsed_payload.response.json")) { + JsonNode n = json(name); + assertTrue(n.isContainerNode(), name + " 应为 JSON 对象/数组"); + } + } + + @Test + void fixtureFieldsDocumentedByReadme() throws IOException { + String readme = Files.readString(DIR.resolve("README.md")); + assertTrue(readme.contains("TaskHeartbeatRequest") && readme.contains("CollectDataItemsPageVo") + && readme.contains("SimilarAsinSubmitResultRequest") + && readme.contains("SimilarAsinParsedPayloadDto"), "README 应说明字段来源"); + assertTrue(readme.contains("脱敏"), "README 应说明脱敏"); + } + + @Test + void fixtureHeartbeatTopKeysMatchDto() throws IOException { + assertEquals(Set.of("moduleType", "phase", "current", "total", "collectStage", + "currentKeyword", "searchCurrentPage", "searchTotalPages", + "detailProcessedAsins", "detailTotalAsins"), + keys(json("heartbeat.request.json")), "心跳请求顶层字段应与 TaskHeartbeatRequest 对齐"); + assertEquals(Set.of("alive", "status", "moduleType", "message"), + keys(json("heartbeat.response.json")), "心跳响应应与 TaskHeartbeatVo 对齐"); + } + + @Test + void fixtureReproducibleStable() throws IOException { + for (String name : List.of("heartbeat.request.json", "items.response.json", + "result.request.json", "parsed_payload.response.json")) { + String a = Files.readString(DIR.resolve(name)); + String b = Files.readString(DIR.resolve(name)); + assertEquals(a, b, name + " 应可复现"); + } + } +} diff --git a/backend-java/src/test/resources/python-contract/README.md b/backend-java/src/test/resources/python-contract/README.md new file mode 100644 index 00000000..0dfdd52d --- /dev/null +++ b/backend-java/src/test/resources/python-contract/README.md @@ -0,0 +1,19 @@ +# Python-contract 夹具(task-202) + +> 目录:`src/test/resources/python-contract/`。用途:模块 13 的 /result、/items、parsed-payload、heartbeat 契约测试固定输入。 + +## 内容与字段来源 + +| 文件 | 对应 Worker 接口 | 字段来源(服务 DTO,非臆造) | +|---|---|---| +| heartbeat.request.json | POST /api/tasks/{taskId}/heartbeat | `TaskHeartbeatRequest`(moduleType/phase/current/total/collectStage/currentKeyword/search*Page*/detail*Asins) | +| heartbeat.response.json | 同上响应 | `TaskHeartbeatVo`(alive/status/moduleType/message) | +| items.response.json | GET /tasks/{taskId}/items(collectdata) | `CollectDataItemsPageVo`+`CollectDataItemVo`(分页字段 + items 行结构) | +| result.request.json | 模块 submit-result(similarasin 示例) | `SimilarAsinSubmitResultRequest`(submissionId/chunkIndex/chunkTotal/done/error/groups) | +| parsed_payload.response.json | GET /tasks/{taskId}/parsed-payload(similarasin) | `SimilarAsinParsedPayloadDto`(aiPrompt/apiKey/imgSwitch/…/items/allItems/groups) | + +## 说明 + +- **脱敏**:所有值均为伪造(taskId/ASIN/文件名/关键词等);敏感字段已置占位——`parsed_payload.response.json` 的 `apiKey=""`、`aiPrompt=""`,不含任何真实凭据。 +- **来源口径**:schema-fixture(字段取自本服务 DTO);非录自 app_client/amazon 真实抓包。若后续在具备桌面端/爬虫运行环境时录制真实报文,可另存 `*.recorded.json` 增补,不替换本 schema-fixture。 +- **可复现**:纯静态 JSON,可重复用于契约测试。 diff --git a/backend-java/src/test/resources/python-contract/heartbeat.request.json b/backend-java/src/test/resources/python-contract/heartbeat.request.json new file mode 100644 index 00000000..0ca768a4 --- /dev/null +++ b/backend-java/src/test/resources/python-contract/heartbeat.request.json @@ -0,0 +1,12 @@ +{ + "moduleType": "similarasin", + "phase": "search", + "current": 3, + "total": 10, + "collectStage": "keyword-search", + "currentKeyword": "ring", + "searchCurrentPage": 1, + "searchTotalPages": 4, + "detailProcessedAsins": 12, + "detailTotalAsins": 120 +} diff --git a/backend-java/src/test/resources/python-contract/heartbeat.response.json b/backend-java/src/test/resources/python-contract/heartbeat.response.json new file mode 100644 index 00000000..6a51b9ab --- /dev/null +++ b/backend-java/src/test/resources/python-contract/heartbeat.response.json @@ -0,0 +1,6 @@ +{ + "alive": true, + "status": "RUNNING", + "moduleType": "similarasin", + "message": "ok" +} diff --git a/backend-java/src/test/resources/python-contract/items.response.json b/backend-java/src/test/resources/python-contract/items.response.json new file mode 100644 index 00000000..c50af1e5 --- /dev/null +++ b/backend-java/src/test/resources/python-contract/items.response.json @@ -0,0 +1,25 @@ +{ + "taskId": 7004, + "taskNo": "SA-20260905-7004", + "taskType": "collect-data", + "taskStatus": "RUNNING", + "page": 1, + "pageSize": 50, + "count": 1, + "total": 1, + "totalPages": 1, + "filters": { + "country": "US" + }, + "items": [ + { + "id": 1, + "rowIndex": 1, + "sourceFileKey": "in/example.xlsx", + "sourceFilename": "example.xlsx", + "keyword": "ring", + "statusValue": "PENDING", + "extra": {} + } + ] +} diff --git a/backend-java/src/test/resources/python-contract/parsed_payload.response.json b/backend-java/src/test/resources/python-contract/parsed_payload.response.json new file mode 100644 index 00000000..e0e6c5ef --- /dev/null +++ b/backend-java/src/test/resources/python-contract/parsed_payload.response.json @@ -0,0 +1,29 @@ +{ + "aiPrompt": "", + "apiKey": "", + "imgSwitch": false, + "categorySwitch": false, + "sourceFiles": [], + "headers": [ + "asin", + "keyword", + "title" + ], + "items": [ + { + "rowIndex": 1, + "asin": "B0FAKETEST123", + "keyword": "ring", + "title": "example" + } + ], + "allItems": [ + { + "rowIndex": 1, + "asin": "B0FAKETEST123", + "keyword": "ring", + "title": "example" + } + ], + "groups": [] +} diff --git a/backend-java/src/test/resources/python-contract/result.request.json b/backend-java/src/test/resources/python-contract/result.request.json new file mode 100644 index 00000000..63e3b37b --- /dev/null +++ b/backend-java/src/test/resources/python-contract/result.request.json @@ -0,0 +1,20 @@ +{ + "submissionId": "sim-20260905-0001", + "chunkIndex": 0, + "chunkTotal": 1, + "done": true, + "error": null, + "groups": [ + { + "name": "group-1", + "items": [ + { + "rowIndex": 1, + "keyword": "ring", + "asin": "B0FAKETEST123", + "status": "SUCCESS" + } + ] + } + ] +}