mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-06 13:54:10 +08:00
feat: 定时日报并入自动化,统一规则管理与发送链路
发送目标与退群通知统一为 来源群 / 自己 / 文件传输助手 / 指定好友 日报页补「定时日报已并入自动化」指引条
This commit is contained in:
@@ -220,16 +220,37 @@ describe('AutomationRuleStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/*
|
||||
* 存量安装:早期版本把"部分勾选"判成无法无损映射,于是落盘了
|
||||
* `targetNeedsReview: true`。这个标记会拦发送(`handleGroupExit` 第②道闸门),
|
||||
* 所以**用户重存一次必须能清掉它** —— 否则那条规则永远发不出去。
|
||||
*
|
||||
* 注意种的是"已经落盘的存量状态",不是靠迁移产出:迁移现在不再产生这个标记了。
|
||||
*/
|
||||
it('保存退群通知会清掉迁移遗留的「目标待重选」标记', () => {
|
||||
writeLegacyMonitorState({
|
||||
monitoredRoomIds: ['a@chatroom', 'b@chatroom'],
|
||||
notificationRoomIds: ['a@chatroom']
|
||||
})
|
||||
const store = new AutomationRuleStore(deps(root))
|
||||
store.saveLeaveNotificationRule({
|
||||
name: '退群通知',
|
||||
enabled: false,
|
||||
ruleType: 'leave_notification',
|
||||
scope: 'group',
|
||||
conditions: {},
|
||||
actions: [],
|
||||
cooldownSeconds: 0,
|
||||
replyDelaySeconds: 2,
|
||||
leaveNotification: {
|
||||
target: { type: 'source_chat' },
|
||||
template: '[退群监测]',
|
||||
notifyScope: 'all',
|
||||
notifyRoomIds: [],
|
||||
targetNeedsReview: true
|
||||
}
|
||||
})
|
||||
expect(
|
||||
store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)?.leaveNotification?.targetNeedsReview
|
||||
).toBe(true)
|
||||
|
||||
// 用户来重存一次:草稿里不带这个标记 ⇒ 归一化后它就该消失。
|
||||
store.saveLeaveNotificationRule({
|
||||
...store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID),
|
||||
enabled: true,
|
||||
@@ -384,14 +405,17 @@ describe('AutomationRuleStore', () => {
|
||||
)
|
||||
|
||||
expect(leave?.enabled).toBe(false)
|
||||
// 关闭状态下 target 只是占位值(= 默认目标),不会发送任何东西。
|
||||
// 关闭状态下 target 只是占位值(= 「发回本群」),不会发送任何东西。
|
||||
expect(leave?.leaveNotification?.target).toEqual({ type: 'source_chat' })
|
||||
// 范围本身也精确:`selected` + 空集 = 「一个群都不通知」。
|
||||
expect(leave?.leaveNotification?.notifyScope).toBe('selected')
|
||||
expect(leave?.leaveNotification?.notifyRoomIds).toEqual([])
|
||||
// 用户写的模板内容保留;同一次启动的"补群名"升级会另加一行。
|
||||
expect(leave?.leaveNotification?.template).toContain('退群: {user}')
|
||||
expect(leave?.leaveNotification?.template).toContain('群聊: {groupName}')
|
||||
})
|
||||
|
||||
it('旧配置在每个被监控群都通知 → 无损映射为「当前群聊」且保持启用', () => {
|
||||
it('旧配置在每个被监控群都通知 → 无损映射为「当前群聊」+ 全部范围,且保持启用', () => {
|
||||
writeLegacyMonitorState({
|
||||
monitoredRoomIds: ['a@chatroom', 'b@chatroom'],
|
||||
notificationRoomIds: ['a@chatroom', 'b@chatroom']
|
||||
@@ -403,15 +427,16 @@ describe('AutomationRuleStore', () => {
|
||||
|
||||
expect(leave?.enabled).toBe(true)
|
||||
expect(leave?.leaveNotification?.target).toEqual({ type: 'source_chat' })
|
||||
expect(leave?.leaveNotification?.notifyScope).toBe('all')
|
||||
expect(leave?.leaveNotification?.targetNeedsReview).toBeUndefined()
|
||||
})
|
||||
|
||||
/*
|
||||
* 关键的一条:旧「通知群聊」是**逐群**的多值配置,新目标是**单值**。
|
||||
* 只勾了一部分时无法无损映射 —— 这时候:
|
||||
* 不取第一个群、不改写成当前群聊、保持关闭、要求用户重选,并留一份备份。
|
||||
* 关键的一条:旧「通知群聊」是**逐群**的多值配置,恢复 `notifyScope` 之后
|
||||
* 它就是一个正常可表达的字段 —— 所以这里**不再降级成 needs_review**:
|
||||
* 那份勾选被原样保留、规则保持启用、不会再多一次"被无辜停用"的体验。
|
||||
*/
|
||||
it('旧配置只勾了部分群 → 标记待重选、不发送、留下备份,绝不猜', () => {
|
||||
it('旧配置只勾了部分群 → 原样保留那份子集,保持启用,不做有损改写', () => {
|
||||
writeLegacyMonitorState({
|
||||
monitoredRoomIds: ['a@chatroom', 'b@chatroom', 'c@chatroom'],
|
||||
notificationRoomIds: ['b@chatroom'],
|
||||
@@ -421,11 +446,13 @@ describe('AutomationRuleStore', () => {
|
||||
const store = new AutomationRuleStore(deps(root))
|
||||
const leave = store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
|
||||
|
||||
expect(leave?.enabled).toBe(false)
|
||||
expect(leave?.leaveNotification?.targetNeedsReview).toBe(true)
|
||||
// 不回落到「取第一个群」,也不改写成别的具体目标 —— 只留默认占位(当前群聊),
|
||||
// 且因为 enabled=false + targetNeedsReview=true,在用户重选之前不会发送。
|
||||
expect(leave?.enabled).toBe(true)
|
||||
expect(leave?.leaveNotification?.targetNeedsReview).toBeUndefined()
|
||||
// 收件人恒为事件所在群(旧版 notifyGroup 的事实)⇒ target 仍是 source_chat。
|
||||
expect(leave?.leaveNotification?.target).toEqual({ type: 'source_chat' })
|
||||
// 二次勾选逐字保留:只通知 b,不多不少。
|
||||
expect(leave?.leaveNotification?.notifyScope).toBe('selected')
|
||||
expect(leave?.leaveNotification?.notifyRoomIds).toEqual(['b@chatroom'])
|
||||
// 用户写的模板内容保留;同一次启动的"补群名"升级会另加一行。
|
||||
expect(leave?.leaveNotification?.template).toContain('退群: {user}')
|
||||
expect(leave?.leaveNotification?.template).toContain('群聊: {groupName}')
|
||||
@@ -434,10 +461,10 @@ describe('AutomationRuleStore', () => {
|
||||
path.join(root, 'automation', 'leave-notification-migration-backup.json')
|
||||
)
|
||||
expect(backup).toMatchObject({
|
||||
outcome: 'needs_review',
|
||||
outcome: 'lossless_selected_groups',
|
||||
legacy: { notificationRoomIds: ['b@chatroom'] }
|
||||
})
|
||||
expect(store.getLastLeaveNotificationMigration()?.outcome).toBe('needs_review')
|
||||
expect(store.getLastLeaveNotificationMigration()?.outcome).toBe('lossless_selected_groups')
|
||||
})
|
||||
|
||||
it('迁移只跑一次:重启两次也只有一条退群通知规则', () => {
|
||||
@@ -477,6 +504,179 @@ describe('AutomationRuleStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 旧「定时日报任务」→ `scheduled_report` 规则的 store 级迁移。
|
||||
*
|
||||
* 纯映射器的逐字段断言在 `scheduled-report-migration.test.ts`;
|
||||
* 这里只守 store 负责的三件事:**插几条 / 幂等 / 解析器不可用时推迟**。
|
||||
*/
|
||||
describe('AutomationRuleStore · 定时日报迁移', () => {
|
||||
const legacyTasksFile = (): string => path.join(root, 'scheduled-reports', 'tasks.json')
|
||||
const backupFile = (): string =>
|
||||
path.join(root, 'automation', 'scheduled-report-migration-backup.json')
|
||||
|
||||
/** 与 main 侧注入的真实解析器同口径(`@chatroom` 原样透传)。 */
|
||||
const resolver = (raw: string): string | undefined => {
|
||||
const key = raw.trim()
|
||||
if (!key) return undefined
|
||||
if (key.endsWith('@chatroom')) return key
|
||||
return { 'tech-md5': 'tech@chatroom', 'product-md5': 'product@chatroom' }[key]
|
||||
}
|
||||
|
||||
const writeLegacyTasks = (value: unknown): void => {
|
||||
fs.ensureDirSync(path.dirname(legacyTasksFile()))
|
||||
fs.writeJsonSync(legacyTasksFile(), value)
|
||||
}
|
||||
|
||||
const legacyTasks = (): unknown[] => [
|
||||
{
|
||||
id: 'scheduled_report_a',
|
||||
name: 'A 群日报',
|
||||
group: 'tech-md5',
|
||||
target: 'tech@chatroom',
|
||||
scheduleTime: '09:00',
|
||||
reportRange: 'yesterday',
|
||||
enabled: true,
|
||||
createdAt: '2026-08-01T01:00:00.000Z',
|
||||
updatedAt: '2026-08-01T01:00:00.000Z'
|
||||
},
|
||||
{
|
||||
id: 'scheduled_report_b',
|
||||
name: 'B 群日报',
|
||||
group: 'product-md5',
|
||||
target: 'product@chatroom',
|
||||
scheduleTime: '10:00',
|
||||
reportRange: 'today',
|
||||
enabled: false,
|
||||
createdAt: '2026-08-02T01:00:00.000Z',
|
||||
updatedAt: '2026-08-02T01:00:00.000Z'
|
||||
},
|
||||
{
|
||||
id: 'scheduled_report_c',
|
||||
name: '发到别的群',
|
||||
group: 'tech-md5',
|
||||
target: 'product@chatroom',
|
||||
scheduleTime: '11:00',
|
||||
reportRange: 'today',
|
||||
enabled: true,
|
||||
createdAt: '2026-08-03T01:00:00.000Z',
|
||||
updatedAt: '2026-08-03T01:00:00.000Z'
|
||||
}
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
fs.removeSync(path.join(root, 'automation'))
|
||||
fs.removeSync(path.join(root, 'scheduled-reports'))
|
||||
})
|
||||
|
||||
it('3 条旧任务 ⇒ 3 条 scheduled_report 规则(连同内置规则一起),且判定分布如实', () => {
|
||||
writeLegacyTasks(legacyTasks())
|
||||
const store = new AutomationRuleStore(deps(root))
|
||||
store.setLegacyConversationResolver(resolver)
|
||||
|
||||
const scheduled = store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')
|
||||
expect(scheduled.map((rule) => rule.id).sort()).toEqual([
|
||||
'scheduled_report_a',
|
||||
'scheduled_report_b',
|
||||
'scheduled_report_c'
|
||||
])
|
||||
|
||||
const summary = store.getLastScheduledReportMigration()
|
||||
expect(summary).toEqual({
|
||||
total: 3,
|
||||
migrated: 3,
|
||||
lossless: 2,
|
||||
needsReview: 1,
|
||||
duplicatesSkipped: 0
|
||||
})
|
||||
|
||||
// 「发到别的群」那条:不自动改写目标,且必须停用。
|
||||
const needsReview = scheduled.find((rule) => rule.id === 'scheduled_report_c')
|
||||
expect(needsReview?.enabled).toBe(false)
|
||||
expect(needsReview?.scheduledReport?.targetNeedsReview).toBe(true)
|
||||
// 旧任务是启用的,但迁移后必须停用 —— 不能替用户决定发到哪儿。
|
||||
expect(store.getRule('scheduled_report_a')?.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('二次启动不会重复迁移(仍只有 3 条,不是 6 条)', () => {
|
||||
writeLegacyTasks(legacyTasks())
|
||||
|
||||
const first = new AutomationRuleStore(deps(root))
|
||||
first.setLegacyConversationResolver(resolver)
|
||||
expect(
|
||||
first.listRules().filter((rule) => rule.ruleType === 'scheduled_report')
|
||||
).toHaveLength(3)
|
||||
|
||||
// 第二个实例 = 重启:从盘上读,旧 tasks.json 仍在原处。
|
||||
const second = new AutomationRuleStore(deps(root))
|
||||
second.setLegacyConversationResolver(resolver)
|
||||
const ids = second
|
||||
.listRules()
|
||||
.filter((rule) => rule.ruleType === 'scheduled_report')
|
||||
.map((rule) => rule.id)
|
||||
expect(ids).toHaveLength(3)
|
||||
expect(new Set(ids).size).toBe(3)
|
||||
// 已迁移标记落盘 ⇒ 不再有待迁任务。
|
||||
expect(second.hasPendingScheduledReportMigration()).toBe(false)
|
||||
})
|
||||
|
||||
it('解析器不可用时**推迟**迁移:不置标记、不插规则、如实报告待迁', () => {
|
||||
writeLegacyTasks(legacyTasks())
|
||||
const store = new AutomationRuleStore(deps(root))
|
||||
|
||||
expect(store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')).toHaveLength(0)
|
||||
expect(store.hasPendingScheduledReportMigration()).toBe(true)
|
||||
expect(store.getLastScheduledReportMigration()).toBeNull()
|
||||
|
||||
// 数据库就绪后注入解析器 ⇒ 立刻补跑。
|
||||
store.setLegacyConversationResolver(resolver)
|
||||
expect(store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')).toHaveLength(3)
|
||||
expect(store.hasPendingScheduledReportMigration()).toBe(false)
|
||||
})
|
||||
|
||||
it('迁移前把旧任务原样落一份 backup(用户数据可回溯)', () => {
|
||||
const tasks = legacyTasks()
|
||||
writeLegacyTasks(tasks)
|
||||
|
||||
const store = new AutomationRuleStore(deps(root))
|
||||
// 先注入解析器、后首次加载:这正是 main 的真实顺序(构造函数不读盘)。
|
||||
// `setLegacyConversationResolver` 在未加载时只记住 resolver,
|
||||
// 迁移推迟到第一次 `ensureLoaded()`,注入顺序不影响结果。
|
||||
store.setLegacyConversationResolver(resolver)
|
||||
store.listRules()
|
||||
|
||||
expect(fs.pathExistsSync(backupFile())).toBe(true)
|
||||
const backup = fs.readJsonSync(backupFile()) as {
|
||||
summary: { migrated: number }
|
||||
legacyTasks: unknown[]
|
||||
}
|
||||
expect(backup.summary.migrated).toBe(3)
|
||||
// 备份里保留**原始字段**(包括旧目标原文),否则出了问题无法回溯。
|
||||
expect(backup.legacyTasks).toEqual(tasks)
|
||||
// 旧 tasks.json 不被删除或改写 —— 迁移必须是可逆的。
|
||||
expect(fs.readJsonSync(legacyTasksFile())).toEqual(tasks)
|
||||
})
|
||||
|
||||
it('没有旧任务时走全新安装路径:不插任何定时日报规则,也不报待迁', () => {
|
||||
const store = new AutomationRuleStore(deps(root))
|
||||
store.setLegacyConversationResolver(resolver)
|
||||
|
||||
expect(store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')).toHaveLength(0)
|
||||
expect(store.hasPendingScheduledReportMigration()).toBe(false)
|
||||
expect(store.getLastScheduledReportMigration()).toBeNull()
|
||||
expect(fs.pathExistsSync(backupFile())).toBe(false)
|
||||
})
|
||||
|
||||
it('旧文件损坏时按「没有旧任务」处理,绝不抛异常挡住启动', () => {
|
||||
fs.ensureDirSync(path.dirname(legacyTasksFile()))
|
||||
fs.writeFileSync(legacyTasksFile(), 'not json at all', 'utf8')
|
||||
|
||||
const store = new AutomationRuleStore(deps(root))
|
||||
store.setLegacyConversationResolver(resolver)
|
||||
expect(store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AutomationExecutionLogService', () => {
|
||||
beforeEach(() => {
|
||||
fs.removeSync(path.join(root, 'automation'))
|
||||
|
||||
@@ -201,6 +201,65 @@ describe('AutomationService.handleGroupExit', () => {
|
||||
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()
|
||||
|
||||
@@ -260,7 +319,7 @@ describe('AutomationService.handleGroupExit', () => {
|
||||
})
|
||||
|
||||
/*
|
||||
* §「旧联系人失效」:不偷偷发给别人,execution 记 failed,Gateway 一次都不调。
|
||||
* 「旧联系人失效」:不偷偷发给别人,execution 记 failed,Gateway 一次都不调。
|
||||
*/
|
||||
it('指定好友已不存在 → execution failed 且不调用 Gateway', async () => {
|
||||
const harness = createHarness({
|
||||
@@ -353,7 +412,7 @@ describe('AutomationService.handleGroupExit', () => {
|
||||
})
|
||||
|
||||
/*
|
||||
* §「不同事件」:同群两个成员先后退出就是两条通知。
|
||||
* 不同事件:同群两个成员先后退出就是两条通知。
|
||||
* 退群通知**不复用**消息型规则的 cooldown,否则第二次会被时间窗吞掉。
|
||||
*/
|
||||
it('不同 eventId 各自执行一次,不被合并', async () => {
|
||||
|
||||
@@ -45,6 +45,23 @@ const contacts: LeaveNotificationCandidateContact[] = [
|
||||
{ m_nsUsrName: 'wxid_self', m_nsNickName: '我自己', type: 'user' }
|
||||
]
|
||||
|
||||
/**
|
||||
* 构造一份合法配置。
|
||||
*
|
||||
* 本文件关心的是**目标解析**,通知范围(`notifyScope`)与之正交,
|
||||
* 所以在这里统一补默认值 —— 契约再新增字段时也只改这一处,
|
||||
* 不必去逐个补十几处字面量(那才是漏字段的根源)。
|
||||
*/
|
||||
function config(overrides: Partial<LeaveNotificationConfig> = {}): LeaveNotificationConfig {
|
||||
return {
|
||||
target: { type: 'source_chat' },
|
||||
template: '',
|
||||
notifyScope: 'all',
|
||||
notifyRoomIds: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function resolve(
|
||||
config: LeaveNotificationConfig,
|
||||
overrides: { selfWxid?: string; event?: GroupMemberExitedEvent } = {}
|
||||
@@ -59,7 +76,7 @@ function resolve(
|
||||
|
||||
describe('leaveNotificationTarget · 四种目标', () => {
|
||||
it('当前群聊 = 事件所在群,不是"最后活跃会话"', () => {
|
||||
const result = resolve({ target: { type: 'source_chat' }, template: '' })
|
||||
const result = resolve(config())
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
@@ -71,23 +88,42 @@ describe('leaveNotificationTarget · 四种目标', () => {
|
||||
})
|
||||
|
||||
it('当前群聊在群名缺失时用中性称呼,不回落成 roomId', () => {
|
||||
const result = resolveLeaveNotificationTarget({
|
||||
config: { target: { type: 'source_chat' }, template: '' },
|
||||
// 事件里没带群名,但本地联系人表里有这个群 ⇒ 用会话昵称,
|
||||
// 比「当前群聊」更具体,且**仍然不是** roomId。
|
||||
const withNickname = resolveLeaveNotificationTarget({
|
||||
config: config(),
|
||||
event: { ...EVENT, groupName: undefined },
|
||||
contacts,
|
||||
selfWxid: 'wxid_self'
|
||||
})
|
||||
expect(withNickname.ok).toBe(true)
|
||||
if (withNickname.ok) {
|
||||
expect(withNickname.target.recipient.name).toBe('测试群')
|
||||
expect(withNickname.target.displayName).toBe('测试群')
|
||||
}
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.target.recipient.name).toBe('当前群聊')
|
||||
expect(result.target.displayName).toBe('发生退群事件的群聊')
|
||||
// 群名与联系人表**都**拿不到 ⇒ 退到中性称呼。
|
||||
// 唯一不能发生的事是把 roomId / wxid 当显示名吐出去。
|
||||
const fallback = resolveLeaveNotificationTarget({
|
||||
config: config(),
|
||||
event: { ...EVENT, groupName: undefined },
|
||||
contacts: [],
|
||||
selfWxid: 'wxid_self'
|
||||
})
|
||||
expect(fallback.ok).toBe(true)
|
||||
if (fallback.ok) {
|
||||
expect(fallback.target.recipient.name).toBe('当前群聊')
|
||||
expect(fallback.target.displayName).toBe('发生退群事件的群聊')
|
||||
// `recipient.id` 本来就该是真实会话 id(发送要用它),
|
||||
// 这条断言约束的是**显示名**:不许把 id 当称呼给人看。
|
||||
expect(fallback.target.recipient.name).not.toContain('@chatroom')
|
||||
expect(fallback.target.displayName).not.toContain('@chatroom')
|
||||
}
|
||||
})
|
||||
|
||||
it('当前群聊在缺少 conversationId 时直接失败', () => {
|
||||
const result = resolveLeaveNotificationTarget({
|
||||
config: { target: { type: 'source_chat' }, template: '' },
|
||||
config: config(),
|
||||
event: { ...EVENT, conversationId: '' },
|
||||
contacts,
|
||||
selfWxid: 'wxid_self'
|
||||
@@ -97,7 +133,7 @@ describe('leaveNotificationTarget · 四种目标', () => {
|
||||
})
|
||||
|
||||
it('发给自己 = 当前登录账号的真实 wxid', () => {
|
||||
const result = resolve({ target: { type: 'self' }, template: '' }, { selfWxid: 'wxid_me' })
|
||||
const result = resolve(config({ target: { type: 'self' } }), { selfWxid: 'wxid_me' })
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
@@ -113,7 +149,7 @@ describe('leaveNotificationTarget · 四种目标', () => {
|
||||
* 那会让用户以为"发给自己"生效了,实际消息去了别处。
|
||||
*/
|
||||
it('拿不到自身身份时失败,且不 fallback 文件传输助手', () => {
|
||||
const result = resolve({ target: { type: 'self' }, template: '' }, { selfWxid: '' })
|
||||
const result = resolve(config({ target: { type: 'self' } }), { selfWxid: '' })
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
@@ -123,7 +159,7 @@ describe('leaveNotificationTarget · 四种目标', () => {
|
||||
})
|
||||
|
||||
it('文件传输助手用协议级固定身份,不靠昵称搜索', () => {
|
||||
const result = resolve({ target: { type: 'file_transfer' }, template: '' })
|
||||
const result = resolve(config({ target: { type: 'file_transfer' } }))
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
@@ -136,10 +172,9 @@ describe('leaveNotificationTarget · 四种目标', () => {
|
||||
})
|
||||
|
||||
it('指定好友用稳定 id,显示名用备注', () => {
|
||||
const result = resolve({
|
||||
target: { type: 'contact', contactId: 'wxid_friend' },
|
||||
template: ''
|
||||
})
|
||||
const result = resolve(
|
||||
config({ target: { type: 'contact', contactId: 'wxid_friend' } })
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
@@ -153,14 +188,14 @@ describe('leaveNotificationTarget · 四种目标', () => {
|
||||
|
||||
describe('leaveNotificationTarget · 失效与非法', () => {
|
||||
it('联系人不在了 → 失败,且不往别处发', () => {
|
||||
const result = resolve({ target: { type: 'contact', contactId: 'wxid_gone' }, template: '' })
|
||||
const result = resolve(config({ target: { type: 'contact', contactId: 'wxid_gone' } }))
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.error).toContain('已不存在或当前无法发送')
|
||||
})
|
||||
|
||||
it('还没选好友 → 失败并提示重新选择', () => {
|
||||
const result = resolve({ target: { type: 'contact' }, template: '' })
|
||||
const result = resolve(config({ target: { type: 'contact' } }))
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.error).toContain('还没有选择通知联系人')
|
||||
@@ -173,7 +208,7 @@ describe('leaveNotificationTarget · 失效与非法', () => {
|
||||
WECHAT_FILE_HELPER_USERNAME,
|
||||
'wxid_self'
|
||||
]) {
|
||||
const result = resolve({ target: { type: 'contact', contactId }, template: '' })
|
||||
const result = resolve(config({ target: { type: 'contact', contactId } }))
|
||||
expect(result.ok).toBe(false)
|
||||
}
|
||||
})
|
||||
@@ -188,10 +223,7 @@ describe('leaveNotificationTarget · 失效与非法', () => {
|
||||
})
|
||||
|
||||
it('未知目标类型 → 失败(不允许静默当成默认值)', () => {
|
||||
const result = resolve({
|
||||
target: { type: 'webhook' as never },
|
||||
template: ''
|
||||
})
|
||||
const result = resolve(config({ target: { type: 'webhook' as never } }))
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat'
|
||||
import type { ScheduledReportTask } from '../../src/shared/scheduled-report'
|
||||
import type {
|
||||
ScheduledReportCreateInput,
|
||||
ScheduledReportExecution,
|
||||
ScheduledReportResult,
|
||||
ScheduledReportTask,
|
||||
ScheduledReportUpdateInput
|
||||
} from '../../src/shared/scheduled-report'
|
||||
import {
|
||||
ScheduledReportApiService,
|
||||
type ScheduledReportApiDependencies
|
||||
@@ -60,36 +66,60 @@ function makeApi(
|
||||
): ApiTestResult {
|
||||
const tasks = options.tasks || []
|
||||
const service = {
|
||||
listTasks: vi.fn(async () => tasks),
|
||||
listExecutions: vi.fn(async () => []),
|
||||
createTask: vi.fn(async (input: Record<string, unknown>) => {
|
||||
const created = task({
|
||||
id: `task-${tasks.length + 1}`,
|
||||
name: String(input.name),
|
||||
group: String(input.group),
|
||||
target: String(input.target),
|
||||
scheduleTime: String(input.scheduleTime),
|
||||
reportRange: input.reportRange as ScheduledReportTask['reportRange'],
|
||||
enabled: Boolean(input.enabled)
|
||||
})
|
||||
tasks.push(created)
|
||||
return { success: true, data: created }
|
||||
}),
|
||||
updateTask: vi.fn(async () => ({ success: true, data: tasks[0] })),
|
||||
deleteTask: vi.fn(async (id: string) => ({ success: true, data: { deletedId: id } })),
|
||||
setTaskEnabled: vi.fn(async (id: string, enabled: boolean) => ({
|
||||
success: true,
|
||||
data: task({ id, enabled })
|
||||
})),
|
||||
runScheduledReportNow: vi.fn(async (id: string) => ({
|
||||
success: true,
|
||||
data: {
|
||||
id: 'execution-1',
|
||||
taskId: id,
|
||||
startedAt: '2026-08-27T01:00:00.000Z',
|
||||
status: 'success' as const
|
||||
listTasks: vi.fn(async (): Promise<ScheduledReportTask[]> => tasks),
|
||||
listExecutions: vi.fn(
|
||||
async (_taskId?: string): Promise<ScheduledReportExecution[]> => []
|
||||
),
|
||||
createTask: vi.fn(
|
||||
async (input: ScheduledReportCreateInput): Promise<ScheduledReportResult<ScheduledReportTask>> => {
|
||||
const created = task({
|
||||
id: `task-${tasks.length + 1}`,
|
||||
name: String(input.name),
|
||||
group: String(input.group),
|
||||
target: String(input.target),
|
||||
scheduleTime: String(input.scheduleTime),
|
||||
reportRange: input.reportRange as ScheduledReportTask['reportRange'],
|
||||
enabled: Boolean(input.enabled)
|
||||
})
|
||||
tasks.push(created)
|
||||
return { success: true, data: created }
|
||||
}
|
||||
}))
|
||||
),
|
||||
updateTask: vi.fn(
|
||||
async (
|
||||
_taskId: string,
|
||||
_input: ScheduledReportUpdateInput
|
||||
): Promise<ScheduledReportResult<ScheduledReportTask>> => ({
|
||||
success: true,
|
||||
data: tasks[0]
|
||||
})
|
||||
),
|
||||
deleteTask: vi.fn(
|
||||
async (id: string): Promise<ScheduledReportResult<{ deletedId: string }>> => ({
|
||||
success: true,
|
||||
data: { deletedId: id }
|
||||
})
|
||||
),
|
||||
setTaskEnabled: vi.fn(
|
||||
async (
|
||||
id: string,
|
||||
enabled: boolean
|
||||
): Promise<ScheduledReportResult<ScheduledReportTask>> => ({
|
||||
success: true,
|
||||
data: task({ id, enabled })
|
||||
})
|
||||
),
|
||||
runScheduledReportNow: vi.fn(
|
||||
async (id: string): Promise<ScheduledReportResult<ScheduledReportExecution>> => ({
|
||||
success: true,
|
||||
data: {
|
||||
id: 'execution-1',
|
||||
taskId: id,
|
||||
startedAt: '2026-08-27T01:00:00.000Z',
|
||||
status: 'success' as const
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
return {
|
||||
api: new ScheduledReportApiService({
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
planScheduledReportMigration,
|
||||
planScheduledReportMigrationBatch,
|
||||
scheduledReportMigrationRuleId,
|
||||
type LegacyScheduledReportTask
|
||||
} from '../../src/main/services/scheduled-report-migration'
|
||||
import { SCHEDULED_REPORT_DEFAULT_MEMBER_NAME_MODE } from '../../src/shared/automation'
|
||||
|
||||
/**
|
||||
* 旧「定时日报任务」→ `AutomationRule(ruleType='scheduled_report')` 的映射器。
|
||||
*
|
||||
* 这是本次迁移里**唯一会改变用户既有行为**的地方,所以每条断言都对应一条产品约束:
|
||||
* - Rule ID 优先复用旧 taskId(不许拿显示名当稳定 ID);
|
||||
* - 配置无损映射(时间 / 范围 / 消息类型 / 模板 / 成员名口径 / 超时);
|
||||
* - 只有"能证明是同一个群"才无损映射成 `source_chat`;
|
||||
* 历史行为事实上支持"生成 A 群日报 → 发 B 群",新产品不支持 ⇒ 必须 `targetNeedsReview`
|
||||
* 且 **enabled = false**,绝不自动改写、绝不静默丢掉目标;
|
||||
* - 解析不到群标识时**保留原值**,运行期如实失败,而不是在这里猜一个群;
|
||||
* - 批次按 id 去重 ⇒ 幂等(同一份文件跑两次结果一致)。
|
||||
*/
|
||||
|
||||
const NOW = Date.parse('2026-08-27T10:00:00+08:00')
|
||||
|
||||
/** 假联系人表:md5 / 群名 → roomId。 */
|
||||
const CONTACTS: Record<string, string> = {
|
||||
'tech-md5': 'tech@chatroom',
|
||||
'product-md5': 'product@chatroom',
|
||||
'技术交流群': 'tech@chatroom'
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 main 侧 `setLegacyConversationResolver` 注入的真实解析器**同口径**:
|
||||
* - 以 `@chatroom` 结尾 ⇒ 原样返回(旧 UI 的 `target` 存的就是 roomId,
|
||||
* 而 `chat.resolveMd5` 对 roomId 是查不到的,所以这条 pass-through 是必需的);
|
||||
* - 否则按 md5 / 群名查联系人表。
|
||||
*/
|
||||
const resolve = (raw: string): string | undefined => {
|
||||
const key = raw.trim()
|
||||
if (!key) return undefined
|
||||
if (key.endsWith('@chatroom')) return key
|
||||
return CONTACTS[key]
|
||||
}
|
||||
|
||||
const task = (overrides: LegacyScheduledReportTask = {}): LegacyScheduledReportTask => ({
|
||||
id: 'scheduled_report_old_1',
|
||||
name: '技术交流群 · 每日日报',
|
||||
group: 'tech-md5',
|
||||
target: 'tech@chatroom',
|
||||
scheduleTime: '09:15',
|
||||
reportRange: 'yesterday',
|
||||
messageTypes: ['text', 'image'],
|
||||
templateId: 'v1',
|
||||
memberNameMode: 'wechatNickname',
|
||||
timeoutSeconds: 600,
|
||||
enabled: true,
|
||||
createdAt: '2026-08-01T01:00:00.000Z',
|
||||
updatedAt: '2026-08-20T01:00:00.000Z',
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('定时日报迁移 · Rule ID', () => {
|
||||
it('优先原样复用旧 taskId(不拿显示名当稳定 ID)', () => {
|
||||
expect(scheduledReportMigrationRuleId(task())).toBe('scheduled_report_old_1')
|
||||
|
||||
// 改名字不影响 id —— 显示名不是身份。
|
||||
expect(scheduledReportMigrationRuleId(task({ name: '换个名字' }))).toBe(
|
||||
'scheduled_report_old_1'
|
||||
)
|
||||
})
|
||||
|
||||
it('旧 id 缺失时退化成由稳定字段派生的确定性 id(重复迁移不产生新规则)', () => {
|
||||
const broken = task({ id: undefined })
|
||||
const first = scheduledReportMigrationRuleId(broken)
|
||||
const second = scheduledReportMigrationRuleId({ ...broken })
|
||||
|
||||
expect(first).toMatch(/^scheduled-report:[0-9a-f]{32}$/)
|
||||
expect(second).toBe(first)
|
||||
})
|
||||
|
||||
it('id 全是空白字符时同样走派生路径', () => {
|
||||
expect(scheduledReportMigrationRuleId(task({ id: ' ' }))).toMatch(/^scheduled-report:/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('定时日报迁移 · 逐字段无损映射', () => {
|
||||
it('配置字段一个不丢(不是"看起来差不多")', () => {
|
||||
const plan = planScheduledReportMigration(task(), resolve, NOW)
|
||||
|
||||
expect(plan.outcome).toBe('lossless_source_chat')
|
||||
expect(plan.name).toBe('技术交流群 · 每日日报')
|
||||
expect(plan.enabled).toBe(true)
|
||||
expect(plan.config.schedule).toEqual({ time: '09:15' })
|
||||
expect(plan.config.report).toEqual({
|
||||
// 来源群存**稳定会话 id**:旧值 'tech-md5' 被解析成 roomId。
|
||||
sourceConversationId: 'tech@chatroom',
|
||||
range: 'yesterday',
|
||||
messageTypes: ['text', 'image'],
|
||||
templateId: 'v1',
|
||||
memberNameMode: 'wechatNickname',
|
||||
timeoutSeconds: 600
|
||||
})
|
||||
expect(plan.config.target).toEqual({ type: 'source_chat' })
|
||||
expect(plan.config.targetNeedsReview).toBeUndefined()
|
||||
expect(plan.config.legacyTarget).toBeUndefined()
|
||||
// 来源群被改写过 ⇒ 留下原文方便用户核对。
|
||||
expect(plan.config.legacySourceGroup).toBe('tech-md5')
|
||||
})
|
||||
|
||||
it('createdAt / updatedAt 继承旧值(ISO → 毫秒),非法值回落迁移时刻', () => {
|
||||
const plan = planScheduledReportMigration(task(), resolve, NOW)
|
||||
expect(plan.createdAt).toBe(Date.parse('2026-08-01T01:00:00.000Z'))
|
||||
expect(plan.updatedAt).toBe(Date.parse('2026-08-20T01:00:00.000Z'))
|
||||
|
||||
const broken = planScheduledReportMigration(
|
||||
task({ createdAt: '不是时间', updatedAt: undefined }),
|
||||
resolve,
|
||||
NOW
|
||||
)
|
||||
expect(broken.createdAt).toBe(NOW)
|
||||
expect(broken.updatedAt).toBe(NOW)
|
||||
})
|
||||
|
||||
it('继承调度游标(lastRunAt / lastScheduledSlot),避免迁移后重复补跑', () => {
|
||||
const slot = new Date('2026-08-26T09:15:00+08:00').toISOString()
|
||||
const plan = planScheduledReportMigration(
|
||||
task({ lastRunAt: '2026-08-26T01:15:00.000Z', lastScheduledSlot: slot }),
|
||||
resolve,
|
||||
NOW
|
||||
)
|
||||
|
||||
expect(plan.config.lastRunAt).toBe('2026-08-26T01:15:00.000Z')
|
||||
expect(plan.config.lastScheduledSlot).toBe(slot)
|
||||
})
|
||||
|
||||
it('脏值一律收敛到合法默认值,绝不落一条跑不起来的规则', () => {
|
||||
const plan = planScheduledReportMigration(
|
||||
task({
|
||||
scheduleTime: '99:99',
|
||||
reportRange: '一个不存在的范围',
|
||||
messageTypes: ['不存在的类型'],
|
||||
templateId: '不存在的模板',
|
||||
memberNameMode: '不存在的口径',
|
||||
timeoutSeconds: 999999
|
||||
}),
|
||||
resolve,
|
||||
NOW
|
||||
)
|
||||
|
||||
expect(plan.config.schedule.time).toBe('18:30')
|
||||
expect(plan.config.report.range).toBe('today')
|
||||
expect(plan.config.report.messageTypes).toEqual(['text'])
|
||||
expect(plan.config.report.templateId).toBe('v1')
|
||||
expect(plan.config.report.memberNameMode).toBe(SCHEDULED_REPORT_DEFAULT_MEMBER_NAME_MODE)
|
||||
expect(plan.config.report.timeoutSeconds).toBe(1800)
|
||||
})
|
||||
|
||||
it('名字缺失时回落到「定时日报」,不会留一条空名字的规则', () => {
|
||||
expect(planScheduledReportMigration(task({ name: ' ' }), resolve, NOW).name).toBe('定时日报')
|
||||
})
|
||||
})
|
||||
|
||||
describe('定时日报迁移 · 发送目标(唯一会改变行为的地方)', () => {
|
||||
it('target 为空 ⇒ 旧系统本就回落到来源群,等价于 source_chat', () => {
|
||||
const plan = planScheduledReportMigration(task({ target: '' }), resolve, NOW)
|
||||
expect(plan.outcome).toBe('lossless_source_chat')
|
||||
expect(plan.config.target).toEqual({ type: 'source_chat' })
|
||||
expect(plan.config.targetNeedsReview).toBeUndefined()
|
||||
})
|
||||
|
||||
it('target 与 group 文本相同 ⇒ 无损映射(不依赖能否解析)', () => {
|
||||
const plan = planScheduledReportMigration(
|
||||
task({ group: 'tech-md5', target: 'tech-md5' }),
|
||||
() => undefined,
|
||||
NOW
|
||||
)
|
||||
expect(plan.outcome).toBe('lossless_source_chat')
|
||||
expect(plan.config.targetNeedsReview).toBeUndefined()
|
||||
})
|
||||
|
||||
it('两者都解析到同一个 roomId ⇒ 无损映射(旧 UI 的常态:group=md5, target=roomId)', () => {
|
||||
const plan = planScheduledReportMigration(
|
||||
task({ group: 'tech-md5', target: 'tech@chatroom' }),
|
||||
resolve,
|
||||
NOW
|
||||
)
|
||||
expect(plan.outcome).toBe('lossless_source_chat')
|
||||
|
||||
// 这条无损判定**依赖**解析器对 `@chatroom` 的 pass-through:
|
||||
// 少了它,旧 UI 里每一组 (md5, roomId) 都会退化成 needs_review,
|
||||
// 等于把所有用户的定时日报全部停用。
|
||||
expect(resolve('tech@chatroom')).toBe('tech@chatroom')
|
||||
})
|
||||
|
||||
it('target 指向**另一个群** ⇒ needs_review + 强制停用 + 保留旧目标原文', () => {
|
||||
const plan = planScheduledReportMigration(
|
||||
task({ group: 'tech-md5', target: 'product@chatroom' }),
|
||||
resolve,
|
||||
NOW
|
||||
)
|
||||
|
||||
expect(plan.outcome).toBe('needs_review')
|
||||
// 不能自动改写目标,也不能在用户确认前就被定时发出去。
|
||||
expect(plan.enabled).toBe(false)
|
||||
expect(plan.config.targetNeedsReview).toBe(true)
|
||||
expect(plan.config.legacyTarget).toBe('product@chatroom')
|
||||
})
|
||||
|
||||
it('target 无法解析 ⇒ 一律 needs_review(拿不到证据就不自动改写)', () => {
|
||||
const plan = planScheduledReportMigration(
|
||||
task({ group: 'tech-md5', target: 'unknown-id' }),
|
||||
resolve,
|
||||
NOW
|
||||
)
|
||||
expect(plan.outcome).toBe('needs_review')
|
||||
expect(plan.enabled).toBe(false)
|
||||
expect(plan.config.legacyTarget).toBe('unknown-id')
|
||||
})
|
||||
|
||||
it('needs_review 的规则即使旧任务是启用的也必须停用', () => {
|
||||
const plan = planScheduledReportMigration(
|
||||
task({ target: 'product@chatroom', enabled: true, group: 'tech-md5' }),
|
||||
resolve,
|
||||
NOW
|
||||
)
|
||||
expect(plan.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('来源群解析不到 ⇒ 保留原值,不在迁移期猜一个群', () => {
|
||||
const plan = planScheduledReportMigration(task({ group: '未知群', target: '' }), resolve, NOW)
|
||||
// 原值留着 —— 运行期会如实失败,比"悄悄换一个群发"安全得多。
|
||||
expect(plan.config.report.sourceConversationId).toBe('未知群')
|
||||
expect(plan.config.legacySourceGroup).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('定时日报迁移 · 批次幂等', () => {
|
||||
const tasks: LegacyScheduledReportTask[] = [
|
||||
task({ id: 'scheduled_report_a', name: 'A', group: 'tech-md5', target: 'tech@chatroom' }),
|
||||
task({ id: 'scheduled_report_b', name: 'B', group: 'product-md5', target: 'product@chatroom' }),
|
||||
task({ id: 'scheduled_report_c', name: 'C', group: 'tech-md5', target: 'product@chatroom' })
|
||||
]
|
||||
|
||||
it('3 条旧任务 ⇒ 3 条规则,并给出判定分布', () => {
|
||||
const { plans, summary } = planScheduledReportMigrationBatch(tasks, resolve, NOW)
|
||||
|
||||
expect(plans.map((plan) => plan.ruleId)).toEqual([
|
||||
'scheduled_report_a',
|
||||
'scheduled_report_b',
|
||||
'scheduled_report_c'
|
||||
])
|
||||
expect(summary).toEqual({
|
||||
total: 3,
|
||||
migrated: 3,
|
||||
lossless: 2,
|
||||
needsReview: 1,
|
||||
duplicatesSkipped: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('重复执行结果完全一致(幂等,不是"看起来没多")', () => {
|
||||
const first = planScheduledReportMigrationBatch(tasks, resolve, NOW)
|
||||
const second = planScheduledReportMigrationBatch(tasks, resolve, NOW)
|
||||
expect(second).toEqual(first)
|
||||
})
|
||||
|
||||
it('同一 id 出现两次只迁一条,并如实计入 duplicatesSkipped', () => {
|
||||
const { plans, summary } = planScheduledReportMigrationBatch(
|
||||
[tasks[0], task({ id: 'scheduled_report_a', name: '重复' })],
|
||||
resolve,
|
||||
NOW
|
||||
)
|
||||
|
||||
expect(plans).toHaveLength(1)
|
||||
expect(plans[0].name).toBe('A')
|
||||
expect(summary.total).toBe(2)
|
||||
expect(summary.migrated).toBe(1)
|
||||
expect(summary.duplicatesSkipped).toBe(1)
|
||||
})
|
||||
|
||||
it('文件里混进非对象元素时跳过而不是崩掉', () => {
|
||||
const { plans } = planScheduledReportMigrationBatch(
|
||||
[tasks[0], null as unknown as LegacyScheduledReportTask, 'x' as unknown as LegacyScheduledReportTask],
|
||||
resolve,
|
||||
NOW
|
||||
)
|
||||
expect(plans).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('空文件 ⇒ 0 条规则(全新安装路径)', () => {
|
||||
const { plans, summary } = planScheduledReportMigrationBatch([], resolve, NOW)
|
||||
expect(plans).toHaveLength(0)
|
||||
expect(summary.migrated).toBe(0)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user