Files
cms-website/tests/integration/db.media.test.ts
T
浅念 f1c181de02 test(db): 空库一键迁移验收 + globalSetup 临时库 + 公共夹具收敛(阶段1验收)
- vitest globalSetup:每次运行 CREATE sf_test_<ts> 临时库 → drizzle-orm 程序化迁移器
  全量迁移(apps/api/src/db/migrate.ts,drizzle-kit 无法指向运行时建库)→ 设
  DATABASE_URL_TEST 让 getDb() 自动回退到临时库;teardown DROP(WITH FORCE)。
  有 default 导出时 vitest 不捕获具名 teardown,故用双具名导出。
- db.full-migrate.test.ts:21 表齐 + 四张不可变触发器在库 + sf_test_* 护栏断言
  (current_database 不符即红,杜绝静默连开发库)。
- helpers.ts 收敛:rand/rejectionMessage/createSiteFixture/withImmutableCleanup/
  withTestDb,Task 6–12 七文件内联版替换为 import(断言、对照组、清场顺序不变)。
- scripts/migrate-test.sh:docker compose up --wait + pnpm test 一键验收。
- 实证:75 测试全绿(72+3);开发库 21 表行数总和 0(零写入);无 sf_test_* 残留。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 20:43:13 +08:00

149 lines
8.7 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.
// 媒体域集成测试:media_assets 双唯一 UNIQUE(site_id, source_url) 与 UNIQUE(site_id, public_path)
// (各带"换列值放行"对照组——同站不同源 URL / 不同公开路径都是合法的第二次入库)+
// media_usages 复合主键 (media_asset_id, content_revision_id, field_path)(换 field_path 放行——
// 同媒体同修订不同字段位引用合法)+ status enum 四值可写往返 + source_id 可空(WP 站外媒体无源 ID)。
// Task 13 起夹具走公共 helpers(createSiteFixture/withImmutableCleanup/withTestDb/rejectionMessage);
// 生命周期纪律不变:随机后缀插入 + finally 反序清理;content_revisions 不可变,清场走
// DISABLE→删→ENABLE 封装。清场顺序:usages → assets → connections →(删 revisions)→ entries → site。
import { describe, it, expect } from 'vitest'
import { createSiteFixture, withImmutableCleanup, withTestDb, rejectionMessage, type Db } from './helpers.js'
type Fixture = {
siteId: string
connectionId: string
entryId: string
revisionId: string
assetId: string
rand: string
sourceUrl: string
publicPath: string
}
// 建链(FK 依赖顺序):site → source_connection(asset 的来源)+ content_entry → content_revision
// (usage 的引用目标)→ media_asset(ready 态,供双唯一探测与 enum 往返)。
async function setupMediaFixture(db: Db): Promise<Fixture> {
const site = await createSiteFixture(db, 'media')
const rand = site.rand
const siteId = site.siteId
const conn = (await db.execute(`insert into source_connections (id, site_id, kind, name, configuration)
values (gen_random_uuid(), '${siteId}', 'wxr', 'media-conn-${rand}', '{}') returning id`)) as any
const entry = (await db.execute(`insert into content_entries (id, site_id, type_key, lifecycle_status)
values (gen_random_uuid(), '${siteId}', 'post', 'active') returning id`)) as any
const rev = (await db.execute(`insert into content_revisions (id, site_id, content_entry_id, locale, schema_version, data, checksum, source_kind, created_by)
values (gen_random_uuid(), '${siteId}', '${entry[0].id}', 'en', 1, '{"body":"<img>"}', 'ck-media-${rand}', 'manual', 'tester') returning id`)) as any
const sourceUrl = `https://wp.example/uploads/${rand}/pic.jpg`
const publicPath = `/uploads/${rand}/pic.jpg`
const asset = (await db.execute(`insert into media_assets (id, site_id, source_connection_id, source_id, source_url, source_path, storage_path, public_path, sha256, detected_mime, declared_mime, byte_size, width, height, alt_text, status)
values (gen_random_uuid(), '${siteId}', '${conn[0].id}', 'wp-att-${rand}', '${sourceUrl}', 'wp-content/uploads/${rand}/pic.jpg', 'media/${rand}/pic.jpg', '${publicPath}', 'sha256-${rand}', 'image/jpeg', 'image/jpg', 2048, 640, 480, 'Pic ${rand}', 'ready') returning id`)) as any
return {
siteId,
connectionId: conn[0].id,
entryId: entry[0].id,
revisionId: rev[0].id,
assetId: asset[0].id,
rand,
sourceUrl,
publicPath,
}
}
// 反序清理:media_usages → media_assets → source_connections →
// content_revisions(不可变:DISABLE→删→ENABLE)→ content_entries → site。
async function cleanupMediaFixture(db: Db, f: Fixture) {
await db.execute(`delete from media_usages where site_id = '${f.siteId}'`)
await db.execute(`delete from media_assets where site_id = '${f.siteId}'`)
await db.execute(`delete from source_connections where site_id = '${f.siteId}'`)
await withImmutableCleanup(db, 'content_revisions', 'content_revisions_immutable', `site_id = '${f.siteId}'`)
await db.execute(`delete from content_entries where site_id = '${f.siteId}'`)
await db.execute(`delete from sites where id = '${f.siteId}'`)
}
async function withMediaFixture(fn: (db: Db, f: Fixture) => Promise<void>) {
await withTestDb(async (db) => {
const f = await setupMediaFixture(db)
try {
await fn(db, f)
} finally {
await cleanupMediaFixture(db, f)
}
})
}
// 探测插入的第二份资产:除显式指定的列外全部换新值,保证被测唯一键是唯一可能的冲突源。
function assetProbe(f: Fixture, overrides: { sourceUrl: string; publicPath: string; sourceId?: string | null }) {
const suffix = Math.random().toString(36).slice(2, 8)
return `insert into media_assets (id, site_id, source_connection_id, source_id, source_url, source_path, storage_path, public_path, sha256, detected_mime, byte_size, status)
values (gen_random_uuid(), '${f.siteId}', '${f.connectionId}', ${overrides.sourceId === undefined ? `'probe-${suffix}'` : overrides.sourceId === null ? 'null' : `'${overrides.sourceId}'`}, '${overrides.sourceUrl}', 'wp-content/uploads/probe-${suffix}', 'media/probe-${suffix}', '${overrides.publicPath}', 'sha256-probe-${suffix}', 'image/jpeg', 1024, 'ready')`
}
describe('媒体域约束', () => {
it('media_assets UNIQUE(site_id, source_url):同站同源 URL 重复插入拒绝,换 source_url 放行', async () => {
await withMediaFixture(async (db, f) => {
// public_path 换新,确保拒绝只可能来自 source_url 唯一键
expect(await rejectionMessage(db, assetProbe(f, {
sourceUrl: f.sourceUrl,
publicPath: `/uploads/probe-${f.rand}-a.jpg`,
}))).toMatch(/media_assets_source_url_uq/)
// 对照组:同站换源 URL(公开路径同步换新)→ 允许(同站不同来源的同一张图是两份资产)
await db.execute(assetProbe(f, {
sourceUrl: `https://wp.example/${f.rand}/other.jpg`,
publicPath: `/uploads/probe-${f.rand}-b.jpg`,
}))
})
})
it('media_assets UNIQUE(site_id, public_path):同站同公开路径重复插入拒绝,换 public_path 放行', async () => {
await withMediaFixture(async (db, f) => {
// source_url 换新,确保拒绝只可能来自 public_path 唯一键
expect(await rejectionMessage(db, assetProbe(f, {
sourceUrl: `https://cdn.example/${f.rand}/pic.jpg`,
publicPath: f.publicPath,
}))).toMatch(/media_assets_public_path_uq/)
// 对照组:同站换公开路径(源 URL 同步换新)→ 允许(公开路径是站内命名空间,不重不解)
await db.execute(assetProbe(f, {
sourceUrl: `https://wp.example/${f.rand}/another.jpg`,
publicPath: `/uploads/probe-${f.rand}-c.jpg`,
}))
})
})
it('media_usages 复合主键 (media_asset_id, content_revision_id, field_path):同三元组重复拒绝,换 field_path 放行', async () => {
await withMediaFixture(async (db, f) => {
await db.execute(`insert into media_usages (site_id, media_asset_id, content_revision_id, field_path, source_url)
values ('${f.siteId}', '${f.assetId}', '${f.revisionId}', 'data.body.img[0]', '${f.sourceUrl}')`)
expect(await rejectionMessage(db, `insert into media_usages (site_id, media_asset_id, content_revision_id, field_path, source_url)
values ('${f.siteId}', '${f.assetId}', '${f.revisionId}', 'data.body.img[0]', '${f.sourceUrl}')`))
.toMatch(/media_usages_media_asset_id_content_revision_id_field_path_pk/)
// 对照组:同媒体同修订换字段位 → 允许(一图在正文中被引用多次是合法的多行 usage)
await db.execute(`insert into media_usages (site_id, media_asset_id, content_revision_id, field_path, source_url)
values ('${f.siteId}', '${f.assetId}', '${f.revisionId}', 'data.body.img[1]', '${f.sourceUrl}')`)
})
})
it('media_assets.status enum 四值(ready/missing/quarantined/unsupported)可写往返,第五值拒绝', async () => {
await withMediaFixture(async (db, f) => {
for (const status of ['missing', 'quarantined', 'unsupported', 'ready']) {
await db.execute(`update media_assets set status = '${status}' where id = '${f.assetId}'`)
const row = (await db.execute(`select status from media_assets where id = '${f.assetId}'`)) as any
expect(row[0].status).toBe(status)
}
// enum 在库:类型化列拒绝四值之外的任意字符串
expect(await rejectionMessage(db, `update media_assets set status = 'bogus' where id = '${f.assetId}'`))
.toMatch(/media_asset_status/)
})
})
it('media_assets.source_id 可空:插 NULL 成功(WP 站外媒体无源 ID)', async () => {
await withMediaFixture(async (db, f) => {
await db.execute(assetProbe(f, {
sourceId: null,
sourceUrl: `https://external.example/${f.rand}/hotlink.jpg`,
publicPath: `/uploads/probe-${f.rand}-d.jpg`,
}))
const row = (await db.execute(`select source_id from media_assets
where site_id = '${f.siteId}' and source_url = 'https://external.example/${f.rand}/hotlink.jpg'`)) as any
expect(row[0].source_id).toBeNull()
})
})
})