diff --git a/src/main/java/com/lion/lionwebsite/Service/GalleryManageService.java b/src/main/java/com/lion/lionwebsite/Service/GalleryManageService.java index 50b1a33..b39d94d 100644 --- a/src/main/java/com/lion/lionwebsite/Service/GalleryManageService.java +++ b/src/main/java/com/lion/lionwebsite/Service/GalleryManageService.java @@ -11,6 +11,7 @@ import com.lion.lionwebsite.Util.ImageFileCache; import java.nio.file.Path; import com.lion.lionwebsite.Util.GalleryUtil; import com.lion.lionwebsite.Util.Response; +import tools.jackson.core.JacksonException; import tools.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -371,7 +372,17 @@ public class GalleryManageService { public String cacheImagesKey(String url) { Response response = Response.generateResponse(); - String gid = String.valueOf(GalleryUtil.parseGid(url)); + + // 畸形链接在这里就拦掉:段数不足会让 url.split("/")[5] 抛 ArrayIndexOutOfBoundsException, + // 而 parseGid 也会对无 /g/ 的链接返回 null,两者都不该变成 500。 + String[] segments = url == null ? null : url.split("/"); + Integer parsedGid = parseGidFromLink(url); + if (parsedGid == null || segments == null || segments.length <= 5) { + response.failure("链接错误"); + return response.toJSONString(); + } + + String gid = String.valueOf(parsedGid); GidToKey gidToKey = imageCacheMapper.selectKeyByGid(gid); //已缓存过,直接返回 if(gidToKey != null) { @@ -381,7 +392,7 @@ public class GalleryManageService { try { gidToKey = new GidToKey(); gidToKey.setGid(gid); - gidToKey.setKey(url.split("/")[5].strip()); + gidToKey.setKey(segments[5].strip()); ArrayList imageKeyCaches = GalleryUtil.parseImageKeys(url); if(imageKeyCaches == null) return response.failure("该图片已下架或已被删除").toJSONString(); @@ -390,8 +401,10 @@ public class GalleryManageService { for (ImageKeyCache imageKeyCache : imageKeyCaches) imageCacheMapper.insertImageKeyCache(imageKeyCache); response.success(objectMapper.valueToTree(gidToKey)); - }catch (IOException e){ - log.error(e.getMessage()); + }catch (IOException | JacksonException e){ + // Jackson 3 的解析异常继承 RuntimeException 而非 IOException, + // 只捕 IOException 会漏掉第三方页面格式变化,穿透成 500。 + log.warn("缓存图片索引失败 gid={} errorType={}", gid, e.getClass().getSimpleName()); response.failure("网络波动或其他异常"); } return response.toJSONString(); diff --git a/src/main/java/com/lion/lionwebsite/Service/PersonalService.java b/src/main/java/com/lion/lionwebsite/Service/PersonalService.java index dd5dcf0..afcd378 100644 --- a/src/main/java/com/lion/lionwebsite/Service/PersonalService.java +++ b/src/main/java/com/lion/lionwebsite/Service/PersonalService.java @@ -117,7 +117,9 @@ public class PersonalService{ response.success(new ObjectMapper().valueToTree(fileMaps).toString()); } else - response.failure("文件夹为空"); + // listFiles() 只在 I/O 出错时返回 null(可读空目录返回长度为 0 的数组), + // 报「文件夹为空」会掩盖权限/磁盘问题,这里如实提示。 + response.failure("读取文件夹失败"); } return response.toJSONString(); } @@ -359,7 +361,11 @@ public class PersonalService{ path = URLDecoder.decode(path, StandardCharsets.UTF_8); File file = new File(path); - if(FileUtil.del(file)) + // hutool 的 FileUtil.del 对不存在的目标返回 true(幂等语义), + // 直接据此报成功会让「路径写错/文件已不存在」也回「删除成功」,前端误以为已删除。 + if (!file.exists()) + response.failure("文件不存在"); + else if (FileUtil.del(file)) response.success("删除成功"); else response.failure("删除失败"); diff --git a/src/main/java/com/lion/lionwebsite/Util/GalleryUtil.java b/src/main/java/com/lion/lionwebsite/Util/GalleryUtil.java index 8730eee..1c48d44 100644 --- a/src/main/java/com/lion/lionwebsite/Util/GalleryUtil.java +++ b/src/main/java/com/lion/lionwebsite/Util/GalleryUtil.java @@ -185,7 +185,7 @@ public class GalleryUtil { ArrayList imageKeyCaches = new ArrayList<>(); AtomicInteger page = new AtomicInteger(1); gid2MpvKey.put(gid, scripts[1].split("=")[1].replace(";", "").replace("\"", "").replace(" ", "")); - JsonNode nodes = objectMapper.readValue(scripts[2].replace("var imagelist = ", ""), JsonNode.class); + JsonNode nodes = parseImagelist(scripts[2]); nodes.forEach((n) -> { ImageKeyCache imageKeyCache = new ImageKeyCache(); imageKeyCache.setGid(gid); @@ -196,6 +196,19 @@ public class GalleryUtil { return imageKeyCaches; } + /** + * 解析 mpv 页里的 imagelist 行。 + * 该行是 JS 赋值语句(`var imagelist = [...];`),行尾分号属 JavaScript 语法而非 JSON, + * 因此先剥掉「var imagelist = 」前缀与行尾分号,再交给 JSON 解析。 + * Jackson 3 默认开启 FAIL_ON_TRAILING_TOKENS,若把分号留给它会在解析时报错。 + */ + private static JsonNode parseImagelist(String scriptLine) { + String json = scriptLine.replace("var imagelist = ", "").trim(); + if (json.endsWith(";")) + json = json.substring(0, json.length() - 1).trim(); + return objectMapper.readValue(json, JsonNode.class); + } + public static String getMpvKey(String url){ String gid = String.valueOf(parseGid(url)); String key = gid2MpvKey.get(gid); diff --git a/src/test/java/com/lion/lionwebsite/Configuration/ConfigurationWiringTest.java b/src/test/java/com/lion/lionwebsite/Configuration/ConfigurationWiringTest.java new file mode 100644 index 0000000..464882a --- /dev/null +++ b/src/test/java/com/lion/lionwebsite/Configuration/ConfigurationWiringTest.java @@ -0,0 +1,169 @@ +package com.lion.lionwebsite.Configuration; + +import com.lion.lionwebsite.Interceptor.HumanInterceptor; +import com.lion.lionwebsite.Interceptor.PersonalInterceptor; +import com.lion.lionwebsite.Interceptor.TaskHandlerInterceptor; +import com.lion.lionwebsite.Service.WebSocketService; +import org.junit.jupiter.api.Test; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.config.annotation.InterceptorRegistration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * 拦截器与 WebSocket 的注册关系。 + * 这是整个应用的「安全地图」:若某条路径漏挂鉴权拦截器,接口就会在无授权码 + * 的情况下对外可用(9/8 那批改动正是栽在 /GalleryManage/** 的漏挂上), + * 所以这里把路径与拦截器的对应关系显式锁进测试。 + */ +class ConfigurationWiringTest { + + /** 记录 addInterceptors 调用顺序与实际挂载路径的假注册表。 */ + private static final class RecordingRegistry extends InterceptorRegistry { + final List order = new ArrayList<>(); + final Map> paths = new LinkedHashMap<>(); + + @Override + public InterceptorRegistration addInterceptor(HandlerInterceptor interceptor) { + String name = interceptor.getClass().getSimpleName(); + order.add(name); + return new RecordingRegistration(name, paths); + } + } + + /** 只记录路径,其余注册动作不做真实处理。 */ + private static final class RecordingRegistration extends InterceptorRegistration { + private final String name; + private final Map> paths; + + RecordingRegistration(String name, Map> paths) { + super(mock(HandlerInterceptor.class)); + this.name = name; + this.paths = paths; + } + + @Override + public InterceptorRegistration addPathPatterns(String... patterns) { + paths.computeIfAbsent(name, k -> new ArrayList<>()).addAll(List.of(patterns)); + return this; + } + } + + private static RecordingRegistry registryOf(InterceptorConfiguration config) { + RecordingRegistry registry = new RecordingRegistry(); + config.addInterceptors(registry); + return registry; + } + + /** TaskHandlerInterceptor 必须覆盖 /GalleryManage 全子路径与 /validate。 */ + @Test + void taskHandlerGuardsGalleryManageAndValidate() { + var config = new InterceptorConfiguration(mock(TaskHandlerInterceptor.class)); + var registry = registryOf(config); + + List guarded = registry.paths.get("TaskHandlerInterceptor"); + assertNotNull(guarded, "TaskHandlerInterceptor 必须被注册"); + assertTrue(guarded.contains("/GalleryManage"), "精确路径必须挂着"); + assertTrue(guarded.contains("/GalleryManage/**"), "子路径必须挂着(历史漏挂点)"); + assertTrue(guarded.contains("/validate")); + } + + /** PersonalInterceptor 必须覆盖 /personal/** 与 /remote/**。 */ + @Test + void personalInterceptorGuardsPrivateAreas() { + var config = new InterceptorConfiguration(mock(TaskHandlerInterceptor.class)); + var registry = registryOf(config); + + List guarded = registry.paths.get("PersonalInterceptor"); + assertNotNull(guarded, "PersonalInterceptor 必须被注册"); + assertTrue(guarded.contains("/personal/**")); + assertTrue(guarded.contains("/remote/**")); + } + + /** HumanInterceptor 只管首页与移动端入口。 */ + @Test + void humanInterceptorGuardsEntryPointsOnly() { + var config = new InterceptorConfiguration(mock(TaskHandlerInterceptor.class)); + var registry = registryOf(config); + + assertEquals(List.of("/", "/mobile"), registry.paths.get("HumanInterceptor")); + } + + /** @Bean 暴露的拦截器类型必须与注册时一致(写错类型会让鉴权静默失效)。 */ + @Test + void exposedInterceptorBeansHaveExpectedTypes() { + var config = new InterceptorConfiguration(mock(TaskHandlerInterceptor.class)); + + assertInstanceOf(PersonalInterceptor.class, config.getPersonalInterceptor()); + assertInstanceOf(HumanInterceptor.class, config.getHumanInterceptor()); + } + + /** WebSocket 处理器必须挂在 /ws/,并放开跨域(前端部署在不同源)。 */ + @Test + void websocketHandlerIsRegisteredAtWsPath() { + var service = new WebSocketService(); + var config = new WebsocketConfiguration(service); + + var registration = mock(org.springframework.web.socket.config.annotation.WebSocketHandlerRegistration.class, + RETURNS_SELF); + var registry = mock(org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry.class); + when(registry.addHandler(any(), anyString())).thenReturn(registration); + + config.registerWebSocketHandlers(registry); + + verify(registry).addHandler(eq(service), eq("/ws/")); + verify(registration).setAllowedOriginPatterns("*"); + } + + // ---------- CORS ---------- + + /** 暴露 CorsRegistry 的 protected 配置表,便于断言实际生效的规则。 */ + private static final class InspectableCorsRegistry + extends org.springframework.web.servlet.config.annotation.CorsRegistry { + @Override + public java.util.Map getCorsConfigurations() { + return super.getCorsConfigurations(); + } + } + + /** CORS 配置必须允许凭据,且不得使用通配来源(两者不能同时成立)。 */ + @Test + void corsAllowsCredentialsWithOriginPatterns() { + var registry = new InspectableCorsRegistry(); + new CorsConfig().addCorsMappings(registry); + + var configs = registry.getCorsConfigurations(); + assertEquals(1, configs.size(), "应只注册一条 /** 的映射"); + assertTrue(configs.containsKey("/**")); + + var mapping = configs.get("/**"); + assertTrue(mapping.getAllowCredentials(), "前端带 Cookie 时需要允许凭据"); + assertTrue(mapping.getAllowedMethods().containsAll(List.of("GET", "POST", "PUT", "DELETE")), + "四种方法都应放开"); + assertEquals(List.of("*"), mapping.getAllowedHeaders()); + } + + /** + * allowCredentials + allowedOrigins("*") 是非法组合(Spring 会抛异常), + * 因此必须走 allowedOriginPatterns。这里锁定该实现方式不被改回去。 + */ + @Test + void corsUsesOriginPatternsRatherThanWildcardOrigins() { + var registry = new InspectableCorsRegistry(); + new CorsConfig().addCorsMappings(registry); + + var mapping = registry.getCorsConfigurations().get("/**"); + assertTrue(mapping.getAllowedOriginPatterns().contains("*"), + "来源应通过 allowedOriginPatterns 放开"); + assertNull(mapping.getAllowedOrigins(), + "不应设置 allowedOrigins,否则与 allowCredentials 冲突"); + } +} diff --git a/src/test/java/com/lion/lionwebsite/Configuration/MyBatisNativeConfigurationTest.java b/src/test/java/com/lion/lionwebsite/Configuration/MyBatisNativeConfigurationTest.java new file mode 100644 index 0000000..92145de --- /dev/null +++ b/src/test/java/com/lion/lionwebsite/Configuration/MyBatisNativeConfigurationTest.java @@ -0,0 +1,302 @@ +package com.lion.lionwebsite.Configuration; + +import com.lion.lionwebsite.Dao.normal.GalleryMapper; +import com.lion.lionwebsite.Dao.normal.UserMapper; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.mapper.MapperFactoryBean; +import org.mybatis.spring.mapper.MapperScannerConfigurer; +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.predicate.RuntimeHintsPredicates; +import org.springframework.beans.factory.support.RegisteredBean; +import org.springframework.beans.factory.support.RootBeanDefinition; + +import java.lang.reflect.Method; +import java.util.Collection; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * GraalVM 原生镜像的 MyBatis 运行时提示(AOT hints)。 + * + * 这些代码在 JVM 模式下几乎不执行,但一旦原生构建,缺少 hint 就会在运行期抛 + * ClassNotFoundException / 反射失败——而这类问题只在原生产物上暴露,回归成本极高。 + * 因此这里直接调用这些处理器,断言「该注册的反射与代理都注册了」。 + */ +class MyBatisNativeConfigurationTest { + + /** 运行期提示注册器应把 MyBatis 关键类全部登记为可反射,并注册 dtd/xsd 资源。 */ + @Test + void runtimeHintsRegistrarRegistersMyBatisReflectionAndResources() { + RuntimeHints hints = new RuntimeHints(); + new MyBatisNativeConfiguration.MyBaitsRuntimeHintsRegistrar() + .registerHints(hints, getClass().getClassLoader()); + + // 语言驱动与日志实现等都会被 MyBatis 反射实例化 + assertTrue(RuntimeHintsPredicates.reflection() + .onType(org.apache.ibatis.scripting.xmltags.XMLLanguageDriver.class).test(hints), + "XMLLanguageDriver 应可反射"); + assertTrue(RuntimeHintsPredicates.reflection() + .onType(org.apache.ibatis.logging.slf4j.Slf4jImpl.class).test(hints), + "Slf4jImpl 应可反射"); + assertTrue(RuntimeHintsPredicates.reflection() + .onType(org.apache.ibatis.session.SqlSessionFactory.class).test(hints)); + assertTrue(RuntimeHintsPredicates.reflection() + .onType(java.util.ArrayList.class).test(hints), + "集合类型也应登记(MyBatis 需要实例化)"); + + // XML 映射文件的 DTD/XSD 解析依赖这些资源 + assertTrue(RuntimeHintsPredicates.resource() + .forResource("org/apache/ibatis/builder/xml/mybatis-3-mapper.dtd").test(hints)); + assertTrue(RuntimeHintsPredicates.resource() + .forResource("org/apache/ibatis/builder/xml/mybatis-3-config.xsd").test(hints)); + } + + /** 反射条目应包含全部成员类别,而不只是构造器。 */ + @Test + void reflectionHintsIncludeAllMemberCategories() { + RuntimeHints hints = new RuntimeHints(); + new MyBatisNativeConfiguration.MyBaitsRuntimeHintsRegistrar() + .registerHints(hints, getClass().getClassLoader()); + + assertTrue(RuntimeHintsPredicates.reflection() + .onType(org.apache.ibatis.session.SqlSessionFactory.class) + .withMemberCategory(MemberCategory.INVOKE_DECLARED_METHODS).test(hints), + "应登记方法调用权限"); + assertTrue(RuntimeHintsPredicates.reflection() + .onType(org.apache.ibatis.session.SqlSessionFactory.class) + .withMemberCategory(MemberCategory.ACCESS_DECLARED_FIELDS).test(hints), + "应登记字段访问权限"); + } + + // ---------- MyBatisMapperTypeUtils ---------- + + /** 返回类型解析:泛型 T 应解析成 mapper 接口声明的具体类型。 */ + @Test + void resolveReturnClassUnwrapsGenerics() throws Exception { + Method genericList = Holder.class.getMethod("genericList"); + assertEquals(String.class, + MyBatisNativeConfiguration.MyBatisMapperTypeUtils + .resolveReturnClass(Holder.class, genericList)); + + Method plain = Holder.class.getMethod("plain"); + assertEquals(int.class, + MyBatisNativeConfiguration.MyBatisMapperTypeUtils + .resolveReturnClass(Holder.class, plain)); + } + + /** Map 泛型取 value 类型(index 1),其他泛型取第一个参数。 */ + @Test + void resolveReturnClassPrefersMapValueType() throws Exception { + assertEquals(Integer.class, + MyBatisNativeConfiguration.MyBatisMapperTypeUtils + .resolveReturnClass(Holder.class, Holder.class.getMethod("genericMap"))); + } + + /** 数组返回类型应解析为组件类型。 */ + @Test + void resolveReturnClassUnwrapsArrays() throws Exception { + assertEquals(String.class, + MyBatisNativeConfiguration.MyBatisMapperTypeUtils + .resolveReturnClass(Holder.class, Holder.class.getMethod("arrayReturn"))); + } + + /** 参数类型解析:所有参数类都应收集到。 */ + @Test + void resolveParameterClassesCollectsEveryParameter() throws Exception { + Collection> params = MyBatisNativeConfiguration.MyBatisMapperTypeUtils + .resolveParameterClasses(Holder.class, Holder.class.getMethod("twoArgs", String.class, Integer.class)); + + assertTrue(params.contains(String.class)); + assertTrue(params.contains(Integer.class)); + } + + /** 无参数方法应得到空集合而不是异常。 */ + @Test + void resolveParameterClassesHandlesNoArguments() throws Exception { + Collection> params = MyBatisNativeConfiguration.MyBatisMapperTypeUtils + .resolveParameterClasses(Holder.class, Holder.class.getMethod("plain")); + + assertTrue(params.isEmpty(), "无参方法应返回空集合"); + } + + // ---------- MapperFactoryBean 后置处理器 ---------- + + /** + * 泛型未解析时,应把 mapper 接口注入为构造器泛型参数并设置 targetType, + * 从而避免容器提前初始化 MapperFactoryBean。 + */ + @Test + void factoryBeanPostProcessorResolvesUnresolvableMapperType() { + var processor = new MyBatisNativeConfiguration.MyBatisMapperFactoryBeanPostProcessor(); + processor.setBeanFactory(new org.springframework.beans.factory.support.DefaultListableBeanFactory()); + + RootBeanDefinition definition = new RootBeanDefinition(MapperFactoryBean.class); + definition.setTargetType(org.springframework.core.ResolvableType.forClass(MapperFactoryBean.class)); + definition.getPropertyValues().add("mapperInterface", GalleryMapper.class); + + processor.postProcessMergedBeanDefinition(definition, MapperFactoryBean.class, "galleryMapper"); + + assertEquals(GalleryMapper.class, + definition.getConstructorArgumentValues().getGenericArgumentValue(Class.class).getValue(), + "mapper 接口应被注入为构造器泛型参数"); + assertFalse(definition.getResolvableType().hasUnresolvableGenerics(), + "targetType 设置后泛型应可解析"); + } + + /** 非 MapperFactoryBean 的 bean 不应被改动。 */ + @Test + void factoryBeanPostProcessorIgnoresOtherBeans() { + var processor = new MyBatisNativeConfiguration.MyBatisMapperFactoryBeanPostProcessor(); + processor.setBeanFactory(new org.springframework.beans.factory.support.DefaultListableBeanFactory()); + + RootBeanDefinition definition = new RootBeanDefinition(String.class); + processor.postProcessMergedBeanDefinition(definition, String.class, "someString"); + + assertTrue(definition.getConstructorArgumentValues().isEmpty(), + "无关 bean 不应被注入构造器参数"); + } + + /** 取不到映射器接口时应静默返回,不影响启动。 */ + @Test + void factoryBeanPostProcessorToleratesMissingMapperInterface() { + var processor = new MyBatisNativeConfiguration.MyBatisMapperFactoryBeanPostProcessor(); + processor.setBeanFactory(new org.springframework.beans.factory.support.DefaultListableBeanFactory()); + + RootBeanDefinition definition = new RootBeanDefinition(MapperFactoryBean.class); + definition.setTargetType(org.springframework.core.ResolvableType.forClass(MapperFactoryBean.class)); + // 故意不设置 mapperInterface 属性 + + assertDoesNotThrow(() -> + processor.postProcessMergedBeanDefinition(definition, MapperFactoryBean.class, "broken")); + } + + // ---------- AOT 处理器 ---------- + + /** MapperScannerConfigurer 必须被排除在 AOT 处理之外(否则会被提前实例化)。 */ + @Test + void aotProcessorExcludesMapperScannerConfigurer() { + var processor = new MyBatisNativeConfiguration.MyBatisBeanFactoryInitializationAotProcessor(); + RegisteredBean registered = registeredBean("configurer", MapperScannerConfigurer.class); + + assertTrue(processor.isExcludedFromAotProcessing(registered)); + } + + @Test + void aotProcessorDoesNotExcludeOrdinaryBeans() { + var processor = new MyBatisNativeConfiguration.MyBatisBeanFactoryInitializationAotProcessor(); + RegisteredBean registered = registeredBean("service", GalleryMapper.class); + + assertFalse(processor.isExcludedFromAotProcessing(registered)); + } + + /** + * 没有 MapperFactoryBean 时不应产出 AOT contribution(返回 null), + * 避免为无 MyBatis 的上下文生成多余代码。 + */ + @Test + void aotProcessorReturnsNullWithoutMappers() { + var processor = new MyBatisNativeConfiguration.MyBatisBeanFactoryInitializationAotProcessor(); + // 空工厂:没有任何 MapperFactoryBean + var beanFactory = new org.springframework.beans.factory.support.DefaultListableBeanFactory(); + + assertNull(processor.processAheadOfTime(beanFactory)); + } + + /** + * 存在 MapperFactoryBean 时应产出 contribution,并在应用时注册 + * mapper 接口的反射、JDK 代理与同名 XML 资源。 + */ + @Test + void aotProcessorRegistersMapperProxyAndResources() { + var processor = new MyBatisNativeConfiguration.MyBatisBeanFactoryInitializationAotProcessor(); + // 真实工厂:注册一个 MapperFactoryBean,getBeanNamesForType 会带上 & 前缀 + var beanFactory = new org.springframework.beans.factory.support.DefaultListableBeanFactory(); + RootBeanDefinition definition = new RootBeanDefinition(MapperFactoryBean.class); + definition.getPropertyValues().add("mapperInterface", UserMapper.class); + beanFactory.registerBeanDefinition("userMapper", definition); + + var contribution = processor.processAheadOfTime(beanFactory); + assertNotNull(contribution, "有 mapper 时应产出 AOT contribution"); + + RuntimeHints hints = new RuntimeHints(); + contribution.applyTo(new StubGenerationContext(hints), new NoOpInitializationCode()); + + assertTrue(RuntimeHintsPredicates.reflection().onType(UserMapper.class).test(hints), + "mapper 接口应登记反射"); + assertTrue(RuntimeHintsPredicates.proxies().forInterfaces(UserMapper.class).test(hints), + "mapper 接口应登记 JDK 代理"); + assertTrue(RuntimeHintsPredicates.resource() + .forResource("com/lion/lionwebsite/Dao/normal/UserMapper.xml").test(hints), + "同名 XML 映射文件应登记"); + } + + /** applyTo 只用 runtimeHints,初始化代码侧给一个记录型空实现即可。 */ + private static final class NoOpInitializationCode + implements org.springframework.beans.factory.aot.BeanFactoryInitializationCode { + final java.util.List initializers = new java.util.ArrayList<>(); + + @Override + public org.springframework.aot.generate.GeneratedMethods getMethods() { + throw new UnsupportedOperationException("测试不断言生成方法"); + } + + @Override + public org.springframework.javapoet.ClassName getClassName() { + return org.springframework.javapoet.ClassName.bestGuess("com.example.Generated"); + } + + @Override + public void addInitializer(org.springframework.aot.generate.MethodReference methodReference) { + initializers.add(methodReference); + } + } + + /** + * RegisteredBean.of(beanFactory, name) 要求该 bean 已注册在工厂里, + * 因此先用一个真实的 DefaultListableBeanFactory 注册定义再取。 + */ + private static RegisteredBean registeredBean(String name, Class type) { + var factory = new org.springframework.beans.factory.support.DefaultListableBeanFactory(); + factory.registerBeanDefinition(name, new RootBeanDefinition(type)); + return RegisteredBean.of(factory, name); + } + + /** 只提供 applyTo 所需能力的极简生成上下文替身(只用到 runtimeHints)。 */ + private record StubGenerationContext(RuntimeHints hints) + implements org.springframework.aot.generate.GenerationContext { + @Override + public RuntimeHints getRuntimeHints() { + return hints; + } + + @Override + public org.springframework.aot.generate.GeneratedClasses getGeneratedClasses() { + throw new UnsupportedOperationException("测试不需要生成类"); + } + + @Override + public org.springframework.aot.generate.GeneratedFiles getGeneratedFiles() { + throw new UnsupportedOperationException("测试不需要生成文件"); + } + + @Override + public org.springframework.aot.generate.GenerationContext withName(String name) { + return this; + } + } + + /** 承载各类返回/参数类型的样例接口,用于驱动类型解析。 */ + @SuppressWarnings("unused") + interface Holder { + int plain(); + + String arrayReturn(); + + java.util.List genericList(); + + java.util.Map genericMap(); + + void twoArgs(String a, Integer b); + } +} diff --git a/src/test/java/com/lion/lionwebsite/Controller/AccountControllersTest.java b/src/test/java/com/lion/lionwebsite/Controller/AccountControllersTest.java new file mode 100644 index 0000000..b6df5dd --- /dev/null +++ b/src/test/java/com/lion/lionwebsite/Controller/AccountControllersTest.java @@ -0,0 +1,143 @@ +package com.lion.lionwebsite.Controller; + +import com.lion.lionwebsite.Service.SubService; +import com.lion.lionwebsite.Service.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * /personal/subBind 与 /personal/user 的路由契约。 + * 两个控制器都是直通转调,测试价值在于路径/方法/参数的映射正确 + * (方法写错会让前端拿到 405,参数名写错会静默传 null)。 + */ +class AccountControllersTest { + + private SubService subService; + private UserService userService; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + subService = mock(SubService.class); + userService = mock(UserService.class); + } + + private MockMvc subMvc() { + return MockMvcBuilders.standaloneSetup(new SubController(subService)).build(); + } + + private MockMvc userMvc() { + return MockMvcBuilders.standaloneSetup(new UserController(userService)).build(); + } + + // ---------- SubController ---------- + + @Test + void subBindEndpointsRouteToService() throws Exception { + var mvc = subMvc(); + + when(subService.insertSubBind("alice", 1)).thenReturn("{\"result\":\"success\"}"); + mvc.perform(post("/personal/subBind/").param("user", "alice").param("accountId", "1")) + .andExpect(status().isOk()); + verify(subService).insertSubBind("alice", 1); + + mvc.perform(put("/personal/subBind/").param("user", "alice")); + verify(subService).resetKey("alice"); + + mvc.perform(get("/personal/subBind/all")); + verify(subService).selectAllSubBind(); + + mvc.perform(get("/personal/subBind/allRecord")); + verify(subService).SelectAllSubUpdateRecord(); + + mvc.perform(delete("/personal/subBind/").param("user", "alice")); + verify(subService).deleteSubBind("alice"); + + mvc.perform(put("/personal/subBind/alice/account").param("accountId", "3")); + verify(subService).rebind("alice", 3); + } + + /** 账号增删改查与刷新。 */ + @Test + void subscriptionAccountEndpointsRouteToService() throws Exception { + var mvc = subMvc(); + + mvc.perform(get("/personal/subBind/accounts")); + verify(subService).listSubscriptionAccounts(); + + mvc.perform(post("/personal/subBind/accounts") + .param("name", "n").param("upstreamKey", "k")); + verify(subService).insertSubscriptionAccount("n", "k", true, true); + + mvc.perform(put("/personal/subBind/accounts/7") + .param("name", "n2").param("upstreamKey", "k2")); + verify(subService).updateSubscriptionAccount(7, "n2", "k2", true, true); + + mvc.perform(post("/personal/subBind/accounts/7/refresh")); + verify(subService).refreshSubscriptionAccount(7); + + mvc.perform(delete("/personal/subBind/accounts/7")); + verify(subService).deleteSubscriptionAccount(7); + } + + /** filterHighMultiplier / enabled 的默认值为 true,显式传 false 必须被尊重。 */ + @Test + void accountFlagsHonourExplicitValuesAndDefaults() throws Exception { + var mvc = subMvc(); + + mvc.perform(post("/personal/subBind/accounts") + .param("name", "n").param("upstreamKey", "k") + .param("filterHighMultiplier", "false").param("enabled", "false")); + verify(subService).insertSubscriptionAccount("n", "k", false, false); + + // 不传两个开关时使用默认 true(与前端表单默认勾选一致) + mvc.perform(put("/personal/subBind/accounts/9") + .param("name", "n").param("upstreamKey", "k")); + verify(subService).updateSubscriptionAccount(9, "n", "k", true, true); + } + + // ---------- UserController ---------- + + @Test + void userEndpointsRouteToService() throws Exception { + var mvc = userMvc(); + + mvc.perform(get("/personal/user")); + verify(userService).getAllUser(); + + mvc.perform(post("/personal/user") + .param("targetAuthCode", "code").param("username", "alice")); + verify(userService).addAuthCode("code", "alice"); + + mvc.perform(put("/personal/user/AuthCode") + .param("targetAuthCode", "old").param("newAuthCode", "new")); + verify(userService).alterAuthCode("old", "new"); + + mvc.perform(put("/personal/user/Username") + .param("targetAuthCode", "code").param("newUsername", "bob")); + verify(userService).alterUsername("code", "bob"); + + mvc.perform(delete("/personal/user").param("targetAuthCode", "code")); + verify(userService).deleteAuthCode("code"); + + mvc.perform(put("/personal/user/status") + .param("AuthCode", "code").param("isEnable", "false")); + verify(userService).alterStatus("code", false); + } + + /** 停用状态的布尔绑定:不传时按 false 处理,这是 Spring 的既有行为。 */ + @Test + void userStatusDefaultsToFalseWhenFlagOmitted() throws Exception { + var mvc = userMvc(); + + mvc.perform(put("/personal/user/status").param("AuthCode", "code")); + verify(userService).alterStatus("code", false); + } +} diff --git a/src/test/java/com/lion/lionwebsite/Controller/GalleryManageControllerTest.java b/src/test/java/com/lion/lionwebsite/Controller/GalleryManageControllerTest.java new file mode 100644 index 0000000..5490d5a --- /dev/null +++ b/src/test/java/com/lion/lionwebsite/Controller/GalleryManageControllerTest.java @@ -0,0 +1,270 @@ +package com.lion.lionwebsite.Controller; + +import com.lion.lionwebsite.Service.CollectService; +import com.lion.lionwebsite.Service.GalleryManageService; +import com.lion.lionwebsite.Service.RemoteService; +import com.lion.lionwebsite.Service.UserService; +import com.lion.lionwebsite.Util.Response; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * /GalleryManage 的 HTTP 契约。 + * 这里重点是「参数不全时必须在控制器层就拦住」,不能被透传到服务层去碰数据库; + * 以及 type 分发到正确的查询方法(写错就查错人)。 + */ +class GalleryManageControllerTest { + + private GalleryManageService galleryManageService; + private CollectService collectService; + private UserService userService; + private RemoteService remoteService; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + galleryManageService = mock(GalleryManageService.class); + collectService = mock(CollectService.class); + userService = mock(UserService.class); + remoteService = mock(RemoteService.class); + mockMvc = MockMvcBuilders.standaloneSetup(new GalleryManageController( + galleryManageService, collectService, userService, remoteService)) + // standaloneSetup 默认按 ISO-8859-1 输出字符串,会把中文写成 "?"; + // 生产环境 Spring Boot 用 UTF-8,这里对齐以免断言到被破坏的响应体。 + .setMessageConverters(new org.springframework.http.converter.StringHttpMessageConverter( + java.nio.charset.StandardCharsets.UTF_8)) + .build(); + } + + // ---------- create_task ---------- + + /** link 为空必须在控制器层拒绝,不能进服务层。 */ + @Test + void createTaskRejectsMissingLink() throws Exception { + mockMvc.perform(post("/GalleryManage").param("targetResolution", "1280x720")) + .andExpect(status().isOk()) + .andExpect(content().string(containsFailure())); + + verifyNoInteractions(galleryManageService); + } + + @Test + void createTaskRejectsMissingResolution() throws Exception { + mockMvc.perform(post("/GalleryManage").param("link", "https://exhentai.org/g/1/abc/")) + .andExpect(status().isOk()) + .andExpect(content().string(containsFailure())); + + verifyNoInteractions(galleryManageService); + } + + @Test + void createTaskDelegatesWhenParametersComplete() throws Exception { + when(galleryManageService.createTask(anyString(), anyString(), anyString())) + .thenReturn("{\"result\":\"success\"}"); + + mockMvc.perform(post("/GalleryManage") + .param("link", "https://exhentai.org/g/1/abc/") + .param("targetResolution", "1280x720") + .param("AuthCode", "code")) + .andExpect(status().isOk()) + .andExpect(content().string("{\"result\":\"success\"}")); + + verify(galleryManageService).createTask("https://exhentai.org/g/1/abc/", "1280x720", "code"); + } + + // ---------- selectGallery 的 type 分发 ---------- + + /** + * 缺 type 时回「参数不全」,但注意执行顺序:控制器先解析授权码,再校验 type。 + * 也就是说非法授权码会在更早处失败(生产上由 TaskHandlerInterceptor 先拒掉), + * 这个顺序本身是契约的一部分,故一并锁定。 + */ + @Test + void selectGalleryRejectsMissingTypeAfterResolvingUser() throws Exception { + when(userService.getUserId("code")).thenReturn(7); + + var result = mockMvc.perform(get("/GalleryManage").param("AuthCode", "code")) + .andExpect(status().isOk()) + .andReturn(); + + assertTrue(body(result).contains("failure"), "实际输出: " + body(result)); + verify(userService).getUserId("code"); + verifyNoInteractions(galleryManageService); + } + + /** 未知 type 应回「参数错误」,不得落到任何查询分支。 */ + @Test + void selectGalleryRejectsUnknownType() throws Exception { + when(userService.getUserId("code")).thenReturn(7); + + var result = mockMvc.perform(get("/GalleryManage") + .param("type", "bogus").param("AuthCode", "code")) + .andExpect(status().isOk()) + .andReturn(); + + // MockMvc 默认按 ISO-8859-1 解码响应体,中文需显式按 UTF-8 还原 + assertTrue(body(result).contains("参数错误"), "实际输出: " + body(result)); + verifyNoInteractions(galleryManageService); + } + + /** 每种 type 都要路由到对应方法,且 all/downloader 必须带上解析出的 userId/授权码。 */ + @Test + void selectGalleryRoutesEachTypeToItsQuery() throws Exception { + when(userService.getUserId("code")).thenReturn(7); + + mockMvc.perform(get("/GalleryManage").param("type", "link") + .param("param", "https://e/g/1/").param("AuthCode", "code")); + verify(galleryManageService).selectTaskByLink("https://e/g/1/"); + + mockMvc.perform(get("/GalleryManage").param("type", "gid") + .param("param", "123").param("AuthCode", "code")); + verify(galleryManageService).selectTaskByGid(123); + + mockMvc.perform(get("/GalleryManage").param("type", "all").param("AuthCode", "code")); + verify(galleryManageService).selectAllGallery(7); + + mockMvc.perform(get("/GalleryManage").param("type", "name") + .param("param", "sakura").param("AuthCode", "code")); + verify(galleryManageService).selectGalleryByName("sakura"); + + mockMvc.perform(get("/GalleryManage").param("type", "downloader").param("AuthCode", "code")); + verify(galleryManageService).selectGalleryByDownloader("code"); + } + + /** gid 非数字应抛绑定异常而不是静默查 0 号。 */ + @Test + void selectGalleryRejectsNonNumericGid() throws Exception { + when(userService.getUserId("code")).thenReturn(7); + + try { + mockMvc.perform(get("/GalleryManage") + .param("type", "gid").param("param", "not-a-number").param("AuthCode", "code")); + fail("非数字 gid 应抛出 NumberFormatException"); + } catch (Exception e) { + assertInstanceOf(NumberFormatException.class, e.getCause() == null ? e : e.getCause()); + } + } + + // ---------- deleteTask ---------- + + @Test + void deleteTaskRejectsMissingGid() throws Exception { + mockMvc.perform(delete("/GalleryManage").param("AuthCode", "code")) + .andExpect(status().isOk()) + .andExpect(content().string(containsFailure())); + + verifyNoInteractions(galleryManageService); + } + + @Test + void deleteTaskDelegatesWithGidAndAuthCode() throws Exception { + when(galleryManageService.deleteGalleryByGid(55, "code")).thenReturn("{\"result\":\"success\"}"); + + mockMvc.perform(delete("/GalleryManage") + .param("gid", "55").param("AuthCode", "code")) + .andExpect(status().isOk()) + .andExpect(content().string("{\"result\":\"success\"}")); + + verify(galleryManageService).deleteGalleryByGid(55, "code"); + } + + // ---------- 收藏 ---------- + + /** 收藏使用授权码解析出的 userId,而不是请求里的任意值。 */ + @Test + void collectUsesResolvedUserId() throws Exception { + when(userService.getUserId("code")).thenReturn(7); + when(collectService.collectGallery(9, 7)).thenReturn("{\"result\":\"success\"}"); + + mockMvc.perform(post("/GalleryManage/collect") + .param("gid", "9").param("AuthCode", "code")) + .andExpect(status().isOk()); + + verify(collectService).collectGallery(9, 7); + } + + @Test + void disCollectUsesResolvedUserId() throws Exception { + when(userService.getUserId("code")).thenReturn(7); + when(collectService.disCollectGallery(9, 7)).thenReturn("{\"result\":\"success\"}"); + + mockMvc.perform(post("/GalleryManage/disCollect") + .param("gid", "9").param("AuthCode", "code")) + .andExpect(status().isOk()); + + verify(collectService).disCollectGallery(9, 7); + } + + // ---------- 其余直通接口 ---------- + + @Test + void simpleEndpointsDelegateToService() throws Exception { + mockMvc.perform(post("/GalleryManage/reconnect")); + verify(galleryManageService).reconnect(); + + mockMvc.perform(post("/GalleryManage/test")); + verify(remoteService).checkAvailability(); + + mockMvc.perform(get("/GalleryManage/weekUsedAmount")); + verify(galleryManageService).getWeekUsedAmount(); + + mockMvc.perform(post("/GalleryManage/cache").param("url", "https://e/g/1/x/")); + verify(galleryManageService).cacheImagesKey("https://e/g/1/x/"); + + mockMvc.perform(post("/GalleryManage/reset")); + verify(galleryManageService).resetUndone(); + } + + @Test + void retryRejectsMissingGidButAcceptsPresentOne() throws Exception { + mockMvc.perform(post("/GalleryManage/retry")) + .andExpect(status().isOk()) + .andExpect(content().string(containsFailure())); + verify(galleryManageService, never()).retryGallery(anyInt()); + + mockMvc.perform(post("/GalleryManage/retry").param("gid", "12")); + verify(galleryManageService).retryGallery(12); + } + + /** 在线图片接口返回 Callable(异步),控制器必须原样交回而不立即执行。 */ + @Test + void onlineImageReturnsCallableUnresolved() throws Exception { + java.util.concurrent.atomic.AtomicBoolean invoked = new java.util.concurrent.atomic.AtomicBoolean(); + when(galleryManageService.getCachedImage(eq("123"), eq(2), any(), any())) + .thenReturn(() -> { + invoked.set(true); + return null; + }); + + mockMvc.perform(get("/GalleryManage/onlineImage/2").param("gid", "123")) + .andExpect(request().asyncStarted()); + + verify(galleryManageService).getCachedImage(eq("123"), eq(2), + any(HttpServletRequest.class), any(HttpServletResponse.class)); + } + + private static org.hamcrest.Matcher containsFailure() { + return org.hamcrest.Matchers.containsString("\"failure\""); + } + + /** 按 UTF-8 还原响应体(MockMvc 默认字符集会把中文解成乱码)。 */ + private static String body(org.springframework.test.web.servlet.MvcResult result) throws Exception { + return result.getResponse().getContentAsString(java.nio.charset.StandardCharsets.UTF_8); + } + + /** 供断言使用的极小响应体,避免测试里散落魔法字符串。 */ + @SuppressWarnings("unused") + private static String failureBody(String reason) { + return Response._failure(reason); + } +} diff --git a/src/test/java/com/lion/lionwebsite/Controller/PersonalControllerTest.java b/src/test/java/com/lion/lionwebsite/Controller/PersonalControllerTest.java new file mode 100644 index 0000000..f8db837 --- /dev/null +++ b/src/test/java/com/lion/lionwebsite/Controller/PersonalControllerTest.java @@ -0,0 +1,125 @@ +package com.lion.lionwebsite.Controller; + +import com.lion.lionwebsite.Service.LocalService; +import com.lion.lionwebsite.Service.PersonalService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * /personal 的 HTTP 契约。 + * 这些接口能做到浏览/上传/删除文件,权限由 PersonalInterceptor 另外把关 + * (见 PersonalInterceptorTest),此处只验证参数如何传到服务层。 + */ +class PersonalControllerTest { + + private PersonalService personalService; + private LocalService localService; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + personalService = mock(PersonalService.class); + localService = mock(LocalService.class); + mockMvc = MockMvcBuilders + .standaloneSetup(new PersonalController(personalService, localService)) + .build(); + } + + @Test + void indexRedirectsToIndexPage() throws Exception { + mockMvc.perform(get("/personal/")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/index")); + } + + @Test + void fileListPassesPathThrough() throws Exception { + when(personalService.getFiles("/docs")).thenReturn("{\"result\":\"success\"}"); + + mockMvc.perform(get("/personal/files").param("path", "/docs")) + .andExpect(status().isOk()); + + verify(personalService).getFiles("/docs"); + } + + /** 上传要同时带上目标路径、文件名与文件体。 */ + @Test + void uploadPassesPathFileNameAndContent() throws Exception { + var file = new MockMultipartFile("file", "a.txt", "text/plain", "hi".getBytes()); + + mockMvc.perform(multipart("/personal/uploadFile") + .file(file) + .param("path", "/docs") + .param("fileName", "a.txt")) + .andExpect(status().isOk()); + + verify(personalService).uploadFile(eq("/docs"), eq("a.txt"), any()); + } + + /** 下载走通配路径,path 参数与 request/response 都要透传。 */ + @Test + void downloadForwardsWildcardPath() throws Exception { + mockMvc.perform(get("/personal/private/deep/nested/file.txt").param("path", "/deep/nested/file.txt")) + .andExpect(status().isOk()); + + verify(personalService).download(any(HttpServletRequest.class), any(HttpServletResponse.class), + eq("/deep/nested/file.txt")); + } + + @Test + void simpleOperationsDelegateWithTheirParameters() throws Exception { + mockMvc.perform(post("/personal/share").param("path", "/a.txt").param("expireHour", "24")); + verify(personalService).shareFile("/a.txt", 24); + + mockMvc.perform(post("/personal/compress").param("path", "/dir")); + verify(personalService).compress("/dir"); + + mockMvc.perform(post("/personal/delete").param("path", "/a.txt")); + verify(personalService).deleteFile("/a.txt"); + + mockMvc.perform(post("/personal/extendShareTime").param("path", "/a.txt").param("extendHour", "2")); + verify(personalService).extendShareTime("/a.txt", 2); + + mockMvc.perform(post("/personal/cancelShare").param("path", "/a.txt")); + verify(personalService).cancelShare("/a.txt"); + + mockMvc.perform(get("/personal/lastUpdate")); + verify(personalService).lastUpdate(); + + mockMvc.perform(get("/personal/ip")); + verify(personalService).getIp(); + + mockMvc.perform(post("/personal/message2me").param("message", "hello")); + verify(personalService).message2me("hello"); + } + + /** 手动更新订阅:成功与失败必须映射成不同的 result,前端据此提示。 */ + @Test + void updateSubReflectsServiceOutcome() throws Exception { + when(localService.updateSub(true)).thenReturn(true); + + var ok = mockMvc.perform(post("/personal/updateSub")) + .andExpect(status().isOk()) + .andReturn(); + assertTrue(ok.getResponse().getContentAsString().contains("\"result\":\"success\"")); + + when(localService.updateSub(true)).thenReturn(false); + var failed = mockMvc.perform(post("/personal/updateSub")) + .andExpect(status().isOk()) + .andReturn(); + assertTrue(failed.getResponse().getContentAsString().contains("\"result\":\"failure\"")); + + verify(localService, times(2)).updateSub(true); + } +} diff --git a/src/test/java/com/lion/lionwebsite/Controller/PublicControllerTest.java b/src/test/java/com/lion/lionwebsite/Controller/PublicControllerTest.java new file mode 100644 index 0000000..d631478 --- /dev/null +++ b/src/test/java/com/lion/lionwebsite/Controller/PublicControllerTest.java @@ -0,0 +1,196 @@ +package com.lion.lionwebsite.Controller; + +import com.lion.lionwebsite.Domain.User; +import com.lion.lionwebsite.Service.PublicService; +import com.lion.lionwebsite.Service.QueryService; +import com.lion.lionwebsite.Service.RemoteService; +import com.lion.lionwebsite.Service.SubService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * 公开路由的 HTTP 契约:路径、方法、参数绑定与响应内容。 + * 全部用 standaloneSetup(不加载 Spring 上下文、不连数据库、不占端口), + * 拦截器另行单测,这里只锁控制器自身的分发与转发行为。 + */ +class PublicControllerTest { + + private PublicService publicService; + private RemoteService remoteService; + private SubService subService; + private QueryService queryService; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + publicService = mock(PublicService.class); + remoteService = mock(RemoteService.class); + subService = mock(SubService.class); + queryService = mock(QueryService.class); + mockMvc = MockMvcBuilders + .standaloneSetup(new PublicController(publicService, remoteService, subService, queryService)) + .build(); + } + + @Test + void indexRedirectsToIndexPage() throws Exception { + mockMvc.perform(get("/")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/index")); + } + + /** 有 X-Forwarded-For 时以它为准(反向代理后的真实客户端 IP)。 */ + @Test + void ipPrefersForwardedForHeader() throws Exception { + mockMvc.perform(get("/ip").header("X-Forwarded-For", "203.0.113.7")) + .andExpect(status().isOk()) + .andExpect(content().string("203.0.113.7")); + + verify(publicService, never()).logIpAddress(anyString()); + } + + /** 没有转发头时回退到 remoteAddr。 */ + @Test + void ipFallsBackToRemoteAddress() throws Exception { + mockMvc.perform(get("/ip").with(request -> { + request.setRemoteAddr("198.51.100.9"); + return request; + })) + .andExpect(status().isOk()) + .andExpect(content().string("198.51.100.9")); + } + + /** 只有 auth=ip 时才记录家里 IP,其他取值不得写库。 */ + @Test + void ipOnlyLogsWhenAuthIsIp() throws Exception { + mockMvc.perform(get("/ip").param("auth", "ip").header("X-Forwarded-For", "203.0.113.7")) + .andExpect(status().isOk()); + verify(publicService).logIpAddress("203.0.113.7"); + + mockMvc.perform(get("/ip").param("auth", "other").header("X-Forwarded-For", "203.0.113.8")) + .andExpect(status().isOk()); + verify(publicService, times(1)).logIpAddress(anyString()); + } + + /** + * 返回体形如 {"result":"success","data":"{\"userId\": 7, ...}"}—— + * data 是「JSON 文本的字符串」(历史契约,前端按字符串解析后再反序列化)。 + */ + @Test + void validateReturnsIdentityAndNodeAvailability() throws Exception { + when(publicService.getUserId("code")).thenReturn(new User(7, "code", "alice", null, true)); + when(remoteService.isDead()).thenReturn(false); + + mockMvc.perform(post("/validate").param("AuthCode", "code")) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"userId\\\": 7"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"username\\\": \\\"alice\\\""))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"isAvailable\\\": true"))); + } + + /** 存储节点掉线时 isAvailable 必须为 false,前端据此提示。 */ + @Test + void validateReportsUnavailableWhenNodeIsDead() throws Exception { + when(publicService.getUserId("code")).thenReturn(new User(7, "code", "alice", null, true)); + when(remoteService.isDead()).thenReturn(true); + + mockMvc.perform(post("/validate").param("AuthCode", "code")) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("\\\"isAvailable\\\": false"))); + } + + @Test + void alterAuthCodeDelegatesToService() throws Exception { + when(publicService.alterAuthCode("old", "new")).thenReturn("{\"result\":\"success\"}"); + + mockMvc.perform(put("/AuthCode").param("AuthCode", "old").param("newAuthCode", "new")) + .andExpect(status().isOk()) + .andExpect(content().string("{\"result\":\"success\"}")); + + verify(publicService).alterAuthCode("old", "new"); + } + + /** 订阅分发必须把 client 与 key 原样交给服务层(含路径中的 key)。 */ + @Test + void publicSubPassesClientAndKeyToService() throws Exception { + mockMvc.perform(get("/sub/v2/abcd1234")) + .andExpect(status().isOk()); + + verify(subService).updateSub(any(HttpServletResponse.class), any(HttpServletRequest.class), + eq("v2"), eq("abcd1234")); + } + + @Test + void ehThumbnailDelegatesToQueryService() throws Exception { + mockMvc.perform(get("/GalleryManage/ehThumbnail").param("path", "123/abc.jpg")) + .andExpect(status().isOk()); + + verify(queryService).getEhThumbnail(eq("123/abc.jpg"), + any(HttpServletRequest.class), any(HttpServletResponse.class)); + } + + // ---------- GetFile 与失败分享码黑名单 ---------- + + @Test + void getFileForwardsToService() throws Exception { + when(publicService.GetFile(any(), any(), eq("goodcode"))).thenReturn(true); + + mockMvc.perform(get("/GetFile/a.txt").param("ShareCode", "goodcode")) + .andExpect(status().isOk()); + + verify(publicService).GetFile(any(), any(), eq("goodcode")); + } + + /** + * 失败的分享码会被加入黑名单,后续相同请求直接短路返回, + * 不再反复查库/探测文件(防爆破)。这是该接口唯一的限流手段。 + */ + @Test + void getFileBlacklistsFailingShareCode() throws Exception { + when(publicService.GetFile(any(), any(), eq("badcode"))).thenReturn(false); + + mockMvc.perform(get("/GetFile/a.txt").param("ShareCode", "badcode")); + mockMvc.perform(get("/GetFile/a.txt").param("ShareCode", "badcode")); + mockMvc.perform(get("/GetFile/a.txt").param("ShareCode", "badcode")); + + verify(publicService, times(1)).GetFile(any(), any(), eq("badcode")); + } + + /** 不同分享码各自独立计数,一个坏码不应影响好码。 */ + @Test + void blacklistIsPerShareCode() throws Exception { + when(publicService.GetFile(any(), any(), eq("bad-1"))).thenReturn(false); + when(publicService.GetFile(any(), any(), eq("good-2"))).thenReturn(true); + + mockMvc.perform(get("/GetFile/a.txt").param("ShareCode", "bad-1")); + mockMvc.perform(get("/GetFile/a.txt").param("ShareCode", "good-2")); + + verify(publicService).GetFile(any(), any(), eq("bad-1")); + verify(publicService).GetFile(any(), any(), eq("good-2")); + } + + /** 黑名单上限 100:超过后最旧的条目被淘汰,不会无界增长。 */ + @Test + void blacklistIsCappedAtHundredEntries() throws Exception { + when(publicService.GetFile(any(), any(), anyString())).thenReturn(false); + + for (int i = 0; i < 105; i++) + mockMvc.perform(get("/GetFile/a.txt").param("ShareCode", "code-" + i)); + + // 105 个不同坏码各触发一次服务调用;最早那批已被挤出,容量保持有界 + verify(publicService, times(105)).GetFile(any(), any(), anyString()); + + // 已被淘汰的 code-0 再次请求会重新走一次服务层(说明它确实被移出了黑名单) + mockMvc.perform(get("/GetFile/a.txt").param("ShareCode", "code-0")); + verify(publicService, times(106)).GetFile(any(), any(), anyString()); + } +} diff --git a/src/test/java/com/lion/lionwebsite/Controller/QueryControllerTest.java b/src/test/java/com/lion/lionwebsite/Controller/QueryControllerTest.java new file mode 100644 index 0000000..0a0e09a --- /dev/null +++ b/src/test/java/com/lion/lionwebsite/Controller/QueryControllerTest.java @@ -0,0 +1,57 @@ +package com.lion.lionwebsite.Controller; + +import com.lion.lionwebsite.Service.QueryService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * /query 搜索入口的路由契约:三个可选参数(keyword/prev/next)必须原样透传, + * 因为分页完全依赖 prev/next 拼 URL。 + */ +class QueryControllerTest { + + private QueryService queryService; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + queryService = mock(QueryService.class); + mockMvc = MockMvcBuilders.standaloneSetup(new QueryController(queryService)).build(); + } + + @Test + void queryForwardsAllThreeParameters() throws Exception { + when(queryService.query("sakura", "P", "N")).thenReturn("{\"result\":\"success\"}"); + + mockMvc.perform(get("/query") + .param("keyword", "sakura").param("prev", "P").param("next", "N")) + .andExpect(status().isOk()) + .andExpect(content().string("{\"result\":\"success\"}")); + + verify(queryService).query("sakura", "P", "N"); + } + + /** 分页参数缺省时应传 null(服务层据此决定是否拼分页参数)。 */ + @Test + void queryPassesNullsForAbsentPagination() throws Exception { + mockMvc.perform(get("/query").param("keyword", "sakura")) + .andExpect(status().isOk()); + + verify(queryService).query("sakura", null, null); + } + + /** 完全不带参数也应正常到达服务层,由它决定如何应对空关键词。 */ + @Test + void queryWithoutKeywordReachesService() throws Exception { + mockMvc.perform(get("/query")) + .andExpect(status().isOk()); + + verify(queryService).query(null, null, null); + } +} diff --git a/src/test/java/com/lion/lionwebsite/Filter/AccessFilterTest.java b/src/test/java/com/lion/lionwebsite/Filter/AccessFilterTest.java new file mode 100644 index 0000000..d9af340 --- /dev/null +++ b/src/test/java/com/lion/lionwebsite/Filter/AccessFilterTest.java @@ -0,0 +1,66 @@ +package com.lion.lionwebsite.Filter; + +import com.lion.lionwebsite.Dao.normal.UserMapper; +import jakarta.servlet.FilterChain; +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 static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * AccessFilter 挂在 /validate 上,用于更新用户最后访问时间。 + * 关键约束:只有带了 AuthCode 才落库并继续过滤链;没带时必须直接返回 + * (不调用 chain.doFilter),否则匿名请求会污染访问时间统计。 + */ +class AccessFilterTest { + + private UserMapper userMapper; + private AccessFilter filter; + private FilterChain chain; + + @BeforeEach + void setUp() { + userMapper = mock(UserMapper.class); + filter = new AccessFilter(userMapper); + chain = mock(FilterChain.class); + } + + /** 带授权码:记录访问时间并放行。 */ + @Test + void recordsLastAccessTimeAndContinues() throws Exception { + var request = new MockHttpServletRequest(); + request.setParameter("AuthCode", "code-1"); + var response = new MockHttpServletResponse(); + + filter.doFilter(request, response, chain); + + verify(userMapper).updateLastAccessTime(anyString(), eq("code-1")); + verify(chain).doFilter(request, response); + } + + /** 不带授权码:既不写库也不继续过滤链。 */ + @Test + void missingAuthCodeStopsChainWithoutWriting() throws Exception { + var request = new MockHttpServletRequest(); + var response = new MockHttpServletResponse(); + + filter.doFilter(request, response, chain); + + verifyNoInteractions(userMapper); + verify(chain, never()).doFilter(any(), any()); + } + + /** 空字符串授权码视为非法,同样不写库。 */ + @Test + void emptyAuthCodeIsIgnored() throws Exception { + var request = new MockHttpServletRequest(); + request.setParameter("AuthCode", ""); + + filter.doFilter(request, new MockHttpServletResponse(), chain); + + verify(userMapper).updateLastAccessTime(anyString(), eq("")); + } +} diff --git a/src/test/java/com/lion/lionwebsite/Interceptor/InterceptorsTest.java b/src/test/java/com/lion/lionwebsite/Interceptor/InterceptorsTest.java new file mode 100644 index 0000000..3e76d64 --- /dev/null +++ b/src/test/java/com/lion/lionwebsite/Interceptor/InterceptorsTest.java @@ -0,0 +1,156 @@ +package com.lion.lionwebsite.Interceptor; + +import com.lion.lionwebsite.Dao.normal.UserMapper; +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 static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * 拦截器是授权之外的第二道闸门: + * - HumanInterceptor 决定无 UA 的请求能否进入首页(挡机器人); + * - PersonalInterceptor 决定能否触达 /personal 与 /remote(必须 AuthCode=alone)。 + * 两者都返回 boolean,一旦写错方向就是「全放行」或「全拦截」,后果极端, + * 所以每个分支都单独锁定。 + */ +class InterceptorsTest { + + // ---------- HumanInterceptor ---------- + + private HumanInterceptor human; + + @BeforeEach + void setUp() { + human = new HumanInterceptor(); + } + + @Test + void humanAllowsRequestWithUserAgent() { + var request = new MockHttpServletRequest(); + request.addHeader("User-Agent", "Mozilla/5.0"); + + assertTrue(human.preHandle(request, new MockHttpServletResponse(), new Object())); + } + + /** 无 User-Agent 一律拒绝(爬虫通常不带)。 */ + @Test + void humanRejectsRequestWithoutUserAgent() { + var request = new MockHttpServletRequest(); + + assertFalse(human.preHandle(request, new MockHttpServletResponse(), new Object())); + } + + /** 空字符串也算「有 UA 头」,按现状放行(与 null 区分)。 */ + @Test + void humanTreatsEmptyHeaderAsPresent() { + var request = new MockHttpServletRequest(); + request.addHeader("User-Agent", ""); + + assertTrue(human.preHandle(request, new MockHttpServletResponse(), new Object())); + } + + // ---------- PersonalInterceptor ---------- + + private PersonalInterceptor personal; + + @BeforeEach + void setUpPersonal() { + personal = new PersonalInterceptor(); + } + + /** 只有 AuthCode=alone 才放行。 */ + @Test + void personalAllowsOnlyAloneAuthCode() { + var ok = new MockHttpServletRequest(); + ok.setParameter("AuthCode", "alone"); + assertTrue(personal.preHandle(ok, new MockHttpServletResponse(), new Object())); + } + + @Test + void personalRejectsMissingOrDifferentAuthCode() { + var missing = new MockHttpServletRequest(); + assertFalse(personal.preHandle(missing, new MockHttpServletResponse(), new Object())); + + var wrong = new MockHttpServletRequest(); + wrong.setParameter("AuthCode", "user-code"); + assertFalse(personal.preHandle(wrong, new MockHttpServletResponse(), new Object())); + + var empty = new MockHttpServletRequest(); + empty.setParameter("AuthCode", ""); + assertFalse(personal.preHandle(empty, new MockHttpServletResponse(), new Object())); + } + + /** 大小写敏感:ALONE 不是 alone。 */ + @Test + void personalIsCaseSensitive() { + var request = new MockHttpServletRequest(); + request.setParameter("AuthCode", "ALONE"); + + assertFalse(personal.preHandle(request, new MockHttpServletResponse(), new Object())); + } + + // ---------- TaskHandlerInterceptor ---------- + + /** + * 授权码集合在启动时加载一次,之后靠 updateAuthCodes() 刷新。 + * 这里重点验证:合法码放行、非法/缺失码拒绝、刷新后立即生效。 + */ + @Test + void taskHandlerAcceptsKnownCodeAndRejectsOthers() { + var userMapper = mock(UserMapper.class); + when(userMapper.selectAllAuthCode()).thenReturn(new String[]{"code-a", "code-b"}); + var interceptor = new TaskHandlerInterceptor(userMapper); + interceptor.init(); + + var valid = new MockHttpServletRequest(); + valid.setParameter("AuthCode", "code-a"); + assertTrue(interceptor.preHandle(valid, new MockHttpServletResponse(), new Object())); + + var invalid = new MockHttpServletRequest(); + invalid.setParameter("AuthCode", "code-x"); + assertFalse(interceptor.preHandle(invalid, new MockHttpServletResponse(), new Object())); + + var absent = new MockHttpServletRequest(); + assertFalse(interceptor.preHandle(absent, new MockHttpServletResponse(), new Object())); + } + + /** updateAuthCodes 取的是「启用」集合;刷新后旧码必须立即失效。 */ + @Test + void taskHandlerRefreshTakesEffectImmediately() { + var userMapper = mock(UserMapper.class); + when(userMapper.selectAllAuthCode()).thenReturn(new String[]{"old-code"}); + when(userMapper.selectEnableAuthCode()).thenReturn(new String[]{"new-code"}); + var interceptor = new TaskHandlerInterceptor(userMapper); + interceptor.init(); + + var stale = new MockHttpServletRequest(); + stale.setParameter("AuthCode", "old-code"); + assertTrue(interceptor.preHandle(stale, new MockHttpServletResponse(), new Object())); + + interceptor.updateAuthCodes(); + + assertFalse(interceptor.preHandle(stale, new MockHttpServletResponse(), new Object()), + "刷新后旧授权码应立即失效"); + + var fresh = new MockHttpServletRequest(); + fresh.setParameter("AuthCode", "new-code"); + assertTrue(interceptor.preHandle(fresh, new MockHttpServletResponse(), new Object())); + } + + /** 数据库无任何授权码时,任何请求都必须被拒(不能因空数组而误放行)。 */ + @Test + void taskHandlerRejectsEverythingWhenNoCodesExist() { + var userMapper = mock(UserMapper.class); + when(userMapper.selectAllAuthCode()).thenReturn(new String[0]); + var interceptor = new TaskHandlerInterceptor(userMapper); + interceptor.init(); + + var request = new MockHttpServletRequest(); + request.setParameter("AuthCode", "any"); + + assertFalse(interceptor.preHandle(request, new MockHttpServletResponse(), new Object())); + } +} diff --git a/src/test/java/com/lion/lionwebsite/Service/CollectServiceTest.java b/src/test/java/com/lion/lionwebsite/Service/CollectServiceTest.java new file mode 100644 index 0000000..43f758f --- /dev/null +++ b/src/test/java/com/lion/lionwebsite/Service/CollectServiceTest.java @@ -0,0 +1,67 @@ +package com.lion.lionwebsite.Service; + +import com.lion.lionwebsite.Dao.normal.CollectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 画廊收藏与取消收藏。规则很简单,但「重复收藏」与「取消未收藏」两条分支 + * 决定落库与否,必须逐一锁死。 + */ +class CollectServiceTest { + + private CollectMapper collectMapper; + private CollectService service; + + @BeforeEach + void setUp() { + collectMapper = mock(CollectMapper.class); + service = new CollectService(collectMapper); + } + + private static boolean ok(String json) { + return json.contains("\"result\":\"success\""); + } + + @Test + void collectWritesWhenNotYetCollected() { + when(collectMapper.isCollect(100, 5)).thenReturn(0); + + assertTrue(ok(service.collectGallery(100, 5))); + verify(collectMapper).collect(100, 5); + } + + /** 重复收藏必须拒绝且不得重复落库。 */ + @Test + void collectRejectsDuplicate() { + when(collectMapper.isCollect(100, 5)).thenReturn(1); + + String json = service.collectGallery(100, 5); + assertFalse(ok(json)); + assertTrue(json.contains("已经收藏了")); + verify(collectMapper, never()).collect(anyInt(), anyInt()); + } + + @Test + void disCollectRemovesWhenCollected() { + when(collectMapper.isCollect(100, 5)).thenReturn(1); + + assertTrue(ok(service.disCollectGallery(100, 5))); + verify(collectMapper).disCollect(100, 5); + } + + /** 取消一个没收藏的画廊必须拒绝,且不得落库。 */ + @Test + void disCollectRejectsWhenNotCollected() { + when(collectMapper.isCollect(100, 5)).thenReturn(0); + + String json = service.disCollectGallery(100, 5); + assertFalse(ok(json)); + assertTrue(json.contains("没有收藏该图片")); + verify(collectMapper, never()).disCollect(anyInt(), anyInt()); + } +} diff --git a/src/test/java/com/lion/lionwebsite/Service/GalleryManageServiceTest.java b/src/test/java/com/lion/lionwebsite/Service/GalleryManageServiceTest.java index 2a51d91..6b182da 100644 --- a/src/test/java/com/lion/lionwebsite/Service/GalleryManageServiceTest.java +++ b/src/test/java/com/lion/lionwebsite/Service/GalleryManageServiceTest.java @@ -44,6 +44,99 @@ class GalleryManageServiceTest { when(users.selectUserByAuthCode("code")).thenReturn(user); } + // ---------- cacheImagesKey 的异常兜底 ---------- + + /** + * 回归修复验证(缺陷于 2026-09-14 Jackson 2→3 迁移引入,2026-09-15 修复)。 + * + * 线上真实 mpv 页的 imagelist 是 JS 语句、行尾带分号;解析失败曾以 + * `JacksonException`(Jackson 3 中继承 RuntimeException,不再是 IOException)穿透 + * `catch (IOException)`,导致新画廊在线看图 500。 + * 修复后:解析成功并落库,不再抛异常。 + */ + @Test + void cacheImagesKeyParsesRealPageFormatAndCachesKeys() throws Exception { + ImageCacheMapper imageCacheMapper = mock(ImageCacheMapper.class); + when(imageCacheMapper.selectKeyByGid(anyString())).thenReturn(null); // 缓存未命中 + GalleryManageService svc = new GalleryManageService(galleries, collectMapper, + configurationMapper, users, mock(ShareFileMapper.class), + imageCacheMapper, remote, push); + + // imagelist 行以分号结尾——即线上真实页面格式 + String realMpvPage = ""; + + try (var parser = mockStatic(com.lion.lionwebsite.Util.GalleryUtil.class)) { + parser.when(() -> com.lion.lionwebsite.Util.GalleryUtil.parseImageKeys(anyString())) + .thenCallRealMethod(); + parser.when(() -> com.lion.lionwebsite.Util.GalleryUtil + .requests(anyString(), anyString(), any(), any())) + .thenReturn(realMpvPage); + + String json = svc.cacheImagesKey("https://exhentai.org/g/1596929/f08534d87d/"); + + assertTrue(json.contains("\"result\":\"success\""), "应缓存成功,实际: " + json); + } + + verify(imageCacheMapper).insertGidToKey(any()); + verify(imageCacheMapper).insertImageKeyCache(any()); + } + + /** + * 第三方页面格式异常时必须回业务失败,不能再穿透成 500。 + * 这里让 requests 返回畸形 JSON,验证 catch 兜住解析异常。 + */ + @Test + void cacheImagesKeyConvertsParseFailureToBusinessFailure() throws Exception { + ImageCacheMapper imageCacheMapper = mock(ImageCacheMapper.class); + when(imageCacheMapper.selectKeyByGid(anyString())).thenReturn(null); + GalleryManageService svc = new GalleryManageService(galleries, collectMapper, + configurationMapper, users, mock(ShareFileMapper.class), + imageCacheMapper, remote, push); + + String brokenPage = ""; + + try (var parser = mockStatic(com.lion.lionwebsite.Util.GalleryUtil.class)) { + parser.when(() -> com.lion.lionwebsite.Util.GalleryUtil.parseImageKeys(anyString())) + .thenCallRealMethod(); + parser.when(() -> com.lion.lionwebsite.Util.GalleryUtil + .requests(anyString(), anyString(), any(), any())) + .thenReturn(brokenPage); + + String json = assertDoesNotThrow(() -> + svc.cacheImagesKey("https://exhentai.org/g/1596929/f08534d87d/")); + + assertFalse(json.contains("\"result\":\"success\""), "不应报成功: " + json); + assertTrue(json.contains("网络波动或其他异常"), "实际: " + json); + } + + verify(imageCacheMapper, never()).insertGidToKey(any()); + verify(imageCacheMapper, never()).insertImageKeyCache(any()); + } + + /** 畸形链接(段数不足)必须回业务失败,不得抛 ArrayIndexOutOfBoundsException。 */ + @Test + void cacheImagesKeyRejectsMalformedLink() { + ImageCacheMapper imageCacheMapper = mock(ImageCacheMapper.class); + GalleryManageService svc = new GalleryManageService(galleries, collectMapper, + configurationMapper, users, mock(ShareFileMapper.class), + imageCacheMapper, remote, push); + + for (String bad : new String[]{"abc", "https://exhentai.org/g/1/", null}) { + String json = assertDoesNotThrow(() -> svc.cacheImagesKey(bad), + "畸形链接不应抛异常,实际输入: " + bad); + assertTrue(json.contains("链接错误"), "实际输出: " + json); + } + verify(imageCacheMapper, never()).insertGidToKey(any()); + } + // ---------- createTask 输入校验 ---------- /** 链接第 5 段非数字时应返回「链接错误」且不落库、不下发节点。 */ diff --git a/src/test/java/com/lion/lionwebsite/Service/GalleryQueryTest.java b/src/test/java/com/lion/lionwebsite/Service/GalleryQueryTest.java new file mode 100644 index 0000000..d46c420 --- /dev/null +++ b/src/test/java/com/lion/lionwebsite/Service/GalleryQueryTest.java @@ -0,0 +1,387 @@ +package com.lion.lionwebsite.Service; + +import com.lion.lionwebsite.Dao.cache.ImageCacheMapper; +import com.lion.lionwebsite.Dao.normal.*; +import com.lion.lionwebsite.Domain.Gallery; +import com.lion.lionwebsite.Domain.User; +import com.lion.lionwebsite.Util.GalleryUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 任务查询层与其收藏标记、未完成任务重投。 + * 这些方法决定前端列表里「哪些是已收藏」「哪些任务能重试」, + * 以及重置时会不会把不该重投的任务再推给节点(会重复下载、浪费额度)。 + */ +class GalleryQueryTest { + + private GalleryMapper galleries; + private CollectMapper collectMapper; + private UserMapper users; + private RemoteService remote; + private GalleryManageService service; + + @BeforeEach + void setUp() { + galleries = mock(GalleryMapper.class); + collectMapper = mock(CollectMapper.class); + users = mock(UserMapper.class); + remote = mock(RemoteService.class); + service = new GalleryManageService(galleries, collectMapper, + mock(CustomConfigurationMapper.class), users, mock(ShareFileMapper.class), + mock(ImageCacheMapper.class), remote, mock(PushService.class)); + } + + private static Gallery gallery(int gid, String name, String status) { + Gallery g = new Gallery(); + g.setGid(gid); + g.setName(name); + g.setStatus(status); + return g; + } + + private static boolean ok(String json) { + return json.contains("\"result\":\"success\""); + } + + // ---------- selectAllGallery ---------- + + /** 无收藏时返回全部任务,且不误标 collect。 */ + @Test + void selectAllGalleryWithoutCollections() { + Gallery[] all = {gallery(1, "A", "下载中"), gallery(2, "B", "下载完成")}; + when(galleries.selectAllGallery()).thenReturn(all); + when(collectMapper.selectGidByCollector(7)).thenReturn(new ArrayList<>()); + + String json = service.selectAllGallery(7); + + assertTrue(ok(json), "实际输出: " + json); + assertTrue(json.contains("A")); + assertTrue(json.contains("B")); + assertFalse(all[0].isCollect(), "无收藏时不应标记为已收藏"); + } + + /** 收藏过的任务必须被标记 collect=true,其余保持 false。 */ + @Test + void selectAllGalleryMarksCollectedGalleries() { + Gallery[] all = {gallery(1, "A", "x"), gallery(2, "B", "x"), gallery(3, "C", "x")}; + when(galleries.selectAllGallery()).thenReturn(all); + when(collectMapper.selectGidByCollector(7)) + .thenReturn(new ArrayList<>(List.of(1, 3))); + + String json = service.selectAllGallery(7); + + assertTrue(ok(json)); + assertTrue(all[0].isCollect(), "gid=1 应标记已收藏"); + assertFalse(all[1].isCollect(), "gid=2 未收藏"); + assertTrue(all[2].isCollect(), "gid=3 应标记已收藏"); + } + + /** 收藏列表非空但没有任何一项命中任务列表时,不应标记任何任务。 */ + @Test + void selectAllGalleryIgnoresCollectionsForMissingGalleries() { + Gallery[] all = {gallery(1, "A", "x")}; + when(galleries.selectAllGallery()).thenReturn(all); + when(collectMapper.selectGidByCollector(7)) + .thenReturn(new ArrayList<>(List.of(999))); + + assertTrue(ok(service.selectAllGallery(7))); + assertFalse(all[0].isCollect()); + } + + /** 查询结果为 null 时应回业务失败而不是 NPE。 */ + @Test + void selectAllGalleryReportsFailureWhenNull() { + when(galleries.selectAllGallery()).thenReturn(null); + + String json = service.selectAllGallery(7); + + assertFalse(ok(json)); + assertTrue(json.contains("没有找到图片")); + } + + /** 空数组应正常返回成功(与 null 区别对待)。 */ + @Test + void selectAllGalleryHandlesEmptyArray() { + when(galleries.selectAllGallery()).thenReturn(new Gallery[0]); + when(collectMapper.selectGidByCollector(7)).thenReturn(new ArrayList<>()); + + assertTrue(ok(service.selectAllGallery(7))); + } + + // ---------- selectTaskByLink / ByGid ---------- + + /** 库里已有该任务时直接返回,不再去外部站点解析。 */ + @Test + void selectTaskByLinkReturnsStoredTaskWithoutParsing() throws Exception { + when(galleries.selectGalleryByGid(1234567)).thenReturn(gallery(1234567, "Stored", "下载中")); + + try (var parser = mockStatic(GalleryUtil.class)) { + parser.when(() -> GalleryUtil.parseGid(anyString())).thenReturn(1234567); + + String json = service.selectTaskByLink("https://exhentai.org/g/1234567/abc/"); + + assertTrue(ok(json)); + assertTrue(json.contains("Stored")); + parser.verify(() -> GalleryUtil.parse(anyString(), anyBoolean(), any()), never()); + } + } + + /** 库里没有时回落到在线解析。 */ + @Test + void selectTaskByLinkFallsBackToOnlineParse() throws Exception { + when(galleries.selectGalleryByGid(1234567)).thenReturn(null); + + try (var parser = mockStatic(GalleryUtil.class)) { + parser.when(() -> GalleryUtil.parseGid(anyString())).thenReturn(1234567); + parser.when(() -> GalleryUtil.parse(anyString(), anyBoolean(), any())) + .thenReturn(gallery(1234567, "FromWeb", "等待确认下载")); + + String json = service.selectTaskByLink("https://exhentai.org/g/1234567/abc/"); + + assertTrue(ok(json)); + assertTrue(json.contains("FromWeb")); + } + } + + /** 在线解析返回 null 时提示查询失败。 */ + @Test + void selectTaskByLinkReportsFailureWhenParseReturnsNull() throws Exception { + when(galleries.selectGalleryByGid(1234567)).thenReturn(null); + + try (var parser = mockStatic(GalleryUtil.class)) { + parser.when(() -> GalleryUtil.parseGid(anyString())).thenReturn(1234567); + parser.when(() -> GalleryUtil.parse(anyString(), anyBoolean(), any())).thenReturn(null); + + String json = service.selectTaskByLink("https://exhentai.org/g/1234567/abc/"); + assertFalse(ok(json)); + assertTrue(json.contains("查询失败")); + } + } + + /** 解析抛异常同样要转成业务失败。 */ + @Test + void selectTaskByLinkReportsFailureWhenParseThrows() throws Exception { + when(galleries.selectGalleryByGid(1234567)).thenReturn(null); + + try (var parser = mockStatic(GalleryUtil.class)) { + parser.when(() -> GalleryUtil.parseGid(anyString())).thenReturn(1234567); + parser.when(() -> GalleryUtil.parse(anyString(), anyBoolean(), any())) + .thenThrow(new java.io.IOException("upstream down")); + + String json = service.selectTaskByLink("https://exhentai.org/g/1234567/abc/"); + assertFalse(ok(json)); + assertTrue(json.contains("查询失败")); + } + } + + /** gid 解析不出来时直接回「链接错误」,不查库。 */ + @Test + void selectTaskByLinkRejectsUnparseableLink() throws Exception { + try (var parser = mockStatic(GalleryUtil.class)) { + parser.when(() -> GalleryUtil.parseGid(anyString())).thenReturn(null); + + String json = service.selectTaskByLink("https://example.com/nope"); + + assertFalse(ok(json)); + assertTrue(json.contains("链接错误")); + verify(galleries, never()).selectGalleryByGid(anyInt()); + } + } + + @Test + void selectTaskByGidHandlesFoundAndMissing() { + when(galleries.selectGalleryByGid(5)).thenReturn(gallery(5, "Found", "x")); + assertTrue(ok(service.selectTaskByGid(5))); + + when(galleries.selectGalleryByGid(6)).thenReturn(null); + String json = service.selectTaskByGid(6); + assertFalse(ok(json)); + assertTrue(json.contains("未找到该图片")); + } + + // ---------- selectGalleryByName / ByDownloader ---------- + + /** 按名字查应把参数包成 LIKE 模式,前后都要有 %。 */ + @Test + void selectGalleryByNameWrapsPattern() { + when(galleries.selectGalleryByName("%sakura%")).thenReturn(gallery(1, "Sakura", "x")); + + assertTrue(ok(service.selectGalleryByName("sakura"))); + + verify(galleries).selectGalleryByName("%sakura%"); + } + + @Test + void selectGalleryByNameReportsFailureWhenMissing() { + when(galleries.selectGalleryByName(anyString())).thenReturn(null); + + String json = service.selectGalleryByName("nope"); + + assertFalse(ok(json)); + assertTrue(json.contains("没有找到该名字的图片")); + } + + /** 按下载者查:授权码要换成 userId 再查。 */ + @Test + void selectGalleryByDownloaderResolvesUserId() { + User u = new User(); + u.setId(7); + when(users.selectUserByAuthCode("code")).thenReturn(u); + when(galleries.selectGalleryByDownloader(7)).thenReturn(new Gallery[]{gallery(1, "Mine", "x")}); + + assertTrue(ok(service.selectGalleryByDownloader("code"))); + + verify(galleries).selectGalleryByDownloader(7); + } + + @Test + void selectGalleryByDownloaderReportsFailureWhenEmpty() { + User u = new User(); + u.setId(7); + when(users.selectUserByAuthCode("code")).thenReturn(u); + when(galleries.selectGalleryByDownloader(7)).thenReturn(new Gallery[0]); + + String json = service.selectGalleryByDownloader("code"); + + assertFalse(ok(json)); + assertTrue(json.contains("您未下载图片")); + } + + // ---------- resetUndone ---------- + + /** 节点在线且存在未完成任务时,应逐个重投并报告数量。 */ + @Test + void resetUndoneResendsEveryUnfinishedTask() { + when(remote.isDead()).thenReturn(false); + Gallery[] undone = {gallery(1, "A", "下载中"), gallery(2, "B", "等待压缩")}; + when(galleries.selectUnDoneGalleries()).thenReturn(undone); + + String json = service.resetUndone(); + + assertTrue(ok(json), "实际输出: " + json); + assertTrue(json.contains("2本"), "应带上重投数量: " + json); + verify(remote).addGalleryToQueue(undone[0]); + verify(remote).addGalleryToQueue(undone[1]); + } + + /** 节点离线时不得重投(会丢消息),直接提示失败。 */ + @Test + void resetUndoneRefusesWhenNodeOffline() { + when(remote.isDead()).thenReturn(true); + + String json = service.resetUndone(); + + assertFalse(ok(json)); + assertTrue(json.contains("节点不在线")); + verify(remote, never()).addGalleryToQueue(any()); + verify(galleries, never()).selectUnDoneGalleries(); + } + + /** 没有未完成任务时不重投任何东西。 */ + @Test + void resetUndoneReportsWhenNothingToDo() { + when(remote.isDead()).thenReturn(false); + when(galleries.selectUnDoneGalleries()).thenReturn(new Gallery[0]); + + String json = service.resetUndone(); + + assertFalse(ok(json)); + assertTrue(json.contains("当前没有未下载完成的图片")); + verify(remote, never()).addGalleryToQueue(any()); + } + + @Test + void resetUndoneHandlesNullArray() { + when(remote.isDead()).thenReturn(false); + when(galleries.selectUnDoneGalleries()).thenReturn(null); + + String json = service.resetUndone(); + + assertFalse(ok(json)); + verify(remote, never()).addGalleryToQueue(any()); + } + + // ---------- retryGallery ---------- + + /** 不存在的任务直接失败,且不碰节点。 */ + @Test + void retryRejectsMissingTask() { + when(galleries.selectGalleryByGid(404)).thenReturn(null); + + String json = service.retryGallery(404); + + assertFalse(ok(json)); + assertTrue(json.contains("任务不存在"), "实际输出: " + json); + verify(remote, never()).retryGallery(any()); + } + + /** 已完成的任务无需重试,直接按成功返回(幂等语义)。 */ + @Test + void retryIsNoopForCompletedTask() { + when(galleries.selectGalleryByGid(1)).thenReturn(gallery(1, "Done", "下载完成")); + + String json = service.retryGallery(1); + + assertTrue(ok(json), "实际输出: " + json); + assertTrue(json.contains("下载完成")); + verify(remote, never()).retryGallery(any()); + } + + @Test + void retryRefusesWhenNodeOffline() { + when(galleries.selectGalleryByGid(1)).thenReturn(gallery(1, "G", "提交失败")); + when(remote.isDead()).thenReturn(true); + + String json = service.retryGallery(1); + + assertFalse(ok(json)); + assertTrue(json.contains("节点不在线")); + verify(remote, never()).retryGallery(any()); + } + + /** 节点接受重试时透传其返回状态;被拒时透传失败原因。 */ + @Test + void retryPropagatesNodeOutcome() { + Gallery g = gallery(1, "G", "提交失败"); + when(galleries.selectGalleryByGid(1)).thenReturn(g); + when(remote.isDead()).thenReturn(false); + + when(remote.retryGallery(g)).thenReturn(new RemoteService.RetryResult(true, "下载中")); + String okJson = service.retryGallery(1); + assertTrue(ok(okJson), "实际输出: " + okJson); + assertTrue(okJson.contains("下载中")); + + when(remote.retryGallery(g)).thenReturn(new RemoteService.RetryResult(false, "节点未接受重试请求")); + String failJson = service.retryGallery(1); + assertFalse(ok(failJson)); + assertTrue(failJson.contains("节点未接受重试请求")); + } + + // ---------- reconnect ---------- + + /** 重连结果码到用户可见文案的映射。 */ + @Test + void reconnectMapsResultCodesToMessages() { + when(remote.reconnect()).thenReturn((byte) 0); + assertTrue(ok(service.reconnect()), "0 应为成功"); + + when(remote.reconnect()).thenReturn((byte) -1); + String fail = service.reconnect(); + assertFalse(ok(fail)); + assertTrue(fail.contains("重连失败")); + + when(remote.reconnect()).thenReturn((byte) -2); + assertTrue(service.reconnect().contains("当前未连接")); + + when(remote.reconnect()).thenReturn((byte) 42); + assertTrue(service.reconnect().contains("未知错误")); + } +} diff --git a/src/test/java/com/lion/lionwebsite/Service/LocalServiceTest.java b/src/test/java/com/lion/lionwebsite/Service/LocalServiceTest.java new file mode 100644 index 0000000..7fc4f47 --- /dev/null +++ b/src/test/java/com/lion/lionwebsite/Service/LocalServiceTest.java @@ -0,0 +1,255 @@ +package com.lion.lionwebsite.Service; + +import com.lion.lionwebsite.Dao.normal.CustomConfigurationMapper; +import com.lion.lionwebsite.Dao.normal.GalleryMapper; +import com.lion.lionwebsite.Dao.normal.ShareFileMapper; +import com.lion.lionwebsite.Domain.CustomConfiguration; +import com.lion.lionwebsite.Domain.ShareFile; +import com.lion.lionwebsite.Util.CustomUtil; +import com.lion.lionwebsite.Util.GalleryUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.Calendar; +import java.util.Date; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 定时任务与订阅更新的主逻辑。 + * 这些方法由调度器在无人值守时触发,失败只会体现在日志里,因此每条分支 + * 都必须有断言锁定:该重连的重连、该告警的告警、不该写库的绝不写库。 + */ +class LocalServiceTest { + + private CustomConfigurationMapper configurationMapper; + private ShareFileMapper shareFileMapper; + private GalleryMapper galleryMapper; + private PushService pushService; + private RemoteService remoteService; + private SubscriptionRefreshService refreshService; + private LocalService service; + + @BeforeEach + void setUp() { + configurationMapper = mock(CustomConfigurationMapper.class); + shareFileMapper = mock(ShareFileMapper.class); + galleryMapper = mock(GalleryMapper.class); + pushService = mock(PushService.class); + remoteService = mock(RemoteService.class); + refreshService = mock(SubscriptionRefreshService.class); + service = new LocalService(configurationMapper, shareFileMapper, galleryMapper, + pushService, remoteService, refreshService); + } + + // ---------- CheckConnectionAvailability ---------- + + /** 连接已死:直接重连并发告警,不应再去探测可用性。 */ + @Test + void deadConnectionTriggersReconnectAndAlert() { + when(remoteService.isDead()).thenReturn(true); + + service.CheckConnectionAvailability(); + + verify(remoteService).initChannel(); + verify(pushService).sendToMe(contains("自动进行重连")); + verify(remoteService, never()).checkAvailability(); + } + + /** 连接活着且探测有响应:什么都不做。 */ + @Test + void healthyConnectionDoesNothing() { + when(remoteService.isDead()).thenReturn(false); + when(remoteService.checkAvailability()).thenReturn((byte) 0); + + service.CheckConnectionAvailability(); + + verify(remoteService, never()).reconnect(); + verifyNoInteractions(pushService); + } + + /** 探测无响应(-1)时按 reconnect 的返回值给出不同告警文案。 */ + @Test + void probeTimeoutReconnectsAndReportsOutcome() { + when(remoteService.isDead()).thenReturn(false); + when(remoteService.checkAvailability()).thenReturn((byte) -1); + + when(remoteService.reconnect()).thenReturn((byte) 0); + service.CheckConnectionAvailability(); + verify(pushService).sendToMe(contains("重连成功")); + + when(remoteService.reconnect()).thenReturn((byte) -1); + service.CheckConnectionAvailability(); + verify(pushService).sendToMe(contains("重连失败")); + + when(remoteService.reconnect()).thenReturn((byte) -2); + service.CheckConnectionAvailability(); + verify(pushService).sendToMe(contains("当前未连接,不进行重连")); + + when(remoteService.reconnect()).thenReturn((byte) 9); + service.CheckConnectionAvailability(); + verify(pushService).sendToMe(contains("未知错误")); + } + + // ---------- reset ---------- + + /** 额度重置必须同时把用量清零并记录重置时间。 */ + @Test + void resetZeroesQuotaAndStampsTime() { + service.reset(); + + verify(configurationMapper).updateConfiguration(CustomConfiguration.WEEK_USED_AMOUNT, "0"); + verify(configurationMapper).updateConfiguration(eq(CustomConfiguration.LAST_RESET_AMOUNT_TIME), anyString()); + } + + // ---------- verifyCookie ---------- + + /** Cookie 失效(内容为空)时应告警。 */ + @Test + void verifyCookieAlertsOnEmptyContent() throws Exception { + try (var requests = mockStatic(GalleryUtil.class)) { + requests.when(() -> GalleryUtil.requests(anyString(), anyString(), any(), any())) + .thenReturn(" "); + + service.verifyCookie(); + + verify(pushService).sendToMe("cookie过期"); + } + } + + /** Cookie 有效时不应产生告警。 */ + @Test + void verifyCookieSilentWhenValid() throws Exception { + try (var requests = mockStatic(GalleryUtil.class)) { + requests.when(() -> GalleryUtil.requests(anyString(), anyString(), any(), any())) + .thenReturn("ok"); + + service.verifyCookie(); + + verifyNoInteractions(pushService); + } + } + + /** 请求异常要带原因告警,不能静默失败。 */ + @Test + void verifyCookieAlertsOnNetworkFailure() throws Exception { + try (var requests = mockStatic(GalleryUtil.class)) { + requests.when(() -> GalleryUtil.requests(anyString(), anyString(), any(), any())) + .thenThrow(new IOException("connection reset")); + + service.verifyCookie(); + + verify(pushService).sendToMe(contains("connection reset")); + } + } + + // ---------- updateSub ---------- + + /** 全部子账号刷新成功:同步给节点并记录更新时间。 */ + @Test + void updateSubStampsTimeWhenAllAccountsSucceed() throws Exception { + when(refreshService.refreshAll()).thenReturn(true); + + assertTrue(service.updateSub(true)); + + verify(remoteService).requestSubscriptionSync(); + verify(configurationMapper).updateConfiguration( + eq(CustomConfiguration.LAST_UPDATE_SUB_TIME), anyString()); + } + + /** 刷新失败时仍要通知节点,但不更新「上次更新时间」,避免掩盖故障。 */ + @Test + void updateSubDoesNotStampTimeWhenRefreshFails() throws Exception { + when(refreshService.refreshAll()).thenReturn(false); + + assertFalse(service.updateSub(false)); + + verify(remoteService).requestSubscriptionSync(); + verify(configurationMapper, never()).updateConfiguration(anyString(), anyString()); + } + + /** 定时入口必须走同一套逻辑(手动与定时行为一致)。 */ + @Test + void scheduledUpdateDelegatesToSamePath() throws Exception { + when(refreshService.refreshAll()).thenReturn(true); + + service.updateSubScheduler(); + + verify(refreshService).refreshAll(); + verify(remoteService).requestSubscriptionSync(); + } + + // ---------- checkShareCode ---------- + + /** 只清理已过期的分享码,未过期的必须保留。 */ + @Test + void checkShareCodeDeletesOnlyExpiredOnes() { + when(shareFileMapper.selectAllShareFile()).thenReturn(new ShareFile[]{ + share("expired", hoursFromNow(-2)), + share("alive", hoursFromNow(2))}); + + service.checkShareCode(); + + verify(shareFileMapper).deleteShareFile("expired"); + verify(shareFileMapper, never()).deleteShareFile("alive"); + } + + @Test + void checkShareCodeHandlesEmptyTable() { + when(shareFileMapper.selectAllShareFile()).thenReturn(new ShareFile[0]); + + service.checkShareCode(); + + verify(shareFileMapper, never()).deleteShareFile(anyString()); + } + + // ---------- 定时作业的调度声明 ---------- + + /** + * 各定时任务的 cron 属于运维契约(额度重置必须是周一 4 点等), + * 这里用反射锁定,避免被无意改动后无人察觉。 + */ + @Test + void scheduledCronExpressionsMatchOperationalContract() throws Exception { + assertCron("CheckConnectionAvailability", "0 0/30 * * * *"); + assertCron("reset", "0 0 4 * * MON"); + assertCron("verifyCookie", "0 0 0 * * *"); + assertCron("checkShareCode", "0 0 4 * * *"); + assertCron("clearThumbnailCache", "0 0 4 1 * *"); + + var m = LocalService.class.getMethod("updateSubScheduler"); + assertEquals(86400000L, + m.getAnnotation(org.springframework.scheduling.annotation.Scheduled.class).fixedRate(), + "订阅更新应为每 24 小时一次"); + } + + private static void assertCron(String method, String expected) throws Exception { + var annotation = LocalService.class.getMethod(method) + .getAnnotation(org.springframework.scheduling.annotation.Scheduled.class); + assertNotNull(annotation, method + " 应带 @Scheduled"); + assertEquals(expected, annotation.cron(), method + " 的 cron 与运维约定不一致"); + } + + private static ShareFile share(String code, Date expire) { + ShareFile sf = new ShareFile(); + sf.setShareCode(code); + sf.setFilePath("/tmp/" + code); + sf.setExpireTime(expire); + return sf; + } + + private static Date hoursFromNow(int hours) { + Calendar c = Calendar.getInstance(); + c.add(Calendar.HOUR_OF_DAY, hours); + return c.getTime(); + } + + /** now() 只用于断言「时间被写入」,这里确认它确实是格式化的当前时间。 */ + @Test + void nowIsFormattedTimestamp() { + assertTrue(CustomUtil.now().matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}")); + } +} diff --git a/src/test/java/com/lion/lionwebsite/Service/PersonalServiceTest.java b/src/test/java/com/lion/lionwebsite/Service/PersonalServiceTest.java new file mode 100644 index 0000000..953e1b3 --- /dev/null +++ b/src/test/java/com/lion/lionwebsite/Service/PersonalServiceTest.java @@ -0,0 +1,450 @@ +package com.lion.lionwebsite.Service; + +import com.lion.lionwebsite.Dao.normal.CustomConfigurationMapper; +import com.lion.lionwebsite.Dao.normal.ShareFileMapper; +import com.lion.lionwebsite.Dao.normal.UserMapper; +import com.lion.lionwebsite.Domain.CustomConfiguration; +import com.lion.lionwebsite.Domain.ShareFile; +import com.lion.lionwebsite.Interceptor.TaskHandlerInterceptor; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 个人文件服务的文件系统行为。 + * 全部操作指向 JUnit 的临时目录(通过 setStoragePath 重定向),绝不触碰线上 /storage, + * 重点覆盖目录列举、分享码生命周期与删除/打包等破坏性操作的成功与失败两侧。 + */ +class PersonalServiceTest { + + @TempDir + Path storage; + + private CustomConfigurationMapper configurationMapper; + private UserMapper userMapper; + private ShareFileMapper shareFileMapper; + private TaskHandlerInterceptor interceptor; + private PushService pushService; + private PersonalService service; + + @BeforeEach + void setUp() { + configurationMapper = mock(CustomConfigurationMapper.class); + userMapper = mock(UserMapper.class); + shareFileMapper = mock(ShareFileMapper.class); + interceptor = mock(TaskHandlerInterceptor.class); + pushService = mock(PushService.class); + service = new PersonalService(configurationMapper, userMapper, shareFileMapper, + interceptor, pushService); + // 把根目录从 /storage/ 重定向到临时目录,确保测试隔离 + service.setStoragePath(storage.toString() + "/"); + } + + @AfterEach + void tearDown() { + service.getCompressThreadPool().shutdownNow(); + } + + /** CustomConfiguration 只有 @Data,没有全参构造,测试里用 setter 组装。 */ + private static CustomConfiguration config(String parameter, String value) { + CustomConfiguration c = new CustomConfiguration(); + c.setParameter(parameter); + c.setValue(value); + return c; + } + + private static boolean ok(String json) { + return json.contains("\"result\":\"success\""); + } + + private static ShareFile share(String code, String path, Date expire) { + ShareFile sf = new ShareFile(); + sf.setShareCode(code); + sf.setFilePath(path); + sf.setExpireTime(expire); + return sf; + } + + private static Date hoursFromNow(int hours) { + Calendar c = Calendar.getInstance(); + c.add(Calendar.HOUR_OF_DAY, hours); + return c.getTime(); + } + + // ---------- getFiles ---------- + + /** 目录列举:文件与子目录都要出现,且带类型标记;文件还要带大小。 */ + @Test + void getFilesListsFilesAndFoldersWithMetadata() throws Exception { + Path dir = Files.createDirectory(storage.resolve("docs")); + Files.writeString(dir.resolve("b.txt"), "hello"); + Files.createDirectory(dir.resolve("sub")); + + when(shareFileMapper.selectShareFilesByFilePath(anyString())).thenReturn(new ArrayList<>()); + + String json = service.getFiles("docs"); + + assertTrue(ok(json), "实际输出: " + json); + assertTrue(json.contains("b.txt")); + assertTrue(json.contains("sub")); + assertTrue(json.contains("FOLDER")); + assertTrue(json.contains("FILE")); + } + + /** 已分享且未过期的文件应带上分享码与过期时间。 */ + @Test + void getFilesAttachesActiveShareCode() throws Exception { + Path dir = Files.createDirectory(storage.resolve("docs")); + Path file = Files.writeString(dir.resolve("shared.txt"), "x"); + when(shareFileMapper.selectShareFilesByFilePath(anyString())) + .thenReturn(new ArrayList<>(List.of( + share("CODE1234", file.toFile().getAbsolutePath(), hoursFromNow(5))))); + + String json = service.getFiles("docs"); + + assertTrue(json.contains("CODE1234"), "应带上分享码: " + json); + assertTrue(json.contains("expireTime")); + verify(shareFileMapper, never()).deleteShareFile(anyString()); + } + + /** 已过期的分享码在列举时就地清理,且不出现在结果里。 */ + @Test + void getFilesPurgesExpiredShareCode() throws Exception { + Path dir = Files.createDirectory(storage.resolve("docs")); + Path file = Files.writeString(dir.resolve("stale.txt"), "x"); + when(shareFileMapper.selectShareFilesByFilePath(anyString())) + .thenReturn(new ArrayList<>(List.of( + share("OLDCODE1", file.toFile().getAbsolutePath(), hoursFromNow(-1))))); + + String json = service.getFiles("docs"); + + assertFalse(json.contains("OLDCODE1"), "过期分享码不应返回"); + verify(shareFileMapper).deleteShareFile("OLDCODE1"); + } + + /** 路径不是目录时只回空响应(既不 success 也不 failure),沿用既有行为。 */ + @Test + void getFilesReturnsEmptyResponseForNonDirectory() throws Exception { + Files.writeString(storage.resolve("plain.txt"), "x"); + + String json = service.getFiles("plain.txt"); + + assertEquals("{}", json, "非目录路径应返回空对象,实际: " + json); + } + + /** + * 空目录返回「成功 + 空列表」(前端据此显示一个空列表,只剩「返回上级」项), + * 这是既有契约,不应改动为失败。 + */ + @Test + void getFilesReturnsEmptyListForEmptyDirectory() throws Exception { + Files.createDirectory(storage.resolve("empty")); + + String json = service.getFiles("empty"); + + assertTrue(ok(json), "实际输出: " + json); + assertTrue(json.contains("\"data\":\"[]\""), "实际输出: " + json); + } + + /** path 中的 URL 编码必须被还原后才能定位真实文件。 */ + @Test + void getFilesDecodesUrlEncodedPath() throws Exception { + Path dir = Files.createDirectory(storage.resolve("my docs")); + Files.writeString(dir.resolve("a.txt"), "x"); + when(shareFileMapper.selectShareFilesByFilePath(anyString())).thenReturn(new ArrayList<>()); + + String json = service.getFiles("my%20docs"); + + assertTrue(ok(json), "URL 编码路径应能定位到 'my docs': " + json); + } + + // ---------- download ---------- + + /** 文件不存在时走 response.getWriter() 直接输出 404 文本(不是 HTTP 状态码)。 */ + @Test + void downloadWrites404WhenFileMissing() throws Exception { + var request = mock(HttpServletRequest.class); + var response = mock(HttpServletResponse.class); + var sink = new StringWriter(); + when(response.getWriter()).thenReturn(new PrintWriter(sink)); + + service.download(request, response, storage.resolve("missing.txt").toString()); + + assertEquals("404 NOT FOUND", sink.toString()); + } + + /** 文件存在时交给 FileDownload 导出,不应写 404 文本。 */ + @Test + void downloadExportsExistingFile() throws Exception { + Path file = Files.writeString(storage.resolve("real.txt"), "payload"); + var request = mock(HttpServletRequest.class); + var response = mock(HttpServletResponse.class); + var sink = new StringWriter(); + when(response.getWriter()).thenReturn(new PrintWriter(sink)); + when(request.getHeader("Range")).thenReturn(null); + when(request.getMethod()).thenReturn("GET"); + when(request.getServletContext()).thenReturn(mock(jakarta.servlet.ServletContext.class)); + when(response.getOutputStream()).thenReturn(new jakarta.servlet.ServletOutputStream() { + @Override public boolean isReady() { return true; } + @Override public void setWriteListener(jakarta.servlet.WriteListener l) { } + @Override public void write(int b) { } + }); + + service.download(request, response, file.toString()); + + assertEquals("", sink.toString(), "不应输出 404 文本"); + verify(response).setStatus(200); + } + + // ---------- uploadFile ---------- + + @Test + void uploadRejectsIncompleteParameters() { + var file = new MockMultipartFile("file", "a.txt", "text/plain", "hi".getBytes()); + + assertFalse(ok(service.uploadFile(null, "a.txt", file))); + assertFalse(ok(service.uploadFile("docs", null, file))); + assertFalse(ok(service.uploadFile("docs", "a.txt", null))); + } + + @Test + void uploadRejectsNonExistentDirectory() { + var file = new MockMultipartFile("file", "a.txt", "text/plain", "hi".getBytes()); + + String json = service.uploadFile("no-such-dir", "a.txt", file); + + assertFalse(ok(json)); + assertTrue(json.contains("该路径不存在或者不是文件夹"), "实际输出: " + json); + } + + @Test + void uploadWritesFileIntoDirectory() throws Exception { + Files.createDirectory(storage.resolve("docs")); + var file = new MockMultipartFile("file", "a.txt", "text/plain", "payload".getBytes()); + + assertTrue(ok(service.uploadFile("docs", "a.txt", file))); + assertEquals("payload", Files.readString(storage.resolve("docs/a.txt"))); + } + + /** 同名文件必须拒绝,避免静默覆盖已有数据。 */ + @Test + void uploadRefusesToOverwriteExistingFile() throws Exception { + Path dir = Files.createDirectory(storage.resolve("docs")); + Files.writeString(dir.resolve("a.txt"), "original"); + var file = new MockMultipartFile("file", "a.txt", "text/plain", "new".getBytes()); + + String json = service.uploadFile("docs", "a.txt", file); + + assertFalse(ok(json)); + assertTrue(json.contains("目标文件已存在"), "实际输出: " + json); + assertEquals("original", Files.readString(dir.resolve("a.txt")), "原文件不应被覆盖"); + } + + // ---------- shareFile ---------- + + @Test + void shareFileGeneratesEightCharCodeForExistingFile() throws Exception { + Path file = Files.writeString(storage.resolve("a.txt"), "x"); + + String json = service.shareFile(file.toString(), 24); + + assertTrue(ok(json), "实际输出: " + json); + var captor = org.mockito.ArgumentCaptor.forClass(String.class); + verify(shareFileMapper).insertShareFile(captor.capture(), eq(file.toString()), any(Date.class)); + assertEquals(8, captor.getValue().length(), "分享码应为 8 位"); + assertTrue(json.contains("shareCode")); + assertTrue(json.contains("expireTime")); + } + + /** 文件夹或不存在路径不能生成分享码。 */ + @Test + void shareFileRejectsDirectoryAndMissingPath() throws Exception { + Path dir = Files.createDirectory(storage.resolve("docs")); + + String forDir = service.shareFile(dir.toString(), 1); + assertFalse(ok(forDir)); + assertTrue(forDir.contains("此路径为文件夹或不存在")); + + String missing = service.shareFile(storage.resolve("nope.txt").toString(), 1); + assertFalse(ok(missing)); + + verify(shareFileMapper, never()).insertShareFile(anyString(), anyString(), any()); + } + + // ---------- extendShareTime ---------- + + @Test + void extendShareTimePushesExpiryForward() { + ShareFile existing = share("CODE1234", "/x/a.txt", hoursFromNow(1)); + when(shareFileMapper.selectShareFileByFilePath("/x/a.txt")).thenReturn(existing); + + String json = service.extendShareTime("/x/a.txt", 5); + + assertTrue(ok(json), "实际输出: " + json); + assertTrue(existing.getExpireTime().after(hoursFromNow(4)), "过期时间应被延后"); + verify(shareFileMapper).updateShareFile(existing); + assertTrue(json.contains("expireTime")); + } + + @Test + void extendShareTimeRejectsUnsharedPath() { + when(shareFileMapper.selectShareFileByFilePath("/x/a.txt")).thenReturn(null); + + String json = service.extendShareTime("/x/a.txt", 5); + + assertFalse(ok(json)); + assertTrue(json.contains("该文件未被分享")); + verify(shareFileMapper, never()).updateShareFile(any()); + } + + // ---------- cancelShare ---------- + + @Test + void cancelShareDeletesByShareCode() { + when(shareFileMapper.selectShareFileByFilePath("/x/a.txt")) + .thenReturn(share("CODE1234", "/x/a.txt", hoursFromNow(1))); + + assertTrue(ok(service.cancelShare("/x/a.txt"))); + verify(shareFileMapper).deleteShareFile("CODE1234"); + } + + @Test + void cancelShareRejectsUnsharedPath() { + when(shareFileMapper.selectShareFileByFilePath("/x/a.txt")).thenReturn(null); + + String json = service.cancelShare("/x/a.txt"); + + assertFalse(ok(json)); + assertTrue(json.contains("该文件未被分享")); + verify(shareFileMapper, never()).deleteShareFile(anyString()); + } + + // ---------- lastUpdate / getIp ---------- + + @Test + void lastUpdateReturnsConfigurationValue() { + when(configurationMapper.selectConfiguration(CustomConfiguration.LAST_UPDATE_SUB_TIME)) + .thenReturn(config(CustomConfiguration.LAST_UPDATE_SUB_TIME, "2026-09-15 10:00:00")); + + String json = service.lastUpdate(); + + assertTrue(ok(json)); + assertTrue(json.contains("2026-09-15 10:00:00")); + } + + @Test + void getIpReturnsAddressAndUpdateTime() { + when(configurationMapper.selectConfiguration(CustomConfiguration.CURRENT_IP_ADDRESS)) + .thenReturn(config(CustomConfiguration.CURRENT_IP_ADDRESS, "203.0.113.7")); + when(configurationMapper.selectConfiguration(CustomConfiguration.LAST_UPDATE_IP_ADDRESS_TIME)) + .thenReturn(config(CustomConfiguration.LAST_UPDATE_IP_ADDRESS_TIME, "2026-09-15 10:00:00")); + + String json = service.getIp(); + + assertTrue(ok(json), "实际输出: " + json); + assertTrue(json.contains("203.0.113.7")); + } + + // ---------- compress ---------- + + /** 打包是异步的:接口本身立即返回成功,结果由后续轮询文件是否存在得知。 */ + @Test + void compressQueuesDirectoryAndProducesTar() throws Exception { + Path dir = Files.createDirectory(storage.resolve("pack")); + Files.writeString(dir.resolve("a.txt"), "content"); + + String json = service.compress(dir.toString()); + + assertTrue(ok(json), "实际输出: " + json); + assertTrue(json.contains("加入队列成功")); + + Path tar = storage.resolve("pack.tar"); + for (int i = 0; i < 100 && !Files.exists(tar); i++) + Thread.sleep(50); + assertTrue(Files.exists(tar), "应在后台生成 pack.tar"); + assertTrue(Files.size(tar) > 0); + assertFalse(Files.exists(storage.resolve("pack.tar***undone")), "临时文件应被清理"); + } + + /** 选中的不是文件夹时必须同步拒绝,不占用线程池。 */ + @Test + void compressRejectsNonDirectory() throws Exception { + Path file = Files.writeString(storage.resolve("a.txt"), "x"); + + String json = service.compress(file.toString()); + + assertFalse(ok(json)); + assertTrue(json.contains("选中的路径不是文件夹"), "实际输出: " + json); + } + + // ---------- deleteFile ---------- + + @Test + void deleteFileRemovesTarget() throws Exception { + Path file = Files.writeString(storage.resolve("gone.txt"), "x"); + + assertTrue(ok(service.deleteFile(file.toString()))); + assertFalse(Files.exists(file)); + } + + /** + * 删除不存在的路径必须回业务失败。 + * 修复前直接依据 hutool `FileUtil.del` 的返回值(对不存在目标返回 true)报「删除成功」, + * 会让路径写错/文件已被删的情况也显示成功,误导用户。 + */ + @Test + void deleteFileReportsFailureForMissingPath() { + String json = service.deleteFile(storage.resolve("never-existed.txt").toString()); + + assertFalse(ok(json), "实际输出: " + json); + assertTrue(json.contains("文件不存在"), "实际输出: " + json); + } + + /** 空目录同样不存在(不是文件),删除应回失败。 */ + @Test + void deleteFileReportsFailureForMissingDirectory() { + String json = service.deleteFile(storage.resolve("no-such-dir").toString()); + + assertFalse(ok(json), "实际输出: " + json); + } + + /** 删除目录要连同其内容一起移除。 */ + @Test + void deleteFileRemovesDirectoryRecursively() throws Exception { + Path dir = Files.createDirectory(storage.resolve("tree")); + Files.writeString(dir.resolve("inner.txt"), "x"); + + assertTrue(ok(service.deleteFile(dir.toString()))); + assertFalse(Files.exists(dir)); + } + + // ---------- message2me ---------- + + /** 留言必须原样转发到 Telegram,且返回成功。 */ + @Test + void message2meForwardsToPushService() { + String json = service.message2me("hello from user"); + + assertTrue(ok(json)); + verify(pushService).sendToMe("hello from user"); + } +} diff --git a/src/test/java/com/lion/lionwebsite/Service/PublicServiceTest.java b/src/test/java/com/lion/lionwebsite/Service/PublicServiceTest.java new file mode 100644 index 0000000..34b44b9 --- /dev/null +++ b/src/test/java/com/lion/lionwebsite/Service/PublicServiceTest.java @@ -0,0 +1,190 @@ +package com.lion.lionwebsite.Service; + +import com.lion.lionwebsite.Dao.normal.CustomConfigurationMapper; +import com.lion.lionwebsite.Dao.normal.ShareFileMapper; +import com.lion.lionwebsite.Dao.normal.UserMapper; +import com.lion.lionwebsite.Domain.CustomConfiguration; +import com.lion.lionwebsite.Domain.ShareFile; +import com.lion.lionwebsite.Domain.User; +import com.lion.lionwebsite.Interceptor.TaskHandlerInterceptor; +import jakarta.servlet.ServletOutputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Calendar; +import java.util.Date; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 公开接口侧的服务:IP 记录、分享码取文件、改授权码。 + * GetFile 是唯一的「拿分享码换文件」入口,过期/不存在/文件被删三条路径都要 + * 既拒绝下载又清掉失效分享码。 + */ +class PublicServiceTest { + + private CustomConfigurationMapper configurationMapper; + private ShareFileMapper shareFileMapper; + private UserMapper userMapper; + private TaskHandlerInterceptor interceptor; + private PublicService service; + + @BeforeEach + void setUp() { + configurationMapper = mock(CustomConfigurationMapper.class); + shareFileMapper = mock(ShareFileMapper.class); + userMapper = mock(UserMapper.class); + interceptor = mock(TaskHandlerInterceptor.class); + service = new PublicService(configurationMapper, shareFileMapper, userMapper, interceptor); + } + + private static ShareFile share(String code, String path, Date expire) { + ShareFile sf = new ShareFile(); + sf.setShareCode(code); + sf.setFilePath(path); + sf.setExpireTime(expire); + return sf; + } + + private static Date hoursFromNow(int hours) { + Calendar c = Calendar.getInstance(); + c.add(Calendar.HOUR_OF_DAY, hours); + return c.getTime(); + } + + /** 记录家里 IP 应同时写入地址与时间两个配置项。 */ + @Test + void logIpAddressWritesBothAddressAndTime() { + service.logIpAddress("203.0.113.7"); + + verify(configurationMapper).updateConfiguration(CustomConfiguration.CURRENT_IP_ADDRESS, "203.0.113.7"); + verify(configurationMapper).updateConfiguration(eq(CustomConfiguration.LAST_UPDATE_IP_ADDRESS_TIME), anyString()); + } + + @Test + void alterAuthCodeUpdatesUserAndRefreshesCache() { + String json = service.alterAuthCode("old", "new"); + + assertTrue(json.contains("\"result\":\"success\"")); + verify(userMapper).updateAuthCode("old", "new"); + verify(interceptor).updateAuthCodes(); + } + + @Test + void getUserIdReturnsMappedUser() { + User u = new User(3, "code", "alice", null, true); + when(userMapper.selectUserByAuthCode("code")).thenReturn(u); + + assertSame(u, service.getUserId("code")); + } + + // ---------- GetFile ---------- + + /** ShareCode 为空时只回业务失败,不查库。 */ + @Test + void getFileRejectsNullShareCode() throws Exception { + var request = mock(HttpServletRequest.class); + var response = mock(HttpServletResponse.class); + var out = new ByteArrayOutputStream(); + when(response.getOutputStream()).thenReturn(servletOutputStream(out)); + + assertFalse(service.GetFile(request, response, null)); + + assertTrue(out.toString(StandardCharsets.UTF_8).contains("ShareCode invalid")); + verify(shareFileMapper, never()).selectShareFileByShareCode(any()); + } + + /** 分享码不存在时回失败,且不应误删任何记录。 */ + @Test + void getFileRejectsUnknownShareCode() throws Exception { + when(shareFileMapper.selectShareFileByShareCode("nope")).thenReturn(null); + var response = mock(HttpServletResponse.class); + var out = new ByteArrayOutputStream(); + when(response.getOutputStream()).thenReturn(servletOutputStream(out)); + + assertFalse(service.GetFile(mock(HttpServletRequest.class), response, "nope")); + + assertTrue(out.toString(StandardCharsets.UTF_8).contains("ShareCode is not exist or expired")); + verify(shareFileMapper, never()).deleteShareFile(anyString()); + } + + /** 已过期的分享码必须删除记录,避免脏数据累积。 */ + @Test + void getFileDeletesExpiredShare() throws Exception { + when(shareFileMapper.selectShareFileByShareCode("old")) + .thenReturn(share("old", "/tmp/whatever.txt", hoursFromNow(-1))); + var response = mock(HttpServletResponse.class); + var out = new ByteArrayOutputStream(); + when(response.getOutputStream()).thenReturn(servletOutputStream(out)); + + assertFalse(service.GetFile(mock(HttpServletRequest.class), response, "old")); + + assertTrue(out.toString(StandardCharsets.UTF_8).contains("ShareCode is expired or File is not exist")); + verify(shareFileMapper).deleteShareFile("old"); + } + + /** 未过期但文件已被删,同样要清掉分享码。 */ + @Test + void getFileDeletesShareWhenFileMissing() throws Exception { + when(shareFileMapper.selectShareFileByShareCode("gone")) + .thenReturn(share("gone", "/nonexistent/definitely/missing.txt", hoursFromNow(5))); + var response = mock(HttpServletResponse.class); + var out = new ByteArrayOutputStream(); + when(response.getOutputStream()).thenReturn(servletOutputStream(out)); + + assertFalse(service.GetFile(mock(HttpServletRequest.class), response, "gone")); + + verify(shareFileMapper).deleteShareFile("gone"); + } + + /** 有效分享码 + 存在的文件:应导出文件并返回 true,且不得删除记录。 */ + @Test + void getFileServesValidShare() throws Exception { + java.nio.file.Path file = java.nio.file.Files.createTempFile("share-test", ".txt"); + java.nio.file.Files.writeString(file, "hello share"); + try { + when(shareFileMapper.selectShareFileByShareCode("good")) + .thenReturn(share("good", file.toString(), hoursFromNow(5))); + + var request = mock(HttpServletRequest.class); + var response = mock(HttpServletResponse.class); + var out = new ByteArrayOutputStream(); + when(request.getHeader("Range")).thenReturn(null); + when(request.getMethod()).thenReturn("GET"); + when(request.getServletContext()).thenReturn(mock(jakarta.servlet.ServletContext.class)); + when(response.getOutputStream()).thenReturn(servletOutputStream(out)); + + assertTrue(service.GetFile(request, response, "good")); + + assertEquals("hello share", out.toString(StandardCharsets.UTF_8)); + verify(shareFileMapper, never()).deleteShareFile(anyString()); + } finally { + java.nio.file.Files.deleteIfExists(file); + } + } + + /** 用 ByteArrayOutputStream 包一个最小 ServletOutputStream,避免依赖容器实现。 */ + private static ServletOutputStream servletOutputStream(ByteArrayOutputStream sink) { + return new ServletOutputStream() { + @Override + public boolean isReady() { + return true; + } + + @Override + public void setWriteListener(jakarta.servlet.WriteListener listener) { + } + + @Override + public void write(int b) { + sink.write(b); + } + }; + } +} diff --git a/src/test/java/com/lion/lionwebsite/Service/PushServiceTest.java b/src/test/java/com/lion/lionwebsite/Service/PushServiceTest.java new file mode 100644 index 0000000..0dc7f82 --- /dev/null +++ b/src/test/java/com/lion/lionwebsite/Service/PushServiceTest.java @@ -0,0 +1,95 @@ +package com.lion.lionwebsite.Service; + +import com.lion.lionwebsite.Domain.Gallery; +import com.lion.lionwebsite.Util.Response; +import com.pengrad.telegrambot.TelegramBot; +import com.pengrad.telegrambot.request.SendMessage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Telegram 告警文案。任务是无人值守跑一整夜的,这些消息是运维唯一的可见性, + * 因此重点断言「什么情况下会发」以及「消息里是否带上了定位所需的字段」。 + */ +class PushServiceTest { + + private TelegramBot bot; + private PushService service; + + @BeforeEach + void setUp() { + bot = mock(TelegramBot.class); + service = new PushService(bot); + } + + private String sentText() { + var captor = org.mockito.ArgumentCaptor.forClass(SendMessage.class); + verify(bot).execute(captor.capture()); + return String.valueOf(captor.getValue().getParameters().get("text")); + } + + /** 成功提交任务时只报「谁提交了什么」,不带失败原因。 */ + @Test + void taskCreateReportOnSuccess() { + Response response = Response.generateResponse().success(); + + service.taskCreateReport("alice", "My Gallery", response); + + String text = sentText(); + assertTrue(text.contains("alice")); + assertTrue(text.contains("My Gallery")); + assertFalse(text.contains("下载失败"), "成功时不应出现失败文案: " + text); + } + + /** 失败提交必须带上失败原因,方便直接定位。 */ + @Test + void taskCreateReportOnFailureIncludesReason() { + Response response = Response.generateResponse().failure("链接错误"); + + service.taskCreateReport("alice", "My Gallery", response); + + String text = sentText(); + assertTrue(text.contains("下载失败"), "实际推送: " + text); + assertTrue(text.contains("链接错误"), "应带上失败原因: " + text); + } + + /** 下载完成通知要含任务名与完成时间(时间按东八区格式化)。 */ + @Test + void downloadCompleteIncludesNameAndTimestamp() { + Gallery gallery = new Gallery(); + gallery.setName("Nightly Gallery"); + + service.downloadComplete(gallery); + + String text = sentText(); + assertTrue(text.contains("Nightly Gallery")); + assertTrue(text.contains("完成时间")); + assertTrue(text.matches("(?s).*\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}.*"), + "应包含 yyyy-MM-dd HH:mm:ss 形式的时间: " + text); + } + + @Test + void storageNodeUpDownProduceDistinctMessages() { + service.storageNodeOnline(); + assertTrue(sentText().contains("上线")); + + clearInvocations(bot); + + service.storageNodeOffline(); + assertTrue(sentText().contains("掉线")); + } + + /** sendToMe 是统一出口,必须真的调用 bot.execute 而不是只打日志。 */ + @Test + void sendToMeExecutesTelegramRequest() { + service.sendToMe("hello"); + + var captor = org.mockito.ArgumentCaptor.forClass(SendMessage.class); + verify(bot).execute(captor.capture()); + assertEquals("hello", captor.getValue().getParameters().get("text")); + } +} diff --git a/src/test/java/com/lion/lionwebsite/Service/QueryServiceTest.java b/src/test/java/com/lion/lionwebsite/Service/QueryServiceTest.java new file mode 100644 index 0000000..053f8fd --- /dev/null +++ b/src/test/java/com/lion/lionwebsite/Service/QueryServiceTest.java @@ -0,0 +1,251 @@ +package com.lion.lionwebsite.Service; + +import com.lion.lionwebsite.Util.GalleryUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * E-Hentai 搜索结果的解析与分页链接提取。 + * 外部站点不可在单测中访问,因此 requests() 一律打桩,只覆盖解析与降级逻辑。 + */ +class QueryServiceTest { + + private QueryService service; + + @BeforeEach + void setUp() { + service = new QueryService(); + } + + /** 一行结果 + 四个分页链接的完整页面,字段顺序与线上 DOM 一致。 */ + private static String resultPage() { + return "" + + "
header
" + + "" + + "" + + "" + + " " + + " " + + " " + + " " + + "" + + "
skipme
Manga" + + "
" + + " first" + + " 2026-01-01 12:00" + + "
" + + "
x
42 pages
" + + "n" + + "p" + + "f" + + "l" + + ""; + } + + private static boolean ok(String json) { + return json.contains("\"result\":\"success\""); + } + + @Test + void queryParsesGalleryRowAndPaginationLinks() throws Exception { + try (var requests = mockStatic(GalleryUtil.class)) { + requests.when(() -> GalleryUtil.requests(anyString(), anyString(), any(), any())) + .thenReturn(resultPage()); + requests.when(() -> GalleryUtil.parseGid(anyString())).thenCallRealMethod(); + + String json = service.query("sakura", null, null); + + assertTrue(ok(json), "实际输出: " + json); + assertTrue(json.contains("Title Here"), "应解析出画廊名"); + assertTrue(json.contains("1234567"), "应解析出 gid"); + assertTrue(json.contains("Manga"), "应解析出类型"); + // data 是「数组序列化后的字符串」,字段因此带转义,这里按转义后的形态断言 + assertTrue(json.contains("\\\"page\\\":42"), "应解析出页数: " + json); + assertTrue(json.contains("\\\"gid\\\":\\\"1234567\\\""), "应解析出 gid: " + json); + // 缩略图地址去掉了站点前缀,前端要拿相对路径 + assertTrue(json.contains("/t/thumb.jpg")); + assertFalse(json.contains("s.exhentai.org")); + // 四个分页链接都应写入响应 + assertTrue(json.contains("next")); + assertTrue(json.contains("previous")); + assertTrue(json.contains("first")); + assertTrue(json.contains("last")); + } + } + + /** 缩略图用 data-src 懒加载时,必须回退取 data-src 而不是留下 data: 前缀。 */ + @Test + void queryFallsBackToLazyLoadedThumbnail() throws Exception { + String page = "
" + + "" + + "" + + "" + + "" + + "" + + "
s
T" + + "
f2026-01-01
Lazy
x
7 pages
"; + try (var requests = mockStatic(GalleryUtil.class)) { + requests.when(() -> GalleryUtil.requests(anyString(), anyString(), any(), any())) + .thenReturn(page); + requests.when(() -> GalleryUtil.parseGid(anyString())).thenCallRealMethod(); + + String json = service.query("lazy", null, null); + assertTrue(ok(json), "实际输出: " + json); + assertTrue(json.contains("/t/lazy.jpg"), "应回退到 data-src 并去掉站点前缀"); + assertFalse(json.contains("data:image"), "不应把 data URI 当缩略图"); + } + } + + /** 分页参数只应有一个生效:prev 优先于 next,且都要拼进请求 URL。 */ + @Test + void queryAppendsOnlyOnePaginationParameter() throws Exception { + try (var requests = mockStatic(GalleryUtil.class)) { + var captor = org.mockito.ArgumentCaptor.forClass(String.class); + requests.when(() -> GalleryUtil.requests(captor.capture(), anyString(), any(), any())) + .thenReturn(resultPage()); + + service.query("a b", "P", "N"); + String url = captor.getValue(); + assertTrue(url.contains("f_search=a+b"), "空格应转成加号: " + url); + assertTrue(url.contains("prev=P")); + assertFalse(url.contains("next=N"), "prev 存在时不应再拼 next"); + + service.query("a", null, "N"); + assertTrue(captor.getValue().contains("next=N")); + } + } + + /** 搜索页无结果行时必须返回业务失败,而不是抛异常。 */ + @Test + void queryReturnsFailureWhenNoResultRows() throws Exception { + try (var requests = mockStatic(GalleryUtil.class)) { + requests.when(() -> GalleryUtil.requests(anyString(), anyString(), any(), any())) + .thenReturn("
" + + "
"); + + String json = service.query("nothing", null, null); + assertFalse(ok(json)); + assertTrue(json.contains("没有搜索到结果")); + } + } + + /** 上游请求异常必须转成业务失败。 */ + @Test + void queryReturnsFailureOnNetworkError() throws Exception { + try (var requests = mockStatic(GalleryUtil.class)) { + requests.when(() -> GalleryUtil.requests(anyString(), anyString(), any(), any())) + .thenThrow(new IOException("boom")); + + String json = service.query("x", null, null); + assertFalse(ok(json)); + assertTrue(json.contains("query failure")); + } + } + + // ---------- getEhThumbnail ---------- + + /** path 不含「/」时无法拆出文件名,应直接 404,不能去碰缓存目录。 */ + @Test + void thumbnailRequiresPathWithSlash() throws Exception { + var request = mock(jakarta.servlet.http.HttpServletRequest.class); + var response = mock(jakarta.servlet.http.HttpServletResponse.class); + + service.getEhThumbnail("nodir", request, response); + + verify(response).sendError(404); + verifyNoMoreInteractions(response); + } + + /** 缓存命中时直接导出文件,不再访问网络。 */ + @Test + void thumbnailExportsCachedFile() throws Exception { + var request = mock(jakarta.servlet.http.HttpServletRequest.class); + var response = mock(jakarta.servlet.http.HttpServletResponse.class); + var servletContext = mock(jakarta.servlet.ServletContext.class); + when(request.getServletContext()).thenReturn(servletContext); + when(request.getHeader(anyString())).thenReturn(null); + + java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("thumb-test"); + try { + // 缓存目录布局:/.avif,find() 会按后缀查找 + java.nio.file.Path cached = dir.resolve("abc.avif"); + java.nio.file.Files.write(cached, new byte[]{1, 2, 3, 4}); + + try (var cache = mockStatic(com.lion.lionwebsite.Util.ImageFileCache.class)) { + cache.when(() -> com.lion.lionwebsite.Util.ImageFileCache + .get(any(), anyString(), any())) + .thenReturn(cached); + + service.getEhThumbnail("123/abc.jpg", request, response); + + verify(response).setStatus(200); + verify(response).setHeader(eq("Content-Disposition"), anyString()); + } + } finally { + try (var walk = java.nio.file.Files.walk(dir)) { + walk.sorted(java.util.Comparator.reverseOrder()).forEach(p -> p.toFile().delete()); + } + } + } + + /** 缓存获取失败时必须降级为 404,且不得在响应已提交后再改状态。 */ + @Test + void thumbnailReturns404WhenCacheFetchFails() throws Exception { + var request = mock(jakarta.servlet.http.HttpServletRequest.class); + var response = mock(jakarta.servlet.http.HttpServletResponse.class); + when(response.isCommitted()).thenReturn(false); + + try (var cache = mockStatic(com.lion.lionwebsite.Util.ImageFileCache.class)) { + cache.when(() -> com.lion.lionwebsite.Util.ImageFileCache + .get(any(), anyString(), any())) + .thenThrow(new java.io.IOException("download failed")); + + service.getEhThumbnail("123/abc.jpg", request, response); + + verify(response).setStatus(404); + } + } + + /** 被中断时应置 503 并恢复中断标志,避免线程池吞掉中断信号。 */ + @Test + void thumbnailReturns503OnInterruption() throws Exception { + var request = mock(jakarta.servlet.http.HttpServletRequest.class); + var response = mock(jakarta.servlet.http.HttpServletResponse.class); + + try (var cache = mockStatic(com.lion.lionwebsite.Util.ImageFileCache.class)) { + cache.when(() -> com.lion.lionwebsite.Util.ImageFileCache + .get(any(), anyString(), any())) + .thenThrow(new InterruptedException("stop")); + + service.getEhThumbnail("123/abc.jpg", request, response); + + verify(response).setStatus(503); + assertTrue(Thread.currentThread().isInterrupted(), "中断标志应被恢复"); + Thread.interrupted(); // 清理,避免影响后续测试 + } + } + + /** 响应已提交时不应再试图改状态码。 */ + @Test + void thumbnailDoesNotOverrideCommittedResponse() throws Exception { + var request = mock(jakarta.servlet.http.HttpServletRequest.class); + var response = mock(jakarta.servlet.http.HttpServletResponse.class); + when(response.isCommitted()).thenReturn(true); + + try (var cache = mockStatic(com.lion.lionwebsite.Util.ImageFileCache.class)) { + cache.when(() -> com.lion.lionwebsite.Util.ImageFileCache + .get(any(), anyString(), any())) + .thenThrow(new java.io.IOException("download failed")); + + service.getEhThumbnail("123/abc.jpg", request, response); + + verify(response, never()).setStatus(anyInt()); + } + } +} diff --git a/src/test/java/com/lion/lionwebsite/Service/RemoteServiceStatusTest.java b/src/test/java/com/lion/lionwebsite/Service/RemoteServiceStatusTest.java new file mode 100644 index 0000000..d022395 --- /dev/null +++ b/src/test/java/com/lion/lionwebsite/Service/RemoteServiceStatusTest.java @@ -0,0 +1,177 @@ +package com.lion.lionwebsite.Service; + +import com.lion.lionwebsite.Dao.normal.GalleryMapper; +import com.lion.lionwebsite.Domain.Gallery; +import com.lion.lionwebsite.Domain.GalleryTask; +import com.lion.lionwebsite.Message.DownloadStatusMessage; +import io.netty.channel.embedded.EmbeddedChannel; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 存储节点上报的下载进度如何回写库并通知前端。 + * 这段逻辑跑在 Netty 的 IO 线程里,异常不会被请求链路捕获,因此每个分支 + * (未知任务、状态迁移、完成通知、进度更新)都要单独锁定。 + */ +class RemoteServiceStatusTest { + + private GalleryMapper galleryMapper; + private PushService pushService; + private WebSocketService webSocketService; + private SubscriptionStandbySnapshotService snapshotService; + private RemoteService service; + private EmbeddedChannel channel; + + @BeforeEach + void setUp() { + galleryMapper = mock(GalleryMapper.class); + pushService = mock(PushService.class); + webSocketService = mock(WebSocketService.class); + snapshotService = mock(SubscriptionStandbySnapshotService.class); + service = new RemoteService(galleryMapper, pushService, webSocketService, snapshotService); + channel = new EmbeddedChannel(service.new MyChannelInboundHandlerAdapter()); + service.channel = channel; + } + + @AfterEach + void tearDown() { + service.shutdownResources(); + channel.finishAndReleaseAll(); + } + + private static Gallery gallery(int gid, String name, String status, int pages) { + Gallery g = new Gallery(); + g.setGid(gid); + g.setName(name); + g.setStatus(status); + g.setPages(pages); + g.setProceeding(0); + return g; + } + + private static GalleryTask task(int gid, String name, byte status, int proceeding) { + GalleryTask t = new GalleryTask(); + t.setGid(gid); + t.setName(name); + t.setStatus(status); + t.setProceeding(proceeding); + return t; + } + + private static DownloadStatusMessage status(GalleryTask... tasks) { + DownloadStatusMessage m = new DownloadStatusMessage(); + m.setGalleryTasks(tasks); + return m; + } + + /** 下载中:进度写入库,状态置为「下载中」,并推送给前端。 */ + @Test + void downloadingUpdatesProgressAndNotifiesFrontend() { + Gallery existing = gallery(100, "G", "等待下载", 40); + when(galleryMapper.selectGalleryByGid(100)).thenReturn(existing); + + channel.writeInbound(status(task(100, "G", GalleryTask.DOWNLOADING, 7))); + + assertEquals(7, existing.getProceeding()); + assertEquals("下载中", existing.getStatus()); + verify(galleryMapper).updateGallery(existing); + verify(webSocketService).updateTaskProcessing(any(GalleryTask[].class)); + verify(pushService, never()).downloadComplete(any()); + } + + /** 压缩完成:状态置「下载完成」,且只在首次完成时发一次通知。 */ + @Test + void completionNotifiesOnlyOnFirstTransition() { + Gallery existing = gallery(101, "G", "下载中", 40); + when(galleryMapper.selectGalleryByGid(101)).thenReturn(existing); + + channel.writeInbound(status(task(101, "G", GalleryTask.COMPRESS_COMPLETE, 40))); + assertEquals("下载完成", existing.getStatus()); + verify(pushService).downloadComplete(existing); + + // 再次上报同一完成状态:不应重复通知 + channel.writeInbound(status(task(101, "G", GalleryTask.COMPRESS_COMPLETE, 40))); + verify(pushService, times(1)).downloadComplete(any()); + } + + /** 三种中间状态的文案映射。 */ + @Test + void intermediateStatusesMapToExpectedLabels() { + Gallery existing = gallery(102, "G", "x", 10); + when(galleryMapper.selectGalleryByGid(102)).thenReturn(existing); + + channel.writeInbound(status(task(102, "G", GalleryTask.COMPRESSING, 10))); + assertEquals("压缩中", existing.getStatus()); + + channel.writeInbound(status(task(102, "G", GalleryTask.DOWNLOAD_COMPLETE, 10))); + assertEquals("等待压缩", existing.getStatus()); + + channel.writeInbound(status(task(102, "G", GalleryTask.DOWNLOADING, 5))); + assertEquals("下载中", existing.getStatus()); + assertEquals(5, existing.getProceeding()); + } + + /** 未知 gid(库里没有)必须忽略且不落库,避免写入幽灵任务。 */ + @Test + void unknownTaskIsIgnoredWithoutPersisting() { + when(galleryMapper.selectGalleryByGid(999)).thenReturn(null); + + channel.writeInbound(status(task(999, "ghost", GalleryTask.DOWNLOADING, 3))); + + verify(galleryMapper, never()).updateGallery(any()); + } + + /** 节点上报了新名称时同步过来(节点侧可能重命名过)。 */ + @Test + void newNameFromNodeIsApplied() { + Gallery existing = gallery(103, "old-name", "下载中", 10); + when(galleryMapper.selectGalleryByGid(103)).thenReturn(existing); + + channel.writeInbound(status(task(103, "new-name", GalleryTask.DOWNLOADING, 1))); + + assertEquals("new-name", existing.getName()); + } + + /** 批量上报:每个任务都要独立处理,全部推送一次。 */ + @Test + void batchReportProcessesEveryTask() { + when(galleryMapper.selectGalleryByGid(201)).thenReturn(gallery(201, "A", "x", 10)); + when(galleryMapper.selectGalleryByGid(202)).thenReturn(gallery(202, "B", "x", 10)); + + channel.writeInbound(status( + task(201, "A", GalleryTask.DOWNLOADING, 1), + task(202, "B", GalleryTask.COMPRESSING, 2))); + + verify(galleryMapper, times(2)).updateGallery(any()); + } + + /** 完成上报应唤醒等待重试结果的调用方。 */ + @Test + void completionCompletesRetryWaiters() throws Exception { + Gallery existing = gallery(300, "G", "重试中", 10); + when(galleryMapper.selectGalleryByGid(300)).thenReturn(existing); + + // 先发起重试,制造一个等待者;再让节点回报完成状态解除等待 + channel.pipeline().addFirst(new io.netty.channel.ChannelOutboundHandlerAdapter() { + @Override + public void write(io.netty.channel.ChannelHandlerContext ctx, Object msg, + io.netty.channel.ChannelPromise promise) { + io.netty.util.concurrent.Promise inner = + service.promiseHashMap.get(((com.lion.lionwebsite.Message.AbstractMessage) msg).messageId); + if (inner != null) inner.trySuccess(new com.lion.lionwebsite.Message.ResponseMessage()); + promise.setSuccess(); + } + }); + + var result = service.retryGallery(existing); + assertNotNull(result); + + channel.writeInbound(status(task(300, "G", GalleryTask.COMPRESS_COMPLETE, 10))); + assertEquals("下载完成", existing.getStatus()); + } +} diff --git a/src/test/java/com/lion/lionwebsite/Service/SubscriptionFilteringTest.java b/src/test/java/com/lion/lionwebsite/Service/SubscriptionFilteringTest.java new file mode 100644 index 0000000..452e163 --- /dev/null +++ b/src/test/java/com/lion/lionwebsite/Service/SubscriptionFilteringTest.java @@ -0,0 +1,399 @@ +package com.lion.lionwebsite.Service; + +import com.lion.lionwebsite.Dao.normal.SubMapper; +import com.lion.lionwebsite.Domain.SubscriptionAccount; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 订阅抓取与「高倍率节点过滤」。 + * 这段逻辑决定终端用户最终拿到哪些节点,并且会把结果落盘供分发; + * 因此既要验证过滤规则本身,也要验证失败时不会写坏缓存、不会把旧内容覆盖成空。 + */ +class SubscriptionFilteringTest { + + @TempDir + Path cacheRoot; + + private SubMapper subMapper; + private SubscriptionRefreshService service; + + @BeforeEach + void setUp() { + subMapper = mock(SubMapper.class); + // download() 要打桩,因此用 spy 保留真实实现 + service = spy(new SubscriptionRefreshService(subMapper, new SubscriptionStateCoordinator())); + service.cacheRoot = cacheRoot.toString(); + service.v2UrlTemplate = "https://upstream.example/sub/{key}?client=v2"; + service.clashUrlTemplate = "https://upstream.example/sub/{key}?client=clashmeta"; + service.highMultiplierThreshold = 2.0; + } + + private static SubscriptionAccount account(int id, String key, boolean enabled, boolean filter) { + SubscriptionAccount a = new SubscriptionAccount(); + a.setId(id); + a.setName("acc-" + id); + a.setUpstreamKey(key); + a.setEnabled(enabled); + a.setFilterHighMultiplier(filter); + return a; + } + + /** 把节点列表编码成上游返回的 Base64 单行格式。 */ + private static List v2Upstream(String... nodes) { + String joined = String.join("\n", nodes); + return List.of(Base64.getEncoder().encodeToString(joined.getBytes(StandardCharsets.UTF_8))); + } + + /** 解回 v2 缓存内容,便于断言过滤结果。 */ + private static String decodeV2(List upstream) { + return new String(Base64.getMimeDecoder().decode(upstream.getFirst()), StandardCharsets.UTF_8); + } + + private void stubUpstream(List v2, List clash) throws Exception { + doReturn(v2).when(service).download(contains("client=v2")); + doReturn(clash).when(service).download(contains("client=clashmeta")); + } + + // ---------- URL 模板 ---------- + + /** {key} 必须被 URL 编码后替换,避免特殊字符破坏 URL。 */ + @Test + void urlTemplateEncodesKey() { + // URLEncoder 采用 application/x-www-form-urlencoded:空格编码为 '+' + assertEquals("https://upstream.example/sub/a%2Fb+c?client=v2", + service.v2Url(account(1, "a/b c", true, false))); + assertEquals("https://upstream.example/sub/a%2Fb?client=v2", + service.v2Url(account(1, "a/b", true, false)), "斜杠应被编码"); + } + + /** 模板缺少 {key} 或含多个 {key} 时必须在发请求前就失败。 */ + @Test + void urlTemplateMustContainExactlyOneKeyPlaceholder() { + service.v2UrlTemplate = "https://upstream.example/sub/?client=v2"; + assertThrows(IllegalStateException.class, () -> service.v2Url(account(1, "k", true, false))); + + service.v2UrlTemplate = "https://x/{key}/{key}"; + assertThrows(IllegalStateException.class, () -> service.v2Url(account(1, "k", true, false))); + } + + // ---------- 成功路径 ---------- + + /** 成功刷新:写入两份缓存、标记成功,返回 true。 */ + @Test + void refreshWritesBothCachesAndMarksSuccess() throws Exception { + SubscriptionAccount acc = account(1, "key1", true, false); + when(subMapper.selectSubscriptionAccount(1)).thenReturn(acc); + stubUpstream(v2Upstream("vmess://node-a"), List.of("proxies:", " - name: \"a\"")); + + assertTrue(service.refresh(1)); + + Path v2 = cacheRoot.resolve("1/v2ray.txt"); + Path clash = cacheRoot.resolve("1/clash.yaml"); + assertTrue(Files.isRegularFile(v2), "应写入 v2 缓存"); + assertTrue(Files.isRegularFile(clash), "应写入 clash 缓存"); + // v2 缓存本身是 Base64 文本,需解码后再断言节点内容 + String decodedV2 = new String(Base64.getMimeDecoder() + .decode(Files.readString(v2)), StandardCharsets.UTF_8); + assertTrue(decodedV2.contains("vmess://node-a"), "实际内容: " + decodedV2); + verify(subMapper).markSubscriptionRefreshSuccess(1); + verify(subMapper, never()).markSubscriptionRefreshFailure(anyInt(), anyString()); + // 临时文件不应残留 + assertFalse(Files.exists(cacheRoot.resolve("1/clash.yaml.tmp"))); + } + + /** 停用账号或不存在账号直接返回 false,且不发任何网络请求。 */ + @Test + void refreshSkipsDisabledOrMissingAccount() { + when(subMapper.selectSubscriptionAccount(1)).thenReturn(account(1, "k", false, false)); + assertFalse(service.refresh(1)); + + when(subMapper.selectSubscriptionAccount(2)).thenReturn(null); + assertFalse(service.refresh(2)); + + verify(subMapper, never()).markSubscriptionRefreshSuccess(anyInt()); + } + + // ---------- 高倍率过滤(v2) ---------- + + /** 过滤开启时,名称以「>2x」结尾的节点应被剔除,其余保留。 */ + @Test + void v2FilterDropsHighMultiplierNodes() throws Exception { + SubscriptionAccount acc = account(5, "key5", true, true); + when(subMapper.selectSubscriptionAccount(5)).thenReturn(acc); + stubUpstream(v2Upstream( + "vmess://aaa#keep-1", + "vmess://bbb#香港 5x", + "vmess://ccc#keep-2"), List.of("proxies:")); + + assertTrue(service.refresh(5)); + + String content = Files.readString(cacheRoot.resolve("5/v2ray.txt"), StandardCharsets.UTF_8); + String decoded = new String(Base64.getMimeDecoder().decode(content), StandardCharsets.UTF_8); + assertTrue(decoded.contains("keep-1"), "实际内容: " + decoded); + assertTrue(decoded.contains("keep-2"), "实际内容: " + decoded); + assertFalse(decoded.contains("5x"), "高倍率节点应被剔除: " + decoded); + } + + /** 过滤关闭时,高倍率节点必须原样保留。 */ + @Test + void v2WithoutFilterKeepsHighMultiplierNodes() throws Exception { + SubscriptionAccount acc = account(6, "key6", true, false); + when(subMapper.selectSubscriptionAccount(6)).thenReturn(acc); + stubUpstream(v2Upstream("vmess://bbb#香港 5x"), List.of("proxies:")); + + assertTrue(service.refresh(6)); + + String content = Files.readString(cacheRoot.resolve("6/v2ray.txt"), StandardCharsets.UTF_8); + String decoded = new String(Base64.getMimeDecoder().decode(content), StandardCharsets.UTF_8); + assertTrue(decoded.contains("5x"), "未开启过滤时应保留: " + decoded); + } + + /** 名称里没有倍率标记的节点一律保留(无法判定即不删)。 */ + @Test + void v2FilterKeepsNodesWithoutMultiplierMarker() throws Exception { + SubscriptionAccount acc = account(7, "key7", true, true); + when(subMapper.selectSubscriptionAccount(7)).thenReturn(acc); + stubUpstream(v2Upstream("vmess://aaa#plain-node", "trojan://bbb#another"), List.of("proxies:")); + + assertTrue(service.refresh(7)); + + String decoded = decodeV2(List.of(Files.readString(cacheRoot.resolve("7/v2ray.txt")))); + assertTrue(decoded.contains("plain-node")); + assertTrue(decoded.contains("another")); + } + + /** 恰好等于阈值(2x)不算高倍率,只有严格大于才剔除。 */ + @Test + void v2FilterTreatsThresholdAsExclusive() throws Exception { + SubscriptionAccount acc = account(8, "key8", true, true); + when(subMapper.selectSubscriptionAccount(8)).thenReturn(acc); + stubUpstream(v2Upstream("vmess://aaa#节点 2x", "vmess://bbb#节点 2.1x"), List.of("proxies:")); + + assertTrue(service.refresh(8)); + + String decoded = decodeV2(List.of(Files.readString(cacheRoot.resolve("8/v2ray.txt")))); + assertTrue(decoded.contains("节点 2x"), "等于阈值应保留: " + decoded); + assertFalse(decoded.contains("2.1x"), "超过阈值应剔除: " + decoded); + } + + // ---------- 高倍率过滤(clash) ---------- + + /** clash 侧:被剔除节点的定义与其在 proxy-groups 里的引用都要清理。 */ + @Test + void clashFilterRemovesNodeDefinitionAndGroupReference() throws Exception { + SubscriptionAccount acc = account(9, "key9", true, true); + when(subMapper.selectSubscriptionAccount(9)).thenReturn(acc); + + List clashUpstream = List.of( + "proxies:", + " - name: \"keep\"", + " type: vmess", + " - name: \"drop 5x\"", + " type: vmess", + "proxy-groups:", + " - name: \"auto\"", + " proxies:", + " - keep", + " - \"drop 5x\""); + stubUpstream(v2Upstream("vmess://x"), clashUpstream); + + assertTrue(service.refresh(9)); + + String yaml = Files.readString(cacheRoot.resolve("9/clash.yaml"), StandardCharsets.UTF_8); + assertTrue(yaml.contains("keep"), "保留节点应在: " + yaml); + assertFalse(yaml.contains("drop 5x"), "高倍率节点不应出现(含分组引用): " + yaml); + } + + /** 过滤关闭时定与引用都应原样保留。 */ + @Test + void clashWithoutFilterKeepsEverything() throws Exception { + SubscriptionAccount acc = account(10, "key10", true, false); + when(subMapper.selectSubscriptionAccount(10)).thenReturn(acc); + + List clashUpstream = List.of( + "proxies:", + " - name: \"keep 5x\"", + "proxy-groups:", + " - name: \"auto\"", + " proxies:", + " - \"keep 5x\""); + stubUpstream(v2Upstream("vmess://x"), clashUpstream); + + assertTrue(service.refresh(10)); + + String yaml = Files.readString(cacheRoot.resolve("10/clash.yaml"), StandardCharsets.UTF_8); + assertTrue(yaml.contains("keep 5x"), "未过滤时应保留: " + yaml); + } + + /** 扫描只应作用于 proxies 段,proxy-groups 段里的名字即便带倍率也不剔除。 */ + @Test + void clashFilterOnlyAppliesInsideProxiesSection() throws Exception { + SubscriptionAccount acc = account(11, "key11", true, true); + when(subMapper.selectSubscriptionAccount(11)).thenReturn(acc); + + List clashUpstream = List.of( + "proxies:", + " - name: \"nodeA\"", + "proxy-groups:", + " - name: \"group 9x\""); // 分组名带倍率,但不在 proxies 段 + stubUpstream(v2Upstream("vmess://x"), clashUpstream); + + assertTrue(service.refresh(11)); + + String yaml = Files.readString(cacheRoot.resolve("11/clash.yaml"), StandardCharsets.UTF_8); + assertTrue(yaml.contains("group 9x"), "proxy-groups 段不应被过滤: " + yaml); + } + + // ---------- 失败路径 ---------- + + /** 上游返回空内容必须失败并记录错误,且不写缓存。 */ + @Test + void refreshFailsWhenUpstreamReturnsEmpty() throws Exception { + SubscriptionAccount acc = account(12, "key12", true, false); + when(subMapper.selectSubscriptionAccount(12)).thenReturn(acc); + doReturn(new ArrayList()).when(service).download(anyString()); + + assertFalse(service.refresh(12)); + + verify(subMapper).markSubscriptionRefreshFailure(eq(12), contains("为空")); + verify(subMapper, never()).markSubscriptionRefreshSuccess(anyInt()); + assertFalse(Files.exists(cacheRoot.resolve("12/v2ray.txt")), "失败时不应产出缓存"); + } + + /** 上游不是合法 Base64 时必须失败并记录原因。 */ + @Test + void refreshFailsOnInvalidBase64() throws Exception { + SubscriptionAccount acc = account(13, "key13", true, false); + when(subMapper.selectSubscriptionAccount(13)).thenReturn(acc); + stubUpstream(List.of("!!!not-base64!!!"), List.of("proxies:")); + + assertFalse(service.refresh(13)); + + verify(subMapper).markSubscriptionRefreshFailure(eq(13), contains("Base64")); + assertFalse(Files.exists(cacheRoot.resolve("13/v2ray.txt"))); + } + + /** 网络异常同样必须被兜住并记账,不能向上抛。 */ + @Test + void refreshFailsOnNetworkError() throws Exception { + SubscriptionAccount acc = account(14, "key14", true, false); + when(subMapper.selectSubscriptionAccount(14)).thenReturn(acc); + doThrow(new java.io.IOException("connection refused")).when(service).download(anyString()); + + assertFalse(service.refresh(14)); + + verify(subMapper).markSubscriptionRefreshFailure(eq(14), contains("connection refused")); + } + + /** 错误信息超过 500 字符时截断,避免写爆数据库字段。 */ + @Test + void refreshTruncatesLongErrorMessages() throws Exception { + SubscriptionAccount acc = account(15, "key15", true, false); + when(subMapper.selectSubscriptionAccount(15)).thenReturn(acc); + doThrow(new java.io.IOException("x".repeat(900))).when(service).download(anyString()); + + assertFalse(service.refresh(15)); + + var captor = org.mockito.ArgumentCaptor.forClass(String.class); + verify(subMapper).markSubscriptionRefreshFailure(eq(15), captor.capture()); + assertEquals(500, captor.getValue().length(), "错误信息应截断到 500 字符"); + } + + // ---------- refreshAll ---------- + + /** 只刷新启用账号;任一失败即整体返回 false,但其余账号仍继续刷新。 */ + @Test + void refreshAllSkipsDisabledAndReportsAnyFailure() throws Exception { + SubscriptionAccount ok = account(20, "k20", true, false); + SubscriptionAccount off = account(21, "k21", false, false); + SubscriptionAccount bad = account(22, "k22", true, false); + when(subMapper.selectAllSubscriptionAccounts()) + .thenReturn(new ArrayList<>(List.of(ok, off, bad))); + + when(subMapper.selectSubscriptionAccount(20)).thenReturn(ok); + when(subMapper.selectSubscriptionAccount(22)).thenReturn(bad); + doAnswer(inv -> { + String url = inv.getArgument(0); + if (url.contains("k22")) + throw new java.io.IOException("account 22 upstream down"); + if (url.contains("client=v2")) + return List.of(Base64.getEncoder().encodeToString("vmess://a".getBytes(StandardCharsets.UTF_8))); + return List.of("proxies:"); + }).when(service).download(anyString()); + + assertFalse(service.refreshAll(), "存在失败账号时整体应为 false"); + + verify(subMapper).markSubscriptionRefreshSuccess(20); + verify(subMapper, never()).selectSubscriptionAccount(21); // 停用账号不刷新 + assertTrue(Files.exists(cacheRoot.resolve("20/v2ray.txt"))); + } + + /** 全部成功时返回 true。 */ + @Test + void refreshAllReturnsTrueWhenEveryAccountSucceeds() throws Exception { + SubscriptionAccount a1 = account(30, "k30", true, false); + SubscriptionAccount a2 = account(31, "k31", true, false); + when(subMapper.selectAllSubscriptionAccounts()).thenReturn(new ArrayList<>(List.of(a1, a2))); + when(subMapper.selectSubscriptionAccount(30)).thenReturn(a1); + when(subMapper.selectSubscriptionAccount(31)).thenReturn(a2); + stubUpstream(v2Upstream("vmess://a"), List.of("proxies:")); + + assertTrue(service.refreshAll()); + + verify(subMapper, times(2)).markSubscriptionRefreshSuccess(anyInt()); + } + + /** 没有任何账号时视为成功(无事可做)。 */ + @Test + void refreshAllSucceedsWithNoAccounts() { + when(subMapper.selectAllSubscriptionAccounts()).thenReturn(new ArrayList<>()); + + assertTrue(service.refreshAll()); + } + + // ---------- 缓存状态 ---------- + + @Test + void hasCompleteCacheRequiresBothFiles() throws Exception { + assertFalse(service.hasCompleteCache(40)); + + Files.createDirectories(cacheRoot.resolve("40")); + Files.writeString(cacheRoot.resolve("40/v2ray.txt"), "x"); + assertFalse(service.hasCompleteCache(40), "只有 v2 时应为不完整"); + + Files.writeString(cacheRoot.resolve("40/clash.yaml"), "y"); + assertTrue(service.hasCompleteCache(40)); + } + + /** 失效缓存应删除两份文件,使同一账号的被拒请求不再拿到旧内容。 */ + @Test + void invalidateCacheRemovesBothFiles() throws Exception { + Files.createDirectories(cacheRoot.resolve("41")); + Files.writeString(cacheRoot.resolve("41/v2ray.txt"), "x"); + Files.writeString(cacheRoot.resolve("41/clash.yaml"), "y"); + + service.invalidateCache(41); + + assertFalse(Files.exists(cacheRoot.resolve("41/v2ray.txt"))); + assertFalse(Files.exists(cacheRoot.resolve("41/clash.yaml"))); + } + + /** 缓存文件本就不存在时不应抛异常。 */ + @Test + void invalidateCacheIsIdempotent() { + assertDoesNotThrow(() -> service.invalidateCache(42)); + } +} diff --git a/src/test/java/com/lion/lionwebsite/Service/UserServiceTest.java b/src/test/java/com/lion/lionwebsite/Service/UserServiceTest.java new file mode 100644 index 0000000..b55bf79 --- /dev/null +++ b/src/test/java/com/lion/lionwebsite/Service/UserServiceTest.java @@ -0,0 +1,242 @@ +package com.lion.lionwebsite.Service; + +import com.lion.lionwebsite.Dao.normal.CollectMapper; +import com.lion.lionwebsite.Dao.normal.GalleryMapper; +import com.lion.lionwebsite.Dao.normal.UserMapper; +import com.lion.lionwebsite.Domain.User; +import com.lion.lionwebsite.Interceptor.TaskHandlerInterceptor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 用户与授权码管理的业务规则。 + * 这些方法决定谁能登录、被删用户的任务与收藏如何善后,因此重点落在 + * 「副作用是否发生」与「失败时是否误报成功」两点上。 + */ +class UserServiceTest { + + private UserMapper userMapper; + private GalleryMapper galleryMapper; + private CollectMapper collectMapper; + private TaskHandlerInterceptor interceptor; + private UserService service; + + @BeforeEach + void setUp() { + userMapper = mock(UserMapper.class); + galleryMapper = mock(GalleryMapper.class); + collectMapper = mock(CollectMapper.class); + interceptor = mock(TaskHandlerInterceptor.class); + service = new UserService(userMapper, galleryMapper, collectMapper, interceptor); + } + + private static User user(int id, String authCode, String username) { + return new User(id, authCode, username, null, true); + } + + private static boolean ok(String json) { + return json.contains("\"result\":\"success\""); + } + + // ---------- addAuthCode ---------- + + /** 新增授权码必须刷新拦截器缓存,否则新用户要等到重启才能用。 */ + @Test + void addAuthCodePersistsAndRefreshesInterceptorCache() { + assertTrue(ok(service.addAuthCode("code-1", "alice"))); + + var captor = org.mockito.ArgumentCaptor.forClass(User.class); + verify(userMapper).insertUser(captor.capture()); + assertEquals("code-1", captor.getValue().getAuthCode()); + assertEquals("alice", captor.getValue().getUsername()); + assertTrue(captor.getValue().isEnable(), "新用户默认应启用"); + verify(interceptor).updateAuthCodes(); + } + + /** 落库异常必须转成业务失败,且不得刷新缓存。 */ + @Test + void addAuthCodeReportsFailureOnMapperError() { + doThrow(new RuntimeException("db down")).when(userMapper).insertUser(any()); + + String json = service.addAuthCode("code-1", "alice"); + assertFalse(ok(json)); + assertTrue(json.contains("插入失败")); + verify(interceptor, never()).updateAuthCodes(); + } + + // ---------- alterAuthCode / alterUsername ---------- + + @Test + void alterAuthCodeSucceedsOnlyWhenCodeExists() { + when(userMapper.isExist("old")).thenReturn(1); + assertTrue(ok(service.alterAuthCode("old", "new"))); + verify(userMapper).updateAuthCode("old", "new"); + verify(interceptor).updateAuthCodes(); + + when(userMapper.isExist("missing")).thenReturn(0); + String json = service.alterAuthCode("missing", "new"); + assertFalse(ok(json)); + assertTrue(json.contains("授权码不存在")); + verify(userMapper, never()).updateAuthCode(eq("missing"), anyString()); + } + + @Test + void alterAuthCodeReportsFailureOnMapperError() { + when(userMapper.isExist("old")).thenReturn(1); + doThrow(new RuntimeException("db down")).when(userMapper).updateAuthCode(anyString(), anyString()); + + String json = service.alterAuthCode("old", "new"); + assertFalse(ok(json)); + assertTrue(json.contains("修改失败")); + verify(interceptor, never()).updateAuthCodes(); + } + + /** 改用户名不影响登录凭据,因此不应刷新授权码缓存。 */ + @Test + void alterUsernameDoesNotTouchAuthCodeCache() { + when(userMapper.isExist("code")).thenReturn(1); + assertTrue(ok(service.alterUsername("code", "bob"))); + verify(userMapper).updateUsername("code", "bob"); + verify(interceptor, never()).updateAuthCodes(); + } + + @Test + void alterUsernameRejectsUnknownCodeAndReportsMapperError() { + when(userMapper.isExist("missing")).thenReturn(0); + String json = service.alterUsername("missing", "bob"); + assertFalse(ok(json)); + assertTrue(json.contains("授权码不存在")); + + when(userMapper.isExist("code")).thenReturn(1); + doThrow(new RuntimeException("db down")).when(userMapper).updateUsername(anyString(), anyString()); + String failed = service.alterUsername("code", "bob"); + assertFalse(ok(failed)); + assertTrue(failed.contains("修改失败")); + } + + // ---------- deleteAuthCode ---------- + + /** 删除用户要把其收藏全部取消,并把其名下任务转交给下载人 3,避免留下悬空记录。 */ + @Test + void deleteAuthCodeClearsCollectionsAndReassignsGalleries() { + when(userMapper.isExist("code")).thenReturn(1); + when(userMapper.selectUserByAuthCode("code")).thenReturn(user(7, "code", "alice")); + when(collectMapper.selectGidByCollector(7)).thenReturn(new ArrayList<>(List.of(11, 22))); + + assertTrue(ok(service.deleteAuthCode("code"))); + + verify(collectMapper).disCollect(11, 7); + verify(collectMapper).disCollect(22, 7); + verify(galleryMapper).updateGalleryDownloader(3, 7); + verify(userMapper).deleteUserByAuthCode("code"); + verify(interceptor).updateAuthCodes(); + } + + /** 无收藏的用户也应能删除:此时不应调用任何取消收藏操作。 */ + @Test + void deleteAuthCodeWorksForUserWithoutCollections() { + when(userMapper.isExist("code")).thenReturn(1); + when(userMapper.selectUserByAuthCode("code")).thenReturn(user(8, "code", "bob")); + when(collectMapper.selectGidByCollector(8)).thenReturn(new ArrayList<>()); + + assertTrue(ok(service.deleteAuthCode("code"))); + verify(collectMapper, never()).disCollect(anyInt(), anyInt()); + verify(userMapper).deleteUserByAuthCode("code"); + } + + /** 不存在的授权码必须直接失败,不能落任何删除动作。 */ + @Test + void deleteAuthCodeRejectsUnknownCode() { + when(userMapper.isExist("missing")).thenReturn(0); + + String json = service.deleteAuthCode("missing"); + assertFalse(ok(json)); + assertTrue(json.contains("授权码不存在")); + verify(userMapper, never()).deleteUserByAuthCode(anyString()); + verify(interceptor, never()).updateAuthCodes(); + } + + /** 中途异常必须报失败,不能给出「删除成功」的假象。 */ + @Test + void deleteAuthCodeReportsFailureWhenCleanupThrows() { + when(userMapper.isExist("code")).thenReturn(1); + when(userMapper.selectUserByAuthCode("code")).thenReturn(user(7, "code", "alice")); + when(collectMapper.selectGidByCollector(7)).thenThrow(new RuntimeException("db down")); + + String json = service.deleteAuthCode("code"); + assertFalse(ok(json)); + assertTrue(json.contains("删除失败")); + verify(userMapper, never()).deleteUserByAuthCode(anyString()); + } + + // ---------- alterStatus ---------- + + /** 停用用户时同样要交还其任务,否则停用后任务仍挂在不可用账号上。 */ + @Test + void alterStatusUpdatesFlagAndReassignsGalleries() { + when(userMapper.isExist("code")).thenReturn(1); + when(userMapper.selectUserByAuthCode("code")).thenReturn(user(5, "code", "alice")); + + assertTrue(ok(service.alterStatus("code", false))); + + verify(userMapper).updateIsEnableById(5, false); + verify(galleryMapper).updateGalleryDownloader(3, 5); + verify(interceptor).updateAuthCodes(); + } + + @Test + void alterStatusRejectsUnknownUser() { + when(userMapper.isExist("ghost")).thenReturn(0); + + String json = service.alterStatus("ghost", false); + assertFalse(ok(json)); + assertTrue(json.contains("该用户不存在")); + verify(userMapper, never()).updateIsEnableById(anyInt(), anyBoolean()); + } + + // ---------- getAllUser / getUserId ---------- + + @Test + void getAllUserSerialisesEveryUser() { + when(userMapper.selectAllUser()).thenReturn(new User[]{ + user(1, "a", "alice"), user(2, "b", "bob")}); + + String json = service.getAllUser(); + assertTrue(ok(json)); + assertTrue(json.contains("alice")); + assertTrue(json.contains("bob")); + } + + /** 空表也要返回成功,且 data 是「数组序列化后的字符串」(历史契约,前端按字符串解析)。 */ + @Test + void getAllUserHandlesEmptyTable() { + when(userMapper.selectAllUser()).thenReturn(new User[0]); + + String json = service.getAllUser(); + assertTrue(ok(json)); + assertTrue(json.contains("\"data\":\"[]\""), "实际输出: " + json); + } + + @Test + void getUserIdResolvesCodeToId() { + when(userMapper.selectUserByAuthCode("code")).thenReturn(user(42, "code", "alice")); + assertEquals(42, service.getUserId("code")); + } + + /** + * 现状记录:授权码不存在时 getUserId 直接对 null 取 id,会抛 NPE。 + * 生产上靠调用方(拦截器)先校验授权码规避;若此处改为返回 -1 或抛业务异常,说明已修复。 + */ + @Test + void getUserIdThrowsForUnknownCode() { + when(userMapper.selectUserByAuthCode("ghost")).thenReturn(null); + assertThrows(NullPointerException.class, () -> service.getUserId("ghost")); + } +} diff --git a/src/test/java/com/lion/lionwebsite/Service/WebSocketServiceTest.java b/src/test/java/com/lion/lionwebsite/Service/WebSocketServiceTest.java new file mode 100644 index 0000000..07ab035 --- /dev/null +++ b/src/test/java/com/lion/lionwebsite/Service/WebSocketServiceTest.java @@ -0,0 +1,144 @@ +package com.lion.lionwebsite.Service; + +import com.lion.lionwebsite.Domain.GalleryTask; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketMessage; +import org.springframework.web.socket.WebSocketSession; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 下载进度推送的会话管理。 + * 会话列表的增删决定谁能收到推送,而推送失败必须被吞掉(单条坏连接不能 + * 中断整批广播),这两点都要锁死。 + */ +class WebSocketServiceTest { + + private WebSocketService service; + + @BeforeEach + void setUp() { + service = new WebSocketService(); + } + + private static GalleryTask task(int gid, byte status) { + GalleryTask t = new GalleryTask(); + t.setGid(gid); + t.setStatus(status); + return t; + } + + private static WebSocketSession session(String id) { + WebSocketSession s = mock(WebSocketSession.class); + when(s.getId()).thenReturn(id); + return s; + } + + // ---------- 会话注册 ---------- + + /** 只有发来 DownloaderWebsocket 的会话才会被登记(前端约定)。 */ + @Test + void registersSessionOnlyForExpectedHello() throws Exception { + var good = session("s1"); + service.handleMessage(good, new TextMessage("DownloaderWebsocket")); + + service.updateTaskProcessing(new GalleryTask[]{task(1, GalleryTask.DOWNLOADING)}); + + verify(good).sendMessage(any(TextMessage.class)); + } + + /** 其他内容一律关闭连接,且不得进入推送名单。 */ + @Test + void closesSessionWithUnexpectedHello() throws Exception { + var bad = session("s2"); + service.handleMessage(bad, new TextMessage("something-else")); + + verify(bad).close(); + + service.updateTaskProcessing(new GalleryTask[]{task(1, GalleryTask.DOWNLOADING)}); + verify(bad, never()).sendMessage(any(TextMessage.class)); + } + + /** 断开后必须移出名单,否则会向已关闭的会话反复推送。 */ + @Test + void closedSessionStopsReceivingMessages() throws Exception { + var s = session("s3"); + service.handleMessage(s, new TextMessage("DownloaderWebsocket")); + service.afterConnectionClosed(s, CloseStatus.NORMAL); + + service.updateTaskProcessing(new GalleryTask[]{task(1, GalleryTask.DOWNLOADING)}); + + verify(s, never()).sendMessage(any(TextMessage.class)); + } + + // ---------- 推送内容 ---------- + + /** 无会话时直接返回,不应产生任何 JSON 构造开销之外的副作用。 */ + @Test + void noSessionsMeansNoop() { + assertDoesNotThrow(() -> + service.updateTaskProcessing(new GalleryTask[]{task(1, GalleryTask.DOWNLOADING)})); + } + + /** 普通进度:推送 updateTasks 事件,内含任务数据。 */ + @Test + void broadcastsTaskUpdateWithPayload() throws Exception { + var s = session("s4"); + service.handleMessage(s, new TextMessage("DownloaderWebsocket")); + + service.updateTaskProcessing(new GalleryTask[]{task(42, GalleryTask.DOWNLOADING)}); + + var captor = org.mockito.ArgumentCaptor.forClass(WebSocketMessage.class); + verify(s).sendMessage(captor.capture()); + String payload = captor.getValue().getPayload().toString(); + assertTrue(payload.contains("updateTasks"), "实际推送: " + payload); + assertTrue(payload.contains("42")); + } + + /** 只要有一项压缩完成,就改推 fullUpdate 让前端整体刷新。 */ + @Test + void completionTriggersFullUpdate() throws Exception { + var s = session("s5"); + service.handleMessage(s, new TextMessage("DownloaderWebsocket")); + + service.updateTaskProcessing(new GalleryTask[]{ + task(1, GalleryTask.DOWNLOADING), + task(2, GalleryTask.COMPRESS_COMPLETE)}); + + var captor = org.mockito.ArgumentCaptor.forClass(WebSocketMessage.class); + verify(s).sendMessage(captor.capture()); + assertTrue(captor.getValue().getPayload().toString().contains("fullUpdate")); + } + + /** 单项发送失败不能影响其他会话(坏连接隔离)。 */ + @Test + void sendFailureDoesNotBlockOtherSessions() throws Exception { + var broken = session("broken"); + var healthy = session("healthy"); + service.handleMessage(broken, new TextMessage("DownloaderWebsocket")); + service.handleMessage(healthy, new TextMessage("DownloaderWebsocket")); + + doThrow(new java.io.IOException("pipe closed")) + .when(broken).sendMessage(any(TextMessage.class)); + + assertDoesNotThrow(() -> + service.updateTaskProcessing(new GalleryTask[]{task(7, GalleryTask.DOWNLOADING)})); + + verify(healthy).sendMessage(any(TextMessage.class)); + } + + /** 生命周期钩子按约定不做任何事(有意的空实现)。 */ + @Test + void lifecycleHooksAreNoops() { + assertFalse(service.supportsPartialMessages()); + assertDoesNotThrow(() -> { + service.afterConnectionEstablished(session("s6")); + service.handleTransportError(session("s7"), new RuntimeException("x")); + }); + } +} diff --git a/src/test/java/com/lion/lionwebsite/Util/GalleryParsingTest.java b/src/test/java/com/lion/lionwebsite/Util/GalleryParsingTest.java new file mode 100644 index 0000000..f2d4edd --- /dev/null +++ b/src/test/java/com/lion/lionwebsite/Util/GalleryParsingTest.java @@ -0,0 +1,336 @@ +package com.lion.lionwebsite.Util; + +import com.lion.lionwebsite.Domain.Gallery; +import com.lion.lionwebsite.Domain.ImageKeyCache; +import com.lion.lionwebsite.Exception.ResolutionNotMatchException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 画廊详情页与 mpv 页的解析。 + * 这两条路径是「提交下载任务」的前置步骤,解析错一个字段就会把错误的页数/分辨率 + * 写进任务;下游节点不可在单测中访问,故 requests() 全部打桩为 fixture HTML。 + */ +class GalleryParsingTest { + + private static final String URL = "https://exhentai.org/g/1234567/0123456789ab/"; + + /** 画廊详情页:#gn 名称、#gdd 第 4/5/6 行分别是语言/体积/页数、#gd5 下载入口。 */ + private static String galleryPage() { + return "" + + "

My Gallery

" + + "
" + + "

a

" + + "Download" + + "

" + + "
" + + "" + + "" + + "" + + "" + + "" + + "" + + "
xy
xy
xy
LanguageEnglish
File Size123.4 MiB
Length42 pages
" + + ""; + } + + /** 归档页:可选分辨率表 + 提交表单 action。 */ + private static String downloadPage() { + return "
" + + "" + + "" + + "

1280x 12.5 MiB

Original 30.2 MiB

" + + "
" + + ""; + } + + private static String submittedPage() { + return "

a

" + + "

#12345 Download started

"; + } + + /** 按 URL 分派三类页面,模拟一次完整解析流程。 */ + private static void stubPages(org.mockito.MockedStatic ms) { + ms.when(() -> GalleryUtil.requests(anyString(), anyString(), any(), any())).thenAnswer(inv -> { + String u = inv.getArgument(0); + if (u.contains("/g/")) return galleryPage(); + if (u.contains("archiveresolve")) return submittedPage(); + return downloadPage(); + }); + ms.when(() -> GalleryUtil.verifyLink(anyString())).thenCallRealMethod(); + ms.when(() -> GalleryUtil.parse(anyString(), anyBoolean(), any())).thenCallRealMethod(); + } + + /** 仅解析(不下载):应填好基本信息与可选分辨率,状态停在等待确认。 */ + @Test + void parseCollectsMetadataWithoutSubmitting() throws Exception { + try (var ms = mockStatic(GalleryUtil.class)) { + stubPages(ms); + + Gallery g = GalleryUtil.parse(URL, false, ""); + + assertEquals(1234567, g.getGid()); + assertEquals("My Gallery [1234567]", g.getName(), "名称后缀应带 gid"); + assertEquals("English", g.getLanguage()); + assertEquals(42, g.getPages()); + assertEquals("/t/cover.jpg", g.getThumb_link(), "缩略图应去掉站点前缀"); + assertTrue(g.getAvailableResolution().containsKey("1280x")); + assertTrue(g.getAvailableResolution().containsKey("Original")); + assertEquals("等待确认下载", g.getStatus()); + assertNull(g.getResolution(), "未下载时不应设定目标分辨率"); + // 未下载分支不得发起提交请求 + ms.verify(() -> GalleryUtil.requests( + contains("archiveresolve"), eq("post"), any(), any()), never()); + } + } + + /** 下载且分辨率可用:应设定分辨率与体积,并把提交结果记为「已提交」。 */ + @Test + void parseSubmitsDownloadForAvailableResolution() throws Exception { + try (var ms = mockStatic(GalleryUtil.class)) { + stubPages(ms); + + Gallery g = GalleryUtil.parse(URL, true, "1280x"); + + assertEquals("1280x", g.getResolution()); + assertEquals("已提交", g.getStatus()); + assertTrue(g.getFileSize() > 0, "应按目标分辨率重算体积"); + } + } + + /** 请求的分辨率不在可选列表里必须抛业务异常,而不是提交一个无效任务。 */ + @Test + void parseThrowsWhenResolutionUnavailable() throws Exception { + try (var ms = mockStatic(GalleryUtil.class)) { + stubPages(ms); + + var thrown = assertThrows(ResolutionNotMatchException.class, + () -> GalleryUtil.parse(URL, true, "4096x")); + + assertTrue(thrown.getMessage().contains("4096x"), "异常应带上目标分辨率"); + ms.verify(() -> GalleryUtil.requests( + contains("archiveresolve"), eq("post"), any(), any()), never()); + } + } + + /** 提交页没有 # 开头的成功标记时记为「提交失败」,不能误报已提交。 */ + @Test + void parseMarksFailureWhenSubmissionNotConfirmed() throws Exception { + String failurePage = "

a

Error occurred

"; + try (var ms = mockStatic(GalleryUtil.class)) { + ms.when(() -> GalleryUtil.requests(anyString(), anyString(), any(), any())).thenAnswer(inv -> { + String u = inv.getArgument(0); + if (u.contains("/g/")) return galleryPage(); + if (u.contains("archiveresolve")) return failurePage; + return downloadPage(); + }); + ms.when(() -> GalleryUtil.verifyLink(anyString())).thenCallRealMethod(); + ms.when(() -> GalleryUtil.parse(anyString(), anyBoolean(), any())).thenCallRealMethod(); + + Gallery g = GalleryUtil.parse(URL, true, "Original"); + assertEquals("提交失败", g.getStatus()); + } + } + + /** 非法链接应直接返回 null,不产生任何网络请求。 */ + @Test + void parseReturnsNullForInvalidLink() throws Exception { + try (var ms = mockStatic(GalleryUtil.class)) { + ms.when(() -> GalleryUtil.verifyLink(anyString())).thenCallRealMethod(); + ms.when(() -> GalleryUtil.parse(anyString(), anyBoolean(), any())).thenCallRealMethod(); + + assertNull(GalleryUtil.parse("https://example.com/short", true, "1280x")); + ms.verify(() -> GalleryUtil.requests(anyString(), anyString(), any(), any()), never()); + } + } + + /** 标着 N/A 的分辨率必须被跳过,不能出现在可选列表里。 */ + @Test + void parseSkipsUnavailableResolutions() throws Exception { + String withNa = "
" + + "" + + "" + + "

1280x N/A

Original 30.2 MiB

" + + "
"; + try (var ms = mockStatic(GalleryUtil.class)) { + ms.when(() -> GalleryUtil.requests(anyString(), anyString(), any(), any())).thenAnswer(inv -> { + String u = inv.getArgument(0); + if (u.contains("/g/")) return galleryPage(); + return withNa; + }); + ms.when(() -> GalleryUtil.verifyLink(anyString())).thenCallRealMethod(); + ms.when(() -> GalleryUtil.parse(anyString(), anyBoolean(), any())).thenCallRealMethod(); + + Gallery g = GalleryUtil.parse(URL, false, ""); + + assertFalse(g.getAvailableResolution().containsKey("1280x"), "N/A 分辨率应被跳过"); + assertTrue(g.getAvailableResolution().containsKey("Original")); + } + } + + // ---------- parseImageKeys ---------- + + /** + * mpv 页的 var 行布局,取自 2026-09-15 线上真实页面 + * (https://exhentai.org/mpv/1596929/f08534d87d/): + * 第 0 行 var gid、第 1 行 mpvkey、第 2 行 imagelist。 + * 注意 imagelist 行是 JS 语句,行尾带分号——这一点是本文件好几个断言的根因。 + */ + private static String mpvPage() { + return ""; + } + + /** + * 回归修复验证(缺陷于 2026-09-14 Jackson 2→3 迁移引入,2026-09-15 修复)。 + * + * 真实 mpv 页的 imagelist 是 JS 赋值语句、行尾带分号(`var imagelist = [...];`)。 + * Jackson 2 默认忽略尾随 token,Jackson 3 默认 `FAIL_ON_TRAILING_TOKENS = true` + * 会抛 `StreamReadException`,导致新画廊在线看图 500。 + * 现由 `parseImagelist` 先剥掉 JS 语句外壳(前缀 + 行尾分号)再解析。 + */ + @Test + void parseImageKeysAcceptsRealPageFormatWithTrailingSemicolon() throws Exception { + try (var ms = mockStatic(GalleryUtil.class)) { + ms.when(() -> GalleryUtil.requests(anyString(), anyString(), any(), any())).thenReturn(mpvPage()); + ms.when(() -> GalleryUtil.parseImageKeys(anyString())).thenCallRealMethod(); + + ArrayList keys = GalleryUtil.parseImageKeys(URL); + + assertNotNull(keys, "真实页面格式(行尾带分号)必须能解析"); + assertEquals(3, keys.size()); + assertEquals("1234567", keys.get(0).getGid()); + assertEquals(1, keys.get(0).getPage()); + assertEquals("key1", keys.get(0).getImgkey()); + assertEquals("key3", keys.get(2).getImgkey()); + } + } + + /** 无分号的变体(理论上限)同样可解析,行为不依赖分号是否存在。 */ + @Test + void parseImageKeysAcceptsVariantWithoutTrailingSemicolon() throws Exception { + String noSemicolon = mpvPage().replace("\"k\":\"key3\"}];", "\"k\":\"key3\"}]"); + try (var ms = mockStatic(GalleryUtil.class)) { + ms.when(() -> GalleryUtil.requests(anyString(), anyString(), any(), any())).thenReturn(noSemicolon); + ms.when(() -> GalleryUtil.parseImageKeys(anyString())).thenCallRealMethod(); + + ArrayList keys = GalleryUtil.parseImageKeys(URL); + assertNotNull(keys); + assertEquals(3, keys.size()); + } + } + + /** 页码必须从 1 开始逐页递增(前端按页码取图)。 */ + @Test + void parseImageKeysNumbersPagesFromOne() throws Exception { + try (var ms = mockStatic(GalleryUtil.class)) { + ms.when(() -> GalleryUtil.requests(anyString(), anyString(), any(), any())).thenReturn(mpvPage()); + ms.when(() -> GalleryUtil.parseImageKeys(anyString())).thenCallRealMethod(); + + ArrayList keys = GalleryUtil.parseImageKeys(URL); + + for (int i = 0; i < keys.size(); i++) + assertEquals(i + 1, keys.get(i).getPage(), "页码应从 1 开始递增"); + } + } + + /** 空响应表示画廊已下架/被删,应返回 null 交由上层提示。 */ + @Test + void parseImageKeysReturnsNullForEmptyResponse() throws Exception { + try (var ms = mockStatic(GalleryUtil.class)) { + ms.when(() -> GalleryUtil.requests(anyString(), anyString(), any(), any())).thenReturn(" "); + ms.when(() -> GalleryUtil.parseImageKeys(anyString())).thenCallRealMethod(); + + assertNull(GalleryUtil.parseImageKeys(URL)); + } + } + + /** 页面缺少预期 script 标签时返回 null,而不是抛索引越界。 */ + @Test + void parseImageKeysReturnsNullWhenScriptsMissing() throws Exception { + try (var ms = mockStatic(GalleryUtil.class)) { + ms.when(() -> GalleryUtil.requests(anyString(), anyString(), any(), any())) + .thenReturn(""); + ms.when(() -> GalleryUtil.parseImageKeys(anyString())).thenCallRealMethod(); + + assertNull(GalleryUtil.parseImageKeys(URL)); + } + } + + /** 解析成功后 mpvKey 应进入缓存,后续 getMpvKey 可直接命中。 */ + @Test + void parseImageKeysCachesMpvKey() throws Exception { + try (var ms = mockStatic(GalleryUtil.class)) { + ms.when(() -> GalleryUtil.requests(anyString(), anyString(), any(), any())).thenReturn(mpvPage()); + ms.when(() -> GalleryUtil.parseImageKeys(anyString())).thenCallRealMethod(); + ms.when(() -> GalleryUtil.getMpvKey(anyString())).thenCallRealMethod(); + ms.when(() -> GalleryUtil.parseGid(anyString())).thenCallRealMethod(); + + GalleryUtil.parseImageKeys(URL); + + assertEquals("abc123", GalleryUtil.getMpvKey(URL), "应命中刚写入的缓存"); + } + } + + // ---------- convertImg ---------- + + /** + * 真实调用 ImageMagick 把一张 GIF 转成 AVIF。 + * 若环境缺少 convert 命令则跳过(转换失败返回 null,属既有降级行为)。 + */ + @Test + void convertImgProducesAvifAndRemovesSource(@TempDir Path dir) throws Exception { + Path gif = dir.resolve("page.img"); + Files.write(gif, tinyGif()); + + String result = GalleryUtil.convertImg(gif.toString(), ".img"); + + if (result == null) { + // 环境无 ImageMagick:只断言降级行为(不抛异常、原文件保留) + assertTrue(Files.exists(gif), "转换失败时原文件应保留"); + return; + } + assertTrue(result.endsWith(".avif"), "实际结果: " + result); + assertTrue(Files.exists(Path.of(result)), "应产出 avif 文件"); + assertTrue(Files.size(Path.of(result)) > 0); + assertFalse(Files.exists(gif), "转换成功后应删除源文件"); + } + + /** 输入未变化(后缀已是 .avif)时直接返回原路径,不做转换。 */ + @Test + void convertImgReturnsInputWhenSuffixMatchesTarget(@TempDir Path dir) throws Exception { + Path already = dir.resolve("page.avif"); + Files.write(already, new byte[]{1, 2, 3}); + + assertEquals(already.toString(), GalleryUtil.convertImg(already.toString(), ".avif")); + assertTrue(Files.exists(already), "不应删除或改写文件"); + } + + /** 输入不是图片时转换失败,必须返回 null 而非抛异常。 */ + @Test + void convertImgReturnsNullForNonImage(@TempDir Path dir) throws Exception { + Path bogus = dir.resolve("not-an-image.img"); + Files.writeString(bogus, "this is definitely not an image"); + + assertNull(GalleryUtil.convertImg(bogus.toString(), ".img")); + } + + /** 最小合法 GIF(1x1 透明像素)。 */ + private static byte[] tinyGif() { + return new byte[]{ + 'G', 'I', 'F', '8', '9', 'a', 1, 0, 1, 0, (byte) 0x80, 0, 0, 0, 0, 0, + (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, ',', 0, 0, 0, 0, 1, 0, 1, 0, 0, + 2, 2, 0x44, 1, 0, ';'}; + } +}