mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 03:01:42 +08:00
467 lines
16 KiB
TypeScript
467 lines
16 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||
|
||
const mocks = vi.hoisted(() => ({
|
||
chat: {
|
||
listContacts: vi.fn(),
|
||
getSelfAccountInfo: vi.fn()
|
||
}
|
||
}))
|
||
|
||
vi.mock('electron', () => ({ app: { getPath: () => '/tmp/tracememo-automation-group-exit' } }))
|
||
vi.mock('../../src/main/services/chat-service', () => mocks.chat)
|
||
|
||
import {
|
||
AutomationService,
|
||
leaveNotificationExecutionId,
|
||
leaveNotificationIdempotencyKey
|
||
} from '../../src/main/services/automation-service'
|
||
import { AutomationActionRunner } from '../../src/main/services/automation-action-runner'
|
||
import type { AutomationExecutionLogService } from '../../src/main/services/automation-execution-log-service'
|
||
import type { AutomationRuleStore } from '../../src/main/services/automation-rule-store'
|
||
import {
|
||
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
|
||
createDefaultLeaveNotificationRule,
|
||
type AutomationExecution,
|
||
type AutomationRule,
|
||
type LeaveNotificationConfig
|
||
} from '../../src/shared/automation'
|
||
import type { GroupMemberExitedEvent } from '../../src/shared/group-exit-event'
|
||
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'
|
||
}
|
||
|
||
const NOW = 1_700_000_000_000
|
||
|
||
/** 本地时区的 `YYYY-MM-DD HH:mm:ss`:`{time}` 按本地时区渲染,写死偏移量只在东八区通过。 */
|
||
function localStamp(epochMs: number): string {
|
||
const date = new Date(epochMs)
|
||
const pad = (value: number): string => String(value).padStart(2, '0')
|
||
return (
|
||
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
|
||
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||
)
|
||
}
|
||
|
||
function exitEvent(overrides: Partial<GroupMemberExitedEvent> = {}): GroupMemberExitedEvent {
|
||
return {
|
||
eventId: 'room@chatroom:wxid_member:1700000000000:1',
|
||
conversationId: 'room@chatroom',
|
||
groupName: '产品测试群',
|
||
memberId: 'wxid_member',
|
||
memberName: '张三',
|
||
wechatNickname: '张三',
|
||
previousCount: 243,
|
||
currentCount: 242,
|
||
occurredAt: NOW,
|
||
...overrides
|
||
}
|
||
}
|
||
|
||
function sentAction(): WechatActionResult {
|
||
return {
|
||
actionId: 'action-1',
|
||
status: 'sent',
|
||
decision: 'allow',
|
||
startedAt: new Date(NOW).toISOString(),
|
||
finishedAt: new Date(NOW).toISOString()
|
||
}
|
||
}
|
||
|
||
interface Harness {
|
||
service: AutomationService
|
||
requests: WechatActionRequest[]
|
||
executions: AutomationExecution[]
|
||
setRule: (rule: AutomationRule | undefined) => void
|
||
setCapability: (capability: PersonalWechatSendCapability) => void
|
||
}
|
||
|
||
function createHarness(
|
||
options: {
|
||
config?: Partial<LeaveNotificationConfig>
|
||
rule?: Partial<AutomationRule>
|
||
actionResult?: () => Promise<WechatActionResult> | WechatActionResult
|
||
} = {}
|
||
): Harness {
|
||
const base = createDefaultLeaveNotificationRule(NOW)
|
||
let rule: AutomationRule | undefined = {
|
||
...base,
|
||
...options.rule,
|
||
leaveNotification: { ...base.leaveNotification!, ...options.config }
|
||
}
|
||
let capability: PersonalWechatSendCapability = readyCapability
|
||
const requests: WechatActionRequest[] = []
|
||
const executions: AutomationExecution[] = []
|
||
|
||
const ruleStore = {
|
||
listRules: () => (rule ? [rule] : []),
|
||
getRule: (id: string) => (rule && rule.id === id ? rule : undefined)
|
||
} as unknown as AutomationRuleStore
|
||
const executionLog = {
|
||
record: (execution: AutomationExecution) => {
|
||
const index = executions.findIndex((item) => item.executionId === execution.executionId)
|
||
if (index >= 0) executions[index] = execution
|
||
else executions.push(execution)
|
||
},
|
||
list: () => executions,
|
||
clear: () => true,
|
||
countSince: () => ({ total: executions.length, success: 0 })
|
||
} as unknown as AutomationExecutionLogService
|
||
const runner = new AutomationActionRunner({
|
||
executeAction: async (request) => {
|
||
requests.push(request)
|
||
return options.actionResult ? options.actionResult() : sentAction()
|
||
},
|
||
now: () => NOW
|
||
})
|
||
|
||
const service = new AutomationService({} as never, {
|
||
ruleStore,
|
||
executionLog,
|
||
runner,
|
||
getCapability: async () => capability,
|
||
now: () => NOW
|
||
})
|
||
|
||
return {
|
||
service,
|
||
requests,
|
||
executions,
|
||
setRule: (next) => {
|
||
rule = next
|
||
},
|
||
setCapability: (next) => {
|
||
capability = next
|
||
}
|
||
}
|
||
}
|
||
|
||
beforeEach(() => {
|
||
mocks.chat.listContacts.mockReset().mockReturnValue([
|
||
{ m_nsUsrName: 'wxid_friend', m_nsNickName: '好友', remark: '好友备注', type: 'user' },
|
||
{ m_nsUsrName: 'room@chatroom', m_nsNickName: '产品测试群', type: 'group' }
|
||
])
|
||
mocks.chat.getSelfAccountInfo.mockReset().mockReturnValue({ wxid: 'wxid_self' })
|
||
})
|
||
|
||
describe('AutomationService.handleGroupExit', () => {
|
||
it('退群事件 → 命中规则 → 解析目标 → Gateway 调用一次 → execution 成功', async () => {
|
||
const harness = createHarness({ config: { target: { type: 'file_transfer' } } })
|
||
|
||
await harness.service.handleGroupExit(exitEvent())
|
||
|
||
expect(harness.requests).toHaveLength(1)
|
||
expect(harness.requests[0]).toMatchObject({
|
||
origin: 'automation',
|
||
purpose: 'automation_leave_notification',
|
||
triggerType: 'automation',
|
||
recipient: { type: 'contact', id: 'filehelper', name: '文件传输助手' },
|
||
content: { type: 'text' }
|
||
})
|
||
expect(harness.executions).toHaveLength(1)
|
||
expect(harness.executions[0]).toMatchObject({
|
||
ruleId: BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
|
||
ruleName: '退群通知',
|
||
sourceDisplayName: '产品测试群',
|
||
status: 'success'
|
||
})
|
||
// 步骤序列:触发 → 匹配 → 目标 → 发送
|
||
expect(harness.executions[0].steps.map((step) => step.key)).toEqual([
|
||
'exit_received',
|
||
'exit_matched',
|
||
'exit_target',
|
||
'exit_send'
|
||
])
|
||
// 用户可见的「目标」是显示名,不是 wxid。
|
||
const targetStep = harness.executions[0].steps.find((step) => step.key === 'exit_target')
|
||
expect(targetStep?.detail).toBe('文件传输助手')
|
||
})
|
||
|
||
it('规则关闭时不执行、也不创建 execution', async () => {
|
||
const harness = createHarness({ rule: { enabled: false } })
|
||
|
||
await harness.service.handleGroupExit(exitEvent())
|
||
|
||
expect(harness.requests).toHaveLength(0)
|
||
expect(harness.executions).toHaveLength(0)
|
||
})
|
||
|
||
it('目标待重选(迁移遗留)时不执行、也不创建 execution', async () => {
|
||
const harness = createHarness({
|
||
config: { targetNeedsReview: true, target: { type: 'file_transfer' } }
|
||
})
|
||
|
||
await harness.service.handleGroupExit(exitEvent())
|
||
|
||
expect(harness.requests).toHaveLength(0)
|
||
expect(harness.executions).toHaveLength(0)
|
||
})
|
||
|
||
/*
|
||
* 「通知范围」=历史「退群监控 → 通知群聊」的第二层。
|
||
* 缺了它,规则一启用就等于给全部已监控群聊发通知 —— 就是那个被报回来的问题。
|
||
*/
|
||
it('范围 = 仅选中的群聊:勾中的群照常发送', async () => {
|
||
const harness = createHarness({
|
||
config: { notifyScope: 'selected', notifyRoomIds: ['room@chatroom'] }
|
||
})
|
||
|
||
await harness.service.handleGroupExit(exitEvent())
|
||
|
||
expect(harness.requests).toHaveLength(1)
|
||
expect(harness.executions).toHaveLength(1)
|
||
})
|
||
|
||
it('范围 = 仅选中的群聊:没勾的群不发,且不创建 execution', async () => {
|
||
const harness = createHarness({
|
||
config: { notifyScope: 'selected', notifyRoomIds: ['other@chatroom'] }
|
||
})
|
||
|
||
await harness.service.handleGroupExit(exitEvent())
|
||
|
||
expect(harness.requests).toHaveLength(0)
|
||
// 被范围排除**不是**"一次失败的执行",所以不能留失败记录吓人。
|
||
expect(harness.executions).toHaveLength(0)
|
||
})
|
||
|
||
it('范围 = 全部已监控群聊:不筛群,照常发送', async () => {
|
||
const harness = createHarness({ config: { notifyScope: 'all', notifyRoomIds: [] } })
|
||
|
||
await harness.service.handleGroupExit(exitEvent())
|
||
|
||
expect(harness.requests).toHaveLength(1)
|
||
})
|
||
|
||
/*
|
||
* 范围判定必须在幂等**之前**:否则被排除的群会白白占用该事件的幂等位,
|
||
* 用户随后补勾了那个群,同一个事件反而再也发不出去。
|
||
*/
|
||
it('范围外不占用幂等位:补勾之后同一事件仍能发出', async () => {
|
||
const harness = createHarness({
|
||
config: { notifyScope: 'selected', notifyRoomIds: [] }
|
||
})
|
||
|
||
await harness.service.handleGroupExit(exitEvent())
|
||
expect(harness.requests).toHaveLength(0)
|
||
expect(harness.executions).toHaveLength(0)
|
||
|
||
harness.setRule(
|
||
createDefaultLeaveNotificationRule(NOW, {
|
||
notifyScope: 'selected',
|
||
notifyRoomIds: ['room@chatroom']
|
||
})
|
||
)
|
||
await harness.service.handleGroupExit(exitEvent())
|
||
|
||
expect(harness.requests).toHaveLength(1)
|
||
})
|
||
|
||
it('缺少 eventId 时直接跳过,绝不发送', async () => {
|
||
const harness = createHarness()
|
||
|
||
await harness.service.handleGroupExit(exitEvent({ eventId: '' }))
|
||
|
||
expect(harness.requests).toHaveLength(0)
|
||
})
|
||
|
||
it('当前群聊 → 发送目标是事件所在群', async () => {
|
||
const harness = createHarness({ config: { target: { type: 'source_chat' } } })
|
||
|
||
await harness.service.handleGroupExit(
|
||
exitEvent({ conversationId: 'A@chatroom', groupName: 'A 群' })
|
||
)
|
||
|
||
expect(harness.requests[0].recipient).toEqual({
|
||
type: 'group',
|
||
id: 'A@chatroom',
|
||
name: 'A 群'
|
||
})
|
||
})
|
||
|
||
it('发给自己 → 发送目标是当前账号 wxid', async () => {
|
||
mocks.chat.getSelfAccountInfo.mockReturnValue({ wxid: 'wxid_realme' })
|
||
const harness = createHarness({ config: { target: { type: 'self' } } })
|
||
|
||
await harness.service.handleGroupExit(exitEvent())
|
||
|
||
expect(harness.requests[0].recipient).toEqual({
|
||
type: 'contact',
|
||
id: 'wxid_realme',
|
||
name: '我'
|
||
})
|
||
})
|
||
|
||
it('文件传输助手 → 稳定 identity,而不是昵称', async () => {
|
||
const harness = createHarness({ config: { target: { type: 'file_transfer' } } })
|
||
|
||
await harness.service.handleGroupExit(exitEvent())
|
||
|
||
expect(harness.requests[0].recipient.id).toBe('filehelper')
|
||
expect(harness.requests[0].recipient.id).not.toBe('文件传输助手')
|
||
})
|
||
|
||
it('指定好友 → 发送目标是保存的稳定 id', async () => {
|
||
const harness = createHarness({
|
||
config: { target: { type: 'contact', contactId: 'wxid_friend' } }
|
||
})
|
||
|
||
await harness.service.handleGroupExit(exitEvent())
|
||
|
||
expect(harness.requests[0].recipient).toEqual({
|
||
type: 'contact',
|
||
id: 'wxid_friend',
|
||
name: '好友备注'
|
||
})
|
||
})
|
||
|
||
/*
|
||
* 「旧联系人失效」:不偷偷发给别人,execution 记 failed,Gateway 一次都不调。
|
||
*/
|
||
it('指定好友已不存在 → execution failed 且不调用 Gateway', async () => {
|
||
const harness = createHarness({
|
||
config: { target: { type: 'contact', contactId: 'wxid_gone' } }
|
||
})
|
||
|
||
await harness.service.handleGroupExit(exitEvent())
|
||
|
||
expect(harness.requests).toHaveLength(0)
|
||
expect(harness.executions).toHaveLength(1)
|
||
expect(harness.executions[0].status).toBe('failed')
|
||
expect(harness.executions[0].errorSummary).toContain('已不存在或当前无法发送')
|
||
const sendStep = harness.executions[0].steps.find((step) => step.key === 'exit_send')
|
||
expect(sendStep?.status).toBe('skipped')
|
||
})
|
||
|
||
it('发送能力缺失 → execution failed(退群事实不受影响)', async () => {
|
||
const harness = createHarness()
|
||
harness.setCapability({
|
||
...readyCapability,
|
||
capabilities: { text: false, image: false, voice: false }
|
||
})
|
||
|
||
await harness.service.handleGroupExit(exitEvent())
|
||
|
||
expect(harness.requests).toHaveLength(0)
|
||
expect(harness.executions[0].status).toBe('failed')
|
||
expect(harness.executions[0].errorSummary).toContain('无法发送文字')
|
||
})
|
||
|
||
it('Gateway 抛错 → execution failed,且 handleGroupExit 自己绝不抛', async () => {
|
||
const harness = createHarness({
|
||
actionResult: () => {
|
||
throw new Error('connector timeout')
|
||
}
|
||
})
|
||
|
||
await expect(harness.service.handleGroupExit(exitEvent())).resolves.toBeUndefined()
|
||
expect(harness.executions).toHaveLength(1)
|
||
expect(harness.executions[0].status).toBe('failed')
|
||
})
|
||
|
||
it('Gateway 返回失败状态 → execution failed', async () => {
|
||
const harness = createHarness({
|
||
actionResult: () => ({
|
||
actionId: 'action-x',
|
||
status: 'failed',
|
||
decision: 'allow',
|
||
errorCode: 'SEND_FAILED',
|
||
reason: '微信发送失败',
|
||
startedAt: new Date(NOW).toISOString(),
|
||
finishedAt: new Date(NOW).toISOString()
|
||
})
|
||
})
|
||
|
||
await harness.service.handleGroupExit(exitEvent())
|
||
|
||
expect(harness.executions[0].status).toBe('failed')
|
||
expect(harness.executions[0].errorSummary).toBe('微信发送失败')
|
||
})
|
||
|
||
it('模板里所有变量都被替换(预览与实发共用同一套规则)', async () => {
|
||
const harness = createHarness({
|
||
config: {
|
||
target: { type: 'file_transfer' },
|
||
template: '{groupName}|{user}|{groupRemark}|{wxid}|{previousCount}->{currentCount}|{time}'
|
||
}
|
||
})
|
||
|
||
await harness.service.handleGroupExit(
|
||
exitEvent({ groupName: '产品测试群', groupRemark: '群内备注', wechatNickname: '微信昵称' })
|
||
)
|
||
|
||
const content = harness.requests[0].content
|
||
expect(content.type).toBe('text')
|
||
if (content.type === 'text') {
|
||
// 群名({groupName})与成员群昵称({groupRemark})是两个值,不能混。
|
||
expect(content.text).toBe(
|
||
`产品测试群|微信昵称|群内备注|wxid_member|243->242|${localStamp(NOW)}`
|
||
)
|
||
}
|
||
})
|
||
|
||
it('同一个 eventId 投递两次 → 只发一次、只产生一条 execution', async () => {
|
||
const harness = createHarness()
|
||
const event = exitEvent()
|
||
|
||
await harness.service.handleGroupExit(event)
|
||
await harness.service.handleGroupExit(event)
|
||
|
||
expect(harness.requests).toHaveLength(1)
|
||
expect(harness.executions).toHaveLength(1)
|
||
})
|
||
|
||
/*
|
||
* 不同事件:同群两个成员先后退出就是两条通知。
|
||
* 退群通知**不复用**消息型规则的 cooldown,否则第二次会被时间窗吞掉。
|
||
*/
|
||
it('不同 eventId 各自执行一次,不被合并', async () => {
|
||
const harness = createHarness()
|
||
|
||
await harness.service.handleGroupExit(exitEvent({ eventId: 'event-a' }))
|
||
await harness.service.handleGroupExit(exitEvent({ eventId: 'event-b' }))
|
||
|
||
expect(harness.requests).toHaveLength(2)
|
||
expect(harness.executions).toHaveLength(2)
|
||
expect(new Set(harness.executions.map((item) => item.executionId)).size).toBe(2)
|
||
})
|
||
|
||
it('幂等键与 executionId 都由 eventId 派生(跨重启也稳定)', () => {
|
||
const eventId = 'room@chatroom:wxid_member:1700000000000:1'
|
||
|
||
expect(leaveNotificationIdempotencyKey(eventId)).toBe(
|
||
`automation_leave_notification:${eventId}`
|
||
)
|
||
// 同一个 (ruleId, eventId) 永远算出同一个 executionId —— 重启后重复投递会覆盖同一条记录。
|
||
expect(leaveNotificationExecutionId('rule-1', eventId)).toBe(
|
||
leaveNotificationExecutionId('rule-1', eventId)
|
||
)
|
||
expect(leaveNotificationExecutionId('rule-1', eventId)).not.toBe(
|
||
leaveNotificationExecutionId('rule-1', 'another-event')
|
||
)
|
||
})
|
||
|
||
it('规则不存在或不是退群通知类型时静默跳过', async () => {
|
||
const harness = createHarness()
|
||
harness.setRule(undefined)
|
||
|
||
await harness.service.handleGroupExit(exitEvent())
|
||
|
||
expect(harness.requests).toHaveLength(0)
|
||
expect(harness.executions).toHaveLength(0)
|
||
})
|
||
})
|