Files
cms-website/tests/integration/publish.test.ts
T

366 lines
17 KiB
TypeScript
Raw Normal View History

// 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 })
}
})
})
})