test(feishu): add TTL filter and unit tests for recent-file disk fallback

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
This commit is contained in:
倪程伟 2026-06-14 00:09:17 +08:00 committed by matevip
parent 39a55db65f
commit 515cba88ee
2 changed files with 298 additions and 4 deletions

View File

@ -222,11 +222,15 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
record RecentFileEntry(String fileName, String path, String fileUrl, String contentType) {}
private final Cache<String, List<RecentFileEntry>> recentFileCache = Caffeine.newBuilder()
// Package-private for testing: seed the cache directly to verify injection paths.
final Cache<String, List<RecentFileEntry>> 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<MessageContentPart> parts, String textContent) {
// Package-private for testing.
String injectRecentFiles(String conversationId, List<MessageContentPart> parts, String textContent) {
List<RecentFileEntry> 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<RecentFileEntry> 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<RecentFileEntry> 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(

View File

@ -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:
*
* <ul>
* <li>{@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.</li>
* <li>{@link FeishuChannelAdapter#injectRecentFiles} Caffeine cache-hit and cache-miss
* (disk fallback) paths, duplicate dedup, image vs file part typing.</li>
* </ul>
*/
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<FeishuChannelAdapter.RecentFileEntry> 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<FeishuChannelAdapter.RecentFileEntry> 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<FeishuChannelAdapter.RecentFileEntry> 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<FeishuChannelAdapter.RecentFileEntry> 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<FeishuChannelAdapter.RecentFileEntry> 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<FeishuChannelAdapter.RecentFileEntry> 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<MessageContentPart> 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<MessageContentPart> 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<MessageContentPart> 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<MessageContentPart> 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<MessageContentPart> 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<MessageContentPart> parts = new ArrayList<>();
String text = a.injectRecentFiles(convId, parts, null);
assertFalse(text.isBlank());
assertTrue(text.contains("data.csv"));
}
}