Files
HomeAgent/internal/plugins/webui/handler_knowledge_tree_test.go
JianFeeeee 6d38a164eb chore(module): Go module 路径随托管地迁移 gitcode → github
## 背景

仓库早已迁移到 GitHub(`github.com/JianFeeeee/HomeAgent`,CI/CD 都在那边跑),
gitcode 保留为**国内只读镜像**(`ci.yml` 的 sync-gitcode-main 快进推送)。
但 go.mod 的 module 路径一直是 `gitcode.com/JianFeeeee/...` —— 既与权威源
不符,`meta.SourceURL`(AGPL §13 的源码提供入口)也指向镜像而非实际运行的代码。

## 关键区分(这是本次改动安全的前提)

两个 module 是**不同**的,影响面差一个数量级:

| module | 谁依赖 | 改它的后果 |
|---|---|---|
| `.../HomeAgent`(主仓) | **只有主仓自己** | 内部重构,零外部影响 |
| `.../homeagent-sdk`(SDK 仓) | 主仓 49 文件 + 所有外部插件 | 需重编插件 |

★ 实测确认:**插件不 import 主仓 module**(`strings qq/plugin.bin | grep HomeAgent` = 0),
只依赖 SDK 仓。而插件与内核的边界是 **RPC 方法名字符串**
(`plugin.init` / `tool.invoke`…,见 proc/protocol.go),**与 Go module 名无关**。
⇒ module 改名**不改变任何 wire 协议**,现网已装的插件照常运行。

SDK 的 GitHub 仓名是 `homeagentsdk`(无连字符),module 路径必须与之精确匹配,
否则 `go get` 会因路径不符失败,故取 `github.com/JianFeeeee/homeagentsdk`。

## 改动

- `go.mod` / `go.sum`:两个 module 路径 + require/replace
- 277 个 Go 文件 + Makefile + 打包脚本的 import 与 ldflags 路径
- SDK 仓 `meta.CoreModule`、`tools/hmapdev` 的 module 声明与工程模板
- 对外文档/链接的仓库地址(README、PLUGIN_DEV、OVERVIEW 等)

## 明确**保留** gitcode 的地方(镜像机制,不能动)

- `GITCODE_TOKEN`、`GC_URL="…@gitcode.com"`(CI 同步 job)
- `gitcode.com/api/v5/...`(release 附件上传)
- git remote `gitcode`(镜像拉取/推送)
- `docs/zh/ci-cd-runbook.md`(整篇讲镜像运维)

## 验证

- `go build ./...` / `go vet ./...` 绿
- `go test ./... -count=1` → 48 包 ok / 0 FAIL
- hmapdev 工具链编译通过(`third_party/homeagent-sdk/tools/hmapdev`)
- 全仓残留检查:module 形态的 `gitcode.com/JianFeeeee` = 0
- 镜像机制抽验:GC_URL / @gitcode / api/v5 / remote 均完好

## 顺带修正

`hmapdev/templates.go` 的注释此前写「gitcode 的模块不在 proxy.golang.org 上」——
module 已迁 GitHub,该论断不再成立。改为如实说明**为何仍默认 replace 到本机 SDK**:
离线可构建、避免版本漂移、内网可用。理由与托管地无关,故 replace 保留。
2026-10-08 10:40:17 +08:00

