Merge branch 'nanin/develop' into develop

This commit is contained in:
Wxw-Gu
2026-08-05 18:44:25 +08:00
37 changed files with 5185 additions and 492 deletions
+259 -4
View File
@@ -25,10 +25,16 @@ describe('export media', () => {
const html = renderExportPage('脱敏导出')
expect(EXPORT_PAGE_SIZE).toBe(240)
expect(html).toContain('<script src="data/messages.js"></script>')
expect(html).toContain("dataScript.src = 'data/messages.js'")
expect(html).toContain('id="archive-loading"')
expect(html).toContain('正在加载聊天档案')
expect(html).toContain('requestAnimationFrame(() => window.setTimeout(loadArchiveData, 0))')
expect(html).toContain('aria-label="聊天时间轴"')
expect(html).toContain('aria-expanded="')
expect(html).toContain('setExpandedTimelineYear')
expect(html).toContain('data-kind="media"')
expect(html).toContain('placeholder="搜索发送者或消息内容…"')
expect(html).toContain('font-size: 16px;')
expect(html).toContain('filtered.slice(windowStart, windowEnd)')
expect(html).toContain('windowStart = Math.max(0, windowEnd - PAGE_SIZE)')
expect(html).toContain('scheduleWindowSlide')
@@ -68,7 +74,7 @@ describe('export media', () => {
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(EXPORT_PAGE_SIZE)
expect(dom.window.document.querySelector('#count')?.textContent).toBe(
'已显示 240 / 筛选结果 500 / 全部 500'
'已显示 240 / 筛选 500 / 全部 500'
)
const list = dom.window.document.querySelector('#messages')!
expect(list.querySelector('.message')?.getAttribute('data-index')).toBe('260')
@@ -90,8 +96,227 @@ describe('export media', () => {
search.value = 'needle'
search.dispatchEvent(new dom.window.Event('input'))
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(5)
expect(dom.window.document.querySelector('#count')?.textContent).toBe('筛选结果 5 / 全部 500')
expect(dom.window.document.querySelector('#count')?.textContent).toContain('筛选 5')
expect(dom.window.document.querySelectorAll('.search-highlight')).toHaveLength(5)
expect(dom.window.document.querySelectorAll('.locate-all')).toHaveLength(5)
expect(dom.window.document.querySelectorAll('.timeline-month').length).toBeGreaterThan(1)
expect(
dom.window.document.querySelectorAll('.timeline-year[aria-expanded="true"]')
).toHaveLength(1)
expect(dom.window.document.querySelectorAll('.timeline-months:not([hidden])')).toHaveLength(1)
dom.window.close()
})
it('does not match hidden sender ids when searching visible message text', () => {
const html = renderExportPage('搜索测试')
const dom = new JSDOM(html, { runScripts: 'outside-only' })
const messages: Message[] = [
{
id: 'hidden-sender-id-match',
from: 'user',
type: '普通文本',
datetime: '',
content: '这条消息不应命中',
name: 'Jamie',
senderId: 'wxid_fixture_member',
isSender: false,
createTime: 1_767_225_600
},
{
id: 'visible-content-match',
from: 'user',
type: '普通文本',
datetime: '',
content: 'https://example.com/xi',
name: 'Cherry',
senderId: 'wxid_fixture_self',
isSender: true,
createTime: 1_767_225_601
}
]
Object.assign(dom.window, {
__WECHAT_EXPORT__: {
version: 1,
sourceId: 'fixture',
name: '搜索测试',
exportedAt: '2026-08-05T00:00:00.000Z',
messages
}
})
dom.window.eval(inlineScriptOf(html))
const search = dom.window.document.querySelector('#query') as HTMLInputElement
search.value = 'xi'
search.dispatchEvent(new dom.window.Event('input'))
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(1)
expect(dom.window.document.querySelector('.message')?.textContent).toContain(
'https://example.com/xi'
)
expect(dom.window.document.querySelectorAll('.search-highlight')).toHaveLength(1)
expect(dom.window.document.querySelector('#count')?.textContent).toBe(
'已显示 1 / 筛选 1 / 全部 2'
)
dom.window.close()
})
it('filters a v2 merged archive by conversation before search and month counts', () => {
const html = renderExportPage('合并档案')
const dom = new JSDOM(html, { runScripts: 'outside-only' })
Object.assign(dom.window, {
__WECHAT_EXPORT__: {
version: 2,
name: '合并档案',
exportedAt: '2026-08-04T00:00:00.000Z',
conversations: [
{ id: 'alpha', name: '聊天 A', type: 'user', messageCount: 2 },
{ id: 'beta', name: '聊天 B', type: 'group', messageCount: 1 }
],
messages: [
messageForArchive('alpha-1', 'alpha', '聊天 A', '共同关键词', 1_767_225_600),
messageForArchive('beta-1', 'beta', '聊天 B', '共同关键词', 1_769_904_000),
messageForArchive('alpha-2', 'alpha', '聊天 A', '仅 A 可见', 1_769_990_400)
]
}
})
dom.window.eval(inlineScriptOf(html))
const filter = dom.window.document.querySelector('#conversation-filter')!
const trigger = dom.window.document.querySelector('#conversation-trigger') as HTMLButtonElement
const menu = dom.window.document.querySelector('#conversation-menu') as HTMLElement
expect(filter.hasAttribute('hidden')).toBe(false)
expect(filter.parentElement?.classList.contains('archive-heading')).toBe(true)
expect((dom.window.document.querySelector('#archive-title') as HTMLElement).hidden).toBe(true)
expect(trigger.textContent).toContain('全部聊天')
expect(menu.querySelectorAll('[data-conversation-id]')).toHaveLength(3)
expect(dom.window.document.querySelectorAll('.conversation-source')).toHaveLength(3)
expect(dom.window.document.querySelector('#count')?.textContent).toBe(
'已显示 3 / 筛选 3 / 全部 3'
)
trigger.click()
;(menu.querySelector('[data-conversation-id="alpha"]') as HTMLButtonElement).click()
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(2)
expect(dom.window.document.querySelectorAll('.conversation-source')).toHaveLength(0)
expect(dom.window.document.querySelector('#count')?.textContent).toBe(
'已显示 2 / 筛选 2 / 当前聊天 2'
)
expect(dom.window.document.querySelectorAll('.timeline-month')).toHaveLength(2)
const search = dom.window.document.querySelector('#query') as HTMLInputElement
search.value = '共同关键词'
search.dispatchEvent(new dom.window.Event('input'))
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(1)
expect(dom.window.document.querySelector('#count')?.textContent).toBe(
'已显示 1 / 筛选 1 / 当前聊天 2'
)
dom.window.close()
})
it('locates every filtered message kind in all messages, including outside the latest window', () => {
const html = renderExportPage('定位消息')
const dom = new JSDOM(html, { runScripts: 'outside-only' })
const categorized: Message[] = [
{
...messageForArchive('target-text', 'fixture', '定位消息', '目标文字', 1),
type: '普通文本'
},
{
...messageForArchive('target-media', 'fixture', '定位消息', '', 2),
type: '图片',
exportMediaType: 'image',
exportMediaUrl: 'media/target.jpg'
},
{
...messageForArchive('target-voice', 'fixture', '定位消息', '', 3),
type: '语音',
voiceDataUrl: 'voices/target.wav'
},
{
...messageForArchive('target-file', 'fixture', '定位消息', '', 4),
type: '文件',
exportMediaType: 'file',
exportMediaUrl: 'files/target.pdf'
},
{
...messageForArchive('target-share', 'fixture', '定位消息', '', 5),
type: '分享',
contentData: { type: 'share', typeVal: '5', title: '目标分享' }
},
{
...messageForArchive('target-system', 'fixture', '定位消息', '目标系统消息', 6),
from: 'system',
type: '系统消息',
contentData: { type: 'system', content: '目标系统消息' }
}
]
const laterMessages = Array.from({ length: EXPORT_PAGE_SIZE }, (_, index) => ({
...messageForArchive(
`later-${index}`,
'fixture',
'定位消息',
`稍后消息-${index}`,
100 + index
),
type: '普通文本'
}))
Object.assign(dom.window, {
__WECHAT_EXPORT__: {
version: 1,
sourceId: 'fixture',
name: '定位消息',
messages: [...categorized, ...laterMessages]
}
})
dom.window.eval(inlineScriptOf(html))
expect(dom.window.document.querySelectorAll('.locate-all')).toHaveLength(0)
for (const kind of ['media', 'voice', 'file', 'share', 'system']) {
const filterButton = dom.window.document.querySelector(`[data-kind="${kind}"]`) as HTMLElement
filterButton.click()
const locateButton = dom.window.document.querySelector('.locate-all') as HTMLElement
expect(locateButton?.getAttribute('aria-label')).toBe('定位到聊天位置')
expect(locateButton?.querySelector('.locate-icon')?.textContent).toBe('⌖')
expect(locateButton?.querySelector('.locate-label')?.textContent).toBe('定位到聊天位置')
locateButton.click()
expect(
dom.window.document.querySelector('[data-kind="all"]')?.classList.contains('active')
).toBe(true)
expect(
dom.window.document.querySelector('.message.located')?.getAttribute('data-index')
).toBe(String(categorized.findIndex((message) => kindOfFixture(message) === kind)))
}
const textFilter = dom.window.document.querySelector('[data-kind="text"]') as HTMLElement
textFilter.click()
const search = dom.window.document.querySelector('#query') as HTMLInputElement
search.value = '目标文字'
search.dispatchEvent(new dom.window.Event('input'))
expect(dom.window.document.querySelector('.search-highlight')?.textContent).toBe('目标文字')
;(dom.window.document.querySelector('.locate-all') as HTMLElement).click()
expect(
dom.window.document.querySelector('[data-kind="all"]')?.classList.contains('active')
).toBe(true)
expect(dom.window.document.querySelector('.message.located')?.getAttribute('data-index')).toBe(
'0'
)
expect(dom.window.document.querySelector('.message.located')?.textContent).toContain('目标文字')
search.value = '稍后消息-137'
search.dispatchEvent(new dom.window.Event('input'))
expect(
dom.window.document.querySelector('[data-kind="all"]')?.classList.contains('active')
).toBe(true)
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(1)
expect(dom.window.document.querySelector('.search-highlight')?.textContent).toBe('稍后消息-137')
;(dom.window.document.querySelector('.locate-all') as HTMLElement).click()
expect(search.value).toBe('')
expect(dom.window.document.querySelectorAll('.search-highlight')).toHaveLength(0)
expect(dom.window.document.querySelectorAll('.locate-all')).toHaveLength(0)
expect(dom.window.document.querySelector('.message.located')?.textContent).toContain(
'稍后消息-137'
)
dom.window.close()
})
@@ -158,6 +383,36 @@ describe('export media', () => {
expect(html).toContain('aria-label="关闭图片预览"')
expect(html).toContain("closeButton.addEventListener('click', closeLightbox)")
expect(html).toContain('if (event.target === box) closeLightbox()')
expect(html).toContain("if (event.key === 'Escape') closeLightbox()")
expect(html).toContain("if (event.key === 'Escape')")
expect(html).toContain('closeLightbox()')
})
})
function messageForArchive(
id: string,
conversationId: string,
conversationName: string,
content: string,
createTime: number
): Message {
return {
id,
from: 'user',
type: '普通文本',
datetime: '',
content,
isSender: false,
createTime,
exportConversationId: conversationId,
exportConversationName: conversationName
}
}
function kindOfFixture(message: Message): string {
if (message.exportMediaType === 'image') return 'media'
if (message.voiceDataUrl) return 'voice'
if (message.exportMediaType === 'file') return 'file'
if (message.contentData?.type === 'share') return 'share'
if (message.contentData?.type === 'system') return 'system'
return 'text'
}
+28
View File
@@ -28,6 +28,34 @@ describe('message parser', () => {
}
})
it.each([
['6', '测试附件.pdf'],
['74', '发送中的附件.zip']
])(
'keeps file app message type %s when attachment metadata contains record tags',
(typeVal, title) => {
const parsed = parseMessageContent(
`<appmsg><type>${typeVal}</type><title>${title}</title><des>1 MB</des><appattach><recorditem>legacy metadata</recorditem><dataitem datatype="8"><datatitle>${title}</datatitle></dataitem></appattach></appmsg>`,
49
)
expect(parsed).toMatchObject({
type: 'share',
title,
typeVal
})
}
)
it('does not classify empty incidental record metadata as a merged forward', () => {
const parsed = parseMessageContent(
'<appmsg><type>5</type><title>普通分享</title><recorditem>legacy metadata</recorditem></appmsg>',
49
)
expect(parsed).toMatchObject({ type: 'share', title: '普通分享', typeVal: '5' })
})
it('uses the quoted group member id instead of the chatroom id', () => {
const parsed = parseMessageContent(
'<appmsg><type>57</type><title>回复内容</title><refermsg><type>1</type><fromusr>123456789@chatroom</fromusr><chatusr>wxid_fixture_member</chatusr><content>被引用内容</content></refermsg></appmsg>',
+78
View File
@@ -0,0 +1,78 @@
import crypto from 'crypto'
import { mkdirSync, mkdtempSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import type { Message } from '../../src/shared/types'
const state = vi.hoisted(() => ({ userData: '' }))
vi.mock('electron', () => ({
app: { getPath: () => state.userData }
}))
const message = (createTime: number, serverId?: string): Message => ({
id: `message-${createTime}`,
from: 'user',
isSender: false,
type: '普通文本',
datetime: new Date(createTime * 1000).toISOString(),
content: String(createTime),
img: '',
name: 'Jamie',
sessionId: 'fixture-user',
localId: 1,
serverId,
createTime
})
describe('recall archive message identity', () => {
beforeAll(() => {
state.userData = mkdtempSync(join(tmpdir(), 'wxe-recall-identity-'))
})
it('does not collapse messages whose local ids repeat across database shards', async () => {
const accountRoot = '/fixture/account'
const sessionMd5 = 'fixture-session'
const archiveDir = join(state.userData, 'recall-archive')
const archiveName = crypto
.createHash('sha1')
.update(`${process.platform}:${accountRoot}`)
.digest('hex')
.slice(0, 16)
mkdirSync(archiveDir, { recursive: true })
writeFileSync(
join(archiveDir, `${archiveName}.json`),
JSON.stringify({
version: 1,
accountRoot,
updatedAt: Date.now(),
sessions: {
[sessionMd5]: {
username: 'fixture-user',
updatedAt: Date.now(),
messages: [],
recalls: []
}
}
})
)
const { configureRecallArchive, mergeRecallArchiveMessages, messageIdentity } =
await import('../../src/main/services/recall-archive-service')
configureRecallArchive(accountRoot)
const oldMessage = message(1_731_327_263)
const newMessage = message(1_765_000_000)
expect(messageIdentity(oldMessage)).not.toBe(messageIdentity(newMessage))
expect(mergeRecallArchiveMessages(sessionMd5, [oldMessage, newMessage])).toEqual([
oldMessage,
newMessage
])
})
it('prefers the globally unique server id when one is available', async () => {
const { messageIdentity } = await import('../../src/main/services/recall-archive-service')
expect(messageIdentity(message(1_731_327_263, 'server-2024'))).toBe('server:server-2024')
})
})
+23 -3
View File
@@ -5,9 +5,16 @@ import { dirname, join, resolve } from 'path'
import { afterAll, describe, expect, it } from 'vitest'
const nodeRequire = createRequire(import.meta.url)
const { validateFfmpegRuntime, validateSherpaRuntime, validateSilkWasmRuntime } = nodeRequire(
'../../scripts/after-pack.cjs'
) as {
const asar = nodeRequire('@electron/asar') as {
createPackage: (source: string, destination: string) => Promise<void>
}
const {
validateAsarRuntimeDependencies,
validateFfmpegRuntime,
validateSherpaRuntime,
validateSilkWasmRuntime
} = nodeRequire('../../scripts/after-pack.cjs') as {
validateAsarRuntimeDependencies: (runtimeResources: string) => void
validateFfmpegRuntime: (runtimeResources: string, platform?: NodeJS.Platform) => void
validateSherpaRuntime: (runtimeResources: string, platform: NodeJS.Platform, arch: string) => void
validateSilkWasmRuntime: (runtimeResources: string) => void
@@ -33,6 +40,19 @@ describe('production runtime packaging', () => {
expect(config).toContain('node_modules/silk-wasm/**')
})
it('rejects an app archive with missing runtime dependencies', async () => {
const resources = join(root, 'asar-resources')
const source = join(root, 'asar-source')
mkdirSync(source, { recursive: true })
writeFileSync(join(source, 'package.json'), '{}')
mkdirSync(resources, { recursive: true })
await asar.createPackage(source, join(resources, 'app.asar'))
expect(() => validateAsarRuntimeDependencies(resources)).toThrow(
/Missing packaged runtime dependencies:.*@electron-toolkit\/utils/
)
})
it('requires and unpacks the bundled ffmpeg-static executable', () => {
const resources = join(root, 'ffmpeg-resources')
const ffmpegPath = join(
+140
View File
@@ -0,0 +1,140 @@
import { createHash } from 'crypto'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import { VideoAssetService } from '../../src/main/video-asset-service'
const temporaryDirectories: string[] = []
const box = (type: string, payload: Buffer): Buffer => {
const header = Buffer.alloc(8)
header.writeUInt32BE(header.length + payload.length, 0)
header.write(type, 4, 4, 'ascii')
return Buffer.concat([header, payload])
}
const mp4Fixture = (durationSeconds: number, marker: string): Buffer => {
const movieHeader = Buffer.alloc(20)
movieHeader.writeUInt32BE(1000, 12)
movieHeader.writeUInt32BE(Math.round(durationSeconds * 1000), 16)
return Buffer.concat([
box('ftyp', Buffer.from('isom0000', 'ascii')),
box('moov', box('mvhd', movieHeader)),
box('mdat', Buffer.from(marker, 'utf8'))
])
}
const jpegFixture = (width: number, height: number): Buffer =>
Buffer.from([
0xff,
0xd8,
0xff,
0xc0,
0x00,
0x11,
0x08,
(height >> 8) & 0xff,
height & 0xff,
(width >> 8) & 0xff,
width & 0xff,
0x03,
0x01,
0x11,
0x00,
0x02,
0x11,
0x00,
0x03,
0x11,
0x00,
0xff,
0xd9
])
const createService = (): {
accountRoot: string
service: VideoAssetService
} => {
const accountRoot = mkdtempSync(join(tmpdir(), 'wxe-video-assets-'))
temporaryDirectories.push(accountRoot)
return {
accountRoot,
service: new VideoAssetService({
getAccountRoot: () => accountRoot,
resolveVideoHardlink: () => null
} as never)
}
}
const monthTimestamp = (year: number, month: number): number =>
Math.floor(new Date(year, month - 1, 15, 12).getTime() / 1000)
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
})
describe('VideoAssetService local fallback', () => {
it('finds a video by its content MD5 when the hardlink mapping is absent', async () => {
const { accountRoot, service } = createService()
const month = join(accountRoot, 'msg', 'video', '2026-07')
mkdirSync(month, { recursive: true })
const content = mp4Fixture(22, 'content-md5-match')
const filePath = join(month, `${'2'.repeat(32)}.mp4`)
writeFileSync(filePath, content)
const contentHash = createHash('md5').update(content).digest('hex')
const result = await service.resolve([contentHash], {
createTime: monthTimestamp(2026, 7)
})
expect(result.success).toBe(true)
expect(service.pathForUrl(result.url!)).toBe(filePath)
})
it('finds a uniquely matching video by month, thumbnail size, and duration', async () => {
const { accountRoot, service } = createService()
const month = join(accountRoot, 'msg', 'video', '2025-11')
mkdirSync(month, { recursive: true })
const stem = '66cecd68e095d87175fb5ed138de3cef'
const filePath = join(month, `${stem}.mp4`)
const posterPath = join(month, `${stem}_thumb.jpg`)
writeFileSync(filePath, mp4Fixture(68.441, 'metadata-match'))
writeFileSync(posterPath, jpegFixture(279, 630))
const result = await service.resolve(
['c92c54c8eae4471be9cc18396daf8015', '021e8a18a765ce14f4c54f40065db98e'],
{
createTime: monthTimestamp(2025, 11),
duration: 68,
width: 279,
height: 630
}
)
expect(result.success).toBe(true)
expect(service.pathForUrl(result.url!)).toBe(filePath)
expect(service.pathForUrl(result.poster!)).toBe(posterPath)
})
it('does not guess when multiple files match the same metadata', async () => {
const { accountRoot, service } = createService()
const month = join(accountRoot, 'msg', 'video', '2026-02')
mkdirSync(month, { recursive: true })
for (const stem of ['a'.repeat(32), 'b'.repeat(32)]) {
writeFileSync(join(month, `${stem}.mp4`), mp4Fixture(12, stem))
writeFileSync(join(month, `${stem}_thumb.jpg`), jpegFixture(224, 398))
}
const result = await service.resolve(['c'.repeat(32)], {
createTime: monthTimestamp(2026, 2),
duration: 12,
width: 224,
height: 398
})
expect(result).toEqual({ success: false, error: '本地未找到该视频文件' })
})
})
+17 -2
View File
@@ -1,9 +1,9 @@
import { describe, expect, it, vi } from 'vitest'
import { Wcdb4Client, type Wcdb4Message } from '../../src/main/wcdb4-client'
const message = (id: string, year: number): Wcdb4Message => ({
const message = (id: string, year: number, serverId = `server-${id}`): Wcdb4Message => ({
mesLocalID: id,
serverId: `server-${id}`,
serverId,
mesDes: 0,
messageType: '1',
msgCreateTime: String(Math.floor(Date.UTC(year, 0, 1) / 1000)),
@@ -12,6 +12,21 @@ const message = (id: string, year: number): Wcdb4Message => ({
})
describe('WCDB message shard pagination', () => {
it('keeps messages whose local ids repeat across database shards', async () => {
const cursor = vi.fn(async () => [
message('1', 2024, 'server-2024'),
message('1', 2025, 'server-2025')
])
const client = Object.assign(Object.create(Wcdb4Client.prototype), {
getMessagesByCursorAsync: cursor
}) as Wcdb4Client
const result = await client.getMessagesAsync('fixture@chatroom')
expect(result).toHaveLength(2)
expect(result.map((item) => item.serverId)).toEqual(['server-2024', 'server-2025'])
})
it('merges cursor and all-store rows for a bounded cross-year page', async () => {
const cursor = vi.fn(async () => [message('2025', 2025)])
const tableScan = vi.fn(async () => [message('2017', 2017), message('2025', 2025)])
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from 'vitest'
import { WechatDb, type WechatMessage } from '../../src/main/wechat-db'
describe('WechatDb normalized messages', () => {
it('keeps normalized identity fields when raw table columns conflict', async () => {
const normalized = {
mesLocalID: '1',
serverId: 'server-1',
mesDes: 0,
messageType: '1',
msgCreateTime: '1731327263',
msgContent: 'fixture',
sender: 'wxid_self',
senderNickname: 'Nanin',
raw: {
mesDes: 1,
sender: '',
senderNickname: ''
}
}
const client = { getMessagesAsync: vi.fn(async () => [normalized]) }
const db = Object.assign(Object.create(WechatDb.prototype), {
wcdb4Client: client,
chatMd5ToUsername: new Map([['fixture-md5', 'fixture-user']]),
ensureChatTableMapping: vi.fn()
}) as WechatDb
const start = Math.floor(new Date(2024, 10, 11).getTime() / 1000)
const end = Math.floor(new Date(2024, 11, 1).getTime() / 1000)
const messages = await db.getUserMessagesForExport('fixture-md5', start, end)
expect(messages[0]).toMatchObject({
mesDes: 0,
sender: 'wxid_self',
senderNickname: 'Nanin'
})
})
it('scans without time bounds, then filters, deduplicates and sorts in application code', async () => {
const row = (id: string, createTime: number): WechatMessage => ({
mesLocalID: id,
serverId: `server-${id}`,
mesDes: 0,
messageType: '1',
msgCreateTime: String(createTime),
msgContent: id,
raw: {}
})
const start = Math.floor(new Date(2025, 0, 1).getTime() / 1000)
const end = Math.floor(new Date(2025, 0, 4).getTime() / 1000)
const shardBoundaryMessage = row('jan-2-boundary', start + 32 * 60 * 60)
const getMessagesAsync = vi.fn(async () => [
row('before-range', start - 1),
row('newest', start + 48 * 60 * 60),
shardBoundaryMessage,
{ ...shardBoundaryMessage },
row('after-range', end + 1)
])
const db = Object.assign(Object.create(WechatDb.prototype), {
wcdb4Client: { getMessagesAsync },
chatMd5ToUsername: new Map([['fixture-md5', 'fixture-user']]),
ensureChatTableMapping: vi.fn()
}) as WechatDb
const messages = await db.getUserMessagesForExport('fixture-md5', start, end)
expect(getMessagesAsync).toHaveBeenCalledOnce()
expect(getMessagesAsync).toHaveBeenCalledWith('fixture-user')
expect(messages.map((message) => message.mesLocalID)).toEqual(['jan-2-boundary', 'newest'])
})
})