修复 Jackson 3 迁移引入的看图 500,并大幅补充单元测试

生产缺陷(2026-09-14 Spring Boot 4.1.1 / Jackson 2→3 迁移引入):
- GalleryUtil.parseImageKeys 解析 mpv 页 imagelist 时,行尾分号属 JS 语法,
  Jackson 3 默认 FAIL_ON_TRAILING_TOKENS=true 会抛 StreamReadException。
  改为先由 parseImagelist 剥掉 JS 语句外壳(前缀 + 行尾分号)再交给 JSON 解析。
  已用线上真实页面验证:修复前必抛异常,修复后正确解析出 154 页 imgkey。
- GalleryManageService.cacheImagesKey 只 catch IOException,而 Jackson 3 的
  JacksonException 继承 RuntimeException,异常穿透 Controller(无 @ControllerAdvice)
  表现为 HTTP 500。改为同时兜 JacksonException 并按业务失败返回。
  同时用 parseGidFromLink 拦住段数不足的畸形链接(原先会抛
  ArrayIndexOutOfBoundsException 变成 500)。

既有行为修正:
- PersonalService.deleteFile 原先依据 hutool FileUtil.del 的返回值报成功,
  而该函数对不存在的目标返回 true,导致路径写错也显示「删除成功」;
  改为先校验存在性,不存在则回「文件不存在」。
- PersonalService.getFiles 的 listFiles()==null 分支原先报「文件夹为空」,
  但该返回值只在 I/O 出错时出现,会掩盖权限/磁盘问题,改为「读取文件夹失败」。