315 lines
9.6 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 webui
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/JianFeeeee/HomeAgent/internal/knowledge"
"github.com/JianFeeeee/HomeAgent/internal/sdk"
"github.com/JianFeeeee/HomeAgent/internal/supervisor"
"github.com/JianFeeeee/HomeAgent/pkg/types"
)
func newTreeHandler(t *testing.T) (*Handler, *knowledge.Store) {
t.Helper()
ks := knowledge.NewStore(t.TempDir())
if err := ks.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(ks.Stop)
for _, e := range []struct{ name, body string }{
{"tech/go/并发", "goroutine 调度 GMP 抢占"},
{"tech/go/context", "context 取消 超时"},
{"tech/rust/所有权", "borrow checker move 语义"},
{"life/sleep", "作息 褪黑素"},
} {
if err := ks.Add(e.name, e.body); err != nil {
t.Fatal(err)
}
}
cfg := &types.Config{Daemon: types.DaemonConfig{
CheckInterval: time.Minute, HeartbeatInterval: 30 * time.Second,
}}
sup := supervisor.New(cfg)
sup.Start()
t.Cleanup(sup.Shutdown)
return NewHandler(testSDK(sdk.SDKConfig{
Supervisor: supervisor.NewSDKAdapter(sup),
Knowledge: sdk.NewKnowledge(ks),
Config: sdk.NewConfig(cfg),
})), ks
}
// 路由不被 /api/v1/knowledge/ 通配吃掉(Go 1.22+ 最长前缀匹配)。
func TestKnowledgeTreeRouteNotSwallowed(t *testing.T) {
h, _ := newTreeHandler(t)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/knowledge/tree", nil))
if rec.Code == http.StatusNotFound && rec.Body.Len() == 0 {
t.Fatal("路由未命中:/api/v1/knowledge/tree 返回了 mux 的 404")
}
// 鉴权未配置时是 503(说明命中了我们的 handler,而不是 mux 404)
if rec.Code != http.StatusServiceUnavailable {
t.Errorf("未配 api_key 时应为 503,实为 %d body=%s", rec.Code, rec.Body.String())
}
}
func treeGet(t *testing.T, h *Handler, path string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)
// 绕过鉴权直接打 handler:这里测的是路由与业务,鉴权另有测试覆盖
h.handleKnowledgeTree(rec, req)
return rec
}
func TestKnowledgeTreeAPI(t *testing.T) {
h, _ := newTreeHandler(t)
rec := treeGet(t, h, "/api/v1/knowledge/tree")
if rec.Code != http.StatusOK {
t.Fatalf("应 200,实为 %d: %s", rec.Code, rec.Body.String())
}
var resp struct {
Tree struct {
Name string `json:"name"`
Path string `json:"path"`
TotalCount int `json:"total_count"`
Children []struct {
Name string `json:"name"`
Path string `json:"path"`
TotalCount int `json:"total_count"`
Children []struct {
ItemCount int `json:"item_count"`
} `json:"children"`
} `json:"children"`
} `json:"tree"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Tree.Name != "root" || resp.Tree.Path != "" {
t.Errorf("根应为 root/空,实为 %q/%q", resp.Tree.Name, resp.Tree.Path)
}
if resp.Tree.TotalCount != 4 {
t.Errorf("总条目应为 4,实为 %d", resp.Tree.TotalCount)
}
// 子节点 Name 是本级段名、Path 是全路径
var tech *struct {
Name string `json:"name"`
Path string `json:"path"`
TotalCount int `json:"total_count"`
Children []struct {
ItemCount int `json:"item_count"`
} `json:"children"`
}
for i := range resp.Tree.Children {
if resp.Tree.Children[i].Name == "tech" {
tech = &resp.Tree.Children[i]
}
}
if tech == nil {
t.Fatal("缺少 tech 节点")
}
if tech.Path != "tech" || tech.TotalCount != 3 {
t.Errorf("tech 应为 path=tech total=3,实为 %q/%d", tech.Path, tech.TotalCount)
}
if len(tech.Children) != 2 {
t.Errorf("tech 下应有 2 个子分类,实为 %d", len(tech.Children))
}
// 响应里不得出现向量
if containsStr(rec.Body.String(), `"vector"`) {
t.Error("树 API 不应返回向量")
}
}
// 子树路由:/tree/tech/go
func TestKnowledgeTreeSubtreeAPI(t *testing.T) {
h, _ := newTreeHandler(t)
rec := treeGet(t, h, "/api/v1/knowledge/tree/tech/go")
if rec.Code != http.StatusOK {
t.Fatalf("应 200,实为 %d: %s", rec.Code, rec.Body.String())
}
var resp struct {
Tree struct {
Name string `json:"name"`
Path string `json:"path"`
ItemCount int `json:"item_count"`
} `json:"tree"`
}
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Tree.Path != "tech/go" || resp.Tree.ItemCount != 2 {
t.Errorf("tech/go 应有 2 条,实为 %q/%d", resp.Tree.Path, resp.Tree.ItemCount)
}
if resp.Tree.Name != "go" {
t.Errorf("Name 应为本级段名 go,实为 %q", resp.Tree.Name)
}
}
// 不存在的分类:404 + 附上现有分类便于自查。
func TestKnowledgeTreeNotFound(t *testing.T) {
h, _ := newTreeHandler(t)
rec := treeGet(t, h, "/api/v1/knowledge/tree/nope/here")
if rec.Code != http.StatusNotFound {
t.Fatalf("应 404,实为 %d: %s", rec.Code, rec.Body.String())
}
var resp struct {
Error string `json:"error"`
Categories []string `json:"categories"`
}
json.Unmarshal(rec.Body.Bytes(), &resp)
if len(resp.Categories) == 0 {
t.Error("404 应附带现有分类列表,便于调用方自查")
}
}
// 懒加载:depth=1 只给一层但计数准确;items=0 不返回条目。
func TestKnowledgeTreeLazyLoad(t *testing.T) {
h, _ := newTreeHandler(t)
rec := treeGet(t, h, "/api/v1/knowledge/tree?depth=1&items=0")
if rec.Code != http.StatusOK {
t.Fatalf("应 200,实为 %d", rec.Code)
}
var resp struct {
Tree struct {
TotalCount int `json:"total_count"`
Children []struct {
Name string `json:"name"`
TotalCount int `json:"total_count"`
Children []any `json:"children"`
} `json:"children"`
} `json:"tree"`
}
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Tree.TotalCount != 4 {
t.Errorf("剪枝不应改变总数,实为 %d", resp.Tree.TotalCount)
}
for _, c := range resp.Tree.Children {
if len(c.Children) != 0 {
t.Errorf("depth=1 时 %s 不应展开子节点", c.Name)
}
}
if containsStr(rec.Body.String(), `"preview"`) {
t.Error("items=0 时不应返回 preview")
}
}
// 分类内检索:/tree/tech?q=...
func TestKnowledgeTreeSearchInCategory(t *testing.T) {
h, _ := newTreeHandler(t)
rec := treeGet(t, h, "/api/v1/knowledge/tree/tech?q=borrow")
if rec.Code != http.StatusOK {
t.Fatalf("应 200,实为 %d: %s", rec.Code, rec.Body.String())
}
var resp struct {
Results []knowledgeView `json:"results"`
Category string `json:"category"`
Query string `json:"query"`
}
json.Unmarshal(rec.Body.Bytes(), &resp)
// 首位必须是真正相关的 rust/所有权。
//
// 不断言"只命中 1 条":词法路对任何查询都会给全库打个低分(这是它
// 融合设计的已知性质——靠排序把强命中顶到前面,而不是靠过滤把弱命中
// 删掉),所以 tech 子树内会有 3 条候选。这是有意行为,不是缺陷。
if len(resp.Results) == 0 {
t.Fatal("tech 子树内检索 borrow 应有命中")
}
if resp.Results[0].Name != "tech/rust/所有权" {
t.Errorf("首位应是最相关的 rust/所有权,实为 %q", resp.Results[0].Name)
}
// 但范围外的 life/sleep 绝不能出现
for _, r := range resp.Results {
if !strings.HasPrefix(r.Name, "tech/") {
t.Errorf("范围外条目 %q 混入 tech 子树检索结果", r.Name)
}
}
if resp.Category != "tech" {
t.Errorf("应回显 category=tech,实为 %q", resp.Category)
}
}
// 平铺分类与计数端点。
func TestKnowledgeTreeCategoriesAndCounts(t *testing.T) {
h, _ := newTreeHandler(t)
rec := treeGet(t, h, "/api/v1/knowledge/tree/categories")
if rec.Code != http.StatusOK {
t.Fatalf("categories 应 200,实为 %d", rec.Code)
}
var c1 struct {
Categories []string `json:"categories"`
}
json.Unmarshal(rec.Body.Bytes(), &c1)
want := map[string]bool{"tech": true, "tech/go": true, "tech/rust": true, "life": true}
for _, c := range c1.Categories {
delete(want, c)
}
if len(want) != 0 {
t.Errorf("缺少 %v,实为 %v", want, c1.Categories)
}
rec = treeGet(t, h, "/api/v1/knowledge/tree/counts")
if rec.Code != http.StatusOK {
t.Fatalf("counts 应 200,实为 %d", rec.Code)
}
var c2 struct {
Counts []struct {
Category string `json:"category"`
Count int `json:"count"`
} `json:"counts"`
}
json.Unmarshal(rec.Body.Bytes(), &c2)
byName := map[string]int{}
for _, x := range c2.Counts {
byName[x.Category] = x.Count
}
if byName["tech"] != 3 || byName["life"] != 1 {
t.Errorf("计数错误: %v", byName)
}
}
// 只读面:非 GET 一律拒绝(防止有人以为能通过它写入)。
func TestKnowledgeTreeIsReadOnly(t *testing.T) {
h, ks := newTreeHandler(t)
before := len(ks.List())
for _, m := range []string{http.MethodPost, http.MethodDelete, http.MethodPut, http.MethodPatch} {
rec := httptest.NewRecorder()
h.handleKnowledgeTree(rec, httptest.NewRequest(m, "/api/v1/knowledge/tree", nil))
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("%s 应 405,实为 %d", m, rec.Code)
}
}
if len(ks.List()) != before {
t.Error("只读端点不应改变条目数")
}
}
// 知识库不可用时给 503 而不是 panic。
func TestKnowledgeTreeUnavailable(t *testing.T) {
h, _ := newTestHandler(t)
rec := treeGet(t, h, "/api/v1/knowledge/tree")
if rec.Code != http.StatusServiceUnavailable {
t.Errorf("无知识库应 503,实为 %d", rec.Code)
}
}
func containsStr(hay, needle string) bool {
return len(hay) >= len(needle) && (func() bool {
for i := 0; i+len(needle) <= len(hay); i++ {
if hay[i:i+len(needle)] == needle {
return true
}
}
return false
})()
}