import assert from 'node:assert/strict'; import test from 'node:test'; import { createScheduleService, shouldAutoCreateReminderAtStart } from './schedule-service.mjs'; test('shouldAutoCreateReminderAtStart detects reminder intent from title or description', () => { assert.equal( shouldAutoCreateReminderAtStart({ title: '吃药提醒', description: '今天上午10点吃药', startAt: 1, }), true, ); assert.equal( shouldAutoCreateReminderAtStart({ title: '开会', description: '明天下午三点', startAt: 1, }), false, ); assert.equal( shouldAutoCreateReminderAtStart({ title: '吃药提醒', description: '今天上午10点吃药', startAt: 1, noReminder: true, }), false, ); assert.equal( shouldAutoCreateReminderAtStart({ title: '买菜', startAt: null, }), false, ); }); test('listUserNotifications accepts mysql JSON columns returned as objects', async () => { const service = createScheduleService({ async query(sql) { assert.match(sql, /FROM h5_user_notifications/); return [ [ { id: 'notification-1', user_id: 'user-1', channel: 'wechat', notification_type: 'balance_low', title: '余额不足提醒', body: '你的账户余额已低于 20.00 元,请及时充值。', data_json: { thresholdCents: 2000 }, status: 'unread', read_at: null, created_at: 1782313300000, updated_at: 1782313300000, }, ], ]; }, }); const notifications = await service.listUserNotifications({ userId: 'user-1' }); assert.equal(notifications.length, 1); assert.deepEqual(notifications[0].data, { thresholdCents: 2000 }); }); test('updateDailyScheduleItem updates metadata and replaces pending reminder', async () => { const queries = []; let reminderSeq = 0; const service = createScheduleService({ async query(sql, params) { queries.push(String(sql).trim().slice(0, 48)); if (sql.includes('FROM h5_schedule_items') && sql.includes('WHERE id = ?')) { return [[{ id: 'item-1', user_id: 'user-1', kind: 'event', title: '早安问候', status: 'active', start_at: 1_786_000_000_000, timezone: 'Asia/Shanghai', metadata_json: JSON.stringify({ source: 'subscribe_morning_reminder', recurrence: 'daily', dailyHour: 8, dailyMinute: 0, }), }]]; } if (sql.includes('FROM h5_schedule_reminders') && sql.includes("status = 'pending'")) { return [[{ id: 'rem-old', user_id: 'user-1', item_id: 'item-1', remind_at: 1, channel: 'wechat', status: 'pending' }]]; } if (sql.includes('INSERT INTO h5_schedule_reminders')) { reminderSeq += 1; return [[]]; } return [[]]; }, clock: { now: () => Date.parse('2026-09-10T10:00:00+08:00') }, }); const result = await service.updateDailyScheduleItem({ userId: 'user-1', itemId: 'item-1', hour: 7, minute: 30, }); assert.equal(result.item.metadata.dailyHour, 7); assert.equal(result.item.metadata.dailyMinute, 30); assert.ok(queries.some((q) => q.includes('UPDATE h5_schedule_items'))); assert.ok(queries.some((q) => q.includes('UPDATE h5_schedule_reminders'))); }); test('buildReminderText uses daily morning greeting library for subscribe reminders', async () => { const service = createScheduleService({ async query(sql) { if (sql.includes('FROM h5_schedule_items')) { return [ [ { id: 'item-morning', user_id: 'user-morning', 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: null, metadata_json: JSON.stringify({ source: 'subscribe_morning_reminder', recurrence: 'daily' }), created_at: 1, updated_at: 1, }, ], ]; } return [[]]; }, clock: { now: () => Date.parse('2026-09-10T08:00:00+08:00') }, }); const text = await service.buildReminderText({ userId: 'user-morning', itemId: 'item-morning', remindAt: Date.parse('2026-09-10T08:00:00+08:00'), }); assert.match(text, /^☀️ 早安\n\n/); assert.doesNotMatch(text, /【待办提醒】/); }); 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); });