package com.yunhai.flowcenter.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.yunhai.platform.openapi.core.YschOpenCall; import com.yunhai.platform.openapi.core.OpenApiException; import com.yunhai.platform.openapi.core.OpenApiErrorCode; import com.yunhai.platform.utils.exception.DException; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.junit.jupiter.params.provider.NullSource; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** * {@link FormCenterSchemaClient} 单元测试(2026-10-20 C1 第一刀)。
* 覆盖:① SchemaView(data) → 快照节的 fields 扁平化(分组展开、enum 收 options 值域); * ② audience 数组解析(契约 v2 C5/G3 breaking)+ 旧单值串容错;③ 失败重试 2 次后转 * SchemaUnavailableException(发布阻断语义)。 */ class FormCenterSchemaClientTest { private YschOpenCall openCall; private FormCenterSchemaClient client; @BeforeEach void setUp() { openCall = mock(YschOpenCall.class); client = new FormCenterSchemaClient(openCall, new ObjectMapper()); } @SuppressWarnings("unchecked") private Map schemaData(String uuid, int version, List audience) { Map data = new HashMap<>(); data.put("formUuid", uuid); data.put("version", version); data.put("code", "leave-form"); data.put("appCode", "svc-hall"); data.put("audience", audience); data.put("groups", Arrays.asList( map("key", "base", "label", "基本信息", "fields", Arrays.asList( map("key", "days", "label", "天数", "type", "number", "required", true), map("key", "grade", "label", "年级", "type", "select", "required", false, "options", Arrays.asList(map("label", "高一", "value", "g1"), map("label", "高二", "value", "g2"))))), map("key", "extra", "label", "补充", "fields", Arrays.asList( map("key", "reason", "label", "事由", "type", "text", "required", true))))); return data; } private static Map map(Object... kv) { Map m = new HashMap<>(); for (int i = 0; i + 1 < kv.length; i += 2) { m.put((String) kv[i], kv[i + 1]); } return m; } @Test @SuppressWarnings("unchecked") void flattensGroupsIntoFieldsAndCollectsEnumFromOptions() { when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))) .thenReturn(schemaData("U1", 3, Arrays.asList("STUDENT", "TEACHER"))); Map snap = client.fetchSchema(27L, "U1", 3); assertEquals("U1", snap.get("formUuid")); assertEquals(3, snap.get("formVersion")); assertEquals(Arrays.asList("STUDENT", "TEACHER"), snap.get("audience")); List> fields = (List>) snap.get("fields"); assertEquals(3, fields.size(), "两个分组的字段要扁平化到一处"); Map grade = fields.stream() .filter(f -> "grade".equals(f.get("key"))).findFirst().orElseThrow(AssertionError::new); assertEquals("select", grade.get("type")); assertEquals(Boolean.FALSE, grade.get("required")); assertEquals(Arrays.asList("g1", "g2"), grade.get("enum"), "options 值域收进 enum(域查③的输入)"); } @Test void legacySingleStringAudienceIsWrappedAsArray() { Map data = schemaData("U1", 3, null); data.put("audience", "STUDENT"); when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(data); Map snap = client.fetchSchema(27L, "U1", 3); assertEquals(Arrays.asList("STUDENT"), snap.get("audience"), "C5 切换窗口的旧单值串按一元素数组容错"); } @Test void retriesTwiceThenThrowsUnavailable() { when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))) .thenThrow(new RuntimeException("connect refused")); assertThrows(FormCenterSchemaClient.SchemaUnavailableException.class, () -> client.fetchSchema(27L, "U1", 3)); verify(openCall, times(FormCenterSchemaClient.MAX_ATTEMPTS)) .get(anyString(), anyString(), anyMap(), eq(Map.class)); } @Test @SuppressWarnings("unchecked") void nullDataTreatedAsFailure() { when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(null); assertThrows(FormCenterSchemaClient.SchemaUnavailableException.class, () -> client.fetchSchema(27L, "U1", 3)); verify(openCall, times(FormCenterSchemaClient.MAX_ATTEMPTS)) .get(anyString(), anyString(), anyMap(), eq(Map.class)); } @ParameterizedTest @NullSource @ValueSource(strings = {"0", "-1", "4", "3.5", "wrong", "2147483648"}) void malformedOrWrongReturnedVersionIsContractFailureWithoutRetry(String returnedVersion) { Map data = schemaData("U1", 3, Arrays.asList("STUDENT")); data.put("version", returnedVersion); when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(data); assertThrows(FormCenterSchemaClient.SchemaContractViolationException.class, () -> client.fetchSchema(27L, "U1", 3)); verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class)); } @ParameterizedTest @NullSource @ValueSource(strings = {"", "U2"}) void missingOrWrongReturnedUuidIsContractFailureWithoutRetry(String returnedUuid) { when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))) .thenReturn(schemaData(returnedUuid, 3, Arrays.asList("STUDENT"))); assertThrows(FormCenterSchemaClient.SchemaContractViolationException.class, () -> client.fetchSchema(27L, "U1", 3)); verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class)); } @ParameterizedTest @ValueSource(ints = {0, -1}) void illegalRequestedVersionMakesNoRemoteCall(int version) { assertThrows(IllegalArgumentException.class, () -> client.fetchSchema(27L, "U1", version)); org.mockito.Mockito.verifyNoInteractions(openCall); } @Test void resourceRejectionUsesEnvelopeCodeAndDoesNotRetry() { // SDK 实际携带 OpenApiErrorCode,不会转换成本地 ErrorCode 枚举;文案刻意无关键词。 when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))) .thenThrow(new DException(new OpenApiErrorCode(2, "opaque"))); assertThrows(FormCenterSchemaClient.SchemaResourceUnavailableException.class, () -> client.fetchSchema(27L, "U1", 3)); verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class)); } @ParameterizedTest @ValueSource(ints = {3, 4}) void authRejectionDoesNotBlindlyRetry(int code) { when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))) .thenThrow(new DException(new OpenApiErrorCode(code, "opaque"))); assertThrows(FormCenterSchemaClient.SchemaAccessDeniedException.class, () -> client.fetchSchema(27L, "U1", 3)); verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class)); } @Test void http404DoesNotBlindlyRetry() { when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))) .thenThrow(new OpenApiException("opaque", 404, "")); assertThrows(FormCenterSchemaClient.SchemaResourceUnavailableException.class, () -> client.fetchSchema(27L, "U1", 3)); verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class)); } @Test void internalRemoteFailureRemainsRetryable() { when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))) .thenThrow(new DException(new OpenApiErrorCode(6, "opaque"))); assertThrows(FormCenterSchemaClient.SchemaUnavailableException.class, () -> client.fetchSchema(27L, "U1", 3)); verify(openCall, times(FormCenterSchemaClient.MAX_ATTEMPTS)) .get(anyString(), anyString(), anyMap(), eq(Map.class)); } }