830f8a4011
Memind CI / Test, build, and release guards (push) Successful in 5m36s
Ship the 10-item numeric subscription loop on WeChat MP, wire schedule delivery for news/weather/quote/health/finance/tech/knowledge/night/surprise pushes, and extend news morning draft cover generation plus a one-shot draft push script. Co-authored-by: Cursor <cursoragent@cursor.com>
497 lines
15 KiB
JavaScript
497 lines
15 KiB
JavaScript
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'), `<!DOCTYPE html><html><body>
|
|
<div class="date-badge">2026年9月20日 · 星期六</div>
|
|
<div class="card"><span class="tag">要闻</span><h3>测试新闻标题</h3><p>正文</p></div>
|
|
</body></html>`);
|
|
|
|
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 = `<!DOCTYPE html><html><body>
|
|
<div class="date-badge">2026年9月20日</div>
|
|
<div class="section" id="finance"><div class="card"><h3>沪指收涨</h3><p>市场走高</p></div></div>
|
|
</body></html>`;
|
|
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);
|
|
});
|