Files
knowledge-governance/internal/httpapi/dingtalk_discovery_test.go
T
姚章浩 e00cab2f70 feat(settings): 数据源配置改成「列出来、勾选、变成来源」,并去掉文件格式筛选
用户对 系统设置 → 数据源配置 的四条要求:
① 这里不要做文件格式筛选(格式归目录级标注决定);
② 列出我账号下能看到的知识库;③ 列出钉盘能看到哪些;④ 勾选那些作为来源。

① 来源卡片不再渲染 FilenameFilterEditor(目录级筛选仍在「来源与目录」里,按目录就近覆盖)。
②③ 新端点 GET /api/v1/dingtalk-spaces/available(管理员,只读):用 dws 列举
   - 知识库:wiki +space-list --type orgWikiSpace/myWikiSpace(实测 17 + 1 个)
   - 钉盘:wiki space list --type mySpace/orgSpace(实测 我的文件 + 6 个组织空间)
   实测约束写进命令形态:--limit 上限是 50;翻页必须 --page-all(--max-items 兜底),
   否则超过一页的知识库会被静默截断(被截断时页面显示"可能不全"而不是假装列全)。
   列不出来 ≠ 空列表:dws 未登录/权限不足 → 503 + 原因;某一类失败只丢那一类。
④ 面板勾选后走既有登记路径(/api/v1/dingtalk-spaces),首次目录扫描自动排队;
   已登记的按空间名标「已是来源」并禁用勾选;扫码会话失效时直接弹二维码,扫完自动重试。

基础设施:api 与 worker 共享同一份 dws 状态(bind mount 同一份,refresh token 是轮换制,
不能各留一份)。子进程 HOME 必须指向 DWS_HOME —— 这条是实测踩出来的:漏了会报"未登录",
已有回归测试钉住。dws 子进程沿用白名单环境,库口令等变量不进子进程。

测试:新包 internal/dingtalkdiscovery(fixture 用真实 dws 输出形状):paging 约束、
截断提示、一类失败不连带、全部失败→ErrUnavailable、子进程环境;httpapi 侧 401/501/503/500
与不泄漏内部错误;OpenAPI 契约(400/503 组件)通过。前端 189 条 + tsc 干净。

面板实测:知识库(18)/钉盘(7)都列出来,已有三条钉钉来源自动标「已是来源」;
勾选「视觉文档」→ 加入为来源 → 新来源 sc-8a4be5b8293d1c9a048c3c7e9b194155(dingtalk,enabled)
+ source.catalog.refresh 已完成。
2026-10-10 16:46:39 +08:00

121 lines
4.3 KiB
Go
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.
package httpapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"knowledge-governance/internal/dingtalkdiscovery"
)
type fakeDingtalkSources struct {
result dingtalkdiscovery.Result
err error
}
func (f fakeDingtalkSources) ListAvailableDingtalkSources(context.Context) (dingtalkdiscovery.Result, error) {
return f.result, f.err
}
func dingtalkSourcesRequest(role string) *http.Request {
request := httptest.NewRequest(http.MethodGet, "/api/v1/dingtalk-spaces/available", nil)
if role == "" {
return request
}
return request.WithContext(context.WithValue(request.Context(), sessionKey{}, sessionIdentity{Username: "tester", Role: role}))
}
func TestListDingtalkSourcesRequiresAdminAndWiring(t *testing.T) {
rec := httptest.NewRecorder()
listDingtalkSources(rec, dingtalkSourcesRequest(""), Config{})
if rec.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated = %d, want 401", rec.Code)
}
// 未接线:501("没接线"不能被当成空列表)。
rec = httptest.NewRecorder()
listDingtalkSources(rec, dingtalkSourcesRequest(roleAdmin), Config{})
if rec.Code != http.StatusNotImplemented {
t.Fatalf("unwired = %d, want 501 body=%s", rec.Code, rec.Body.String())
}
}
func TestListDingtalkSourcesReportsEnvironmentProblemsAsUnavailable(t *testing.T) {
// 用包装错误模拟真实映射:errors.Is 必须认出来,页面才会显示"暂时列不出来"。
config := Config{DingtalkSources: fakeDingtalkSources{err: wrapUnavailable()}}
rec := httptest.NewRecorder()
listDingtalkSources(rec, dingtalkSourcesRequest(roleAdmin), config)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("unavailable = %d, want 503 body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "dingtalk_discovery_unavailable") {
t.Fatalf("body = %s", rec.Body.String())
}
}
func wrapUnavailable() error {
return &wrappedUnavailable{err: dingtalkdiscovery.ErrUnavailable}
}
type wrappedUnavailable struct{ err error }
func (w *wrappedUnavailable) Error() string { return "list knowledge bases: " + w.err.Error() }
func (w *wrappedUnavailable) Unwrap() error { return w.err }
func TestListDingtalkSourcesReturnsKnowledgeBasesAndDriveSpaces(t *testing.T) {
config := Config{DingtalkSources: fakeDingtalkSources{result: dingtalkdiscovery.Result{
KnowledgeBases: []dingtalkdiscovery.KnowledgeBase{{Name: "ATOM知识库", SpaceID: "9JOGOQneRgkneX4Q", URL: "https://alidocs.dingtalk.com/i/spaces/9JOGOQneRgkneX4Q/overview"}},
DriveSpaces: []dingtalkdiscovery.DriveSpace{{Name: "全员文件夹", SpaceID: "102571304", SpaceType: "orgSpace"}},
DriveNotice: "list orgSpace: permission denied",
FetchedAt: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC),
}}}
rec := httptest.NewRecorder()
listDingtalkSources(rec, dingtalkSourcesRequest(roleAdmin), config)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
var payload struct {
Data struct {
KnowledgeBases []struct {
Name string `json:"name"`
SpaceID string `json:"space_id"`
URL string `json:"url"`
} `json:"knowledge_bases"`
DriveSpaces []struct {
Name string `json:"name"`
SpaceType string `json:"space_type"`
} `json:"drive_spaces"`
DriveNotice string `json:"drive_notice"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if len(payload.Data.KnowledgeBases) != 1 || payload.Data.KnowledgeBases[0].Name != "ATOM知识库" {
t.Fatalf("knowledge bases = %+v", payload.Data.KnowledgeBases)
}
if len(payload.Data.DriveSpaces) != 1 || payload.Data.DriveSpaces[0].SpaceType != "orgSpace" {
t.Fatalf("drive spaces = %+v", payload.Data.DriveSpaces)
}
if payload.Data.DriveNotice == "" {
t.Fatal("drive notice should be relayed so the page can explain why 钉盘 is missing")
}
}
func TestListDingtalkSourcesHidesInternalErrors(t *testing.T) {
config := Config{DingtalkSources: fakeDingtalkSources{err: errors.New("internal detail: connection string leaked")}}
rec := httptest.NewRecorder()
listDingtalkSources(rec, dingtalkSourcesRequest(roleAdmin), config)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d", rec.Code)
}
if strings.Contains(rec.Body.String(), "connection string") {
t.Fatalf("internal error leaked: %s", rec.Body.String())
}
}