feat(schedule): 待办提醒推送、行事历仅展示提醒与管理 API
单次提醒 worker 投递、local 时间写入校验、未来 7 天提醒列表与忽略/批量删除;行事历去掉事项重复展示。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -31,3 +31,117 @@ test('listUserNotifications accepts mysql JSON columns returned as objects', asy
|
||||
assert.equal(notifications.length, 1);
|
||||
assert.deepEqual(notifications[0].data, { thresholdCents: 2000 });
|
||||
});
|
||||
|
||||
test('buildReminderText formats reminder with event time', async () => {
|
||||
const queries = [];
|
||||
const service = createScheduleService({
|
||||
async query(sql, params) {
|
||||
queries.push(sql.trim().slice(0, 40));
|
||||
if (sql.includes('FROM h5_schedule_items')) {
|
||||
return [
|
||||
[
|
||||
{
|
||||
id: 'item-1',
|
||||
user_id: 'user-1',
|
||||
kind: 'event',
|
||||
title: '开会',
|
||||
description: null,
|
||||
status: 'active',
|
||||
start_at: 1_786_000_000_000,
|
||||
end_at: null,
|
||||
due_at: null,
|
||||
all_day: 0,
|
||||
timezone: 'Asia/Shanghai',
|
||||
location: '会议室 A',
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
return [[]];
|
||||
},
|
||||
clock: { now: () => 1_786_000_000_000 },
|
||||
});
|
||||
|
||||
const text = await service.buildReminderText({
|
||||
userId: 'user-1',
|
||||
itemId: 'item-1',
|
||||
remindAt: 1_785_964_000_000,
|
||||
});
|
||||
|
||||
assert.match(text, /【待办提醒】开会/);
|
||||
assert.match(text, /事项时间:/);
|
||||
assert.match(text, /提醒时间:/);
|
||||
assert.match(text, /地点:会议室 A/);
|
||||
});
|
||||
|
||||
test('parseLocalDateTimeString resolves Asia/Shanghai wall clock', async () => {
|
||||
const { parseLocalDateTimeString, assertReasonableScheduleEpoch } = await import('./schedule-time.mjs');
|
||||
const ms = parseLocalDateTimeString('2026-07-01 06:00', 'Asia/Shanghai');
|
||||
assert.equal(new Date(ms).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }), '2026/7/1 06:00:00');
|
||||
const now = Date.UTC(2026, 5, 29, 16, 0, 0);
|
||||
assert.throws(
|
||||
() => assertReasonableScheduleEpoch(1779127200000, { now, fieldName: '开始时间' }),
|
||||
/超出合理范围/,
|
||||
);
|
||||
});
|
||||
|
||||
test('cancelReminder marks pending reminder as cancelled', async () => {
|
||||
const service = createScheduleService({
|
||||
async query(sql) {
|
||||
if (sql.includes('FROM h5_schedule_reminders r') && sql.includes('WHERE r.id = ?')) {
|
||||
return [
|
||||
[
|
||||
{
|
||||
id: 'rem-1',
|
||||
user_id: 'user-1',
|
||||
item_id: 'item-1',
|
||||
remind_at: 1782856500000,
|
||||
offset_minutes: 5,
|
||||
channel: 'wechat',
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
last_error: null,
|
||||
locked_until: null,
|
||||
sent_at: null,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
item_title: '早起跑步',
|
||||
item_kind: 'event',
|
||||
item_timezone: 'Asia/Shanghai',
|
||||
item_start_at: 1782856800000,
|
||||
item_end_at: 1782858600000,
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
if (sql.includes('UPDATE h5_schedule_reminders') && sql.includes('cancelled')) {
|
||||
return [{ affectedRows: 1 }];
|
||||
}
|
||||
return [[], undefined];
|
||||
},
|
||||
});
|
||||
|
||||
const reminder = await service.cancelReminder({ userId: 'user-1', reminderId: 'rem-1' });
|
||||
assert.equal(reminder.status, 'cancelled');
|
||||
});
|
||||
|
||||
test('deleteReminders bulk deletes by user', async () => {
|
||||
const service = createScheduleService({
|
||||
async query(sql, params) {
|
||||
if (sql.startsWith('DELETE FROM h5_schedule_reminders')) {
|
||||
assert.equal(params[0], 'user-1');
|
||||
assert.deepEqual(params.slice(1), ['rem-1', 'rem-2']);
|
||||
return [{ affectedRows: 2 }];
|
||||
}
|
||||
return [[], undefined];
|
||||
},
|
||||
});
|
||||
|
||||
const deleted = await service.deleteReminders({
|
||||
userId: 'user-1',
|
||||
reminderIds: ['rem-1', 'rem-2'],
|
||||
});
|
||||
assert.equal(deleted, 2);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user