Files
goodbuddy/src/main/channels/dingtalk-channel-driver.test.ts
T
mesalogo d80d42b032 feat(storage): move desktop SQLite ownership into a storage utility process
Main no longer opens desktop business SQLite. A single utility-process
storage owner holds the Assistant, Knowledge, attachment, document-result,
remote-binding and native model-ledger databases and runs whole domain
transactions; Main and Runtime callers use explicit async domain operations.

- Remove the Main readWithFallback path and the supervision worker's
  separate business write connection; manifests are scanned read-only and
  committed by the sole writer.
- Remote events are ACKed only after commit; process loss reports
  unconfirmed writes instead of replaying them.
- Use native utilityProcess messaging with one in-flight bound (32) and
  ordered waiting instead of capacity rejection and retries.
- Start the storage process before the window so startup overlaps.
- Keep short tool output in memory and store full output once in owned
  storage; paging, branching, restart and deletion reuse that copy.
- Put required runtime launch files under goodbuddy-prefixed owned temp
  directories and reclaim confirmed stale ones; stop writing duplicate
  parsed.md.
- Add lint guards for SQLite ownership and architecture, concurrency
  acceptance fixtures, and maintenance/architecture documentation.

Validation: typecheck and lint pass. Alternating baseline (edf019aa) vs
candidate runs show no regression on single-session, C01, C02, C04 and a
5-minute C06; Main p99 drops from ~21 ms to ~16.7 ms under concurrency.
Fresh portable copy (1.75M events) upgrade content unchanged; real-model
output paging passes with 4 external requests. Full npm test runs had
1-9 different timing-sensitive failures per run that pass when rerun.
Not covered: C03 review publication, C07 source body, app-level C05.
2026-10-06 15:40:18 +08:00

