mirror of
https://github.com/wangwangit/SubsTracker.git
synced 2026-09-07 01:23:21 +08:00
新增三个 KV 仓库(v3 通知/调度可观测性数据底座):
1. src/data/reminders.repo.js
- reminder_rules:{subId} 数组,支持 CRUD + 智能预设(4 条:到期前 7/3/1/当天)
- legacyFieldToRule 把 v2 的 reminderUnit/reminderValue 单点提醒
转成 1 条等价 before_expiry 规则
- normalizeRule 修复非法字段,repeatInterval 仅 after_expiry 类型保留
2. src/data/notification-logs.repo.js
- notify_log:{ymdh}:{subId}:{ruleId}:{channel}:{rand}, TTL 30 天
- writeLog / query(subId/channel/status/since/until/limit 过滤)
- 时间倒序,KV.list 拉全后排序
3. src/data/scheduler-logs.repo.js
- sched_log:{isoUtc}, TTL 30 天
- writeLog / getRecent(按时间倒序)
迁移扩展:MIGRATION_STEPS 追加两个 step
- reminder_rules_v3:为现有订阅生成默认提醒规则
- scheduler_logs_v3:v2 的 scheduler_status_history 转换到新表
调试页新增链接:/debug?export=sched_logs&limit=50 直接下载 JSON
测试:
- tests/data/reminders.test.js (13)
- tests/data/notification-logs.test.js (8)
- tests/data/scheduler-logs.test.js (4)
- 共 85 条测试全绿;lint 干净
Refs Task 4 of refactor/v3-product-grade plan.
237 lines
8.3 KiB
JavaScript
237 lines
8.3 KiB
JavaScript
// @ts-check
|
||
/**
|
||
* 订阅仓库 + 迁移单元测试
|
||
*
|
||
* 用 @cloudflare/vitest-pool-workers 提供的真实 KVNamespace 跑,
|
||
* 不需要手写 mock,直接 import { env } from 'cloudflare:test'。
|
||
*/
|
||
import { describe, it, expect, beforeEach } from 'vitest';
|
||
// @ts-ignore — vitest-pool-workers 注入的虚拟模块
|
||
import { env } from 'cloudflare:test';
|
||
|
||
import * as subRepo from '../../src/data/subscriptions.repo.js';
|
||
import {
|
||
ensureMigrations,
|
||
migrateSubscriptions,
|
||
SCHEMA_VERSION,
|
||
_resetMigrationCache,
|
||
_getCachedSchemaVersion
|
||
} from '../../src/data/migrate.js';
|
||
|
||
/** 把 KV 清空(仅遍历常见 v2/v3 键 prefix) */
|
||
async function clearKv() {
|
||
// KV.list 在 vitest-pool-workers 是真实实现
|
||
const list = await env.SUBSCRIPTIONS_KV.list();
|
||
await Promise.all(list.keys.map((k) => env.SUBSCRIPTIONS_KV.delete(k.name)));
|
||
}
|
||
|
||
beforeEach(async () => {
|
||
await clearKv();
|
||
_resetMigrationCache();
|
||
});
|
||
|
||
describe('subscriptions.repo', () => {
|
||
it('listIds 空仓库返回空数组', async () => {
|
||
expect(await subRepo.listIds(env)).toEqual([]);
|
||
expect(await subRepo.listAll(env)).toEqual([]);
|
||
});
|
||
|
||
it('save 新订阅 → 索引追加 + 单 Key 写入', async () => {
|
||
const sub = { id: 'a1', name: 'Netflix' };
|
||
await subRepo.save(env, sub);
|
||
|
||
expect(await subRepo.listIds(env)).toEqual(['a1']);
|
||
expect(await subRepo.getById(env, 'a1')).toEqual(sub);
|
||
expect(await subRepo.listAll(env)).toEqual([sub]);
|
||
});
|
||
|
||
it('save 已存在订阅 → 仅更新 sub:{id},索引不变', async () => {
|
||
await subRepo.save(env, { id: 'a1', name: 'Old' });
|
||
await subRepo.save(env, { id: 'a1', name: 'New' });
|
||
|
||
expect(await subRepo.listIds(env)).toEqual(['a1']);
|
||
expect((await subRepo.getById(env, 'a1')).name).toBe('New');
|
||
});
|
||
|
||
it('saveMany 批量写入并合并索引', async () => {
|
||
await subRepo.saveMany(env, [
|
||
{ id: 'b1', name: 'A' },
|
||
{ id: 'b2', name: 'B' },
|
||
{ id: 'b3', name: 'C' }
|
||
]);
|
||
|
||
expect((await subRepo.listIds(env)).sort()).toEqual(['b1', 'b2', 'b3']);
|
||
expect(await subRepo.listAll(env)).toHaveLength(3);
|
||
});
|
||
|
||
it('deleteById 移除单 Key 与索引', async () => {
|
||
await subRepo.save(env, { id: 'c1', name: 'X' });
|
||
await subRepo.save(env, { id: 'c2', name: 'Y' });
|
||
|
||
expect(await subRepo.deleteById(env, 'c1')).toBe(true);
|
||
expect(await subRepo.listIds(env)).toEqual(['c2']);
|
||
expect(await subRepo.getById(env, 'c1')).toBeNull();
|
||
});
|
||
|
||
it('deleteById 不存在 → 返回 false 且清理悬空索引', async () => {
|
||
await env.SUBSCRIPTIONS_KV.put('sub_index', JSON.stringify(['ghost']));
|
||
expect(await subRepo.deleteById(env, 'ghost')).toBe(false);
|
||
expect(await subRepo.listIds(env)).toEqual([]);
|
||
});
|
||
|
||
it('replaceAll 整体替换(迁移用)', async () => {
|
||
await subRepo.save(env, { id: 'old1' });
|
||
await subRepo.save(env, { id: 'old2' });
|
||
|
||
await subRepo.replaceAll(env, [{ id: 'new1' }, { id: 'new2' }, { id: 'new3' }]);
|
||
|
||
expect((await subRepo.listIds(env)).sort()).toEqual(['new1', 'new2', 'new3']);
|
||
expect(await subRepo.getById(env, 'old1')).toBeNull();
|
||
expect(await subRepo.getById(env, 'new2')).toEqual({ id: 'new2' });
|
||
});
|
||
|
||
it('save 缺少 id 抛异常', async () => {
|
||
await expect(subRepo.save(env, /** @type {any} */ ({}))).rejects.toThrow();
|
||
await expect(subRepo.save(env, /** @type {any} */ ({ id: '' }))).rejects.toThrow();
|
||
});
|
||
});
|
||
|
||
describe('migrate.migrateSubscriptions', () => {
|
||
it('旧 subscriptions 数组 → 新 sub:{id} + sub_index + 备份', async () => {
|
||
const old = [
|
||
{ id: 's1', name: 'Netflix' },
|
||
{ id: 's2', name: 'Spotify' },
|
||
{ id: 's3', name: 'AWS' }
|
||
];
|
||
await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(old));
|
||
|
||
await migrateSubscriptions(env);
|
||
|
||
expect((await subRepo.listIds(env)).sort()).toEqual(['s1', 's2', 's3']);
|
||
expect((await subRepo.getById(env, 's2')).name).toBe('Spotify');
|
||
|
||
// 备份存在,原 Key 已删
|
||
expect(await env.SUBSCRIPTIONS_KV.get('subscriptions_v2_backup')).toBeTruthy();
|
||
expect(await env.SUBSCRIPTIONS_KV.get('subscriptions')).toBeNull();
|
||
});
|
||
|
||
it('旧 subscriptions 不存在 → 仅写空索引,不报错', async () => {
|
||
await migrateSubscriptions(env);
|
||
expect(await subRepo.listIds(env)).toEqual([]);
|
||
expect(await env.SUBSCRIPTIONS_KV.get('subscriptions_v2_backup')).toBeNull();
|
||
});
|
||
|
||
it('旧 subscriptions 是损坏 JSON → 按空处理不抛异常', async () => {
|
||
await env.SUBSCRIPTIONS_KV.put('subscriptions', '{ this is not valid json');
|
||
await migrateSubscriptions(env);
|
||
expect(await subRepo.listIds(env)).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('migrate.migrateSchedulerLogs', () => {
|
||
it('迁移旧 scheduler_status_history 到 sched_log:*', async () => {
|
||
const v2History = [
|
||
{
|
||
lastRunAt: '2026-05-24T10:00:00.000Z',
|
||
timezone: 'Asia/Shanghai',
|
||
currentHour: '18',
|
||
configuredHours: ['08', '18'],
|
||
shouldNotifyThisHour: true,
|
||
checkedSubscriptions: 5,
|
||
expiringMatched: 2,
|
||
dedupeSkipped: 1,
|
||
sent: true,
|
||
updatedSubscriptions: 1,
|
||
reason: '已尝试发送'
|
||
},
|
||
{
|
||
lastRunAt: '2026-05-24T09:00:00.000Z',
|
||
timezone: 'Asia/Shanghai',
|
||
currentHour: '17',
|
||
configuredHours: ['08', '18'],
|
||
shouldNotifyThisHour: false,
|
||
sent: false,
|
||
reason: '当前小时未在通知时段内'
|
||
}
|
||
];
|
||
await env.SUBSCRIPTIONS_KV.put('scheduler_status_history', JSON.stringify(v2History));
|
||
|
||
const { migrateSchedulerLogs } = await import('../../src/data/migrate.js');
|
||
await migrateSchedulerLogs(env);
|
||
|
||
const list = await env.SUBSCRIPTIONS_KV.list({ prefix: 'sched_log:' });
|
||
expect(list.keys.length).toBe(2);
|
||
});
|
||
});
|
||
|
||
describe('migrate.ensureMigrations(编排器)', () => {
|
||
it('初次执行 → 设置 schema_version 与 step 标记', async () => {
|
||
await env.SUBSCRIPTIONS_KV.put(
|
||
'subscriptions',
|
||
JSON.stringify([{ id: 'x1', name: 'X', reminderUnit: 'day', reminderValue: 5 }])
|
||
);
|
||
|
||
const result = await ensureMigrations(env);
|
||
|
||
expect(result.migrated).toBe(true);
|
||
expect(result.ranSteps).toContain('subscriptions_v3');
|
||
expect(result.ranSteps).toContain('reminder_rules_v3');
|
||
expect(result.ranSteps).toContain('scheduler_logs_v3');
|
||
expect(await env.SUBSCRIPTIONS_KV.get('schema_version')).toBe(SCHEMA_VERSION);
|
||
expect(await env.SUBSCRIPTIONS_KV.get('migrate:subscriptions_v3')).toBe('done');
|
||
expect(await env.SUBSCRIPTIONS_KV.get('migrate:reminder_rules_v3')).toBe('done');
|
||
expect(await env.SUBSCRIPTIONS_KV.get('migrate:scheduler_logs_v3')).toBe('done');
|
||
expect(_getCachedSchemaVersion()).toBe(SCHEMA_VERSION);
|
||
|
||
// reminder rules 也已生成
|
||
const rules = await env.SUBSCRIPTIONS_KV.get('reminder_rules:x1');
|
||
expect(rules).toBeTruthy();
|
||
const parsed = JSON.parse(/** @type {string} */ (rules));
|
||
expect(parsed).toHaveLength(1);
|
||
expect(parsed[0].value).toBe(5);
|
||
});
|
||
|
||
it('schema_version=v3 → 跳过,命中缓存', async () => {
|
||
await env.SUBSCRIPTIONS_KV.put('schema_version', SCHEMA_VERSION);
|
||
|
||
const r1 = await ensureMigrations(env);
|
||
expect(r1.migrated).toBe(false);
|
||
expect(r1.reason).toBe('already_v3');
|
||
|
||
const r2 = await ensureMigrations(env);
|
||
expect(r2.reason).toBe('cached'); // 二次走内存缓存
|
||
});
|
||
|
||
it('幂等:连续两次执行结果一致', async () => {
|
||
await env.SUBSCRIPTIONS_KV.put(
|
||
'subscriptions',
|
||
JSON.stringify([{ id: 'idem', name: 'A' }])
|
||
);
|
||
await ensureMigrations(env);
|
||
_resetMigrationCache();
|
||
|
||
// 第二次:schema_version 已是 v3,应直接跳过
|
||
const r2 = await ensureMigrations(env);
|
||
expect(r2.migrated).toBe(false);
|
||
expect(r2.reason).toBe('already_v3');
|
||
|
||
expect((await subRepo.listIds(env)).length).toBe(1);
|
||
});
|
||
|
||
it('锁存在时 → 跳过,下次再试', async () => {
|
||
await env.SUBSCRIPTIONS_KV.put('migration_lock', 'someone-else-running');
|
||
|
||
const result = await ensureMigrations(env);
|
||
expect(result.migrated).toBe(false);
|
||
expect(result.reason).toBe('locked_elsewhere');
|
||
|
||
// 没设置 schema_version
|
||
expect(await env.SUBSCRIPTIONS_KV.get('schema_version')).toBeNull();
|
||
});
|
||
|
||
it('迁移成功后会自动释放锁', async () => {
|
||
await ensureMigrations(env);
|
||
expect(await env.SUBSCRIPTIONS_KV.get('migration_lock')).toBeNull();
|
||
});
|
||
});
|