Files
cms-website/tests/integration/db.media.test.ts
T
浅念 1cc0dcc57f feat(db): 0004 媒体域——assets/usages 双唯一
media_assets 双唯一 UNIQUE(site_id, source_url) / UNIQUE(site_id, public_path)
+ status 四值 enum + source_id 可空;media_usages 复合主键
(media_asset_id, content_revision_id, field_path),无独立 id/时间戳(spec §5.5)。

vitest fileParallelism: false——不可变表清场 DISABLE/ENABLE TRIGGER 是库级 DDL,
与并行文件的不可变断言竞态(实测 media 清场窗口内 content 断言偶发放行);
串行执行消除竞态,套件仅数秒。

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

167 lines
9.5 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)。
// 生命周期纪律:随机后缀插入 + finally 反序清理;content_revisions 不可变,清场走
// DISABLE TRIGGER → 删本级行 → ENABLE,嵌套 finally 保证触发器必然恢复。
// 清场顺序:usages → assets → connections →(DISABLE 删 revisions)→ entries → site。
import { describe, it, expect } from 'vitest'
import { getDb } from '../../apps/api/src/db/client.js'
type Db = ReturnType<typeof getDb>
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 rand = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const site = (await db.execute(`insert into sites (id, key, name, default_locale, production_origin)
values (gen_random_uuid(), 'media-${rand}', 'MediaFixture', 'en', 'https://media.example') returning id`)) as any
const siteId = site[0].id
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 嵌套 finally)→ 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 db.execute(`alter table content_revisions disable trigger content_revisions_immutable`)
try {
await db.execute(`delete from content_revisions where site_id = '${f.siteId}'`)
} finally {
await db.execute(`alter table content_revisions enable trigger content_revisions_immutable`)
}
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>) {
const db = getDb()
const f = await setupMediaFixture(db)
try {
await fn(db, f)
} finally {
await cleanupMediaFixture(db, f)
await (db as any).$client.end()
}
}
// drizzle 会把 PostgresError 包成 DrizzleQueryError(message 只有 "Failed query: ..."),
// 服务端原始错误文本在 cause 里——断言约束/枚举身份时必须看 cause。
async function rejectionMessage(db: Db, query: string): Promise<string> {
try {
await db.execute(query)
} catch (e: any) {
return String(e?.cause?.message ?? e?.message ?? e)
}
throw new Error(`expected query to be rejected: ${query}`)
}
// 探测插入的第二份资产:除显式指定的列外全部换新值,保证被测唯一键是唯一可能的冲突源。
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()
})
})
})