Skip to content
Merged
202 changes: 202 additions & 0 deletions src/__tests__/fetch-topic-root-images.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
/**
* Tests for fetchTopicRootImages — separately fetched topic-root multimodal images.
*
* 覆盖:
* - msg_type='image' / msg_type='post' 解析
* - LRU 缓存命中重排 + 上限淘汰
* - 失败/空 / 删除等场景写哨兵, 避免每轮 resume 重复打 API
* - imageKeys 截长防御 (MAX_HISTORY_IMAGES)
*/
// @ts-nocheck — test file
import { describe, it, expect, vi, beforeEach } from 'vitest';

const mockGetMessageById = vi.fn();
const mockDownloadMessageImage = vi.fn();
const mockSaveMessageFileToCache = vi.fn();

vi.mock('../feishu/client.js', () => ({
feishuClient: {
getMessageById: (...args: unknown[]) => mockGetMessageById(...args),
downloadMessageImage: (...args: unknown[]) => mockDownloadMessageImage(...args),
},
}));

vi.mock('../feishu/file-cache.js', async (importOriginal) => {
const original = await importOriginal();
return {
...original,
saveMessageFileToCache: (...args: unknown[]) => mockSaveMessageFileToCache(...args),
};
});

vi.mock('../utils/logger.js', () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));

vi.mock('../utils/image-compress.js', () => ({
compressImage: vi.fn(),
compressImageForHistory: vi.fn(async (buf: Buffer, mediaType: string) => ({
data: buf,
mediaType,
})),
}));

import {
_testFetchTopicRootImages as fetchTopicRootImages,
_testClearTopicRootCache as clearCache,
} from '../feishu/event-handler.js';

const JPEG_PREFIX = Buffer.from([0xff, 0xd8, 0xff, 0xe0]);
const imageBuf = () => Buffer.concat([JPEG_PREFIX, Buffer.from('fake')]);

