修复 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:
@@ -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, ';'};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user