测试:143 → 379 用例,指令覆盖率 36.2% → 80.2%(分支 34.6% → 69.8%)。
新增控制器(MockMvc standaloneSetup)、服务层、拦截器/过滤器、WebSocket、
配置装配与 MyBatis AOT hints 的测试;全部离线可跑,不加载 Spring 上下文、不连库、不占端口。
This commit is contained in:
root
2026-09-15 13:42:22 +08:00
parent a288846552
commit a0959618ca
25 changed files with 4609 additions and 7 deletions
@@ -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<ImageKeyCache> 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();
@@ -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("删除失败");
@@ -185,7 +185,7 @@ public class GalleryUtil {
ArrayList<ImageKeyCache> 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);
@@ -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<String> order = new ArrayList<>();
final Map<String, List<String>> 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<String, List<String>> paths;
RecordingRegistration(String name, Map<String, List<String>> 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<String> 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<String> 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<String, org.springframework.web.cors.CorsConfiguration> 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 冲突");
}
}
@@ -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<Class<?>> 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<Class<?>> 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<org.springframework.aot.generate.MethodReference> 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<String> genericList();
java.util.Map<String, Integer> genericMap();
void twoArgs(String a, Integer b);
}
}
@@ -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);
}
}
@@ -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<String> 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);
}
}
@@ -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);
}
}
@@ -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());
}
}
@@ -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);
}
}
@@ -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(""));
}
}
@@ -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()));
}
}
@@ -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());
}
}
@@ -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 = "<html><body><script>x</script><script>\n"
+ "var gid=1596929;\n"
+ "var mpvkey = \"nfa9l8ianjg\";\n"
+ "var imagelist = [{\"n\":\"a.png\",\"k\":\"bd9015\",\"t\":\"(x) -0px 0\"}];\n"
+ "</script></body></html>";
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 = "<html><body><script>x</script><script>\n"
+ "var gid=1;\n"
+ "var mpvkey = \"k\";\n"
+ "var imagelist = [{{not valid json;\n"
+ "</script></body></html>";
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 段非数字时应返回「链接错误」且不落库、不下发节点。 */
@@ -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("未知错误"));
}
}
@@ -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("<html>ok</html>");
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}"));
}
}
@@ -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");
}
}
@@ -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);
}
};
}
}
@@ -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"));
}
}
@@ -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 "<html><body>"
+ "<div class='ido'><div class='hdr'>header</div><div class='body'>"
+ "<table><tbody>"
+ "<tr><td>skipme</td></tr>"
+ "<tr>"
+ " <td>Manga</td>"
+ " <td><img src='/t/thumb.jpg'/>"
+ " <div class='x'><div class='y'>"
+ " <span onclick='a()'>first</span>"
+ " <span onclick='b()'>2026-01-01 12:00</span>"
+ " </div></div></td>"
+ " <td><a href='https://exhentai.org/g/1234567/abcdef/'>"
+ " <div class='glink'>Title Here</div></a></td>"
+ " <td><div>x</div><span>42 pages</span></td>"
+ "</tr>"
+ "</tbody></table></div></div>"
+ "<a id='unext' href='https://exhentai.org/?next=1'>n</a>"
+ "<a id='uprev' href='https://exhentai.org/?prev=1'>p</a>"
+ "<a id='ufirst' href='https://exhentai.org/?first=1'>f</a>"
+ "<a id='ulast' href='https://exhentai.org/?last=1'>l</a>"
+ "</body></html>";
}
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 = "<html><body><div class='ido'><div></div><div>"
+ "<table><tbody><tr><td>s</td></tr>"
+ "<tr><td>T</td>"
+ "<td><img src='data:image/gif;base64,R0lGOD' data-src='https://s.exhentai.org/t/lazy.jpg'/>"
+ "<div><div><span onclick='a()'>f</span><span onclick='b()'>2026-01-01</span></div></div></td>"
+ "<td><a href='https://exhentai.org/g/99/zz/'><div>Lazy</div></a></td>"
+ "<td><div>x</div><span>7 pages</span></td>"
+ "</tr></tbody></table></div></div></body></html>";
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("<html><body><div class='ido'><div></div><div>"
+ "<table><tbody></tbody></table></div></div></body></html>");
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 {
// 缓存目录布局:<CachePath>/<fileName>.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());
}
}
}
@@ -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<com.lion.lionwebsite.Message.AbstractMessage> 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());
}
}
@@ -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<String> 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<String> upstream) {
return new String(Base64.getMimeDecoder().decode(upstream.getFirst()), StandardCharsets.UTF_8);
}
private void stubUpstream(List<String> v2, List<String> 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<String> 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<String> 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<String> 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<String>()).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));
}
}
@@ -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"));
}
}
@@ -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"));
});
}
}
@@ -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 "<html><body>"
+ "<h1 id='gn'>My Gallery</h1>"
+ "<div id='gd1'><div style='background:url(https://s.exhentai.org/t/cover.jpg) no-repeat'></div></div>"
+ "<div id='gd5'><p>a</p><p>"
+ "<a onclick=\"return false; location.href='https://exhentai.org/archive/abc123'; return false\">Download</a>"
+ "</p></div>"
+ "<div id='gdd'><table><tbody>"
+ "<tr><td class='gdt1'>x</td><td class='gdt2'>y</td></tr>"
+ "<tr><td class='gdt1'>x</td><td class='gdt2'>y</td></tr>"
+ "<tr><td class='gdt1'>x</td><td class='gdt2'>y</td></tr>"
+ "<tr><td class='gdt1'>Language</td><td class='gdt2'>English</td></tr>"
+ "<tr><td class='gdt1'>File Size</td><td class='gdt2'>123.4 MiB</td></tr>"
+ "<tr><td class='gdt1'>Length</td><td class='gdt2'>42 pages</td></tr>"
+ "</tbody></table></div>"
+ "</body></html>";
}
/** 归档页:可选分辨率表 + 提交表单 action。 */
private static String downloadPage() {
return "<html><body><div id='db'><div><table><tbody>"
+ "<tr><td><p>1280x 12.5 MiB</p></td></tr>"
+ "<tr><td><p>Original 30.2 MiB</p></td></tr>"
+ "</tbody></table></div></div>"
+ "<form id='hathdl_form' action='https://exhentai.org/archiveresolve'></form>"
+ "</body></html>";
}
private static String submittedPage() {
return "<html><body><div id='db'><p>a</p>"
+ "<p><strong>#12345 Download started</strong></p></div></body></html>";
}
/** 按 URL 分派三类页面,模拟一次完整解析流程。 */
private static void stubPages(org.mockito.MockedStatic<GalleryUtil> 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 = "<html><body><div id='db'><p>a</p><p><strong>Error occurred</strong></p></div></body></html>";
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 = "<html><body><div id='db'><div><table><tbody>"
+ "<tr><td><p>1280x N/A</p></td></tr>"
+ "<tr><td><p>Original 30.2 MiB</p></td></tr>"
+ "</tbody></table></div></div>"
+ "<form id='hathdl_form' action='https://exhentai.org/archiveresolve'></form></body></html>";
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 "<html><body><script>var unrelated = 1;</script><script>\n"
+ "var gid=1234567;\n"
+ "var mpvkey = \"abc123\";\n"
+ "var imagelist = [{\"k\":\"key1\"},{\"k\":\"key2\"},{\"k\":\"key3\"}];\n"
+ "</script></body></html>";
}
/**
* 回归修复验证(缺陷于 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<ImageKeyCache> 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<ImageKeyCache> 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<ImageKeyCache> 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("<html><body><script>var only = 1;</script></body></html>");
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, ';'};
}
}