c35f10835a
- 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>
366 lines
17 KiB
TypeScript
366 lines
17 KiB
TypeScript
// 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 })
|
||
}
|
||
})
|
||
})
|
||
})
|