package com.baiyue.firstleg.controller; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.RequestParam; import org.yaml.snakeyaml.Yaml; import java.nio.charset.StandardCharsets; import java.lang.reflect.Method; import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; @DisplayName("FBA计费OpenAPI契约") class FbaPricingContractTest { @Test @DisplayName("确认和发送费用兼容新的组合权限与原有权限") void allowsCombinedConfirmSendPermissionAndLegacyPermissions() { assertPricingAuthority("preview", "hasAnyAuthority('firstleg:inbound:confirm-send-fee', 'firstleg:fba-pricing:calculate')"); assertPricingAuthority("confirm", "hasAnyAuthority('firstleg:inbound:confirm-send-fee'," + " 'firstleg:fba-pricing:confirm')"); assertPricingAuthority("send", "hasAnyAuthority('firstleg:inbound:confirm-send-fee'," + " 'firstleg:inbound:send-fee')"); assertPricingAuthority("adjustPublished", "hasAnyAuthority('firstleg:fba-pricing:adjust'," + " 'firstleg:inbound:confirm-send-fee')"); assertPricingAuthority("manualQuote", "hasAuthority('firstleg:inbound:confirm-send-fee')"); } @Test @DisplayName("客户规则保留客户参数,船司价格使用平台公共作用域") void keepsCustomerRuleScopeAndRemovesCarrierRateTenantScope() { Method rules = method("rules"); assertThat(rules.getParameterTypes()[1]).isEqualTo(Long.class); assertThat(rules.getParameters()[1].getAnnotation(RequestParam.class).required()).isFalse(); Method rates = method("rates"); assertThat(rates.getParameterTypes()).doesNotContain(Long.class); for (String scopedMethod : new String[] {"rule", "createRule", "retireRule", "setDefaultRule"}) { Method method = method(scopedMethod); assertThat(method.getParameterTypes()[1]).isEqualTo(long.class); assertThat(method.getParameters()[1].getAnnotation(RequestParam.class).required()).isTrue(); } for (String publicMethod : new String[] {"rate", "createRate", "retireRate"}) { assertThat(java.util.Arrays.stream(method(publicMethod).getParameters()) .map(parameter -> parameter.getAnnotation(RequestParam.class))) .allMatch(annotation -> annotation == null); } } @Test @DisplayName("契约声明二期边界计费、人工费用调整和幂等头") void documentsPricingLifecycle() throws Exception { String openapi = new ClassPathResource("openapi/fba-pricing-v1.yaml") .getContentAsString(StandardCharsets.UTF_8); assertThat(openapi) .contains("/api/platform/v1/fba-pricing/customer-rules/versions:") .contains("/api/platform/v1/inbounds/{inboundId}/fees/preview:") .contains("不生成费用版本") .contains("/api/platform/v1/fba-pricing/customer-rules/{id}/default:") .contains("/api/platform/v1/fba-pricing/carrier-rates/versions:") .contains("/api/platform/v1/inbounds/{inboundId}/fees/calculate:") .contains("/api/platform/v1/inbounds/{inboundId}/fees/manual-quote:") .contains("/api/platform/v1/inbounds/{inboundId}/fees/confirm:") .contains("/api/platform/v1/inbounds/{inboundId}/fees/send:") .contains("/api/platform/v1/inbounds/{inboundId}/fees/published:") .contains("Idempotency-Key") .contains("主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态") .contains("区域阶梯渠道可按平台 FBA 仓或海外中转仓的邮编区域") .contains("不再以 carrierId、业务 customerId 或客户计费规则作为前置") .contains("仅此动作将费用置为 SENT") .contains("写入 Finance Outbox") .contains("不回退 WAIT_SPLIT 状态") .contains("expectedFeeVersion") .contains("chargeableQuantity") .contains("unitPrice") .doesNotContain("required: [subtotalAmount, additionalAmount, reason]") .doesNotContain("待客户确认"); String customerRules = openapi.substring( openapi.indexOf("/api/platform/v1/fba-pricing/customer-rules:"), openapi.indexOf("/api/platform/v1/fba-pricing/customer-rules/versions:")); assertThat(customerRules) .contains("OptionalTenantId") .contains("name: ruleCode") .doesNotContain("#/components/parameters/Page") .doesNotContain("#/components/parameters/Size") .doesNotContain("name: customerName") .doesNotContain("name: chargeMode") .doesNotContain("name: status"); String carrierRates = openapi.substring( openapi.indexOf("/api/platform/v1/fba-pricing/carrier-rates:"), openapi.indexOf("/api/platform/v1/fba-pricing/carrier-rates/versions:")); assertThat(carrierRates) .doesNotContain("OptionalTenantId") .contains("name: rateCode"); assertThat(openapi) .contains("OptionalTenantId: {name: tenantId, in: query, required: false") .contains("schema: {type: string, pattern: '^[1-9][0-9]*$'}") .doesNotContain("format: int64"); } @Test @DisplayName("费用快照强制返回实际换算与计费量舍入参数") void requiresActualConversionAndRoundingMetadata() throws Exception { String source = new ClassPathResource("openapi/fba-pricing-v1.yaml") .getContentAsString(StandardCharsets.UTF_8); Map schemas = map(map(new Yaml().load(source)).get("components")); Map fee = map(map(schemas.get("schemas")).get("FeeSnapshotResponse")); assertThat(list(fee.get("required"))).contains( "conversionFactor", "roundingScale", "roundingMode"); Map properties = map(fee.get("properties")); assertThat(map(properties.get("conversionFactor")).get("description").toString()) .contains("167", "363"); assertThat(list(map(properties.get("roundingScale")).get("enum"))).containsExactly(6); assertThat(list(map(properties.get("roundingMode")).get("enum"))).containsExactly("HALF_UP"); } @Test @DisplayName("客户规则列表响应锁定数组包络和实体核心字段") void freezesCustomerRuleVersionListResponse() throws Exception { String source = new ClassPathResource("openapi/fba-pricing-v1.yaml") .getContentAsString(StandardCharsets.UTF_8); Map api = map(new Yaml().load(source)); Map paths = map(api.get("paths")); Map get = map(map(paths.get( "/api/platform/v1/fba-pricing/customer-rules")).get("get")); Map success = map(map(get.get("responses")).get("200")); Map responseSchema = map(map(map(success.get("content")) .get("application/json")).get("schema")); assertThat(responseSchema).containsEntry( "$ref", "#/components/schemas/CustomerRuleVersionListApiResponse"); Map schemas = map(map(api.get("components")).get("schemas")); Map envelope = map(schemas.get("CustomerRuleVersionListApiResponse")); assertThat(list(envelope.get("required"))).containsExactly("code", "message", "data", "timestamp"); Map envelopeProperties = map(envelope.get("properties")); assertThat(envelopeProperties).containsOnlyKeys( "code", "message", "data", "requestId", "traceId", "timestamp"); Map data = map(envelopeProperties.get("data")); assertThat(data).containsEntry("type", "array"); assertThat(map(data.get("items"))).containsEntry( "$ref", "#/components/schemas/CustomerRuleVersionResource"); Map resource = map(schemas.get("CustomerRuleVersionResource")); assertThat(map(resource.get("properties"))).containsOnlyKeys( "id", "tenantId", "ruleCode", "ruleVersion", "customerId", "customerName", "businessType", "chargeMode", "taxMode", "conversionFactor", "roundingScale", "roundingMode", "currency", "effectiveFrom", "effectiveTo", "status", "remark", "createdBy", "createdAt", "updatedBy", "updatedAt", "deleted", "version"); for (String longField : List.of("id", "tenantId", "customerId", "createdBy", "updatedBy")) { assertThat(map(map(resource.get("properties")).get(longField))).containsEntry("type", "string"); } assertThat(source).doesNotContain("CustomerRuleVersionSummary"); } private static Method method(String name) { return java.util.Arrays.stream(PlatformFbaMasterDataController.class.getDeclaredMethods()) .filter(candidate -> candidate.getName().equals(name)) .findFirst().orElseThrow(); } private static void assertPricingAuthority(String name, String expected) { Method method = java.util.Arrays.stream(PlatformFbaPricingController.class.getDeclaredMethods()) .filter(candidate -> candidate.getName().equals(name)) .findFirst().orElseThrow(); assertThat(method.getAnnotation(PreAuthorize.class).value()).isEqualTo(expected); } @SuppressWarnings("unchecked") private static Map map(Object value) { return (Map) value; } @SuppressWarnings("unchecked") private static List list(Object value) { return (List) value; } }