feat(education): add question favorites

This commit is contained in:
2026-07-28 00:21:35 +08:00
parent 12676dfbec
commit 93f02df68c
19 changed files with 2245 additions and 0 deletions

View File

@@ -0,0 +1,455 @@
package cn.iocoder.yudao.module.education.controller.app.favorite;
import cn.iocoder.yudao.framework.common.biz.infra.logger.ApiErrorLogCommonApi;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.framework.web.core.handler.GlobalExceptionHandler;
import cn.iocoder.yudao.module.education.controller.app.favorite.vo.*;
import cn.iocoder.yudao.module.education.service.favorite.FavoriteService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.*;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* FavoriteController HTTP seam test — uses standalone MockMvc with real controller
* wiring and the production {@link GlobalExceptionHandler} to prove route mapping,
* authentication, query param forwarding, answer-field absence in JSON,
* and error handling through the HTTP layer.
*
* @author 恭学教育
*/
class FavoriteControllerHttpTest {
private MockMvc mockMvc;
private FavoriteService favoriteService;
@BeforeEach
void setUp() {
favoriteService = mock(FavoriteService.class);
FavoriteController controller = new FavoriteController();
try {
var field = FavoriteController.class.getDeclaredField("favoriteService");
field.setAccessible(true);
field.set(controller, favoriteService);
} catch (Exception e) {
throw new RuntimeException(e);
}
// Use the real GlobalExceptionHandler to match production behavior
ApiErrorLogCommonApi apiErrorLogApi = mock(ApiErrorLogCommonApi.class);
GlobalExceptionHandler realHandler = new GlobalExceptionHandler("test", apiErrorLogApi);
mockMvc = MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(realHandler)
.build();
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
// ========== POST /education/favorite/create ==========
@Test
void shouldCreateFavorite() throws Exception {
setLoginUser(100L);
FavoritePageItemRespVO expected = FavoritePageItemRespVO.builder()
.id(1L).targetType("QUESTION").targetId("q-001")
.stem("Test question?").type("choice").difficulty("easy")
.options(List.of(FavoritePageItemRespVO.OptionVO.builder()
.label("A").content("Option A").order(1.0).build()))
.contentVersion("v1").available(true)
.createTime(LocalDateTime.now())
.build();
when(favoriteService.create(any(), eq(100L), eq(1L))).thenReturn(expected);
MvcResult result = mockMvc.perform(post("/education/favorite/create")
.contentType("application/json")
.content("{\"targetType\":\"QUESTION\",\"targetId\":\"q-001\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.id").value(1))
.andExpect(jsonPath("$.data.targetType").value("QUESTION"))
.andExpect(jsonPath("$.data.targetId").value("q-001"))
.andReturn();
String json = result.getResponse().getContentAsString();
assertFalse(json.contains("correctAnswer"), "HTTP response must not contain correctAnswer");
assertFalse(json.contains("\"answer\""), "HTTP response must not contain answer");
assertFalse(json.contains("explanation"), "HTTP response must not contain explanation");
assertFalse(json.contains("isCorrect"), "HTTP response must not contain isCorrect");
assertTrue(json.contains("\"stem\""), "HTTP response should contain stem");
assertTrue(json.contains("\"options\""), "HTTP response should contain options");
}
@Test
void shouldReturnUnauthorizedCodeForFavoriteCreateWithoutAuth() throws Exception {
// Production: GlobalExceptionHandler returns HTTP 200 with error code in body
mockMvc.perform(post("/education/favorite/create")
.contentType("application/json")
.content("{\"targetType\":\"QUESTION\",\"targetId\":\"q-001\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode()));
}
@Test
void shouldRejectFavoriteForInvisibleQuestion() throws Exception {
setLoginUser(100L);
when(favoriteService.create(any(), eq(100L), eq(1L)))
.thenThrow(new ServiceException(QUESTION_NOT_FOUND.getCode(), QUESTION_NOT_FOUND.getMsg()));
mockMvc.perform(post("/education/favorite/create")
.contentType("application/json")
.content("{\"targetType\":\"QUESTION\",\"targetId\":\"q-hidden\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(QUESTION_NOT_FOUND.getCode()));
}
@Test
void shouldRejectFavoriteWithInvalidTargetType() throws Exception {
setLoginUser(100L);
when(favoriteService.create(any(), eq(100L), eq(1L)))
.thenThrow(new ServiceException(FAVORITE_TARGET_TYPE_INVALID.getCode(),
FAVORITE_TARGET_TYPE_INVALID.getMsg()));
mockMvc.perform(post("/education/favorite/create")
.contentType("application/json")
.content("{\"targetType\":\"INVALID\",\"targetId\":\"q-001\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(FAVORITE_TARGET_TYPE_INVALID.getCode()));
}
// ========== DELETE /education/favorite/delete ==========
@Test
void shouldDeleteFavoriteById() throws Exception {
setLoginUser(100L);
doNothing().when(favoriteService).delete(any(), eq(100L), eq(1L));
mockMvc.perform(delete("/education/favorite/delete")
.contentType("application/json")
.content("{\"id\":1}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data").value(true));
}
@Test
void shouldDeleteFavoriteByTargetTypeAndTargetId() throws Exception {
setLoginUser(100L);
doNothing().when(favoriteService).delete(any(), eq(100L), eq(1L));
mockMvc.perform(delete("/education/favorite/delete")
.contentType("application/json")
.content("{\"targetType\":\"QUESTION\",\"targetId\":\"q-001\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data").value(true));
}
@Test
void shouldReturnUnauthorizedCodeForFavoriteDeleteWithoutAuth() throws Exception {
mockMvc.perform(delete("/education/favorite/delete")
.contentType("application/json")
.content("{\"id\":1}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode()));
}
@Test
void shouldReturnSuccessForRepeatDelete() throws Exception {
setLoginUser(100L);
doNothing().when(favoriteService).delete(any(), eq(100L), eq(1L));
mockMvc.perform(delete("/education/favorite/delete")
.contentType("application/json")
.content("{\"id\":1}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data").value(true));
}
// ========== GET /education/favorite/page ==========
@Test
void shouldPageFavorites() throws Exception {
setLoginUser(100L);
PageResult<FavoritePageItemRespVO> pageResult = new PageResult<>(
List.of(FavoritePageItemRespVO.builder()
.id(1L).targetType("QUESTION").targetId("q-001")
.stem("Q1").type("choice").difficulty("easy")
.contentVersion("v1").available(true)
.createTime(LocalDateTime.now())
.build()),
1L
);
when(favoriteService.page(any(), eq(100L), eq(1L))).thenReturn(pageResult);
MvcResult result = mockMvc.perform(get("/education/favorite/page")
.param("pageNo", "1")
.param("pageSize", "10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.list[0].targetId").value("q-001"))
.andReturn();
String json = result.getResponse().getContentAsString();
assertFalse(json.contains("correctAnswer"), "page response must not contain correctAnswer");
assertFalse(json.contains("explanation"), "page response must not contain explanation");
}
@Test
void shouldForwardTargetTypeFilter() throws Exception {
setLoginUser(100L);
when(favoriteService.page(any(), eq(100L), eq(1L))).thenReturn(
new PageResult<>(Collections.emptyList(), 0L));
mockMvc.perform(get("/education/favorite/page")
.param("pageNo", "1")
.param("pageSize", "20")
.param("targetType", "QUESTION"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
verify(favoriteService).page(argThat(req ->
"QUESTION".equals(req.getTargetType())), eq(100L), eq(1L));
}
@Test
void shouldReturnUnauthorizedCodeForFavoritePageWithoutAuth() throws Exception {
mockMvc.perform(get("/education/favorite/page")
.param("pageNo", "1")
.param("pageSize", "10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode()));
}
// ========== Finding 4: Page defensive bounds ==========
@Test
void shouldRejectPageNoBelowMinimum() throws Exception {
setLoginUser(100L);
mockMvc.perform(get("/education/favorite/page")
.param("pageNo", "0")
.param("pageSize", "10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(BAD_REQUEST.getCode()))
.andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("页码最小为 1")));
}
@Test
void shouldRejectPageNoAboveMaximum() throws Exception {
setLoginUser(100L);
mockMvc.perform(get("/education/favorite/page")
.param("pageNo", "10001")
.param("pageSize", "10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(BAD_REQUEST.getCode()))
.andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("页码最大为 10000")));
}
@Test
void shouldRejectPageSizeAboveMaximum() throws Exception {
setLoginUser(100L);
mockMvc.perform(get("/education/favorite/page")
.param("pageNo", "1")
.param("pageSize", "101"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(BAD_REQUEST.getCode()))
.andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("每页条数最大为 100")));
}
// ========== Finding 5: Page invalid targetType ==========
@Test
void shouldReturnTargetTypeInvalidForPageWithUnsupportedTargetType() throws Exception {
setLoginUser(100L);
when(favoriteService.page(any(), eq(100L), eq(1L)))
.thenThrow(new ServiceException(FAVORITE_TARGET_TYPE_INVALID.getCode(), "不支持的收藏目标类型INVALID"));
mockMvc.perform(get("/education/favorite/page")
.param("pageNo", "1")
.param("pageSize", "10")
.param("targetType", "INVALID"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(FAVORITE_TARGET_TYPE_INVALID.getCode()));
}
// ========== POST /education/favorite/status ==========
@Test
void shouldReturnFavoriteStatus() throws Exception {
setLoginUser(100L);
FavoriteStatusRespVO status = FavoriteStatusRespVO.builder()
.favoritedIds(List.of("q-001", "q-003"))
.build();
when(favoriteService.status(anyList(), eq(100L), eq(1L))).thenReturn(status);
mockMvc.perform(post("/education/favorite/status")
.contentType("application/json")
.content("{\"questionIds\":[\"q-001\",\"q-002\",\"q-003\"]}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.favoritedIds[0]").value("q-001"))
.andExpect(jsonPath("$.data.favoritedIds[1]").value("q-003"));
}
@Test
void shouldReturnEmptyStatusForEmptyList() throws Exception {
setLoginUser(100L);
// @NotEmpty validation on questionIds will reject empty list at the controller level
// with BAD_REQUEST, since @Valid @RequestBody is enforced
mockMvc.perform(post("/education/favorite/status")
.contentType("application/json")
.content("{\"questionIds\":[]}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(BAD_REQUEST.getCode()));
}
@Test
void shouldReturnUnauthorizedCodeForFavoriteStatusWithoutAuth() throws Exception {
mockMvc.perform(post("/education/favorite/status")
.contentType("application/json")
.content("{\"questionIds\":[\"q-001\"]}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(UNAUTHORIZED.getCode()));
}
// ========== Finding 3: Batch status bounds ==========
@Test
void shouldRejectStatusWithMoreThan100Ids() throws Exception {
setLoginUser(100L);
// Build 101 IDs
StringBuilder ids = new StringBuilder("[");
for (int i = 0; i < 101; i++) {
if (i > 0) ids.append(",");
ids.append("\"q-").append(String.format("%03d", i)).append("\"");
}
ids.append("]");
mockMvc.perform(post("/education/favorite/status")
.contentType("application/json")
.content("{\"questionIds\":" + ids + "}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(BAD_REQUEST.getCode()))
.andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("最多查询 100 个题目")));
}
@Test
void shouldRejectStatusWithOversizedId() throws Exception {
setLoginUser(100L);
String longId = "q-" + "x".repeat(130);
mockMvc.perform(post("/education/favorite/status")
.contentType("application/json")
.content("{\"questionIds\":[\"" + longId + "\"]}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(BAD_REQUEST.getCode()))
.andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("长度不能超过 128")));
}
@Test
void shouldRejectStatusWithBlankId() throws Exception {
setLoginUser(100L);
mockMvc.perform(post("/education/favorite/status")
.contentType("application/json")
.content("{\"questionIds\":[\"q-001\",\" \",\"q-002\"]}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
// Blank IDs are filtered (trim → empty → filtered), and remaining valid IDs are processed
}
@Test
void shouldDeduplicateStatusIds() throws Exception {
setLoginUser(100L);
FavoriteStatusRespVO status = FavoriteStatusRespVO.builder()
.favoritedIds(List.of("q-001"))
.build();
when(favoriteService.status(anyList(), eq(100L), eq(1L))).thenReturn(status);
mockMvc.perform(post("/education/favorite/status")
.contentType("application/json")
.content("{\"questionIds\":[\"q-001\",\"q-001\",\"q-001\"]}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
// Verify service received deduplicated list (only one "q-001")
verify(favoriteService).status(argThat(list -> list.size() == 1 && list.contains("q-001")),
eq(100L), eq(1L));
}
// ========== Answer field negative assertions ==========
@Test
void shouldNeverLeakAnswerInAnyFavoriteEndpoint() throws Exception {
setLoginUser(100L);
FavoritePageItemRespVO item = FavoritePageItemRespVO.builder()
.id(1L).targetType("QUESTION").targetId("q-001")
.stem("Full question").type("choice").difficulty("easy")
.options(List.of(
FavoritePageItemRespVO.OptionVO.builder()
.label("A").content("Right").order(1.0).build(),
FavoritePageItemRespVO.OptionVO.builder()
.label("B").content("Wrong").order(2.0).build()
))
.contentVersion("v1").available(true)
.createTime(LocalDateTime.now())
.build();
when(favoriteService.create(any(), eq(100L), eq(1L))).thenReturn(item);
MvcResult result = mockMvc.perform(post("/education/favorite/create")
.contentType("application/json")
.content("{\"targetType\":\"QUESTION\",\"targetId\":\"q-001\"}"))
.andExpect(status().isOk())
.andReturn();
String json = result.getResponse().getContentAsString();
assertFalse(json.contains("correctAnswer"), "create: must not leak correctAnswer");
assertFalse(json.contains("\"answer\""), "create: must not leak answer");
assertFalse(json.contains("explanation"), "create: must not leak explanation");
assertFalse(json.contains("analysis"), "create: must not leak analysis");
assertFalse(json.contains("isCorrect"), "create: must not leak isCorrect");
assertTrue(json.contains("\"stem\":\"Full question\""), "create: should contain stem");
assertTrue(json.contains("\"label\":\"A\""), "create: should contain option label");
}
// ========== helpers ==========
private void setLoginUser(Long userId) {
LoginUser loginUser = new LoginUser();
loginUser.setId(userId);
loginUser.setTenantId(1L);
SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest());
}
}

View File

@@ -0,0 +1,739 @@
package cn.iocoder.yudao.module.education.service.favorite;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
import cn.iocoder.yudao.module.education.controller.app.favorite.vo.*;
import cn.iocoder.yudao.module.education.controller.app.question.vo.SafeQuestionRespVO;
import cn.iocoder.yudao.module.education.dal.dataobject.EducationFavoriteDO;
import cn.iocoder.yudao.module.education.dal.mysql.EducationFavoriteMapper;
import cn.iocoder.yudao.module.education.enums.FavoriteTargetType;
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogService;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.context.annotation.Import;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicReference;
import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* FavoriteService test — real DB (H2) backing all favorite assertions.
*
* @author 恭学教育
*/
@Import(FavoriteServiceImpl.class)
public class FavoriteServiceImplTest extends BaseDbUnitTest {
@Resource
private FavoriteService favoriteService;
@Resource
private EducationFavoriteMapper favoriteMapper;
@MockitoBean
private QuestionCatalogService questionCatalogService;
private final Long userId = 100L;
private final Long tenantId = 1L;
private final Long otherUserId = 999L;
private final Long otherTenantId = 999L;
@BeforeEach
void setUp() {
// Default mock: question exists and is visible
SafeQuestionRespVO defaultQuestion = SafeQuestionRespVO.builder()
.id("q-001")
.contentVersion("v1")
.stem("Test question?")
.type("choice")
.difficulty("easy")
.options(List.of(
SafeQuestionRespVO.SafeOptionVO.builder()
.label("A").content("Option A").order(1.0).build(),
SafeQuestionRespVO.SafeOptionVO.builder()
.label("B").content("Option B").order(2.0).build()
))
.build();
lenient().when(questionCatalogService.getQuestion("q-001")).thenReturn(defaultQuestion);
lenient().when(questionCatalogService.getQuestion("q-002")).thenReturn(
SafeQuestionRespVO.builder()
.id("q-002")
.contentVersion("v2")
.stem("Another question?")
.type("fill")
.difficulty("hard")
.build()
);
lenient().when(questionCatalogService.getQuestion("q-hidden"))
.thenThrow(new ServiceException(QUESTION_NOT_FOUND.getCode(), QUESTION_NOT_FOUND.getMsg()));
}
// ========== helpers ==========
private void assertFavoriteActive(Long tenantId, Long userId, String targetId) {
EducationFavoriteDO active = favoriteMapper.selectActiveByTenantUserTarget(
tenantId, userId, FavoriteTargetType.QUESTION.getCode(), targetId);
assertNotNull(active, "favorite should be active");
assertFalse(active.getDeleted());
}
private void assertFavoriteNotActive(Long tenantId, Long userId, String targetId) {
EducationFavoriteDO active = favoriteMapper.selectActiveByTenantUserTarget(
tenantId, userId, FavoriteTargetType.QUESTION.getCode(), targetId);
assertNull(active, "favorite should not be active");
}
// ========== Create: basic ==========
@Test
void shouldCreateFavorite() {
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
req.setTargetType("QUESTION");
req.setTargetId("q-001");
FavoritePageItemRespVO resp = favoriteService.create(req, userId, tenantId);
assertNotNull(resp.getId());
assertEquals("QUESTION", resp.getTargetType());
assertEquals("q-001", resp.getTargetId());
assertEquals("Test question?", resp.getStem());
assertEquals("choice", resp.getType());
assertEquals("easy", resp.getDifficulty());
assertEquals(Boolean.TRUE, resp.getAvailable());
assertNotNull(resp.getCreateTime());
assertFalse(resp.getOptions().isEmpty());
assertEquals("A", resp.getOptions().get(0).getLabel());
// DB verification
assertFavoriteActive(tenantId, userId, "q-001");
}
// ========== Create: duplicate idempotent ==========
@Test
void shouldReturnExistingOnDuplicateAdd() {
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
req.setTargetType("QUESTION");
req.setTargetId("q-001");
FavoritePageItemRespVO first = favoriteService.create(req, userId, tenantId);
FavoritePageItemRespVO second = favoriteService.create(req, userId, tenantId);
assertEquals(first.getId(), second.getId(), "duplicate create must return same record");
assertEquals(first.getStem(), second.getStem());
// Only one active row
assertFavoriteActive(tenantId, userId, "q-001");
}
// ========== Create: concurrent duplicate ==========
@Test
void shouldConvergeOnConcurrentDuplicateCreate() throws Exception {
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
req.setTargetType("QUESTION");
req.setTargetId("q-001");
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch go = new CountDownLatch(1);
AtomicReference<FavoritePageItemRespVO> r1 = new AtomicReference<>();
AtomicReference<FavoritePageItemRespVO> r2 = new AtomicReference<>();
Thread t1 = new Thread(() -> {
try { ready.countDown(); go.await();
r1.set(favoriteService.create(req, userId, tenantId)); }
catch (Exception ignored) {}
});
Thread t2 = new Thread(() -> {
try { ready.countDown(); go.await();
r2.set(favoriteService.create(req, userId, tenantId)); }
catch (Exception ignored) {}
});
t1.start(); t2.start();
ready.await(); go.countDown();
t1.join(10000); t2.join(10000);
assertNotNull(r1.get(), "thread 1 must get a response");
assertNotNull(r2.get(), "thread 2 must get a response");
assertEquals(r1.get().getId(), r2.get().getId(), "concurrent creates must converge");
// Only one active row
assertFavoriteActive(tenantId, userId, "q-001");
}
// ========== Create: invisible target rejected ==========
@Test
void shouldRejectInvisibleTarget() {
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
req.setTargetType("QUESTION");
req.setTargetId("q-hidden");
assertServiceException(
() -> favoriteService.create(req, userId, tenantId),
FAVORITE_TARGET_NOT_FOUND);
// No record created
assertFavoriteNotActive(tenantId, userId, "q-hidden");
}
// ========== Create: invalid target type ==========
@Test
void shouldRejectInvalidTargetType() {
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
req.setTargetType("INVALID");
req.setTargetId("q-001");
assertServiceException(
() -> favoriteService.create(req, userId, tenantId),
FAVORITE_TARGET_TYPE_INVALID, "INVALID");
}
// ========== Delete: basic ==========
@Test
void shouldDeleteFavorite() {
FavoriteCreateReqVO createReq = new FavoriteCreateReqVO();
createReq.setTargetType("QUESTION");
createReq.setTargetId("q-001");
FavoritePageItemRespVO created = favoriteService.create(createReq, userId, tenantId);
// Delete by id
FavoriteDeleteReqVO deleteReq = new FavoriteDeleteReqVO();
deleteReq.setId(created.getId());
favoriteService.delete(deleteReq, userId, tenantId);
// Verify not active
assertFavoriteNotActive(tenantId, userId, "q-001");
}
// ========== Delete: by targetType+targetId ==========
@Test
void shouldDeleteByTargetTypeAndTargetId() {
FavoriteCreateReqVO createReq = new FavoriteCreateReqVO();
createReq.setTargetType("QUESTION");
createReq.setTargetId("q-001");
favoriteService.create(createReq, userId, tenantId);
FavoriteDeleteReqVO deleteReq = new FavoriteDeleteReqVO();
deleteReq.setTargetType("QUESTION");
deleteReq.setTargetId("q-001");
favoriteService.delete(deleteReq, userId, tenantId);
assertFavoriteNotActive(tenantId, userId, "q-001");
}
// ========== Delete: repeat delete idempotent ==========
@Test
void shouldRepeatDeleteIdempotently() {
FavoriteCreateReqVO createReq = new FavoriteCreateReqVO();
createReq.setTargetType("QUESTION");
createReq.setTargetId("q-001");
FavoritePageItemRespVO created = favoriteService.create(createReq, userId, tenantId);
FavoriteDeleteReqVO deleteReq = new FavoriteDeleteReqVO();
deleteReq.setId(created.getId());
// First delete
favoriteService.delete(deleteReq, userId, tenantId);
assertFavoriteNotActive(tenantId, userId, "q-001");
// Second delete — should not throw
favoriteService.delete(deleteReq, userId, tenantId);
assertFavoriteNotActive(tenantId, userId, "q-001");
}
// ========== Delete: repeat delete by targetType+targetId ==========
@Test
void shouldRepeatDeleteByTargetTypeAndTargetIdIdempotently() {
FavoriteCreateReqVO createReq = new FavoriteCreateReqVO();
createReq.setTargetType("QUESTION");
createReq.setTargetId("q-001");
favoriteService.create(createReq, userId, tenantId);
FavoriteDeleteReqVO deleteReq = new FavoriteDeleteReqVO();
deleteReq.setTargetType("QUESTION");
deleteReq.setTargetId("q-001");
favoriteService.delete(deleteReq, userId, tenantId);
favoriteService.delete(deleteReq, userId, tenantId); // no error
assertFavoriteNotActive(tenantId, userId, "q-001");
}
// ========== Delete: cross-user isolation ==========
@Test
void shouldNotDeleteOtherUserFavorite() {
FavoriteCreateReqVO createReq = new FavoriteCreateReqVO();
createReq.setTargetType("QUESTION");
createReq.setTargetId("q-001");
FavoritePageItemRespVO created = favoriteService.create(createReq, userId, tenantId);
// Other user tries to delete
FavoriteDeleteReqVO deleteReq = new FavoriteDeleteReqVO();
deleteReq.setId(created.getId());
favoriteService.delete(deleteReq, otherUserId, tenantId); // no error, no effect
// Original user's favorite still active
assertFavoriteActive(tenantId, userId, "q-001");
}
// ========== Delete: cross-tenant isolation ==========
@Test
void shouldNotDeleteOtherTenantFavorite() {
FavoriteCreateReqVO createReq = new FavoriteCreateReqVO();
createReq.setTargetType("QUESTION");
createReq.setTargetId("q-001");
FavoritePageItemRespVO created = favoriteService.create(createReq, userId, tenantId);
FavoriteDeleteReqVO deleteReq = new FavoriteDeleteReqVO();
deleteReq.setId(created.getId());
favoriteService.delete(deleteReq, userId, otherTenantId); // no error, no effect
assertFavoriteActive(tenantId, userId, "q-001");
}
// ========== Re-add after delete ==========
@Test
void shouldReAddAfterDelete() {
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
req.setTargetType("QUESTION");
req.setTargetId("q-001");
// Create
FavoritePageItemRespVO first = favoriteService.create(req, userId, tenantId);
// Delete
FavoriteDeleteReqVO deleteReq = new FavoriteDeleteReqVO();
deleteReq.setId(first.getId());
favoriteService.delete(deleteReq, userId, tenantId);
assertFavoriteNotActive(tenantId, userId, "q-001");
// Re-add
FavoritePageItemRespVO reAdded = favoriteService.create(req, userId, tenantId);
assertNotNull(reAdded);
assertEquals(first.getId(), reAdded.getId(), "re-add must reactivate same row");
assertEquals(Boolean.TRUE, reAdded.getAvailable());
// Verify active in DB
assertFavoriteActive(tenantId, userId, "q-001");
}
// ========== Page: bounds ==========
@Test
void shouldPaginateFavoritesByTenantAndUser() {
// Insert 5 favorites
for (int i = 1; i <= 5; i++) {
String qid = "q-" + String.format("%03d", i);
when(questionCatalogService.getQuestion(qid)).thenReturn(
SafeQuestionRespVO.builder()
.id(qid).contentVersion("v1").stem("Q " + i)
.type("choice").difficulty("easy").build()
);
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
req.setTargetType("QUESTION");
req.setTargetId(qid);
favoriteService.create(req, userId, tenantId);
}
// Page 1, size 3
FavoritePageReqVO pageReq1 = new FavoritePageReqVO();
pageReq1.setPageNo(1); pageReq1.setPageSize(3);
PageResult<FavoritePageItemRespVO> page1 = favoriteService.page(pageReq1, userId, tenantId);
assertEquals(5, page1.getTotal());
assertEquals(3, page1.getList().size());
// Page 2, size 3
FavoritePageReqVO pageReq2 = new FavoritePageReqVO();
pageReq2.setPageNo(2); pageReq2.setPageSize(3);
PageResult<FavoritePageItemRespVO> page2 = favoriteService.page(pageReq2, userId, tenantId);
assertEquals(2, page2.getList().size());
// Filter by QUESTION type
FavoritePageReqVO filtered = new FavoritePageReqVO();
filtered.setPageNo(1); filtered.setPageSize(10); filtered.setTargetType("QUESTION");
PageResult<FavoritePageItemRespVO> filteredPage = favoriteService.page(filtered, userId, tenantId);
assertEquals(5, filteredPage.getTotal());
}
// ========== Page: cross-user isolation ==========
@Test
void shouldReturnEmptyPageForOtherUser() {
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
req.setTargetType("QUESTION");
req.setTargetId("q-001");
favoriteService.create(req, userId, tenantId);
FavoritePageReqVO pageReq = new FavoritePageReqVO();
pageReq.setPageNo(1); pageReq.setPageSize(10);
PageResult<FavoritePageItemRespVO> otherPage = favoriteService.page(pageReq, otherUserId, tenantId);
assertEquals(0, otherPage.getTotal());
}
// ========== Page: cross-tenant isolation ==========
@Test
void shouldReturnEmptyPageForOtherTenant() {
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
req.setTargetType("QUESTION");
req.setTargetId("q-001");
favoriteService.create(req, userId, tenantId);
FavoritePageReqVO pageReq = new FavoritePageReqVO();
pageReq.setPageNo(1); pageReq.setPageSize(10);
PageResult<FavoritePageItemRespVO> otherPage = favoriteService.page(pageReq, userId, otherTenantId);
assertEquals(0, otherPage.getTotal());
}
// ========== Page: deleted excluded ==========
@Test
void shouldExcludeDeletedFromPage() {
FavoriteCreateReqVO req1 = new FavoriteCreateReqVO();
req1.setTargetType("QUESTION");
req1.setTargetId("q-001");
favoriteService.create(req1, userId, tenantId);
when(questionCatalogService.getQuestion("q-002")).thenReturn(
SafeQuestionRespVO.builder()
.id("q-002").contentVersion("v1").stem("Q2")
.type("choice").difficulty("easy").build()
);
FavoriteCreateReqVO req2 = new FavoriteCreateReqVO();
req2.setTargetType("QUESTION");
req2.setTargetId("q-002");
FavoritePageItemRespVO created2 = favoriteService.create(req2, userId, tenantId);
// Delete first
FavoriteDeleteReqVO deleteReq = new FavoriteDeleteReqVO();
deleteReq.setTargetType("QUESTION");
deleteReq.setTargetId("q-001");
favoriteService.delete(deleteReq, userId, tenantId);
// Page should only show q-002
FavoritePageReqVO pageReq = new FavoritePageReqVO();
pageReq.setPageNo(1); pageReq.setPageSize(10);
PageResult<FavoritePageItemRespVO> result = favoriteService.page(pageReq, userId, tenantId);
assertEquals(1, result.getTotal());
assertEquals("q-002", result.getList().get(0).getTargetId());
}
// ========== Status: batch check ==========
@Test
void shouldReturnFavoritedIds() {
// Favorite q-001 and q-003, not q-002
FavoriteCreateReqVO req1 = new FavoriteCreateReqVO();
req1.setTargetType("QUESTION");
req1.setTargetId("q-001");
favoriteService.create(req1, userId, tenantId);
when(questionCatalogService.getQuestion("q-003")).thenReturn(
SafeQuestionRespVO.builder()
.id("q-003").contentVersion("v1").stem("Q3")
.type("choice").difficulty("easy").build()
);
FavoriteCreateReqVO req3 = new FavoriteCreateReqVO();
req3.setTargetType("QUESTION");
req3.setTargetId("q-003");
favoriteService.create(req3, userId, tenantId);
FavoriteStatusRespVO status = favoriteService.status(
List.of("q-001", "q-002", "q-003", "q-004"), userId, tenantId);
assertEquals(2, status.getFavoritedIds().size());
assertTrue(status.getFavoritedIds().contains("q-001"));
assertTrue(status.getFavoritedIds().contains("q-003"));
assertFalse(status.getFavoritedIds().contains("q-002"));
}
// ========== Status: cross-user isolation ==========
@Test
void shouldReturnEmptyStatusForOtherUser() {
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
req.setTargetType("QUESTION");
req.setTargetId("q-001");
favoriteService.create(req, userId, tenantId);
FavoriteStatusRespVO status = favoriteService.status(
List.of("q-001"), otherUserId, tenantId);
assertTrue(status.getFavoritedIds().isEmpty());
}
// ========== Status: deleted excluded ==========
@Test
void shouldExcludeDeletedFromStatus() {
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
req.setTargetType("QUESTION");
req.setTargetId("q-001");
favoriteService.create(req, userId, tenantId);
// Delete
FavoriteDeleteReqVO deleteReq = new FavoriteDeleteReqVO();
deleteReq.setTargetType("QUESTION");
deleteReq.setTargetId("q-001");
favoriteService.delete(deleteReq, userId, tenantId);
FavoriteStatusRespVO status = favoriteService.status(
List.of("q-001"), userId, tenantId);
assertTrue(status.getFavoritedIds().isEmpty(),
"deleted favorites should not appear in status");
}
// ========== Status: empty list ==========
@Test
void shouldHandleEmptyStatusQuery() {
FavoriteStatusRespVO status = favoriteService.status(List.of(), userId, tenantId);
assertNotNull(status.getFavoritedIds());
assertTrue(status.getFavoritedIds().isEmpty());
FavoriteStatusRespVO statusNull = favoriteService.status(null, userId, tenantId);
assertNotNull(statusNull.getFavoritedIds());
assertTrue(statusNull.getFavoritedIds().isEmpty());
}
// ========== No answer leak: response JSON ==========
@Test
void shouldNotContainAnswerFieldsInResponse() {
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
req.setTargetType("QUESTION");
req.setTargetId("q-001");
FavoritePageItemRespVO resp = favoriteService.create(req, userId, tenantId);
// Structural guarantee: FavoritePageItemRespVO has no correctAnswer/explanation/isCorrect fields
// This is enforced at the type level — the class doesn't have these fields.
// Verify options don't contain isCorrect
for (FavoritePageItemRespVO.OptionVO opt : resp.getOptions()) {
// OptionVO only has label, content, order — no isCorrect field
assertNotNull(opt.getLabel());
}
}
// ========== Finding 1: Availability refresh in page ==========
@Test
void shouldShowAvailableFalseWhenSourceBecomesUnavailable() {
// Favorite q-001 successfully
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
req.setTargetType("QUESTION");
req.setTargetId("q-001");
FavoritePageItemRespVO created = favoriteService.create(req, userId, tenantId);
assertTrue(created.getAvailable());
// Now make q-001 unavailable
when(questionCatalogService.getQuestion("q-001"))
.thenThrow(new ServiceException(QUESTION_NOT_FOUND.getCode(), QUESTION_NOT_FOUND.getMsg()));
// Page should still list the favorite but with available=false
FavoritePageReqVO pageReq = new FavoritePageReqVO();
pageReq.setPageNo(1); pageReq.setPageSize(10);
PageResult<FavoritePageItemRespVO> page = favoriteService.page(pageReq, userId, tenantId);
assertEquals(1, page.getTotal());
FavoritePageItemRespVO item = page.getList().get(0);
assertEquals("q-001", item.getTargetId());
assertFalse(item.getAvailable(), "available should be false when source is gone");
// Snapshot must be preserved
assertEquals("Test question?", item.getStem());
assertEquals("choice", item.getType());
assertEquals("easy", item.getDifficulty());
}
@Test
void shouldShowAvailableTrueWhenSourceReturns() {
// Create favorite, then make source unavailable, then restore
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
req.setTargetType("QUESTION");
req.setTargetId("q-001");
FavoritePageItemRespVO created = favoriteService.create(req, userId, tenantId);
assertTrue(created.getAvailable());
// Make unavailable — use doThrow to avoid triggering previous stubs
doThrow(new ServiceException(QUESTION_NOT_FOUND.getCode(), QUESTION_NOT_FOUND.getMsg()))
.when(questionCatalogService).getQuestion("q-001");
FavoritePageReqVO pageReq = new FavoritePageReqVO();
pageReq.setPageNo(1); pageReq.setPageSize(10);
PageResult<FavoritePageItemRespVO> page1 = favoriteService.page(pageReq, userId, tenantId);
assertFalse(page1.getList().get(0).getAvailable());
// Restore availability — use doReturn to avoid triggering previous stubs
doReturn(SafeQuestionRespVO.builder()
.id("q-001").contentVersion("v1").stem("Test question?")
.type("choice").difficulty("easy")
.options(List.of(
SafeQuestionRespVO.SafeOptionVO.builder()
.label("A").content("Option A").order(1.0).build()
))
.build())
.when(questionCatalogService).getQuestion("q-001");
PageResult<FavoritePageItemRespVO> page2 = favoriteService.page(pageReq, userId, tenantId);
assertTrue(page2.getList().get(0).getAvailable(), "available should be true when source returns");
}
@Test
void shouldNotMarkUnavailableOnUpstreamError() {
// Create favorite
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
req.setTargetType("QUESTION");
req.setTargetId("q-001");
favoriteService.create(req, userId, tenantId);
// Make getQuestion throw a non-gone error (e.g., upstream timeout)
when(questionCatalogService.getQuestion("q-001"))
.thenThrow(new ServiceException(CATALOG_UPSTREAM_TIMEOUT.getCode(), CATALOG_UPSTREAM_TIMEOUT.getMsg()));
// Page should propagate the error, not silently mark unavailable
FavoritePageReqVO pageReq = new FavoritePageReqVO();
pageReq.setPageNo(1); pageReq.setPageSize(10);
assertThrows(ServiceException.class, () ->
favoriteService.page(pageReq, userId, tenantId));
}
@Test
void shouldDeduplicateGetQuestionCallsInRefresh() {
// Create two favorites for the same question ID (only one active row, but simulate)
// Actually, unique constraint prevents duplicates. Let's create one favorite and
// verify the refresh doesn't double-call for the same targetId.
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
req.setTargetType("QUESTION");
req.setTargetId("q-001");
favoriteService.create(req, userId, tenantId);
// Clear invocation counts to verify refresh behavior
clearInvocations(questionCatalogService);
when(questionCatalogService.getQuestion("q-001")).thenReturn(
SafeQuestionRespVO.builder()
.id("q-001").contentVersion("v1").stem("Test question?")
.type("choice").difficulty("easy").build()
);
FavoritePageReqVO pageReq = new FavoritePageReqVO();
pageReq.setPageNo(1); pageReq.setPageSize(10);
favoriteService.page(pageReq, userId, tenantId);
// getQuestion should only be called once for q-001 (deduplication)
verify(questionCatalogService, times(1)).getQuestion("q-001");
}
// ========== Finding 2: Duplicate create with source validation ==========
@Test
void shouldRejectDuplicateCreateWhenSourceBecameUnavailable() {
// Create favorite
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
req.setTargetType("QUESTION");
req.setTargetId("q-001");
FavoritePageItemRespVO created = favoriteService.create(req, userId, tenantId);
assertTrue(created.getAvailable());
// Make source unavailable
when(questionCatalogService.getQuestion("q-001"))
.thenThrow(new ServiceException(QUESTION_NOT_FOUND.getCode(), QUESTION_NOT_FOUND.getMsg()));
// Duplicate create should now reject
FavoriteCreateReqVO dupReq = new FavoriteCreateReqVO();
dupReq.setTargetType("QUESTION");
dupReq.setTargetId("q-001");
assertServiceException(
() -> favoriteService.create(dupReq, userId, tenantId),
FAVORITE_TARGET_NOT_FOUND);
// Verify the existing record still exists but has available=false via page()
FavoritePageReqVO pageReq = new FavoritePageReqVO();
pageReq.setPageNo(1); pageReq.setPageSize(10);
PageResult<FavoritePageItemRespVO> page = favoriteService.page(pageReq, userId, tenantId);
assertEquals(1, page.getTotal(), "existing favorite should still be listable");
FavoritePageItemRespVO item = page.getList().get(0);
assertEquals("q-001", item.getTargetId());
assertFalse(item.getAvailable(), "available should be false after source became unavailable");
// Snapshot preserved
assertEquals("Test question?", item.getStem());
}
@Test
void shouldAllowCreateWhenSourceReturnsAfterBeingUnavailable() {
// Create favorite
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
req.setTargetType("QUESTION");
req.setTargetId("q-001");
favoriteService.create(req, userId, tenantId);
// Delete it
FavoriteDeleteReqVO deleteReq = new FavoriteDeleteReqVO();
deleteReq.setTargetType("QUESTION");
deleteReq.setTargetId("q-001");
favoriteService.delete(deleteReq, userId, tenantId);
// Make source unavailable
doThrow(new ServiceException(QUESTION_NOT_FOUND.getCode(), QUESTION_NOT_FOUND.getMsg()))
.when(questionCatalogService).getQuestion("q-001");
// Try to re-create — should fail
assertServiceException(
() -> favoriteService.create(req, userId, tenantId),
FAVORITE_TARGET_NOT_FOUND);
// Restore source
doReturn(SafeQuestionRespVO.builder()
.id("q-001").contentVersion("v2").stem("Updated question?")
.type("choice").difficulty("easy").build())
.when(questionCatalogService).getQuestion("q-001");
// Re-create should succeed with available=true
FavoritePageItemRespVO reAdded = favoriteService.create(req, userId, tenantId);
assertTrue(reAdded.getAvailable(), "re-added favorite should have available=true");
assertEquals("Updated question?", reAdded.getStem());
}
@Test
void shouldReAddAfterDeleteIncludingAvailabilityCheck() {
// Standard re-add case: create, delete, re-add
FavoriteCreateReqVO req = new FavoriteCreateReqVO();
req.setTargetType("QUESTION");
req.setTargetId("q-001");
FavoritePageItemRespVO first = favoriteService.create(req, userId, tenantId);
FavoriteDeleteReqVO deleteReq = new FavoriteDeleteReqVO();
deleteReq.setId(first.getId());
favoriteService.delete(deleteReq, userId, tenantId);
assertFavoriteNotActive(tenantId, userId, "q-001");
// Re-add — source is still available
FavoritePageItemRespVO reAdded = favoriteService.create(req, userId, tenantId);
assertNotNull(reAdded);
assertEquals(first.getId(), reAdded.getId(), "re-add must reactivate same row");
assertTrue(reAdded.getAvailable());
assertFavoriteActive(tenantId, userId, "q-001");
}
}

View File

@@ -1,3 +1,4 @@
DELETE FROM education_favorite;
DELETE FROM education_wrong_question_idempotency;
DELETE FROM education_wrong_question;

View File

@@ -200,3 +200,29 @@ CREATE TABLE IF NOT EXISTS "education_wrong_question_idempotency" (
);
CREATE INDEX IF NOT EXISTS "idx_wq_idem_report" ON "education_wrong_question_idempotency" ("report_id");
-- Ticket #10: favorites
CREATE TABLE IF NOT EXISTS "education_favorite" (
"id" BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY,
"tenant_id" BIGINT NOT NULL,
"user_id" BIGINT NOT NULL,
"target_type" VARCHAR(32) NOT NULL,
"target_id" VARCHAR(64) NOT NULL,
"stem" CLOB DEFAULT NULL,
"type" VARCHAR(32) DEFAULT NULL,
"difficulty" VARCHAR(32) DEFAULT NULL,
"options" CLOB DEFAULT NULL,
"content_version" VARCHAR(64) NOT NULL DEFAULT '',
"available" BIT NOT NULL DEFAULT TRUE,
"creator" VARCHAR(64) DEFAULT '',
"create_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" VARCHAR(64) DEFAULT '',
"update_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted" BIT NOT NULL DEFAULT FALSE,
PRIMARY KEY ("id"),
CONSTRAINT "uk_tenant_user_target" UNIQUE ("tenant_id", "user_id", "target_type", "target_id")
);
CREATE INDEX IF NOT EXISTS "idx_fav_tenant_user" ON "education_favorite" ("tenant_id", "user_id");
CREATE INDEX IF NOT EXISTS "idx_fav_tenant_user_target_type" ON "education_favorite" ("tenant_id", "user_id", "target_type");