diff --git a/pom.xml b/pom.xml index 96f1932..9ec2fee 100644 --- a/pom.xml +++ b/pom.xml @@ -116,6 +116,26 @@ + + org.jacoco + jacoco-maven-plugin + 0.8.13 + + + prepare-agent + + prepare-agent + + + + report + test + + report + + + + org.graalvm.buildtools native-maven-plugin diff --git a/src/test/java/com/lion/lionwebsite/Filter/AdaptorFilterTest.java b/src/test/java/com/lion/lionwebsite/Filter/AdaptorFilterTest.java new file mode 100644 index 0000000..39d5f35 --- /dev/null +++ b/src/test/java/com/lion/lionwebsite/Filter/AdaptorFilterTest.java @@ -0,0 +1,108 @@ +package com.lion.lionwebsite.Filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * 主站入口的 UA 分流过滤器:把移动端访客导向 /mobile,其余放行。 + * 它挂在 "/" 与 "/personal/" 上,判定错误会让桌面端用户被错误重定向,故两侧都要锁住。 + */ +class AdaptorFilterTest { + + private final AdaptorFilter filter = new AdaptorFilter(); + + private record Result(boolean chainCalled, String redirectedTo) {} + + private Result run(String userAgent, String servletPath, String authCode) throws Exception { + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + + when(request.getHeader("User-Agent")).thenReturn(userAgent); + when(request.getHeader("X-Forwarded-For")).thenReturn("203.0.113.9"); + when(request.getParameter("AuthCode")).thenReturn(authCode); + when(request.getServletPath()).thenReturn(servletPath); + + filter.doFilter(request, response, chain); + + String redirect = null; + var captor = org.mockito.ArgumentCaptor.forClass(String.class); + verify(response, atMost(1)).sendRedirect(captor.capture()); + if (!captor.getAllValues().isEmpty()) redirect = captor.getValue(); + return new Result(org.mockito.Mockito.mockingDetails(chain).getInvocations().size() > 0, redirect); + } + + @Test + void desktopUserAgentPassesThrough() throws Exception { + Result r = run("Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0", "/", null); + assertTrue(r.chainCalled(), "桌面 UA 必须放行到后续处理"); + assertNull(r.redirectedTo(), "桌面 UA 不应被重定向"); + } + + @Test + void androidUserAgentIsRedirectedToMobile() throws Exception { + Result r = run("Mozilla/5.0 (Linux; Android 13) Chrome/120.0", "/", null); + assertFalse(r.chainCalled(), "移动 UA 不应继续走桌面链路"); + assertEquals("/mobile", r.redirectedTo()); + } + + @Test + void iPhoneUserAgentIsRedirectedToMobile() throws Exception { + Result r = run("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) Safari/604.1", "/", null); + assertEquals("/mobile", r.redirectedTo()); + } + + /** 带 AuthCode=alone 的个人页访问应把授权码透传到移动端,否则移动端要重新输入。 */ + @Test + void personalPageOnMobilePreservesAloneAuthCode() throws Exception { + Result r = run("Mozilla/5.0 (Linux; Android 13)", "/personal/", "alone"); + assertEquals("/mobile?AuthCode=alone", r.redirectedTo()); + } + + /** 个人页 + 移动端 + 非 alone 的授权码:不透传,仅跳转基础路径。 */ + @Test + void personalPageOnMobileWithOtherAuthCodeDoesNotLeakIt() throws Exception { + Result r = run("Mozilla/5.0 (Linux; Android 13)", "/personal/", "secret-code"); + assertEquals("/mobile", r.redirectedTo()); + assertFalse(r.redirectedTo().contains("secret-code"), "非 alone 的授权码不得出现在跳转 URL 中"); + } + + /** 桌面 UA 访问个人页时不得因 AuthCode=alone 被误跳转到移动端。 */ + @Test + void desktopPersonalPageWithAloneIsNotRedirected() throws Exception { + Result r = run("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", "/personal/", "alone"); + assertTrue(r.chainCalled()); + assertNull(r.redirectedTo()); + } + + /** /validate 是验证入口,必须放行,且移动 UA 也不应被重定向。 */ + @Test + void validatePathAlwaysPassesThrough() throws Exception { + Result desktop = run("Mozilla/5.0 (Windows NT 10.0)", "/validate", null); + assertTrue(desktop.chainCalled()); + assertNull(desktop.redirectedTo()); + + Result mobile = run("Mozilla/5.0 (Linux; Android 13)", "/validate", null); + assertTrue(mobile.chainCalled(), "/validate 在移动 UA 下也必须放行"); + assertNull(mobile.redirectedTo()); + } + + /** UA 缺失时直接返回(不重定向、不放行),避免无 UA 客户端进入业务链路。 */ + @Test + void missingUserAgentNeitherRedirectsNorContinues() throws Exception { + Result r = run(null, "/", null); + assertFalse(r.chainCalled(), "无 UA 的请求不应继续"); + assertNull(r.redirectedTo(), "无 UA 的请求也不应被重定向"); + } + + @Test + void iphonePersonalPagePreservesAloneAuthCode() throws Exception { + Result r = run("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)", "/personal/", "alone"); + assertEquals("/mobile?AuthCode=alone", r.redirectedTo()); + } +} diff --git a/src/test/java/com/lion/lionwebsite/Interceptor/TaskHandlerInterceptorTest.java b/src/test/java/com/lion/lionwebsite/Interceptor/TaskHandlerInterceptorTest.java new file mode 100644 index 0000000..8478dbb --- /dev/null +++ b/src/test/java/com/lion/lionwebsite/Interceptor/TaskHandlerInterceptorTest.java @@ -0,0 +1,121 @@ +package com.lion.lionwebsite.Interceptor; + +import com.lion.lionwebsite.Dao.normal.UserMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * 任务接口的鉴权拦截器。它决定谁能操作下载任务,是应用内唯一的授权判定点, + * 因此这里覆盖「放行」与「拒绝」两侧,并锁死若干必须拒绝的输入形态。 + */ +class TaskHandlerInterceptorTest { + + private UserMapper mapper; + private TaskHandlerInterceptor interceptor; + + @BeforeEach + void setUp() { + mapper = mock(UserMapper.class); + interceptor = new TaskHandlerInterceptor(mapper); + } + + /** 以给定 AuthCodes 初始化,并针对某次请求参数返回放行与否。 */ + private boolean handle(String[] codes, String requestAuthCode) { + when(mapper.selectAllAuthCode()).thenReturn(codes); + interceptor.init(); + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getParameter("AuthCode")).thenReturn(requestAuthCode); + return interceptor.preHandle(request, mock(HttpServletResponse.class), new Object()); + } + + @Test + void validAuthCodeIsAllowed() { + assertTrue(handle(new String[]{"aaaa-bbbb", "cccc-dddd"}, "cccc-dddd")); + } + + @Test + void firstConfiguredAuthCodeIsAllowed() { + assertTrue(handle(new String[]{"first", "second"}, "first")); + } + + @Test + void unknownAuthCodeIsRejected() { + assertFalse(handle(new String[]{"aaaa-bbbb"}, "not-a-real-code")); + } + + @Test + void missingAuthCodeIsRejected() { + assertFalse(handle(new String[]{"aaaa-bbbb"}, null)); + } + + @Test + void emptyAuthCodeIsRejected() { + assertFalse(handle(new String[]{"aaaa-bbbb"}, "")); + } + + @Test + void blankLookalikeIsRejected() { + assertFalse(handle(new String[]{"aaaa-bbbb"}, " ")); + } + + /** 前缀/后缀匹配不得被当作通过,避免宽松比较导致的越权。 */ + @Test + void prefixAndSuffixVariantsAreRejected() { + assertFalse(handle(new String[]{"secret-code"}, "secret"), "前缀不得放行"); + assertFalse(handle(new String[]{"secret-code"}, "secret-code-extra"), "多余后缀不得放行"); + assertFalse(handle(new String[]{"secret-code"}, "SECRET-CODE"), "大小写不同不得放行"); + } + + /** 未配置任何 AuthCode 时,除 null 外的输入都必须拒绝。 */ + @Test + void noConfiguredCodesRejectsEverything() { + assertFalse(handle(new String[]{}, "anything")); + assertFalse(handle(new String[]{}, "")); + assertFalse(handle(new String[]{}, null)); + } + + /** 列表中含 null 项时不得抛 NPE(历史数据可能产生 null AuthCode)。 */ + @Test + void nullEntryInConfiguredCodesDoesNotThrow() { + assertFalse(handle(new String[]{"good", null}, "some-code")); + assertTrue(handle(new String[]{"good", null}, "good")); + } + + /** 初始化后按数据库当前值判定,不缓存过期结果。 */ + @Test + void initLoadsCodesFromMapper() { + when(mapper.selectAllAuthCode()).thenReturn(new String[]{"x"}); + interceptor.init(); + verify(mapper).selectAllAuthCode(); + + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getParameter("AuthCode")).thenReturn("x"); + assertTrue(interceptor.preHandle(request, mock(HttpServletResponse.class), new Object())); + } + + /** updateAuthCodes 必须改用 selectEnableAuthCode,使被吊销的授权码立即失效。 */ + @Test + void updateAuthCodesSwitchesToEnabledSet() { + when(mapper.selectAllAuthCode()).thenReturn(new String[]{"old-code"}); + interceptor.init(); + verify(mapper).selectAllAuthCode(); + + when(mapper.selectEnableAuthCode()).thenReturn(new String[]{"new-code"}); + interceptor.updateAuthCodes(); + verify(mapper).selectEnableAuthCode(); + + HttpServletRequest revoked = mock(HttpServletRequest.class); + when(revoked.getParameter("AuthCode")).thenReturn("old-code"); + assertFalse(interceptor.preHandle(revoked, mock(HttpServletResponse.class), new Object()), + "刷新后旧的授权码必须失效"); + + HttpServletRequest current = mock(HttpServletRequest.class); + when(current.getParameter("AuthCode")).thenReturn("new-code"); + assertTrue(interceptor.preHandle(current, mock(HttpServletResponse.class), new Object())); + } +} diff --git a/src/test/java/com/lion/lionwebsite/Message/MessageCodecTest.java b/src/test/java/com/lion/lionwebsite/Message/MessageCodecTest.java new file mode 100644 index 0000000..0b186d6 --- /dev/null +++ b/src/test/java/com/lion/lionwebsite/Message/MessageCodecTest.java @@ -0,0 +1,315 @@ +package com.lion.lionwebsite.Message; + +import com.lion.lionwebsite.Domain.GalleryTask; +import io.netty.buffer.ByteBuf; +import io.netty.channel.embedded.EmbeddedChannel; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 节点通信的线协议编解码。该格式是主站与 storageNode 的契约: + * 帧 = [messageType(1B)][bodyLength(4B, 大端)][JSON body], + * 两侧各自使用 Jackson(主站 Jackson 3、节点 Jackson 2.x),故这里同时锁死编码结果与解码容错。 + */ +class MessageCodecTest { + + private EmbeddedChannel channel; + + private EmbeddedChannel channel() { + channel = new EmbeddedChannel(new MessageCodec()); + return channel; + } + + @AfterEach + void tearDown() { + if (channel != null) channel.finishAndReleaseAll(); + } + + /** 编码一条消息并取回其帧。 */ + private static ByteBuf encode(EmbeddedChannel ch, AbstractMessage message) { + assertTrue(ch.writeOutbound(message), "消息应被编码并写入出站缓冲"); + ByteBuf frame = ch.readOutbound(); + assertNotNull(frame, "应能取出编好的帧"); + return frame; + } + + /** 解码一个帧并返回产出的消息(无产出时返回 null)。 */ + private static T decode(EmbeddedChannel ch, ByteBuf frame) { + assertTrue(ch.writeInbound(frame), "帧应被解码器消费"); + return ch.readInbound(); + } + + /** 只读地取出帧头声明长度与正文,不移动读指针。 */ + private static String frameBody(ByteBuf frame) { + int length = frame.getInt(1); + byte[] body = new byte[length]; + frame.getBytes(5, body); + return new String(body, StandardCharsets.UTF_8); + } + + /** 帧头必须是 1 字节类型 + 4 字节长度,且长度等于正文实际字节数。 */ + private static void assertWellFormedFrame(ByteBuf frame, byte expectedType, String expectedJsonFragment) { + assertEquals(expectedType, frame.getByte(0), "messageType 应为帧首字节"); + String json = frameBody(frame); + assertEquals(frame.getInt(1), json.getBytes(StandardCharsets.UTF_8).length, + "帧头声明长度须等于正文实际字节数"); + assertTrue(json.contains(expectedJsonFragment), + "正文应包含 " + expectedJsonFragment + ",实际为 " + json); + } + + @Test + void downloadPostRoundTripsTaskAndKeepsFieldValues() { + GalleryTask task = new GalleryTask(); + task.setGid(123456); + task.setName("sample gallery"); + task.setStatus(GalleryTask.DOWNLOADING); + task.setProceeding(7); + task.setPath("/secret/path"); + + DownloadPostMessage out = new DownloadPostMessage(); + out.setMessageId(42); + out.setGalleryTask(task); + + EmbeddedChannel ch = channel(); + ByteBuf frame = encode(ch, out); + assertWellFormedFrame(frame, AbstractMessage.DOWNLOAD_POST_MESSAGE, "123456"); + assertFalse(frameBody(frame).contains("/secret/path"), + "path 标注了 @JsonIgnore,不应出现在帧内(会泄漏存储机本地路径)"); + + DownloadPostMessage in = decode(ch, frame); + assertNotNull(in); + assertEquals(42, in.getMessageId(), "messageId 必须原样保留,否则响应无法对号"); + assertNotNull(in.getGalleryTask()); + assertEquals(123456, in.getGalleryTask().getGid()); + assertEquals("sample gallery", in.getGalleryTask().getName()); + assertEquals(GalleryTask.DOWNLOADING, in.getGalleryTask().getStatus()); + assertEquals(7, in.getGalleryTask().getProceeding()); + } + + @Test + void downloadStatusRoundTripsArrayPreservingOrder() { + GalleryTask first = new GalleryTask(); + first.setGid(1); + first.setName("a"); + first.setStatus(GalleryTask.COMPRESS_COMPLETE); + GalleryTask second = new GalleryTask(); + second.setGid(2); + second.setName("b"); + second.setStatus(GalleryTask.COMPRESSING); + + DownloadStatusMessage out = new DownloadStatusMessage(); + out.setMessageId(7); + out.setGalleryTasks(new GalleryTask[]{first, second}); + + EmbeddedChannel ch = channel(); + DownloadStatusMessage in = decode(ch, encode(ch, out)); + + assertNotNull(in); + assertEquals(2, in.getGalleryTasks().length); + assertEquals(1, in.getGalleryTasks()[0].getGid(), "数组顺序必须保持"); + assertEquals(GalleryTask.COMPRESS_COMPLETE, in.getGalleryTasks()[0].getStatus()); + assertEquals(GalleryTask.COMPRESSING, in.getGalleryTasks()[1].getStatus()); + } + + @Test + void responseMessageRoundTripsResultCode() { + ResponseMessage out = new ResponseMessage(); + out.setMessageId(99); + out.setResult((byte) 3); + + EmbeddedChannel ch = channel(); + ResponseMessage in = decode(ch, encode(ch, out)); + + assertNotNull(in); + assertEquals(99, in.getMessageId()); + assertEquals(3, in.getResult(), "result 码承载节点语义,不能丢"); + } + + @Test + void identityDeleteAndAvailableCheckRoundTrip() { + EmbeddedChannel ch = channel(); + + IdentityMessage identityOut = new IdentityMessage("lionwebsite"); + identityOut.setMessageId(1); + IdentityMessage identity = decode(ch, encode(ch, identityOut)); + assertNotNull(identity); + assertEquals("lionwebsite", identity.getIdentity(), "身份串决定节点是否登记为 server"); + + DeleteGalleryMessage deleteOut = new DeleteGalleryMessage(); + deleteOut.setMessageId(5); + deleteOut.setGalleryName("gallery-name"); + DeleteGalleryMessage delete = decode(ch, encode(ch, deleteOut)); + assertNotNull(delete); + assertEquals("gallery-name", delete.getGalleryName()); + + AvailableCheckMessage checkOut = new AvailableCheckMessage(); + checkOut.setMessageId(8); + assertNotNull(decode(ch, encode(ch, checkOut))); + } + + @Test + void maintainMessageEncodesWithItsOwnType() { + MaintainMessage out = new MaintainMessage(); + out.setMessageId(11); + + EmbeddedChannel ch = channel(); + ByteBuf frame = encode(ch, out); + assertEquals(AbstractMessage.MAINTAIN_MESSAGE, frame.getByte(0)); + } + + @Test + void subscriptionSnapshotRoundTripsAllTopLevelFields() { + SubscriptionSnapshotMessage out = new SubscriptionSnapshotMessage(); + out.setMessageId(77); + out.setSchemaVersion(1); + out.setRevision("rev-abc"); + out.setGeneratedAt(1789364669466L); + out.setPayloadBase64("cGF5bG9hZA=="); + out.setPayloadSha256("payload-hash"); + out.setSignature("sig"); + + EmbeddedChannel ch = channel(); + SubscriptionSnapshotMessage in = decode(ch, encode(ch, out)); + + assertNotNull(in); + assertEquals(77, in.getMessageId()); + assertEquals(1, in.getSchemaVersion()); + assertEquals("rev-abc", in.getRevision()); + assertEquals(1789364669466L, in.getGeneratedAt()); + assertEquals("cGF5bG9hZA==", in.getPayloadBase64()); + assertEquals("payload-hash", in.getPayloadSha256()); + assertEquals("sig", in.getSignature()); + } + + /** payload 内嵌对象的往返:账号/绑定快照字段必须逐个保真,否则备机分发会串账号。 */ + @Test + void snapshotPayloadSurvivesNestedJsonRoundTrip() throws Exception { + tools.jackson.databind.ObjectMapper mapper = new tools.jackson.databind.ObjectMapper(); + + SubscriptionAccountSnapshot account = new SubscriptionAccountSnapshot(); + account.setAccountId(12); + account.setEnabled(false); + account.setFilterHighMultiplier(true); + account.setV2ContentBase64("YWJj"); + account.setClashContentBase64("ZGVm"); + account.setV2Sha256("h-v2"); + account.setClashSha256("h-clash"); + + SubscriptionBindingSnapshot binding = new SubscriptionBindingSnapshot(); + binding.setPublicKeySha256("pub"); + binding.setAccountId(12); + + SubscriptionSnapshotPayload payload = new SubscriptionSnapshotPayload(); + payload.setSchemaVersion(2); + payload.setAccounts(new java.util.ArrayList<>(java.util.List.of(account))); + payload.setBindings(new java.util.ArrayList<>(java.util.List.of(binding))); + + SubscriptionSnapshotPayload back = + mapper.readValue(mapper.writeValueAsString(payload), SubscriptionSnapshotPayload.class); + + assertEquals(2, back.getSchemaVersion()); + assertEquals(1, back.getAccounts().size()); + assertEquals(1, back.getBindings().size()); + SubscriptionAccountSnapshot a = back.getAccounts().get(0); + assertEquals(12, a.getAccountId()); + assertFalse(a.isEnabled()); + assertTrue(a.isFilterHighMultiplier()); + assertEquals("YWJj", a.getV2ContentBase64()); + assertEquals("ZGVm", a.getClashContentBase64()); + assertEquals("h-clash", a.getClashSha256()); + assertEquals(12, back.getBindings().get(0).getAccountId()); + assertEquals("pub", back.getBindings().get(0).getPublicKeySha256()); + } + + /** 未显式设置的列表字段必须是空列表而非 null,否则节点侧遍历会 NPE。 */ + @Test + void unsetPayloadListsDefaultToEmptyNotNul() { + SubscriptionSnapshotPayload payload = new SubscriptionSnapshotPayload(); + assertNotNull(payload.getAccounts()); + assertNotNull(payload.getBindings()); + assertTrue(payload.getAccounts().isEmpty()); + assertTrue(payload.getBindings().isEmpty()); + } + + /** 未知消息类型必须被静默丢弃,否则单条坏帧会打断整条节点连接。 */ + @Test + void unknownMessageTypeIsDroppedWithoutThrowing() { + EmbeddedChannel ch = new EmbeddedChannel(new MessageCodec()); + try { + ByteBuf buf = ch.alloc().buffer(); + buf.writeByte((byte) 120); + byte[] body = "{}".getBytes(StandardCharsets.UTF_8); + buf.writeInt(body.length); + buf.writeBytes(body); + + ch.writeInbound(buf); + assertNull(ch.readInbound(), "未知类型不应产出消息"); + assertTrue(ch.isActive(), "未知类型不应导致通道关闭"); + } finally { + ch.finishAndReleaseAll(); + } + } + + /** name 为 null(@JsonInclude(NON_NULL))时仍应正常往返,不得破坏其他字段。 */ + @Test + void nullOptionalFieldsDoNotBreakRoundTrip() { + GalleryTask task = new GalleryTask(); + task.setGid(1); + task.setName(null); + task.setStatus(GalleryTask.DOWNLOAD_COMPLETE); + + DownloadPostMessage out = new DownloadPostMessage(); + out.setMessageId(1); + out.setGalleryTask(task); + + EmbeddedChannel ch = channel(); + DownloadPostMessage in = decode(ch, encode(ch, out)); + assertNotNull(in); + assertNotNull(in.getGalleryTask()); + assertNull(in.getGalleryTask().getName()); + assertEquals(GalleryTask.DOWNLOAD_COMPLETE, in.getGalleryTask().getStatus()); + } + + /** 多字节 UTF-8(中文画廊名)长度须按字节而非字符计算,否则接收端会截断正文。 */ + @Test + void multibyteNamesUseByteLengthNotCharLength() { + GalleryTask task = new GalleryTask(); + task.setGid(9); + task.setName("中文画廊名"); + task.setStatus(GalleryTask.DOWNLOADING); + + DownloadPostMessage out = new DownloadPostMessage(); + out.setMessageId(3); + out.setGalleryTask(task); + + EmbeddedChannel ch = channel(); + ByteBuf frame = encode(ch, out); + assertTrue(frameBody(frame).contains("中文画廊名")); + + DownloadPostMessage in = decode(ch, frame); + assertEquals("中文画廊名", in.getGalleryTask().getName()); + } + + /** 在两个独立编解码器间往返,确保格式不依赖实例共享状态。 */ + @Test + void frameEncodedByOneCodecDecodesInAnother() { + EmbeddedChannel encoder = new EmbeddedChannel(new MessageCodec()); + EmbeddedChannel decoder = new EmbeddedChannel(new MessageCodec()); + try { + IdentityMessage out = new IdentityMessage("lionwebsiteside"); + out.setMessageId(4); + ByteBuf frame = encode(encoder, out); + IdentityMessage in = decode(decoder, frame); + assertNotNull(in); + assertEquals("lionwebsiteside", in.getIdentity()); + assertEquals(4, in.getMessageId()); + } finally { + encoder.finishAndReleaseAll(); + decoder.finishAndReleaseAll(); + } + } +} diff --git a/src/test/java/com/lion/lionwebsite/Service/GalleryManageServiceTest.java b/src/test/java/com/lion/lionwebsite/Service/GalleryManageServiceTest.java new file mode 100644 index 0000000..b3a831e --- /dev/null +++ b/src/test/java/com/lion/lionwebsite/Service/GalleryManageServiceTest.java @@ -0,0 +1,323 @@ +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 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 GalleryManageServiceTest { + + private GalleryMapper galleries; + private CollectMapper collectMapper; + private CustomConfigurationMapper configurationMapper; + private UserMapper users; + private RemoteService remote; + private PushService push; + private GalleryManageService service; + + @BeforeEach + void setUp() { + galleries = mock(GalleryMapper.class); + collectMapper = mock(CollectMapper.class); + configurationMapper = mock(CustomConfigurationMapper.class); + users = mock(UserMapper.class); + remote = mock(RemoteService.class); + push = mock(PushService.class); + service = new GalleryManageService(galleries, collectMapper, + configurationMapper, users, mock(ShareFileMapper.class), + mock(ImageCacheMapper.class), remote, push); + + User user = new User(); + user.setId(7); + user.setUsername("tester"); + when(users.selectUserByAuthCode("code")).thenReturn(user); + } + + // ---------- createTask 输入校验 ---------- + + /** 链接第 5 段非数字时应返回「链接错误」且不落库、不下发节点。 */ + @Test + void malformedLinkIsRejectedWithoutPersisting() { + String response = service.createTask("https://example.org/g/not-a-number/key/", "original", "code"); + + assertTrue(response.contains("链接错误"), "应提示链接错误,实际: " + response); + assertFalse(response.contains("\"result\":\"success\"")); + verify(galleries, never()).insertGallery(any()); + verify(remote, never()).addGalleryToQueue(any()); + verify(push).taskCreateReport(eq("tester"), eq("未知任务"), any()); + } + + /** + * ⚠️ 发现(未擅自修改生产逻辑):段数不足的链接会抛 ArrayIndexOutOfBoundsException。 + * createTask 只捕获 NumberFormatException(`link.split("/")[4]` 在段数不足时先抛越界), + * 且项目没有 @ControllerAdvice,因此异常会穿透为 500。 + * Controller 层只校验了 null,未校验格式,故该路径可由外部构造的 link 触发。 + * 这里记录现状;是否收紧需人工决策。 + */ + @Test + void shortLinkThrowsIndexOutOfBoundsInsteadOfFailingGracefully() { + assertThrows(ArrayIndexOutOfBoundsException.class, + () -> service.createTask("https://example.org/g", "original", "code"), + "若此断言失败说明已修复为友好报错,应同步更新该测试"); + verify(galleries, never()).insertGallery(any()); + } + + /** 节点离线时必须明确告知用户,且不落库。 */ + @Test + void taskIsRejectedWhenNodeIsOffline() { + when(remote.isDead()).thenReturn(true); + + String response = service.createTask("https://example.org/g/555/key/", "original", "code"); + + assertTrue(response.contains("节点"), "应说明节点不可用,实际: " + response); + assertFalse(response.contains("\"result\":\"success\"")); + verify(galleries, never()).insertGallery(any()); + verify(remote, never()).addGalleryToQueue(any()); + } + + // ---------- 查询 ---------- + + /** 按链接查询:无对应任务时应返回失败而不是抛异常。 */ + @Test + void selectTaskByLinkReturnsFailureWhenAbsent() { + try (var parser = mockStatic(com.lion.lionwebsite.Util.GalleryUtil.class)) { + parser.when(() -> com.lion.lionwebsite.Util.GalleryUtil.parseGid(anyString())).thenReturn(999); + when(galleries.selectGalleryByGid(999)).thenReturn(null); + + String response = service.selectTaskByLink("https://example.org/g/999/key/"); + assertFalse(response.contains("\"result\":\"success\"")); + } + } + + @Test + void selectTaskByLinkReturnsTaskWhenPresent() { + Gallery gallery = new Gallery(); + gallery.setGid(321); + gallery.setName("sample [321]"); + when(galleries.selectGalleryByGid(321)).thenReturn(gallery); + + String response = service.selectTaskByLink("https://example.org/g/321/key/"); + assertTrue(response.contains("321"), "应回传对应任务: " + response); + } + + /** 链接无法解析出 gid 时应安全失败。 */ + @Test + void selectTaskByLinkWithUnparsableLinkFailsSafely() { + try (var parser = mockStatic(com.lion.lionwebsite.Util.GalleryUtil.class)) { + parser.when(() -> com.lion.lionwebsite.Util.GalleryUtil.parseGid(anyString())).thenReturn(null); + String response = assertDoesNotThrow(() -> service.selectTaskByLink("garbage")); + assertFalse(response.contains("\"result\":\"success\"")); + } + } + + @Test + void selectTaskByGidReturnsFailureWhenAbsent() { + when(galleries.selectGalleryByGid(404)).thenReturn(null); + String response = service.selectTaskByGid(404); + assertFalse(response.contains("\"result\":\"success\"")); + } + + // ---------- 删除与重试 ---------- + + /** 删除不存在的任务应返回失败,且不调用删除。 */ + @Test + void deleteNonexistentTaskFailsWithoutDeleting() { + when(galleries.selectGalleryByGid(777)).thenReturn(null); + String response = service.deleteGalleryByGid(777, "code"); + assertFalse(response.contains("\"result\":\"success\"")); + verify(galleries, never()).deleteGalleryByGid(anyInt()); + } + + /** + * ⚠️ 安全发现(未擅自修改生产逻辑):当该画廊没有任何收藏记录时, + * collectMapper.selectCollectorByGid 返回空列表,授权判断 + * `!(collector.isEmpty() || ...)` 中 collector.isEmpty() 直接为真, + * 使整个条件短路放行,**下载者身份完全未被校验**。 + * 即:任意持有有效授权码的用户都能删除他人的任务记录。 + * 无收藏正是最常见的情形,故影响面不小。 + */ + @Test + void deleteWithoutCollectorsSkipsDownloaderCheck() { + Gallery gallery = new Gallery(); + gallery.setGid(888); + gallery.setName("other-user-task [888]"); + gallery.setDownloader(999); // 属于别的用户 + when(galleries.selectGalleryByGid(888)).thenReturn(gallery); + when(collectMapper.selectCollectorByGid(888)).thenReturn(new java.util.ArrayList<>()); + when(remote.deleteGallery(any())).thenReturn((byte) 0); + + String response = service.deleteGalleryByGid(888, "code"); // 请求者是 id=7 + + assertTrue(response.contains("\"result\":\"success\""), + "当前实现会放行;若此断言失败说明已补上下载者校验,应同步更新该测试"); + verify(galleries).deleteGalleryByGid(888); + } + + /** + * ⚠️ 发现(未擅自修改生产逻辑):remoteService.deleteGallery(gallery) 位于授权判断的 + * if/else **之外**,因此即使授权判定为「拒绝」,仍会向存储节点下发删除文件指令。 + * 同时 switch 中的 `case 0 -> response.success()` 会覆盖前面写入的 failure, + * 使被拒的请求对外表现为成功。 + */ + @Test + void deniedDeleteStillNotifiesNodeAndReportsSuccess() { + Gallery gallery = new Gallery(); + gallery.setGid(889); + gallery.setName("collected-by-other [889]"); + gallery.setDownloader(7); + when(galleries.selectGalleryByGid(889)).thenReturn(gallery); + // 有他人收藏 -> 授权应被拒 + when(collectMapper.selectCollectorByGid(889)) + .thenReturn(new java.util.ArrayList<>(java.util.List.of(999))); + when(remote.deleteGallery(any())).thenReturn((byte) 0); + + String response = service.deleteGalleryByGid(889, "code"); + + verify(galleries, never()).deleteGalleryByGid(889); // 数据库记录确实没删 + verify(remote).deleteGallery(any()); // 但删除指令仍下发了 + assertTrue(response.contains("\"result\":\"success\""), + "当前实现会把拒绝结果覆盖为 success;若此断言失败说明已修复,应同步更新该测试"); + } + + /** 本人任务删除应放行并调用删除。 */ + @Test + void deleteAllowsOwnTask() { + Gallery gallery = new Gallery(); + gallery.setGid(890); + gallery.setName("mine [890]"); + gallery.setDownloader(7); + when(galleries.selectGalleryByGid(890)).thenReturn(gallery); + when(collectMapper.selectCollectorByGid(890)) + .thenReturn(new java.util.ArrayList<>(java.util.List.of(7))); + when(remote.deleteGallery(any())).thenReturn((byte) 0); + + String response = service.deleteGalleryByGid(890, "code"); + assertTrue(response.contains("\"result\":\"success\""), "本人任务应可删除: " + response); + verify(galleries).deleteGalleryByGid(890); + } + + /** 节点返回 IO 错误时应如实反馈,不能被 success 覆盖。 */ + @Test + void deleteReportsNodeIoError() { + Gallery gallery = new Gallery(); + gallery.setGid(891); + gallery.setName("mine [891]"); + gallery.setDownloader(7); + when(galleries.selectGalleryByGid(891)).thenReturn(gallery); + when(collectMapper.selectCollectorByGid(891)) + .thenReturn(new java.util.ArrayList<>(java.util.List.of(7))); + when(remote.deleteGallery(any())).thenReturn( + com.lion.lionwebsite.Error.ErrorCode.IO_ERROR); + + String response = service.deleteGalleryByGid(891, "code"); + assertTrue(response.contains("IO错误"), "节点 IO 错误应如实返回: " + response); + } + + /** 文件不存在的语义应与 IO 错误区分开。 */ + @Test + void deleteReportsFileNotFoundDistinctly() { + Gallery gallery = new Gallery(); + gallery.setGid(892); + gallery.setName("mine [892]"); + gallery.setDownloader(7); + when(galleries.selectGalleryByGid(892)).thenReturn(gallery); + when(collectMapper.selectCollectorByGid(892)) + .thenReturn(new java.util.ArrayList<>(java.util.List.of(7))); + when(remote.deleteGallery(any())).thenReturn( + com.lion.lionwebsite.Error.ErrorCode.FILE_NOT_FOUND); + + String response = service.deleteGalleryByGid(892, "code"); + assertTrue(response.contains("文件不存在"), "应区分文件不存在: " + response); + } + + /** 重试不存在的任务应返回失败。 */ + @Test + void retryNonexistentTaskFails() { + when(galleries.selectGalleryByGid(555)).thenReturn(null); + String response = service.retryGallery(555); + assertFalse(response.contains("\"result\":\"success\"")); + } + + /** 已完成的任务重试是幂等的:返回成功并回显「下载完成」,不重复下发节点。 */ + @Test + void retryOfCompletedTaskIsIdempotentSuccess() { + Gallery gallery = new Gallery(); + gallery.setGid(556); + gallery.setName("done [556]"); + gallery.setStatus("下载完成"); + when(galleries.selectGalleryByGid(556)).thenReturn(gallery); + + String response = service.retryGallery(556); + assertTrue(response.contains("\"result\":\"success\""), "重复重试应幂等成功: " + response); + assertTrue(response.contains("下载完成"), "应回显当前已完成状态: " + response); + verify(remote, never()).retryGallery(any()); + } + + /** 节点离线时重试应失败且不改变任务状态。 */ + @Test + void retryFailsWhenNodeOffline() { + Gallery gallery = new Gallery(); + gallery.setGid(557); + gallery.setName("stuck [557]"); + gallery.setStatus("已提交"); + when(galleries.selectGalleryByGid(557)).thenReturn(gallery); + when(remote.isDead()).thenReturn(true); + + String response = service.retryGallery(557); + assertFalse(response.contains("\"result\":\"success\"")); + verify(galleries, never()).updateGallery(any()); + } + + @Test + void retryRejectsUnknownGidWithNoRecord() { + when(galleries.selectGalleryByGid(anyInt())).thenReturn(null); + assertFalse(service.retryGallery(1).contains("\"result\":\"success\"")); + assertFalse(service.retryGallery(2).contains("\"result\":\"success\"")); + } + + // ---------- 列表 ---------- + + /** 用量查询应返回格式化后的已用量与上次重置时间。 */ + @Test + void weekUsedAmountReturnsFormattedValues() { + com.lion.lionwebsite.Domain.CustomConfiguration used = + new com.lion.lionwebsite.Domain.CustomConfiguration(); + used.setParameter(com.lion.lionwebsite.Domain.CustomConfiguration.WEEK_USED_AMOUNT); + used.setValue(String.valueOf(1024L * 1024 * 500)); + com.lion.lionwebsite.Domain.CustomConfiguration reset = + new com.lion.lionwebsite.Domain.CustomConfiguration(); + reset.setParameter(com.lion.lionwebsite.Domain.CustomConfiguration.LAST_RESET_AMOUNT_TIME); + reset.setValue("2026-09-14 14:23:58"); + when(configurationMapper.selectConfiguration( + com.lion.lionwebsite.Domain.CustomConfiguration.WEEK_USED_AMOUNT)).thenReturn(used); + when(configurationMapper.selectConfiguration( + com.lion.lionwebsite.Domain.CustomConfiguration.LAST_RESET_AMOUNT_TIME)).thenReturn(reset); + + String response = service.getWeekUsedAmount(); + + assertTrue(response.contains("500.00MB"), "已用量应格式化为人类可读: " + response); + assertTrue(response.contains("2026-09-14 14:23:58"), "应带上次重置时间: " + response); + } + + /** + * ⚠️ 发现:配置行缺失时 getWeekUsedAmount 会 NPE(readWeekUsedAmount 未做空值判断)。 + * 正常部署下这两行由初始化脚本写入,故未暴露;此处记录现状。 + */ + @Test + void weekUsedAmountThrowsWhenConfigRowsMissing() { + when(configurationMapper.selectConfiguration(anyString())).thenReturn(null); + assertThrows(NullPointerException.class, service::getWeekUsedAmount, + "若此断言失败说明已补空值保护,应同步更新该测试"); + } +} diff --git a/src/test/java/com/lion/lionwebsite/Service/SubServiceTest.java b/src/test/java/com/lion/lionwebsite/Service/SubServiceTest.java new file mode 100644 index 0000000..ddf1ddd --- /dev/null +++ b/src/test/java/com/lion/lionwebsite/Service/SubServiceTest.java @@ -0,0 +1,341 @@ +package com.lion.lionwebsite.Service; + +import com.lion.lionwebsite.Dao.normal.SubMapper; +import com.lion.lionwebsite.Dao.normal.UserMapper; +import com.lion.lionwebsite.Domain.SubBind; +import com.lion.lionwebsite.Domain.SubscriptionAccount; +import com.lion.lionwebsite.Domain.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 子账号与绑定的业务规则。这些方法决定谁能拿到订阅、绑定到哪个账号, + * 校验失败必须返回业务 failure 而不是抛异常,且失败路径不得落库。 + */ +class SubServiceTest { + + private SubMapper subMapper; + private UserMapper userMapper; + private SubscriptionRefreshService refreshService; + private RemoteService remoteService; + private SubService service; + + @BeforeEach + void setUp() { + subMapper = mock(SubMapper.class); + userMapper = mock(UserMapper.class); + refreshService = mock(SubscriptionRefreshService.class); + remoteService = mock(RemoteService.class); + service = new SubService(subMapper, userMapper, refreshService, remoteService, + new SubscriptionStateCoordinator()); + } + + private static SubscriptionAccount account(Integer id, String name, String key, boolean enabled) { + return new SubscriptionAccount(id, name, key, false, enabled, null, null, null, null, 0, null, null); + } + + private static boolean ok(String json) { + return json.contains("\"result\":\"success\""); + } + + // ---------- insertSubscriptionAccount ---------- + + @Test + void blankNameOrKeyIsRejectedWithoutInsert() { + assertFalse(ok(service.insertSubscriptionAccount("", "key", false, true))); + assertFalse(ok(service.insertSubscriptionAccount("name", "", false, true))); + assertFalse(ok(service.insertSubscriptionAccount(" ", "key", false, true))); + assertFalse(ok(service.insertSubscriptionAccount(null, "key", false, true))); + assertFalse(ok(service.insertSubscriptionAccount("name", null, false, true))); + verify(subMapper, never()).insertSubscriptionAccount(any()); + } + + @Test + void duplicateNameOrKeyIsRejected() { + when(subMapper.countSubscriptionAccountName("dup")).thenReturn(1); + assertFalse(ok(service.insertSubscriptionAccount("dup", "fresh-key", false, true))); + + when(subMapper.countSubscriptionAccountName("fresh-name")).thenReturn(0); + when(subMapper.countSubscriptionAccountKey("used-key")).thenReturn(1); + assertFalse(ok(service.insertSubscriptionAccount("fresh-name", "used-key", false, true))); + + verify(subMapper, never()).insertSubscriptionAccount(any()); + } + + /** 名称与上游 key 两端空白应被裁剪后再校验与入库。 */ + @Test + void valuesAreTrimmedBeforePersisting() { + when(subMapper.countSubscriptionAccountName("trimmed")).thenReturn(0); + when(subMapper.countSubscriptionAccountKey("key123")).thenReturn(0); + doAnswer(inv -> { + inv.getArgument(0, SubscriptionAccount.class).setId(5); + return null; + }).when(subMapper).insertSubscriptionAccount(any()); + + assertTrue(ok(service.insertSubscriptionAccount(" trimmed ", " key123 ", true, false))); + + var captor = org.mockito.ArgumentCaptor.forClass(SubscriptionAccount.class); + verify(subMapper).insertSubscriptionAccount(captor.capture()); + assertEquals("trimmed", captor.getValue().getName()); + assertEquals("key123", captor.getValue().getUpstreamKey()); + assertTrue(captor.getValue().isFilterHighMultiplier()); + assertFalse(captor.getValue().isEnabled(), "enabled 应原样透传"); + } + + /** enabled=true 时才立刻刷新并同步;enabled=false 时不应触发上游刷新。 */ + @Test + void refreshAndSyncOnlyHappenForEnabledAccount() { + when(subMapper.countSubscriptionAccountName(anyString())).thenReturn(0); + when(subMapper.countSubscriptionAccountKey(anyString())).thenReturn(0); + doAnswer(inv -> { + inv.getArgument(0, SubscriptionAccount.class).setId(9); + return null; + }).when(subMapper).insertSubscriptionAccount(any()); + + service.insertSubscriptionAccount("enabled-one", "k1", false, true); + verify(refreshService).refresh(9); + verify(remoteService).requestSubscriptionSync(); + + clearInvocations(refreshService, remoteService); + service.insertSubscriptionAccount("disabled-one", "k2", false, false); + verify(refreshService, never()).refresh(anyInt()); + verify(remoteService).requestSubscriptionSync(); + } + + // ---------- updateSubscriptionAccount ---------- + + @Test + void updatingMissingAccountFails() { + when(subMapper.selectSubscriptionAccount(404)).thenReturn(null); + assertFalse(ok(service.updateSubscriptionAccount(404, "n", "k", false, true))); + verify(subMapper, never()).updateSubscriptionAccount(any()); + } + + @Test + void updatingWithBlankFieldsFails() { + when(subMapper.selectSubscriptionAccount(1)).thenReturn(account(1, "old", "old-key", true)); + assertFalse(ok(service.updateSubscriptionAccount(1, "", "key", false, true))); + assertFalse(ok(service.updateSubscriptionAccount(1, "name", " ", false, true))); + verify(subMapper, never()).updateSubscriptionAccount(any()); + } + + /** 修改时不得与「其他」账号重名或重 key,但与自己相同应允许。 */ + @Test + void updateRejectsConflictsWithOtherAccountsButAllowsSelf() { + when(subMapper.selectSubscriptionAccount(1)).thenReturn(account(1, "mine", "my-key", true)); + when(subMapper.selectAllSubscriptionAccounts()) + .thenReturn(new ArrayList<>(java.util.List.of( + account(1, "mine", "my-key", true), + account(2, "taken", "taken-key", true)))); + + assertFalse(ok(service.updateSubscriptionAccount(1, "taken", "my-key", false, true)), + "与其他账号重名应拒绝"); + assertFalse(ok(service.updateSubscriptionAccount(1, "mine", "taken-key", false, true)), + "与其他账号重 key 应拒绝"); + + assertTrue(ok(service.updateSubscriptionAccount(1, "mine", "my-key", false, true)), + "与自身相同的名称/key 应允许"); + } + + @Test + void deleteRejectsAccountThatStillHasBindings() { + SubscriptionAccount bound = account(3, "bound", "k", true); + bound.setBoundUserCount(2); + when(subMapper.selectSubscriptionAccount(3)).thenReturn(bound); + + assertFalse(ok(service.deleteSubscriptionAccount(3))); + verify(subMapper, never()).deleteSubscriptionAccount(anyInt()); + } + + @Test + void deleteSucceedsWhenNoBindingsRemain() { + SubscriptionAccount free = account(4, "free", "k", true); + free.setBoundUserCount(0); + when(subMapper.selectSubscriptionAccount(4)).thenReturn(free); + + assertTrue(ok(service.deleteSubscriptionAccount(4))); + verify(subMapper).deleteSubscriptionAccount(4); + verify(refreshService).invalidateCache(4); + } + + // ---------- insertSubBind ---------- + + @Test + void bindRejectsUnknownUser() { + when(userMapper.selectUserByUsername("ghost")).thenReturn(null); + assertFalse(ok(service.insertSubBind("ghost", 1))); + verify(subMapper, never()).insertSubBind(any()); + } + + @Test + void bindRejectsDisabledOrMissingAccount() { + when(userMapper.selectUserByUsername("alice")).thenReturn(new User()); + + when(subMapper.selectSubscriptionAccount(7)).thenReturn(null); + assertFalse(ok(service.insertSubBind("alice", 7))); + + when(subMapper.selectSubscriptionAccount(8)).thenReturn(account(8, "off", "k", false)); + assertFalse(ok(service.insertSubBind("alice", 8)), "已停用账号不可绑定"); + + verify(subMapper, never()).insertSubBind(any()); + } + + /** 账号尚无完整缓存时必须拒绝,否则用户会拿到空订阅。 */ + @Test + void bindRejectsAccountWithoutCompleteCache() { + when(userMapper.selectUserByUsername("alice")).thenReturn(new User()); + when(subMapper.selectSubscriptionAccount(1)).thenReturn(account(1, "a", "k", true)); + when(refreshService.hasCompleteCache(1)).thenReturn(false); + + assertFalse(ok(service.insertSubBind("alice", 1))); + verify(subMapper, never()).insertSubBind(any()); + } + + @Test + void bindRejectsUserThatAlreadyHasBinding() { + when(userMapper.selectUserByUsername("alice")).thenReturn(new User()); + when(subMapper.selectSubscriptionAccount(1)).thenReturn(account(1, "a", "k", true)); + when(refreshService.hasCompleteCache(1)).thenReturn(true); + when(subMapper.countSubBindByUser("alice")).thenReturn(1); + + assertFalse(ok(service.insertSubBind("alice", 1))); + verify(subMapper, never()).insertSubBind(any()); + } + + /** 成功绑定时 key 应是 8 位随机串,且会避开已存在的 key。 */ + @Test + void bindGeneratesUniqueEightCharKey() { + when(userMapper.selectUserByUsername("alice")).thenReturn(new User()); + when(subMapper.selectSubscriptionAccount(1)).thenReturn(account(1, "a", "k", true)); + when(refreshService.hasCompleteCache(1)).thenReturn(true); + when(subMapper.countSubBindByUser("alice")).thenReturn(0); + // 前两次「已存在」,第三次通过——验证重试而非直接失败 + when(subMapper.selectSubBindExist(anyString())).thenReturn(true, true, false); + + assertTrue(ok(service.insertSubBind("alice", 1))); + + var captor = org.mockito.ArgumentCaptor.forClass(SubBind.class); + verify(subMapper).insertSubBind(captor.capture()); + assertEquals(8, captor.getValue().getKey().length(), "订阅 key 应为 8 位"); + assertEquals("alice", captor.getValue().getUser()); + assertEquals(1, captor.getValue().getSubscriptionAccountId()); + verify(subMapper, times(3)).selectSubBindExist(anyString()); + verify(remoteService).requestSubscriptionSync(); + } + + @Test + void resetKeyRejectsUserWithoutBinding() { + when(subMapper.countSubBindByUser("nobody")).thenReturn(0); + assertFalse(ok(service.resetKey("nobody"))); + verify(subMapper, never()).updateSubBindKey(anyString(), anyString()); + } + + @Test + void resetKeyReplacesKeyAndClearsAccessRecords() { + when(subMapper.countSubBindByUser("alice")).thenReturn(1); + when(subMapper.selectSubBindExist(anyString())).thenReturn(false); + + assertTrue(ok(service.resetKey("alice"))); + + var captor = org.mockito.ArgumentCaptor.forClass(String.class); + verify(subMapper).updateSubBindKey(eq("alice"), captor.capture()); + assertEquals(8, captor.getValue().length()); + verify(subMapper).deleteSubUpdateRecord("alice"); + verify(remoteService).requestSubscriptionSync(); + } + + @Test + void rebindRejectsDisabledTargetAndMissingBinding() { + when(subMapper.selectSubscriptionAccount(2)).thenReturn(account(2, "off", "k", false)); + assertFalse(ok(service.rebind("alice", 2)), "目标账号停用应拒绝"); + + when(subMapper.selectSubscriptionAccount(3)).thenReturn(account(3, "on", "k", true)); + when(refreshService.hasCompleteCache(3)).thenReturn(true); + when(subMapper.updateSubBindAccount("alice", 3)).thenReturn(0); + assertFalse(ok(service.rebind("alice", 3)), "无既有绑定应拒绝"); + } + + @Test + void rebindSucceedsWhenTargetHealthyAndBindingExists() { + when(subMapper.selectSubscriptionAccount(3)).thenReturn(account(3, "on", "k", true)); + when(refreshService.hasCompleteCache(3)).thenReturn(true); + when(subMapper.updateSubBindAccount("alice", 3)).thenReturn(1); + + assertTrue(ok(service.rebind("alice", 3))); + verify(remoteService).requestSubscriptionSync(); + } + + // ---------- 公开订阅分发 updateSub ---------- + + /** 未知 key 必须回 404,不能泄漏「账号存在但未绑定」这类差异。 */ + @Test + void publicSubReturns404ForUnknownKey() throws Exception { + when(subMapper.selectSubBind("nokey")).thenReturn(null); + var response = mock(jakarta.servlet.http.HttpServletResponse.class); + service.updateSub(response, mock(jakarta.servlet.http.HttpServletRequest.class), "v2", "nokey"); + verify(response).sendError(eq(404), anyString()); + } + + @Test + void publicSubReturns404WhenAccountDisabled() throws Exception { + SubBind bind = new SubBind("key1", "alice", 1, "name", false, false); + when(subMapper.selectSubBind("key1")).thenReturn(bind); + var response = mock(jakarta.servlet.http.HttpServletResponse.class); + service.updateSub(response, mock(jakarta.servlet.http.HttpServletRequest.class), "v2", "key1"); + verify(response).sendError(eq(404), anyString()); + } + + @Test + void publicSubReturns404WhenAccountIdMissing() throws Exception { + SubBind bind = new SubBind("key1", "alice", null, "name", true, false); + when(subMapper.selectSubBind("key1")).thenReturn(bind); + var response = mock(jakarta.servlet.http.HttpServletResponse.class); + service.updateSub(response, mock(jakarta.servlet.http.HttpServletRequest.class), "v2", "key1"); + verify(response).sendError(eq(404), anyString()); + } + + /** 非法 client(既非 v2 也非 cat)应回 400,避免把未知格式当订阅返回。 */ + @Test + void publicSubRejectsUnknownClient() throws Exception { + SubBind bind = new SubBind("key1", "alice", 1, "name", true, false); + when(subMapper.selectSubBind("key1")).thenReturn(bind); + var request = mock(jakarta.servlet.http.HttpServletRequest.class); + when(request.getHeader("User-Agent")).thenReturn("Mozilla/5.0"); + when(request.getRemoteAddr()).thenReturn("203.0.113.5"); + var response = mock(jakarta.servlet.http.HttpServletResponse.class); + + service.updateSub(response, request, "clashmeta", "key1"); + verify(response).sendError(eq(400), anyString()); + } + + /** 缓存文件缺失时应回 503(暂时不可用),而不是 404 或空响应。 */ + @Test + void publicSubReturns503WhenCacheMissing() throws Exception { + SubBind bind = new SubBind("key1", "alice", 1, "name", true, false); + when(subMapper.selectSubBind("key1")).thenReturn(bind); + when(refreshService.cachedPath(eq(1), anyString())) + .thenReturn(java.nio.file.Path.of("/nonexistent/cache/v2.txt")); + var request = mock(jakarta.servlet.http.HttpServletRequest.class); + when(request.getHeader("User-Agent")).thenReturn("Mozilla/5.0"); + when(request.getRemoteAddr()).thenReturn("203.0.113.5"); + var response = mock(jakarta.servlet.http.HttpServletResponse.class); + + service.updateSub(response, request, "v2", "key1"); + verify(response).sendError(eq(503), anyString()); + } + + @Test + void publicSubIgnoresNullKeyOrClient() throws Exception { + var response = mock(jakarta.servlet.http.HttpServletResponse.class); + var request = mock(jakarta.servlet.http.HttpServletRequest.class); + service.updateSub(response, request, null, "key1"); + service.updateSub(response, request, "v2", null); + verifyNoInteractions(response); + } +} diff --git a/src/test/java/com/lion/lionwebsite/Util/CustomUtilTest.java b/src/test/java/com/lion/lionwebsite/Util/CustomUtilTest.java new file mode 100644 index 0000000..d6853cd --- /dev/null +++ b/src/test/java/com/lion/lionwebsite/Util/CustomUtilTest.java @@ -0,0 +1,158 @@ +package com.lion.lionwebsite.Util; + +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.ServerSocket; +import java.time.LocalDateTime; +import java.time.format.DateTimeParseException; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 通用工具:体积换算、时间格式化、端口探测、404 输出。 */ +class CustomUtilTest { + + // ---------- fileSizeToString ---------- + + @Test + void bytesBelowOneKbAreShownAsPlainBytes() { + assertEquals("0B", CustomUtil.fileSizeToString(0)); + assertEquals("1B", CustomUtil.fileSizeToString(1)); + assertEquals("1023B", CustomUtil.fileSizeToString(1023)); + } + + @Test + void kilobytesUseTwoDecimals() { + assertEquals("1.00KB", CustomUtil.fileSizeToString(1024)); + assertEquals("1.50KB", CustomUtil.fileSizeToString(1536)); + } + + @Test + void megabytesAndGigabytesUseTwoDecimals() { + assertEquals("1.00MB", CustomUtil.fileSizeToString(1024L * 1024)); + assertEquals("2.50GB", CustomUtil.fileSizeToString((long) (2.5 * 1024 * 1024 * 1024))); + } + + /** 边界值必须落在「较大」的那一档,不能出现 1024B 这种输出。 */ + @Test + void unitBoundariesRollOverToTheNextUnit() { + assertEquals("1.00KB", CustomUtil.fileSizeToString(1024), "1024B 应进位为 KB"); + assertEquals("1.00MB", CustomUtil.fileSizeToString(1024L * 1024), "MB 边界应进位"); + assertEquals("1023.99KB", CustomUtil.fileSizeToString(1024L * 1024 - 11)); + } + + // ---------- stringToFileSize ---------- + + @Test + void parsesPlainByteValues() { + assertEquals(512L, CustomUtil.stringToFileSize("512B")); + assertEquals(0L, CustomUtil.stringToFileSize("0B")); + } + + @Test + void parsesIntegerAndDecimalValuesWithUnits() { + assertEquals(1024L, CustomUtil.stringToFileSize("1KB")); + assertEquals(1024L, CustomUtil.stringToFileSize("1KiB")); + assertEquals(1024L * 1024, CustomUtil.stringToFileSize("1MB")); + assertEquals(1024L * 1024, CustomUtil.stringToFileSize("1MiB")); + assertEquals(1536L, CustomUtil.stringToFileSize("1.5KB")); + assertEquals((long) (2.5 * 1024 * 1024 * 1024), CustomUtil.stringToFileSize("2.5GB")); + } + + /** 无法识别的单位返回 0,而不是抛异常,调用方据此判定无效输入。 */ + @Test + void unknownUnitYieldsZero() { + assertEquals(0L, CustomUtil.stringToFileSize("10TB")); + assertEquals(0L, CustomUtil.stringToFileSize("10XB")); + } + + /** 与 fileSizeToString 互为逆运算(KB 及以上取整容差)。 */ + @Test + void roundTripsThroughBothDirections() { + for (long size : new long[]{512, 1024, 2048, 1024L * 512, 1024L * 1024, 1024L * 1024 * 8}) { + String text = CustomUtil.fileSizeToString(size); + assertEquals(size, CustomUtil.stringToFileSize(text), "往返应还原: " + text); + } + } + + /** 空串/纯文本无数字时不抛异常(当前实现会抛 NumberFormatException,此处锁住该行为)。 */ + @Test + void malformedInputWithoutDigitsThrows() { + assertThrows(Exception.class, () -> CustomUtil.stringToFileSize("abc")); + } + + // ---------- 时间 ---------- + + @Test + void nowMatchesConfiguredPattern() { + String now = CustomUtil.now(); + assertDoesNotThrow(() -> LocalDateTime.parse(now, CustomUtil.dateTimeFormatter()), + "now() 必须与 dateTimeFormatter() 的模式一致,否则解析失败"); + assertEquals(19, now.length(), "yyyy-MM-dd HH:mm:ss 固定 19 字符"); + } + + @Test + void formatterIsStableAcrossCalls() { + assertSame(CustomUtil.dateTimeFormatter(), CustomUtil.dateTimeFormatter()); + assertNotNull(CustomUtil.dateTimeFormatter().parse("2026-09-14 14:23:58")); + assertThrows(DateTimeParseException.class, + () -> CustomUtil.dateTimeFormatter().parse("2026/09/14 14:23:58")); + } + + // ---------- 端口探测 ---------- + + @Test + void findIdlePortReturnsUsablePort() throws IOException { + int port = CustomUtil._findIdlePort(49152); + assertTrue(port >= 49152 && port < 65535, "应返回区间内的端口,实际: " + port); + try (ServerSocket probe = new ServerSocket(port)) { + assertEquals(port, probe.getLocalPort(), "返回的端口应真的可用"); + } + } + + /** 起始端口被占用时应向后续端口回退。 */ + @Test + void findIdlePortSkipsOccupiedPort() throws IOException { + try (ServerSocket occupied = new ServerSocket(0)) { + int busy = occupied.getLocalPort(); + int found = CustomUtil._findIdlePort(busy); + assertNotEquals(busy, found, "被占用的端口不应被返回"); + assertTrue(found > busy, "应向后寻找,实际: " + found); + } + } + + // ---------- 常量 ---------- + + @Test + void sizeConstantsAreConsistent() { + assertEquals(1024.0, CustomUtil.ONE_KB); + assertEquals(1024.0 * 1024, CustomUtil.ONE_MB); + assertEquals(1024.0 * 1024 * 1024, CustomUtil.ONE_GB); + } + + @Test + void objectMapperIsSharedAndUsable() { + assertNotNull(CustomUtil.objectMapper); + assertSame(CustomUtil.objectMapper, CustomUtil.objectMapper, "应为共享单例"); + assertTrue(CustomUtil.objectMapper.createObjectNode().isObject()); + } + + // ---------- fourZeroFour ---------- + + @Test + void fourZeroFourSends404() throws IOException { + HttpServletResponse response = mock(HttpServletResponse.class); + CustomUtil.fourZeroFour(response); + verify(response).sendError(404); + } + + /** sendError 抛 IOException 时应被吞掉并记日志,不能把异常抛给调用方。 */ + @Test + void fourZeroFourSwallowsIoException() throws IOException { + HttpServletResponse response = mock(HttpServletResponse.class); + doThrow(new IOException("client gone")).when(response).sendError(404); + assertDoesNotThrow(() -> CustomUtil.fourZeroFour(response)); + } +} diff --git a/src/test/java/com/lion/lionwebsite/Util/GalleryUtilTest.java b/src/test/java/com/lion/lionwebsite/Util/GalleryUtilTest.java new file mode 100644 index 0000000..42220cc --- /dev/null +++ b/src/test/java/com/lion/lionwebsite/Util/GalleryUtilTest.java @@ -0,0 +1,221 @@ +package com.lion.lionwebsite.Util; + +import com.lion.lionwebsite.Domain.ImageKeyCache; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 图库解析与图片地址相关的纯逻辑部分。 + * parse()/parseImageKeys() 依赖外部站点,这里只覆盖可离线判定的分支: + * 链接校验、gid 提取、mpvKey 缓存行为,以及取图失败时的降级(必须返回 null 而非抛异常)。 + */ +class GalleryUtilTest { + + // ---------- verifyLink ---------- + + /** 非法或过短链接必须被拒(返回 null),这是录入侧的第一道闸门。 */ + @Test + void verifyLinkRejectsNullOrTooShort() { + assertNull(GalleryUtil.verifyLink(null)); + assertNull(GalleryUtil.verifyLink("")); + assertNull(GalleryUtil.verifyLink("https://exhentai.org/g/123/abc")); + } + + /** 必须同时含 /g/ 与 exhentai,缺一即拒。 */ + @Test + void verifyLinkRequiresGalleryPathAndSite() { + assertNull(GalleryUtil.verifyLink( + "https://example.com/g/1234567/0123456789"), "非 e-hentai 域名应拒绝"); + assertNull(GalleryUtil.verifyLink( + "https://exhentai.org/some/other/very/long/path/here"), "缺少 /g/ 应拒绝"); + } + + @Test + void verifyLinkAcceptsWellFormedGalleryUrl() { + String url = "https://exhentai.org/g/1234567/0123456789ab/"; + assertNotNull(GalleryUtil.verifyLink(url)); + assertEquals("", GalleryUtil.verifyLink(url), "通过校验时返回空串作为 origin"); + } + + // ---------- parseGid ---------- + + @Test + void parseGidExtractsNumericIdFromLink() { + assertEquals(1234567, GalleryUtil.parseGid("https://exhentai.org/g/1234567/0123456789ab/")); + assertEquals(1, GalleryUtil.parseGid("https://exhentai.org/g/1/x/")); + assertEquals(987654321, GalleryUtil.parseGid("https://exhentai.org/g/987654321/key/")); + } + + /** 缺少 /g/ 段时必须返回 null 而不是抛异常。 */ + @Test + void parseGidReturnsNullWhenMarkerMissing() { + assertNull(GalleryUtil.parseGid("https://exhentai.org/1234567/abc/")); + assertNull(GalleryUtil.parseGid("")); + assertNull(GalleryUtil.parseGid("no-marker-here")); + } + + /** gid 非数字时返回 null(NumberFormatException 未被捕获会向上抛,这里锁住实际行为)。 */ + @Test + void parseGidOnNonNumericSegmentEitherNullsOrThrows() { + try { + Integer result = GalleryUtil.parseGid("https://exhentai.org/g/not-a-number/key/"); + assertNull(result, "非数字 gid 应为 null"); + } catch (NumberFormatException expected) { + // 当前实现对非数字段会抛 NumberFormatException,属既有行为; + // 调用方(parse/refreshMpvKey)传入的都是已验证链接。 + assertTrue(true); + } + } + + // ---------- getImageUrl 失败降级 ---------- + + /** 取图接口异常时必须返回 null,供上层回退到 404,而不是把异常抛穿请求链路。 */ + @Test + void getImageUrlReturnsNullWhenUpstreamFails() { + ImageKeyCache cache = new ImageKeyCache(); + cache.setGid("1234567"); + cache.setPage(1); + cache.setImgkey("abc123"); + + try (var methods = mockStatic(GalleryUtil.class)) { + methods.when(() -> GalleryUtil.requests(anyString(), anyString(), any(), any())) + .thenThrow(new java.io.IOException("upstream down")); + methods.when(() -> GalleryUtil.getImageUrl(anyString(), any())) + .thenCallRealMethod(); + + assertNull(GalleryUtil.getImageUrl("mpv-key", cache)); + } + } + + /** 上游返回 Key mismatch 时同样返回 null(表示当前 mpvKey 已失效)。 */ + @Test + void getImageUrlReturnsNullOnKeyMismatch() { + ImageKeyCache cache = new ImageKeyCache(); + cache.setGid("1234567"); + cache.setPage(2); + cache.setImgkey("abc123"); + + try (var methods = mockStatic(GalleryUtil.class)) { + methods.when(() -> GalleryUtil.requests(anyString(), anyString(), any(), any())) + .thenReturn("{\"error\":\"Key mismatch\"}"); + methods.when(() -> GalleryUtil.getImageUrl(anyString(), any())) + .thenCallRealMethod(); + + assertNull(GalleryUtil.getImageUrl("stale-key", cache)); + } + } + + /** 正常响应应取出 "i" 字段作为图片地址。 */ + @Test + void getImageUrlExtractsUrlFromValidResponse() { + ImageKeyCache cache = new ImageKeyCache(); + cache.setGid("1234567"); + cache.setPage(3); + cache.setImgkey("abc123"); + + try (var methods = mockStatic(GalleryUtil.class)) { + methods.when(() -> GalleryUtil.requests(anyString(), anyString(), any(), any())) + .thenReturn("{\"i\":\"https://s.exhentai.org/s/abc/1234567-3\"}"); + methods.when(() -> GalleryUtil.getImageUrl(anyString(), any())) + .thenCallRealMethod(); + + assertEquals("https://s.exhentai.org/s/abc/1234567-3", + GalleryUtil.getImageUrl("good-key", cache)); + } + } + + /** 请求体必须带上 gid / mpvkey / imgkey / method / page 五个契约字段。 */ + @Test + void getImageUrlSendsRequiredApiFields() { + ImageKeyCache cache = new ImageKeyCache(); + cache.setGid("777"); + cache.setPage(5); + cache.setImgkey("img-key-value"); + + try (var methods = mockStatic(GalleryUtil.class)) { + methods.when(() -> GalleryUtil.requests(anyString(), anyString(), any(), any())) + .thenReturn("{\"i\":\"https://example.org/x\"}"); + methods.when(() -> GalleryUtil.getImageUrl(anyString(), any())) + .thenCallRealMethod(); + GalleryUtil.getImageUrl("mpv-value", cache); + + @SuppressWarnings("unchecked") + var bodyCaptor = org.mockito.ArgumentCaptor.forClass(HashMap.class); + methods.verify(() -> GalleryUtil.requests(anyString(), eq("post"), any(), bodyCaptor.capture())); + + var body = (HashMap) bodyCaptor.getValue(); + assertEquals("777", body.get("gid")); + assertEquals("mpv-value", body.get("mpvkey")); + assertEquals("img-key-value", body.get("imgkey")); + assertEquals("imagedispatch", body.get("method")); + assertEquals("5", body.get("page")); + assertEquals("json", body.get("payload")); + } + } + + // ---------- convertImg ---------- + + /** 后缀已匹配目标格式时应原样返回,不做无谓转换。 */ + @Test + void convertImgReturnsInputWhenTargetEqualsSource() { + assertEquals("/tmp/pic.avif", GalleryUtil.convertImg("/tmp/pic.avif", ".avif")); + } + + /** 转换器不存在或失败时返回 null(调用方据此回退原图),不得抛异常。 */ + @Test + void convertImgReturnsNullWhenConverterUnavailable() { + String result = GalleryUtil.convertImg("/nonexistent/image-xyz.png", ".png"); + assertNull(result, "转换失败应返回 null 让调用方回退"); + } + + // ---------- mpvKey 缓存 ---------- + + /** 缓存命中时不应触发刷新(避免每次取图都打上游)。 */ + @Test + void getMpvKeyUsesCacheWithoutRefreshing() { + String url = "https://example.org/g/555000111/key/"; + GalleryUtil.gid2MpvKey.put("555000111", "cached-value"); + try (var methods = mockStatic(GalleryUtil.class)) { + methods.when(() -> GalleryUtil.parseGid(url)).thenReturn(555000111); + methods.when(() -> GalleryUtil.getMpvKey(url)).thenCallRealMethod(); + + assertEquals("cached-value", GalleryUtil.getMpvKey(url)); + methods.verify(() -> GalleryUtil.refreshMpvKey(anyString()), never()); + } finally { + GalleryUtil.gid2MpvKey.remove("555000111"); + } + } + + // ---------- 体量换算与展示 ---------- + + /** fileSizeToString 与 stringToFileSize 是展示/回读的一对,须保持互逆。 */ + @Test + void displayAndParseFileSizeAreInverse() { + long size = 350L * 1024 * 1024; + assertEquals(size, CustomUtil.stringToFileSize(CustomUtil.fileSizeToString(size))); + } + + /** 解析出的 gid 列表用于批量取图,顺序与页序必须一致。 */ + @Test + void imageKeyCacheCarriesGidPageAndKey() { + List caches = new ArrayList<>(); + for (int page = 1; page <= 3; page++) { + ImageKeyCache cache = new ImageKeyCache(); + cache.setGid("1234567"); + cache.setPage(page); + cache.setImgkey("key-" + page); + caches.add(cache); + } + assertEquals(3, caches.size()); + assertEquals(1, caches.get(0).getPage()); + assertEquals("key-3", caches.get(2).getImgkey()); + assertEquals("1234567", caches.get(0).getGid()); + } +} diff --git a/src/test/java/com/lion/lionwebsite/Util/ResponseTest.java b/src/test/java/com/lion/lionwebsite/Util/ResponseTest.java new file mode 100644 index 0000000..aca4629 --- /dev/null +++ b/src/test/java/com/lion/lionwebsite/Util/ResponseTest.java @@ -0,0 +1,119 @@ +package com.lion.lionwebsite.Util; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 统一响应封装。所有 Controller 都通过它产出 JSON, + * 且前端与机器人按 "result"/"data" 两个字段判定成败,故键名与语义必须锁死。 + */ +class ResponseTest { + + /** + * 未标记状态的 Response 上调用 isSuccess() 会抛 NPE: + * isSuccess() 内部直接 result.get("result").asText(),而 "result" 键此时尚不存在。 + * 这是既有实现的真实行为(潜在缺陷),此处显式记录而非掩盖; + * 生产路径上 Controller 总会先调 success()/failure(),故未暴露。 + */ + @Test + void isSuccessOnUnsetStatusThrowsDueToMissingKey() { + Response response = Response.generateResponse(); + assertThrows(NullPointerException.class, response::isSuccess, + "若此断言失败说明实现已修复为容忍缺失 result 键,应同步更新该测试"); + } + + @Test + void successSetsResultFlag() { + Response response = Response.generateResponse().success(); + assertTrue(response.isSuccess()); + assertEquals("{\"result\":\"success\"}", response.toJSONString()); + } + + @Test + void successWithDataKeepsBothFields() { + Response response = Response.generateResponse().success("payload"); + assertTrue(response.isSuccess()); + assertEquals("payload", response.getData()); + assertTrue(response.toJSONString().contains("\"data\":\"payload\"")); + } + + @Test + void failureSetsFlagAndData() { + Response response = Response.generateResponse(); + response.failure("boom"); + assertFalse(response.isSuccess()); + assertEquals("boom", response.getData()); + assertEquals("failure", response.get("result")); + } + + @Test + void jsonNodeDataIsEmbeddedAsStructuredValue() { + tools.jackson.databind.ObjectMapper mapper = new tools.jackson.databind.ObjectMapper(); + tools.jackson.databind.node.ObjectNode node = mapper.createObjectNode(); + node.put("gid", 42); + + Response response = Response.generateResponse().success(node); + assertTrue(response.isSuccess()); + String json = response.toJSONString(); + assertTrue(json.contains("\"gid\":42"), "结构化数据应内嵌为对象而非字符串: " + json); + assertFalse(json.contains("\\\"gid\\\""), "不应被双重转义"); + } + + @Test + void arbitraryKeyValueRoundTrips() { + Response response = Response.generateResponse(); + response.set("custom", "value"); + assertEquals("value", response.get("custom")); + assertTrue(response.toJSONString().contains("\"custom\":\"value\"")); + } + + /** 静态便捷方法供异常等场景直接返回 JSON 字符串。 */ + @Test + void staticHelpersProduceExpectedJson() { + assertEquals("{\"result\":\"success\"}", Response._success()); + assertTrue(Response._success("done").contains("\"result\":\"success\"")); + assertTrue(Response._success("done").contains("\"data\":\"done\"")); + assertTrue(Response._failure("bad").contains("\"result\":\"failure\"")); + assertTrue(Response._failure("bad").contains("\"data\":\"bad\"")); + } + + /** 已废弃的 getResult() 实际返回 data 字段(保留兼容,锁住该行为以免误改)。 */ + @Test + @SuppressWarnings("deprecation") + void deprecatedGetResultReturnsDataField() { + Response response = Response.generateResponse().success("the-data"); + assertEquals("the-data", response.getResult()); + assertEquals(response.getData(), response.getResult()); + } + + /** 多次 success/failure 调用以后者为准。 */ + @Test + void lastStatusCallWins() { + Response response = Response.generateResponse(); + response.success("first"); + response.failure("second"); + assertFalse(response.isSuccess()); + assertEquals("second", response.getData()); + } + + /** 每个 Response 实例必须独立,避免共享 ObjectNode 造成串数据。 */ + @Test + void instancesDoNotShareState() { + Response first = Response.generateResponse().success("one"); + Response second = Response.generateResponse().success("two"); + assertEquals("one", first.getData()); + assertEquals("two", second.getData()); + assertNotSame(first.toJSONString(), second.toJSONString()); + } + + /** 中文与特殊字符应被正确转义或原样输出,不破坏 JSON 结构。 */ + @Test + void nonAsciiDataProducesValidJson() { + Response response = Response.generateResponse().success("下载失败:网络异常 \"quoted\""); + String json = response.toJSONString(); + assertDoesNotThrow(() -> new tools.jackson.databind.ObjectMapper().readTree(json), + "输出必须是合法 JSON"); + assertTrue(json.contains("下载失败")); + } +}