feat(wechat): add Intent Transaction Layer with unified task schema
Introduce Draft → Confirm → Commit flow for WeChat schedule intents behind feature flags, plus h5_tasks dual-write/read aggregation and rollout scripts so reminders and automations get explicit user confirmation before persisting. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createTaskUnifiedService,
|
||||
formatUnifiedTaskListReply,
|
||||
mapCommitToUnifiedTask,
|
||||
} from './task-unified-service.mjs';
|
||||
|
||||
test('mapCommitToUnifiedTask maps timed reminder commit', () => {
|
||||
const mapped = mapCommitToUnifiedTask({
|
||||
userId: 'user-1',
|
||||
kind: 'timed_reminder',
|
||||
committed: {
|
||||
item: { id: 'item-1', title: '项目计划例会', startAt: 1000, timezone: 'Asia/Shanghai' },
|
||||
reminder: { id: 'rem-1', remindAt: 1000, channel: 'wechat' },
|
||||
},
|
||||
});
|
||||
assert.equal(mapped.type, 'reminder');
|
||||
assert.equal(mapped.title, '项目计划例会');
|
||||
assert.deepEqual(mapped.legacyRef, { table: 'h5_schedule_reminders', id: 'rem-1' });
|
||||
});
|
||||
|
||||
test('mapCommitToUnifiedTask maps scheduled task commit', () => {
|
||||
const mapped = mapCommitToUnifiedTask({
|
||||
userId: 'user-1',
|
||||
kind: 'scheduled_task',
|
||||
committed: {
|
||||
task: {
|
||||
id: 'task-1',
|
||||
title: '每日新闻页',
|
||||
taskSpec: '搜索今日新闻并生成 HTML 页面',
|
||||
recurrence: 'daily',
|
||||
hour: 6,
|
||||
minute: 0,
|
||||
nextRunAt: 2000,
|
||||
timezone: 'Asia/Shanghai',
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(mapped.type, 'automation');
|
||||
assert.equal(mapped.actionLevel, 2);
|
||||
});
|
||||
|
||||
test('formatUnifiedTaskListReply renders mixed task types', () => {
|
||||
const text = formatUnifiedTaskListReply([
|
||||
{
|
||||
type: 'automation',
|
||||
title: '每日新闻页',
|
||||
trigger: { repeat: 'daily', hour: 6, minute: 0 },
|
||||
nextRunAt: null,
|
||||
},
|
||||
{
|
||||
type: 'reminder',
|
||||
title: '项目计划例会',
|
||||
trigger: { at: 1000, timezone: 'Asia/Shanghai' },
|
||||
nextRunAt: 1000,
|
||||
},
|
||||
]);
|
||||
assert.match(text, /任务一览/);
|
||||
assert.match(text, /每日新闻页/);
|
||||
assert.match(text, /项目计划例会/);
|
||||
});
|
||||
|
||||
function createMemoryPool() {
|
||||
const tasks = [];
|
||||
return {
|
||||
tasks,
|
||||
async query(sql, params = []) {
|
||||
if (sql.includes('INSERT INTO h5_tasks')) {
|
||||
const row = {
|
||||
id: params[0],
|
||||
user_id: params[1],
|
||||
type: params[2],
|
||||
title: params[3],
|
||||
spec_json: params[4],
|
||||
trigger_json: params[5],
|
||||
action_json: params[6],
|
||||
action_level: params[7],
|
||||
notify_channel: params[8],
|
||||
status: params[9],
|
||||
next_run_at: params[10],
|
||||
last_run_at: params[11],
|
||||
legacy_ref_json: params[12],
|
||||
source_channel: params[13],
|
||||
source_message_id: params[14],
|
||||
source_text: params[15],
|
||||
created_at: params[16],
|
||||
updated_at: params[17],
|
||||
};
|
||||
tasks.push(row);
|
||||
return [{ affectedRows: 1 }];
|
||||
}
|
||||
if (sql.includes('SELECT id FROM h5_tasks') && sql.includes('legacy_ref_json')) {
|
||||
const ref = params[1];
|
||||
const hit = tasks.find((row) => row.user_id === params[0] && row.legacy_ref_json === ref);
|
||||
return [[hit].filter(Boolean)];
|
||||
}
|
||||
if (sql.includes('SELECT * FROM h5_tasks WHERE id = ?')) {
|
||||
return [[tasks.find((row) => row.id === params[0])].filter(Boolean)];
|
||||
}
|
||||
if (sql.includes('SELECT * FROM h5_tasks') && sql.includes('user_id = ?')) {
|
||||
return [tasks.filter((row) => row.user_id === params[0] && row.status === params[1])];
|
||||
}
|
||||
if (sql.includes('FROM h5_scheduled_tasks')) return [[]];
|
||||
if (sql.includes('FROM h5_schedule_reminders')) return [[]];
|
||||
if (sql.includes('FROM h5_schedule_digest_subscriptions')) return [[]];
|
||||
throw new Error(`Unexpected SQL: ${sql}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('syncFromCommit inserts unified task row', async () => {
|
||||
const pool = createMemoryPool();
|
||||
const service = createTaskUnifiedService(pool, { clock: { now: () => 3000 } });
|
||||
const row = await service.syncFromCommit({
|
||||
userId: 'user-1',
|
||||
kind: 'create_todo',
|
||||
committed: { item: { id: 'item-1', title: '跟进合同', timezone: 'Asia/Shanghai' } },
|
||||
});
|
||||
assert.equal(row.type, 'todo');
|
||||
assert.equal(row.title, '跟进合同');
|
||||
assert.equal(pool.tasks.length, 1);
|
||||
});
|
||||
|
||||
test('listUserTasks merges stored and legacy without duplicates', async () => {
|
||||
const pool = createMemoryPool();
|
||||
pool.query = async (sql, params = []) => {
|
||||
if (sql.includes('INSERT INTO h5_tasks') || sql.includes('SELECT id FROM h5_tasks')) {
|
||||
return createMemoryPool().query(sql, params);
|
||||
}
|
||||
if (sql.includes('SELECT * FROM h5_tasks') && sql.includes('user_id = ?')) {
|
||||
return [[{
|
||||
id: 'unified-1',
|
||||
user_id: 'user-1',
|
||||
type: 'automation',
|
||||
title: '每日新闻页',
|
||||
spec_json: '{}',
|
||||
trigger_json: '{"repeat":"daily","hour":6,"minute":0}',
|
||||
action_json: '{"kind":"agent_run"}',
|
||||
action_level: 2,
|
||||
notify_channel: 'both',
|
||||
status: 'active',
|
||||
next_run_at: 5000,
|
||||
last_run_at: null,
|
||||
legacy_ref_json: '{"table":"h5_scheduled_tasks","id":"task-1"}',
|
||||
source_channel: 'wechat',
|
||||
source_message_id: null,
|
||||
source_text: null,
|
||||
created_at: 1000,
|
||||
updated_at: 1000,
|
||||
}]];
|
||||
}
|
||||
if (sql.includes('FROM h5_scheduled_tasks')) {
|
||||
return [[{
|
||||
id: 'task-1',
|
||||
user_id: 'user-1',
|
||||
title: '每日新闻页',
|
||||
task_spec: 'news',
|
||||
recurrence: 'daily',
|
||||
hour: 6,
|
||||
minute: 0,
|
||||
weekday: null,
|
||||
timezone: 'Asia/Shanghai',
|
||||
next_run_at: 5000,
|
||||
status: 'active',
|
||||
source_channel: 'wechat',
|
||||
source_message_id: null,
|
||||
source_text: null,
|
||||
created_at: 1000,
|
||||
updated_at: 1000,
|
||||
}]];
|
||||
}
|
||||
if (sql.includes('FROM h5_schedule_reminders')) return [[]];
|
||||
if (sql.includes('FROM h5_schedule_digest_subscriptions')) return [[]];
|
||||
if (sql.includes('FROM h5_balance_alert_subscriptions')) return [[]];
|
||||
if (sql.includes('FROM h5_schedule_items') && sql.includes("kind = 'task'")) return [[]];
|
||||
throw new Error(`Unexpected SQL: ${sql}`);
|
||||
};
|
||||
const service = createTaskUnifiedService(pool);
|
||||
const tasks = await service.listUserTasks({ userId: 'user-1', limit: 10 });
|
||||
assert.equal(tasks.length, 1);
|
||||
assert.equal(tasks[0].id, 'unified-1');
|
||||
});
|
||||
Reference in New Issue
Block a user