import { describe, expect, it, vi } from 'vitest'; import { PublishStatus } from '../src/common/enums'; import { CatalogService } from '../src/catalog/catalog.service'; import { ReviewsService } from '../src/reviews/reviews.service'; import { ScenesService } from '../src/scenes/scenes.service'; /** * 场景子系统必须**可降级**(不是可选项的健壮性,而是生产事故的直接教训)。 * * 现场:数据库里没有 `scenes` 表(迁移没落到该库 / dist 里缺迁移文件),于是 * - `GET /api/scenes` → 500 * - `GET /api/catalog/export` → 500 ← 客户端**拉不到任何目录**,整站不可用 * - `GET /api/catalog/version` → 500 * - `GET /api/reviews?status=pending` → 500 ← 管理端待审列表也挂了 * 而 `/api/songs`、`/api/models` 都正常。 * * 契约与文档都明确「**场景是可选资源**」(没有可用场景时客户端回退内置空场景), * 因此场景表不可用**不能**连带打挂与场景无关的功能。这里守护这条底线: * 降级为空场景 + 日志 + `/health` 如实暴露,而不是 500。 */ describe('ScenesService 降级(scenes 表不可用时)', () => { const boom = () => { throw new Error("Table 'opendance.scenes' doesn't exist"); }; function buildScenes(repository: unknown) { const service = new ScenesService( repository as never, { findMany: vi.fn(async () => new Map()), url: () => '' } as never, {} as never, ); return service; } it('published() 在表缺失时返回空数组而不是抛错', async () => { const service = buildScenes({ find: vi.fn(async () => boom()) }); await expect(service.published()).resolves.toEqual([]); }); it('published() 同时记录可用性与原因(供 /health 暴露)', async () => { const service = buildScenes({ find: vi.fn(async () => boom()) }); expect(service.diagnostics()).toEqual({ available: true, error: null }); await service.published(); const diag = service.diagnostics(); expect(diag.available).toBe(false); expect(diag.error).toContain("doesn't exist"); }); it('表恢复后自动回到可用状态', async () => { const find = vi.fn(async () => boom()); const service = buildScenes({ find }); await service.published(); expect(service.diagnostics().available).toBe(false); // 表建好后:查询成功(空结果),可用性应自动恢复 find.mockImplementation(async () => [] as never); await expect(service.published()).resolves.toEqual([]); expect(service.diagnostics()).toEqual({ available: true, error: null }); }); it('错误信息截断到 300 字符(避免把整段 SQL 错误塞进 /health)', async () => { const service = buildScenes({ find: vi.fn(async () => { throw new Error('x'.repeat(1000)); }), }); await service.published(); expect(service.diagnostics().error?.length).toBe(300); }); }); describe('降级不会波及与场景无关的功能', () => { it('catalog/export 仍然成功,scenes 为空数组(客户端可继续拉目录)', async () => { const scenesService = new ScenesService( { find: vi.fn(async () => { throw new Error("Table 'opendance.scenes' doesn't exist"); }), } as never, { findMany: vi.fn(async () => new Map()), url: () => '' } as never, {} as never, ); const repository = (rows: unknown[]) => ({ find: vi.fn(async () => rows) }); const service = new CatalogService( repository([]) as never, repository([]) as never, repository([]) as never, { toDtos: vi.fn(async (rows: unknown[]) => rows) } as never, { toDtos: vi.fn(async (rows: unknown[]) => rows) } as never, { toDtos: vi.fn(async (rows: unknown[]) => rows), resolveDefaultModelId: vi.fn(async () => null), } as never, scenesService as never, { contractVersions: { 'rest-api': '0.5.0' } } as never, ); const bundle = await service.exportBundle(); expect(bundle.scenes).toEqual([]); expect(bundle.contractVersion['rest-api']).toBe('0.5.0'); const version = await service.version(); expect(version.revision).toMatch(/^[0-9a-f]{64}$/); expect(scenesService.diagnostics().available).toBe(false); }); it('待审列表跳过场景而不是整体 500', async () => { const service = new ReviewsService( {} as never, { find: vi.fn(async () => []) } as never, // songs { find: vi.fn(async () => []) } as never, // motions { find: vi.fn(async () => []) } as never, // models { find: vi.fn(async () => { throw new Error("Table 'opendance.scenes' doesn't exist"); }), } as never, // scenes {} as never, // modelsService ); const page = await service.listByStatus(PublishStatus.Pending, { skip: 0, pageSize: 20, page: 1, } as never); expect(page.total).toBe(0); expect(page.items).toEqual([]); }); });