mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
39a55db65f
commit
515cba88ee
@ -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(
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user