feat: 表单一期放行扩到 11 类(+checkbox/multiselect/phone/image)+ 契约审计修复
build / build-push (push) Successful in 2m9s
build / build-push (push) Successful in 2m9s
- FormValidator:PHASE1_TYPES 加 checkbox/multiselect/phone/image;options 必填口径扩到 select/radio/checkbox/multiselect(4 类);validateValue 补多选(值须在 options 内)分支, image 并入 upload 的附件数组通道 - 审计修复:maxFiles 上限判定改为 maxFiles>0(缺省或 0=不限),与前端设计器「留空不限」 及渲染器口径对齐——原实现把 0 当上限,会让 maxFiles:0 的表单任何附件都被拒 - ServiceForm 注释纠偏:契约类名 FormValidator(原误写 FormSchemaValidator)、一期 11 类 - 测试:FormValidatorTest +6(新 4 类 schema、多分组计数、options 必填、多选越界、phone pattern/maxLength、image maxFiles/非数组、precision 非白名单)、ServiceFormServiceTest +4 (冻结行不可变契约:带已发布版本 id 被拒 / 不传 id 按 code 升版出草稿 / 草稿就地更新) 验证:mvn test 102/102 绿(基线 92)。
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
package com.yunhai.svchall.service.impl;
|
||||
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.svchall.entity.ServiceForm;
|
||||
import com.yunhai.svchall.entity.ServiceItem;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcFormSaveRequest;
|
||||
import com.yunhai.svchall.model.svc.SvcModels.SvcFormVo;
|
||||
import com.yunhai.svchall.repository.ServiceFormRepository;
|
||||
import com.yunhai.svchall.repository.ServiceItemRepository;
|
||||
@@ -9,19 +11,24 @@ import com.yunhai.svchall.service.SiteDirectory;
|
||||
import com.yunhai.svchall.service.SiteGuard;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 表单列表使用场景可见性单测:list() 必须带回"被哪些事项绑定"的明细(refItems),
|
||||
* 表单列表使用场景可见性 + 保存升版契约单测:list() 必须带回"被哪些事项绑定"的明细(refItems),
|
||||
* 管理员才能从表单页反查使用位置(一期可见闭环);表单 sch 级共享,跨站点绑定如实展示。
|
||||
* save() 侧冻结「行不可变」:已发布/停用版本只接受"不传 id"的重提(按 code 升版),带 id 一律拒。
|
||||
*/
|
||||
class ServiceFormServiceTest {
|
||||
|
||||
@@ -130,4 +137,71 @@ class ServiceFormServiceTest {
|
||||
assertEquals(0, list.get(1).getRefItems().size(), "seal 表单不应带上 leave 的事项明细");
|
||||
assertEquals("seal", list.get(1).getCode());
|
||||
}
|
||||
|
||||
// ===== save():行不可变契约下「编辑已发布表单=不传 id 重提,后端按 code 升版」 =====
|
||||
|
||||
private SvcFormSaveRequest req(Integer id) {
|
||||
SvcFormSaveRequest r = new SvcFormSaveRequest();
|
||||
r.setId(id);
|
||||
r.setCode("leave");
|
||||
r.setName("请假申请单");
|
||||
r.setSchemaJson("{\"groups\":[{\"title\":\"基本信息\",\"fields\":["
|
||||
+ "{\"key\":\"reason\",\"label\":\"事由\",\"type\":\"textarea\",\"required\":true}]}]}");
|
||||
return r;
|
||||
}
|
||||
|
||||
@Test
|
||||
void savingPublishedVersionWithIdIsRejected() {
|
||||
// 设计器必须自行「去掉 id」——带已发布版本的 id 提交一律拒(P1 回归:曾导致编辑主路径整体不可用)
|
||||
when(formRepository.findByIdAndSchId(9, 1L)).thenReturn(Optional.of(form("leave", 1, 1)));
|
||||
assertThrows(BizException.class, () -> service.save(null, "S1", req(9)),
|
||||
"已发布版本不可就地改");
|
||||
}
|
||||
|
||||
@Test
|
||||
void savingWithoutIdOnPublishedCodeClonesNewDraftVersion() {
|
||||
ServiceForm published = form("leave", 1, 1);
|
||||
published.setId(9);
|
||||
when(formRepository.findBySchIdAndCodeOrderByVersionDesc(1L, "leave"))
|
||||
.thenReturn(Arrays.asList(published));
|
||||
when(formRepository.save(org.mockito.ArgumentMatchers.any(ServiceForm.class)))
|
||||
.thenAnswer(inv -> {
|
||||
ServiceForm f = inv.getArgument(0);
|
||||
f.setId(10);
|
||||
return f;
|
||||
});
|
||||
|
||||
assertEquals(Integer.valueOf(10), service.save(null, "S1", req(null)));
|
||||
|
||||
ArgumentCaptor<ServiceForm> saved = ArgumentCaptor.forClass(ServiceForm.class);
|
||||
verify(formRepository).save(saved.capture());
|
||||
assertEquals(Integer.valueOf(2), saved.getValue().getVersion(), "已发布版本重提应升一版");
|
||||
assertEquals(Integer.valueOf(0), saved.getValue().getStatus(), "升版产物必须是草稿");
|
||||
assertEquals("leave", saved.getValue().getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void savingWithoutIdOnExistingDraftUpdatesItInPlace() {
|
||||
ServiceForm draft = form("leave", 2, 0);
|
||||
draft.setId(10);
|
||||
when(formRepository.findBySchIdAndCodeOrderByVersionDesc(1L, "leave"))
|
||||
.thenReturn(Arrays.asList(draft));
|
||||
when(formRepository.save(draft)).thenReturn(draft);
|
||||
|
||||
assertEquals(Integer.valueOf(10), service.save(null, "S1", req(null)));
|
||||
assertEquals(Integer.valueOf(2), draft.getVersion(), "已有草稿不再升版,就地更新");
|
||||
assertEquals("请假申请单", draft.getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void savingDraftWithIdUpdatesItInPlace() {
|
||||
// 设计器装载到草稿版本(status=0)时应带 id:就地更新,不产生多余版本行
|
||||
ServiceForm draft = form("leave", 2, 0);
|
||||
draft.setId(10);
|
||||
when(formRepository.findByIdAndSchId(10, 1L)).thenReturn(Optional.of(draft));
|
||||
when(formRepository.save(draft)).thenReturn(draft);
|
||||
|
||||
assertEquals(Integer.valueOf(10), service.save(null, "S1", req(10)));
|
||||
verify(formRepository).save(draft);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* 表单 schema 契约单测(V2.0 Part B.3:14 类命名冻结/一期 7 类/声明式校验白名单/提交强校验)。
|
||||
* 表单 schema 契约单测(V2.0 Part B.3:14 类命名冻结/一期 11 类/声明式校验白名单/提交强校验)。
|
||||
*/
|
||||
@DisplayName("FormValidator - 表单 schema 契约")
|
||||
class FormValidatorTest {
|
||||
@@ -23,6 +23,18 @@ class FormValidatorTest {
|
||||
+ "{\"key\":\"joinDate\",\"label\":\"日期\",\"type\":\"date\"}"
|
||||
+ "]}]}";
|
||||
|
||||
/** 设计器迁移后新放行的 4 类 + 多分组(checkbox/multiselect/phone/image) */
|
||||
private static final String NEW_TYPES_SCHEMA = "{\"groups\":["
|
||||
+ "{\"title\":\"基本信息\",\"fields\":["
|
||||
+ "{\"key\":\"mobile\",\"label\":\"手机号\",\"type\":\"phone\",\"required\":true,"
|
||||
+ "\"pattern\":\"^1[3-9]\\\\d{9}$\",\"maxLength\":11},"
|
||||
+ "{\"key\":\"hobby\",\"label\":\"爱好\",\"type\":\"checkbox\",\"options\":[\"读书\",\"运动\"]},"
|
||||
+ "{\"key\":\"courses\",\"label\":\"课程\",\"type\":\"multiselect\",\"options\":[\"数学\",\"语文\"]}]},"
|
||||
+ "{\"title\":\"附件\",\"fields\":["
|
||||
+ "{\"key\":\"files\",\"label\":\"证明材料\",\"type\":\"upload\",\"accept\":\".pdf\",\"maxSize\":20,\"maxFiles\":2},"
|
||||
+ "{\"key\":\"photo\",\"label\":\"证件照\",\"type\":\"image\",\"accept\":\".jpg,.png\",\"maxSize\":5,\"maxFiles\":1}"
|
||||
+ "]}]}";
|
||||
|
||||
@Test
|
||||
@DisplayName("合法 schema 通过校验并返回字段数")
|
||||
void validSchemaPasses() {
|
||||
@@ -30,7 +42,7 @@ class FormValidatorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("一期只放行 7 类控件:user 为二期占位 → 拒绝")
|
||||
@DisplayName("二期类型(user/address/users)仍拒绝")
|
||||
void phase2TypeRejected() {
|
||||
String schema = "{\"groups\":[{\"fields\":["
|
||||
+ "{\"key\":\"u\",\"label\":\"用户\",\"type\":\"user\"}]}]}";
|
||||
@@ -39,6 +51,104 @@ class FormValidatorTest {
|
||||
.hasMessageContaining("二期开放");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("设计器新放行的 4 类(checkbox/multiselect/phone/image)通过;多分组按全表单计数字段")
|
||||
void newTypesAndMultiGroupPass() {
|
||||
assertThat(FormValidator.validateSchema(NEW_TYPES_SCHEMA)).isEqualTo(5);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("checkbox/multiselect 未配 options 拒绝(与 select/radio 同口径)")
|
||||
void optionRequiredForMultiChoice() {
|
||||
for (String type : new String[]{"checkbox", "multiselect"}) {
|
||||
String noOptions = "{\"groups\":[{\"fields\":["
|
||||
+ "{\"key\":\"h\",\"label\":\"爱好\",\"type\":\"" + type + "\"}]}]}";
|
||||
assertThatThrownBy(() -> FormValidator.validateSchema(noOptions))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("options");
|
||||
|
||||
String emptyOptions = "{\"groups\":[{\"fields\":["
|
||||
+ "{\"key\":\"h\",\"label\":\"爱好\",\"type\":\"" + type + "\",\"options\":[]}]}]}";
|
||||
assertThatThrownBy(() -> FormValidator.validateSchema(emptyOptions))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("options");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("提交校验:checkbox/multiselect 必须是数组且每个值都在 options 内")
|
||||
void submitMultiChoiceValidation() {
|
||||
FormValidator.validateSchema(NEW_TYPES_SCHEMA);
|
||||
String base = "\"mobile\":\"13800138000\"";
|
||||
|
||||
// 非数组
|
||||
assertThatThrownBy(() -> FormValidator.validateSubmit(
|
||||
"{" + base + ",\"hobby\":\"读书\"}", NEW_TYPES_SCHEMA))
|
||||
.hasMessageContaining("必须是多选数组");
|
||||
// 候选外
|
||||
assertThatThrownBy(() -> FormValidator.validateSubmit(
|
||||
"{" + base + ",\"courses\":[\"数学\",\"体育\"]}", NEW_TYPES_SCHEMA))
|
||||
.hasMessageContaining("候选列表");
|
||||
// 合法多选(含单元素数组与空对象提交的必填口径)
|
||||
assertThatCode(() -> FormValidator.validateSubmit(
|
||||
"{" + base + ",\"hobby\":[\"读书\",\"运动\"],\"courses\":[\"数学\"]}", NEW_TYPES_SCHEMA))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("提交校验:phone 走文本分支(pattern 与 maxLength 都生效)")
|
||||
void submitPhoneValidation() {
|
||||
FormValidator.validateSchema(NEW_TYPES_SCHEMA);
|
||||
// 位数够但首位非 1 → pattern 不匹配
|
||||
assertThatThrownBy(() -> FormValidator.validateSubmit(
|
||||
"{\"mobile\":\"23800138000\",\"hobby\":[\"读书\"]}", NEW_TYPES_SCHEMA))
|
||||
.hasMessageContaining("格式不正确");
|
||||
// 超长 → maxLength 先行拦截
|
||||
assertThatThrownBy(() -> FormValidator.validateSubmit(
|
||||
"{\"mobile\":\"13800138000123\",\"hobby\":[\"读书\"]}", NEW_TYPES_SCHEMA))
|
||||
.hasMessageContaining("长度超过上限");
|
||||
assertThatCode(() -> FormValidator.validateSubmit(
|
||||
"{\"mobile\":\"13900139000\",\"hobby\":[\"读书\"]}", NEW_TYPES_SCHEMA))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("提交校验:image 与 upload 同通道(数组 + maxFiles 上限;缺省/0=不限)")
|
||||
void submitImageValidation() {
|
||||
FormValidator.validateSchema(NEW_TYPES_SCHEMA);
|
||||
String mobile = "\"mobile\":\"13800138000\"";
|
||||
|
||||
assertThatThrownBy(() -> FormValidator.validateSubmit(
|
||||
"{" + mobile + ",\"photo\":\"http://x/1.jpg\"}", NEW_TYPES_SCHEMA))
|
||||
.hasMessageContaining("必须是附件/图片数组");
|
||||
// maxFiles=1 超限
|
||||
assertThatThrownBy(() -> FormValidator.validateSubmit(
|
||||
"{" + mobile + ",\"photo\":[{\"id\":1},{\"id\":2}]}", NEW_TYPES_SCHEMA))
|
||||
.hasMessageContaining("附件数超过上限");
|
||||
// 1 张通过
|
||||
assertThatCode(() -> FormValidator.validateSubmit(
|
||||
"{" + mobile + ",\"photo\":[{\"id\":1}]}", NEW_TYPES_SCHEMA))
|
||||
.doesNotThrowAnyException();
|
||||
|
||||
// maxFiles 缺省 = 不限;显式 0 与缺省同口径(设计器「留空/0 不限」)
|
||||
String noLimit = "{\"groups\":[{\"fields\":["
|
||||
+ "{\"key\":\"p\",\"label\":\"图\",\"type\":\"image\",\"maxFiles\":0}]}]}";
|
||||
FormValidator.validateSchema(noLimit);
|
||||
assertThatCode(() -> FormValidator.validateSubmit(
|
||||
"{\"p\":[{\"id\":1},{\"id\":2},{\"id\":3}]}", noLimit))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("number 的 precision 不在白名单 → 拒绝(设计器不产出该属性)")
|
||||
void precisionPropRejected() {
|
||||
String schema = "{\"groups\":[{\"fields\":["
|
||||
+ "{\"key\":\"amount\",\"label\":\"金额\",\"type\":\"number\",\"precision\":2}]}]}";
|
||||
assertThatThrownBy(() -> FormValidator.validateSchema(schema))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("precision");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("未冻结类型拒绝(14 类之外的字符串 key)")
|
||||
void unfrozenTypeRejected() {
|
||||
|
||||
Reference in New Issue
Block a user