a1b46d2b7c
背景:来源侧被判「禁止导入」后,已建好的测试库知识不会自动消失;此前只有变更集
删除路径会入队 test.remove,页面既列不出条目也没有按钮,运维无法闭环(车道只有按
库计数)。
新增:
- GET /api/v1/test-publications?removal=required:条目级列表,判据与车道计数同源
(superseded_at IS NULL / 有 knowledge_id / completed / retrieval_state NOT IN
(disabling,disabled) / policy_state='blocked'),过滤是叠加而不是替换。
- POST /api/v1/test-publications/{id}/removal:engineer 级写路由,要求 Idempotency-Key,
按条目幂等(operator-remove:<id>);置 disabling 并交既有 test.remove 工作流去删除
远端知识并读回校验。错误映射 404/409(已移除)/409(不可移除)/501(未接线)。
- 界面:需下架车道逐条显示条目 + 「下架(从测试库移除)」,页内二次确认,成功提示,
完成后计数归零。
修掉过程中踩到的两个真问题:
1. 二次确认的确认态原先经容器 props 传给视图,一次容器改动把三个 props 丢了 ——
视图永远渲染原按钮、确认步骤静默消失,而所有静态单元测试仍然通过。改为把确认态
放在视图内部;并把「取消」放在原按钮位置(真实指针连点两下只会落到取消)。
2. jobs 上有部分唯一索引 UNIQUE (business_change_set_id, business_version_id,
business_target_kb_id, business_action) WHERE business_change_set_id IS NOT NULL。
本路径给这三列写空串(非 NULL),('','','','remove') 对所有条目是同一个键 ——
第一条之后每次 INSERT 都撞唯一索引、被 ON CONFLICT DO NOTHING 静默吞掉,留下
「状态已 disabling、没有任务」的永久卡死条目,而车道恰好隐藏 disabling,运维也
看不到。改为写 NULL(语义:无变更集身份)+ 冲突目标收敛到 idempotency_key +
校验 RowsAffected()(0 行回查既有任务;查不到就报错,不提交无任务的 disabling)。
卡死状态在重试时可自愈。
测试:internal/platform/postgres/test_publication_removal_integration_test.go(两条发布
各自入队、business_* 为 NULL、卡死自愈;把修复退回旧写法该测试即 FAIL,已实测)、
internal/httpapi/test_publication_removal_test.go(权限/幂等键/错误映射/未接线 501/列表
过滤)、前端 testPublications.test.ts + WeKnoraMonitor.test.tsx。
验收(纯界面,两条都走完):解析监控 → 需下架(2)→ 逐条「下架」+ 二次确认 →
retrieval_state=disabled(PDF 03:57:03Z、md 04:08:55Z),jobs completed(attempt 2,
首次 test_remove_pending=远端仍在处理,重试通过;工作流以 GetKnowledge 读回 404 判
成功)→ 车道需下架(0)。核验「确认前不执行」:首次点击后仍 unpublished、移除任务数仍 1。
175 lines
7.5 KiB
TypeScript
175 lines
7.5 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import { fetchAcceptanceResults, fetchTestPublications, parseTestPublicationsResponse, retryPublication, retryPublicationsBatch, startTestPublications, fetchJobFailures, fetchRemovalRequiredPublications, startPublicationRemoval } from './testPublications'
|
|
|
|
afterEach(() => vi.unstubAllGlobals())
|
|
|
|
describe('parseTestPublicationsResponse', () => {
|
|
it('parses the safe publication projection envelope', () => {
|
|
const result = parseTestPublicationsResponse({
|
|
data: [{
|
|
id: 'pub-safe', title: 'Safe title', ingest_state: 'failed',
|
|
processing_state: 'failed', retrieval_state: 'unpublished',
|
|
target_scope: 'external', change_set_id: 'cs-1', change_type: 'updated', can_start: false,
|
|
retry_count: 2, last_error: 'remote_parse_failed', can_retry: true,
|
|
updated_at: '2026-09-04T05:00:00Z', test_kb_id: 'must-drop',
|
|
}],
|
|
total: 1,
|
|
limit: 25,
|
|
offset: 0,
|
|
})
|
|
|
|
expect(result.total).toBe(1)
|
|
expect(result.items[0].can_retry).toBe(true)
|
|
expect(result.items[0].target_scope).toBe('external')
|
|
expect(result.items[0].change_type).toBe('updated')
|
|
expect(result.items[0].last_error).toBe('remote_parse_failed')
|
|
expect(result.items[0]).not.toHaveProperty('test_kb_id')
|
|
})
|
|
})
|
|
|
|
describe('fetchTestPublications', () => {
|
|
it('requests the selected processing state and page', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [], total: 0, limit: 25, offset: 0 }),
|
|
})
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
await fetchTestPublications({ processing_state: 'failed', limit: 25, offset: 0 })
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'/api/v1/test-publications?processing_state=failed&limit=25&offset=0',
|
|
expect.objectContaining({ headers: { Accept: 'application/json' } }),
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('fetchAcceptanceResults', () => {
|
|
it('reads the acceptance record list', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [{ check_type: 'chunks_present', state: 'passed', observed_at: '2026-09-04T05:05:00Z' }] }),
|
|
})
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
const records = await fetchAcceptanceResults('pub-safe')
|
|
|
|
expect(records).toHaveLength(1)
|
|
expect(records[0].check_type).toBe('chunks_present')
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'/api/v1/test-publications/pub-safe/acceptance',
|
|
expect.objectContaining({ headers: { Accept: 'application/json' } }),
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('retryPublication', () => {
|
|
it('posts the retry request', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
await retryPublication('pub-safe')
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'/api/v1/test-publications/pub-safe/retry',
|
|
expect.objectContaining({ method: 'POST' }),
|
|
)
|
|
})
|
|
|
|
it('surfaces the server error message', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 409,
|
|
json: async () => ({ error: 'publication is not in a retryable state' }),
|
|
})
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
await expect(retryPublication('pub-safe')).rejects.toThrow('publication is not in a retryable state')
|
|
})
|
|
})
|
|
|
|
describe('需下架条目的移除入口', () => {
|
|
it('只取被禁止但仍存活在测试库的条目', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: [], total: 0, limit: 50, offset: 0 }) })
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
await fetchRemovalRequiredPublications()
|
|
|
|
const url = String(fetchMock.mock.calls[0][0])
|
|
expect(url).toContain('/api/v1/test-publications?')
|
|
expect(url).toContain('removal=required')
|
|
})
|
|
|
|
it('带幂等键触发下架并回传任务号', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ publication_id: 'tp-1', job_id: 'job-remove-1', state: 'disabling' }), { status: 202 }))
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
await expect(startPublicationRemoval('tp-1')).resolves.toEqual({ job_id: 'job-remove-1' })
|
|
const [url, init] = fetchMock.mock.calls[0]
|
|
expect(url).toBe('/api/v1/test-publications/tp-1/removal')
|
|
expect(init.method).toBe('POST')
|
|
expect(String(init.headers['Idempotency-Key'])).not.toHaveLength(0)
|
|
})
|
|
|
|
it('把服务端拒绝原因原样抛给界面', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({ status: 409, json: async () => ({ error: '该知识的检索已经移除,无需重复下架' }) })
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
await expect(startPublicationRemoval('tp-1')).rejects.toThrow('该知识的检索已经移除,无需重复下架')
|
|
})
|
|
})
|
|
|
|
describe('startTestPublications', () => {
|
|
it('starts only the explicitly confirmed processing plans', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: { selected: 2, started: 1, already_started: 1, unavailable: 0 } }), { status: 202 }))
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
await expect(startTestPublications(['pub-a', 'pub-b'], '已核对差异')).resolves.toEqual({ selected: 2, started: 1, already_started: 1, unavailable: 0 })
|
|
expect(fetchMock).toHaveBeenCalledWith('/api/v1/test-publications/start', expect.objectContaining({
|
|
method: 'POST',
|
|
headers: expect.objectContaining({ 'Idempotency-Key': expect.any(String) }),
|
|
body: JSON.stringify({ publication_ids: ['pub-a', 'pub-b'], reason: '已核对差异' }),
|
|
}))
|
|
})
|
|
})
|
|
describe('retryPublicationsBatch', () => {
|
|
it('sends one server-side batch request and parses per-item outcomes', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: { batch_key: 'op-1', total: 2, started: 1, already_running: 1, not_retryable: 0,
|
|
outcomes: [{ publication_id: 'tp-1', result: 'started' }, { publication_id: 'tp-2', result: 'already_running' }] }, operation: 'created' }),
|
|
})
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
const result = await retryPublicationsBatch(['tp-1', 'tp-2'], '解析服务恢复')
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith('/api/v1/test-publications/retry-batch',
|
|
expect.objectContaining({ method: 'POST' }))
|
|
const call = fetchMock.mock.calls[0] as [string, RequestInit]
|
|
expect(JSON.parse(String(call[1].body))).toEqual({ publication_ids: ['tp-1', 'tp-2'], reason: '解析服务恢复' })
|
|
expect(result.started).toBe(1)
|
|
expect(result.outcomes[1].result).toBe('already_running')
|
|
})
|
|
|
|
it('rejects a malformed batch envelope', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: {} }) }))
|
|
await expect(retryPublicationsBatch(['tp-1'], 'r')).rejects.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('fetchJobFailures', () => {
|
|
it('reads the open dead-letter projection', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: { items: [{ id: 'jf-1', job_id: 'job-1', job_type: 'test.upload', error_code: 'remote_permanent', attempt: 5, max_attempts_at_failure: 5, state: 'open', created_at: '2026-09-11T20:00:00Z' }], total: 1, limit: 50, offset: 0 } }),
|
|
})
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
const items = await fetchJobFailures()
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith('/api/v1/job-failures?state=open&limit=50', expect.objectContaining({ headers: { Accept: 'application/json' } }))
|
|
expect(items[0].error_code).toBe('remote_permanent')
|
|
})
|
|
})
|