Files
SubsTracker/tests/data/migrate.test.js
wangwangit 6e1b506c8a polish: 默认时区 Asia/Shanghai + 清理代码与文档中的版本号字眼
时区默认值:
- 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。
2026-05-24 20:04:16 +08:00

237 lines
8.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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();
});
});