cc-haha/adapters/common/__tests__/session-recovery.test.ts
程序员阿江(Relakkes) 49481123f5 fix: recover stale IM session bindings
IM adapters could keep in-process bridge state after desktop session deletion removed the persisted mapping or after the server no longer had the mapped session. The restore path now validates the stored mapping against bridge memory and the server session before reusing it, then clears stale transient state before creating a replacement session.

Constraint: Issue #574 is intermittent and tied to adapter-created sessions; live reporter Telegram credentials are not available locally.
Rejected: Keep separate recovery checks in each IM adapter | duplicates the same stale-state logic across Telegram, Feishu, WeChat, and DingTalk.
Confidence: high
Scope-risk: moderate
Directive: Do not use bridge.hasSession() as the sole restore check; verify adapter-sessions state and server session existence first.
Tested: bun run check:adapters (364 pass, 0 fail)
Tested: cd adapters && bunx tsc --noEmit
Tested: bun test src/server/__tests__/sessions.test.ts src/server/__tests__/websocket-handler.test.ts --timeout 30000 (110 pass, 0 fail)
Tested: git diff --check
Not-tested: Live Telegram bot/provider reproduction on the reporter machine
2026-05-25 16:09:31 +08:00

164 lines
4.7 KiB
TypeScript

import { describe, expect, it } from 'bun:test'
import { restoreStoredSessionBinding } from '../session-recovery.js'
import type { SessionEntry } from '../session-store.js'
function makeStore(initial: SessionEntry | null) {
let entry = initial
return {
get() {
return entry
},
delete() {
entry = null
},
current() {
return entry
},
}
}
function makeBridge(options?: { currentSessionId?: string | null; open?: boolean; hasSession?: boolean }) {
const calls: string[] = []
const bridge = {
calls,
connectSession(_chatId: string, sessionId: string) {
calls.push(`connect:${sessionId}`)
return true
},
getSessionId() {
return options?.currentSessionId ?? null
},
hasSession() {
return options?.hasSession ?? Boolean(options?.currentSessionId)
},
isSessionOpen(_chatId: string, sessionId?: string) {
return Boolean(options?.open && (!sessionId || sessionId === options?.currentSessionId))
},
onServerMessage() {
calls.push('handler')
},
resetSession() {
calls.push('reset')
},
waitForOpen() {
calls.push('wait')
return Promise.resolve(true)
},
}
return bridge
}
describe('restoreStoredSessionBinding', () => {
it('resets stale bridge memory when server-side delete removed the stored mapping', async () => {
const store = makeStore(null)
const bridge = makeBridge({ currentSessionId: 'deleted-session', hasSession: true })
let cleared = 0
const restored = await restoreStoredSessionBinding({
chatId: 'chat-1',
bridge,
sessionStore: store,
httpClient: { sessionExists: async () => true },
onServerMessage: () => {},
logPrefix: '[Test]',
clearTransientState: () => {
cleared += 1
},
})
expect(restored).toBeNull()
expect(bridge.calls).toEqual(['reset'])
expect(cleared).toBe(1)
})
it('drops a persisted mapping when the server no longer has that session', async () => {
const entry = { sessionId: 'deleted-session', workDir: '/repo', updatedAt: 1 }
const store = makeStore(entry)
const bridge = makeBridge()
let cleared = 0
const restored = await restoreStoredSessionBinding({
chatId: 'chat-1',
bridge,
sessionStore: store,
httpClient: { sessionExists: async () => false },
onServerMessage: () => {},
logPrefix: '[Test]',
clearTransientState: () => {
cleared += 1
},
})
expect(restored).toBeNull()
expect(store.current()).toBeNull()
expect(bridge.calls).toEqual([])
expect(cleared).toBe(1)
})
it('resets bridge memory when it points at a different session than the store', async () => {
const entry = { sessionId: 'stored-session', workDir: '/repo', updatedAt: 1 }
const store = makeStore(entry)
const bridge = makeBridge({ currentSessionId: 'stale-session', hasSession: true })
let cleared = 0
const restored = await restoreStoredSessionBinding({
chatId: 'chat-1',
bridge,
sessionStore: store,
httpClient: { sessionExists: async () => true },
onServerMessage: () => {},
logPrefix: '[Test]',
clearTransientState: () => {
cleared += 1
},
})
expect(restored).toEqual(entry)
expect(bridge.calls).toEqual(['reset', 'connect:stored-session', 'handler', 'wait'])
expect(cleared).toBe(1)
})
it('reuses an already-open bridge for the stored session', async () => {
const entry = { sessionId: 'live-session', workDir: '/repo', updatedAt: 1 }
const store = makeStore(entry)
const bridge = makeBridge({ currentSessionId: 'live-session', open: true })
let checked = 0
const restored = await restoreStoredSessionBinding({
chatId: 'chat-1',
bridge,
sessionStore: store,
httpClient: {
sessionExists: async () => {
checked += 1
return true
},
},
onServerMessage: () => {},
logPrefix: '[Test]',
})
expect(restored).toEqual(entry)
expect(checked).toBe(0)
expect(bridge.calls).toEqual([])
})
it('connects to an existing stored session after validating it still exists', async () => {
const entry = { sessionId: 'stored-session', workDir: '/repo', updatedAt: 1 }
const store = makeStore(entry)
const bridge = makeBridge()
const restored = await restoreStoredSessionBinding({
chatId: 'chat-1',
bridge,
sessionStore: store,
httpClient: { sessionExists: async () => true },
onServerMessage: () => {},
logPrefix: '[Test]',
})
expect(restored).toEqual(entry)
expect(bridge.calls).toEqual(['connect:stored-session', 'handler', 'wait'])
})
})