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