// Plan 4 Task 1 失败测试(先于实现):content:publish 显式发布(V2 spec §8、前置事实 3/8)。 // 覆盖契约(对照计划 Task 1): // 1. dry-run:mini 全链(register→import)后 3 条候选清单(typeKey/slug/修订对 current→NULL) // + 汇总计数(待发布/已等)+ confirm token(随机、两次 dry-run 不同)+ **零写入** // (published 全 NULL、无 audit_logs 行、无 site_config_revisions 行); // 2. confirm(token 取自 dry-run 输出):3 条 published_revision_id=current_revision_id + // audit_logs 行(action='content.publish'、actor_type='cli_user'、actor_label=传入值、 // command_id='content:publish'、before/after 摘要含条数)+ 返回发布计数; // 3. 幂等:重复 confirm(已全等)→ 0 条变更 + skip 语义(candidates 空、upToDate=3) // + 无新增 audit 行(no-op 不落审计); // 4. site_config_revisions 首版:首次 publish 从 site 行生成 v1 // (data={name,origin,locale,brand:{}}、checksum=canonicalJson 哈希同法)+ // UNIQUE(site_id,version) 幂等(二次发布不建 v2);已有 config 修订则跳过(created=false); // 5. actor:传入生效落 audit_logs.actor_label;缺省 super_admin@cli(前置事实 8①); // 6. 无候选条目:dry-run 空清单不报错;confirm 空清单 no-op(零写入零审计); // 未导入站点 → SITE_NOT_FOUND(dry-run 与 confirm 双通道)。 // 运行于 globalSetup 临时库(ADR-015);storage/报告根 os.tmpdir,finally 清理。 // P0 门禁不做(前置事实 8②,ADR-018 记偏离)——本测试面不含报告文件阻断语义。 import { createHash } from 'node:crypto' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { canonicalJson } from '../../apps/api/src/common/canonical.js' import { createImportJob } from '../../apps/api/src/modules/import/import.service.js' import { DEFAULT_ACTOR_LABEL, PublishError, publishSiteContent, } from '../../apps/api/src/modules/publish/publish.service.js' import { registerSource } from '../../apps/api/src/modules/source/register.service.js' import { rand, withImmutableCleanup, withTestDb, type Db } from './helpers.js' const FIXTURES_ROOT = fileURLToPath(new URL('../fixtures', import.meta.url)) const WXR_FIXTURE = resolve(FIXTURES_ROOT, 'wxr', 'mini.xml') /** 本文件写入的行清场:audit_logs(不可变触发器开关)+ config 修订 + import 全链反序 */ async function cleanupSiteRows(db: Db, siteId: string): Promise { await withImmutableCleanup(db, 'audit_logs', 'audit_logs_immutable', `site_id = '${siteId}'`) await db.execute(`delete from site_config_revisions where site_id = '${siteId}'`) await db.execute(`delete from route_registry where site_id = '${siteId}'`) await db.execute(`delete from media_usages where site_id = '${siteId}'`) await db.execute(`delete from media_assets where site_id = '${siteId}'`) await db.execute(`delete from content_terms where site_id = '${siteId}'`) await db.execute(`delete from terms where site_id = '${siteId}'`) await db.execute(`delete from taxonomies where site_id = '${siteId}'`) await db.execute(`delete from source_records where site_id = '${siteId}'`) await db.execute(`delete from entry_locales where site_id = '${siteId}'`) await withImmutableCleanup(db, 'content_revisions', 'content_revisions_immutable', `site_id = '${siteId}'`) await db.execute(`delete from content_entries where site_id = '${siteId}'`) await db.execute(`delete from import_jobs where site_id = '${siteId}'`) await withImmutableCleanup(db, 'source_snapshots', 'source_snapshots_immutable', `site_id = '${siteId}'`) await db.execute(`delete from source_connections where site_id = '${siteId}'`) await db.execute(`delete from sites where id = '${siteId}'`) } interface Work { work: string storageRoot: string reportRoot: string allowedRoots: string[] } async function makeWork(prefix: string): Promise { const work = await mkdtemp(join(tmpdir(), `${prefix}-`)) return { work, storageRoot: join(work, 'storage'), reportRoot: join(work, 'artifacts', 'import'), allowedRoots: [FIXTURES_ROOT, work], } } /** mini 全链(register wxr → import):3 内容条目全 current 未发布(无媒体快照——attachment 301 目标缺席 issues 属预期) */ async function registerAndImport(db: Db, w: Work): Promise<{ key: string; siteId: string }> { const key = `sfpub-${rand()}` const reg = await registerSource(db, { siteKey: key, wxrPath: WXR_FIXTURE, allowedRoots: w.allowedRoots, storageRoot: w.storageRoot, reportRoot: join(w.work, 'artifacts', 'register'), }) const result = await createImportJob(db, { siteKey: key, reportRoot: w.reportRoot }) expect(result.status).toBe('succeeded_with_issues') return { key, siteId: reg.siteId } } /** 单值计数便捷 */ async function countOf(db: Db, sql: string): Promise { const rows = (await db.execute(sql)) as any return Number(rows[0].n) } describe('content:publish(显式发布)', () => { it('dry-run:3 条候选清单(typeKey/slug/修订对)+ 计数 + confirm token;零写入', async () => { await withTestDb(async (db) => { const w = await makeWork('sf-pub-dry') try { const { key, siteId } = await registerAndImport(db, w) try { const dry = await publishSiteContent(db, { siteKey: key, dryRun: true }) expect(dry.dryRun).toBe(true) expect(dry.publishedCount).toBe(0) expect(dry.upToDateCount).toBe(0) // confirm token:形状(pub- 前缀随机 hex)+ 两次 dry-run 不同(随机性) expect(dry.confirmToken).toMatch(/^pub-[0-9a-f]{24}$/) const dry2 = await publishSiteContent(db, { siteKey: key, dryRun: true }) expect(dry2.confirmToken).toMatch(/^pub-[0-9a-f]{24}$/) expect(dry2.confirmToken).not.toBe(dry.confirmToken) // 候选清单:mini 3 条(typeKey/slug 排序钉死)——修订对 current→NULL expect(dry.candidates).toHaveLength(3) expect( dry.candidates.map((c) => [c.typeKey, c.slug, c.locale, c.publishedRevisionId]), ).toStrictEqual([ ['basic_page', 'about-us', 'en', null], ['news_article', 'fixture-news-a', 'en', null], ['product', 'fixture-product', 'en', null], ]) for (const c of dry.candidates) { expect(c.currentRevisionId).toMatch(/^[0-9a-f-]{36}$/) // current 修订真实存在于该 entry(清单不是凭空构造) expect( await countOf( db, `select count(*)::int as n from content_revisions where site_id = '${siteId}' and id = '${c.currentRevisionId}' and content_entry_id = '${c.contentEntryId}'`, ), ).toBe(1) } // 零写入:published 全 NULL + 无 audit 行 + 无 config 修订 expect( await countOf( db, `select count(*)::int as n from entry_locales where site_id = '${siteId}' and published_revision_id is not null`, ), ).toBe(0) expect(await countOf(db, `select count(*)::int as n from audit_logs where site_id = '${siteId}'`)).toBe(0) expect(await countOf(db, `select count(*)::int as n from site_config_revisions where site_id = '${siteId}'`)).toBe(0) } finally { await cleanupSiteRows(db, siteId) } } finally { await rm(w.work, { recursive: true, force: true }) } }) }) it('confirm:3 条 published=current + audit_logs 行(action/actor/before-after 摘要含条数)+ 发布计数;config v1 首版生成', async () => { await withTestDb(async (db) => { const w = await makeWork('sf-pub-confirm') try { const { key, siteId } = await registerAndImport(db, w) try { const dry = await publishSiteContent(db, { siteKey: key, dryRun: true }) const run = await publishSiteContent(db, { siteKey: key, confirmToken: dry.confirmToken!, actor: 'boss@corp', }) expect(run.dryRun).toBe(false) expect(run.publishedCount).toBe(3) expect(run.candidates).toHaveLength(3) expect(run.upToDateCount).toBe(3) expect(run.confirmToken).toBe(dry.confirmToken) // DB:全站 3 条 published_revision_id = current_revision_id(修订指针冻结语义) const eq = await countOf( db, `select count(*)::int as n from entry_locales where site_id = '${siteId}' and published_revision_id = current_revision_id`, ) expect(eq).toBe(3) // audit:恰一行,字段面(前置事实 8①——actor_label NOT NULL,本任务是全仓第一个审计写入方) const audit = (await db.execute(`select actor_type, actor_label, command_id, action, object_type, object_id, before_value, after_value from audit_logs where site_id = '${siteId}'`)) as any expect(audit).toHaveLength(1) expect(audit[0].action).toBe('content.publish') expect(audit[0].actor_type).toBe('cli_user') expect(audit[0].actor_label).toBe('boss@corp') expect(audit[0].command_id).toBe('content:publish') expect(audit[0].object_id).toBe(siteId) // before/after 摘要含条数 expect(Number(audit[0].before_value.pendingPublish)).toBe(3) expect(Number(audit[0].before_value.upToDate)).toBe(0) expect(Number(audit[0].after_value.published)).toBe(3) expect(Number(audit[0].after_value.upToDate)).toBe(3) // config v1:data={name,origin,locale,brand:{}}(站点行派生)+ checksum=canonicalJson 哈希同法 const config = (await db.execute(`select version, data, checksum from site_config_revisions where site_id = '${siteId}'`)) as any expect(config).toHaveLength(1) expect(config[0].version).toBe(1) expect(config[0].data).toStrictEqual({ name: key, origin: `https://${key}.example`, locale: 'en', brand: {}, }) expect(config[0].checksum).toBe(createHash('sha256').update(canonicalJson(config[0].data)).digest('hex')) expect(run.siteConfigRevision).toStrictEqual({ created: true, version: 1 }) } finally { await cleanupSiteRows(db, siteId) } } finally { await rm(w.work, { recursive: true, force: true }) } }) }) it('幂等:重复 confirm(已全等)→ 0 条变更 + skip 语义(空候选、upToDate=3)+ 无新增 audit/config 行', async () => { await withTestDb(async (db) => { const w = await makeWork('sf-pub-idem') try { const { key, siteId } = await registerAndImport(db, w) try { const dry = await publishSiteContent(db, { siteKey: key, dryRun: true }) const first = await publishSiteContent(db, { siteKey: key, confirmToken: dry.confirmToken! }) expect(first.publishedCount).toBe(3) // 重复发布:候选为空(published=current 已等)→ no-op const again = await publishSiteContent(db, { siteKey: key, confirmToken: dry.confirmToken! }) expect(again.publishedCount).toBe(0) expect(again.candidates).toHaveLength(0) expect(again.upToDateCount).toBe(3) expect(again.siteConfigRevision).toBeNull() // 零新增写入:audit 仍 1 行、config 仍 1 行(UNIQUE(site_id,version) 幂等) expect(await countOf(db, `select count(*)::int as n from audit_logs where site_id = '${siteId}'`)).toBe(1) expect(await countOf(db, `select count(*)::int as n from site_config_revisions where site_id = '${siteId}'`)).toBe(1) } finally { await cleanupSiteRows(db, siteId) } } finally { await rm(w.work, { recursive: true, force: true }) } }) }) it('config 既有跳过:publish 前已有 config 修订 → 不新建(created=false,行数不变)', async () => { await withTestDb(async (db) => { const w = await makeWork('sf-pub-skip') try { const { key, siteId } = await registerAndImport(db, w) try { await db.execute(`insert into site_config_revisions (id, site_id, version, data, checksum) values (gen_random_uuid(), '${siteId}', 4, '{"seed":true}', 'seed')`) const run = await publishSiteContent(db, { siteKey: key, confirmToken: 'pub-000000000000000000000000' }) expect(run.publishedCount).toBe(3) expect(run.siteConfigRevision).toStrictEqual({ created: false, version: 4 }) // 行数不变:seed 行原样(不建 v1/v5、不改 seed)——投影转原生 //(postgres.js 原型差异会让 toStrictEqual 报"无视觉差异"失败,import-full 同款) const rows = (await db.execute(`select version, data from site_config_revisions where site_id = '${siteId}' order by version`)) as any expect( rows.map((r: any) => ({ version: Number(r.version), data: JSON.parse(JSON.stringify(r.data)) })), ).toStrictEqual([{ version: 4, data: { seed: true } }]) } finally { await cleanupSiteRows(db, siteId) } } finally { await rm(w.work, { recursive: true, force: true }) } }) }) it('actor:传入生效;缺省 super_admin@cli(前置事实 8①)', async () => { await withTestDb(async (db) => { const w = await makeWork('sf-pub-actor') try { const { key, siteId } = await registerAndImport(db, w) try { const run = await publishSiteContent(db, { siteKey: key, confirmToken: 'pub-111111111111111111111111' }) expect(run.publishedCount).toBe(3) const audit = (await db.execute(`select actor_label from audit_logs where site_id = '${siteId}'`)) as any expect(audit).toHaveLength(1) expect(audit[0].actor_label).toBe(DEFAULT_ACTOR_LABEL) expect(audit[0].actor_label).toBe('super_admin@cli') } finally { await cleanupSiteRows(db, siteId) } } finally { await rm(w.work, { recursive: true, force: true }) } }) }) it('无候选/未导入站点:dry-run 空清单不报错;confirm 空清单 no-op;SITE_NOT_FOUND 双通道', async () => { await withTestDb(async (db) => { const w = await makeWork('sf-pub-empty') try { const { key, siteId } = await registerAndImport(db, w) try { // 发布到全等 const dry = await publishSiteContent(db, { siteKey: key, dryRun: true }) await publishSiteContent(db, { siteKey: key, confirmToken: dry.confirmToken! }) // 二次 dry-run:空清单不报错 const dryEmpty = await publishSiteContent(db, { siteKey: key, dryRun: true }) expect(dryEmpty.candidates).toHaveLength(0) expect(dryEmpty.publishedCount).toBe(0) expect(dryEmpty.upToDateCount).toBe(3) expect(dryEmpty.confirmToken).toMatch(/^pub-[0-9a-f]{24}$/) // confirm 空清单:no-op(零新增 audit、零写入) const noop = await publishSiteContent(db, { siteKey: key, confirmToken: dry.confirmToken! }) expect(noop.publishedCount).toBe(0) expect(await countOf(db, `select count(*)::int as n from audit_logs where site_id = '${siteId}'`)).toBe(1) } finally { await cleanupSiteRows(db, siteId) } // 未导入站点(不存在):dry-run 与 confirm 双通道 SITE_NOT_FOUND for (const input of [ { siteKey: 'no-such-site', dryRun: true }, { siteKey: 'no-such-site', confirmToken: 'pub-222222222222222222222222' }, ]) { const err = await publishSiteContent(db, input).then( () => { throw new Error('不应成功') }, (e: unknown) => e, ) expect(err).toBeInstanceOf(PublishError) expect((err as PublishError).code).toBe('SITE_NOT_FOUND') } } finally { await rm(w.work, { recursive: true, force: true }) } }) }) it('confirm 缺 token → CONFIRM_TOKEN_REQUIRED(显式发布纪律:无 token 不执行)', async () => { await withTestDb(async (db) => { const w = await makeWork('sf-pub-notoken') try { const { key, siteId } = await registerAndImport(db, w) try { const err = await publishSiteContent(db, { siteKey: key }).then( () => { throw new Error('不应成功') }, (e: unknown) => e, ) expect(err).toBeInstanceOf(PublishError) expect((err as PublishError).code).toBe('CONFIRM_TOKEN_REQUIRED') // 零写入(拒绝发生在任何 UPDATE 之前) expect( await countOf( db, `select count(*)::int as n from entry_locales where site_id = '${siteId}' and published_revision_id is not null`, ), ).toBe(0) expect(await countOf(db, `select count(*)::int as n from audit_logs where site_id = '${siteId}'`)).toBe(0) } finally { await cleanupSiteRows(db, siteId) } } finally { await rm(w.work, { recursive: true, force: true }) } }) }) })