fix(import): term 边缘欠报闭合(null-id 双序等价/同 slug 双 id 报告);handleEvent 分派形状
审查修复:
- null-id wp:term 双序等价:seen 分支同样报缺 term_id issue;自建行 name-only
收编(UPDATE name 为权威名、source_id 不动),term 先行/row 先行终态 name
与 issue 双序一致(测试钉死);真实 id 行不覆盖(first-wins)
- 同 slug 双 term_id:镜像条件显式报告(保留先到、跳过后到 + rawLocation),
文档序 first-wins 策略 JSDoc 钉死(测试含关联绑保留行)
- 冲突重查未命中由 found[0]! 改显式 throw(taxonomy/term 两处,含键上下文)
- adoptTerm catch 注明 ctx.db 直连约定(事务内 25P02 连锁风险——Task 6 包
事务时须重估);收编撞唯一约束路径补测试(自建行原样保留 + issue)
- 重构:内部件抽为 createTermEventHandler({handleEvent, finish}) 可分派形状
(Task 6 单消费方拉流分派、无 tee 的编排意图),createTermWriter 为自消费
整流便捷壳
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -462,4 +462,218 @@ describe('term 物化(含自建缺失)', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('null-id wp:term 双序等价:name-only 收编——term 先行/row 先行终态 name 与 issue 双序一致', async () => {
|
||||
await withTestDb(async (db) => {
|
||||
// 两序各起独立站点(终态对比不受他序残留影响);work 根共用、子目录隔离
|
||||
const work = await mkdtemp(join(tmpdir(), 'sf-terms-nullid-'))
|
||||
const runScenario = async (order: 'term-first' | 'row-first') => {
|
||||
const dir = join(work, order)
|
||||
const key = `sfterm-${rand()}`
|
||||
const reg = await registerSource(db, {
|
||||
siteKey: key,
|
||||
wxrPath: WXR_FIXTURE,
|
||||
allowedRoots: [FIXTURES_ROOT],
|
||||
storageRoot: join(dir, 'storage'),
|
||||
reportRoot: join(dir, 'artifacts', 'register'),
|
||||
})
|
||||
try {
|
||||
const wxr = reg.snapshots.find((s) => s.kind === 'wxr')!
|
||||
const nullIdTerm = termEvent({
|
||||
termId: null,
|
||||
taxonomy: 'category',
|
||||
slug: 'company-news',
|
||||
name: 'Company News',
|
||||
})
|
||||
const row = rowEvent({
|
||||
sourceId: '501',
|
||||
taxonomyRefs: [{ taxonomy: 'category', sourceTermId: 'company-news' }],
|
||||
})
|
||||
const result = await createImportJob(db, {
|
||||
siteKey: key,
|
||||
snapshotId: wxr.id,
|
||||
reportRoot: join(dir, 'artifacts', 'import'),
|
||||
events: eventsOf(order === 'term-first' ? [nullIdTerm, row] : [row, nullIdTerm]),
|
||||
pipeline: seededTermWriter(async (ctx) => {
|
||||
await seedContentEntry(ctx, { sourceType: 'wp:post', sourceId: '501', typeKey: 'news_article' })
|
||||
}),
|
||||
})
|
||||
expect(result.status).toBe('succeeded_with_issues')
|
||||
const termsRows = (await db.execute(`select t.source_id, t.slug, t.name
|
||||
from terms t join taxonomies x on x.id = t.taxonomy_id
|
||||
where t.site_id = '${reg.siteId}'`)) as any
|
||||
const ct = (await db.execute(`select r.source_id as row_id, t.slug, c.is_primary
|
||||
from content_terms c
|
||||
join source_records r on r.content_entry_id = c.content_entry_id
|
||||
join terms t on t.id = c.term_id
|
||||
where c.site_id = '${reg.siteId}'`)) as any
|
||||
const report = JSON.parse(
|
||||
await readFile(join(dir, 'artifacts', 'import', result.jobId, 'reports', 'import.json'), 'utf8'),
|
||||
) as { issues: string[] }
|
||||
return { terms: plain(termsRows), ct: plain(ct), issues: report.issues }
|
||||
} finally {
|
||||
await cleanupSiteRows(db, reg.siteId)
|
||||
}
|
||||
}
|
||||
try {
|
||||
const termFirst = await runScenario('term-first')
|
||||
const rowFirst = await runScenario('row-first')
|
||||
// 双序终态一致:单行 auto:company-news、name 为 wp:term 权威名(name-only 收编)
|
||||
const expectedTerms = [
|
||||
{ source_id: 'auto:company-news', slug: 'company-news', name: 'Company News' },
|
||||
]
|
||||
expect(termFirst.terms).toStrictEqual(expectedTerms)
|
||||
expect(rowFirst.terms).toStrictEqual(expectedTerms)
|
||||
expect(termFirst.ct).toStrictEqual(rowFirst.ct)
|
||||
expect(termFirst.ct).toStrictEqual([
|
||||
{ row_id: '501', slug: 'company-news', is_primary: true },
|
||||
])
|
||||
// issue 双序均在(缺 term_id 的 wp:term 事件无论先后到都报告)
|
||||
expect(termFirst.issues).toHaveLength(1)
|
||||
expect(rowFirst.issues).toHaveLength(1)
|
||||
expect(termFirst.issues[0]).toMatch(/缺 term_id/)
|
||||
expect(rowFirst.issues[0]).toMatch(/缺 term_id/)
|
||||
} finally {
|
||||
await rm(work, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('同 slug 双 term_id:文档序 first-wins——保留先到 id、后到跳过并报告,关联绑保留行、name 不被覆盖', async () => {
|
||||
await withTestDb(async (db) => {
|
||||
const work = await mkdtemp(join(tmpdir(), 'sf-terms-dualid-'))
|
||||
try {
|
||||
const key = `sfterm-${rand()}`
|
||||
const reg = await registerSource(db, {
|
||||
siteKey: key,
|
||||
wxrPath: WXR_FIXTURE,
|
||||
allowedRoots: [FIXTURES_ROOT],
|
||||
storageRoot: join(work, 'storage'),
|
||||
reportRoot: join(work, 'artifacts', 'register'),
|
||||
})
|
||||
try {
|
||||
const wxr = reg.snapshots.find((s) => s.kind === 'wxr')!
|
||||
const events: AdapterEvent[] = [
|
||||
termEvent({ termId: 31, taxonomy: 'category', slug: 'company', name: 'Company' }),
|
||||
termEvent({ termId: 99, taxonomy: 'category', slug: 'company', name: 'Company Dup' }),
|
||||
rowEvent({ sourceId: '501', taxonomyRefs: [{ taxonomy: 'category', sourceTermId: 'company' }] }),
|
||||
]
|
||||
const result = await createImportJob(db, {
|
||||
siteKey: key,
|
||||
snapshotId: wxr.id,
|
||||
reportRoot: join(work, 'artifacts', 'import'),
|
||||
events: eventsOf(events),
|
||||
pipeline: seededTermWriter(async (ctx) => {
|
||||
await seedContentEntry(ctx, { sourceType: 'wp:post', sourceId: '501', typeKey: 'news_article' })
|
||||
}),
|
||||
})
|
||||
expect(result.status).toBe('succeeded_with_issues')
|
||||
|
||||
// 单行:先到的 31 保留(name 不被后到 99 的事件覆盖——first-wins)
|
||||
const termsRows = (await db.execute(`select t.source_id, t.slug, t.name
|
||||
from terms t join taxonomies x on x.id = t.taxonomy_id
|
||||
where t.site_id = '${reg.siteId}'`)) as any
|
||||
expect(plain(termsRows)).toStrictEqual([
|
||||
{ source_id: '31', slug: 'company', name: 'Company' },
|
||||
])
|
||||
|
||||
// issue:保留/跳过双 id + rawLocation 均可审计
|
||||
const report = JSON.parse(
|
||||
await readFile(join(work, 'artifacts', 'import', result.jobId, 'reports', 'import.json'), 'utf8'),
|
||||
) as { counts: Record<string, number>; issues: string[] }
|
||||
expect(report.issues).toHaveLength(1)
|
||||
expect(report.issues[0]).toMatch(/同 slug 双 term_id:保留 31、跳过 99/)
|
||||
|
||||
// 关联绑保留行(31);计数零自建
|
||||
const ct = (await db.execute(`select r.source_id as row_id, t.source_id as term_source_id, c.is_primary
|
||||
from content_terms c
|
||||
join source_records r on r.content_entry_id = c.content_entry_id
|
||||
join terms t on t.id = c.term_id
|
||||
where c.site_id = '${reg.siteId}'`)) as any
|
||||
expect(plain(ct)).toStrictEqual([
|
||||
{ row_id: '501', term_source_id: '31', is_primary: true },
|
||||
])
|
||||
expect(report.counts).toStrictEqual({
|
||||
termsProcessed: 2,
|
||||
autoCreatedTerms: 0,
|
||||
contentTermsCreated: 1,
|
||||
})
|
||||
} finally {
|
||||
await cleanupSiteRows(db, reg.siteId)
|
||||
}
|
||||
} finally {
|
||||
await rm(work, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('收编撞唯一约束:source_id 已被他行占用 → 自建行原样保留 + issue(写入器唯一 try/catch 路径)', async () => {
|
||||
await withTestDb(async (db) => {
|
||||
const work = await mkdtemp(join(tmpdir(), 'sf-terms-adopt-'))
|
||||
try {
|
||||
const key = `sfterm-${rand()}`
|
||||
const reg = await registerSource(db, {
|
||||
siteKey: key,
|
||||
wxrPath: WXR_FIXTURE,
|
||||
allowedRoots: [FIXTURES_ROOT],
|
||||
storageRoot: join(work, 'storage'),
|
||||
reportRoot: join(work, 'artifacts', 'register'),
|
||||
})
|
||||
try {
|
||||
const wxr = reg.snapshots.find((s) => s.kind === 'wxr')!
|
||||
// 事件 1 占位 source_id=31(slug 不同→不自建命中);事件 2 按 slug 自建
|
||||
// auto:company;事件 3 收编 UPDATE auto:company→31 撞 terms_identity_uq
|
||||
const events: AdapterEvent[] = [
|
||||
termEvent({ termId: 31, taxonomy: 'category', slug: 'company-alt', name: 'Alt' }),
|
||||
rowEvent({ sourceId: '501', taxonomyRefs: [{ taxonomy: 'category', sourceTermId: 'company' }] }),
|
||||
termEvent({ termId: 31, taxonomy: 'category', slug: 'company', name: 'Company' }),
|
||||
]
|
||||
const result = await createImportJob(db, {
|
||||
siteKey: key,
|
||||
snapshotId: wxr.id,
|
||||
reportRoot: join(work, 'artifacts', 'import'),
|
||||
events: eventsOf(events),
|
||||
pipeline: seededTermWriter(async (ctx) => {
|
||||
await seedContentEntry(ctx, { sourceType: 'wp:post', sourceId: '501', typeKey: 'news_article' })
|
||||
}),
|
||||
})
|
||||
expect(result.status).toBe('succeeded_with_issues')
|
||||
|
||||
// 两行并存:占位 31 原样;自建行整语句回退(source_id 与 name 都未被收编改动)
|
||||
const termsRows = (await db.execute(`select t.source_id, t.slug, t.name
|
||||
from terms t join taxonomies x on x.id = t.taxonomy_id
|
||||
where t.site_id = '${reg.siteId}' order by t.source_id`)) as any
|
||||
expect(plain(termsRows)).toStrictEqual([
|
||||
{ source_id: '31', slug: 'company-alt', name: 'Alt' },
|
||||
{ source_id: 'auto:company', slug: 'company', name: 'company' },
|
||||
])
|
||||
|
||||
const report = JSON.parse(
|
||||
await readFile(join(work, 'artifacts', 'import', result.jobId, 'reports', 'import.json'), 'utf8'),
|
||||
) as { counts: Record<string, number>; issues: string[] }
|
||||
expect(report.issues).toHaveLength(1)
|
||||
expect(report.issues[0]).toMatch(/收编撞唯一约束.*31/)
|
||||
|
||||
// 关联仍挂保留的自建行;自建计数不回退(收编失败,行仍是自建终态)
|
||||
const ct = (await db.execute(`select r.source_id as row_id, t.source_id as term_source_id, c.is_primary
|
||||
from content_terms c
|
||||
join source_records r on r.content_entry_id = c.content_entry_id
|
||||
join terms t on t.id = c.term_id
|
||||
where c.site_id = '${reg.siteId}'`)) as any
|
||||
expect(plain(ct)).toStrictEqual([
|
||||
{ row_id: '501', term_source_id: 'auto:company', is_primary: true },
|
||||
])
|
||||
expect(report.counts).toStrictEqual({
|
||||
termsProcessed: 2,
|
||||
autoCreatedTerms: 1,
|
||||
contentTermsCreated: 1,
|
||||
})
|
||||
} finally {
|
||||
await cleanupSiteRows(db, reg.siteId)
|
||||
}
|
||||
} finally {
|
||||
await rm(work, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user