285 lines
9.1 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import { createServer } from 'node:http'
import { AssistantDatabase } from '../assistant/assistant-database'
import { ChannelService } from './channel-service'
import { SqliteChannelOutbox } from './sqlite-channel-state'
import {
DingTalkChannelDriver,
createOfficialDingTalkTransportFactory
} from './dingtalk-channel-driver'
import type {
DingTalkStreamEnvelope,
DingTalkStreamTransport,
DingTalkTransportFactory
} from './dingtalk-driver'
const SESSION_WEBHOOK =
'https://oapi.dingtalk.com/robot/sendBySession?session=opaque'
class FakeTransport implements DingTalkStreamTransport {
listener?: (envelope: DingTalkStreamEnvelope) => Promise<void>
readonly stop = vi.fn(async () => undefined)
readonly replyText = vi.fn(async () => undefined)
async start(
listener: (envelope: DingTalkStreamEnvelope) => Promise<void>
): Promise<void> {
this.listener = listener
}
}
function envelope(
messageId = 'event-1',
conversationType = '2'
): DingTalkStreamEnvelope {
return {
headers: { messageId },
data: JSON.stringify({
conversationId: 'conversation-1',
conversationType,
createAt: 1_800_000_000_000,
isInAtList: conversationType === '2',
msgId: 'provider-1',
msgtype: 'text',
senderStaffId: 'USER-1',
sessionWebhook: SESSION_WEBHOOK,
sessionWebhookExpiredTime: 4_000_000_000_000,
text: { content: '请总结进展' }
})
}
}
describe('DingTalkChannelDriver', () => {
it('retries an HTTP 200 business failure through the production transport and SQLite outbox', async () => {
const requests: string[] = []
const server = createServer(async (request, response) => {
let body = ''
for await (const chunk of request) body += String(chunk)
requests.push(body)
response.setHeader('content-type', 'application/json')
response.end(JSON.stringify(requests.length === 1
? { errcode: 130101, errmsg: 'synthetic rate limit' }
: { errcode: 0, errmsg: 'ok' }))
})
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const address = server.address() as { port: number }
const database = new AssistantDatabase(':memory:')
database.initialize('C:\\Workspace')
const outbox = new SqliteChannelOutbox(database)
let receive!: (message: { headers: { messageId: string }; data: string }) => void
const driver = new DingTalkChannelDriver({
clientId: 'client-id', clientSecret: 'synthetic-secret', allowedSenderIds: ['user-1'],
transportFactory: createOfficialDingTalkTransportFactory({
clientFactory: async () => ({
registerCallbackListener: (_topic, listener) => { receive = listener },
socketCallBackResponse: () => undefined,
connect: async () => undefined, disconnect: () => undefined
}),
fetchImpl: (_input, init) => fetch(`http://127.0.0.1:${address.port}/reply`, init)
})
})
const executor = vi.fn(async () => ({ status: 'completed', output: 'synthetic reply' }))
const failure = vi.fn()
const service = new ChannelService(driver, executor, {
allowedSenderIds: ['user-1'], allowGroupMessages: true, outbox, onDeliveryFailure: failure
})
try {
await service.start()
receive(envelope())
await vi.waitFor(() => expect(failure).toHaveBeenCalledOnce())
expect(failure.mock.calls[0]?.[0]).toMatchObject({ message: '钉钉回复请求失败 (130101)' })
expect((await outbox.listUndelivered())[0]).toMatchObject({ state: 'failed', attempts: 1 })
await vi.waitFor(() => expect(requests).toHaveLength(2), { timeout: 3000 })
await vi.waitFor(async () => expect(await outbox.listUndelivered()).toEqual([]))
expect(requests[1]).toBe(requests[0])
expect(executor).toHaveBeenCalledOnce()
} finally {
await service.stop()
database.close()
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
}
})
it.each(['', '{}', 'null', 'not JSON', JSON.stringify({ errcode: 0, padding: 'x'.repeat(65_536) })])(
'rejects invalid or oversized successful HTTP responses (%#)', async (body) => {
const transport = await createOfficialDingTalkTransportFactory({
fetchImpl: async () => new Response(body)
}).create({ clientId: 'synthetic', clientSecret: 'synthetic' })
await expect(transport.replyText(SESSION_WEBHOOK, 'reply')).rejects.toThrow()
}
)
it('adapts group text and consumes only the issued reply context', async () => {
const transport = new FakeTransport()
const factory: DingTalkTransportFactory = {
create: async () => transport
}
const driver = new DingTalkChannelDriver({
clientId: 'client-id',
clientSecret: 'client-secret',
allowedSenderIds: ['user-1'],
transportFactory: factory
})
const messages: unknown[] = []
await driver.start((message) => {
messages.push(message)
})
await transport.listener?.(envelope())
expect(messages).toEqual([
{
channel: 'dingtalk',
accountId: 'client-id',
eventId: 'event-1',
senderId: 'user-1',
conversationId: 'conversation-1',
conversationType: 'group',
text: '请总结进展',
mentioned: true,
receivedAt: 1_800_000_000_000
}
])
await driver.send(
{
channel: 'dingtalk',
eventId: 'event-1',
conversationId: 'conversation-1',
recipientId: 'user-1',
status: 'completed',
output: '已完成'
},
new AbortController().signal
)
expect(transport.replyText).toHaveBeenCalledWith(
SESSION_WEBHOOK,
'已完成'
)
await expect(
driver.send(
{
channel: 'dingtalk',
eventId: 'event-1',
conversationId: 'conversation-1',
recipientId: 'user-1',
status: 'completed',
output: '重复回复'
},
new AbortController().signal
)
).rejects.toThrow('上下文无效')
})
it('acks official Stream callbacks before asynchronous processing', async () => {
const order: string[] = []
let listener:
| ((message: {
headers: { messageId: string }
data: string
}) => void)
| undefined
const client = {
registerCallbackListener: vi.fn(
(
_topic: string,
value: (message: {
headers: { messageId: string }
data: string
}) => void
) => {
listener = value
}
),
socketCallBackResponse: vi.fn(() => {
order.push('ack')
}),
connect: vi.fn(async () => undefined),
disconnect: vi.fn()
}
const fetchImpl = vi.fn(async () => Response.json({ errcode: 0, errmsg: 'ok' }))
const factory = createOfficialDingTalkTransportFactory({
clientFactory: async (credentials) => {
expect(credentials).toEqual({
clientId: 'client-id',
clientSecret: 'client-secret'
})
return client
},
fetchImpl
})
const transport = await factory.create({
clientId: 'client-id',
clientSecret: 'client-secret'
})
await transport.start(async () => {
order.push('processed')
})
listener?.({
headers: { messageId: 'stream-1' },
data: '{}'
})
expect(order).toEqual(['ack'])
await vi.waitFor(() => {
expect(order).toEqual(['ack', 'processed'])
})
await transport.replyText(SESSION_WEBHOOK, '安全回复')
expect(fetchImpl).toHaveBeenCalledWith(
SESSION_WEBHOOK,
expect.objectContaining({
method: 'POST',
redirect: 'error'
})
)
expect(client.registerCallbackListener).toHaveBeenCalledWith(
'/v1.0/im/bot/messages/get',
expect.any(Function)
)
expect(client.socketCallBackResponse).toHaveBeenCalledWith(
'stream-1',
{ status: 'SUCCESS' }
)
})
it('rejects unsupported attachments without consuming the reply context', async () => {
const transport = new FakeTransport()
const driver = new DingTalkChannelDriver({
clientId: 'client-id',
clientSecret: 'client-secret',
allowedSenderIds: ['user-1'],
transportFactory: {
create: async () => transport
}
})
await driver.start(() => undefined)
await transport.listener?.(envelope('media-event'))
const message = {
channel: 'dingtalk' as const,
eventId: 'media-event',
conversationId: 'conversation-1',
recipientId: 'user-1',
status: 'completed',
output: '文件已生成',
attachments: [
{
name: 'result.txt',
mimeType: 'text/plain',
size: 2,
kind: 'file' as const,
dataBase64: 'b2s='
}
]
}
await expect(
driver.send(message, new AbortController().signal)
).rejects.toThrow('暂不支持发送附件')
await driver.send(
{ ...message, attachments: undefined },
new AbortController().signal
)
expect(transport.replyText).toHaveBeenCalledOnce()
await driver.stop()
})
})