完成 M002 V3 CP6 P0 fixtures 回归基线

This commit is contained in:
andy
2026-07-11 22:23:19 +08:00
parent 25dfa33f45
commit 3ae3fea6bc
33 changed files with 4600 additions and 75 deletions

View File

@@ -638,14 +638,15 @@ class SuperAgentTaskResultControllerTest {
"reason_code": "no_booking_action_detected",
"automation_action": "none"
},
"notification": {
"required": true,
"notification_type": "source_message_review",
"show_source_message": true,
"requires_user_decision": true
},
"manual_review": null
}
"notification": {
"required": true,
"notification_type": "source_message_review",
"show_source_message": true,
"requires_user_decision": true,
"visible_message": "未匹配到当前 Agent 支持的业务事件类型,请查看原邮件并决定是否需要回复或进行其他处理。"
},
"manual_review": null
}
""";
MvcResult result = mockMvc.perform(signedPost(body, "nonce-v3-s10-entry-result-001"))
@@ -695,23 +696,31 @@ class SuperAgentTaskResultControllerTest {
"route_code": "S99",
"handler_type": "main_agent_outcome",
"result_type": "source_message_review_notification",
"current_or_history": "current",
"agent_assessment": {
"status": "insufficient_business_material",
"reason_code": "cannot_form_business_material_package",
"automation_action": "none"
},
"notification": {
"required": true,
"notification_type": "source_message_review",
"show_source_message": true,
"requires_user_decision": true
},
"manual_review": {
"reason_code": "cannot_form_business_material_package",
"review_notes": "需要人工查看原邮件。"
}
}
"current_or_history": "current",
"agent_assessment": {
"status": "material_package_unavailable",
"reason_code": "material_package_unavailable",
"automation_action": "none"
},
"notification": {
"required": true,
"notification_type": "source_message_review",
"show_source_message": true,
"requires_user_decision": true,
"visible_message": "当前输入不足,无法判断是否匹配当前 Agent 支持的业务事件类型,请查看原邮件并决定后续处理。"
},
"manual_review": {
"reason_code": "material_package_unavailable",
"visible_reason": "当前输入不足,无法完成支持业务事件范围分类。",
"review_record_type": "main_agent_entry_review",
"missing_fields": [],
"blocking_points": [],
"conflicting_points": [],
"suggested_human_actions": ["review_source_message"],
"evidence_to_check": ["source_message"],
"known_fields": {}
}
}
""";
MvcResult result = mockMvc.perform(signedPost(body, "nonce-v3-s99-entry-result-001"))
@@ -761,23 +770,31 @@ class SuperAgentTaskResultControllerTest {
"route_code": "S99",
"handler_type": "main_agent_outcome",
"result_type": "source_message_review_notification",
"current_or_history": "current",
"agent_assessment": {
"status": "insufficient_business_material",
"reason_code": "cannot_form_business_material_package",
"automation_action": "none"
},
"notification": {
"required": true,
"notification_type": "source_message_review",
"show_source_message": true,
"requires_user_decision": true
},
"manual_review": {
"reason_code": "cannot_form_business_material_package",
"review_notes": "需要人工查看原邮件。"
}
}
"current_or_history": "current",
"agent_assessment": {
"status": "material_package_unavailable",
"reason_code": "material_package_unavailable",
"automation_action": "none"
},
"notification": {
"required": true,
"notification_type": "source_message_review",
"show_source_message": true,
"requires_user_decision": true,
"visible_message": "当前输入不足,无法判断是否匹配当前 Agent 支持的业务事件类型,请查看原邮件并决定后续处理。"
},
"manual_review": {
"reason_code": "material_package_unavailable",
"visible_reason": "当前输入不足,无法完成支持业务事件范围分类。",
"review_record_type": "main_agent_entry_review",
"missing_fields": [],
"blocking_points": [],
"conflicting_points": [],
"suggested_human_actions": ["review_source_message"],
"evidence_to_check": ["source_message"],
"known_fields": {}
}
}
""";
MvcResult result = mockMvc.perform(signedPost(body, "nonce-v3-s99-frontend-visible-001"))
@@ -810,11 +827,11 @@ class SuperAgentTaskResultControllerTest {
.value("source_message_review_notification"))
.andExpect(jsonPath("$.source_message_only_result.route_code").value("S99"))
.andExpect(jsonPath("$.source_message_only_result.agent_assessment.status")
.value("insufficient_business_material"))
.value("material_package_unavailable"))
.andExpect(jsonPath("$.source_message_only_result.notification.notification_type")
.value("source_message_review"))
.andExpect(jsonPath("$.source_message_only_result.manual_review.reason_code")
.value("cannot_form_business_material_package"));
.value("material_package_unavailable"));
}
@Test

View File

@@ -0,0 +1,459 @@
package cn.nianxx.thhotel.workflows.reservation.control;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import cn.nianxx.thhotel.ThHotelApplication;
import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult;
import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;
import java.util.List;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
@SpringBootTest(
classes = ThHotelApplication.class,
properties = {
"superagent.task-result.hmac-secret=test-superagent-secret",
"superagent.task-result.clock-skew-seconds=300",
"superagent.task-result.nonce-ttl-seconds=600",
"superagent.task-result.max-body-bytes=20000"
})
@AutoConfigureMockMvc
@ActiveProfiles("test")
class SuperAgentTaskResultP0FixtureRegressionTest {
private static final String ENDPOINT = "/api/integrations/superagent/task-results";
private static final String CLIENT_ID = "superagent-test-client";
private static final String SECRET = "test-superagent-secret";
private static final Path FIXTURES_DIR = Path.of(
"..",
"docs",
"import",
"20260711",
"开发交付_P0冻结基线_2026-07-11",
"03_P0_Acceptance",
"fixtures");
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private SourceMessageCaptureService captureService;
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void shouldAcceptLegalS10AndS99MainOutcomeFixtures() throws Exception {
JsonNode cases = fixture("main_outcomes.json").path("cases");
JsonNode s10 = caseValue(cases, "legal_s10_no_supported_match");
JsonNode s99 = caseValue(cases, "legal_s99_material_unavailable");
String s10ExternalId = "p0-main-outcome-s10-001";
String s99ExternalId = "p0-main-outcome-s99-001";
captureSourceMessage(s10ExternalId);
captureSourceMessage(s99ExternalId);
MvcResult s10Result = mockMvc.perform(signedPost(withSourceMessageId(s10, s10ExternalId), "nonce-p0-s10-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.items[0].route_code").value("S10"))
.andExpect(jsonPath("$.items[0].system_process_category").value("SOURCE_MESSAGE_NOTIFICATION"))
.andExpect(jsonPath("$.items[0].system_task_type").value("SOURCE_MESSAGE_ONLY"))
.andReturn();
MvcResult s99Result = mockMvc.perform(signedPost(withSourceMessageId(s99, s99ExternalId), "nonce-p0-s99-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.items[0].route_code").value("S99"))
.andExpect(jsonPath("$.items[0].system_process_category").value("SOURCE_MESSAGE_NOTIFICATION"))
.andExpect(jsonPath("$.items[0].system_task_type").value("SOURCE_MESSAGE_ONLY"))
.andReturn();
assertSourceMessageOnlyTaskCount(s10Result, "S10");
assertSourceMessageOnlyTaskCount(s99Result, "S99");
}
@Test
void shouldRejectIllegalMainOutcomeFixturesWithoutCreatingTasks() throws Exception {
JsonNode cases = fixture("main_outcomes.json").path("cases");
for (String caseId : List.of(
"illegal_s10_with_review_object",
"illegal_s99_with_null_review",
"illegal_union_template_value")) {
JsonNode value = caseValue(cases, caseId);
String externalId = "p0-" + caseId.replace('_', '-');
SourceMessageCaptureResult source = captureSourceMessage(externalId);
mockMvc.perform(signedPost(withSourceMessageId(value, externalId), "nonce-" + externalId))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("ADAPTER_CONTRACT_ERROR"))
.andExpect(content().string(notContainsSecret()));
Long taskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_task
WHERE source_message_id = ?
""", Long.class, source.inboxId());
assertThat(taskCount).isZero();
}
}
@Test
void shouldReturnTypedInfrastructureInputErrorForMissingSourceIdentityFixtures() throws Exception {
JsonNode cases = fixture("source_identity_errors.json").path("cases");
for (JsonNode fixtureCase : cases) {
if (fixtureCase.path("expected_error").isNull()) {
continue;
}
String caseId = fixtureCase.path("case_id").asText();
String body = objectMapper.writeValueAsString(fixtureCase.path("input"));
mockMvc.perform(signedPost(body, "nonce-" + caseId))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.result_type").value("infrastructure_input_error"))
.andExpect(jsonPath("$.error_code").value("missing_source_message_id"))
.andExpect(jsonPath("$.retryable").value(true))
.andExpect(jsonPath("$.missing_fields[0]").value("source_message.source_message_id"))
.andExpect(jsonPath("$.route_code").doesNotExist())
.andExpect(jsonPath("$.message_events").doesNotExist());
}
}
@Test
void shouldNotRewriteOtherInfrastructureInputErrorsToMissingSourceMessageId() throws Exception {
mockMvc.perform(signedPost("""
{
"result_type": "infrastructure_input_error",
"error_code": "object_storage_unavailable",
"retryable": true,
"missing_fields": []
}
""", "nonce-p0-infra-other-001"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("INFRASTRUCTURE_INPUT_ERROR"))
.andExpect(jsonPath("$.result_type").doesNotExist());
}
@Test
void shouldLetNonBlankSourceIdentityPassP0GuardWithoutTypedMissingError() throws Exception {
JsonNode validInput = caseItem(
fixture("source_identity_errors.json").path("cases"),
"nonblank_source_message_id_passes_guard").path("input");
mockMvc.perform(signedPost(objectMapper.writeValueAsString(validInput), "nonce-p0-source-valid-guard-001"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("SOURCE_MESSAGE_REQUIRED"))
.andExpect(jsonPath("$.result_type").doesNotExist());
}
@Test
void shouldKeepCandidateGateFixtureAsP0RegressionReference() throws Exception {
JsonNode cases = fixture("candidate_gate.json").path("cases");
assertThat(caseIdsByValidity(cases, true)).containsExactly(
"valid_coarse_attachment_signal",
"valid_single_supported_direction",
"valid_main_no_active_signal_routes_s10",
"valid_skill_no_supported_event_routes_s10");
assertThat(caseIdsByValidity(cases, false)).containsExactly(
"invalid_candidate_missing_target_hints",
"invalid_candidate_wrong_status",
"invalid_candidate_empty_possible_types",
"invalid_review_outcome_used_as_candidate",
"invalid_evidence_refs_string_array",
"invalid_target_hints_object");
JsonNode supportedSignal = caseItem(cases, "valid_coarse_attachment_signal")
.path("candidate_events").get(0);
assertThat(supportedSignal.path("classification_status").asText()).isEqualTo("coarse_supported_signal");
assertThat(supportedSignal.path("possible_event_types")).hasSize(2);
assertThat(supportedSignal.path("evidence_refs").get(0).isObject()).isTrue();
assertThat(caseItem(cases, "invalid_review_outcome_used_as_candidate")
.path("candidate_events").get(0).path("possible_event_types").get(0).asText())
.isEqualTo("Need Manual Review");
}
@Test
void shouldCreateParentSplitTasksFromP0FixtureInEventOrder() throws Exception {
ObjectNode root = fixture("parent_split_two_children.json").deepCopy();
String externalId = "p0-parent-split-001";
SourceMessageCaptureResult source = captureSourceMessage(externalId);
MvcResult result = mockMvc.perform(signedPost(withSourceMessageId(root, externalId), "nonce-p0-parent-split-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accepted_count").value(3))
.andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
.andExpect(jsonPath("$.items[0].execution_order").value(1))
.andExpect(jsonPath("$.items[1].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
.andExpect(jsonPath("$.items[1].execution_order").value(1))
.andExpect(jsonPath("$.items[2].route_code").value("R07_LINKED_PARENT_RELEASE_AFTER_CHILD_SPLIT_NORMAL"))
.andReturn();
String parentOrderId = com.jayway.jsonpath.JsonPath.read(
result.getResponse().getContentAsString(),
"$.items[2].order_id");
Integer parentExecutionOrder = com.jayway.jsonpath.JsonPath.read(
result.getResponse().getContentAsString(),
"$.items[2].execution_order");
assertThat(parentOrderId).isNotBlank();
assertThat(parentExecutionOrder).isEqualTo(1);
Long transitionCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_ai_transition
WHERE source_message_id = ?
AND route_code IN (
'R02_NEW_GROUP_BLOCK_NORMAL',
'R07_LINKED_PARENT_RELEASE_AFTER_CHILD_SPLIT_NORMAL'
)
""", Long.class, source.inboxId());
assertThat(transitionCount).isEqualTo(3L);
}
@Test
void shouldCreateRowDerivedMainParentAndTraceTasksFromP0Fixture() throws Exception {
ObjectNode root = fixture("row_multiple_derived.json").deepCopy();
String externalId = "p0-row-derived-001";
SourceMessageCaptureResult source = captureSourceMessage(externalId);
mockMvc.perform(signedPost(withSourceMessageId(root, externalId), "nonce-p0-row-derived-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accepted_count").value(3))
.andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
.andExpect(jsonPath("$.items[1].route_code").value("R07_LINKED_PARENT_RELEASE_AFTER_CHILD_SPLIT_NORMAL"))
.andExpect(jsonPath("$.items[2].route_code").value("R13_EXTRA_BED_NORMAL"));
Long taskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_task
WHERE source_message_id = ?
""", Long.class, source.inboxId());
assertThat(taskCount).isEqualTo(3L);
}
@Test
void shouldPreserveMixedAllotmentUnhandledIntentAndRejectPartialAllotmentAsBusinessTask() throws Exception {
JsonNode fixture = fixture("allotment_scope.json");
ObjectNode mixedOutput = fixture.path("mixed_partial_allotment").path("output").deepCopy();
String externalId = "p0-allotment-mixed-001";
SourceMessageCaptureResult source = captureSourceMessage(externalId);
mockMvc.perform(signedPost(withSourceMessageId(mixedOutput, externalId), "nonce-p0-allotment-mixed-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accepted_count").value(2))
.andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
.andExpect(jsonPath("$.items[1].route_code").value("R42_UNHANDLED_CURRENT_INTENT"))
.andExpect(jsonPath("$.items[1].task_id").doesNotExist());
Long unsupportedTaskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_ai_transition
WHERE source_message_id = ?
AND ai_task_type IN (
'Allotment Maintenance',
'Update Booking / Amendment',
'Cancel Allotment'
)
""", Long.class, source.inboxId());
assertThat(unsupportedTaskCount).isZero();
}
@Test
void shouldCreateTypeKnownManualReviewAndResolveSameCardFromP0Fixture() throws Exception {
JsonNode manualReview = fixture("manual_review_resolution.json");
ObjectNode event = manualReview.path("known_subtype_manual_review").path("event").deepCopy();
String externalId = "p0-manual-review-same-card-001";
SourceMessageCaptureResult source = captureSourceMessage(externalId);
String body = businessRoot(externalId, event);
MvcResult result = mockMvc.perform(signedPost(body, "nonce-p0-manual-review-create-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_REVIEW"))
.andExpect(jsonPath("$.items[0].system_task_type").value("NEW_BOOKING"))
.andReturn();
String taskId = com.jayway.jsonpath.JsonPath.read(
result.getResponse().getContentAsString(),
"$.items[0].task_id");
String orderId = com.jayway.jsonpath.JsonPath.read(
result.getResponse().getContentAsString(),
"$.items[0].order_id");
mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-resolutions", taskId)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"confirmed_order_id": "%s",
"field_overrides": [
{
"field_pointer": "/extracted_fields/room_items/0/pms_room_type_code",
"value": "SU1"
}
]
}
""".formatted(orderId)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.task_status").value("READY"))
.andExpect(jsonPath("$.review_status").value("RESOLVED"))
.andExpect(jsonPath("$.review_resolution.field_overrides[0].field_pointer")
.value("/extracted_fields/room_items/0/pms_room_type_code"))
.andExpect(jsonPath("$.review_resolution.field_overrides[0].field_path")
.value("extracted_fields.pms_room_type_code"))
.andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.room_type']")
.value("SUITE"))
.andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.room_quantity']")
.value(2))
.andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.pms_room_type_code']")
.value("SU1"));
Long sourceTaskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_task
WHERE source_message_id = ?
""", Long.class, source.inboxId());
String aiPayloadJson = jdbcTemplate.queryForObject("""
SELECT ai_payload_json
FROM workflow_reservation_task_card
WHERE task_id = ?
""", String.class, Long.valueOf(taskId));
JsonNode aiPayload = objectMapper.readTree(aiPayloadJson);
assertThat(sourceTaskCount).isEqualTo(1L);
assertThat(aiPayload.path("extracted_fields").path("room_items").get(0)
.path("pms_room_type_code").isNull()).isTrue();
assertThat(aiPayload.path("extracted_fields").has("room_type")).isFalse();
assertThat(aiPayloadJson).doesNotContain("\"pms_room_type_code\":\"SU1\"");
}
private JsonNode fixture(String fileName) throws Exception {
return objectMapper.readTree(FIXTURES_DIR.resolve(fileName).toFile());
}
private JsonNode caseValue(JsonNode cases, String caseId) {
return caseItem(cases, caseId).path("value");
}
private JsonNode caseItem(JsonNode cases, String caseId) {
for (JsonNode item : cases) {
if (caseId.equals(item.path("case_id").asText())) {
return item;
}
}
throw new IllegalArgumentException("fixture case not found: " + caseId);
}
private List<String> caseIdsByValidity(JsonNode cases, boolean expectValid) {
List<String> ids = new java.util.ArrayList<>();
for (JsonNode item : cases) {
if (item.path("expect_valid").asBoolean(false) == expectValid) {
ids.add(item.path("case_id").asText());
}
}
return ids;
}
private String withSourceMessageId(JsonNode root, String externalId) throws Exception {
ObjectNode copy = root.deepCopy();
((ObjectNode) copy.path("source_message")).put("source_message_id", externalId);
return objectMapper.writeValueAsString(copy);
}
private String businessRoot(String externalId, JsonNode event) throws Exception {
ObjectNode root = objectMapper.createObjectNode();
ObjectNode sourceMessage = root.putObject("source_message");
sourceMessage.put("source_message_id", externalId);
sourceMessage.put("subject", "P0 fixture");
sourceMessage.put("from", "agent@example.test");
sourceMessage.putArray("cc");
sourceMessage.put("received_at", "2026-07-11T10:00:00+08:00");
sourceMessage.put("source_channel", "Email");
ArrayNode events = root.putArray("message_events");
events.add(event);
root.putArray("case_candidates");
root.putArray("extraction_warnings");
root.putArray("unhandled_current_intents");
return objectMapper.writeValueAsString(root);
}
private SourceMessageCaptureResult captureSourceMessage(String externalMessageId) {
return captureService.capture(new CaptureSourceMessageCommand(
"HOTEL-TEST",
"AGENTBUS",
"EMAIL",
externalMessageId,
"thread-" + externalMessageId,
"frame-" + externalMessageId,
"session-m002-p0",
Instant.parse("2026-07-11T02:00:00Z"),
"agent@example.test",
"M002 P0 fixture",
"P0 fixture source message.",
"<html><body>P0 fixture source message.</body></html>",
"{\"source\":{\"external_message_id\":\"" + externalMessageId + "\"}}",
"agentbus-outlook-v1",
List.of()
));
}
private void assertSourceMessageOnlyTaskCount(MvcResult result, String routeCode) throws Exception {
String taskId = com.jayway.jsonpath.JsonPath.read(
result.getResponse().getContentAsString(),
"$.items[0].task_id");
Long taskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_task task
JOIN workflow_reservation_ai_transition transition
ON transition.id = task.ai_transition_id
WHERE task.id = ?
AND transition.route_code = ?
AND task.queue_participation = 0
""", Long.class, Long.valueOf(taskId), routeCode);
assertThat(taskCount).isEqualTo(1L);
}
private MockHttpServletRequestBuilder signedPost(String body, String nonce) throws Exception {
String timestamp = Instant.now().toString();
return post(ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(body)
.header("X-TH-Hotel-SuperAgent-Client-Id", CLIENT_ID)
.header("X-TH-Hotel-SuperAgent-Timestamp", timestamp)
.header("X-TH-Hotel-SuperAgent-Nonce", nonce)
.header("X-TH-Hotel-SuperAgent-Signature", signature(body, nonce, timestamp));
}
private String signature(String body, String nonce, String timestamp) throws Exception {
String bodyHash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(body.getBytes(StandardCharsets.UTF_8)));
String canonical = "POST\n" + ENDPOINT + "\n" + timestamp + "\n" + nonce + "\n" + CLIENT_ID + "\n" + bodyHash;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return "sha256=" + HexFormat.of().formatHex(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8)));
}
private org.hamcrest.Matcher<String> notContainsSecret() {
return org.hamcrest.Matchers.not(containsString(SECRET));
}
}