mirror of
https://github.com/wangwangit/SubsTracker.git
synced 2026-07-01 17:14:22 +08:00
时区默认值:
- DEFAULT_CONFIG.TIMEZONE: 'UTC' → 'Asia/Shanghai'
- 配置页时区下拉用 <optgroup> 把中国标准时间钉到"🇨🇳 推荐"分组
- 未知时区 fallback 也改 Asia/Shanghai
去除版本号字眼(45 文件):
- 全部 "维护人:v3 重构 (2026-05)" 文件头注释删除
- 文件描述里的 "(v3 重写)" "(v3 新增)" 等去掉
- @deprecated v2 兼容 → @deprecated 旧版兼容函数
- 注释里的 "v2/v3" 替换为中性表述("早期版本""旧调度器""既有客户端"等)
- src/api/handlers/v3-routes.js → src/api/handlers/extras.js
- tests/api/v3-routes.test.js → tests/api/extras-routes.test.js
- 函数名 handleV3Routes → handleExtraRoutes
- README / MIGRATION / ARCHITECTURE 整体重写:
- README 去掉 "v3 关键改进里程碑" 段,改写为标准产品功能介绍
- MIGRATION 重写为通用"升级指南",用"旧版本"代替 v2
- ARCHITECTURE 模块图与流程描述去掉所有 v3 标签
- package.json description 去掉 v3
- wrangler.toml / wrangler.dev.toml 注释里 v3 去掉
故意保留的(持久化的 KV 数据兼容性标识):
- schema_version 字符串值 'v3'
- migrate:subscriptions_v3 / reminder_rules_v3 / scheduler_logs_v3 step ID
- subscriptions_v2_backup KV key
- 文档对这些标识有显式说明:"是 KV 内部数据兼容性标记,与产品版本号无关"
171 测试全绿;lint 干净;wrangler dry-run 571 KiB / gzip 119 KiB。
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 清空(仅遍历常见 KV key 前缀) */
|
||
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 已就位 → 跳过,命中缓存', 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 已就位,应直接跳过
|
||
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();
|
||
});
|
||
});
|