diff --git a/lombok.config b/lombok.config new file mode 100644 index 0000000..062c1ea --- /dev/null +++ b/lombok.config @@ -0,0 +1,2 @@ +# 让 Lombok 生成的方法带 @Generated 注解,JaCoCo 自动排除生成代码,覆盖率只统计手写逻辑 +lombok.addLombokGeneratedAnnotation = true diff --git a/src/test/java/com/guo/learningprogresstracker/ApplicationMainTest.java b/src/test/java/com/guo/learningprogresstracker/ApplicationMainTest.java new file mode 100644 index 0000000..4507d0f --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/ApplicationMainTest.java @@ -0,0 +1,25 @@ +package com.guo.learningprogresstracker; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +/** + * 应用启动类 main 方法覆盖测试: + * 以非 Web 模式 + H2 内存库拉起 Spring 容器,正常返回即覆盖 main 方法体两行。 + */ +class ApplicationMainTest { + + @Test + void main_startsApplicationContextInNonWebMode() { + assertDoesNotThrow(() -> LearningProgressTrackerApplication.main(new String[]{ + "--spring.main.web-application-type=none", + "--spring.datasource.driver-class-name=org.h2.Driver", + "--spring.datasource.url=jdbc:h2:mem:lpt_main_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;NON_KEYWORDS=USER", + "--spring.datasource.username=sa", + "--spring.datasource.password=", + "--spring.flyway.enabled=false", + "--mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.nologging.NoLoggingImpl" + })); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/common/GlobalExceptionHandlerTest.java b/src/test/java/com/guo/learningprogresstracker/common/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..08f6714 --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/common/GlobalExceptionHandlerTest.java @@ -0,0 +1,95 @@ +package com.guo.learningprogresstracker.common; + +import cn.dev33.satoken.exception.NotLoginException; +import com.guo.learningprogresstracker.entity.CommonResult; +import com.guo.learningprogresstracker.exception.AppException; +import com.guo.learningprogresstracker.exception.ErrorParameterException; +import com.guo.learningprogresstracker.exception.NotFindEntitiesException; +import com.guo.learningprogresstracker.exception.OperationFailedException; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * GlobalExceptionHandler 单元测试:直接实例化并调用各 handler 方法, + * 断言 CommonResult 的 code / message 语义。 + */ +class GlobalExceptionHandlerTest { + + private final GlobalExceptionHandler handler = new GlobalExceptionHandler(); + + @Test + void errorParameterException_returnsBadRequestWithMessage() { + CommonResult result = handler.errorParameterException(new ErrorParameterException("参数有误")); + + assertEquals(HttpStatus.BAD_REQUEST.value(), result.getCode()); + assertEquals("参数有误", result.getMessage()); + assertNull(result.getData()); + } + + @Test + void notFindEntitiesException_returnsBadRequestWithMessage() { + CommonResult result = handler.notFindEntitiesException(new NotFindEntitiesException("数据不存在")); + + assertEquals(HttpStatus.BAD_REQUEST.value(), result.getCode()); + assertEquals("数据不存在", result.getMessage()); + } + + @Test + void operationFailedException_returnsBadRequestWithMessage() { + CommonResult result = handler.operationFailedException(new OperationFailedException("操作失败")); + + assertEquals(HttpStatus.BAD_REQUEST.value(), result.getCode()); + assertEquals("操作失败", result.getMessage()); + } + + @Test + void appException_returnsServerErrorWithMessage() { + CommonResult result = handler.AppException(new AppException("业务异常")); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.value(), result.getCode()); + assertEquals("业务异常", result.getMessage()); + } + + @Test + void methodArgumentNotValidException_returnsFirstFieldErrorMessage() { + MethodArgumentNotValidException ex = + new MethodArgumentNotValidException(null, new BeanPropertyBindingResult(new Object(), "o")); + ex.getBindingResult().addError(new FieldError("o", "f", "字段校验失败")); + + CommonResult result = handler.MyMethodArgumentNotValidException(ex); + + assertEquals(HttpStatus.BAD_REQUEST.value(), result.getCode()); + assertEquals("字段校验失败", result.getMessage()); + } + + @Test + void handleNotLogin_setsHttp401AndReturnsUnauthorizedBody() { + NotLoginException ex = new NotLoginException( + NotLoginException.NOT_TOKEN_MESSAGE, "login", NotLoginException.NOT_TOKEN); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + CommonResult result = handler.handleNotLogin(ex, request, response); + + assertEquals(HttpStatus.UNAUTHORIZED.value(), response.getStatus()); + assertEquals(HttpStatus.UNAUTHORIZED.value(), result.getCode()); + assertEquals("未登录,请重新登录", result.getMessage()); + assertNull(result.getData()); + } + + @Test + void fallbackException_returnsGenericBadRequest() { + CommonResult result = handler.Exception(new RuntimeException("意外错误")); + + assertEquals(HttpStatus.BAD_REQUEST.value(), result.getCode()); + assertEquals("操作没有成功,请稍后再试", result.getMessage()); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/config/MybatisPlusConfigTest.java b/src/test/java/com/guo/learningprogresstracker/config/MybatisPlusConfigTest.java new file mode 100644 index 0000000..b8b9cd5 --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/config/MybatisPlusConfigTest.java @@ -0,0 +1,106 @@ +package com.guo.learningprogresstracker.config; + +import cn.dev33.satoken.SaManager; +import cn.dev33.satoken.context.SaTokenContext; +import cn.dev33.satoken.context.SaTokenContextForThreadLocal; +import cn.dev33.satoken.context.SaTokenContextForThreadLocalStorage; +import cn.dev33.satoken.exception.NotLoginException; +import cn.dev33.satoken.servlet.model.SaRequestForServlet; +import cn.dev33.satoken.servlet.model.SaResponseForServlet; +import cn.dev33.satoken.servlet.model.SaStorageForServlet; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; +import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler; +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.mock.web.MockHttpServletResponse; + +import java.lang.reflect.Field; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * MybatisPlusConfig 单元测试:通过拦截器链反射取出 TenantLineHandler, + * 校验忽略表清单、租户列名,以及无登录态时 getTenantId 抛 NotLoginException。 + */ +class MybatisPlusConfigTest { + + private SaTokenContext previousContext; + + @BeforeEach + void setUpSaTokenThreadLocalContext() { + previousContext = SaManager.getSaTokenContext(); + SaManager.setSaTokenContext(new SaTokenContextForThreadLocal()); + MockHttpServletRequest request = new MockHttpServletRequest(); + SaTokenContextForThreadLocalStorage.setBox( + new SaRequestForServlet(request), + new SaResponseForServlet(new MockHttpServletResponse()), + new SaStorageForServlet(request)); + } + + @AfterEach + void restoreSaTokenContext() { + SaTokenContextForThreadLocalStorage.clearBox(); + SaManager.setSaTokenContext(previousContext); + } + + @Test + void mybatisPlusInterceptor_registersPaginationAndTenantInterceptors() throws Exception { + MybatisPlusInterceptor interceptor = new MybatisPlusConfig().mybatisPlusInterceptor(); + + List innerInterceptors = interceptor.getInterceptors(); + assertEquals(2, innerInterceptors.size()); + assertInstanceOf(PaginationInnerInterceptor.class, innerInterceptors.get(0)); + TenantLineInnerInterceptor tenantInterceptor = + assertInstanceOf(TenantLineInnerInterceptor.class, innerInterceptors.get(1)); + + TenantLineHandler handler = tenantLineHandler(tenantInterceptor); + assertEquals("created_by", handler.getTenantIdColumn()); + } + + @Test + void ignoreTable_excludesSystemTablesWithoutCreatedBy() throws Exception { + TenantLineHandler handler = tenantLineHandler(tenantInterceptorOf(new MybatisPlusConfig())); + + assertTrue(handler.ignoreTable("user")); + assertTrue(handler.ignoreTable("flyway_schema_history")); + assertTrue(handler.ignoreTable("databasechangelog")); + assertTrue(handler.ignoreTable("databasechangeloglock")); + } + + @Test + void ignoreTable_businessTablesAreFiltered() throws Exception { + TenantLineHandler handler = tenantLineHandler(tenantInterceptorOf(new MybatisPlusConfig())); + + assertFalse(handler.ignoreTable("task")); + assertFalse(handler.ignoreTable("study_sessions")); + assertFalse(handler.ignoreTable("TASK")); + } + + @Test + void getTenantId_withoutLoginState_throwsNotLoginException() throws Exception { + TenantLineHandler handler = tenantLineHandler(tenantInterceptorOf(new MybatisPlusConfig())); + + assertThrows(NotLoginException.class, handler::getTenantId); + } + + private TenantLineInnerInterceptor tenantInterceptorOf(MybatisPlusConfig config) { + return (TenantLineInnerInterceptor) config.mybatisPlusInterceptor().getInterceptors().get(1); + } + + /** TenantLineInnerInterceptor 未暴露 handler 访问器,通过反射读取私有字段。 */ + private TenantLineHandler tenantLineHandler(TenantLineInnerInterceptor interceptor) throws Exception { + Field field = TenantLineInnerInterceptor.class.getDeclaredField("tenantLineHandler"); + field.setAccessible(true); + return (TenantLineHandler) field.get(interceptor); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/config/WebMvcConfigTest.java b/src/test/java/com/guo/learningprogresstracker/config/WebMvcConfigTest.java new file mode 100644 index 0000000..ba19c54 --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/config/WebMvcConfigTest.java @@ -0,0 +1,173 @@ +package com.guo.learningprogresstracker.config; + +import cn.dev33.satoken.context.SaTokenContext; +import cn.dev33.satoken.exception.NotLoginException; +import cn.dev33.satoken.interceptor.SaInterceptor; +import cn.dev33.satoken.spring.SaTokenContextForSpringInJakartaServlet; +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.mock.web.MockHttpServletResponse; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.handler.MappedInterceptor; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * WebMvcConfig 单元测试: + * - addCorsMappings 用真实 CorsRegistry 验证三个配置分支的注册结果; + * - addInterceptors 取出注册的 SaInterceptor,借助 RequestContextHolder + sa-token Spring 上下文 + * 驱动 preHandle,覆盖 OPTIONS 放行 lambda 与 GET 未登录抛 NotLoginException 分支。 + */ +class WebMvcConfigTest { + + private SaTokenContext previousContext; + + @BeforeEach + void setUpSaTokenContext() { + previousContext = cn.dev33.satoken.SaManager.getSaTokenContext(); + cn.dev33.satoken.SaManager.setSaTokenContext(new SaTokenContextForSpringInJakartaServlet()); + } + + @AfterEach + void restoreSaTokenContext() { + cn.dev33.satoken.SaManager.setSaTokenContext(previousContext); + RequestContextHolder.resetRequestAttributes(); + } + + // ============ addCorsMappings ============ + + @Test + void addCorsMappings_wildcardOrigin_registersOriginPatternsWithCredentials() { + CorsProperties props = new CorsProperties(); + props.setAllowedOrigins(new String[]{"*"}); + WebMvcConfig config = new WebMvcConfig(props); + + InspectableCorsRegistry registry = new InspectableCorsRegistry(); + config.addCorsMappings(registry); + + Map configurations = registry.getCorsConfigurationsPublic(); + assertEquals(1, configurations.size()); + CorsConfiguration cors = configurations.get("/**"); + assertEquals(List.of("*"), cors.getAllowedOriginPatterns()); + assertTrue(cors.getAllowedMethods().contains("GET")); + assertTrue(cors.getAllowedMethods().contains("OPTIONS")); + assertTrue(cors.getAllowCredentials()); + } + + @Test + void addCorsMappings_explicitOrigins_nullCredentials_defaultsToTrue() { + CorsProperties props = new CorsProperties(); + props.setAllowedOrigins(new String[]{"http://localhost:5158"}); + props.setAllowCredentials(null); + WebMvcConfig config = new WebMvcConfig(props); + + InspectableCorsRegistry registry = new InspectableCorsRegistry(); + config.addCorsMappings(registry); + + CorsConfiguration cors = registry.getCorsConfigurationsPublic().get("/**"); + assertEquals(List.of("http://localhost:5158"), cors.getAllowedOrigins()); + assertTrue(cors.getAllowCredentials()); + } + + @Test + void addCorsMappings_explicitOrigins_credentialsFalse_staysFalse() { + CorsProperties props = new CorsProperties(); + props.setAllowedOrigins(new String[]{"http://localhost:5158"}); + props.setAllowCredentials(Boolean.FALSE); + WebMvcConfig config = new WebMvcConfig(props); + + InspectableCorsRegistry registry = new InspectableCorsRegistry(); + config.addCorsMappings(registry); + + CorsConfiguration cors = registry.getCorsConfigurationsPublic().get("/**"); + assertFalse(cors.getAllowCredentials()); + } + + // ============ addInterceptors ============ + + @Test + void addInterceptors_registersSingleSaInterceptor() { + WebMvcConfig config = new WebMvcConfig(new CorsProperties()); + + InspectableInterceptorRegistry registry = new InspectableInterceptorRegistry(); + config.addInterceptors(registry); + + assertEquals(1, registry.getInterceptorsPublic().size()); + HandlerInterceptor interceptor = (HandlerInterceptor) registry.getInterceptorsPublic().get(0); + // 注册时配置了 include/exclude 路径,框架会包装为 MappedInterceptor,最终都指向 SaInterceptor + assertTrue(interceptor instanceof MappedInterceptor || interceptor instanceof SaInterceptor); + } + + @Test + void preHandle_optionsRequest_passesWithoutLoginCheck() throws Exception { + SaInterceptor interceptor = registeredSaInterceptor(); + MockHttpServletRequest request = new MockHttpServletRequest("OPTIONS", "/tasks"); + MockHttpServletResponse response = new MockHttpServletResponse(); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + + assertTrue(interceptor.preHandle(request, response, new Object())); + } + + @Test + void preHandle_getRequestWithoutToken_throwsNotLoginException() { + SaInterceptor interceptor = registeredSaInterceptor(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/tasks"); + MockHttpServletResponse response = new MockHttpServletResponse(); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + + assertThrows(NotLoginException.class, + () -> interceptor.preHandle(request, response, new Object())); + } + + private SaInterceptor registeredSaInterceptor() { + WebMvcConfig config = new WebMvcConfig(new CorsProperties()); + InspectableInterceptorRegistry registry = new InspectableInterceptorRegistry(); + config.addInterceptors(registry); + return assertInstanceOf(SaInterceptor.class, unwrap(registry.getInterceptorsPublic().get(0))); + } + + private Object unwrap(Object interceptor) { + if (interceptor instanceof MappedInterceptor mapped) { + return mapped.getInterceptor(); + } + return interceptor; + } + + /** InterceptorRegistry#getInterceptors 是 protected,这里仅在测试内拓宽可见性。 */ + private static class InspectableInterceptorRegistry extends InterceptorRegistry { + @Override + public List getInterceptors() { + return super.getInterceptors(); + } + + List getInterceptorsPublic() { + return getInterceptors(); + } + } + + /** CorsRegistry#getCorsConfigurations 是 protected,这里仅在测试内拓宽可见性。 */ + private static class InspectableCorsRegistry extends CorsRegistry { + @Override + public Map getCorsConfigurations() { + return super.getCorsConfigurations(); + } + + Map getCorsConfigurationsPublic() { + return getCorsConfigurations(); + } + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/config/serializer/LocalDateTimeSerializerTest.java b/src/test/java/com/guo/learningprogresstracker/config/serializer/LocalDateTimeSerializerTest.java new file mode 100644 index 0000000..f7d8ac8 --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/config/serializer/LocalDateTimeSerializerTest.java @@ -0,0 +1,56 @@ +package com.guo.learningprogresstracker.config.serializer; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import org.junit.jupiter.api.Test; + +import java.io.StringWriter; +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * LocalDateTimeSerializer 单元测试:注册到 ObjectMapper 序列化非 null 值; + * null 分支 Jackson 默认不回调自定义序列化器,故直接驱动 serialize(null, gen, null) 覆盖。 + */ +class LocalDateTimeSerializerTest { + + @Test + void serialize_nonNullValue_writesFormattedDateTime() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + SimpleModule module = new SimpleModule(); + module.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer()); + mapper.registerModule(module); + + Bean bean = new Bean(); + bean.setTime(LocalDateTime.of(2024, 5, 1, 8, 30, 5)); + + assertEquals("{\"time\":\"2024-05-01T08:30:05\"}", mapper.writeValueAsString(bean)); + } + + @Test + void serialize_nullValue_writesJsonNull() throws Exception { + StringWriter writer = new StringWriter(); + JsonGenerator generator = new JsonFactory().createGenerator(writer); + + new LocalDateTimeSerializer().serialize(null, generator, null); + + generator.close(); + assertEquals("null", writer.toString()); + } + + /** 简单载体,字段名为 time。 */ + public static class Bean { + private LocalDateTime time; + + public LocalDateTime getTime() { + return time; + } + + public void setTime(LocalDateTime time) { + this.time = time; + } + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/controller/LoginControllerUnitTest.java b/src/test/java/com/guo/learningprogresstracker/controller/LoginControllerUnitTest.java new file mode 100644 index 0000000..1abc423 --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/controller/LoginControllerUnitTest.java @@ -0,0 +1,56 @@ +package com.guo.learningprogresstracker.controller; + +import com.guo.learningprogresstracker.dto.request.LoginBody; +import com.guo.learningprogresstracker.entity.CommonResult; +import com.guo.learningprogresstracker.service.LoginService; +import com.guo.learningprogresstracker.service.UserService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * LoginController 纯单元测试:mock LoginService / UserService。 + */ +@ExtendWith(MockitoExtension.class) +class LoginControllerUnitTest { + + @Mock + private LoginService loginService; + + @Mock + private UserService userServiceImpl; + + @InjectMocks + private LoginController controller; + + @Test + void login_authenticatesThenIssuesToken() throws Exception { + LoginBody body = new LoginBody(); + body.setUsername("alice"); + body.setPassword("secret"); + when(userServiceImpl.authenticate("alice", "secret")).thenReturn("U-1"); + when(loginService.login("U-1")).thenReturn("token-abc"); + + CommonResult result = controller.login(body); + + assertEquals(200, result.getCode()); + assertEquals("登录成功!", result.getMessage()); + assertEquals("token-abc", result.getData()); + verify(loginService).login("U-1"); + } + + @Test + void logout_delegatesToLoginService() { + CommonResult result = controller.logout(); + + assertEquals(200, result.getCode()); + assertEquals("已登出", result.getMessage()); + verify(loginService).logout(); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/controller/ReportFragmentsControllerUnitTest.java b/src/test/java/com/guo/learningprogresstracker/controller/ReportFragmentsControllerUnitTest.java new file mode 100644 index 0000000..8a6848a --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/controller/ReportFragmentsControllerUnitTest.java @@ -0,0 +1,67 @@ +package com.guo.learningprogresstracker.controller; + +import com.guo.learningprogresstracker.dto.request.CreateFragmentsRequest; +import com.guo.learningprogresstracker.dto.request.UpdateFragmentsRequest; +import com.guo.learningprogresstracker.entity.CommonResult; +import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity; +import com.guo.learningprogresstracker.service.impl.StudyReportFragmentsServiceImpl; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +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.verify; +import static org.mockito.Mockito.when; + +/** + * reportFragmentsController 纯单元测试(类名小写开头,保持与生产代码一致): + * 直接 new 控制器并 mock StudyReportFragmentsServiceImpl。 + */ +@ExtendWith(MockitoExtension.class) +class ReportFragmentsControllerUnitTest { + + @Mock + private StudyReportFragmentsServiceImpl studyReportFragmentsServiceImpl; + + @InjectMocks + private reportFragmentsController controller; + + @Test + void createFragments_delegatesToService() throws Exception { + CreateFragmentsRequest request = new CreateFragmentsRequest(); + request.setSessionNum("S-1"); + request.setContent("今日学习内容"); + + CommonResult result = controller.createFragments(request); + + assertEquals(200, result.getCode()); + verify(studyReportFragmentsServiceImpl).createFragments(request); + } + + @Test + void updateFragments_delegatesToService() throws Exception { + UpdateFragmentsRequest request = new UpdateFragmentsRequest(); + request.setContent("更新后的内容"); + + CommonResult result = controller.updateFragments(9, request); + + assertEquals(200, result.getCode()); + verify(studyReportFragmentsServiceImpl).updateFragments(9, request); + } + + @Test + void getFragmentsBySession_returnsFragmentList() { + List fragments = List.of(new StudyReportFragmentsEntity()); + when(studyReportFragmentsServiceImpl.getFragmentsBySession("S-2")).thenReturn(fragments); + + CommonResult> result = controller.getFragmentsBySession("S-2"); + + assertEquals(200, result.getCode()); + assertSame(fragments, result.getData()); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/controller/ReviewControllerUnitTest.java b/src/test/java/com/guo/learningprogresstracker/controller/ReviewControllerUnitTest.java new file mode 100644 index 0000000..92d3cf0 --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/controller/ReviewControllerUnitTest.java @@ -0,0 +1,225 @@ +package com.guo.learningprogresstracker.controller; + +import com.guo.learningprogresstracker.dto.ReviewFeedItem; +import com.guo.learningprogresstracker.dto.ReviewTaskStats; +import com.guo.learningprogresstracker.dto.request.RecallCompareRequest; +import com.guo.learningprogresstracker.dto.request.UpdateStandardMindMapRequest; +import com.guo.learningprogresstracker.entity.CommonResult; +import com.guo.learningprogresstracker.entity.ReviewRecallRecordEntity; +import com.guo.learningprogresstracker.entity.ReviewStandardMindMapEntity; +import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity; +import com.guo.learningprogresstracker.entity.StudyReportsEntity; +import com.guo.learningprogresstracker.service.ReviewService; +import com.guo.learningprogresstracker.service.StandardMindMapService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * ReviewController 纯单元测试:直接 new 控制器并 mock 两个服务接口。 + */ +@ExtendWith(MockitoExtension.class) +class ReviewControllerUnitTest { + + @Mock + private ReviewService reviewService; + + @Mock + private StandardMindMapService standardMindMapService; + + @InjectMocks + private ReviewController controller; + + @Test + void getReviewFeed_passesLimitAndMode() { + List feed = List.of(new ReviewFeedItem()); + when(reviewService.getReviewFeed(30, "recent")).thenReturn(feed); + + CommonResult> result = controller.getReviewFeed(30, "recent"); + + assertEquals(200, result.getCode()); + assertSame(feed, result.getData()); + } + + @Test + void getReviewTaskStats_noArg_returnsAllTaskStats() { + List stats = List.of(new ReviewTaskStats()); + when(reviewService.getReviewTaskStats()).thenReturn(stats); + + CommonResult> result = controller.getReviewTaskStats(); + + assertEquals(200, result.getCode()); + assertSame(stats, result.getData()); + } + + @Test + void getReviewTaskStats_byTaskNum_returnsSingleStats() { + ReviewTaskStats stats = new ReviewTaskStats(); + when(reviewService.getReviewTaskStats("T001")).thenReturn(stats); + + CommonResult result = controller.getReviewTaskStats("T001"); + + assertEquals(200, result.getCode()); + assertSame(stats, result.getData()); + } + + @Test + void getTaskReview_returnsTaskFeed() { + List feed = List.of(new ReviewFeedItem()); + when(reviewService.getTaskReview("T002")).thenReturn(feed); + + CommonResult> result = controller.getTaskReview("T002"); + + assertEquals(200, result.getCode()); + assertSame(feed, result.getData()); + } + + @Test + void getReportDetail_delegatesToService() throws Exception { + StudyReportsEntity report = new StudyReportsEntity(); + when(reviewService.getReportDetail(11)).thenReturn(report); + + CommonResult result = controller.getReportDetail(11); + + assertEquals(200, result.getCode()); + assertSame(report, result.getData()); + } + + @Test + void getFragmentDetail_delegatesToService() throws Exception { + StudyReportFragmentsEntity fragment = new StudyReportFragmentsEntity(); + when(reviewService.getFragmentDetail(22)).thenReturn(fragment); + + CommonResult result = controller.getFragmentDetail(22); + + assertEquals(200, result.getCode()); + assertSame(fragment, result.getData()); + } + + @Test + void getStandardMindMap_delegatesToService() throws Exception { + ReviewStandardMindMapEntity mindMap = new ReviewStandardMindMapEntity(); + when(standardMindMapService.getOrGenerate("T003")).thenReturn(mindMap); + + CommonResult result = controller.getStandardMindMap("T003"); + + assertEquals(200, result.getCode()); + assertSame(mindMap, result.getData()); + } + + @Test + void regenerateStandardMindMap_incrementalMode_usesIncrementalGenerate() throws Exception { + ReviewStandardMindMapEntity mindMap = new ReviewStandardMindMapEntity(); + when(standardMindMapService.incrementalGenerate("T004")).thenReturn(mindMap); + + CommonResult result = controller.regenerateStandardMindMap("T004", "incremental"); + + assertEquals(200, result.getCode()); + assertSame(mindMap, result.getData()); + verify(standardMindMapService).incrementalGenerate("T004"); + } + + @Test + void regenerateStandardMindMap_defaultMode_usesFullRegenerate() throws Exception { + ReviewStandardMindMapEntity mindMap = new ReviewStandardMindMapEntity(); + when(standardMindMapService.regenerate("T005")).thenReturn(mindMap); + + CommonResult result = controller.regenerateStandardMindMap("T005", "full"); + + assertEquals(200, result.getCode()); + assertSame(mindMap, result.getData()); + verify(standardMindMapService).regenerate("T005"); + } + + @Test + void updateStandardMindMap_passesOutline() throws Exception { + UpdateStandardMindMapRequest request = new UpdateStandardMindMapRequest(); + request.setOutline("- 根节点\n- 子节点"); + ReviewStandardMindMapEntity mindMap = new ReviewStandardMindMapEntity(); + when(standardMindMapService.updateByOutline("T006", "- 根节点\n- 子节点")).thenReturn(mindMap); + + CommonResult result = controller.updateStandardMindMap("T006", request); + + assertEquals(200, result.getCode()); + assertSame(mindMap, result.getData()); + } + + @Test + void recallCompare_passesOutlineAndFocusPath() throws Exception { + RecallCompareRequest request = new RecallCompareRequest(); + request.setRecallOutline("- 回忆内容"); + request.setFocusPath("根/子"); + ReviewStandardMindMapEntity mindMap = new ReviewStandardMindMapEntity(); + when(standardMindMapService.recallCompare("T007", "- 回忆内容", "根/子")).thenReturn(mindMap); + + CommonResult result = controller.recallCompare("T007", request); + + assertEquals(200, result.getCode()); + assertSame(mindMap, result.getData()); + } + + @Test + void findNode_nullBody_usesEmptyContent() throws Exception { + Map matched = Map.of("id", "n1"); + when(standardMindMapService.findNode("T008", "")).thenReturn(matched); + + CommonResult> result = controller.findNode("T008", null); + + assertEquals(200, result.getCode()); + assertSame(matched, result.getData()); + } + + @Test + void findNode_bodyWithoutContentKey_usesEmptyContent() throws Exception { + Map matched = Map.of("id", "n2"); + when(standardMindMapService.findNode("T009", "")).thenReturn(matched); + + CommonResult> result = controller.findNode("T009", Map.of("other", "x")); + + assertEquals(200, result.getCode()); + assertSame(matched, result.getData()); + } + + @Test + void findNode_bodyWithContent_usesContent() throws Exception { + Map matched = Map.of("id", "n3"); + when(standardMindMapService.findNode("T010", "租户隔离")).thenReturn(matched); + + CommonResult> result = controller.findNode("T010", Map.of("content", "租户隔离")); + + assertEquals(200, result.getCode()); + assertSame(matched, result.getData()); + } + + @Test + void listRecallRecords_returnsRecords() throws Exception { + List records = List.of(new ReviewRecallRecordEntity()); + when(standardMindMapService.listRecallRecords("T011")).thenReturn(records); + + CommonResult> result = controller.listRecallRecords("T011"); + + assertEquals(200, result.getCode()); + assertSame(records, result.getData()); + } + + @Test + void getRecallRecord_returnsSingleRecord() throws Exception { + ReviewRecallRecordEntity record = new ReviewRecallRecordEntity(); + when(standardMindMapService.getRecallRecord(33)).thenReturn(record); + + CommonResult result = controller.getRecallRecord(33); + + assertEquals(200, result.getCode()); + assertSame(record, result.getData()); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/controller/StudySessionControllerUnitTest.java b/src/test/java/com/guo/learningprogresstracker/controller/StudySessionControllerUnitTest.java new file mode 100644 index 0000000..42789a8 --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/controller/StudySessionControllerUnitTest.java @@ -0,0 +1,191 @@ +package com.guo.learningprogresstracker.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.guo.learningprogresstracker.dto.request.AbortStudySessionRequest; +import com.guo.learningprogresstracker.dto.request.EndedStudySessionRequest; +import com.guo.learningprogresstracker.dto.request.UpsertExpectationRequest; +import com.guo.learningprogresstracker.dto.response.StudySessionResponse; +import com.guo.learningprogresstracker.entity.CommonResult; +import com.guo.learningprogresstracker.entity.StudyExpectationsEntity; +import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity; +import com.guo.learningprogresstracker.entity.StudyReportsEntity; +import com.guo.learningprogresstracker.service.StudyExpectationsService; +import com.guo.learningprogresstracker.service.impl.StudySessionsServiceImpl; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * StudySessionController 纯单元测试:直接 new 控制器并 mock 服务层, + * 不启动 Spring 容器(与既有 @SpringBootTest 风格的 StudySessionControllerTest 互补)。 + */ +@ExtendWith(MockitoExtension.class) +class StudySessionControllerUnitTest { + + @Mock + private StudySessionsServiceImpl studySessionsServiceImpl; + + @Mock + private StudyExpectationsService studyExpectationsService; + + @InjectMocks + private StudySessionController controller; + + @Test + void upsertExpectation_delegatesToService() throws Exception { + UpsertExpectationRequest request = new UpsertExpectationRequest(); + request.setDescription("目标:掌握租户隔离"); + StudyExpectationsEntity entity = new StudyExpectationsEntity(); + when(studyExpectationsService.upsertExpectation("S001", "目标:掌握租户隔离")).thenReturn(entity); + + CommonResult result = controller.upsertExpectation("S001", request); + + assertEquals(200, result.getCode()); + assertSame(entity, result.getData()); + verify(studyExpectationsService).upsertExpectation("S001", "目标:掌握租户隔离"); + } + + @Test + void getExpectation_delegatesToService() { + StudyExpectationsEntity entity = new StudyExpectationsEntity(); + when(studyExpectationsService.getBySessionNum("S002")).thenReturn(entity); + + CommonResult result = controller.getExpectation("S002"); + + assertEquals(200, result.getCode()); + assertSame(entity, result.getData()); + } + + @Test + void getStudySessionBySessionNum_delegatesToService() throws Exception { + StudySessionResponse response = new StudySessionResponse(); + when(studySessionsServiceImpl.getStudySessionBySessionNum("S003")).thenReturn(response); + + CommonResult result = controller.getStudySessionBySessionNum("S003"); + + assertEquals(200, result.getCode()); + assertSame(response, result.getData()); + } + + @Test + void startStudySession_continuesSessionAndReturnsSuccess() throws Exception { + CommonResult result = controller.startStudySession("S004"); + + assertEquals(200, result.getCode()); + assertNull(result.getData()); + verify(studySessionsServiceImpl).continueStudySession("S004"); + } + + @Test + void pauseStudySession_passesEndTimeThrough() throws Exception { + LocalDateTime endTime = LocalDateTime.of(2024, 5, 1, 12, 0); + + CommonResult result = controller.pauseStudySession("S005", endTime); + + assertEquals(200, result.getCode()); + verify(studySessionsServiceImpl).pauseStudySession("S005", endTime); + } + + @Test + void endedStudySession_withReportMessage_returnsMessageInData() throws Exception { + EndedStudySessionRequest request = new EndedStudySessionRequest(); + request.setContent("报告内容"); + when(studySessionsServiceImpl.endedStudySession("S006", "报告内容")).thenReturn("报告摘要"); + + CommonResult result = controller.endedStudySession("S006", request); + + assertEquals(200, result.getCode()); + // 注意:CommonResult.success(String) 重载优先匹配 message 参数, + // 报告摘要落在 message 字段而非 data(生产行为如此,按实际语义断言) + assertEquals("报告摘要", result.getMessage()); + } + + @Test + void endedStudySession_withoutReportMessage_returnsEmptySuccess() throws Exception { + EndedStudySessionRequest request = new EndedStudySessionRequest(); + request.setContent("无报告内容"); + when(studySessionsServiceImpl.endedStudySession("S007", "无报告内容")).thenReturn(null); + + CommonResult result = controller.endedStudySession("S007", request); + + assertEquals(200, result.getCode()); + assertNull(result.getData()); + } + + @Test + void abortStudySession_delegatesConfirmation() throws Exception { + AbortStudySessionRequest request = new AbortStudySessionRequest(); + request.setConfirmation("确认删除"); + + CommonResult result = controller.abortStudySession("S008", request); + + assertEquals(200, result.getCode()); + verify(studySessionsServiceImpl).abortStudySession("S008", "确认删除"); + } + + @Test + void getAllFragments_returnsFragmentList() throws Exception { + ArrayList fragments = new ArrayList<>(List.of("片段一", "片段二")); + when(studySessionsServiceImpl.getAllFragments("S009")).thenReturn(fragments); + + CommonResult> result = controller.getAllFragments("S009"); + + assertEquals(200, result.getCode()); + assertSame(fragments, result.getData()); + } + + @Test + void getReportDraft_returnsGeneratedDraft() throws Exception { + when(studySessionsServiceImpl.generateReportDraft("S010")).thenReturn("草稿内容"); + + CommonResult result = controller.getReportDraft("S010"); + + assertEquals(200, result.getCode()); + assertEquals("草稿内容", result.getData()); + } + + @Test + void getTaskFragments_passesPagingArguments() { + Page page = new Page<>(); + when(studySessionsServiceImpl.getTaskFragments("T001", 2, 5, "关键词")).thenReturn(page); + + CommonResult> result = controller.getTaskFragments("T001", 2, 5, "关键词"); + + assertEquals(200, result.getCode()); + assertSame(page, result.getData()); + } + + @Test + void getTaskReports_passesPagingArguments() { + Page page = new Page<>(); + when(studySessionsServiceImpl.getTaskReports("T002", 3, 20, null)).thenReturn(page); + + CommonResult> result = controller.getTaskReports("T002", 3, 20, null); + + assertEquals(200, result.getCode()); + assertSame(page, result.getData()); + } + + @Test + void getActiveSession_passesExcludeTaskNum() { + StudySessionResponse response = new StudySessionResponse(); + when(studySessionsServiceImpl.getActiveSession("T003")).thenReturn(response); + + CommonResult result = controller.getActiveSession("T003"); + + assertEquals(200, result.getCode()); + assertSame(response, result.getData()); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/controller/TaskControllerUnitTest.java b/src/test/java/com/guo/learningprogresstracker/controller/TaskControllerUnitTest.java new file mode 100644 index 0000000..675e20e --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/controller/TaskControllerUnitTest.java @@ -0,0 +1,188 @@ +package com.guo.learningprogresstracker.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.guo.learningprogresstracker.dto.TaskInfo; +import com.guo.learningprogresstracker.dto.request.CreateTaskApplicationRequest; +import com.guo.learningprogresstracker.dto.request.TaskRequest; +import com.guo.learningprogresstracker.dto.request.UpdateTaskApplicationRequest; +import com.guo.learningprogresstracker.dto.response.StudySessionResponse; +import com.guo.learningprogresstracker.dto.response.TaskInfoResponse; +import com.guo.learningprogresstracker.entity.CommonResult; +import com.guo.learningprogresstracker.entity.TaskApplicationEntity; +import com.guo.learningprogresstracker.entity.UserPriorityWeightsEntity; +import com.guo.learningprogresstracker.service.PriorityWeightsService; +import com.guo.learningprogresstracker.service.TasksService; +import com.guo.learningprogresstracker.service.impl.StudySessionsServiceImpl; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +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.verify; +import static org.mockito.Mockito.when; + +/** + * TaskController 纯单元测试:直接 new 控制器并 mock 三个依赖服务。 + */ +@ExtendWith(MockitoExtension.class) +class TaskControllerUnitTest { + + @Mock + private TasksService tasksService; + + @Mock + private StudySessionsServiceImpl studySessionsServiceImpl; + + @Mock + private PriorityWeightsService priorityWeightsService; + + @InjectMocks + private TaskController controller; + + @Test + void getPriorityWeights_delegatesToService() { + UserPriorityWeightsEntity weights = new UserPriorityWeightsEntity(); + when(priorityWeightsService.getWeights()).thenReturn(weights); + + CommonResult result = controller.getPriorityWeights(); + + assertEquals(200, result.getCode()); + assertSame(weights, result.getData()); + } + + @Test + void savePriorityWeights_delegatesToService() throws Exception { + UserPriorityWeightsEntity weights = new UserPriorityWeightsEntity(); + UserPriorityWeightsEntity saved = new UserPriorityWeightsEntity(); + when(priorityWeightsService.saveWeights(weights)).thenReturn(saved); + + CommonResult result = controller.savePriorityWeights(weights); + + assertEquals(200, result.getCode()); + assertSame(saved, result.getData()); + } + + @Test + void addTask_returnsGeneratedTaskNum() throws Exception { + TaskRequest request = new TaskRequest(); + when(tasksService.addTask(request)).thenReturn("T-100"); + + CommonResult result = controller.addTask(request); + + assertEquals(200, result.getCode()); + assertEquals("T-100", result.getData()); + } + + @Test + void getTask_delegatesToService() { + TaskInfoResponse response = new TaskInfoResponse(); + when(tasksService.getTask("1")).thenReturn(response); + + CommonResult result = controller.getTask("1"); + + assertEquals(200, result.getCode()); + assertSame(response, result.getData()); + } + + @Test + void updateTask_delegatesToService() { + TaskRequest request = new TaskRequest(); + + CommonResult result = controller.updateTask("2", request); + + assertEquals(200, result.getCode()); + verify(tasksService).updateTask("2", request); + } + + @Test + void deleteTask_delegatesToService() throws Exception { + CommonResult result = controller.deleteTask("3"); + + assertEquals(200, result.getCode()); + verify(tasksService).deleteTask("3"); + } + + @Test + void getApplications_returnsApplicationList() throws Exception { + List applications = List.of(new TaskApplicationEntity()); + when(tasksService.getApplications("T-1")).thenReturn(applications); + + CommonResult> result = controller.getApplications("T-1"); + + assertEquals(200, result.getCode()); + assertSame(applications, result.getData()); + } + + @Test + void createApplication_fillsTaskNumBeforeDelegation() throws Exception { + CreateTaskApplicationRequest request = new CreateTaskApplicationRequest(); + request.setTitle("背单词"); + TaskApplicationEntity created = new TaskApplicationEntity(); + when(tasksService.createApplication(request)).thenReturn(created); + + CommonResult result = controller.createApplication("T-2", request); + + assertEquals("T-2", request.getTaskNum()); + assertEquals(200, result.getCode()); + assertSame(created, result.getData()); + } + + @Test + void updateApplication_delegatesToService() throws Exception { + UpdateTaskApplicationRequest request = new UpdateTaskApplicationRequest(); + request.setTitle("更新后的标题"); + TaskApplicationEntity updated = new TaskApplicationEntity(); + when(tasksService.updateApplication(7, request)).thenReturn(updated); + + CommonResult result = controller.updateApplication(7, request); + + assertEquals(200, result.getCode()); + assertSame(updated, result.getData()); + } + + @Test + void deleteApplication_delegatesToService() throws Exception { + CommonResult result = controller.deleteApplication(8); + + assertEquals(200, result.getCode()); + verify(tasksService).deleteApplication(8); + } + + @Test + void tasksList_passesPagingArguments() { + Page page = new Page<>(); + when(tasksService.taskList(2, 20)).thenReturn(page); + + CommonResult> result = controller.tasksList(2, 20); + + assertEquals(200, result.getCode()); + assertSame(page, result.getData()); + } + + @Test + void startOrContinueStudySession_delegatesToImpl() throws Exception { + StudySessionResponse response = new StudySessionResponse(); + when(studySessionsServiceImpl.startOrContinueStudySession("T-3")).thenReturn(response); + + CommonResult result = controller.startOrContinueStudySession("T-3"); + + assertEquals(200, result.getCode()); + assertSame(response, result.getData()); + } + + @Test + void getNotEndedStudySessionByTaskNum_delegatesToImpl() throws Exception { + StudySessionResponse response = new StudySessionResponse(); + when(studySessionsServiceImpl.getNotEndedStudySessionByTaskNum("T-4")).thenReturn(response); + + CommonResult result = controller.getNotEndedStudySessionByTaskNum("T-4"); + + assertEquals(200, result.getCode()); + assertSame(response, result.getData()); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/controller/TestControllerUnitTest.java b/src/test/java/com/guo/learningprogresstracker/controller/TestControllerUnitTest.java new file mode 100644 index 0000000..f06d744 --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/controller/TestControllerUnitTest.java @@ -0,0 +1,90 @@ +package com.guo.learningprogresstracker.controller; + +import com.guo.learningprogresstracker.entity.CommonResult; +import com.guo.learningprogresstracker.entity.TestTableEntity; +import com.guo.learningprogresstracker.exception.AppException; +import com.guo.learningprogresstracker.exception.ErrorParameterException; +import com.guo.learningprogresstracker.exception.NotFindEntitiesException; +import com.guo.learningprogresstracker.exception.OperationFailedException; +import com.guo.learningprogresstracker.service.TestTableService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * TestController 纯单元测试:同包直接注入 mock 的 TestTableService 字段, + * 覆盖按 id 触发的各业务异常分支。 + */ +@ExtendWith(MockitoExtension.class) +class TestControllerUnitTest { + + @Mock + private TestTableService testTableService; + + @Test + void getTestEntityById_id1_throwsAppException() { + TestController controller = new TestController(); + controller.testTableService = testTableService; + + AppException ex = assertThrows(AppException.class, () -> controller.getTestEntityById("1")); + + assertEquals("测试AppException", ex.getMessage()); + verifyNoInteractions(testTableService); + } + + @Test + void getTestEntityById_id2_throwsErrorParameterException() { + TestController controller = new TestController(); + controller.testTableService = testTableService; + + ErrorParameterException ex = assertThrows(ErrorParameterException.class, + () -> controller.getTestEntityById("2")); + + assertEquals("测试ErrorParameterException", ex.getMessage()); + verifyNoInteractions(testTableService); + } + + @Test + void getTestEntityById_id3_throwsNotFindEntitiesException() { + TestController controller = new TestController(); + controller.testTableService = testTableService; + + NotFindEntitiesException ex = assertThrows(NotFindEntitiesException.class, + () -> controller.getTestEntityById("3")); + + assertEquals("测试NotFindEntitiesException", ex.getMessage()); + verifyNoInteractions(testTableService); + } + + @Test + void getTestEntityById_id4_throwsOperationFailedException() { + TestController controller = new TestController(); + controller.testTableService = testTableService; + + OperationFailedException ex = assertThrows(OperationFailedException.class, + () -> controller.getTestEntityById("4")); + + assertEquals("测试OperationFailedException", ex.getMessage()); + verifyNoInteractions(testTableService); + } + + @Test + void getTestEntityById_otherId_returnsEntityFromService() throws Exception { + TestController controller = new TestController(); + controller.testTableService = testTableService; + TestTableEntity entity = new TestTableEntity(); + when(testTableService.getTestEntityById("5")).thenReturn(entity); + + CommonResult result = controller.getTestEntityById("5"); + + assertEquals(200, result.getCode()); + assertSame(entity, result.getData()); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/controller/UtilsControllerUnitTest.java b/src/test/java/com/guo/learningprogresstracker/controller/UtilsControllerUnitTest.java new file mode 100644 index 0000000..1b47f8d --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/controller/UtilsControllerUnitTest.java @@ -0,0 +1,59 @@ +package com.guo.learningprogresstracker.controller; + +import com.guo.learningprogresstracker.entity.CommonResult; +import com.guo.learningprogresstracker.support.TestHttpServer; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * UtilsController 单元测试:fetchTitle 内部调用 TitleFetcher 静态方法无法 mock, + * 用本地 TestHttpServer 提供真实页面验证网络分支,null/空白参数直接断言错误结果。 + */ +class UtilsControllerUnitTest extends TestHttpServer { + + private final UtilsController controller = new UtilsController(); + + @Test + void fetchTitle_nullUrl_returnsError() { + CommonResult> result = controller.fetchTitle(null); + + assertEquals(400, result.getCode()); + assertEquals("url 参数不能为空", result.getMessage()); + assertNull(result.getData()); + } + + @Test + void fetchTitle_blankUrl_returnsError() { + CommonResult> result = controller.fetchTitle(" "); + + assertEquals(400, result.getCode()); + assertEquals("url 参数不能为空", result.getMessage()); + } + + @Test + void fetchTitle_validUrl_returnsPageTitle() { + route("GET", "/page", 200, "单元测试标题"); + + CommonResult> result = controller.fetchTitle(baseUrl() + "/page?q=1"); + + assertEquals(200, result.getCode()); + assertEquals("单元测试标题", result.getData().get("title")); + assertEquals("q=1", lastQuery("GET", "/page")); + } + + @Test + void fetchTitle_pageWithoutTitle_fallsBackToUrl() { + route("GET", "/plain", 200, "纯文本没有标题", + Map.of("Content-Type", "text/plain; charset=utf-8")); + + String url = baseUrl() + "/plain"; + CommonResult> result = controller.fetchTitle(url); + + assertEquals(200, result.getCode()); + assertEquals(url, result.getData().get("title")); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/entity/CommonResultTest.java b/src/test/java/com/guo/learningprogresstracker/entity/CommonResultTest.java new file mode 100644 index 0000000..1c19338 --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/entity/CommonResultTest.java @@ -0,0 +1,106 @@ +package com.guo.learningprogresstracker.entity; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * CommonResult 单元测试:覆盖全部工厂方法与构造重载。 + */ +class CommonResultTest { + + @Test + void success_noArgs_shouldUseDefaultMessage() { + CommonResult result = CommonResult.success(); + + assertEquals(200, result.getCode()); + assertEquals("请求成功", result.getMessage()); + assertNull(result.getData()); + } + + @Test + void success_withMessage_shouldKeepMessage() { + CommonResult result = CommonResult.success("操作完成"); + + assertEquals(200, result.getCode()); + assertEquals("操作完成", result.getMessage()); + assertNull(result.getData()); + } + + @Test + void success_withData_shouldUseDefaultMessage() { + // 注意:CommonResult.success(String) 重载优先匹配 message 参数, + // 必须先向上转型为 Object 才能命中 success(T data) 这个工厂方法 + CommonResult result = CommonResult.success((Object) "数据"); + + assertEquals(200, result.getCode()); + assertEquals("请求成功", result.getMessage()); + assertEquals("数据", result.getData()); + } + + @Test + void success_withMessageAndData_shouldKeepBoth() { + CommonResult result = CommonResult.success("操作完成", "数据"); + + assertEquals(200, result.getCode()); + assertEquals("操作完成", result.getMessage()); + assertEquals("数据", result.getData()); + } + + @Test + void twoArgConstructor_shouldDefaultNullData() { + CommonResult result = new CommonResult<>(400, "参数错误"); + + assertEquals(400, result.getCode()); + assertEquals("参数错误", result.getMessage()); + assertNull(result.getData()); + } + + @Test + void error_shouldUseBadRequest() { + CommonResult result = CommonResult.error("请求失败"); + + assertEquals(400, result.getCode()); + assertEquals("请求失败", result.getMessage()); + assertNull(result.getData()); + } + + @Test + void notFind_noArgs_shouldUseDefaultMessage() { + CommonResult result = CommonResult.notFind(); + + assertEquals(404, result.getCode()); + assertEquals("请求的资源不存在", result.getMessage()); + assertNull(result.getData()); + } + + @Test + void notFind_withMessage_shouldKeepMessage() { + CommonResult result = CommonResult.notFind("找不到任务"); + + assertEquals(404, result.getCode()); + assertEquals("找不到任务", result.getMessage()); + assertNull(result.getData()); + } + + @Test + void serverError_shouldUse500() { + CommonResult result = CommonResult.serverError("服务器错误"); + + assertEquals(500, result.getCode()); + assertEquals("服务器错误", result.getMessage()); + assertNull(result.getData()); + } + + @Test + void settersAndGetters_shouldWork() { + CommonResult result = new CommonResult<>(); + result.setCode(200); + result.setMessage("ok"); + result.setData("d"); + + assertEquals(200, result.getCode()); + assertEquals("ok", result.getMessage()); + assertEquals("d", result.getData()); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/entity/TestTableEntityTest.java b/src/test/java/com/guo/learningprogresstracker/entity/TestTableEntityTest.java new file mode 100644 index 0000000..8f78198 --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/entity/TestTableEntityTest.java @@ -0,0 +1,90 @@ +package com.guo.learningprogresstracker.entity; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * TestTableEntity 单元测试:覆盖手写的 equals/hashCode/toString 全部分支。 + */ +class TestTableEntityTest { + + private TestTableEntity entity(Integer id, String idName) { + TestTableEntity entity = new TestTableEntity(); + entity.setId(id); + entity.setIdName(idName); + return entity; + } + + @Test + void equals_sameReference_shouldBeTrue() { + TestTableEntity entity = entity(1, "a"); + assertEquals(entity, entity); + } + + @Test + void equals_null_shouldBeFalse() { + assertNotEquals(entity(1, "a"), null); + } + + @Test + void equals_differentClass_shouldBeFalse() { + TestTableEntity entity = entity(1, "a"); + TestTableEntity subclass = new TestTableEntity() { + }; + subclass.setId(1); + subclass.setIdName("a"); + assertNotEquals(entity, subclass); + } + + @Test + void equals_sameFields_shouldBeTrue() { + assertEquals(entity(1, "a"), entity(1, "a")); + } + + @Test + void equals_bothFieldsNull_shouldBeTrue() { + assertEquals(entity(null, null), entity(null, null)); + } + + @Test + void equals_differentId_shouldBeFalse() { + assertNotEquals(entity(1, "a"), entity(2, "a")); + } + + @Test + void equals_differentIdName_shouldBeFalse() { + assertNotEquals(entity(1, "a"), entity(1, "b")); + } + + @Test + void equals_idNullVsSet_shouldBeFalse() { + assertNotEquals(entity(null, "a"), entity(1, "a")); + } + + @Test + void equals_idNameNullVsSet_shouldBeFalse() { + assertNotEquals(entity(1, null), entity(1, "a")); + } + + @Test + void hashCode_equalEntities_shouldMatch() { + assertEquals(entity(1, "a").hashCode(), entity(1, "a").hashCode()); + } + + @Test + void hashCode_nullFields_shouldBeStable() { + assertEquals(entity(null, null).hashCode(), entity(null, null).hashCode()); + } + + @Test + void toString_shouldContainFields() { + TestTableEntity entity = entity(1, "测试"); + String text = entity.toString(); + + assertTrue(text.contains("TestTableEntity")); + assertTrue(text.contains("id=1")); + assertTrue(text.contains("idName=测试")); + assertTrue(text.contains("serialVersionUID=1")); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/enums/StudySessionStateEnumTest.java b/src/test/java/com/guo/learningprogresstracker/enums/StudySessionStateEnumTest.java new file mode 100644 index 0000000..1fb7e49 --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/enums/StudySessionStateEnumTest.java @@ -0,0 +1,18 @@ +package com.guo.learningprogresstracker.enums; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * StudySessionStateEnum 单元测试:覆盖 getDescription 未覆盖行。 + */ +class StudySessionStateEnumTest { + + @Test + void descriptions_shouldMatchConstants() { + assertEquals("进行中", StudySessionStateEnum.ONGOING.getDescription()); + assertEquals("暂停", StudySessionStateEnum.PAUSED.getDescription()); + assertEquals("已结束", StudySessionStateEnum.ENDED.getDescription()); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/enums/TaskApplicationStatusEnumTest.java b/src/test/java/com/guo/learningprogresstracker/enums/TaskApplicationStatusEnumTest.java new file mode 100644 index 0000000..04eb893 --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/enums/TaskApplicationStatusEnumTest.java @@ -0,0 +1,40 @@ +package com.guo.learningprogresstracker.enums; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * TaskApplicationStatusEnum 单元测试:覆盖 fromCodeOrDefault 的未知码兜底/合法码分支。 + */ +class TaskApplicationStatusEnumTest { + + @Test + void fromCodeOrDefault_nullOrBlank_shouldReturnTodo() { + assertSame(TaskApplicationStatusEnum.TODO, TaskApplicationStatusEnum.fromCodeOrDefault(null)); + assertSame(TaskApplicationStatusEnum.TODO, TaskApplicationStatusEnum.fromCodeOrDefault("")); + assertSame(TaskApplicationStatusEnum.TODO, TaskApplicationStatusEnum.fromCodeOrDefault(" ")); + } + + @Test + void fromCodeOrDefault_validCode_shouldMatchIgnoringCase() { + assertSame(TaskApplicationStatusEnum.TODO, TaskApplicationStatusEnum.fromCodeOrDefault("todo")); + assertSame(TaskApplicationStatusEnum.DOING, TaskApplicationStatusEnum.fromCodeOrDefault("doing")); + assertSame(TaskApplicationStatusEnum.DONE, TaskApplicationStatusEnum.fromCodeOrDefault("DONE")); + } + + @Test + void fromCodeOrDefault_unknownCode_shouldFallbackTodo() { + assertSame(TaskApplicationStatusEnum.TODO, TaskApplicationStatusEnum.fromCodeOrDefault("not-a-status")); + } + + @Test + void codeAndDescription_shouldMatchConstants() { + assertEquals("TODO", TaskApplicationStatusEnum.TODO.getCode()); + assertEquals("待应用", TaskApplicationStatusEnum.TODO.getDescription()); + assertEquals("DOING", TaskApplicationStatusEnum.DOING.getCode()); + assertEquals("应用中", TaskApplicationStatusEnum.DOING.getDescription()); + assertEquals("DONE", TaskApplicationStatusEnum.DONE.getCode()); + assertEquals("已完成", TaskApplicationStatusEnum.DONE.getDescription()); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/exception/AppExceptionTest.java b/src/test/java/com/guo/learningprogresstracker/exception/AppExceptionTest.java new file mode 100644 index 0000000..7afa7f2 --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/exception/AppExceptionTest.java @@ -0,0 +1,18 @@ +package com.guo.learningprogresstracker.exception; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * AppException 构造与消息测试。 + */ +class AppExceptionTest { + + @Test + void constructor_shouldKeepMessage() { + AppException ex = new AppException("数据库连接失败"); + + assertEquals("数据库连接失败", ex.getMessage()); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/exception/OperationFailedExceptionTest.java b/src/test/java/com/guo/learningprogresstracker/exception/OperationFailedExceptionTest.java new file mode 100644 index 0000000..1999ec9 --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/exception/OperationFailedExceptionTest.java @@ -0,0 +1,25 @@ +package com.guo.learningprogresstracker.exception; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * OperationFailedException 构造与消息测试。 + */ +class OperationFailedExceptionTest { + + @Test + void noArgConstructor_messageShouldBeNull() { + OperationFailedException ex = new OperationFailedException(); + + assertNull(ex.getMessage()); + } + + @Test + void messageConstructor_shouldKeepMessage() { + OperationFailedException ex = new OperationFailedException("操作失败"); + + assertEquals("操作失败", ex.getMessage()); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/mapStruct/FragmentsConvertTest.java b/src/test/java/com/guo/learningprogresstracker/mapStruct/FragmentsConvertTest.java new file mode 100644 index 0000000..f65cc78 --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/mapStruct/FragmentsConvertTest.java @@ -0,0 +1,29 @@ +package com.guo.learningprogresstracker.mapStruct; + +import com.guo.learningprogresstracker.dto.request.CreateFragmentsRequest; +import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 仅触发 MAPPER 字段初始化(Mappers.getMapper 加载生成的 Impl), + * 覆盖接口内的初始化行;生成实现已被 JaCoCo 排除,不做映射细节断言。 + */ +class FragmentsConvertTest { + + @Test + void mapper_shouldBeInitializedAndConvertRequest() { + assertNotNull(FragmentsConvert.MAPPER); + + CreateFragmentsRequest request = new CreateFragmentsRequest(); + request.setSessionNum("SESSION_A"); + request.setContent("学习内容"); + + StudyReportFragmentsEntity entity = FragmentsConvert.MAPPER.toFragmentsEntity(request); + + assertNotNull(entity); + assertEquals("SESSION_A", entity.getSessionNum()); + assertEquals("学习内容", entity.getContent()); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/mapStruct/StudySessionConvertTest.java b/src/test/java/com/guo/learningprogresstracker/mapStruct/StudySessionConvertTest.java new file mode 100644 index 0000000..44e4545 --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/mapStruct/StudySessionConvertTest.java @@ -0,0 +1,29 @@ +package com.guo.learningprogresstracker.mapStruct; + +import com.guo.learningprogresstracker.dto.response.StudySessionResponse; +import com.guo.learningprogresstracker.entity.StudySessionsEntity; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 仅触发 MAPPER 字段初始化(Mappers.getMapper 加载生成的 Impl), + * 覆盖接口内的初始化行;生成实现已被 JaCoCo 排除,不做映射细节断言。 + */ +class StudySessionConvertTest { + + @Test + void mapper_shouldBeInitializedAndConvertEntity() { + assertNotNull(StudySessionConvert.MAPPER); + + StudySessionsEntity entity = new StudySessionsEntity(); + entity.setSessionNum("SESSION_A"); + entity.setSessionState("ONGOING"); + + StudySessionResponse response = StudySessionConvert.MAPPER.toStudySessionResponse(entity); + + assertNotNull(response); + assertEquals("SESSION_A", response.getSessionNum()); + assertEquals("ONGOING", response.getSessionState()); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/service/impl/AiServiceClientTest.java b/src/test/java/com/guo/learningprogresstracker/service/impl/AiServiceClientTest.java new file mode 100644 index 0000000..b1a735e --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/service/impl/AiServiceClientTest.java @@ -0,0 +1,231 @@ +package com.guo.learningprogresstracker.service.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.guo.learningprogresstracker.support.TestHttpServer; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * AiServiceClient 单元测试:基于本地 TestHttpServer 覆盖异步 submit + poll 全部分支。 + */ +class AiServiceClientTest extends TestHttpServer { + + private AiServiceClient newClient(String url, int timeoutSeconds) { + AiServiceClient client = new AiServiceClient(); + client.setUrl(url); + client.setTimeoutSeconds(timeoutSeconds); + client.init(); + return client; + } + + // ============ 配置状态 ============ + + @Test + void isConfigured_dependsOnUrl() { + AiServiceClient client = new AiServiceClient(); + assertFalse(client.isConfigured()); + client.setUrl("http://localhost:5199"); + assertTrue(client.isConfigured()); + client.setUrl(" "); + assertFalse(client.isConfigured()); + // init 的两条日志分支(未配置 / 已配置) + client.init(); + client.setUrl(baseUrl()); + client.init(); + } + + @Test + void publicApis_returnEmptyWhenNotConfigured() { + AiServiceClient client = new AiServiceClient(); + client.setTimeoutSeconds(1); + client.init(); + assertTrue(client.aggregateReport("任务", List.of("片段"), null).isEmpty()); + assertTrue(client.generateMindMap("任务", "描述", List.of("报告")).isEmpty()); + assertTrue(client.compareRecall("任务", "标准大纲", "回忆大纲").isEmpty()); + } + + // ============ aggregateReport ============ + + @Test + void aggregateReport_returnsEmptyWhenFragmentsNullOrEmpty() { + AiServiceClient client = newClient(baseUrl(), 1); + assertTrue(client.aggregateReport("任务", null, null).isEmpty()); + assertTrue(client.aggregateReport("任务", List.of(), null).isEmpty()); + } + + @Test + void aggregateReport_success_coversExpectationBothBranches() { + route("POST", "/ai/tasks", 201, "{\"taskId\":\"t1\"}"); + route("GET", "/ai/tasks/t1", 200, "{\"status\":\"done\",\"result\":{\"report\":\"聚合报告内容\"}}"); + AiServiceClient client = newClient(baseUrl(), 2); + + // expectation 为 null → 参数不含 expectation + Optional noExpectation = client.aggregateReport("任务A", List.of("片段1"), null); + assertTrue(noExpectation.isPresent()); + assertEquals("聚合报告内容", noExpectation.get()); + + // expectation 非空 → 参数带 expectation + Optional withExpectation = client.aggregateReport("任务A", List.of("片段1"), "期望目标"); + assertTrue(withExpectation.isPresent()); + assertEquals("聚合报告内容", withExpectation.get()); + + assertEquals("application/json", requestHeader("POST", "/ai/tasks", "Content-Type")); + } + + // ============ generateMindMap ============ + + @Test + void generateMindMap_success_returnsOutline() { + route("POST", "/ai/tasks", 201, "{\"taskId\":\"t2\"}"); + route("GET", "/ai/tasks/t2", 200, + "{\"status\":\"done\",\"result\":{\"outline\":\"- 分支A\\n- 分支B\"}}"); + AiServiceClient client = newClient(baseUrl(), 2); + + Optional outline = client.generateMindMap("任务A", null, List.of("报告内容")); + assertTrue(outline.isPresent()); + assertEquals("- 分支A\n- 分支B", outline.get()); + } + + @Test + void generateMindMap_outlineMissing_returnsEmpty() { + route("POST", "/ai/tasks", 201, "{\"taskId\":\"t3\"}"); + route("GET", "/ai/tasks/t3", 200, "{\"status\":\"done\",\"result\":{}}"); + AiServiceClient client = newClient(baseUrl(), 2); + + assertTrue(client.generateMindMap("任务A", "描述", List.of("报告内容")).isEmpty()); + } + + @Test + void generateMindMap_outlineBlank_returnsEmpty() { + route("POST", "/ai/tasks", 201, "{\"taskId\":\"t4\"}"); + route("GET", "/ai/tasks/t4", 200, "{\"status\":\"done\",\"result\":{\"outline\":\" \"}}"); + AiServiceClient client = newClient(baseUrl(), 2); + + assertTrue(client.generateMindMap("任务A", "描述", List.of("报告内容")).isEmpty()); + } + + // ============ compareRecall ============ + + @Test + void compareRecall_success_returnsJsonResult() { + route("POST", "/ai/tasks", 201, "{\"taskId\":\"t5\"}"); + route("GET", "/ai/tasks/t5", 200, + "{\"status\":\"done\",\"result\":{\"matches\":[],\"evaluation\":\"ok\"}}"); + AiServiceClient client = newClient(baseUrl(), 2); + + Optional result = client.compareRecall("任务A", "标准大纲", "回忆大纲"); + assertTrue(result.isPresent()); + assertEquals("ok", result.get().path("evaluation").asText()); + } + + // ============ 提交阶段失败 ============ + + @Test + void submitTask_non201_returnsEmpty() { + route("POST", "/ai/tasks", 500, "server error body"); + AiServiceClient client = newClient(baseUrl(), 1); + + assertTrue(client.compareRecall("任务A", "标准大纲", "回忆大纲").isEmpty()); + } + + @Test + void submitTask_201WithoutTaskId_returnsEmpty() { + route("POST", "/ai/tasks", 201, "{\"message\":\"accepted\"}"); + AiServiceClient client = newClient(baseUrl(), 1); + + assertTrue(client.compareRecall("任务A", "标准大纲", "回忆大纲").isEmpty()); + } + + @Test + void submitTask_201BlankTaskId_returnsEmpty() { + route("POST", "/ai/tasks", 201, "{\"taskId\":\" \"}"); + AiServiceClient client = newClient(baseUrl(), 1); + + assertTrue(client.aggregateReport("任务A", List.of("片段"), null).isEmpty()); + } + + @Test + void submitTask_connectionRefused_returnsEmpty() { + AiServiceClient client = newClient("http://localhost:1", 1); + + assertTrue(client.generateMindMap("任务A", "描述", List.of("报告内容")).isEmpty()); + } + + // ============ 轮询阶段 ============ + + @Test + void pollTask_failed_returnsEmpty() { + route("POST", "/ai/tasks", 201, "{\"taskId\":\"t8\"}"); + route("GET", "/ai/tasks/t8", 200, "{\"status\":\"failed\",\"error\":\"模型超时\"}"); + AiServiceClient client = newClient(baseUrl(), 2); + + assertTrue(client.compareRecall("任务A", "标准大纲", "回忆大纲").isEmpty()); + } + + @Test + void pollTask_404_returnsEmpty() { + route("POST", "/ai/tasks", 201, "{\"taskId\":\"t9\"}"); + route("GET", "/ai/tasks/t9", 404, "{\"error\":\"not found\"}"); + AiServiceClient client = newClient(baseUrl(), 2); + + assertTrue(client.compareRecall("任务A", "标准大纲", "回忆大纲").isEmpty()); + } + + @Test + void pollTask_unknownStatus_returnsEmpty() { + route("POST", "/ai/tasks", 201, "{\"taskId\":\"t10\"}"); + route("GET", "/ai/tasks/t10", 200, "{\"status\":\"weird\"}"); + AiServiceClient client = newClient(baseUrl(), 2); + + assertTrue(client.compareRecall("任务A", "标准大纲", "回忆大纲").isEmpty()); + } + + @Test + void pollTask_pending_thenDeadlineExpires_returnsEmpty() { + route("POST", "/ai/tasks", 201, "{\"taskId\":\"t11\"}"); + route("GET", "/ai/tasks/t11", 200, "{\"status\":\"pending\"}"); + AiServiceClient client = newClient(baseUrl(), 1); + + long start = System.currentTimeMillis(); + Optional result = client.compareRecall("任务A", "标准大纲", "回忆大纲"); + long elapsed = System.currentTimeMillis() - start; + + assertTrue(result.isEmpty()); + // pending 后退避 2s,超过 1s 的 deadline 后退出 + assertTrue(elapsed >= 2000, "应等待退避后超时退出,实际 " + elapsed + "ms"); + assertTrue(elapsed < 10000, "不应无限轮询,实际 " + elapsed + "ms"); + } + + @Test + void pollTask_running_thenDeadlineExpires_returnsEmpty() { + route("POST", "/ai/tasks", 201, "{\"taskId\":\"t12\"}"); + route("GET", "/ai/tasks/t12", 200, "{\"status\":\"running\"}"); + AiServiceClient client = newClient(baseUrl(), 1); + + long start = System.currentTimeMillis(); + Optional result = client.aggregateReport("任务A", List.of("片段"), null); + long elapsed = System.currentTimeMillis() - start; + + assertTrue(result.isEmpty()); + assertTrue(elapsed >= 2000, "应等待退避后超时退出,实际 " + elapsed + "ms"); + } + + @Test + void pollTask_non200Continues_thenDeadlineExpires_returnsEmpty() { + route("POST", "/ai/tasks", 201, "{\"taskId\":\"t13\"}"); + route("GET", "/ai/tasks/t13", 503, "temporarily unavailable"); + AiServiceClient client = newClient(baseUrl(), 1); + + long start = System.currentTimeMillis(); + Optional result = client.compareRecall("任务A", "标准大纲", "回忆大纲"); + long elapsed = System.currentTimeMillis() - start; + + assertTrue(result.isEmpty()); + // 503 → 退避 2s → continue → deadline 过期退出 + assertTrue(elapsed >= 2000, "应等待退避后超时退出,实际 " + elapsed + "ms"); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/service/impl/BuiltinMindMapGeneratorTest.java b/src/test/java/com/guo/learningprogresstracker/service/impl/BuiltinMindMapGeneratorTest.java new file mode 100644 index 0000000..2a0b87d --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/service/impl/BuiltinMindMapGeneratorTest.java @@ -0,0 +1,198 @@ +package com.guo.learningprogresstracker.service.impl; + +import com.guo.learningprogresstracker.entity.StudyReportsEntity; +import com.guo.learningprogresstracker.entity.TaskApplicationEntity; +import com.guo.learningprogresstracker.entity.TaskEntity; +import com.guo.learningprogresstracker.utils.MindMapNode; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * BuiltinMindMapGenerator 单元测试:会话分组、标题截断、应用场景、同级去重。 + */ +class BuiltinMindMapGeneratorTest { + + private final BuiltinMindMapGenerator generator = new BuiltinMindMapGenerator(); + + private static TaskEntity task() { + TaskEntity task = new TaskEntity(); + task.setTaskNum("T9"); + task.setTaskName("学习Java"); + task.setTaskDescription("系统学习Java基础"); + return task; + } + + private static StudyReportsEntity report(String sessionNum, String content, Integer id) { + StudyReportsEntity report = new StudyReportsEntity(); + report.setSessionNum(sessionNum); + report.setContent(content); + report.setId(id); + return report; + } + + private static TaskApplicationEntity app(Integer id, String title, String description) { + TaskApplicationEntity application = new TaskApplicationEntity(); + application.setId(id); + application.setTitle(title); + application.setDescription(description); + return application; + } + + @Test + void isAvailable_alwaysTrue_andNameIsBuiltin() { + assertTrue(generator.isAvailable()); + assertEquals("BUILTIN", generator.generatorName()); + } + + @Test + void generate_nullOrEmptyReports_returnsEmpty() { + assertTrue(generator.generate(task(), null, List.of(), null).isEmpty()); + assertTrue(generator.generate(task(), List.of(), List.of(), null).isEmpty()); + } + + @Test + void generate_groupsBySession_withDatePrefixAndRootFallbackTitle() { + TaskEntity task = task(); + task.setTaskName(null); // → 兜底"学习任务" + task.setTaskDescription("任务描述内容"); + + StudyReportsEntity withDate = report("S1", "会话一的学习内容", 1); + withDate.setCreatedTime(LocalDateTime.of(2024, 5, 6, 10, 30)); + StudyReportsEntity noDate = report("S2", "会话二的学习内容", 2); + noDate.setCreatedTime(null); + + MindMapNode root = generator.generate(task, List.of(withDate, noDate), List.of(), null).get(); + + assertEquals("学习任务", root.getTitle()); + assertEquals("任务描述内容", root.getNotes()); + assertEquals(2, root.getChildren().size()); + + // 日期前缀分支 + MindMapNode dated = root.getChildren().stream() + .filter(n -> n.getTitle().startsWith("05-06")) + .findFirst().orElseThrow(); + assertEquals("05-06 会话一的学习内容", dated.getTitle()); + + // createdTime 为 null → 无前缀 + MindMapNode undated = root.getChildren().stream() + .filter(n -> !n.getTitle().startsWith("05-06")) + .findFirst().orElseThrow(); + assertEquals("会话二的学习内容", undated.getTitle()); + + // 报告子节点:title / notes / sourceType / sourceId + MindMapNode reportNode = dated.getChildren().get(0); + assertEquals("会话一的学习内容", reportNode.getTitle()); + assertEquals("会话一的学习内容", reportNode.getNotes()); + assertEquals("REPORT", reportNode.getSourceType()); + assertEquals(1, reportNode.getSourceId()); + } + + @Test + void generate_truncatesTitlesAt60CharsWithEllipsis() { + String exact60 = "界".repeat(60); + String long70 = "长".repeat(70); + StudyReportsEntity r1 = report("S1", exact60, 1); + StudyReportsEntity r2 = report("S1", long70, 2); + + MindMapNode root = generator.generate(task(), List.of(r1, r2), List.of(), null).get(); + + // 会话标题取第一条报告,恰好 60 字不截断 + MindMapNode session = root.getChildren().get(0); + assertEquals(exact60, session.getTitle()); + + assertEquals(exact60, session.getChildren().get(0).getTitle()); + // 超过 60 字 → 截断 + 省略号 + MindMapNode truncated = session.getChildren().get(1); + assertEquals("长".repeat(60) + "…", truncated.getTitle()); + assertEquals(61, truncated.getTitle().length()); + assertEquals(long70, truncated.getNotes()); + assertEquals("REPORT", truncated.getSourceType()); + assertEquals(2, truncated.getSourceId()); + } + + @Test + void generate_skipsNullOrBlankReportContent() { + StudyReportsEntity nullContent = report("S1", null, 1); + StudyReportsEntity blankContent = report("S1", " ", 2); + StudyReportsEntity valid = report("S1", "有效学习内容", 3); + + MindMapNode root = + generator.generate(task(), List.of(nullContent, blankContent, valid), List.of(), null).get(); + + MindMapNode session = root.getChildren().get(0); + // 会话标题取第一条报告内容,null → truncate 返回 "" + assertEquals("", session.getTitle()); + // null / 空白内容报告被跳过 + assertEquals(1, session.getChildren().size()); + assertEquals("有效学习内容", session.getChildren().get(0).getTitle()); + } + + @Test + void generate_applications_nullOrEmptyAddsNoBranch() { + MindMapNode withNull = + generator.generate(task(), List.of(report("S1", "内容", 1)), null, null).get(); + assertTrue(withNull.getChildren().stream().noneMatch(n -> "应用场景".equals(n.getTitle()))); + + MindMapNode withEmpty = + generator.generate(task(), List.of(report("S1", "内容", 1)), List.of(), null).get(); + assertTrue(withEmpty.getChildren().stream().noneMatch(n -> "应用场景".equals(n.getTitle()))); + } + + @Test + void generate_applications_skipsBlankTitle_nullDescriptionToEmptyNotes() { + TaskApplicationEntity nullTitle = app(1, null, "描述"); + TaskApplicationEntity blankTitle = app(2, " ", "描述"); + TaskApplicationEntity valid = app(3, "缓存设计", null); + + MindMapNode root = generator.generate(task(), + List.of(report("S1", "内容", 9)), List.of(nullTitle, blankTitle, valid), null).get(); + + MindMapNode appNode = root.getChildren().stream() + .filter(n -> "应用场景".equals(n.getTitle())) + .findFirst().orElseThrow(); + // title 为 null / 空白的应用被跳过 + assertEquals(1, appNode.getChildren().size()); + MindMapNode child = appNode.getChildren().get(0); + assertEquals("缓存设计", child.getTitle()); + // description 为 null → notes 为空串 + assertEquals("", child.getNotes()); + assertEquals("APPLICATION", child.getSourceType()); + assertEquals(3, child.getSourceId()); + } + + @Test + void generate_deduplicatesSameLevelTitles_keepsLongerNotes() { + // 两条报告标准化后同名:"重复" 与 "重复。" → 合并,notes 长者胜 + StudyReportsEntity r1 = report("S1", "重复", 1); + StudyReportsEntity r2 = report("S1", "重复。", 2); + + MindMapNode root = generator.generate(task(), List.of(r1, r2), List.of(), null).get(); + + MindMapNode session = root.getChildren().get(0); + assertEquals(1, session.getChildren().size()); + MindMapNode merged = session.getChildren().get(0); + assertEquals("重复", merged.getTitle()); + assertEquals("重复。", merged.getNotes()); + } + + @Test + void generate_deduplicatesSessionNodes_mergesChildren() { + // 两个会话首条报告内容相同 → 会话节点同名合并,报告子节点拼接 + StudyReportsEntity r1 = report("S1", "相同会话内容", 1); + StudyReportsEntity r2 = report("S2", "相同会话内容", 2); + + MindMapNode root = generator.generate(task(), List.of(r1, r2), List.of(), null).get(); + + assertEquals(1, root.getChildren().size()); + MindMapNode session = root.getChildren().get(0); + assertEquals("相同会话内容", session.getTitle()); + // 递归去重:拼接进来的两个同名报告节点再次合并为一个(notes 相同长度保留前者) + assertEquals(1, session.getChildren().size()); + assertEquals("相同会话内容", session.getChildren().get(0).getTitle()); + assertEquals("相同会话内容", session.getChildren().get(0).getNotes()); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/service/impl/LoginServiceImplTest.java b/src/test/java/com/guo/learningprogresstracker/service/impl/LoginServiceImplTest.java new file mode 100644 index 0000000..da59260 --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/service/impl/LoginServiceImplTest.java @@ -0,0 +1,92 @@ +package com.guo.learningprogresstracker.service.impl; + +import cn.dev33.satoken.SaManager; +import cn.dev33.satoken.context.SaTokenContext; +import cn.dev33.satoken.context.SaTokenContextForThreadLocal; +import cn.dev33.satoken.context.SaTokenContextForThreadLocalStorage; +import cn.dev33.satoken.context.model.SaRequest; +import cn.dev33.satoken.context.model.SaResponse; +import cn.dev33.satoken.context.model.SaStorage; +import cn.dev33.satoken.stp.StpUtil; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * LoginServiceImpl 单元测试:不启动 Spring,利用 Sa-Token 提供的 + * ThreadLocal 上下文(SaTokenContextForThreadLocal)伪造 Request/Response/Storage, + * 配合默认的内存版 SaTokenDao 覆盖 login/logout 两行逻辑。 + */ +class LoginServiceImplTest { + + private final LoginServiceImpl loginService = new LoginServiceImpl(); + private final SaTokenContextForThreadLocal threadLocalContext = new SaTokenContextForThreadLocal(); + private SaTokenContext previousContext; + + @BeforeEach + void setUp() { + previousContext = SaManager.getSaTokenContext(); + SaManager.setSaTokenContext(threadLocalContext); + SaRequest request = Mockito.mock(SaRequest.class); + SaResponse response = Mockito.mock(SaResponse.class); + SaTokenContextForThreadLocalStorage.setBox(request, response, new InMemoryStorage()); + } + + @AfterEach + void tearDown() { + SaTokenContextForThreadLocalStorage.clearBox(); + SaManager.setSaTokenContext(previousContext); + } + + /** + * 基于内存 Map 的 SaStorage 实现,供 Sa-Token 存取"本次会话刚创建的 token"标记。 + */ + static class InMemoryStorage implements SaStorage { + private final Map data = new HashMap<>(); + + @Override + public SaStorage set(String key, Object value) { + data.put(key, value); + return this; + } + + @Override + public Object get(String key) { + return data.get(key); + } + + @Override + public SaStorage delete(String key) { + data.remove(key); + return this; + } + + @Override + public Object getSource() { + return data; + } + } + + @Test + void login_shouldReturnTokenValue() throws Exception { + String token = loginService.login("user-1"); + + assertNotNull(token, "登录成功后应返回 token"); + assertEquals(token, StpUtil.getTokenInfo().getTokenValue()); + } + + @Test + void logout_shouldClearLoginState() throws Exception { + loginService.login("user-2"); + + loginService.logout(); + + assertFalse(StpUtil.isLogin(), "登出后不应再处于登录态"); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/service/impl/PriorityWeightsServiceImplTest.java b/src/test/java/com/guo/learningprogresstracker/service/impl/PriorityWeightsServiceImplTest.java new file mode 100644 index 0000000..165dbc6 --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/service/impl/PriorityWeightsServiceImplTest.java @@ -0,0 +1,152 @@ +package com.guo.learningprogresstracker.service.impl; + +import com.guo.learningprogresstracker.entity.TaskEntity; +import com.guo.learningprogresstracker.entity.UserPriorityWeightsEntity; +import com.guo.learningprogresstracker.exception.ErrorParameterException; +import com.guo.learningprogresstracker.mapper.TasksMapper; +import com.guo.learningprogresstracker.mapper.UserPriorityWeightsMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * PriorityWeightsServiceImpl 单元测试:纯 Mockito。 + * 覆盖 get/update 权重的全部分支:无记录默认、新建、更新并重算、校验失败等。 + */ +@ExtendWith(MockitoExtension.class) +class PriorityWeightsServiceImplTest { + + @InjectMocks + private PriorityWeightsServiceImpl priorityWeightsService; + + @Mock + private UserPriorityWeightsMapper weightsMapper; + + @Mock + private TasksMapper tasksMapper; + + @Test + void getWeights_existing_shouldReturnStored() { + UserPriorityWeightsEntity stored = UserPriorityWeightsEntity.defaults(); + when(weightsMapper.selectOne(any())).thenReturn(stored); + + assertSame(stored, priorityWeightsService.getWeights()); + } + + @Test + void getWeights_missing_shouldReturnDefaults() { + when(weightsMapper.selectOne(any())).thenReturn(null); + + UserPriorityWeightsEntity weights = priorityWeightsService.getWeights(); + + assertEquals(0.35, weights.getUrgencyWeight(), 0.0001); + assertEquals(0.25, weights.getImportanceWeight(), 0.0001); + assertEquals(0.20, weights.getContentDifficultyWeight(), 0.0001); + assertEquals(0.10, weights.getFutureValueWeight(), 0.0001); + assertEquals(0.10, weights.getSubjectivePriorityWeight(), 0.0001); + } + + /** + * 无既有记录 → insert 新建,任务列表为空时不触发重算循环体 + */ + @Test + void saveWeights_noExistingRecord_shouldInsert() throws Exception { + UserPriorityWeightsEntity weights = UserPriorityWeightsEntity.defaults(); + when(weightsMapper.selectOne(any())).thenReturn(null); + when(tasksMapper.selectList(any())).thenReturn(List.of()); + + UserPriorityWeightsEntity result = priorityWeightsService.saveWeights(weights); + + assertSame(weights, result); + verify(weightsMapper).insert(weights); + verify(weightsMapper, never()).updateById(any(UserPriorityWeightsEntity.class)); + verify(tasksMapper, never()).updateById(any(TaskEntity.class)); + } + + /** + * 已有记录 → 复用其 id 更新,并用新权重重算全部任务(覆盖维度为 null 与非 null 的 orZero 分支) + */ + @Test + void saveWeights_existingRecord_shouldUpdateAndRecalculateAllTasks() throws Exception { + UserPriorityWeightsEntity existing = UserPriorityWeightsEntity.defaults(); + existing.setId(7); + when(weightsMapper.selectOne(any())).thenReturn(existing); + + TaskEntity allNullDims = new TaskEntity(); + allNullDims.setId(1); + TaskEntity fullDims = new TaskEntity(); + fullDims.setId(2); + fullDims.setUrgency(2); + fullDims.setImportance(3); + fullDims.setContentDifficulty(4); + fullDims.setFutureValue(5); + fullDims.setSubjectivePriority(1); + when(tasksMapper.selectList(any())).thenReturn(Arrays.asList(allNullDims, fullDims)); + + UserPriorityWeightsEntity weights = UserPriorityWeightsEntity.defaults(); + UserPriorityWeightsEntity result = priorityWeightsService.saveWeights(weights); + + assertSame(weights, result); + assertEquals(Integer.valueOf(7), result.getId()); + verify(weightsMapper).updateById(weights); + verify(weightsMapper, never()).insert(any(UserPriorityWeightsEntity.class)); + // 全 null 维度按 0 计算优先级 + assertEquals(0.0, allNullDims.getCalculatedPriority(), 0.0001); + // 2*0.35 + 3*0.25 + 4*0.20 + 5*0.10 + 1*0.10 = 2.85 + assertEquals(2.85, fullDims.getCalculatedPriority(), 0.0001); + verify(tasksMapper).updateById(allNullDims); + verify(tasksMapper).updateById(fullDims); + } + + /** + * 权重超过 1 → 校验失败 + */ + @Test + void saveWeights_weightAboveOne_shouldThrow() { + UserPriorityWeightsEntity weights = UserPriorityWeightsEntity.defaults(); + weights.setUrgencyWeight(1.5); + + ErrorParameterException ex = assertThrows(ErrorParameterException.class, + () -> priorityWeightsService.saveWeights(weights)); + + assertEquals("权重必须在 0 到 1 之间", ex.getMessage()); + } + + /** + * 负权重 → 校验失败 + */ + @Test + void saveWeights_negativeWeight_shouldThrow() { + UserPriorityWeightsEntity weights = UserPriorityWeightsEntity.defaults(); + weights.setImportanceWeight(-0.1); + + ErrorParameterException ex = assertThrows(ErrorParameterException.class, + () -> priorityWeightsService.saveWeights(weights)); + + assertEquals("权重必须在 0 到 1 之间", ex.getMessage()); + } + + /** + * 全部权重为 null → orZero 兜底为 0,总和为 0 ≠ 1 → 抛出求和校验 + */ + @Test + void saveWeights_allNullWeights_shouldThrowSumError() { + UserPriorityWeightsEntity weights = new UserPriorityWeightsEntity(); + + ErrorParameterException ex = assertThrows(ErrorParameterException.class, + () -> priorityWeightsService.saveWeights(weights)); + + assertTrue(ex.getMessage().startsWith("五项权重之和必须为 1,当前为 ")); + verify(weightsMapper, never()).insert(any(UserPriorityWeightsEntity.class)); + verify(weightsMapper, never()).updateById(any(UserPriorityWeightsEntity.class)); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/service/impl/RemoteAiMindMapClientTest.java b/src/test/java/com/guo/learningprogresstracker/service/impl/RemoteAiMindMapClientTest.java new file mode 100644 index 0000000..a474252 --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/service/impl/RemoteAiMindMapClientTest.java @@ -0,0 +1,120 @@ +package com.guo.learningprogresstracker.service.impl; + +import com.guo.learningprogresstracker.entity.StudyReportsEntity; +import com.guo.learningprogresstracker.entity.TaskEntity; +import com.guo.learningprogresstracker.utils.MindMapNode; +import com.guo.learningprogresstracker.utils.MindMapTreeTool; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * RemoteAiMindMapClient 单元测试:mock AiServiceClient,验证降级与大纲解析。 + */ +@ExtendWith(MockitoExtension.class) +class RemoteAiMindMapClientTest { + + @Mock + private AiServiceClient aiServiceClient; + + @InjectMocks + private RemoteAiMindMapClient client; + + private static TaskEntity task() { + TaskEntity task = new TaskEntity(); + task.setTaskNum("T1"); + task.setTaskName("学习Java入门"); + task.setTaskDescription("Java 基础学习描述"); + return task; + } + + private static StudyReportsEntity report(String content) { + StudyReportsEntity report = new StudyReportsEntity(); + report.setSessionNum("S1"); + report.setContent(content); + report.setId(1); + return report; + } + + @Test + void isAvailable_followsAiConfiguration() { + when(aiServiceClient.isConfigured()).thenReturn(true); + assertTrue(client.isAvailable()); + when(aiServiceClient.isConfigured()).thenReturn(false); + assertFalse(client.isAvailable()); + } + + @Test + void generatorName_isAi() { + assertEquals("AI", client.generatorName()); + } + + @Test + void generate_notAvailable_returnsEmpty() { + when(aiServiceClient.isConfigured()).thenReturn(false); + Optional result = client.generate(task(), List.of(report("内容")), List.of(), null); + assertTrue(result.isEmpty()); + verify(aiServiceClient, never()).generateMindMap(any(), any(), any()); + } + + @Test + void generate_reportsAllBlank_returnsEmpty() { + when(aiServiceClient.isConfigured()).thenReturn(true); + StudyReportsEntity nullContent = report(null); + StudyReportsEntity blankContent = report(" "); + + Optional result = + client.generate(task(), List.of(nullContent, blankContent), List.of(), null); + + assertTrue(result.isEmpty()); + verify(aiServiceClient, never()).generateMindMap(any(), any(), any()); + } + + @Test + void generate_aiReturnsEmpty_returnsEmpty() { + when(aiServiceClient.isConfigured()).thenReturn(true); + when(aiServiceClient.generateMindMap(any(), any(), any())).thenReturn(Optional.empty()); + + Optional result = client.generate(task(), List.of(report("内容")), List.of(), null); + + assertTrue(result.isEmpty()); + } + + @Test + void generate_aiReturnsBlankOutline_returnsEmpty() { + when(aiServiceClient.isConfigured()).thenReturn(true); + when(aiServiceClient.generateMindMap(any(), any(), any())).thenReturn(Optional.of(" ")); + + Optional result = client.generate(task(), List.of(report("内容")), List.of(), null); + + assertTrue(result.isEmpty()); + } + + @Test + void generate_success_parsesOutlineToTree() { + when(aiServiceClient.isConfigured()).thenReturn(true); + when(aiServiceClient.generateMindMap(any(), any(), any())) + .thenReturn(Optional.of("学习Java\n- 线程基础\n - 线程安全")); + + Optional result = + client.generate(task(), List.of(report("报告内容")), List.of(), "前端已有大纲"); + + assertTrue(result.isPresent()); + MindMapNode root = result.get(); + assertEquals("学习Java", root.getTitle()); + assertEquals(1, root.getChildren().size()); + assertEquals("线程基础", root.getChildren().get(0).getTitle()); + assertEquals("线程安全", root.getChildren().get(0).getChildren().get(0).getTitle()); + assertEquals(3, MindMapTreeTool.countNodes(root)); + assertEquals(3, MindMapTreeTool.maxDepth(root)); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/service/impl/StudyExpectationsServiceImplTest.java b/src/test/java/com/guo/learningprogresstracker/service/impl/StudyExpectationsServiceImplTest.java new file mode 100644 index 0000000..c49f97f --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/service/impl/StudyExpectationsServiceImplTest.java @@ -0,0 +1,108 @@ +package com.guo.learningprogresstracker.service.impl; + +import com.guo.learningprogresstracker.entity.StudyExpectationsEntity; +import com.guo.learningprogresstracker.exception.ErrorParameterException; +import com.guo.learningprogresstracker.mapper.StudyExpectationsMapper; +import com.guo.learningprogresstracker.mapper.StudySessionsMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * StudyExpectationsServiceImpl 单元测试:纯 Mockito,不连数据库。 + * 该服务为普通类构造注入两个 Mapper,直接 @InjectMocks 即可。 + */ +@ExtendWith(MockitoExtension.class) +class StudyExpectationsServiceImplTest { + + @InjectMocks + private StudyExpectationsServiceImpl studyExpectationsService; + + @Mock + private StudyExpectationsMapper studyExpectationsMapper; + + @Mock + private StudySessionsMapper studySessionsMapper; + + /** + * 场景1:会话不存在 → 抛出,不写库 + */ + @Test + void upsertExpectation_sessionMissing_shouldThrow() { + when(studySessionsMapper.exists(any())).thenReturn(false); + + ErrorParameterException ex = assertThrows(ErrorParameterException.class, + () -> studyExpectationsService.upsertExpectation("NOT_EXIST", "预期")); + + assertEquals("这次学习会话不存在或已结束", ex.getMessage()); + verify(studyExpectationsMapper, never()).insert(any(StudyExpectationsEntity.class)); + verify(studyExpectationsMapper, never()).updateById(any(StudyExpectationsEntity.class)); + } + + /** + * 场景2:尚无预期记录 → 插入新记录并返回 + */ + @Test + void upsertExpectation_noExistingRecord_shouldInsert() throws Exception { + when(studySessionsMapper.exists(any())).thenReturn(true); + when(studyExpectationsMapper.selectOne(any())).thenReturn(null); + + StudyExpectationsEntity result = studyExpectationsService.upsertExpectation("SESSION_A", "掌握微积分"); + + assertEquals("SESSION_A", result.getSessionNum()); + assertEquals("掌握微积分", result.getDescription()); + verify(studyExpectationsMapper).insert(result); + verify(studyExpectationsMapper, never()).updateById(any(StudyExpectationsEntity.class)); + } + + /** + * 场景3:已有预期记录 → 更新描述并返回原实体 + */ + @Test + void upsertExpectation_existingRecord_shouldUpdate() throws Exception { + when(studySessionsMapper.exists(any())).thenReturn(true); + StudyExpectationsEntity existing = new StudyExpectationsEntity(); + existing.setExpectationId(3); + existing.setSessionNum("SESSION_A"); + existing.setDescription("旧预期"); + when(studyExpectationsMapper.selectOne(any())).thenReturn(existing); + + StudyExpectationsEntity result = studyExpectationsService.upsertExpectation("SESSION_A", "新预期"); + + assertSame(existing, result); + assertEquals("新预期", result.getDescription()); + verify(studyExpectationsMapper).updateById(existing); + verify(studyExpectationsMapper, never()).insert(any(StudyExpectationsEntity.class)); + } + + /** + * 场景4:按会话编号查询 → 透传 mapper 结果 + */ + @Test + void getBySessionNum_shouldReturnMapperResult() { + StudyExpectationsEntity entity = new StudyExpectationsEntity(); + entity.setSessionNum("SESSION_A"); + entity.setDescription("预期内容"); + when(studyExpectationsMapper.selectOne(any())).thenReturn(entity); + + StudyExpectationsEntity result = studyExpectationsService.getBySessionNum("SESSION_A"); + + assertSame(entity, result); + } + + /** + * 场景5:按会话编号删除 → 委托 mapper.delete + */ + @Test + void deleteBySessionNum_shouldDelegateToDelete() { + studyExpectationsService.deleteBySessionNum("SESSION_A"); + + verify(studyExpectationsMapper).delete(any()); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/service/impl/StudyReportFragmentsServiceImplTest.java b/src/test/java/com/guo/learningprogresstracker/service/impl/StudyReportFragmentsServiceImplTest.java new file mode 100644 index 0000000..e1f79bd --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/service/impl/StudyReportFragmentsServiceImplTest.java @@ -0,0 +1,126 @@ +package com.guo.learningprogresstracker.service.impl; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.guo.learningprogresstracker.dto.request.CreateFragmentsRequest; +import com.guo.learningprogresstracker.dto.request.UpdateFragmentsRequest; +import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity; +import com.guo.learningprogresstracker.entity.StudySessionsEntity; +import com.guo.learningprogresstracker.exception.NotFindEntitiesException; +import com.guo.learningprogresstracker.mapper.StudySessionsMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * StudyReportFragmentsServiceImpl 单元测试:继承 ServiceImpl, + * 参考 StudySessionsServiceImplTest 的手法对 spy 打桩 save/updateById/getById/list, + * 避免 baseMapper 为空引发 NPE。 + */ +@ExtendWith(MockitoExtension.class) +class StudyReportFragmentsServiceImplTest { + + @Spy + @InjectMocks + private StudyReportFragmentsServiceImpl studyReportFragmentsService; + + @Mock + private StudySessionsMapper studySessionsMapper; + + private CreateFragmentsRequest createRequest(String sessionNum, String content) { + CreateFragmentsRequest request = new CreateFragmentsRequest(); + request.setSessionNum(sessionNum); + request.setContent(content); + return request; + } + + /** + * 场景1:会话不存在 → 抛出,不落库 + */ + @Test + void createFragments_sessionMissing_shouldThrow() { + // studySessionsMapper.selectOne 未打桩时默认返回 null,即查不到会话 + + NotFindEntitiesException ex = assertThrows(NotFindEntitiesException.class, + () -> studyReportFragmentsService.createFragments(createRequest("NOT_EXIST", "内容"))); + + assertEquals("这次学习会话不存在或已结束", ex.getMessage()); + verify(studyReportFragmentsService, never()).save(any(StudyReportFragmentsEntity.class)); + } + + /** + * 场景2:会话存在 → 转换请求并保存残片 + */ + @Test + void createFragments_success_shouldSaveEntity() throws Exception { + StudySessionsEntity session = new StudySessionsEntity(); + session.setSessionNum("SESSION_A"); + when(studySessionsMapper.selectOne(any())).thenReturn(session); + doReturn(true).when(studyReportFragmentsService).save(any(StudyReportFragmentsEntity.class)); + + studyReportFragmentsService.createFragments(createRequest("SESSION_A", "今天学了导数")); + + verify(studyReportFragmentsService).save(argThat(entity -> + "SESSION_A".equals(entity.getSessionNum()) + && "今天学了导数".equals(entity.getContent()))); + } + + /** + * 场景3:残片不存在 → 抛出 + */ + @Test + void updateFragments_missing_shouldThrow() { + doReturn(null).when(studyReportFragmentsService).getById(any()); + + NotFindEntitiesException ex = assertThrows(NotFindEntitiesException.class, + () -> studyReportFragmentsService.updateFragments(9, new UpdateFragmentsRequest())); + + assertEquals("这条学习残片不存在或已被删除", ex.getMessage()); + } + + /** + * 场景4:残片存在 → 以指定 id 更新内容 + */ + @Test + void updateFragments_success_shouldUpdateWithId() throws Exception { + StudyReportFragmentsEntity existing = new StudyReportFragmentsEntity(); + existing.setId(9); + existing.setContent("旧内容"); + doReturn(existing).when(studyReportFragmentsService).getById(any()); + doReturn(true).when(studyReportFragmentsService).updateById(any(StudyReportFragmentsEntity.class)); + UpdateFragmentsRequest request = new UpdateFragmentsRequest(); + request.setContent("更新后的内容"); + + studyReportFragmentsService.updateFragments(9, request); + + verify(studyReportFragmentsService).updateById(argThat(entity -> + Integer.valueOf(9).equals(entity.getId()) + && "更新后的内容".equals(entity.getContent()))); + } + + /** + * 场景5:按会话查询残片列表 → 透传 list 结果 + */ + @Test + void getFragmentsBySession_shouldReturnList() { + StudyReportFragmentsEntity f1 = new StudyReportFragmentsEntity(); + f1.setContent("残片1"); + StudyReportFragmentsEntity f2 = new StudyReportFragmentsEntity(); + f2.setContent("残片2"); + doReturn(List.of(f1, f2)).when(studyReportFragmentsService).list(any(Wrapper.class)); + + List result = studyReportFragmentsService.getFragmentsBySession("SESSION_A"); + + assertEquals(2, result.size()); + assertEquals("残片1", result.get(0).getContent()); + assertEquals("残片2", result.get(1).getContent()); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/service/impl/TestTableServiceImplTest.java b/src/test/java/com/guo/learningprogresstracker/service/impl/TestTableServiceImplTest.java new file mode 100644 index 0000000..c653249 --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/service/impl/TestTableServiceImplTest.java @@ -0,0 +1,36 @@ +package com.guo.learningprogresstracker.service.impl; + +import com.guo.learningprogresstracker.entity.TestTableEntity; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; + +/** + * TestTableServiceImpl 单元测试:服务仅一个透传查询,覆盖 getById 透传路径。 + */ +@ExtendWith(MockitoExtension.class) +class TestTableServiceImplTest { + + @Spy + @InjectMocks + private TestTableServiceImpl testTableService; + + @Test + void getTestEntityById_shouldReturnMapperResult() { + TestTableEntity entity = new TestTableEntity(); + entity.setId(1); + entity.setIdName("测试记录"); + doReturn(entity).when(testTableService).getById(any()); + + TestTableEntity result = testTableService.getTestEntityById("1"); + + assertSame(entity, result); + assertEquals("测试记录", result.getIdName()); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/service/impl/UserServiceImplTest.java b/src/test/java/com/guo/learningprogresstracker/service/impl/UserServiceImplTest.java new file mode 100644 index 0000000..17763a9 --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/service/impl/UserServiceImplTest.java @@ -0,0 +1,61 @@ +package com.guo.learningprogresstracker.service.impl; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.guo.learningprogresstracker.entity.UserEntity; +import com.guo.learningprogresstracker.exception.AppException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mindrot.jbcrypt.BCrypt; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; + +/** + * UserServiceImpl 单元测试:覆盖认证的用户不存在、密码错误、密码正确三条路径。 + */ +@ExtendWith(MockitoExtension.class) +class UserServiceImplTest { + + @Spy + @InjectMocks + private UserServiceImpl userService; + + private UserEntity userWithPassword(String rawPassword) { + UserEntity user = new UserEntity(); + user.setId("user-1"); + user.setUserName("admin"); + user.setUserPassword(BCrypt.hashpw(rawPassword, BCrypt.gensalt())); + return user; + } + + @Test + void authenticate_userMissing_shouldThrow() { + doReturn(Optional.empty()).when(userService).getOneOpt(any(Wrapper.class)); + + AppException ex = assertThrows(AppException.class, () -> userService.authenticate("ghost", "pw")); + + assertEquals("账号或密码错误!", ex.getMessage()); + } + + @Test + void authenticate_wrongPassword_shouldThrow() { + doReturn(Optional.of(userWithPassword("correct-pw"))).when(userService).getOneOpt(any(Wrapper.class)); + + AppException ex = assertThrows(AppException.class, () -> userService.authenticate("admin", "wrong-pw")); + + assertEquals("账号或密码错误!", ex.getMessage()); + } + + @Test + void authenticate_correctPassword_shouldReturnUserId() throws Exception { + doReturn(Optional.of(userWithPassword("correct-pw"))).when(userService).getOneOpt(any(Wrapper.class)); + + assertEquals("user-1", userService.authenticate("admin", "correct-pw")); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/support/TestHttpServer.java b/src/test/java/com/guo/learningprogresstracker/support/TestHttpServer.java new file mode 100644 index 0000000..f45e829 --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/support/TestHttpServer.java @@ -0,0 +1,124 @@ +package com.guo.learningprogresstracker.support; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.List; +import java.util.Map; +import java.util.zip.GZIPOutputStream; + +/** + * 测试用本地 HTTP 服务器(JDK 内置 HttpServer),用于覆盖 TitleFetcher / AiServiceClient 等网络代码。 + * 每个测试方法独立启停,端口随机,路由按注册顺序匹配(前缀匹配)。 + */ +public class TestHttpServer { + + private HttpServer server; + private final List routes = new ArrayList<>(); + /** 请求头按 "METHOD path" 记录(key 小写化),同名后续请求覆盖。 */ + private final Map> requestHeadersByPath = new LinkedHashMap<>(); + private final Map lastPaths = new LinkedHashMap<>(); + private final Map lastQueries = new LinkedHashMap<>(); + private String lastRequestKey; + + private record Route(String method, String pathPrefix, int status, byte[] body, + Map headers) { + } + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("localhost", 0), 0); + server.createContext("/", exchange -> { + String path = exchange.getRequestURI().getPath(); + String method = exchange.getRequestMethod(); + Map lowercaseHeaders = new LinkedHashMap<>(); + exchange.getRequestHeaders().forEach((k, v) -> lowercaseHeaders.put(k.toLowerCase(Locale.ROOT), String.join(",", v))); + lastRequestKey = method + " " + path; + requestHeadersByPath.put(lastRequestKey, lowercaseHeaders); + lastPaths.put(method + " " + path, path); + lastQueries.put(method + " " + path, + exchange.getRequestURI().getQuery() == null ? "" : exchange.getRequestURI().getQuery()); + for (Route route : routes) { + if (route.method.equals(method) && path.startsWith(route.pathPrefix)) { + if (exchange.getRequestHeaders().getFirst("Content-Length") != null) { + exchange.getRequestBody().readAllBytes(); + } + byte[] body = route.body(); + Map headers = new LinkedHashMap<>(route.headers()); + boolean gzip = "gzip".equals(headers.remove("Content-Encoding-Test")); + if (gzip) { + headers.put("Content-Encoding", "gzip"); + java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); + try (GZIPOutputStream gz = new GZIPOutputStream(bos)) { + gz.write(body); + } + body = bos.toByteArray(); + } + headers.forEach(exchange.getResponseHeaders()::set); + if (route.status() >= 300 && route.status() < 400) { + exchange.getResponseHeaders().set("Location", headers.getOrDefault("Location", "/redirected")); + } + exchange.sendResponseHeaders(route.status(), body.length == 0 ? -1 : body.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(body); + } + return; + } + } + exchange.sendResponseHeaders(404, -1); + }); + server.start(); + } + + @AfterEach + void stopServer() { + if (server != null) { + server.stop(0); + } + } + + /** 注册路由;body 为响应体。 */ + public void route(String method, String pathPrefix, int status, String body) { + route(method, pathPrefix, status, body, Map.of()); + } + + /** 注册路由;headers 里 key=Content-Encoding-Test,value=gzip 表示用 gzip 压缩响应体。 */ + public void route(String method, String pathPrefix, int status, String body, Map headers) { + routes.add(new Route(method, pathPrefix, status, body.getBytes(StandardCharsets.UTF_8), headers)); + } + + /** 服务根地址,如 http://localhost:12345 */ + public String baseUrl() { + return "http://localhost:" + server.getAddress().getPort(); + } + + /** 最近一次请求的指定请求头值(不区分大小写)。 */ + public String lastRequestHeader(String name) { + Map headers = requestHeadersByPath.get(lastRequestKey); + return headers == null ? null : headers.get(name.toLowerCase(Locale.ROOT)); + } + + /** 最近一次请求是否包含指定请求头(不区分大小写)。 */ + public boolean hasRequestHeader(String name) { + return lastRequestHeader(name) != null; + } + + /** 指定 "METHOD path" 的请求头值(不区分大小写;该路径尚未请求过返回 null)。 */ + public String requestHeader(String method, String path, String name) { + Map headers = requestHeadersByPath.get(method + " " + path); + return headers == null ? null : headers.get(name.toLowerCase(Locale.ROOT)); + } + + /** 最近一次匹配路由的请求查询串。 */ + public String lastQuery(String method, String path) { + return lastQueries.get(method + " " + path); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/support/TestHttpsServer.java b/src/test/java/com/guo/learningprogresstracker/support/TestHttpsServer.java new file mode 100644 index 0000000..473da67 --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/support/TestHttpsServer.java @@ -0,0 +1,80 @@ +package com.guo.learningprogresstracker.support; + +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.security.KeyStore; +import java.util.ArrayList; +import java.util.List; + +/** + * 测试用本地 HTTPS 服务器,使用 src/test/resources/test-keystore.p12 中的自签名证书。 + * 客户端(TitleFetcher 的信任所有证书逻辑)与之完成真实 TLS 握手,可覆盖信任管理器代码路径。 + */ +public class TestHttpsServer { + + private HttpsServer server; + private final List routes = new ArrayList<>(); + + private record Route(String pathPrefix, int status, byte[] body, String contentType) { + } + + @BeforeEach + void startServer() throws Exception { + KeyStore ks = KeyStore.getInstance("PKCS12"); + try (InputStream in = getClass().getResourceAsStream("/test-keystore.p12")) { + ks.load(in, "changeit".toCharArray()); + } + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(ks, "changeit".toCharArray()); + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(kmf.getKeyManagers(), null, null); + + server = HttpsServer.create(new InetSocketAddress("localhost", 0), 0); + server.setHttpsConfigurator(new HttpsConfigurator(sslContext)); + server.createContext("/", exchange -> { + String path = exchange.getRequestURI().getPath(); + for (Route route : routes) { + if (path.startsWith(route.pathPrefix())) { + exchange.getResponseHeaders().set("Content-Type", route.contentType()); + exchange.sendResponseHeaders(route.status(), route.body().length == 0 ? -1 : route.body().length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(route.body()); + } + return; + } + } + exchange.sendResponseHeaders(404, -1); + }); + server.start(); + } + + @AfterEach + void stopServer() { + if (server != null) { + server.stop(0); + } + } + + public void route(String pathPrefix, int status, String body, String contentType) { + routes.add(new Route(pathPrefix, status, body.getBytes(StandardCharsets.UTF_8), contentType)); + } + + public void html(String pathPrefix, String title) { + route(pathPrefix, 200, "" + title + "hi", "text/html; charset=utf-8"); + } + + /** 服务 HTTPS 根地址,如 https://localhost:12345 */ + public String baseUrl() { + return "https://localhost:" + server.getAddress().getPort(); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/utils/CalculatedPriorityToolTest.java b/src/test/java/com/guo/learningprogresstracker/utils/CalculatedPriorityToolTest.java new file mode 100644 index 0000000..d82f9e2 --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/utils/CalculatedPriorityToolTest.java @@ -0,0 +1,38 @@ +package com.guo.learningprogresstracker.utils; + +import com.guo.learningprogresstracker.dto.PriorityDto; +import com.guo.learningprogresstracker.entity.UserPriorityWeightsEntity; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class CalculatedPriorityToolTest { + + private PriorityDto dto(int urgency, int importance, int difficulty, int future, int subjective) { + PriorityDto dto = new PriorityDto(); + dto.setUrgency(urgency); + dto.setImportance(importance); + dto.setContentDifficulty(difficulty); + dto.setFutureValue(future); + dto.setSubjectivePriority(subjective); + return dto; + } + + @Test + void calculatedPriority_withDefaultWeights() { + // 默认权重:10*0.35 + 8*0.25 + 5*0.20 + 7*0.10 + 9*0.10 = 8.1 + assertEquals(8.1, CalculatedPriorityTool.calculatedPriority(dto(10, 8, 5, 7, 9)), 1e-9); + } + + @Test + void calculatedPriority_withCustomWeights() { + UserPriorityWeightsEntity weights = new UserPriorityWeightsEntity(); + weights.setUrgencyWeight(1.0); + weights.setImportanceWeight(1.0); + weights.setContentDifficultyWeight(1.0); + weights.setFutureValueWeight(1.0); + weights.setSubjectivePriorityWeight(1.0); + // 权重全为 1 时等于各维度求和:10+8+5+7+9 = 39 + assertEquals(39.0, CalculatedPriorityTool.calculatedPriority(dto(10, 8, 5, 7, 9), weights), 1e-9); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/utils/GenerateNumToolTest.java b/src/test/java/com/guo/learningprogresstracker/utils/GenerateNumToolTest.java new file mode 100644 index 0000000..0741484 --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/utils/GenerateNumToolTest.java @@ -0,0 +1,27 @@ +package com.guo.learningprogresstracker.utils; + +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GenerateNumToolTest { + + @Test + void generateNum_defaultSeparator_prefixDateSequence() { + String today = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); + String code = GenerateNumTool.generateNum("ORD"); + assertTrue(code.startsWith("ORD-" + today + "-"), "实际值: " + code); + // 序列为毫秒时间戳对 1_000_000 取模,最多 6 位数字 + assertTrue(code.matches("ORD-" + today + "-\\d{1,6}"), "实际值: " + code); + } + + @Test + void generateNum_customSeparator_prefixDateSequence() { + String today = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); + String code = GenerateNumTool.generateNum("BIZ", "_"); + assertTrue(code.matches("BIZ_" + today + "_\\d{1,6}"), "实际值: " + code); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/utils/MindMapNodeTest.java b/src/test/java/com/guo/learningprogresstracker/utils/MindMapNodeTest.java new file mode 100644 index 0000000..9f6b5f3 --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/utils/MindMapNodeTest.java @@ -0,0 +1,155 @@ +package com.guo.learningprogresstracker.utils; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MindMapNodeTest { + + @Test + void noArgConstructor_leavesFieldsNull() { + MindMapNode n = new MindMapNode(); + assertNull(n.getTitle()); + assertNull(n.getNotes()); + assertNull(n.getSourceType()); + assertNull(n.getSourceId()); + assertNull(n.getChildren()); + } + + @Test + void titleConstructor_setsEmptyNotesAndMutableChildren() { + MindMapNode n = new MindMapNode("标题"); + assertEquals("标题", n.getTitle()); + assertEquals("", n.getNotes()); + assertNotNull(n.getChildren()); + assertTrue(n.getChildren().isEmpty()); + } + + @Test + void toMap_nullScalarsBecomeEmptyString_andOptionalKeysOmitted() { + Map map = new MindMapNode().toMap(); + assertEquals("", map.get("title")); + assertEquals("", map.get("notes")); + assertFalse(map.containsKey("sourceType")); + assertFalse(map.containsKey("sourceId")); + assertEquals(List.of(), map.get("children")); + } + + @Test + @SuppressWarnings("unchecked") + void toMap_withScalarsAndNestedChildren() { + MindMapNode n = new MindMapNode("父"); + n.setNotes("备注"); + n.setSourceType("REPORT"); + n.setSourceId(9); + n.setChildren(new ArrayList<>(List.of(new MindMapNode("子")))); + + Map map = n.toMap(); + assertEquals("父", map.get("title")); + assertEquals("备注", map.get("notes")); + assertEquals("REPORT", map.get("sourceType")); + assertEquals(Integer.valueOf(9), map.get("sourceId")); + + List> children = (List>) map.get("children"); + assertEquals(1, children.size()); + assertEquals("子", children.get(0).get("title")); + } + + @Test + void fromMap_supportsBothChildrenAndNodesKeys() { + Map viaChildren = Map.of( + "title", "父", + "children", List.of(Map.of("title", "子1"))); + MindMapNode a = MindMapNode.fromMap(viaChildren); + assertEquals("父", a.getTitle()); + assertEquals("子1", a.getChildren().get(0).getTitle()); + + Map viaNodes = Map.of( + "title", "父", + "nodes", List.of(Map.of("title", "子2"))); + MindMapNode b = MindMapNode.fromMap(viaNodes); + assertEquals("子2", b.getChildren().get(0).getTitle()); + } + + @Test + void fromMap_convertsNumericSourceId_andDefaults() { + Map map = new HashMap<>(); + map.put("title", "t"); + map.put("notes", "笔记"); + map.put("sourceType", "TASK"); + map.put("sourceId", 42L); // Number 统一转 Integer + + MindMapNode n = MindMapNode.fromMap(map); + assertEquals("t", n.getTitle()); + assertEquals("笔记", n.getNotes()); + assertEquals("TASK", n.getSourceType()); + assertEquals(Integer.valueOf(42), n.getSourceId()); + assertTrue(n.getChildren().isEmpty()); // 无 children/nodes key + + map.put("sourceId", null); // 显式 null 不写入 + assertNull(MindMapNode.fromMap(map).getSourceId()); + } + + @Test + void fromMap_skipsNonMapEntriesInChildList() { + Map map = Map.of( + "title", "父", + "children", List.of("文本", Map.of("title", "有效"), 42)); + MindMapNode n = MindMapNode.fromMap(map); + assertEquals(1, n.getChildren().size()); + assertEquals("有效", n.getChildren().get(0).getTitle()); + } + + @Test + void toMapFromMapRoundTrip_keepsFields() { + MindMapNode child = new MindMapNode("子"); + child.setNotes("cn"); + MindMapNode root = new MindMapNode("根"); + root.setNotes("rn"); + root.setSourceType("REPORT"); + root.setSourceId(3); + root.setChildren(new ArrayList<>(List.of(child))); + + MindMapNode rebuilt = MindMapNode.fromMap(root.toMap()); + assertEquals("根", rebuilt.getTitle()); + assertEquals("rn", rebuilt.getNotes()); + assertEquals("REPORT", rebuilt.getSourceType()); + assertEquals(Integer.valueOf(3), rebuilt.getSourceId()); + assertEquals("子", rebuilt.getChildren().get(0).getTitle()); + assertEquals("cn", rebuilt.getChildren().get(0).getNotes()); + } + + @Test + void fromJson_parsesValidJson_andFallsBackOnInvalid() { + MindMapNode ok = MindMapNode.fromJson( + "{\"title\":\"根\",\"notes\":\"n\",\"children\":[{\"title\":\"子\"}]}", + new ObjectMapper()); + assertEquals("根", ok.getTitle()); + assertEquals("n", ok.getNotes()); + assertEquals("子", ok.getChildren().get(0).getTitle()); + + assertEquals("解析失败", MindMapNode.fromJson("not json", new ObjectMapper()).getTitle()); + assertEquals("解析失败", MindMapNode.fromJson(null, new ObjectMapper()).getTitle()); + } + + @Test + void chainedSetters_returnSameInstance() { + MindMapNode n = new MindMapNode(); + assertSame(n, n.setTitle("t")); + assertSame(n, n.setNotes("n")); + assertSame(n, n.setSourceType("s")); + assertSame(n, n.setSourceId(1)); + assertSame(n, n.setChildren(new ArrayList<>())); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/utils/MindMapTreeToolTest.java b/src/test/java/com/guo/learningprogresstracker/utils/MindMapTreeToolTest.java new file mode 100644 index 0000000..0465ebd --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/utils/MindMapTreeToolTest.java @@ -0,0 +1,370 @@ +package com.guo.learningprogresstracker.utils; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Constructor; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class MindMapTreeToolTest { + + /** 构建带可变 children 列表的节点 */ + private static MindMapNode node(String title, MindMapNode... children) { + MindMapNode n = new MindMapNode(title); + List list = new ArrayList<>(); + Collections.addAll(list, children); + n.setChildren(list); + return n; + } + + private static List titles(List nodes) { + return nodes.stream().map(MindMapNode::getTitle).toList(); + } + + // ============ 私有构造器 ============ + + @Test + void privateConstructor_onlyForUtilityUsage() throws Exception { + Constructor ctor = MindMapTreeTool.class.getDeclaredConstructor(); + ctor.setAccessible(true); + assertNotNull(ctor.newInstance()); + } + + // ============ parseOutline ============ + + @Test + void parseOutline_nullBlankOrWhitespaceOnly_returnsUnnamed() { + assertEquals("未命名", MindMapTreeTool.parseOutline(null).getTitle()); + assertEquals("未命名", MindMapTreeTool.parseOutline("").getTitle()); + assertEquals("未命名", MindMapTreeTool.parseOutline(" \n\t ").getTitle()); + // 全部是空白行时过滤后为空,同样返回"未命名" + assertEquals("未命名", MindMapTreeTool.parseOutline(" \n\t\t\n").getTitle()); + } + + @Test + void parseOutline_indentAndMarkers_buildHierarchyWithBacktracking() { + String outline = String.join("\n", + "项目导图", + "- 分支A", + " * 子A1", + " + 孙A1a", + "- 分支B", + "1. 分支C", + "* 1. 组合标记"); + + MindMapNode root = MindMapTreeTool.parseOutline(outline); + assertEquals("项目导图", root.getTitle()); + assertEquals(List.of("分支A", "分支B", "分支C", "组合标记"), titles(root.getChildren())); + assertEquals(List.of("子A1"), titles(root.getChildren().get(0).getChildren())); + assertEquals(List.of("孙A1a"), titles(root.getChildren().get(0).getChildren().get(0).getChildren())); + // 深层节点被解析时均被显式挂上 children 列表 + assertNotNull(root.getChildren().get(0).getChildren().get(0).getChildren()); + } + + @Test + void parseOutline_tabIndent_levels() { + String outline = String.join("\n", + "根", + "\t一级A", + "\t\t二级A1", + "\t一级B"); + MindMapNode root = MindMapTreeTool.parseOutline(outline); + assertEquals(List.of("一级A", "一级B"), titles(root.getChildren())); + assertEquals(List.of("二级A1"), titles(root.getChildren().get(0).getChildren())); + } + + @Test + void parseOutline_hashLevels_andPopBack() { + String outline = String.join("\n", + "根", + "# 一级", + "## 二级", + "### 三级", + "## 二级B"); + MindMapNode root = MindMapTreeTool.parseOutline(outline); + // 实际弹栈语义:## 二级B 出栈到 level<2 时父级是一级 + assertEquals(List.of("一级"), titles(root.getChildren())); + assertEquals(List.of("二级", "二级B"), titles(root.getChildren().get(0).getChildren())); + assertEquals(List.of("三级"), titles(root.getChildren().get(0).getChildren().get(0).getChildren())); + } + + @Test + void parseOutline_stripsTrailingWhitespace_andRootStrip() { + String outline = " 根标题 \r\n - 节点A \t \r\n"; + MindMapNode root = MindMapTreeTool.parseOutline(outline); + assertEquals("根标题", root.getTitle()); + assertEquals(List.of("节点A"), titles(root.getChildren())); + } + + // ============ toOutline / toFullOutline ============ + + @Test + void toOutline_singleLevelChildren() { + MindMapNode root = node("Root", node("A"), node("B")); + assertEquals(" - A\n - B\n", MindMapTreeTool.toOutline(root)); + } + + @Test + void toOutline_nullTitleAndNoChildren() { + MindMapNode leaf = new MindMapNode(); // children 为 null + assertEquals("", MindMapTreeTool.toOutline(leaf)); + + MindMapNode root = new MindMapNode(); + root.setChildren(new ArrayList<>(List.of(new MindMapNode()))); + assertEquals(" - \n", MindMapTreeTool.toOutline(root)); + } + + @Test + void toOutline_multiLevel_documentsCurrentDuplicationBehavior() { + // 现状记录(生产缺陷,见测试报告):level 0 的二次遍历会把孙子层重复输出一次 + MindMapNode root = node("Root", node("A", node("A1"))); + assertEquals(" - A\n - A1\n - A1\n", MindMapTreeTool.toOutline(root)); + } + + @Test + void toFullOutline_rootTitleThenIndentedChildren() { + MindMapNode root = node("Root", node("A", node("A1")), node("B")); + assertEquals("Root\n - A\n - A1\n - B\n", MindMapTreeTool.toFullOutline(root)); + } + + @Test + void toFullOutline_nullTitleRoot() { + assertEquals("\n", MindMapTreeTool.toFullOutline(new MindMapNode())); + } + + // ============ flatten / countNodes / maxDepth ============ + + @Test + void flatten_countNodes_maxDepth() { + MindMapNode leaf = new MindMapNode(); + leaf.setChildren(null); // 叶子:children 为 null + MindMapNode empty = new MindMapNode("e"); // children 为空列表 + + MindMapNode root = node("r", node("a", node("a1")), node("b")); + + assertEquals(1, MindMapTreeTool.maxDepth(leaf)); + assertEquals(1, MindMapTreeTool.maxDepth(empty)); + assertEquals(3, MindMapTreeTool.maxDepth(root)); + + assertEquals(List.of("r", "a", "a1", "b"), titles(MindMapTreeTool.flatten(root))); + assertEquals(4, MindMapTreeTool.countNodes(root)); + assertEquals(1, MindMapTreeTool.countNodes(leaf)); + } + + // ============ extractSubtree ============ + + @Test + void extractSubtree_byFullPath_returnsDeepCopy() { + MindMapNode a1 = node("A1"); + a1.setNotes("笔记"); + a1.setSourceType("REPORT"); + a1.setSourceId(7); + a1.setChildren(new ArrayList<>(List.of(node("A1子")))); + MindMapNode root = node("Root", node("A", a1, node("A2")), node("B")); + + MindMapNode sub = MindMapTreeTool.extractSubtree(root, "Root / A / A1"); + assertNotNull(sub); + assertEquals("A1", sub.getTitle()); + assertEquals("笔记", sub.getNotes()); + assertEquals("REPORT", sub.getSourceType()); + assertEquals(Integer.valueOf(7), sub.getSourceId()); + assertEquals(List.of("A1子"), titles(sub.getChildren())); + assertNotSame(a1, sub); + assertNotSame(a1.getChildren().get(0), sub.getChildren().get(0)); + // 修改副本不影响原树 + sub.getChildren().clear(); + assertEquals(1, a1.getChildren().size()); + } + + @Test + void extractSubtree_withoutRootSegment_andNormalizedMatching() { + MindMapNode root = node("复习计划", node("Java 核心(进阶)", node("并发编程"))); + + // 大小写与标点差异通过 normalizeForMerge 匹配 + MindMapNode sub = MindMapTreeTool.extractSubtree(root, "java核心进阶 / 并发编程"); + assertNotNull(sub); + assertEquals("并发编程", sub.getTitle()); + + // 不含根标题:第一段直接在根 children 中查找 + MindMapNode sub2 = MindMapTreeTool.extractSubtree(root, "Java 核心(进阶)"); + assertNotNull(sub2); + assertEquals("Java 核心(进阶)", sub2.getTitle()); + + // 空路径段被跳过 + MindMapNode sub3 = MindMapTreeTool.extractSubtree(root, "复习计划 // Java 核心(进阶)"); + assertNotNull(sub3); + assertEquals("Java 核心(进阶)", sub3.getTitle()); + } + + @Test + void extractSubtree_nullOrMismatchedPathReturnsNull() { + MindMapNode root = node("Root", node("A", node("A1")), new MindMapNode()); + assertNull(MindMapTreeTool.extractSubtree(null, "Root")); + assertNull(MindMapTreeTool.extractSubtree(root, null)); + assertNull(MindMapTreeTool.extractSubtree(root, " ")); + assertNull(MindMapTreeTool.extractSubtree(root, "Root / X")); // 中途失配 + assertNull(MindMapTreeTool.extractSubtree(root, "Root / A / A1 / 更深")); // 末端失配 + } + + // ============ findClosestNode ============ + + @Test + void findClosestNode_titleMatch() { + MindMapNode root = node("Root", node("Java 并发"), node("完全无关")); + MindMapNode best = MindMapTreeTool.findClosestNode(root, "java并发"); + assertNotNull(best); + assertEquals("Java 并发", best.getTitle()); + } + + @Test + void findClosestNode_notesCountWithHalfWeight() { + MindMapNode hit = node("完全无关"); + hit.setNotes("数据库索引优化详解"); + MindMapNode root = node("Root", hit, node("另一个")); + + MindMapNode best = MindMapTreeTool.findClosestNode(root, "数据库索引优化"); + assertNotNull(best); + assertEquals("完全无关", best.getTitle()); + } + + @Test + void findClosestNode_lowScoresOrInvalidInputsReturnNull() { + MindMapNode root = node("Root", new MindMapNode(), node(" "), node("abcd")); + assertNull(MindMapTreeTool.findClosestNode(root, "zzzz")); // 全部低于阈值 + assertNull(MindMapTreeTool.findClosestNode(null, "x")); + assertNull(MindMapTreeTool.findClosestNode(root, null)); + assertNull(MindMapTreeTool.findClosestNode(root, " ")); + } + + // ============ getPath ============ + + @Test + void getPath_hitAndMissAndNulls() { + MindMapNode root = node("Root", node("A"), node("B", node("C"))); + assertEquals("Root / B / C", MindMapTreeTool.getPath(root, "C")); + assertEquals("Root", MindMapTreeTool.getPath(root, "ROOT")); // normalize 后命中根 + assertEquals("", MindMapTreeTool.getPath(root, "不存在")); + assertEquals("", MindMapTreeTool.getPath(null, "x")); + assertEquals("", MindMapTreeTool.getPath(root, null)); + } + + @Test + void getPath_handlesNullTitlesAlongPath() { + MindMapNode root = node("Root", new MindMapNode()); + assertEquals("Root", MindMapTreeTool.getPath(root, "Root")); + assertEquals("", MindMapTreeTool.getPath(root, "不在树中")); + } + + // ============ similarityScore ============ + + @Test + void similarityScore_variousCases() { + assertEquals(1.0, MindMapTreeTool.similarityScore("Java 并发", "java并发"), 1e-9); + assertEquals(0.9, MindMapTreeTool.similarityScore("java concurrency guide", "concurrency"), 1e-9); + // bigram Jaccard:{ab,bc,cd} 与 {ab,bc,ce} 交 2 / 并 4 + assertEquals(0.5, MindMapTreeTool.similarityScore("abcd", "abce"), 1e-9); + assertEquals(0.0, MindMapTreeTool.similarityScore("", "x"), 1e-9); + assertEquals(0.0, MindMapTreeTool.similarityScore(null, "x"), 1e-9); + assertEquals(0.0, MindMapTreeTool.similarityScore("x", null), 1e-9); + assertEquals(0.0, MindMapTreeTool.similarityScore("a", "b"), 1e-9); // 双方 bigram 均为空 + assertEquals(0.9, MindMapTreeTool.similarityScore("a", "ab"), 1e-9); // 子串匹配优先于 bigram + } + + // ============ mergeTrees ============ + + @Test + void mergeTrees_nullSideReturnsOther() { + MindMapNode oldTree = node("旧"); + MindMapNode newTree = node("新"); + assertSame(newTree, MindMapTreeTool.mergeTrees(null, newTree)); + assertSame(oldTree, MindMapTreeTool.mergeTrees(oldTree, null)); + } + + @Test + void mergeTrees_keepsOldTitlesAndAppendsOldOnlyNodes() { + MindMapNode oldChild = node("任务,一"); + oldChild.setNotes("旧备注"); + oldChild.setChildren(new ArrayList<>(List.of(node("旧子")))); + MindMapNode oldTree = node("根", oldChild, node("旧独有")); + + MindMapNode newChild = node("任务一。"); + newChild.setChildren(new ArrayList<>(List.of(node("新子")))); + MindMapNode newTree = node("新根", newChild, node("新增")); + + MindMapNode merged = MindMapTreeTool.mergeTrees(oldTree, newTree); + + assertEquals("根", merged.getTitle()); // 保留旧树根标题 + assertEquals(List.of("任务,一", "新增", "旧独有"), titles(merged.getChildren())); + + MindMapNode kept = merged.getChildren().get(0); + assertEquals("旧备注", kept.getNotes()); + assertEquals(List.of("新子", "旧子"), titles(kept.getChildren())); + + assertNotSame(oldChild, kept); + assertSame(newTree.getChildren().get(1), merged.getChildren().get(1)); // 新节点直接复用 + assertSame(oldTree.getChildren().get(1), merged.getChildren().get(2)); // 旧独有节点直接复用 + } + + @Test + void mergeTrees_nullChildrenLists() { + // 旧 children 为 null:新 children 全部保留 + MindMapNode oldNoKids = new MindMapNode(); + oldNoKids.setChildren(null); + MindMapNode newWithKids = node("新", node("k1")); + MindMapNode merged = MindMapTreeTool.mergeTrees(oldNoKids, newWithKids); + assertNull(merged.getTitle()); + assertEquals(List.of("k1"), titles(merged.getChildren())); + + // 新 children 为 null:旧 children 原样保留 + MindMapNode oldWithKids = node("旧", node("k2")); + MindMapNode newNoKids = new MindMapNode("新2"); + newNoKids.setChildren(null); + MindMapNode merged2 = MindMapTreeTool.mergeTrees(oldWithKids, newNoKids); + assertEquals(List.of("k2"), titles(merged2.getChildren())); + assertSame(oldWithKids.getChildren().get(0), merged2.getChildren().get(0)); + } + + // ============ toJson / fromJson ============ + + @Test + void toJson_serializesNodeMapJson() { + MindMapNode n = node("根", node("子")); + n.setNotes("备注"); + String json = MindMapTreeTool.toJson(n, new ObjectMapper()); + assertTrue(json.contains("\"title\":\"根\"")); + assertTrue(json.contains("\"notes\":\"备注\"")); + assertTrue(json.contains("\"title\":\"子\"")); + assertTrue(json.contains("\"children\":[]")); + } + + @Test + void toJson_serializationFailureReturnsFallback() throws Exception { + ObjectMapper failing = mock(ObjectMapper.class); + when(failing.writeValueAsString(any())).thenThrow(new RuntimeException("boom")); + assertEquals("{\"title\":\"序列化失败\"}", MindMapTreeTool.toJson(node("x"), failing)); + } + + @Test + void fromJson_parsesValidJson_andFallsBackOnInvalid() { + MindMapNode ok = MindMapTreeTool.fromJson( + "{\"title\":\"根\",\"children\":[{\"title\":\"子\",\"nodes\":[]}]}", + new ObjectMapper()); + assertEquals("根", ok.getTitle()); + assertEquals("子", ok.getChildren().get(0).getTitle()); + + assertEquals("解析失败", MindMapTreeTool.fromJson("not json", new ObjectMapper()).getTitle()); + assertEquals("解析失败", MindMapTreeTool.fromJson(null, new ObjectMapper()).getTitle()); + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/utils/TitleFetcherTest.java b/src/test/java/com/guo/learningprogresstracker/utils/TitleFetcherTest.java new file mode 100644 index 0000000..3844136 --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/utils/TitleFetcherTest.java @@ -0,0 +1,291 @@ +package com.guo.learningprogresstracker.utils; + +import com.guo.learningprogresstracker.support.TestHttpServer; +import com.guo.learningprogresstracker.support.TestHttpsServer; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import javax.net.ssl.X509TrustManager; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.security.cert.X509Certificate; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TitleFetcherTest { + + // ============ HTTP 组:基于本地 TestHttpServer ============ + @Nested + class HttpGroup extends TestHttpServer { + + @Test + void fetchTitle_basicHtml_withQueryAndPrefixRoute() { + route("GET", "/basic", 200, "基础标题"); + assertEquals("基础标题", TitleFetcher.fetchTitle(baseUrl() + "/basic?src=unit")); + assertEquals("src=unit", lastQuery("GET", "/basic")); + } + + @Test + void fetchTitle_decodesHtmlEntities_andCollapsesWhitespace() { + route("GET", "/entities", 200, + "A&B <tag> "引" 'x' A  B 多 空格"); + assertEquals("A&B \"引\" 'x' A B 多 空格", + TitleFetcher.fetchTitle(baseUrl() + "/entities")); + } + + @Test + void fetchTitle_whitespaceOnlyTitleReturnsNull() { + route("GET", "/emptytitle", 200, " "); + assertNull(TitleFetcher.fetchTitle(baseUrl() + "/emptytitle")); + } + + @Test + void fetchTitle_gzipResponseBody() { + route("GET", "/gz", 200, "压缩标题", + Map.of("Content-Encoding-Test", "gzip")); + assertEquals("压缩标题", TitleFetcher.fetchTitle(baseUrl() + "/gz")); + } + + @Test + void fetchTitle_gzipHeaderButRawBody_fallsBackAndReturnsNull() { + // 声明 gzip 但响应体未压缩:GZIPInputStream 构造抛异常 → 走 catch 回退读原始流 + route("GET", "/badgz", 200, "not gzip", + Map.of("Content-Encoding", "gzip")); + assertNull(TitleFetcher.fetchTitle(baseUrl() + "/badgz")); + } + + @Test + void fetchTitle_deflateHeaderWithRawBody_returnsNull() { + // InflaterInputStream 包装成功但读取原始字节时抛错 → doFetch 捕获返回 null + route("GET", "/deflate", 200, "not deflate", + Map.of("Content-Encoding", "deflate")); + assertNull(TitleFetcher.fetchTitle(baseUrl() + "/deflate")); + } + + @Test + void fetchTitle_gbkCharsetDecoding() { + String rawTitle = "中文标题"; + route("GET", "/gbk", 200, + "" + rawTitle + "", + Map.of("Content-Type", "text/html; charset=GBK")); + // 服务端实际发送 UTF-8 字节,客户端按 GBK 解码:两者结果必然不同 + String expected = new String(rawTitle.getBytes(StandardCharsets.UTF_8), Charset.forName("GBK")); + assertFalse(expected.isBlank()); + String got = TitleFetcher.fetchTitle(baseUrl() + "/gbk"); + assertNotEquals(rawTitle, got); + assertEquals(expected, got); + } + + @Test + void fetchTitle_unknownCharsetFallsBackToUtf8() { + route("GET", "/badcharset", 200, "Ascii 标题", + Map.of("Content-Type", "text/html; charset=NOT-A-REAL-CHARSET")); + assertEquals("Ascii 标题", TitleFetcher.fetchTitle(baseUrl() + "/badcharset")); + } + + @Test + void fetchTitle_textHtmlWithoutCharsetDefaultsToUtf8() { + route("GET", "/nocharset", 200, "无charset", + Map.of("Content-Type", "text/html")); + assertEquals("无charset", TitleFetcher.fetchTitle(baseUrl() + "/nocharset")); + } + + @Test + void fetchTitle_nonTextContentTypeReturnsNull() { + route("GET", "/pdf", 200, "%PDF-1.4 not html", + Map.of("Content-Type", "application/pdf")); + assertNull(TitleFetcher.fetchTitle(baseUrl() + "/pdf")); + } + + @Test + void fetchTitle_textPlainWithoutTitleReturnsNull() { + route("GET", "/txt", 200, "纯文本没有标题", + Map.of("Content-Type", "text/plain; charset=utf-8")); + assertNull(TitleFetcher.fetchTitle(baseUrl() + "/txt")); + } + + @Test + void fetchTitle_xhtmlAccepted() { + route("GET", "/xhtml", 200, "XHTML 标题", + Map.of("Content-Type", "application/xhtml+xml; charset=utf-8")); + assertEquals("XHTML 标题", TitleFetcher.fetchTitle(baseUrl() + "/xhtml")); + } + + @Test + void fetchTitle_errorStatusReturnsNull() { + route("GET", "/err", 500, "oops"); + assertNull(TitleFetcher.fetchTitle(baseUrl() + "/err")); + } + + // ---- 重定向链 ---- + + @Test + void fetchTitle_followsRedirectWithRelativeLocation() { + route("GET", "/start", 200, "没有标题的页面"); + route("HEAD", "/start", 302, "", Map.of("Location", "/final")); + route("GET", "/final", 200, "最终标题"); + assertEquals("最终标题", TitleFetcher.fetchTitle(baseUrl() + "/start")); + } + + @Test + void fetchTitle_followsAbsoluteRedirectLocation() { + route("GET", "/absc", 200, "没有标题"); + route("HEAD", "/absc", 301, "", Map.of("Location", baseUrl() + "/absfinal")); + route("GET", "/absfinal", 200, "绝对跳转"); + assertEquals("绝对跳转", TitleFetcher.fetchTitle(baseUrl() + "/absc")); + } + + @Test + void fetchTitle_selfRedirectReturnsNull() { + route("GET", "/loop", 200, "loop"); + route("HEAD", "/loop", 302, "", Map.of("Location", "/loop")); + assertNull(TitleFetcher.fetchTitle(baseUrl() + "/loop")); + } + + @Test + void fetchTitle_tooManyRedirectsReturnsNull() { + for (int i = 0; i <= 5; i++) { + route("GET", "/r" + i, 200, "跳转" + i + ""); + route("HEAD", "/r" + i, 302, "", Map.of("Location", "/r" + (i + 1))); + } + assertNull(TitleFetcher.fetchTitle(baseUrl() + "/r0")); + } + + @Test + void fetchTitle_headWithoutRedirectStatusReturnsNull() { + route("GET", "/stay", 200, "没有标题"); + route("HEAD", "/stay", 200, ""); + assertNull(TitleFetcher.fetchTitle(baseUrl() + "/stay")); + } + + @Test + void fetchTitle_blankRedirectLocationReturnsNull() { + route("GET", "/blank", 200, "没有标题"); + route("HEAD", "/blank", 302, "", Map.of("Location", " ")); + assertNull(TitleFetcher.fetchTitle(baseUrl() + "/blank")); + } + + // ---- 非法输入 / 连接失败 ---- + + @Test + void fetchTitle_invalidInputsReturnNull() { + assertNull(TitleFetcher.fetchTitle(null)); + assertNull(TitleFetcher.fetchTitle("")); + assertNull(TitleFetcher.fetchTitle(" ")); + assertNull(TitleFetcher.fetchTitle("ftp://example.com/file")); + assertNull(TitleFetcher.fetchTitle("example.com/page")); + } + + @Test + void fetchTitle_connectionRefusedReturnsNull() { + assertNull(TitleFetcher.fetchTitle("http://localhost:1/")); + } + + // ---- embedTitles ---- + + @Test + void embedTitles_nullBlankOrLinkOnly_returnsAsIs() { + assertNull(TitleFetcher.embedTitles(null)); + assertEquals(" ", TitleFetcher.embedTitles(" ")); + String linkOnly = "已有 [手册](" + baseUrl() + "/keep) 说明"; + assertEquals(linkOnly, TitleFetcher.embedTitles(linkOnly)); + } + + @Test + void embedTitles_replacesBareUrl_andStripsTrailingPunctuation() { + route("GET", "/doc", 200, "接口文档"); + String url = baseUrl() + "/doc"; + assertEquals("参见 [接口文档](" + url + ")。", TitleFetcher.embedTitles("参见 " + url + "。")); + } + + @Test + void embedTitles_protectsExistingMarkdownLinks_thenRestores() { + route("GET", "/fresh", 200, "新标题"); + String out = TitleFetcher.embedTitles( + "看 [手册](" + baseUrl() + "/keep) 与 " + baseUrl() + "/fresh"); + assertEquals("看 [手册](" + baseUrl() + "/keep) 与 [新标题](" + baseUrl() + "/fresh)", out); + } + + @Test + void embedTitles_deduplicatesUrls() { + route("GET", "/dup", 200, "重复标题"); + String url = baseUrl() + "/dup"; + assertEquals("[重复标题](" + url + ") 和 [重复标题](" + url + ")", + TitleFetcher.embedTitles(url + " 和 " + url)); + } + + @Test + void embedTitles_failedFetchKeepsBareUrl() { + String text = "文档在 http://localhost:1/missing 页面"; + assertEquals(text, TitleFetcher.embedTitles(text)); + } + + @Test + void embedTitles_multipleUrlsAllReplaced() { + route("GET", "/a", 200, ""); + route("GET", "/b", 200, ""); + String out = TitleFetcher.embedTitles(baseUrl() + "/a 中间 " + baseUrl() + "/b"); + assertEquals("[甲](" + baseUrl() + "/a) 中间 [乙](" + baseUrl() + "/b)", out); + } + } + + // ============ HTTPS 组:自签名证书触发信任所有证书路径 ============ + @Nested + class HttpsGroup extends TestHttpsServer { + + @Test + void fetchTitle_selfSignedCertificateTrusted() { + html("/secure", "加密标题"); + assertEquals("加密标题", TitleFetcher.fetchTitle(baseUrl() + "/secure")); + } + } + + // ============ 静态块与内部 TrustManager ============ + + @Test + void trustAllManager_isInstalledAndAcceptsEverything() throws Exception { + // 静态块应成功安装宽松 SSL(sslRelaxed = true 分支) + Field relaxed = TitleFetcher.class.getDeclaredField("sslRelaxed"); + relaxed.setAccessible(true); + assertTrue(relaxed.getBoolean(null), "静态块应成功安装信任所有证书的 SSLContext"); + + // 握手之外直接调用三个回调,确保内部类全部行被执行 + X509TrustManager tm = newTrustAllManagerInstance(); + assertArrayEquals(new X509Certificate[0], tm.getAcceptedIssuers()); + assertDoesNotThrow(() -> tm.checkClientTrusted(null, "RSA")); + assertDoesNotThrow(() -> tm.checkServerTrusted(null, "RSA")); + } + + private static X509TrustManager newTrustAllManagerInstance() throws Exception { + try { + for (Class c : TitleFetcher.class.getDeclaredClasses()) { + if (X509TrustManager.class.isAssignableFrom(c)) { + return (X509TrustManager) newInstanceAllowingAll(c); + } + } + } catch (Throwable ignored) { + // 个别实现不通过 getDeclaredClasses 暴露匿名类,退回按名称查找 + } + Class anon = Class.forName(TitleFetcher.class.getName() + "$1"); + assertTrue(X509TrustManager.class.isAssignableFrom(anon), "TitleFetcher$1 应为 X509TrustManager"); + return (X509TrustManager) newInstanceAllowingAll(anon); + } + + private static Object newInstanceAllowingAll(Class type) throws Exception { + for (Constructor ctor : type.getDeclaredConstructors()) { + ctor.setAccessible(true); + return ctor.newInstance(new Object[ctor.getParameterCount()]); + } + throw new IllegalStateException("无可访问构造器: " + type.getName()); + } +} diff --git a/src/test/resources/test-keystore.p12 b/src/test/resources/test-keystore.p12 new file mode 100644 index 0000000..2c9f6e9 Binary files /dev/null and b/src/test/resources/test-keystore.p12 differ