R1 先行批:question-center 后端(SB 2.7.18,23 单测全绿)+ 建表脚本 + 仓库说明
build / build-push (push) Successful in 51s
build / build-push (push) Successful in 51s
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
package com.yunhai.question.fc;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.platform.openapi.core.YschOpenCall;
|
||||
import com.yunhai.platform.utils.exception.DException;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
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;
|
||||
|
||||
/**
|
||||
* FormCenterClient 单元测试(mock YschOpenCall):
|
||||
* ① 业务拒绝(DException=FC 信封 code!=0)不重试直接转 Reject;
|
||||
* ② 网络/信封异常重试 2 次后转 Unavailable;③ answers 分页解析(rows/nextCursor/after 透传)。
|
||||
*/
|
||||
class FormCenterClientTest {
|
||||
|
||||
private YschOpenCall openCall;
|
||||
private FormCenterClient client;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
openCall = mock(YschOpenCall.class);
|
||||
client = new FormCenterClient(openCall, new ObjectMapper());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchSchemaReturnsData() {
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("formUuid", "U1");
|
||||
data.put("version", 2);
|
||||
data.put("groups", Arrays.asList(
|
||||
group("base", field("q1", "你的性别", "radio",
|
||||
option("男", "m"), option("女", "f")))));
|
||||
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(data);
|
||||
|
||||
Map<String, Object> got = client.fetchSchema(27L, "U1");
|
||||
|
||||
assertEquals("U1", got.get("formUuid"));
|
||||
assertEquals(1, client.parseFields(got).size());
|
||||
assertEquals("radio", client.parseFields(got).get(0).get("type"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fcBusinessRejectIsNotRetried() {
|
||||
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
|
||||
.thenThrow(new DException("表单尚未发布:q-001"));
|
||||
|
||||
FormCenterClient.FormCenterRejectException e = assertThrows(
|
||||
FormCenterClient.FormCenterRejectException.class,
|
||||
() -> client.fetchSchema(27L, "U1"));
|
||||
assertTrue(e.getMessage().contains("表单尚未发布"));
|
||||
verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void networkFailureRetriesTwiceThenUnavailable() {
|
||||
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
|
||||
.thenThrow(new RuntimeException("connect refused"));
|
||||
|
||||
assertThrows(FormCenterClient.FormCenterUnavailableException.class,
|
||||
() -> client.fetchSchema(27L, "U1"));
|
||||
verify(openCall, times(FormCenterClient.MAX_ATTEMPTS))
|
||||
.get(anyString(), anyString(), anyMap(), eq(Map.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void pullAnswersParsesRowsAndCursor() {
|
||||
Map<String, Object> page = new HashMap<>();
|
||||
page.put("rows", Arrays.asList(row("a1"), row("a2")));
|
||||
page.put("nextCursor", "a2");
|
||||
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(page);
|
||||
|
||||
FormCenterClient.AnswersPage got = client.pullAnswers(27L, "U1", null, 500);
|
||||
|
||||
assertEquals(2, got.getRows().size());
|
||||
assertEquals("a2", got.getNextCursor());
|
||||
assertEquals("a1", got.getRows().get(0).get("answer_uuid"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void pullAnswersEndOfWindowHasNullCursor() {
|
||||
Map<String, Object> page = new HashMap<>();
|
||||
page.put("rows", Arrays.asList());
|
||||
page.put("nextCursor", null);
|
||||
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(page);
|
||||
|
||||
FormCenterClient.AnswersPage got = client.pullAnswers(27L, "U1", "a2", 500);
|
||||
|
||||
assertEquals(null, got.getNextCursor());
|
||||
assertTrue(got.getRows().isEmpty());
|
||||
}
|
||||
|
||||
private static Map<String, Object> row(String uuid) {
|
||||
Map<String, Object> r = new HashMap<>();
|
||||
r.put("answer_uuid", uuid);
|
||||
r.put("is_deleted", 0);
|
||||
return r;
|
||||
}
|
||||
|
||||
static Map<String, Object> group(String key, Map<String, Object>... fields) {
|
||||
Map<String, Object> g = new HashMap<>();
|
||||
g.put("key", key);
|
||||
g.put("fields", Arrays.asList(fields));
|
||||
return g;
|
||||
}
|
||||
|
||||
static Map<String, Object> field(String key, String label, String type, Map<String, Object>... options) {
|
||||
Map<String, Object> f = new HashMap<>();
|
||||
f.put("key", key);
|
||||
f.put("label", label);
|
||||
f.put("type", type);
|
||||
f.put("options", Arrays.asList(options));
|
||||
return f;
|
||||
}
|
||||
|
||||
static Map<String, Object> option(String label, String value) {
|
||||
Map<String, Object> o = new HashMap<>();
|
||||
o.put("label", label);
|
||||
o.put("value", value);
|
||||
return o;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package com.yunhai.question.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.question.entity.PullCursor;
|
||||
import com.yunhai.question.entity.Questionnaire;
|
||||
import com.yunhai.question.fc.FormCenterClient;
|
||||
import com.yunhai.question.repository.AnswerProjectionRepository;
|
||||
import com.yunhai.question.repository.PullCursorRepository;
|
||||
import com.yunhai.question.repository.QuestionnaireRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 投影服务单测(H2 真库 + mock FormCenterClient)——任务书点名的四件之二/三:
|
||||
* 游标推进、投影幂等 upsert(重放同窗口不产生重复行、不重复累计 seen_count)、墓碑翻位。
|
||||
*/
|
||||
@DataJpaTest
|
||||
class ProjectionServiceTest {
|
||||
|
||||
private static final Long SCH_ID = 990001L;
|
||||
private static final String FORM_UUID = "f0123456789abcdef0123456789abcde";
|
||||
|
||||
@Autowired
|
||||
private QuestionnaireRepository questionnaireRepository;
|
||||
@Autowired
|
||||
private PullCursorRepository pullCursorRepository;
|
||||
@Autowired
|
||||
private AnswerProjectionRepository answerProjectionRepository;
|
||||
@Autowired
|
||||
private PlatformTransactionManager txManager;
|
||||
|
||||
private FormCenterClient fc;
|
||||
private ProjectionService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
fc = mock(FormCenterClient.class);
|
||||
service = new ProjectionService(questionnaireRepository, pullCursorRepository,
|
||||
answerProjectionRepository, fc, new ObjectMapper(),
|
||||
new TransactionTemplate(txManager), 10, 100);
|
||||
}
|
||||
|
||||
private Questionnaire questionnaire(String formUuid) {
|
||||
Questionnaire q = new Questionnaire();
|
||||
q.setSchId(SCH_ID);
|
||||
q.setFormUuid(formUuid);
|
||||
q.setFormCode("q-001");
|
||||
q.setTitleSnapshot("测试问卷");
|
||||
q.setStatus(Questionnaire.STATUS_COLLECTING);
|
||||
q.setAnonymous(false);
|
||||
return questionnaireRepository.save(q);
|
||||
}
|
||||
|
||||
/** FC 行(snake_case 闭集) */
|
||||
private static Map<String, Object> fcRow(String uuid, boolean deleted, Object... valuePairs) {
|
||||
Map<String, Object> values = new LinkedHashMap<>();
|
||||
for (int i = 0; i + 1 < valuePairs.length; i += 2) {
|
||||
values.put(String.valueOf(valuePairs[i]), valuePairs[i + 1]);
|
||||
}
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("answer_uuid", uuid);
|
||||
row.put("form_uuid", FORM_UUID);
|
||||
row.put("submitter_id", 101);
|
||||
row.put("subject_id", null);
|
||||
row.put("status", "待处理");
|
||||
row.put("values", values);
|
||||
row.put("create_time", "2026-10-21 10:00:00");
|
||||
row.put("update_time", "2026-10-21 10:00:00");
|
||||
row.put("is_deleted", deleted ? 1 : 0);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** after=afterArg 的页(rows 可空) */
|
||||
private void stubPage(String afterArg, String nextCursor, Map<String, Object>... rows) {
|
||||
when(fc.pullAnswers(any(), anyString(), eq(afterArg), anyInt()))
|
||||
.thenReturn(new FormCenterClient.AnswersPage(Arrays.asList(rows), nextCursor));
|
||||
}
|
||||
|
||||
@Test
|
||||
void advancesCursorAcrossPages() {
|
||||
questionnaire(FORM_UUID);
|
||||
stubPage(null, "a1", fcRow("a1", false, "q1", "m"));
|
||||
stubPage("a1", "a2", fcRow("a2", false, "q1", "f"));
|
||||
stubPage("a2", null);
|
||||
|
||||
service.pullOnce(SCH_ID, FORM_UUID);
|
||||
assertEquals("a2", pullCursorRepository.findById(FORM_UUID).get().getCursor());
|
||||
assertEquals(2, answerProjectionRepository.countByFormUuid(FORM_UUID));
|
||||
|
||||
// 第二轮:游标从 a2 起,只拉增量
|
||||
stubPage("a2", "a3", fcRow("a3", false, "q1", "m"));
|
||||
stubPage("a3", null);
|
||||
int fresh = service.pullOnce(SCH_ID, FORM_UUID);
|
||||
assertEquals(1, fresh);
|
||||
assertEquals("a3", pullCursorRepository.findById(FORM_UUID).get().getCursor());
|
||||
assertEquals(3, answerProjectionRepository.countByFormUuid(FORM_UUID));
|
||||
// 增量轮从 a2 起——验证游标确已推进(a2 之前的窗口未再请求)
|
||||
verify(fc, times(1)).pullAnswers(any(), anyString(), eq((String) null), anyInt());
|
||||
verify(fc, times(1)).pullAnswers(any(), anyString(), eq("a1"), anyInt());
|
||||
verify(fc, times(2)).pullAnswers(any(), anyString(), eq("a2"), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void replaySameWindowIsIdempotent() {
|
||||
questionnaire(FORM_UUID);
|
||||
stubPage(null, "a1", fcRow("a1", false, "q1", "m"));
|
||||
stubPage("a1", null);
|
||||
service.pullOnce(SCH_ID, FORM_UUID);
|
||||
|
||||
// 模拟游标回拨(运维/异常后重放):从头再拉同窗口 + 新行 a2——a1 重放不算新行
|
||||
PullCursor rollback = pullCursorRepository.findById(FORM_UUID).orElseThrow(AssertionError::new);
|
||||
rollback.setCursor(null);
|
||||
pullCursorRepository.saveAndFlush(rollback);
|
||||
stubPage(null, "a2", fcRow("a1", false, "q1", "m"), fcRow("a2", false, "q1", "f"));
|
||||
stubPage("a2", null);
|
||||
int fresh = service.pullOnce(SCH_ID, FORM_UUID);
|
||||
|
||||
assertEquals(1, fresh);
|
||||
assertEquals(2, answerProjectionRepository.countByFormUuid(FORM_UUID));
|
||||
PullCursor cursor = pullCursorRepository.findById(FORM_UUID).get();
|
||||
assertEquals(2L, cursor.getSeenCount(), "重放行不重复累计 seen_count");
|
||||
assertEquals("a2", cursor.getCursor());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tombstoneFlipsIsDeletedWithoutDroppingRow() {
|
||||
questionnaire(FORM_UUID);
|
||||
stubPage(null, "a1", fcRow("a1", false, "q1", "m"));
|
||||
stubPage("a1", null);
|
||||
service.pullOnce(SCH_ID, FORM_UUID);
|
||||
assertEquals(1, answerProjectionRepository.countByFormUuidAndIsDeletedFalse(FORM_UUID));
|
||||
|
||||
// FC 侧软删 → 墓碑行:同一 answer_uuid,is_deleted=1
|
||||
stubPage("a1", null, fcRow("a1", true, "q1", "m"));
|
||||
service.pullOnce(SCH_ID, FORM_UUID);
|
||||
|
||||
assertEquals(0, answerProjectionRepository.countByFormUuidAndIsDeletedFalse(FORM_UUID));
|
||||
assertEquals(1, answerProjectionRepository.countByFormUuid(FORM_UUID), "墓碑不删行");
|
||||
assertTrue(answerProjectionRepository.findByAnswerUuid("a1").getIsDeleted());
|
||||
assertEquals(1L, pullCursorRepository.findById(FORM_UUID).get().getSeenCount(),
|
||||
"墓碑是已见行的翻位,不计新行");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatedRowKeepsSingleRow() {
|
||||
questionnaire(FORM_UUID);
|
||||
stubPage(null, "a1", fcRow("a1", false, "q1", "m"));
|
||||
stubPage("a1", null);
|
||||
service.pullOnce(SCH_ID, FORM_UUID);
|
||||
|
||||
// 状态被 FC 侧更新后重放:同 uuid 更新而非插入
|
||||
Map<String, Object> updated = fcRow("a1", false, "q1", "f");
|
||||
updated.put("status", "已处理");
|
||||
stubPage("a1", null, updated);
|
||||
service.pullOnce(SCH_ID, FORM_UUID);
|
||||
|
||||
assertEquals(1, answerProjectionRepository.countByFormUuid(FORM_UUID));
|
||||
assertEquals("已处理", answerProjectionRepository.findByAnswerUuid("a1").getStatus());
|
||||
assertEquals(1L, pullCursorRepository.findById(FORM_UUID).get().getSeenCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void firstPullWithEmptyWindowInitializesCursor() {
|
||||
questionnaire(FORM_UUID);
|
||||
stubPage(null, null);
|
||||
|
||||
int fresh = service.pullOnce(SCH_ID, FORM_UUID);
|
||||
|
||||
assertEquals(0, fresh);
|
||||
PullCursor cursor = pullCursorRepository.findById(FORM_UUID).get();
|
||||
assertNull(cursor.getCursor(), "空窗口不推进游标(null=从未有行)");
|
||||
assertEquals("OK", cursor.getLastStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reconciliationWarnsOnSeenCountDrift() {
|
||||
questionnaire(FORM_UUID);
|
||||
stubPage(null, "a1", fcRow("a1", false, "q1", "m"));
|
||||
stubPage("a1", null);
|
||||
service.pullOnce(SCH_ID, FORM_UUID);
|
||||
|
||||
// 模拟投影表被外部删行:seen_count=1 但投影总量=0 → 漂移告警路径(不抛错)
|
||||
answerProjectionRepository.deleteAll();
|
||||
service.checkReconciliation(FORM_UUID, pullCursorRepository.findById(FORM_UUID).get());
|
||||
assertEquals(0, answerProjectionRepository.countByFormUuid(FORM_UUID));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.yunhai.question.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.question.entity.Questionnaire;
|
||||
import com.yunhai.question.fc.FormCenterClient;
|
||||
import com.yunhai.question.repository.QuestionnaireRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* 绑定登记幂等单测(H2 真库——uk form_uuid 是数据库级约束,mock 测不出真实行为):
|
||||
* 重复提交第三步不产生重复行;发布被 FC 拒(未发布)时拦截。
|
||||
*/
|
||||
@DataJpaTest
|
||||
class QuestionnaireServiceTest {
|
||||
|
||||
private static final Long SCH_ID = 990001L;
|
||||
|
||||
@Autowired
|
||||
private QuestionnaireRepository questionnaireRepository;
|
||||
|
||||
private FormCenterClient fc;
|
||||
private QuestionnaireService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
fc = mock(FormCenterClient.class);
|
||||
service = new QuestionnaireService(questionnaireRepository, fc, new ObjectMapper());
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerIsIdempotentOnFormUuid() {
|
||||
Questionnaire first = service.register(SCH_ID,
|
||||
"u0123456789abcdef0123456789abcde", "q-001", "食堂满意度调查", false, null);
|
||||
Questionnaire second = service.register(SCH_ID,
|
||||
"u0123456789abcdef0123456789abcde", "q-001", "食堂满意度调查", false, null);
|
||||
|
||||
assertSame(first, second, "重复登记返回既有行");
|
||||
assertEquals(1, questionnaireRepository.count());
|
||||
assertEquals(Questionnaire.STATUS_DRAFT, second.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerKeepsSchoolsIsolated() {
|
||||
service.register(SCH_ID, "u0123456789abcdef0123456789abcde", "q-001", "本校问卷", false, null);
|
||||
service.register(990002L, "u1123456789abcdef0123456789abcde", "q-002", "他校问卷", false, null);
|
||||
|
||||
List<Questionnaire> mine = service.list(SCH_ID);
|
||||
assertEquals(1, mine.size());
|
||||
assertEquals("u0123456789abcdef0123456789abcde", mine.get(0).getFormUuid());
|
||||
}
|
||||
|
||||
@Test
|
||||
void myFillableIntersectsByFormCode() {
|
||||
Questionnaire draft1 = service.register(SCH_ID, "u0123456789abcdef0123456789abcde", "q-001", "问卷一", false, null);
|
||||
Questionnaire draft2 = service.register(SCH_ID, "u1123456789abcdef0123456789abcde", "q-002", "问卷二", false, null);
|
||||
draft1.setStatus(Questionnaire.STATUS_COLLECTING);
|
||||
draft2.setStatus(Questionnaire.STATUS_COLLECTING);
|
||||
questionnaireRepository.save(draft1);
|
||||
questionnaireRepository.save(draft2);
|
||||
|
||||
// FC fillable 只回了 q-002/q-999:交集后仅问卷二可见
|
||||
List<Questionnaire> got = service.myFillable(SCH_ID, Arrays.asList("q-002", "q-999"));
|
||||
assertEquals(1, got.size());
|
||||
assertEquals("q-002", got.get(0).getFormCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void myFillableWithNoCodesReturnsEmpty() {
|
||||
service.register(SCH_ID, "u0123456789abcdef0123456789abcde", "q-001", "问卷一", false, null);
|
||||
assertEquals(0, service.myFillable(SCH_ID, java.util.Collections.<String>emptyList()).size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.yunhai.question.service;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.question.common.BizException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/** 校管理员守卫单测:超管(type=1)/校领导(2)/管理员(7) 放行,普通教师/学生/未登录拒绝 */
|
||||
class SchoolAdminGuardTest {
|
||||
|
||||
private final SchoolAdminGuard guard = new SchoolAdminGuard();
|
||||
|
||||
private BoaUser user(Integer id, Integer type, Integer schId, Integer... roleIds) {
|
||||
BoaUser user = mock(BoaUser.class, RETURNS_DEEP_STUBS);
|
||||
when(user.getId()).thenReturn(id);
|
||||
when(user.getType()).thenReturn(type);
|
||||
when(user.getRoleIds()).thenReturn(roleIds.length == 0 ? null
|
||||
: new java.util.ArrayList<>(Arrays.asList(roleIds)));
|
||||
when(user.getCurrentRole().getSchId()).thenReturn(schId);
|
||||
return user;
|
||||
}
|
||||
|
||||
@Test
|
||||
void superAdminPasses() {
|
||||
assertEquals(990001L, guard.requireSchool(user(1, 1, 990001)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void schoolLeaderAndAdminRolePass() {
|
||||
assertEquals(990001L, guard.requireSchool(user(9, 2, 990001, 2)));
|
||||
assertEquals(990001L, guard.requireSchool(user(10, 2, 990001, 1, 7)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void plainTeacherAndStudentRejected() {
|
||||
assertThrows(BizException.class, () -> guard.requireSchool(user(11, 2, 990001, 1)));
|
||||
assertThrows(BizException.class, () -> guard.requireSchool(user(12, 2, 990001, 3)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anonymousAndNoSchoolContextRejected() {
|
||||
assertThrows(BizException.class, () -> guard.requireSchool(user(null, null, null)));
|
||||
assertThrows(BizException.class, () -> guard.requireSchool(user(13, 1, null, 2)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.yunhai.question.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.question.entity.AnswerProjection;
|
||||
import com.yunhai.question.entity.Questionnaire;
|
||||
import com.yunhai.question.fc.FormCenterClient;
|
||||
import com.yunhai.question.repository.AnswerProjectionRepository;
|
||||
import com.yunhai.question.repository.QuestionnaireRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
|
||||
|
||||
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.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* 基础统计单测(H2 真库)——任务书点名的"人数口径统计":
|
||||
* 一份答卷对一题的一个选项最多计 1,多选按答卷去重后逐选项计数(与选次口径可区分)。
|
||||
*/
|
||||
@DataJpaTest
|
||||
class StatsServiceTest {
|
||||
|
||||
private static final String FORM_UUID = "s0123456789abcdef0123456789abcde";
|
||||
|
||||
@Autowired
|
||||
private QuestionnaireRepository questionnaireRepository;
|
||||
@Autowired
|
||||
private AnswerProjectionRepository answerProjectionRepository;
|
||||
|
||||
private StatsService statsService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
QuestionnaireService questionnaireService = new QuestionnaireService(
|
||||
questionnaireRepository, mock(FormCenterClient.class), new ObjectMapper());
|
||||
statsService = new StatsService(answerProjectionRepository, questionnaireService, new ObjectMapper());
|
||||
}
|
||||
|
||||
private Questionnaire published() {
|
||||
Questionnaire q = new Questionnaire();
|
||||
q.setSchId(990001L);
|
||||
q.setFormUuid(FORM_UUID);
|
||||
q.setFormCode("q-001");
|
||||
q.setTitleSnapshot("统计测试卷");
|
||||
q.setStatus(Questionnaire.STATUS_COLLECTING);
|
||||
q.setAnonymous(false);
|
||||
q.setFormVersion(3);
|
||||
// schema 快照:单选 q1(radio) + 多选 q2(checkbox) + 文本 q3(text)
|
||||
q.setSchemaSnapshot("{\"formUuid\":\"" + FORM_UUID + "\",\"version\":3,\"groups\":[{\"key\":\"g1\",\"fields\":["
|
||||
+ "{\"key\":\"q1\",\"label\":\"年级\",\"type\":\"radio\",\"required\":true,\"options\":["
|
||||
+ "{\"label\":\"高一\",\"value\":\"g1v1\"},{\"label\":\"高二\",\"value\":\"g1v2\"}]},"
|
||||
+ "{\"key\":\"q2\",\"label\":\"喜欢的社团(多选)\",\"type\":\"checkbox\",\"options\":["
|
||||
+ "{\"label\":\"篮球\",\"value\":\"shetuan_basketball\"},{\"label\":\"合唱\",\"value\":\"shetuan_chorus\"},{\"label\":\"编程\",\"value\":\"shetuan_coding\"}]},"
|
||||
+ "{\"key\":\"q3\",\"label\":\"建议\",\"type\":\"textarea\"}"
|
||||
+ "]}]}");
|
||||
return questionnaireRepository.save(q);
|
||||
}
|
||||
|
||||
private void answer(String uuid, Object q1, Object q2, String q3) {
|
||||
AnswerProjection p = new AnswerProjection();
|
||||
p.setAnswerUuid(uuid);
|
||||
p.setFormUuid(FORM_UUID);
|
||||
Map<String, Object> values = new HashMap<>();
|
||||
values.put("q1", q1);
|
||||
values.put("q2", q2);
|
||||
values.put("q3", q3);
|
||||
try {
|
||||
p.setValuesJson(new ObjectMapper().writeValueAsString(values));
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
p.setSubmitterId(101);
|
||||
p.setStatus("待处理");
|
||||
p.setIsDeleted(false);
|
||||
p.setFcCreateTime("2026-10-21 10:00:00");
|
||||
answerProjectionRepository.save(p);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void multiSelectCountsDistinctRespondentsPerOption() {
|
||||
published();
|
||||
// 卷A:q2 选 [篮球,合唱];卷B:q2 选 [合唱];卷C:q2 未答
|
||||
answer("a1", "g1v1", Arrays.asList("shetuan_basketball", "shetuan_chorus"), "很好");
|
||||
answer("a2", "g1v2", Arrays.asList("shetuan_chorus"), null);
|
||||
answer("a3", "g1v1", null, null);
|
||||
|
||||
Map<String, Object> stats = statsService.stats(questionnaireRepository
|
||||
.findByFormUuidAndIsDeletedFalse(FORM_UUID).get());
|
||||
|
||||
assertEquals(3L, stats.get("total"));
|
||||
|
||||
List<Map<String, Object>> byField = (List<Map<String, Object>>) stats.get("byField");
|
||||
Map<String, Object> q2 = byField.stream()
|
||||
.filter(f -> "q2".equals(f.get("key"))).findFirst().get();
|
||||
|
||||
// 人数口径:篮球=1 人、合唱=2 人、编程=0 人;answeredSheets=2(a3 未答不计分母)
|
||||
assertEquals(2L, q2.get("answeredSheets"));
|
||||
Map<String, Long> counts = new HashMap<>();
|
||||
for (Map<String, Object> item : (List<Map<String, Object>>) q2.get("items")) {
|
||||
counts.put(String.valueOf(item.get("value")), ((Number) item.get("count")).longValue());
|
||||
}
|
||||
assertEquals(1L, counts.get("shetuan_basketball"), "篮球:仅卷A计 1 人");
|
||||
assertEquals(2L, counts.get("shetuan_chorus"), "合唱:卷A+卷B 各计 1 人(人数口径,非选次——选次也是 2)");
|
||||
assertEquals(0L, counts.get("shetuan_coding"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void multiSelectSameOptionTwiceInOneSheetCountsOnce() {
|
||||
published();
|
||||
// 一份答卷的 q2 里同选项出现两次(异常数据防御):人数口径仍计 1
|
||||
answer("a1", "g1v1", Arrays.asList("shetuan_basketball", "shetuan_basketball"), null);
|
||||
|
||||
Map<String, Object> stats = statsService.stats(questionnaireRepository
|
||||
.findByFormUuidAndIsDeletedFalse(FORM_UUID).get());
|
||||
List<Map<String, Object>> byField = (List<Map<String, Object>>) stats.get("byField");
|
||||
Map<String, Object> q2 = byField.stream()
|
||||
.filter(f -> "q2".equals(f.get("key"))).findFirst().get();
|
||||
Map<String, Long> counts = new HashMap<>();
|
||||
for (Map<String, Object> item : (List<Map<String, Object>>) q2.get("items")) {
|
||||
counts.put(String.valueOf(item.get("value")), ((Number) item.get("count")).longValue());
|
||||
}
|
||||
assertEquals(1L, counts.get("shetuan_basketball"), "同卷同选项去重后计 1");
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void radioCountsByScalarAndTextDetailListed() {
|
||||
published();
|
||||
answer("a1", "g1v1", null, "建议一");
|
||||
answer("a2", "g1v2", null, "建议二");
|
||||
|
||||
Map<String, Object> stats = statsService.stats(questionnaireRepository
|
||||
.findByFormUuidAndIsDeletedFalse(FORM_UUID).get());
|
||||
List<Map<String, Object>> byField = (List<Map<String, Object>>) stats.get("byField");
|
||||
Map<String, Object> q1 = byField.stream()
|
||||
.filter(f -> "q1".equals(f.get("key"))).findFirst().get();
|
||||
Map<String, Long> counts = new HashMap<>();
|
||||
for (Map<String, Object> item : (List<Map<String, Object>>) q1.get("items")) {
|
||||
counts.put(String.valueOf(item.get("value")), ((Number) item.get("count")).longValue());
|
||||
}
|
||||
assertEquals(1L, counts.get("g1v1"));
|
||||
assertEquals(1L, counts.get("g1v2"));
|
||||
|
||||
List<Map<String, Object>> textDetail = (List<Map<String, Object>>) stats.get("textDetail");
|
||||
Map<String, Object> q3 = textDetail.stream()
|
||||
.filter(f -> "q3".equals(f.get("key"))).findFirst().get();
|
||||
assertEquals(Arrays.asList("建议一", "建议二"), q3.get("items"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void tombstoneRowsAreExcluded() {
|
||||
published();
|
||||
answer("a1", "g1v1", null, null);
|
||||
AnswerProjection tombstone = new AnswerProjection();
|
||||
tombstone.setAnswerUuid("a2");
|
||||
tombstone.setFormUuid(FORM_UUID);
|
||||
tombstone.setValuesJson("{\"q1\":\"g1v2\"}");
|
||||
tombstone.setSubmitterId(102);
|
||||
tombstone.setIsDeleted(true);
|
||||
answerProjectionRepository.save(tombstone);
|
||||
|
||||
Map<String, Object> stats = statsService.stats(questionnaireRepository
|
||||
.findByFormUuidAndIsDeletedFalse(FORM_UUID).get());
|
||||
assertEquals(1L, stats.get("total"), "墓碑行不进统计");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user