Files
knowledge-governance/web/src/dingtalkSession.test.ts
T
姚章浩 9a8a3e7795
build / build (push) Successful in 57s
fix(dingtalk): 登记钉钉知识库 409 space_registration_conflict —— 归档行占名 + 错误被吞
现象:点「公司级技术支持知识库」登记,返回 HTTP 409:space_registration_conflict。

根因(真数据取证):
- source_connections.name 有唯一约束。这 3 个钉钉来源是**早期路径**登记的
  (base_url 为空,id 与 dws 现在推导的确定性 id 不同):
  sc-18d10ca0… 公司级技术支持知识库 / sc-eaef0ec1… ATOM知识库 / sc-dc7cea1c… 技术诊断部知识沉淀。
- 上一步「去掉来源」只是归档下架,**名字仍占着唯一槽位** ⇒ 重新登记时 INSERT 撞
  UNIQUE(name)。
- 处理器把**所有**存储层错误都翻译成同一个 409 space_registration_conflict(真实原因被吞)。

修复:
- store:同名的另一行若**已归档** ⇒ 改名腾位(`…(已下架 · <短ID>)`,归档行不显示,
  数据与审计一行不动);若**仍在使用** ⇒ ErrDingtalkSpaceNameTaken(绝不悄悄改名)。
  同一个登记键解析到别的 root 时:在用中的行报 ErrDingtalkSpaceIdentityConflict;
  归档过的行允许就地改写("去掉后再用 dws 重新获取"的语义)。
- httpapi:分类映射 —— space_name_conflict / space_identity_conflict(409 + 可行动 hint),
  其余内部故障改回 500 space_registration_failed,不再伪装成冲突。
- web:读一次响应体同时取 error 与 hint,**hint 优先**;补两个新码的中文说明。

验证:真 postgres 集成测试复刻线上形态(旧行占名 + 归档 ⇒ 登记成功、旧行改名保持归档、
内容不丢;同名在用 ⇒ 拒绝;同键换 root 在用 ⇒ 拒绝、归档 ⇒ 就地改写);handler 分类测试
(409/409/500,且不回显内部细节);前端 hint 优先用例;make verify 15/15。
2026-10-14 12:59:52 +08:00

77 lines
2.8 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest'
import { DingtalkRequestError, registerDingtalkSpace, startDingtalkSession } from './dingtalkSession'
afterEach(() => vi.unstubAllGlobals())
describe('DingTalk session client', () => {
it('returns the starting state immediately while the QR is being generated', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
id: 'session-1',
state: 'starting',
created_at: 1,
expires_at: 2,
}),
{ status: 201 }
)
)
vi.stubGlobal('fetch', fetchMock)
const target = 'https://alidocs.dingtalk.com/i/spaces/9JOGOQneRgkneX4Q/overview'
await expect(startDingtalkSession(target)).resolves.toMatchObject({ id: 'session-1', state: 'starting' })
expect(fetchMock).toHaveBeenCalledWith('/api/v1/dingtalk-session', expect.objectContaining({ method: 'POST' }))
expect(JSON.parse(String(fetchMock.mock.calls[0][1]?.body))).toEqual({ space: target })
})
it('preserves login_required as a machine-readable error for automatic QR fallback', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: 'login_required' }), {
status: 409,
headers: { 'Content-Type': 'application/json' },
})
)
)
const error = await registerDingtalkSpace('https://alidocs.dingtalk.com/i/spaces/9JOGOQneRgkneX4Q/overview').then(
() => null,
(cause: unknown) => cause
)
expect(error).toBeInstanceOf(DingtalkRequestError)
expect(error).toMatchObject({ code: 'login_required', status: 409 })
})
})
describe('钉钉知识库登记的失败说明', () => {
it('后端 hint 优先,其次本地文案;错误码始终带出', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: 'space_name_conflict', hint: '已有同名来源仍在使用:先「去掉来源」' }), {
status: 409,
})
)
)
await expect(registerDingtalkSpace('https://alidocs.dingtalk.com/i/spaces/x')).rejects.toThrow(
'已有同名来源仍在使用:先「去掉来源」'
)
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: 'space_identity_conflict' }), { status: 409 }))
)
await expect(registerDingtalkSpace('https://alidocs.dingtalk.com/i/spaces/x')).rejects.toThrow(
'这个登记键已指向另一个知识库空间'
)
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: 'space_registration_failed' }), { status: 500 }))
)
await expect(registerDingtalkSpace('https://alidocs.dingtalk.com/i/spaces/x')).rejects.toThrow(
'登记失败(服务端内部错误)'
)
})
})