describe('fetchTopicRootImages', () => {
beforeEach(() => {
vi.clearAllMocks();
clearCache();
mockDownloadMessageImage.mockResolvedValue(imageBuf());
mockSaveMessageFileToCache.mockImplementation(async (msgId, key) =>
`/tmp/cache/${msgId}-${key}.jpg`,
);
});

it('extracts image_key from msg_type=image root', async () => {
mockGetMessageById.mockResolvedValue([
{
message_id: 'root1',
msg_type: 'image',
body: { content: '{"image_key":"ik_root"}' },
},
]);

const res = await fetchTopicRootImages('root1');
expect(res.rootMessageId).toBe('root1');
expect(res.images).toHaveLength(1);
expect(res.images[0].label).toBe('话题首条消息的图片');
expect(res.savedPaths).toEqual(['/tmp/cache/root1-ik_root.jpg']);
expect(mockDownloadMessageImage).toHaveBeenCalledWith('root1', 'ik_root');
});

it('extracts img tags from msg_type=post root', async () => {
const postContent = JSON.stringify({
zh_cn: {
title: 'hi',
content: [
[{ tag: 'text', text: 'before' }, { tag: 'img', image_key: 'ik_a' }],
[{ tag: 'img', image_key: 'ik_b' }, { tag: 'text', text: 'after' }],
],
},
});
mockGetMessageById.mockResolvedValue([
{ message_id: 'root2', msg_type: 'post', body: { content: postContent } },
]);

const res = await fetchTopicRootImages('root2');
expect(res.images).toHaveLength(2);
expect(mockDownloadMessageImage).toHaveBeenCalledWith('root2', 'ik_a');
expect(mockDownloadMessageImage).toHaveBeenCalledWith('root2', 'ik_b');
});

it('caps imageKeys at MAX_HISTORY_IMAGES (5)', async () => {
// post 含 7 张图,只取前 5
const content = [
[{ tag: 'img', image_key: 'k1' }, { tag: 'img', image_key: 'k2' }],
[{ tag: 'img', image_key: 'k3' }, { tag: 'img', image_key: 'k4' }],
[{ tag: 'img', image_key: 'k5' }, { tag: 'img', image_key: 'k6' }, { tag: 'img', image_key: 'k7' }],
];
mockGetMessageById.mockResolvedValue([
{ message_id: 'r', msg_type: 'post', body: { content: JSON.stringify({ zh_cn: { content } }) } },
]);

const res = await fetchTopicRootImages('r');
expect(res.images).toHaveLength(5);
expect(mockDownloadMessageImage).toHaveBeenCalledTimes(5);
});

it('skips non-string image_key values defensively', async () => {
const content = [[
{ tag: 'img', image_key: null },
{ tag: 'img', image_key: 123 },
{ tag: 'img' /* missing */ },
{ tag: 'img', image_key: 'good' },
]];
mockGetMessageById.mockResolvedValue([
{ message_id: 'r', msg_type: 'post', body: { content: JSON.stringify({ content }) } },
]);

const res = await fetchTopicRootImages('r');
expect(res.images).toHaveLength(1);
expect(mockDownloadMessageImage).toHaveBeenCalledWith('r', 'good');
});

it('caches result and returns it on second call without re-fetching', async () => {
mockGetMessageById.mockResolvedValue([
{ message_id: 'root1', msg_type: 'image', body: { content: '{"image_key":"ik"}' } },
]);

const first = await fetchTopicRootImages('root1');
const second = await fetchTopicRootImages('root1');

expect(mockGetMessageById).toHaveBeenCalledTimes(1);
expect(mockDownloadMessageImage).toHaveBeenCalledTimes(1);
expect(second.images).toEqual(first.images);
});

it('caches empty sentinel on getMessageById returning empty (negative cache)', async () => {
mockGetMessageById.mockResolvedValue([]);

const first = await fetchTopicRootImages('t_empty');
const second = await fetchTopicRootImages('t_empty');

expect(first.rootMessageId).toBeUndefined();
expect(first.images).toHaveLength(0);
// 关键: 第二次不再 hit Feishu API
expect(mockGetMessageById).toHaveBeenCalledTimes(1);
expect(second.images).toHaveLength(0);
});

it('caches empty sentinel on getMessageById throwing (transient errors should not hammer API)', async () => {
mockGetMessageById.mockRejectedValue(new Error('network'));

const first = await fetchTopicRootImages('t_err');
const second = await fetchTopicRootImages('t_err');

expect(first.images).toHaveLength(0);
expect(mockGetMessageById).toHaveBeenCalledTimes(1);
expect(second.images).toHaveLength(0);
});

it('caches empty result when root message has no images', async () => {
mockGetMessageById.mockResolvedValue([
{ message_id: 'root_text', msg_type: 'text', body: { content: '{"text":"hi"}' } },
]);

await fetchTopicRootImages('root_text');
await fetchTopicRootImages('root_text');

expect(mockGetMessageById).toHaveBeenCalledTimes(1);
});

it('handles malformed body content gracefully', async () => {
mockGetMessageById.mockResolvedValue([
{ message_id: 'r', msg_type: 'image', body: { content: 'not-json' } },
]);

const res = await fetchTopicRootImages('r');
expect(res.images).toHaveLength(0);
});

it('returns partial results when some downloads fail', async () => {
const content = [[{ tag: 'img', image_key: 'ok' }, { tag: 'img', image_key: 'fail' }]];
mockGetMessageById.mockResolvedValue([
{ message_id: 'r', msg_type: 'post', body: { content: JSON.stringify({ content }) } },
]);
mockDownloadMessageImage.mockImplementation(async (_msgId, key) => {
if (key === 'fail') throw new Error('boom');
return imageBuf();
});

const res = await fetchTopicRootImages('r');
expect(res.images).toHaveLength(1);
expect(res.savedPaths).toHaveLength(1);
});
});
75 changes: 63 additions & 12 deletions src/__tests__/lazy-history-images.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
/**
* Tests for lazy loading of parent chat images in history context.
* Tests for downloadHistoryImages — split outputs:
* - Parent-chat images → lazyHints (metadata only)
* - Topic-root images → handled separately by fetchTopicRootImages (excluded here)
* - Pure history images → persisted to cache and returned as historyImagePaths (text-hint only,
* not embedded in multimodal to avoid context pollution)
*
* 与 lazy-history-files.test.ts 对齐:当 buildChatHistoryContext / buildDirectTaskHistory
* 从父群补充消息时,父群中的图片附件不应被自动下载并嵌入 prompt。
Expand All @@ -13,13 +17,22 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

const mockDownloadMessageImage = vi.fn();
const mockSaveMessageFileToCache = vi.fn();

vi.mock('../feishu/client.js', () => ({
feishuClient: {
downloadMessageImage: (...args: unknown[]) => mockDownloadMessageImage(...args),
},
}));

vi.mock('../feishu/file-cache.js', async (importOriginal) => {
const original = await importOriginal();
return {
...original,
saveMessageFileToCache: (...args: unknown[]) => mockSaveMessageFileToCache(...args),
};
});

vi.mock('../utils/logger.js', () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
Expand All @@ -44,13 +57,16 @@ function makeImageBuf(payload = 'fake'): Buffer {
return Buffer.concat([JPEG_PREFIX, Buffer.from(payload)]);
}

describe('downloadHistoryImages with parentMsgCount (lazy loading)', () => {
describe('downloadHistoryImages — text-hint output for pure history', () => {
beforeEach(() => {
vi.clearAllMocks();
mockDownloadMessageImage.mockResolvedValue(makeImageBuf());
mockSaveMessageFileToCache.mockImplementation(async (msgId, imageKey) =>
`/tmp/cache/${msgId}-${imageKey}.jpg`,
);
});

it('downloads all images when parentMsgCount is 0 (default)', async () => {
it('persists all images to cache (text-hint) when parentMsgCount is 0 (default)', async () => {
const messages = [
makeMsg('m1', [{ imageKey: 'ik1' }]),
makeMsg('m2', [{ imageKey: 'ik2' }]),
Expand All @@ -59,7 +75,11 @@ describe('downloadHistoryImages with parentMsgCount (lazy loading)', () => {
const result = await downloadHistoryImages(messages);

expect(mockDownloadMessageImage).toHaveBeenCalledTimes(2);
expect(result.images).toHaveLength(2);
expect(result.historyImagePaths).toHaveLength(2);
expect(result.historyImagePaths).toEqual(expect.arrayContaining([
'/tmp/cache/m1-ik1.jpg',
'/tmp/cache/m2-ik2.jpg',
]));
expect(result.lazyHints).toHaveLength(0);
});

Expand All @@ -77,7 +97,9 @@ describe('downloadHistoryImages with parentMsgCount (lazy loading)', () => {
expect(mockDownloadMessageImage).toHaveBeenCalledTimes(1);
expect(mockDownloadMessageImage).toHaveBeenCalledWith('thread_msg_target_resume', 'ik_target_resume');

expect(result.images).toHaveLength(1);
// 话题图片走 historyImagePaths(文本路径,不进多模态)
expect(result.historyImagePaths).toHaveLength(1);
expect(result.historyImagePaths[0]).toContain('thread_msg_target_resume');

// 父群图片变成元数据
expect(result.lazyHints).toHaveLength(1);
Expand All @@ -95,9 +117,8 @@ describe('downloadHistoryImages with parentMsgCount (lazy loading)', () => {

const result = await downloadHistoryImages(messages, 2);

// 全部父群图片都不下载
expect(mockDownloadMessageImage).not.toHaveBeenCalled();
expect(result.images).toHaveLength(0);
expect(result.historyImagePaths).toHaveLength(0);
expect(result.lazyHints).toHaveLength(2);
});

Expand All @@ -109,7 +130,7 @@ describe('downloadHistoryImages with parentMsgCount (lazy loading)', () => {
const result = await downloadHistoryImages(messages, 0);

expect(mockDownloadMessageImage).toHaveBeenCalledTimes(1);
expect(result.images).toHaveLength(1);
expect(result.historyImagePaths).toHaveLength(1);
expect(result.lazyHints).toHaveLength(0);
});

Expand All @@ -119,7 +140,7 @@ describe('downloadHistoryImages with parentMsgCount (lazy loading)', () => {
const result = await downloadHistoryImages(messages, 1);

expect(mockDownloadMessageImage).not.toHaveBeenCalled();
expect(result.images).toHaveLength(0);
expect(result.historyImagePaths).toHaveLength(0);
expect(result.lazyHints).toHaveLength(0);
});

Expand Down Expand Up @@ -148,8 +169,8 @@ describe('downloadHistoryImages with parentMsgCount (lazy loading)', () => {
// 父群图片仍输出元数据
expect(result.lazyHints).toHaveLength(1);
expect(result.lazyHints[0]).toContain('p1');
// 话题图片下载失败,images 为空
expect(result.images).toHaveLength(0);
// 话题图片下载失败,historyImagePaths 为空
expect(result.historyImagePaths).toHaveLength(0);
});

it('multiple imageRefs in one message are tracked separately by source', async () => {
Expand All @@ -165,8 +186,38 @@ describe('downloadHistoryImages with parentMsgCount (lazy loading)', () => {
expect(result.lazyHints[0]).toContain('ik_p1');
expect(result.lazyHints[1]).toContain('ik_p2');

// 话题图片正常下载
// 话题图片正常下载 + 落盘 → historyImagePaths
expect(mockDownloadMessageImage).toHaveBeenCalledTimes(1);
expect(mockDownloadMessageImage).toHaveBeenCalledWith('thread_msg', 'ik_t1');
expect(result.historyImagePaths).toHaveLength(1);
});

it('excludes topic-root images (those are fetched separately by fetchTopicRootImages)', async () => {
const messages = [
// 话题首条 — 应该被 fetchTopicRootImages 单独处理,此处不重复下载
makeMsg('topic_root_msg', [{ imageKey: 'ik_root' }]),
// 话题中间的其他图片 — 走 historyImagePaths
makeMsg('thread_msg_mid', [{ imageKey: 'ik_mid' }]),
];

const result = await downloadHistoryImages(messages, 0, 'topic_root_msg');

// 话题首条图片不再下载/落盘 (由 fetchTopicRootImages 接管)
expect(mockDownloadMessageImage).toHaveBeenCalledTimes(1);
expect(mockDownloadMessageImage).toHaveBeenCalledWith('thread_msg_mid', 'ik_mid');
expect(result.historyImagePaths).toHaveLength(1);
expect(result.historyImagePaths[0]).toContain('thread_msg_mid');
});

it('falls back gracefully when saveMessageFileToCache throws', async () => {
mockSaveMessageFileToCache.mockRejectedValue(new Error('disk full'));

const messages = [makeMsg('m1', [{ imageKey: 'ik1' }])];

const result = await downloadHistoryImages(messages);

// 下载仍然进行,但落盘失败 → 不计入 historyImagePaths
expect(mockDownloadMessageImage).toHaveBeenCalledTimes(1);
expect(result.historyImagePaths).toHaveLength(0);
});
});
Loading
Loading