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('buildReminderDelivery returns news digest with verified html url', async () => { const fs = await import('node:fs'); const os = await import('node:os'); const path = await import('node:path'); const { PUBLIC_ZONE_DIR, PUBLISH_ROOT_DIR } = await import('./user-publish.mjs'); const root = fs.mkdtempSync(path.join(os.tmpdir(), 'schedule-news-')); const userId = 'news-source-user'; const publicDir = path.join(root, PUBLISH_ROOT_DIR, userId, PUBLIC_ZONE_DIR); fs.mkdirSync(publicDir, { recursive: true }); fs.writeFileSync(path.join(publicDir, 'daily-news-0920.html'), `
2026年9月20日 · 星期六
要闻

测试新闻标题

正文

`); const service = createScheduleService({ async query(sql) { if (sql.includes('FROM h5_schedule_items')) { return [[{ id: 'item-news', user_id: 'user-subscriber', kind: 'event', title: '每日新闻早报', description: null, status: 'active', start_at: Date.parse('2026-09-20T07:30:00+08:00'), end_at: null, due_at: null, all_day: 0, timezone: 'Asia/Shanghai', location: null, metadata_json: JSON.stringify({ source: 'wechat_push:news', recurrence: 'daily' }), created_at: 1, updated_at: 1, }], []]; } if (sql.includes('h5_wechat_admin_config')) { return [[{ config_json: JSON.stringify({ sourceUserId: userId, pageSlugPattern: 'daily-news-*', timezone: 'Asia/Shanghai', }), }], []]; } return [[]]; }, clock: { now: () => Date.parse('2026-09-20T07:30:00+08:00') }, }, { h5Root: root, env: { H5_WECHAT_NEWS_MORNING_DRAFT_USER_ID: userId }, }); const delivery = await service.buildReminderDelivery({ userId: 'user-subscriber', itemId: 'item-news', remindAt: Date.parse('2026-09-20T07:30:00+08:00'), }); assert.match(delivery.text, /测试新闻标题/u); assert.equal(delivery.verifiedHtmlUrls.length, 1); assert.match(delivery.verifiedHtmlUrls[0], /daily-news-0920\.html/u); }); test('buildReminderDelivery returns quote and health push text', async () => { const items = { 'item-quote': { id: 'item-quote', user_id: 'user-push', kind: 'event', title: '每日金句', description: null, status: 'active', start_at: Date.parse('2026-09-20T07:30:00+08:00'), end_at: null, due_at: null, all_day: 0, timezone: 'Asia/Shanghai', location: null, metadata_json: JSON.stringify({ source: 'wechat_push:quote', recurrence: 'daily', dailyHour: 7, dailyMinute: 30, }), created_at: 1, updated_at: 1, }, 'item-health': { id: 'item-health', user_id: 'user-push', kind: 'event', title: '健康提醒', description: null, status: 'active', start_at: Date.parse('2026-09-20T10:00:00+08:00'), end_at: null, due_at: null, all_day: 0, timezone: 'Asia/Shanghai', location: null, metadata_json: JSON.stringify({ source: 'wechat_push:health', recurrence: 'daily', dailyHour: 10, dailyMinute: 0, }), created_at: 1, updated_at: 1, }, }; const service = createScheduleService({ async query(sql, params) { if (sql.includes('FROM h5_schedule_items')) { const itemId = params?.[0]; return [[items[itemId] ?? null].filter(Boolean), []]; } return [[]]; }, clock: { now: () => Date.parse('2026-09-20T10:00:00+08:00') }, }); const quoteDelivery = await service.buildReminderDelivery({ userId: 'user-push', itemId: 'item-quote', remindAt: Date.parse('2026-09-20T07:30:00+08:00'), }); assert.match(quoteDelivery.text, /^💡 每日金句/u); const healthDelivery = await service.buildReminderDelivery({ userId: 'user-push', itemId: 'item-health', remindAt: Date.parse('2026-09-20T10:00:00+08:00'), }); assert.match(healthDelivery.text, /^🏃 健康提醒/u); }); test('buildReminderDelivery returns finance and night push text', async () => { const financeHtml = `
2026年9月20日

沪指收涨

市场走高

`; const root = (await import('node:fs')).mkdtempSync( (await import('node:path')).join((await import('node:os')).tmpdir(), 'schedule-phase4-'), ); const { PUBLIC_ZONE_DIR, PUBLISH_ROOT_DIR } = await import('./user-publish.mjs'); const userId = 'news-source-phase4'; const publicDir = (await import('node:path')).join(root, PUBLISH_ROOT_DIR, userId, PUBLIC_ZONE_DIR); (await import('node:fs')).mkdirSync(publicDir, { recursive: true }); (await import('node:fs')).writeFileSync( (await import('node:path')).join(publicDir, 'daily-news-0920.html'), financeHtml, ); const items = { 'item-finance': { id: 'item-finance', user_id: 'user-push', kind: 'event', title: '财经市场', description: null, status: 'active', start_at: Date.parse('2026-09-20T08:30:00+08:00'), end_at: null, due_at: null, all_day: 0, timezone: 'Asia/Shanghai', location: null, metadata_json: JSON.stringify({ source: 'wechat_push:finance', recurrence: 'daily' }), created_at: 1, updated_at: 1, }, 'item-night': { id: 'item-night', user_id: 'user-push', kind: 'event', title: '晚安总结', description: null, status: 'active', start_at: Date.parse('2026-09-20T22:00:00+08:00'), end_at: null, due_at: null, all_day: 0, timezone: 'Asia/Shanghai', location: null, metadata_json: JSON.stringify({ source: 'wechat_push:night', recurrence: 'daily' }), created_at: 1, updated_at: 1, }, }; const service = createScheduleService({ async query(sql, params) { if (sql.includes('FROM h5_schedule_items')) { return [[items[params?.[0]] ?? null].filter(Boolean), []]; } if (sql.includes('h5_wechat_admin_config')) { return [[{ config_json: JSON.stringify({ sourceUserId: userId, pageSlugPattern: 'daily-news-*', timezone: 'Asia/Shanghai', }), }], []]; } return [[]]; }, clock: { now: () => Date.parse('2026-09-20T22:00:00+08:00') }, }, { h5Root: root, env: {} }); const financeDelivery = await service.buildReminderDelivery({ userId: 'user-push', itemId: 'item-finance', remindAt: Date.parse('2026-09-20T08:30:00+08:00'), }); assert.match(financeDelivery.text, /沪指收涨/u); const nightDelivery = await service.buildReminderDelivery({ userId: 'user-push', itemId: 'item-night', remindAt: Date.parse('2026-09-20T22:00:00+08:00'), }); assert.match(nightDelivery.text, /^🌙 晚安总结/u); }); 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); });