mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
399 lines
13 KiB
TypeScript
399 lines
13 KiB
TypeScript
import { afterEach, describe, it } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import * as fs from 'fs'
|
|
import * as os from 'os'
|
|
import * as path from 'path'
|
|
import { PullEngine } from './pull-engine'
|
|
import type { DataSource, DataImporter, FetchParams, HttpFetcher, ImportSession, SyncNotifier } from './types'
|
|
|
|
const tempFiles: string[] = []
|
|
|
|
afterEach(() => {
|
|
for (const file of tempFiles.splice(0)) {
|
|
try {
|
|
if (fs.existsSync(file)) fs.unlinkSync(file)
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
})
|
|
|
|
function writeTempJson(data: unknown): string {
|
|
const file = path.join(os.tmpdir(), `chatlab-pull-engine-test-${process.pid}-${tempFiles.length}.json`)
|
|
fs.writeFileSync(file, JSON.stringify(data), 'utf-8')
|
|
tempFiles.push(file)
|
|
return file
|
|
}
|
|
|
|
async function withImmediateTimers<T>(fn: () => Promise<T>): Promise<T> {
|
|
const originalSetTimeout = globalThis.setTimeout
|
|
;(globalThis as any).setTimeout = (callback: (...args: any[]) => void) => {
|
|
callback()
|
|
return 0
|
|
}
|
|
try {
|
|
return await fn()
|
|
} finally {
|
|
globalThis.setTimeout = originalSetTimeout
|
|
}
|
|
}
|
|
|
|
function createDataSource(): DataSource {
|
|
return {
|
|
id: 'ds_test',
|
|
name: 'Test Source',
|
|
baseUrl: 'http://example.test/api/v1',
|
|
token: '',
|
|
intervalMinutes: 10,
|
|
pullLimit: 1000,
|
|
enabled: true,
|
|
createdAt: 1,
|
|
sessions: [],
|
|
}
|
|
}
|
|
|
|
function createSession(): ImportSession {
|
|
return {
|
|
id: 'is_test',
|
|
name: 'Test Session',
|
|
remoteSessionId: 'remote_session',
|
|
targetSessionId: 'local_session',
|
|
lastPullAt: 100,
|
|
lastStatus: 'idle',
|
|
lastError: '',
|
|
lastNewMessages: 0,
|
|
}
|
|
}
|
|
|
|
function createEngine(options: {
|
|
files: string[]
|
|
importResult: Awaited<ReturnType<DataImporter['importFile']>>
|
|
dataSource: DataSource
|
|
fetchParams?: FetchParams[]
|
|
sessionUpdates?: Array<{ sessionId: string; updates: Partial<ImportSession> }>
|
|
pullResults?: Array<{ status: 'success' | 'error'; detail: string }>
|
|
}): PullEngine {
|
|
const files = [...options.files]
|
|
const fetcher: HttpFetcher = {
|
|
async fetchToTempFile(
|
|
_baseUrl: string,
|
|
_remoteSessionId: string,
|
|
_token: string,
|
|
params: FetchParams
|
|
): Promise<string> {
|
|
options.fetchParams?.push({ ...params })
|
|
const file = files.shift()
|
|
if (!file) throw new Error('Unexpected retry fetch')
|
|
return file
|
|
},
|
|
}
|
|
const importer: DataImporter = {
|
|
sessionExists: () => true,
|
|
importFile: async () => options.importResult,
|
|
}
|
|
const notifier: SyncNotifier = {
|
|
onSessionListChanged: () => {},
|
|
onPullResult: (_sourceId, _sessionId, status, detail) => {
|
|
options.pullResults?.push({ status, detail })
|
|
},
|
|
}
|
|
const dsManager = {
|
|
get: () => options.dataSource,
|
|
updateSession: (_sourceId: string, sessionId: string, updates: Partial<ImportSession>) => {
|
|
options.sessionUpdates?.push({ sessionId, updates })
|
|
},
|
|
}
|
|
|
|
return new PullEngine({
|
|
fetcher,
|
|
importer,
|
|
notifier,
|
|
dsManager: dsManager as any,
|
|
})
|
|
}
|
|
|
|
describe('PullEngine', () => {
|
|
it('imports a small final page instead of treating it as empty', async () => {
|
|
const session = createSession()
|
|
const dataSource = createDataSource()
|
|
dataSource.sessions = [session]
|
|
const smallFinalPage = writeTempJson({
|
|
chatlab: { version: '0.0.2', exportedAt: 100 },
|
|
meta: { name: 'Test Session', platform: 'test', type: 'group' },
|
|
members: [{ platformId: 'u1', accountName: 'Alice' }],
|
|
messages: [{ sender: 'u1', timestamp: 101, type: 0, content: 'hi' }],
|
|
sync: { hasMore: false, nextSince: 101 },
|
|
})
|
|
let importCount = 0
|
|
const engine = createEngine({
|
|
files: [smallFinalPage],
|
|
dataSource,
|
|
importResult: {
|
|
success: true,
|
|
newMessageCount: 1,
|
|
sessionId: session.targetSessionId,
|
|
},
|
|
})
|
|
;(engine as any).importer.importFile = async () => {
|
|
importCount++
|
|
return { success: true, newMessageCount: 1, sessionId: session.targetSessionId }
|
|
}
|
|
|
|
const result = await withImmediateTimers(() => engine.executePullSession(dataSource.id, dataSource, session))
|
|
|
|
assert.equal(result.success, true)
|
|
assert.equal(result.newMessageCount, 1)
|
|
assert.equal(importCount, 1)
|
|
})
|
|
|
|
it('reports retry import failure instead of marking the pull successful', async () => {
|
|
const session = createSession()
|
|
const dataSource = createDataSource()
|
|
dataSource.sessions = [session]
|
|
const emptyInitialPage = writeTempJson({
|
|
chatlab: { version: '0.0.2', exportedAt: 100 },
|
|
meta: { name: 'Test Session', platform: 'test', type: 'group' },
|
|
members: [],
|
|
messages: [],
|
|
sync: { hasMore: false, nextSince: 100 },
|
|
})
|
|
const retryPage = writeTempJson({
|
|
chatlab: { version: '0.0.2', exportedAt: 100 },
|
|
meta: { name: 'Test Session', platform: 'test', type: 'group' },
|
|
members: [{ platformId: 'u1', accountName: 'Alice' }],
|
|
messages: [
|
|
{
|
|
sender: 'u1',
|
|
timestamp: 101,
|
|
type: 0,
|
|
content: 'x'.repeat(1200),
|
|
},
|
|
],
|
|
sync: { hasMore: false, nextSince: 101 },
|
|
})
|
|
const emptyTerminalPage = writeTempJson({
|
|
chatlab: { version: '0.0.2', exportedAt: 100 },
|
|
meta: { name: 'Test Session', platform: 'test', type: 'group' },
|
|
members: [],
|
|
messages: [],
|
|
sync: { hasMore: false, nextSince: 101 },
|
|
})
|
|
const emptyRetryPage1 = writeTempJson({
|
|
chatlab: { version: '0.0.2', exportedAt: 100 },
|
|
meta: { name: 'Test Session', platform: 'test', type: 'group' },
|
|
members: [],
|
|
messages: [],
|
|
sync: { hasMore: false, nextSince: 101 },
|
|
})
|
|
const emptyRetryPage2 = writeTempJson({
|
|
chatlab: { version: '0.0.2', exportedAt: 100 },
|
|
meta: { name: 'Test Session', platform: 'test', type: 'group' },
|
|
members: [],
|
|
messages: [],
|
|
sync: { hasMore: false, nextSince: 101 },
|
|
})
|
|
const emptyRetryPage3 = writeTempJson({
|
|
chatlab: { version: '0.0.2', exportedAt: 100 },
|
|
meta: { name: 'Test Session', platform: 'test', type: 'group' },
|
|
members: [],
|
|
messages: [],
|
|
sync: { hasMore: false, nextSince: 101 },
|
|
})
|
|
const sessionUpdates: Array<{ sessionId: string; updates: Partial<ImportSession> }> = []
|
|
const pullResults: Array<{ status: 'success' | 'error'; detail: string }> = []
|
|
const engine = createEngine({
|
|
files: [emptyInitialPage, retryPage, emptyTerminalPage, emptyRetryPage1, emptyRetryPage2, emptyRetryPage3],
|
|
dataSource,
|
|
sessionUpdates,
|
|
pullResults,
|
|
importResult: {
|
|
success: false,
|
|
newMessageCount: 0,
|
|
sessionId: session.targetSessionId,
|
|
error: 'retry import failed',
|
|
},
|
|
})
|
|
const result = await withImmediateTimers(() => engine.executePullSession(dataSource.id, dataSource, session))
|
|
|
|
assert.equal(result.success, false)
|
|
assert.equal(result.error, 'retry import failed')
|
|
assert.equal(pullResults.at(-1)?.status, 'error')
|
|
assert.equal(sessionUpdates.at(-1)?.updates.lastStatus, 'error')
|
|
})
|
|
|
|
it('continues pagination with nextOffset when nextSince is absent', async () => {
|
|
const session = createSession()
|
|
session.lastPullAt = 0
|
|
const dataSource = createDataSource()
|
|
dataSource.sessions = [session]
|
|
const firstPage = writeTempJson({
|
|
chatlab: { version: '0.0.2', exportedAt: 100 },
|
|
meta: { name: 'Test Session', platform: 'test', type: 'group' },
|
|
members: [{ platformId: 'u1', accountName: 'Alice' }],
|
|
messages: [
|
|
{ sender: 'u1', timestamp: 100, type: 0, content: 'page 1a' },
|
|
{ sender: 'u1', timestamp: 101, type: 0, content: 'page 1b' },
|
|
],
|
|
sync: { hasMore: true, nextOffset: 2, watermark: 200 },
|
|
})
|
|
const secondPage = writeTempJson({
|
|
chatlab: { version: '0.0.2', exportedAt: 100 },
|
|
meta: { name: 'Test Session', platform: 'test', type: 'group' },
|
|
members: [{ platformId: 'u1', accountName: 'Alice' }],
|
|
messages: [{ sender: 'u1', timestamp: 200, type: 0, content: 'page 2' }],
|
|
sync: { hasMore: false, watermark: 200 },
|
|
})
|
|
const fetchParams: FetchParams[] = []
|
|
const engine = createEngine({
|
|
files: [firstPage, secondPage],
|
|
dataSource,
|
|
fetchParams,
|
|
importResult: {
|
|
success: true,
|
|
newMessageCount: 1,
|
|
sessionId: session.targetSessionId,
|
|
},
|
|
})
|
|
|
|
const result = await withImmediateTimers(() => engine.executePullSession(dataSource.id, dataSource, session))
|
|
|
|
assert.equal(result.success, true)
|
|
assert.equal(fetchParams.length, 2)
|
|
assert.equal(fetchParams[0]?.offset, undefined)
|
|
assert.equal(fetchParams[1]?.offset, 2)
|
|
})
|
|
|
|
it('persists the imported message cursor with overlap instead of the wall clock', async () => {
|
|
const session = createSession()
|
|
session.lastPullAt = 0
|
|
const dataSource = createDataSource()
|
|
dataSource.sessions = [session]
|
|
const page = writeTempJson({
|
|
chatlab: { version: '0.0.2', exportedAt: 100 },
|
|
meta: { name: 'Test Session', platform: 'test', type: 'group' },
|
|
members: [{ platformId: 'u1', accountName: 'Alice' }],
|
|
messages: [{ sender: 'u1', timestamp: 2000, type: 0, content: 'latest imported message' }],
|
|
sync: { hasMore: false, watermark: 999999 },
|
|
})
|
|
const sessionUpdates: Array<{ sessionId: string; updates: Partial<ImportSession> }> = []
|
|
const engine = createEngine({
|
|
files: [page],
|
|
dataSource,
|
|
sessionUpdates,
|
|
importResult: {
|
|
success: true,
|
|
newMessageCount: 1,
|
|
sessionId: session.targetSessionId,
|
|
},
|
|
})
|
|
|
|
const result = await withImmediateTimers(() => engine.executePullSession(dataSource.id, dataSource, session))
|
|
|
|
assert.equal(result.success, true)
|
|
assert.equal(sessionUpdates.at(-1)?.updates.lastPullAt, 1940)
|
|
})
|
|
|
|
it('persists terminal retry nextSince when it is newer than imported messages', async () => {
|
|
const session = createSession()
|
|
session.lastPullAt = 0
|
|
const dataSource = createDataSource()
|
|
dataSource.sessions = [session]
|
|
const emptyInitialPage = writeTempJson({
|
|
chatlab: { version: '0.0.2', exportedAt: 100 },
|
|
meta: { name: 'Test Session', platform: 'test', type: 'group' },
|
|
members: [],
|
|
messages: [],
|
|
sync: { hasMore: false, nextSince: 100 },
|
|
})
|
|
const terminalRetryPage = writeTempJson({
|
|
chatlab: { version: '0.0.2', exportedAt: 100 },
|
|
meta: { name: 'Test Session', platform: 'test', type: 'group' },
|
|
members: [{ platformId: 'u1', accountName: 'Alice' }],
|
|
messages: [{ sender: 'u1', timestamp: 2000, type: 0, content: 'late retry message' }],
|
|
sync: { hasMore: false, nextSince: 5000 },
|
|
})
|
|
const sessionUpdates: Array<{ sessionId: string; updates: Partial<ImportSession> }> = []
|
|
const engine = createEngine({
|
|
files: [emptyInitialPage, terminalRetryPage],
|
|
dataSource,
|
|
sessionUpdates,
|
|
importResult: {
|
|
success: true,
|
|
newMessageCount: 1,
|
|
sessionId: session.targetSessionId,
|
|
},
|
|
})
|
|
|
|
const result = await withImmediateTimers(() => engine.executePullSession(dataSource.id, dataSource, session))
|
|
|
|
assert.equal(result.success, true)
|
|
assert.equal(sessionUpdates.at(-1)?.updates.lastPullAt, 4940)
|
|
})
|
|
|
|
it('persists terminal retry nextSince from empty retry pages', async () => {
|
|
const session = createSession()
|
|
session.lastPullAt = 0
|
|
const dataSource = createDataSource()
|
|
dataSource.sessions = [session]
|
|
const emptyPages = Array.from({ length: 4 }, () =>
|
|
writeTempJson({
|
|
chatlab: { version: '0.0.2', exportedAt: 100 },
|
|
meta: { name: 'Test Session', platform: 'test', type: 'group' },
|
|
members: [],
|
|
messages: [],
|
|
sync: { hasMore: false, nextSince: 5000 },
|
|
})
|
|
)
|
|
const sessionUpdates: Array<{ sessionId: string; updates: Partial<ImportSession> }> = []
|
|
const engine = createEngine({
|
|
files: emptyPages,
|
|
dataSource,
|
|
sessionUpdates,
|
|
importResult: {
|
|
success: true,
|
|
newMessageCount: 0,
|
|
sessionId: session.targetSessionId,
|
|
},
|
|
})
|
|
|
|
const result = await withImmediateTimers(() => engine.executePullSession(dataSource.id, dataSource, session))
|
|
|
|
assert.equal(result.success, true)
|
|
assert.equal(result.newMessageCount, 0)
|
|
assert.equal(sessionUpdates.at(-1)?.updates.lastPullAt, 4940)
|
|
})
|
|
|
|
it('keeps the pull cursor stable when a successful pull returns no new cursor', async () => {
|
|
const session = createSession()
|
|
session.lastPullAt = 100
|
|
const dataSource = createDataSource()
|
|
dataSource.sessions = [session]
|
|
const emptyPages = Array.from({ length: 4 }, () =>
|
|
writeTempJson({
|
|
chatlab: { version: '0.0.2', exportedAt: 100 },
|
|
meta: { name: 'Test Session', platform: 'test', type: 'group' },
|
|
members: [],
|
|
messages: [],
|
|
sync: { hasMore: false },
|
|
})
|
|
)
|
|
const sessionUpdates: Array<{ sessionId: string; updates: Partial<ImportSession> }> = []
|
|
const engine = createEngine({
|
|
files: emptyPages,
|
|
dataSource,
|
|
sessionUpdates,
|
|
importResult: {
|
|
success: true,
|
|
newMessageCount: 0,
|
|
sessionId: session.targetSessionId,
|
|
},
|
|
})
|
|
|
|
const result = await withImmediateTimers(() => engine.executePullSession(dataSource.id, dataSource, session))
|
|
|
|
assert.equal(result.success, true)
|
|
assert.equal(result.newMessageCount, 0)
|
|
assert.equal(sessionUpdates.at(-1)?.updates.lastPullAt, 100)
|
|
})
|
|
})
|