Files
WechatExplorer/tests/unit/wechat-action-gateway.test.ts
T

508 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { mkdtempSync, readJsonSync, rmSync } from 'fs-extra'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
capability: { getPersonalWechatSendCapability: vi.fn() },
sender: { send: vi.fn() }
}))
vi.mock('electron', () => ({ app: { getPath: () => '/tmp/tracememo-gateway-default' } }))
vi.mock('../../src/main/services/personal-wechat-capability-service', () => ({
personalWechatCapabilityService: mocks.capability
}))
vi.mock('../../src/main/services/personal-wechat-send-service', () => ({
personalWechatSendService: mocks.sender
}))
import {
AUTOMATION_SEND_INTERVAL_MS,
WechatActionGateway,
toPersonalWechatSendResult
} from '../../src/main/services/wechat-action-gateway'
import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat'
import type { WechatActionRequest, WechatActionResult } from '../../src/shared/wechat-action'
const readyCapability: PersonalWechatSendCapability = {
supported: true,
ready: true,
status: 'ready',
capabilities: { text: true, image: true, voice: true },
senderStatus: {} as never,
message: 'ready'
}
describe('WechatActionGateway', () => {
const directories: string[] = []
beforeEach(() => {
mocks.capability.getPersonalWechatSendCapability.mockReset().mockResolvedValue(readyCapability)
mocks.sender.send.mockReset().mockResolvedValue({ success: true, status: {} })
})
afterEach(() => {
while (directories.length) rmSync(directories.pop()!, { recursive: true, force: true })
})
function createGateway(): WechatActionGateway {
const userData = mkdtempSync(join(tmpdir(), 'tracememo-wechat-action-'))
directories.push(userData)
return new WechatActionGateway({ getUserDataPath: () => userData })
}
function memberAction(
gateway: WechatActionGateway,
roomId = 'room@chatroom'
): Promise<WechatActionResult> {
gateway.registerMemberEvent({ id: 'event-1', roomId: 'room@chatroom' })
return gateway.execute({
origin: 'member_monitor',
purpose: 'member_left_notification',
triggerType: 'automation',
sourceId: 'event-1',
recipient: { type: 'group', id: roomId },
content: { type: 'text', text: '张三已退出群聊' }
})
}
it('allows a valid member action, sends once, and writes an audit record', async () => {
const userData = mkdtempSync(join(tmpdir(), 'tracememo-wechat-action-'))
directories.push(userData)
const gateway = new WechatActionGateway({ getUserDataPath: () => userData })
const result = await memberAction(gateway)
expect(result).toMatchObject({ status: 'sent', decision: 'allow' })
expect(mocks.sender.send).toHaveBeenCalledOnce()
expect(mocks.sender.send).toHaveBeenCalledWith({
type: 'text',
to: 'room@chatroom',
isGroup: true,
text: '张三已退出群聊'
})
const audit = readJsonSync(join(userData, 'actions', 'wechat-actions.json'))
expect(audit).toEqual([
expect.objectContaining({
purpose: 'member_left_notification',
recipientId: 'room@chatroom',
sendStatus: 'sent',
decision: 'allow',
contentPreview: '张三已退出群聊'
})
])
})
it('deduplicates the same member event across repeated execution calls', async () => {
const gateway = createGateway()
const first = await memberAction(gateway)
const second = await memberAction(gateway)
expect(second.actionId).toBe(first.actionId)
expect(mocks.sender.send).toHaveBeenCalledOnce()
})
it('blocks a recipient outside the source group', async () => {
const gateway = createGateway()
const result = await memberAction(gateway, 'other@chatroom')
expect(result).toMatchObject({
status: 'blocked',
decision: 'block',
errorCode: 'RECIPIENT_SCOPE_VIOLATION'
})
expect(mocks.sender.send).not.toHaveBeenCalled()
})
it('blocks unknown automation purposes before checking capability', async () => {
const gateway = createGateway()
const result = await gateway.execute({
origin: 'unknown',
purpose: 'arbitrary_message',
triggerType: 'automation',
recipient: { type: 'group', id: 'room@chatroom' },
content: { type: 'text', text: '不应自动发送' }
})
expect(result).toMatchObject({
status: 'blocked',
decision: 'block',
errorCode: 'ACTION_NOT_ALLOWED'
})
expect(mocks.capability.getPersonalWechatSendCapability).not.toHaveBeenCalled()
expect(mocks.sender.send).not.toHaveBeenCalled()
})
it('returns INVALID_REQUEST for malformed input without throwing from audit handling', async () => {
const gateway = createGateway()
const result = await gateway.execute({
origin: 'member_monitor',
purpose: 'member_left_notification',
triggerType: 'automation',
recipient: { type: 'group', id: 'room@chatroom' }
} as never)
expect(result).toMatchObject({
status: 'blocked',
decision: 'block',
errorCode: 'INVALID_REQUEST'
})
})
it('returns INVALID_RECIPIENT when the recipient id is missing', async () => {
const gateway = createGateway()
const result = await gateway.execute({
origin: 'member_monitor',
purpose: 'member_left_notification',
triggerType: 'automation',
sourceId: 'event-1',
recipient: { type: 'group', id: '' },
content: { type: 'text', text: '张三已退出群聊' }
})
expect(result).toMatchObject({
status: 'blocked',
decision: 'block',
errorCode: 'INVALID_RECIPIENT'
})
expect(mocks.sender.send).not.toHaveBeenCalled()
})
it('does not accept an event lookup for a different source id', async () => {
const userData = mkdtempSync(join(tmpdir(), 'tracememo-wechat-action-'))
directories.push(userData)
const gateway = new WechatActionGateway({
getUserDataPath: () => userData,
getMemberEvent: () => ({ id: 'different-event', roomId: 'room@chatroom' })
})
const result = await gateway.execute({
origin: 'member_monitor',
purpose: 'member_left_notification',
triggerType: 'automation',
sourceId: 'event-1',
recipient: { type: 'group', id: 'room@chatroom' },
content: { type: 'text', text: '张三已退出群聊' }
})
expect(result).toMatchObject({
status: 'blocked',
decision: 'block',
errorCode: 'INVALID_REQUEST'
})
expect(mocks.sender.send).not.toHaveBeenCalled()
})
it('returns a structured capability failure without sending', async () => {
const gateway = createGateway()
mocks.capability.getPersonalWechatSendCapability.mockResolvedValueOnce({
...readyCapability,
ready: false,
capabilities: { text: false, image: false, voice: false },
message: '当前微信发送能力不可用'
})
const result = await memberAction(gateway)
expect(result).toMatchObject({
status: 'failed',
decision: 'allow',
errorCode: 'SEND_CAPABILITY_UNAVAILABLE'
})
expect(mocks.sender.send).not.toHaveBeenCalled()
})
it('用户主动发送只依赖微信发送能力', async () => {
const gateway = createGateway()
const result = await gateway.execute({
origin: 'user_tts',
purpose: 'tts_voice',
triggerType: 'user',
recipient: { type: 'contact', id: 'wxid_user' },
content: { type: 'text', text: '用户主动发送' }
})
expect(result).toMatchObject({ status: 'sent', decision: 'allow' })
expect(mocks.sender.send).toHaveBeenCalledOnce()
})
it('converts a transport throw into SEND_FAILED', async () => {
const gateway = createGateway()
mocks.sender.send.mockRejectedValueOnce(new Error('connector timeout'))
const result = await memberAction(gateway)
expect(result).toMatchObject({
status: 'failed',
decision: 'allow',
errorCode: 'SEND_FAILED',
reason: 'connector timeout'
})
})
it('sends accepted automation actions in FIFO order at least three seconds apart', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-03T08:00:00.000Z'))
const gateway = createGateway()
const sentAt: Array<{ to: string; at: number }> = []
mocks.sender.send.mockImplementation(async (request: { to: string }) => {
sentAt.push({ to: request.to, at: Date.now() })
return { success: true, status: {} }
})
const action = (id: string) =>
gateway.execute({
idempotencyKey: `scheduled:${id}`,
origin: 'scheduled_report',
purpose: 'scheduled_report',
triggerType: 'automation',
executionId: id,
recipient: { type: 'group', id: `${id}@chatroom` },
content: { type: 'image', path: `/tmp/${id}.png` }
})
const pending = [action('A'), action('B'), action('C')]
await vi.advanceTimersByTimeAsync(0)
expect(sentAt).toEqual([{ to: 'A@chatroom', at: Date.now() }])
await vi.advanceTimersByTimeAsync(AUTOMATION_SEND_INTERVAL_MS - 1)
expect(sentAt).toHaveLength(1)
await vi.advanceTimersByTimeAsync(1)
expect(sentAt.map((item) => item.to)).toEqual(['A@chatroom', 'B@chatroom'])
await vi.advanceTimersByTimeAsync(AUTOMATION_SEND_INTERVAL_MS)
await Promise.all(pending)
expect(sentAt.map((item) => item.to)).toEqual([
'A@chatroom',
'B@chatroom',
'C@chatroom'
])
expect(sentAt[1].at - sentAt[0].at).toBe(AUTOMATION_SEND_INTERVAL_MS)
expect(sentAt[2].at - sentAt[1].at).toBe(AUTOMATION_SEND_INTERVAL_MS)
vi.useRealTimers()
})
it('keeps the safety interval after a failed automatic send', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-03T09:00:00.000Z'))
const gateway = createGateway()
const sentAt: number[] = []
mocks.sender.send.mockImplementation(async () => {
sentAt.push(Date.now())
if (sentAt.length === 1) throw new Error('first failed')
return { success: true, status: {} }
})
const first = memberAction(gateway)
await vi.advanceTimersByTimeAsync(0)
gateway.registerMemberEvent({ id: 'event-2', roomId: 'room@chatroom' })
const second = gateway.execute({
origin: 'member_monitor',
purpose: 'member_left_notification',
triggerType: 'automation',
sourceId: 'event-2',
recipient: { type: 'group', id: 'room@chatroom' },
content: { type: 'text', text: '李四已退出群聊' }
})
await vi.advanceTimersByTimeAsync(AUTOMATION_SEND_INTERVAL_MS)
const results = await Promise.all([first, second])
expect(results.map((result) => result.status)).toEqual(['failed', 'sent'])
expect(sentAt[1] - sentAt[0]).toBe(AUTOMATION_SEND_INTERVAL_MS)
vi.useRealTimers()
})
it('lets a user action send immediately while an automatic action is waiting', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-03T09:30:00.000Z'))
const gateway = createGateway()
const sent: Array<{ to: string; at: number }> = []
mocks.sender.send.mockImplementation(async (request: { to: string }) => {
sent.push({ to: request.to, at: Date.now() })
return { success: true, status: {} }
})
const automaticAction = (id: string) =>
gateway.execute({
idempotencyKey: `scheduled:${id}`,
origin: 'scheduled_report',
purpose: 'scheduled_report',
triggerType: 'automation',
executionId: id,
recipient: { type: 'group', id: `${id}@chatroom` },
content: { type: 'image', path: `/tmp/${id}.png` }
})
const first = automaticAction('first')
const waiting = automaticAction('waiting')
await vi.advanceTimersByTimeAsync(0)
const userResult = await gateway.execute({
origin: 'user_tts',
purpose: 'tts_voice',
triggerType: 'user',
recipient: { type: 'contact', id: 'wxid_user' },
content: { type: 'text', text: '用户主动发送' }
})
expect(userResult.status).toBe('sent')
expect(sent).toEqual([
{ to: 'first@chatroom', at: Date.now() },
{ to: 'wxid_user', at: Date.now() }
])
await vi.advanceTimersByTimeAsync(AUTOMATION_SEND_INTERVAL_MS)
await Promise.all([first, waiting])
expect(sent[2]).toEqual({ to: 'waiting@chatroom', at: Date.now() })
vi.useRealTimers()
})
it('does not put capability failures into the automatic send queue', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-03T10:00:00.000Z'))
const gateway = createGateway()
mocks.capability.getPersonalWechatSendCapability
.mockResolvedValueOnce({ ...readyCapability, ready: false })
.mockResolvedValueOnce(readyCapability)
await expect(memberAction(gateway)).resolves.toMatchObject({
status: 'failed',
errorCode: 'SEND_CAPABILITY_UNAVAILABLE'
})
const startedAt = Date.now()
await gateway.execute({
origin: 'scheduled_report',
purpose: 'scheduled_report',
triggerType: 'automation',
executionId: 'execution-ready',
recipient: { type: 'group', id: 'room@chatroom' },
content: { type: 'image', path: '/tmp/report.png' }
})
expect(mocks.sender.send).toHaveBeenCalledOnce()
expect(Date.now()).toBe(startedAt)
vi.useRealTimers()
})
/**
* 项目规则:**所有发送都走 WechatActionGateway**。
*
* 手动发送(`wechat-personal:send`)改造前是直连 `PersonalWechatSendService` 的,
* 于是没有审计、没有幂等、也不进 Send Log。这一组锁住改造后的语义。
*/
describe('用户手动发送(triggerType: user)', () => {
function manualAction(
gateway: WechatActionGateway,
overrides: Partial<WechatActionRequest> = {}
): Promise<WechatActionResult> {
return gateway.execute({
origin: 'user_manual',
purpose: 'manual_image',
triggerType: 'user',
recipient: { type: 'group', id: 'room@chatroom' },
content: { type: 'image', path: '/tmp/report.png' },
...overrides
} as WechatActionRequest)
}
it('放行、发送一次,并写入审计记录', async () => {
const userData = mkdtempSync(join(tmpdir(), 'tracememo-wechat-action-'))
directories.push(userData)
const gateway = new WechatActionGateway({ getUserDataPath: () => userData })
const result = await manualAction(gateway)
expect(result).toMatchObject({ status: 'sent', decision: 'allow' })
expect(mocks.sender.send).toHaveBeenCalledWith({
type: 'image',
to: 'room@chatroom',
isGroup: true,
filePath: '/tmp/report.png'
})
const audit = readJsonSync(join(userData, 'actions', 'wechat-actions.json'))
expect(audit).toEqual([
expect.objectContaining({
origin: 'user_manual',
purpose: 'manual_image',
triggerType: 'user',
recipientId: 'room@chatroom',
decision: 'allow',
sendStatus: 'sent'
})
])
})
it('不受 automation 的 purpose allowlist 限制,也不吃 3s 节流', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-21T01:00:00.000Z'))
const gateway = createGateway()
const first = await manualAction(gateway)
const second = await manualAction(gateway)
expect(first.status).toBe('sent')
expect(second.status).toBe('sent')
// 节流只对 triggerType === 'automation' 生效:两次点击必须立刻都发出去。
expect(mocks.sender.send).toHaveBeenCalledTimes(2)
vi.useRealTimers()
})
it('空文本由规范化拦下,不落到发送层', async () => {
const gateway = createGateway()
const result = await manualAction(gateway, {
purpose: 'manual_text',
content: { type: 'text', text: ' ' }
})
expect(result.status).toBe('blocked')
expect(result.errorCode).toBe('INVALID_REQUEST')
expect(mocks.sender.send).not.toHaveBeenCalled()
})
})
describe('toPersonalWechatSendResult', () => {
const fallback = { canSend: true } as unknown as Parameters<
typeof toPersonalWechatSendResult
>[1]
it('优先返回底层 sendResult(它带着真实的 status)', () => {
const underlying = { success: true, status: { canSend: true, marker: 'real' } }
expect(
toPersonalWechatSendResult(
{
actionId: 'a1',
status: 'sent',
decision: 'allow',
startedAt: '2026-09-21T01:00:00.000Z',
finishedAt: '2026-09-21T01:00:01.000Z',
sendResult: underlying
},
fallback
)
).toBe(underlying)
})
it('拿不到 sendResult 时按 action.status 合成', () => {
const sent = toPersonalWechatSendResult(
{
actionId: 'a1',
status: 'sent',
decision: 'allow',
startedAt: '2026-09-21T01:00:00.000Z',
finishedAt: '2026-09-21T01:00:01.000Z'
},
fallback
)
expect(sent).toEqual({ success: true, status: fallback })
const blocked = toPersonalWechatSendResult(
{
actionId: 'a2',
status: 'blocked',
decision: 'block',
errorCode: 'ACTION_NOT_ALLOWED',
reason: '自动化动作不允许执行',
startedAt: '2026-09-21T01:00:00.000Z',
finishedAt: '2026-09-21T01:00:01.000Z'
},
fallback
)
expect(blocked.success).toBe(false)
expect(blocked.error).toBe('自动化动作不允许执行')
})
})
})