Files
cms-website/tests/integration/publish.test.ts
T
浅念 c35f10835a feat(publish): 显式发布命令(dry-run/confirm/审计/站点配置首版/锚点helper)
- publishSiteContent 零装饰器服务:dry-run 只读清单(current 非空且 published≠current)
  + 随机 confirm token;confirm 单事务批量 UPDATE published←current + audit_logs
  (action=content.publish,全仓第一个审计写入方)+ site_config_revisions v1 首版
  ({name,origin,locale,brand:{}},UNIQUE(site_id,version) 幂等)
- 空清单 no-op 零写入;SITE_NOT_FOUND/CONFIRM_TOKEN_REQUIRED 均先于任何写入
- P0 门禁不做(ADR-018 记偏离,服务 JSDoc 注明);导入永不发布(ADR-010)
- sf CLI + content:publish(--dry-run/--confirm/--actor,缺省 dry-run 安全优先,
  actor 缺省 super_admin@cli)
- anchors.ts:五列内容身份查询从 content/term/route 三写入器抽单源共享(零行为
  变更,既有 333 测试守护)
- 集成测试 7 例(临时库 mini 全链):dry-run 零写入/confirm/幂等/config 首版与
  既有跳过/actor/空清单/SITE_NOT_FOUND/缺 token

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 07:06:23 +08:00

366 lines
17 KiB
TypeScript
Raw 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.
// 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<void> {
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<Work> {
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<number> {
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 })
}
})
})
})