From 515cba88ee6eb4a00cbf1ce151a9c7850bb2e288 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=80=AA=E7=A8=8B=E4=BC=9F?= Date: Sun, 14 Jun 2026 00:09:17 +0800 Subject: [PATCH] test(feishu): add TTL filter and unit tests for recent-file disk fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadRecentFilesFromDisk now filters out files older than RECENT_FILE_TTL_MINUTES (60 min) so the disk fallback matches the Caffeine cache TTL and does not inject stale attachments into future conversations. Testability refactoring: - recentFileCache: private → package-private (tests can seed the cache directly) - chatUploadsRoot: new package-private Path field (tests redirect to @TempDir) - loadRecentFilesFromDisk: add (Path dir, long cutoffMs) package-private overload; private (String) wrapper delegates to it - injectRecentFiles: private → package-private New test class FeishuRecentFileCacheTest (14 cases): - loadRecentFilesFromDisk: non-existent dir, empty dir, fresh files sorted newest-first, stale files excluded by TTL, mixed fresh+stale, >5 files capped, timestamp-prefix stripping, MIME guessing from extension - injectRecentFiles: Caffeine cache hit, cache-miss disk fallback, empty disk, duplicate-path dedup, image vs file part typing, null textContent guard Relates to #325 --- .../channel/feishu/FeishuChannelAdapter.java | 29 +- .../feishu/FeishuRecentFileCacheTest.java | 273 ++++++++++++++++++ 2 files changed, 298 insertions(+), 4 deletions(-) create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuRecentFileCacheTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java index 01216750..841bf0cc 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java @@ -222,11 +222,15 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre record RecentFileEntry(String fileName, String path, String fileUrl, String contentType) {} - private final Cache> recentFileCache = Caffeine.newBuilder() + // Package-private for testing: seed the cache directly to verify injection paths. + final Cache> recentFileCache = Caffeine.newBuilder() .expireAfterWrite(RECENT_FILE_TTL_MINUTES, TimeUnit.MINUTES) .maximumSize(200) .build(); + // Package-private for testing: redirect to a temp directory without touching real disk. + Path chatUploadsRoot = Path.of("data", "chat-uploads"); + public FeishuChannelAdapter(ChannelEntity channelEntity, ChannelMessageRouter messageRouter, ObjectMapper objectMapper) { @@ -1709,7 +1713,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre if (dl == null) return null; // Save to data/chat-uploads/{conversationId}/ - Path uploadDir = Path.of("data", "chat-uploads", conversationId); + Path uploadDir = chatUploadsRoot.resolve(conversationId); Files.createDirectories(uploadDir); String rawName = (dl.fileName() != null && !dl.fileName().isBlank()) ? dl.fileName() : fileKey; @@ -1751,7 +1755,8 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre * * @return updated textContent with file descriptions appended */ - private String injectRecentFiles(String conversationId, List parts, String textContent) { + // Package-private for testing. + String injectRecentFiles(String conversationId, List parts, String textContent) { List recent = recentFileCache.getIfPresent(conversationId); if (recent == null || recent.isEmpty()) { // Fallback: scan data/chat-uploads/{conversationId}/ on disk. @@ -1798,11 +1803,27 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre * are still on disk. */ private List loadRecentFilesFromDisk(String conversationId) { - Path dir = Path.of("data", "chat-uploads", conversationId); + long cutoff = System.currentTimeMillis() - RECENT_FILE_TTL_MINUTES * 60_000L; + return loadRecentFilesFromDisk(chatUploadsRoot.resolve(conversationId), cutoff); + } + + /** + * Package-private for testing: explicit {@code dir} and {@code cutoffMs} make the + * test deterministic without temp-directory path construction or time mocking. + * Production callers go through {@link #loadRecentFilesFromDisk(String)}. + */ + List loadRecentFilesFromDisk(Path dir, long cutoffMs) { if (!Files.isDirectory(dir)) return List.of(); try (var stream = Files.list(dir)) { return stream .filter(Files::isRegularFile) + .filter(p -> { + try { + return Files.getLastModifiedTime(p).toMillis() >= cutoffMs; + } catch (Exception e) { + return true; + } + }) .sorted((a, b) -> { try { return Long.compare( diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuRecentFileCacheTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuRecentFileCacheTest.java new file mode 100644 index 00000000..d73c9b4e --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuRecentFileCacheTest.java @@ -0,0 +1,273 @@ +package vip.mate.channel.feishu; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import vip.mate.channel.ChannelMessageRouter; +import vip.mate.channel.model.ChannelEntity; +import vip.mate.workspace.conversation.model.MessageContentPart; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +/** + * Unit tests for the per-chat recent-file cache: + * + *
    + *
  • {@link FeishuChannelAdapter#loadRecentFilesFromDisk(Path, long)} — disk scan with + * explicit dir and TTL cutoff so tests never touch the real filesystem or depend on + * wall-clock time.
  • + *
  • {@link FeishuChannelAdapter#injectRecentFiles} — Caffeine cache-hit and cache-miss + * (disk fallback) paths, duplicate dedup, image vs file part typing.
  • + *
+ */ +class FeishuRecentFileCacheTest { + + // ==================== helpers ==================== + + private static FeishuChannelAdapter newAdapter() { + ChannelEntity e = new ChannelEntity(); + e.setId(1L); + e.setChannelType("feishu"); + e.setConfigJson("{\"app_id\":\"cli_test\",\"app_secret\":\"x\"}"); + return new FeishuChannelAdapter( + e, mock(ChannelMessageRouter.class), new ObjectMapper(), + null, null, null, null, null, null, null); + } + + /** Write a tiny file and stamp its last-modified time. */ + private static Path touch(Path dir, String name, long lastModifiedMs) throws IOException { + Path file = dir.resolve(name); + Files.writeString(file, "x"); + Files.setLastModifiedTime(file, FileTime.fromMillis(lastModifiedMs)); + return file; + } + + private static final long NOW = System.currentTimeMillis(); + private static final long FIVE_MIN_AGO = NOW - 5 * 60_000L; + private static final long TEN_MIN_AGO = NOW - 10 * 60_000L; + private static final long OLD = NOW - 90 * 60_000L; // > 60-min TTL + + // ==================== loadRecentFilesFromDisk ==================== + + @Test + void loadFromDisk_nonExistentDir_returnsEmpty(@TempDir Path tmp) { + FeishuChannelAdapter a = newAdapter(); + List result = + a.loadRecentFilesFromDisk(tmp.resolve("no-such"), NOW - 60 * 60_000L); + assertTrue(result.isEmpty()); + } + + @Test + void loadFromDisk_emptyDir_returnsEmpty(@TempDir Path tmp) throws IOException { + Path dir = tmp.resolve("chat"); Files.createDirectories(dir); + FeishuChannelAdapter a = newAdapter(); + assertTrue(a.loadRecentFilesFromDisk(dir, NOW - 60 * 60_000L).isEmpty()); + } + + @Test + void loadFromDisk_freshFiles_returnedSortedNewestFirst(@TempDir Path tmp) throws IOException { + Path dir = tmp.resolve("chat"); Files.createDirectories(dir); + touch(dir, "1000000000001_a.txt", TEN_MIN_AGO); + touch(dir, "1000000000002_b.txt", FIVE_MIN_AGO); + + FeishuChannelAdapter a = newAdapter(); + long cutoff = NOW - 60 * 60_000L; + List result = a.loadRecentFilesFromDisk(dir, cutoff); + + assertEquals(2, result.size()); + // newest first + assertEquals("b.txt", result.get(0).fileName()); + assertEquals("a.txt", result.get(1).fileName()); + } + + @Test + void loadFromDisk_staleFiles_excluded(@TempDir Path tmp) throws IOException { + Path dir = tmp.resolve("chat"); Files.createDirectories(dir); + touch(dir, "1000000000001_old.txt", OLD); + + FeishuChannelAdapter a = newAdapter(); + long cutoff = NOW - 60 * 60_000L; + assertTrue(a.loadRecentFilesFromDisk(dir, cutoff).isEmpty()); + } + + @Test + void loadFromDisk_mixFreshAndStale_onlyFreshReturned(@TempDir Path tmp) throws IOException { + Path dir = tmp.resolve("chat"); Files.createDirectories(dir); + touch(dir, "1000000000001_fresh.pdf", FIVE_MIN_AGO); + touch(dir, "1000000000002_stale.pdf", OLD); + + FeishuChannelAdapter a = newAdapter(); + long cutoff = NOW - 60 * 60_000L; + List result = a.loadRecentFilesFromDisk(dir, cutoff); + + assertEquals(1, result.size()); + assertEquals("fresh.pdf", result.get(0).fileName()); + } + + @Test + void loadFromDisk_moreThan5FreshFiles_cappedAtMax(@TempDir Path tmp) throws IOException { + Path dir = tmp.resolve("chat"); Files.createDirectories(dir); + for (int i = 1; i <= 7; i++) { + touch(dir, "100000000000" + i + "_f" + i + ".txt", FIVE_MIN_AGO - i * 1000L); + } + + FeishuChannelAdapter a = newAdapter(); + long cutoff = NOW - 60 * 60_000L; + List result = a.loadRecentFilesFromDisk(dir, cutoff); + + assertEquals(5, result.size()); // RECENT_FILE_MAX_PER_CHAT + } + + @Test + void loadFromDisk_timestampPrefixStripped(@TempDir Path tmp) throws IOException { + Path dir = tmp.resolve("chat"); Files.createDirectories(dir); + touch(dir, "1777391026594_report.pdf", FIVE_MIN_AGO); + // No-underscore name: no stripping + touch(dir, "plain.pdf", TEN_MIN_AGO); + + FeishuChannelAdapter a = newAdapter(); + long cutoff = NOW - 60 * 60_000L; + List result = a.loadRecentFilesFromDisk(dir, cutoff); + + assertEquals(2, result.size()); + // newest first is the one with timestamp prefix + assertEquals("report.pdf", result.get(0).fileName()); + assertEquals("plain.pdf", result.get(1).fileName()); + } + + @Test + void loadFromDisk_contentTypeGuessingByExtension(@TempDir Path tmp) throws IOException { + Path dir = tmp.resolve("chat"); Files.createDirectories(dir); + touch(dir, "1000000000001_doc.pdf", FIVE_MIN_AGO); + touch(dir, "1000000000002_img.png", TEN_MIN_AGO); + touch(dir, "1000000000003_mystery.xyz", OLD - 1); // stale, should be excluded + + FeishuChannelAdapter a = newAdapter(); + long cutoff = NOW - 60 * 60_000L; + List result = a.loadRecentFilesFromDisk(dir, cutoff); + + assertEquals(2, result.size()); + assertEquals("application/pdf", result.get(0).contentType()); + assertEquals("image/png", result.get(1).contentType()); + } + + // ==================== injectRecentFiles ==================== + + @Test + void injectRecentFiles_cacheHit_injectsFromCache(@TempDir Path tmp) { + FeishuChannelAdapter a = newAdapter(); + a.chatUploadsRoot = tmp; // no disk files — only cache should be hit + + String convId = "feishu:oc_test123"; + a.recentFileCache.put(convId, List.of( + new FeishuChannelAdapter.RecentFileEntry("report.pdf", "/tmp/report.pdf", + null, "application/pdf") + )); + + List parts = new ArrayList<>(); + String text = a.injectRecentFiles(convId, parts, "请分析"); + + assertEquals(1, parts.size()); + assertEquals("file", parts.get(0).getType()); + assertEquals("report.pdf", parts.get(0).getFileName()); + assertTrue(text.contains("[用户发送了文件: report.pdf]")); + } + + @Test + void injectRecentFiles_cacheMiss_diskHasFreshFile_fallsBackToDisk(@TempDir Path tmp) throws IOException { + FeishuChannelAdapter a = newAdapter(); + a.chatUploadsRoot = tmp; + + String convId = "feishu:oc_groupX"; + Path convDir = tmp.resolve(convId); + Files.createDirectories(convDir); + Files.writeString(convDir.resolve("1000000000001_summary.txt"), "content"); + + List parts = new ArrayList<>(); + String text = a.injectRecentFiles(convId, parts, "帮我看看"); + + assertEquals(1, parts.size(), "disk fallback should inject the file"); + assertEquals("summary.txt", parts.get(0).getFileName()); + assertTrue(text.contains("[用户发送了文件: summary.txt]")); + } + + @Test + void injectRecentFiles_cacheMiss_diskEmpty_noChange(@TempDir Path tmp) { + FeishuChannelAdapter a = newAdapter(); + a.chatUploadsRoot = tmp; + + List parts = new ArrayList<>(); + String original = "帮我看看"; + String text = a.injectRecentFiles("feishu:oc_empty", parts, original); + + assertTrue(parts.isEmpty()); + assertEquals(original, text); + } + + @Test + void injectRecentFiles_duplicatePathSkipped(@TempDir Path tmp) { + FeishuChannelAdapter a = newAdapter(); + a.chatUploadsRoot = tmp; + + String convId = "feishu:oc_dedup"; + String existingPath = "/some/path/file.pdf"; + a.recentFileCache.put(convId, List.of( + new FeishuChannelAdapter.RecentFileEntry("file.pdf", existingPath, + null, "application/pdf") + )); + + // part already carrying the same path + MessageContentPart existing = MessageContentPart.file("key", "file.pdf", null); + existing.setPath(existingPath); + List parts = new ArrayList<>(List.of(existing)); + + a.injectRecentFiles(convId, parts, ""); + + // size unchanged — duplicate suppressed + assertEquals(1, parts.size()); + } + + @Test + void injectRecentFiles_imageEntry_setsTypeImage(@TempDir Path tmp) { + FeishuChannelAdapter a = newAdapter(); + a.chatUploadsRoot = tmp; + + String convId = "feishu:oc_img"; + a.recentFileCache.put(convId, List.of( + new FeishuChannelAdapter.RecentFileEntry("photo.png", "/tmp/photo.png", + null, "image/png") + )); + + List parts = new ArrayList<>(); + a.injectRecentFiles(convId, parts, ""); + + assertEquals(1, parts.size()); + assertEquals("image", parts.get(0).getType()); + } + + @Test + void injectRecentFiles_nullTextContent_handledGracefully(@TempDir Path tmp) { + FeishuChannelAdapter a = newAdapter(); + a.chatUploadsRoot = tmp; + + String convId = "feishu:oc_nulltext"; + a.recentFileCache.put(convId, List.of( + new FeishuChannelAdapter.RecentFileEntry("data.csv", "/tmp/data.csv", + null, "text/csv") + )); + + List parts = new ArrayList<>(); + String text = a.injectRecentFiles(convId, parts, null); + + assertFalse(text.isBlank()); + assertTrue(text.contains("data.csv")); + } +}