Files
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

1884 lines
65 KiB
Go

package wiki
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"slices"
"strings"
"testing"
"time"
"unicode/utf8"
"github.com/Tencent/WeKnora/internal/datasource/connector/feishu/core"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
func TestMain(m *testing.M) {
os.Setenv("SSRF_WHITELIST", "127.0.0.1,localhost,open.feishu.cn,open.larksuite.com")
secutils.ResetSSRFWhitelistForTest()
os.Exit(m.Run())
}
// ──────────────────────────────────────────────────────────────────────
// Fake Feishu API server
// ──────────────────────────────────────────────────────────────────────
// fakeFeishu builds an httptest.Server that emulates the relevant Feishu APIs.
// It returns the server and a core.Config pointing at it.
func fakeFeishu(nodes []core.WikiNode) (*httptest.Server, *core.Config) {
mux := http.NewServeMux()
// --- auth ---
mux.HandleFunc("/open-apis/auth/v3/tenant_access_token/internal", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, core.TokenResponse{
ApiResponse: core.ApiResponse{Code: 0},
TenantAccessToken: "fake-token",
Expire: 7200,
})
})
// --- wiki spaces ---
mux.HandleFunc("/open-apis/wiki/v2/spaces", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, core.WikiSpaceListResponse{
ApiResponse: core.ApiResponse{Code: 0},
Data: core.WikiSpaceListData{
Items: []core.WikiSpace{
{SpaceID: "space1", Name: "Test Space", Description: "desc", Visibility: "public"},
},
},
})
})
// --- wiki nodes (top-level only for simplicity) ---
mux.HandleFunc("/open-apis/wiki/v2/spaces/space1/nodes", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, core.WikiNodeListResponse{
ApiResponse: core.ApiResponse{Code: 0},
Data: core.WikiNodeListData{
Items: nodes,
},
})
})
// --- export task: create ---
mux.HandleFunc("/open-apis/drive/v1/export_tasks", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
writeJSON(w, core.ExportTaskCreateResponse{
ApiResponse: core.ApiResponse{Code: 0},
Data: core.ExportTaskCreateData{Ticket: "ticket-123"},
})
return
}
// GET /open-apis/drive/v1/export_tasks/ticket-123
writeJSON(w, core.ExportTaskStatusResponse{
ApiResponse: core.ApiResponse{Code: 0},
Data: core.ExportTaskStatusData{
Result: core.ExportTaskResult{
FileToken: "ft-abc",
FileSize: 100,
JobStatus: 0, // success
FileName: "exported.docx",
},
},
})
})
// --- export task: status polling (pattern match with ticket) ---
mux.HandleFunc("/open-apis/drive/v1/export_tasks/ticket-123", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, core.ExportTaskStatusResponse{
ApiResponse: core.ApiResponse{Code: 0},
Data: core.ExportTaskStatusData{
Result: core.ExportTaskResult{
FileToken: "ft-abc",
FileSize: 100,
JobStatus: 0,
FileName: "exported.docx",
},
},
})
})
// --- export file download ---
mux.HandleFunc("/open-apis/drive/v1/export_tasks/file/ft-abc/download", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
w.Write([]byte("fake-docx-content"))
})
// --- drive file download (for "file" type nodes) ---
mux.HandleFunc("/open-apis/drive/v1/files/", func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/download") {
w.Header().Set("Content-Type", "application/octet-stream")
w.Write([]byte("fake-pdf-binary"))
return
}
http.NotFound(w, r)
})
ts := httptest.NewServer(mux)
cfg := &core.Config{
AppID: "test-app-id",
AppSecret: "test-app-secret",
BaseURL: ts.URL,
}
return ts, cfg
}
func fakeFeishuWithChildFailure(topNodes []core.WikiNode, failingParentToken string) (*httptest.Server, *core.Config) {
return fakeFeishuHierarchy(topNodes, nil, failingParentToken)
}
func fakeFeishuHierarchy(topNodes []core.WikiNode, childNodes map[string][]core.WikiNode, failingParentToken string) (*httptest.Server, *core.Config) {
mux := http.NewServeMux()
nodeByToken := make(map[string]core.WikiNode)
for _, node := range topNodes {
node.SpaceID = "space1"
nodeByToken[node.NodeToken] = node
}
for parentToken, nodes := range childNodes {
for _, node := range nodes {
node.SpaceID = "space1"
if node.ParentNodeID == "" {
node.ParentNodeID = parentToken
}
nodeByToken[node.NodeToken] = node
}
}
mux.HandleFunc("/open-apis/auth/v3/tenant_access_token/internal", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, core.TokenResponse{
ApiResponse: core.ApiResponse{Code: 0},
TenantAccessToken: "fake-token",
Expire: 7200,
})
})
mux.HandleFunc("/open-apis/wiki/v2/spaces", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, core.WikiSpaceListResponse{
ApiResponse: core.ApiResponse{Code: 0},
Data: core.WikiSpaceListData{
Items: []core.WikiSpace{
{SpaceID: "space1", Name: "Test Space", Description: "desc", Visibility: "public"},
},
},
})
})
mux.HandleFunc("/open-apis/wiki/v2/spaces/space1/nodes", func(w http.ResponseWriter, r *http.Request) {
parentToken := r.URL.Query().Get("parent_node_token")
if parentToken != "" && parentToken == failingParentToken {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"code":1663,"msg":"internal error"}`))
return
}
nodes := topNodes
if parentToken != "" {
nodes = childNodes[parentToken]
for i := range nodes {
if nodes[i].ParentNodeID == "" {
nodes[i].ParentNodeID = parentToken
}
if nodes[i].SpaceID == "" {
nodes[i].SpaceID = "space1"
}
}
}
writeJSON(w, core.WikiNodeListResponse{
ApiResponse: core.ApiResponse{Code: 0},
Data: core.WikiNodeListData{
Items: nodes,
},
})
})
mux.HandleFunc("/open-apis/wiki/v2/spaces/get_node", func(w http.ResponseWriter, r *http.Request) {
nodeToken := r.URL.Query().Get("token")
node, ok := nodeByToken[nodeToken]
if !ok {
writeJSON(w, core.WikiNodeInfoResponse{
ApiResponse: core.ApiResponse{Code: 1663, Msg: "node not found"},
})
return
}
writeJSON(w, core.WikiNodeInfoResponse{
ApiResponse: core.ApiResponse{Code: 0},
Data: core.WikiNodeInfoData{Node: node},
})
})
mux.HandleFunc("/open-apis/drive/v1/files/", func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/download") {
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write([]byte("fake-file-content"))
return
}
http.NotFound(w, r)
})
ts := httptest.NewServer(mux)
cfg := &core.Config{
AppID: "test-app-id",
AppSecret: "test-app-secret",
BaseURL: ts.URL,
}
return ts, cfg
}
func writeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
func makeConfig(cfg *core.Config, resourceIDs []string) *types.DataSourceConfig {
creds := map[string]interface{}{
"app_id": cfg.AppID,
"app_secret": cfg.AppSecret,
"base_url": cfg.BaseURL,
}
return &types.DataSourceConfig{
Type: types.ConnectorTypeFeishu,
Credentials: creds,
ResourceIDs: resourceIDs,
}
}
// ──────────────────────────────────────────────────────────────────────
// Helper function tests
// ──────────────────────────────────────────────────────────────────────
func TestIsSupportedDocType(t *testing.T) {
tests := []struct {
objType string
expected bool
}{
{"docx", true},
{"doc", true},
{"sheet", true},
{"bitable", true},
{"file", true},
{"mindnote", false},
{"slides", false},
{"unknown", false},
{"", false},
}
for _, tt := range tests {
t.Run(tt.objType, func(t *testing.T) {
got := core.IsSupportedDocType(tt.objType)
if got != tt.expected {
t.Errorf("core.IsSupportedDocType(%q) = %v, want %v", tt.objType, got, tt.expected)
}
})
}
}
func TestSanitizeFileName(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"hello", "hello"},
{"", "untitled"},
{"a/b\\c:d*e", "a_b_c_d_e"},
{"normal file.docx", "normal file.docx"},
{strings.Repeat("a", 300), strings.Repeat("a", 200)},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := core.SanitizeFileName(tt.input)
if got != tt.expected {
t.Errorf("core.SanitizeFileName(%q) = %q, want %q", tt.input, got, tt.expected)
}
})
}
}
func TestSanitizeFileName_TruncatesAtRuneBoundary(t *testing.T) {
// Each 测 is 3 bytes; raw byte truncation at 200 would split a rune and
// produce invalid UTF-8 that downstream filename validation rejects.
long := strings.Repeat("测试", 100)
got := core.SanitizeFileName(long)
if !utf8.ValidString(got) {
t.Fatalf("core.SanitizeFileName produced invalid UTF-8: %q", got)
}
if len(got) > 200 {
t.Errorf("len = %d, want ≤ 200", len(got))
}
if len(got) == 0 {
t.Error("result is empty")
}
}
func TestSanitizeFileName_PreservesExtensionOnTruncation(t *testing.T) {
// A pathologically long (>200-byte) CJK attachment name ending in ".pdf".
// Truncation must keep the ".pdf" suffix — downstream file-type validation
// classifies by extension, so a chopped extension would reject the file.
long := strings.Repeat("报告", 150) + ".pdf" // 150*6 bytes + ".pdf"
got := core.SanitizeFileName(long)
if !utf8.ValidString(got) {
t.Fatalf("produced invalid UTF-8: %q", got)
}
if len(got) > 200 {
t.Errorf("len = %d, want ≤ 200", len(got))
}
if !strings.HasSuffix(got, ".pdf") {
t.Errorf("extension dropped by truncation: %q must end in .pdf", got)
}
}
func TestParseFeishuTimestamp(t *testing.T) {
ts := core.ParseFeishuTimestamp("1711468800") // 2024-03-27 00:00:00 UTC
if ts.IsZero() {
t.Fatal("expected non-zero time")
}
if ts.Unix() != 1711468800 {
t.Errorf("unexpected unix = %d", ts.Unix())
}
if !core.ParseFeishuTimestamp("").IsZero() {
t.Error("expected zero time for empty string")
}
if !core.ParseFeishuTimestamp("invalid").IsZero() {
t.Error("expected zero time for invalid string")
}
}
func TestParseFeishuConfig(t *testing.T) {
t.Run("valid", func(t *testing.T) {
cfg, err := core.ParseFeishuConfig(&types.DataSourceConfig{
Credentials: map[string]interface{}{
"app_id": "id1",
"app_secret": "sec1",
"base_url": "https://open.feishu.cn",
},
}, core.RegionFeishu)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.AppID != "id1" || cfg.AppSecret != "sec1" || cfg.BaseURL != "https://open.feishu.cn" {
t.Errorf("unexpected config: %+v", cfg)
}
})
t.Run("nil config", func(t *testing.T) {
_, err := core.ParseFeishuConfig(nil, core.RegionFeishu)
if err == nil {
t.Fatal("expected error for nil config")
}
})
t.Run("missing credentials", func(t *testing.T) {
_, err := core.ParseFeishuConfig(&types.DataSourceConfig{
Credentials: map[string]interface{}{
"app_id": "id1",
// missing app_secret
},
}, core.RegionFeishu)
if err == nil {
t.Fatal("expected error for missing app_secret")
}
})
}
// ──────────────────────────────────────────────────────────────────────
// Connector interface tests
// ──────────────────────────────────────────────────────────────────────
func TestConnectorType(t *testing.T) {
c := NewConnector(core.RegionFeishu)
if c.Type() != types.ConnectorTypeFeishu {
t.Errorf("Type() = %q, want %q", c.Type(), types.ConnectorTypeFeishu)
}
}
func TestConnectorValidate(t *testing.T) {
ts, cfg := fakeFeishu(nil)
defer ts.Close()
c := NewConnector(core.RegionFeishu)
err := c.Validate(context.Background(), makeConfig(cfg, nil))
if err != nil {
t.Fatalf("Validate() error: %v", err)
}
}
func TestConnectorValidate_BadCredentials(t *testing.T) {
c := NewConnector(core.RegionFeishu)
err := c.Validate(context.Background(), &types.DataSourceConfig{
Credentials: map[string]interface{}{
"app_id": "bad",
"app_secret": "bad",
"base_url": "http://127.0.0.1:1", // will fail to connect
},
})
if err == nil {
t.Fatal("expected error for bad credentials")
}
}
func TestConnectorListResources(t *testing.T) {
ts, cfg := fakeFeishu(nil)
defer ts.Close()
c := NewConnector(core.RegionFeishu)
resources, err := c.ListResources(context.Background(), makeConfig(cfg, nil), "")
if err != nil {
t.Fatalf("ListResources() error: %v", err)
}
if len(resources) != 1 {
t.Fatalf("expected 1 resource, got %d", len(resources))
}
if resources[0].ExternalID != "space1" {
t.Errorf("ExternalID = %q, want %q", resources[0].ExternalID, "space1")
}
if resources[0].Name != "Test Space" {
t.Errorf("Name = %q, want %q", resources[0].Name, "Test Space")
}
if resources[0].Type != "wiki_space" {
t.Errorf("Type = %q, want %q", resources[0].Type, "wiki_space")
}
if !resources[0].HasChildren {
t.Errorf("HasChildren = false, want true")
}
}
// TestConnectorListResources_LazyLoadsOneLevel verifies that ListResources loads
// the wiki tree lazily — only the requested level — instead of recursing the whole
// tree up front (Tencent/WeKnora#1672).
func TestConnectorListResources_LazyLoadsOneLevel(t *testing.T) {
topNodes := []core.WikiNode{
{NodeToken: "nt-root", ObjToken: "obj-root", ObjType: "docx", Title: "Root", HasChild: true, ObjEditTime: "100"},
{NodeToken: "nt-peer", ObjToken: "obj-peer", ObjType: "docx", Title: "Peer", ObjEditTime: "200"},
}
childNodes := map[string][]core.WikiNode{
"nt-root": {
{NodeToken: "nt-child", ObjToken: "obj-child", ObjType: "docx", Title: "Child", ObjEditTime: "300"},
},
}
ts, cfg := fakeFeishuHierarchy(topNodes, childNodes, "")
defer ts.Close()
c := NewConnector(core.RegionFeishu)
// Root listing: only the space, no descendants.
spaces, err := c.ListResources(context.Background(), makeConfig(cfg, nil), "")
if err != nil {
t.Fatalf("ListResources(root) error: %v", err)
}
if len(spaces) != 1 || spaces[0].ExternalID != "space1" || !spaces[0].HasChildren {
t.Fatalf("want single space with children, got %+v", spaces)
}
// Expanding the space returns only its top-level nodes.
top, err := c.ListResources(context.Background(), makeConfig(cfg, nil), "space1")
if err != nil {
t.Fatalf("ListResources(space1) error: %v", err)
}
if len(top) != 2 {
t.Fatalf("want 2 top-level nodes, got %d: %+v", len(top), top)
}
byID := make(map[string]types.Resource)
for _, r := range top {
byID[r.ExternalID] = r
}
root := byID["space1:nt-root"]
if root.ParentID != "space1" || !root.HasChildren {
t.Fatalf("root node resource wrong: %+v", root)
}
// Expanding a node returns only its direct children.
children, err := c.ListResources(context.Background(), makeConfig(cfg, nil), "space1:nt-root")
if err != nil {
t.Fatalf("ListResources(space1:nt-root) error: %v", err)
}
if len(children) != 1 {
t.Fatalf("want 1 child node, got %d: %+v", len(children), children)
}
child := children[0]
if child.ExternalID != "space1:nt-child" || child.ParentID != "space1:nt-root" || child.Name != "Child" {
t.Fatalf("child resource wrong: %+v", child)
}
if child.Metadata["space_id"] != "space1" || child.Metadata["node_token"] != "nt-child" {
t.Fatalf("child metadata wrong: %+v", child.Metadata)
}
}
// TestConnectorResolveResourceAncestors verifies that the ancestor chain of a
// deeply nested selection is resolved (so an edit-mode picker can reveal it)
// without listing the whole tree.
func TestConnectorResolveResourceAncestors(t *testing.T) {
topNodes := []core.WikiNode{
{NodeToken: "nt-root", ObjToken: "obj-root", ObjType: "docx", Title: "Root", HasChild: true},
}
childNodes := map[string][]core.WikiNode{
"nt-root": {
{NodeToken: "nt-child", ObjToken: "obj-child", ObjType: "docx", Title: "Child", HasChild: true},
},
"nt-child": {
{NodeToken: "nt-grandchild", ObjToken: "obj-gc", ObjType: "docx", Title: "Grandchild"},
},
}
ts, cfg := fakeFeishuHierarchy(topNodes, childNodes, "")
defer ts.Close()
c := NewConnector(core.RegionFeishu)
// A deeply nested node resolves to its space plus every intermediate parent.
ancestors, err := c.ResolveResourceAncestors(
context.Background(), makeConfig(cfg, nil), []string{"space1:nt-grandchild"},
)
if err != nil {
t.Fatalf("ResolveResourceAncestors error: %v", err)
}
got := make(map[string]bool)
for _, a := range ancestors {
got[a] = true
}
for _, want := range []string{"space1", "space1:nt-root", "space1:nt-child"} {
if !got[want] {
t.Fatalf("missing ancestor %q, got %+v", want, ancestors)
}
}
if got["space1:nt-grandchild"] {
t.Fatalf("selection itself must not be returned as its own ancestor: %+v", ancestors)
}
// A top-level node only needs its space loaded.
ancestors, err = c.ResolveResourceAncestors(
context.Background(), makeConfig(cfg, nil), []string{"space1:nt-root"},
)
if err != nil {
t.Fatalf("ResolveResourceAncestors(top-level) error: %v", err)
}
if len(ancestors) != 1 || ancestors[0] != "space1" {
t.Fatalf("want [space1] for a top-level node, got %+v", ancestors)
}
// A whole-space selection is already top-level: nothing to reveal.
ancestors, err = c.ResolveResourceAncestors(
context.Background(), makeConfig(cfg, nil), []string{"space1"},
)
if err != nil {
t.Fatalf("ResolveResourceAncestors(space) error: %v", err)
}
if len(ancestors) != 0 {
t.Fatalf("want no ancestors for a space selection, got %+v", ancestors)
}
}
// ──────────────────────────────────────────────────────────────────────
// FetchAll: test all supported doc types
// ──────────────────────────────────────────────────────────────────────
func TestFetchAll_DocxNode(t *testing.T) {
nodes := []core.WikiNode{{
NodeToken: "nt1",
ObjToken: "obj-docx-1",
ObjType: "docx",
Title: "My Document",
NodeEditTime: "1711468800",
}}
ts, cfg := fakeFeishu(nodes)
defer ts.Close()
c := NewConnector(core.RegionFeishu)
items, err := c.FetchAll(context.Background(), makeConfig(cfg, []string{"space1"}), []string{"space1"})
if err != nil {
t.Fatalf("FetchAll() error: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 item, got %d", len(items))
}
item := items[0]
if item.ExternalID != "nt1" {
t.Errorf("ExternalID = %q, want %q", item.ExternalID, "nt1")
}
if item.Title != "My Document" {
t.Errorf("Title = %q", item.Title)
}
if string(item.Content) != "fake-docx-content" {
t.Errorf("Content = %q, want %q", string(item.Content), "fake-docx-content")
}
if item.FileName != "exported.docx" {
t.Errorf("FileName = %q, want %q", item.FileName, "exported.docx")
}
if item.Metadata["obj_type"] != "docx" {
t.Errorf("Metadata[obj_type] = %q", item.Metadata["obj_type"])
}
if item.Metadata["channel"] != types.ChannelFeishu {
t.Errorf("Metadata[channel] = %q", item.Metadata["channel"])
}
}
func TestFetchAll_SheetNode(t *testing.T) {
nodes := []core.WikiNode{{
NodeToken: "nt-sheet",
ObjToken: "obj-sheet-1",
ObjType: "sheet",
Title: "Sales Report",
NodeEditTime: "1711468800",
}}
ts, cfg := fakeFeishu(nodes)
defer ts.Close()
c := NewConnector(core.RegionFeishu)
items, err := c.FetchAll(context.Background(), makeConfig(cfg, []string{"space1"}), []string{"space1"})
if err != nil {
t.Fatalf("FetchAll() error: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 item, got %d", len(items))
}
if items[0].Metadata["obj_type"] != "sheet" {
t.Errorf("obj_type = %q, want sheet", items[0].Metadata["obj_type"])
}
}
func TestFetchAll_BitableNode(t *testing.T) {
nodes := []core.WikiNode{{
NodeToken: "nt-bitable",
ObjToken: "obj-bitable-1",
ObjType: "bitable",
Title: "Project Tracker",
NodeEditTime: "1711468800",
}}
ts, cfg := fakeFeishu(nodes)
defer ts.Close()
c := NewConnector(core.RegionFeishu)
items, err := c.FetchAll(context.Background(), makeConfig(cfg, []string{"space1"}), []string{"space1"})
if err != nil {
t.Fatalf("FetchAll() error: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 item, got %d", len(items))
}
if items[0].Metadata["obj_type"] != "bitable" {
t.Errorf("obj_type = %q, want bitable", items[0].Metadata["obj_type"])
}
}
func TestFetchAll_FileNode(t *testing.T) {
nodes := []core.WikiNode{{
NodeToken: "nt-file",
ObjToken: "obj-file-1",
ObjType: "file",
Title: "manual.pdf",
NodeEditTime: "1711468800",
}}
ts, cfg := fakeFeishu(nodes)
defer ts.Close()
c := NewConnector(core.RegionFeishu)
items, err := c.FetchAll(context.Background(), makeConfig(cfg, []string{"space1"}), []string{"space1"})
if err != nil {
t.Fatalf("FetchAll() error: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 item, got %d", len(items))
}
item := items[0]
if string(item.Content) != "fake-pdf-binary" {
t.Errorf("Content = %q, want %q", string(item.Content), "fake-pdf-binary")
}
if item.FileName != "manual.pdf" {
t.Errorf("FileName = %q, want %q", item.FileName, "manual.pdf")
}
if item.Metadata["obj_type"] != "file" {
t.Errorf("obj_type = %q, want file", item.Metadata["obj_type"])
}
}
func TestFetchAll_SkipsMindnoteAndSlides(t *testing.T) {
nodes := []core.WikiNode{
{NodeToken: "nt-mn", ObjToken: "obj-mn", ObjType: "mindnote", Title: "Brain Map"},
{NodeToken: "nt-sl", ObjToken: "obj-sl", ObjType: "slides", Title: "Presentation"},
}
ts, cfg := fakeFeishu(nodes)
defer ts.Close()
c := NewConnector(core.RegionFeishu)
items, err := c.FetchAll(context.Background(), makeConfig(cfg, []string{"space1"}), []string{"space1"})
if err != nil {
t.Fatalf("FetchAll() error: %v", err)
}
// Both should be core.Skipped (nil returned by fetchNodeContent)
if len(items) != 0 {
t.Errorf("expected 0 items (mindnote+slides core.Skipped), got %d", len(items))
}
}
func TestFetchAll_MixedTypes(t *testing.T) {
nodes := []core.WikiNode{
{NodeToken: "nt1", ObjToken: "obj1", ObjType: "docx", Title: "Doc", NodeEditTime: "1711468800"},
{NodeToken: "nt2", ObjToken: "obj2", ObjType: "sheet", Title: "Sheet", NodeEditTime: "1711468800"},
{NodeToken: "nt3", ObjToken: "obj3", ObjType: "file", Title: "report.pdf", NodeEditTime: "1711468800"},
{NodeToken: "nt4", ObjToken: "obj4", ObjType: "mindnote", Title: "Mind", NodeEditTime: "1711468800"},
{NodeToken: "nt5", ObjToken: "obj5", ObjType: "slides", Title: "Slides", NodeEditTime: "1711468800"},
{NodeToken: "nt6", ObjToken: "obj6", ObjType: "bitable", Title: "Table", NodeEditTime: "1711468800"},
}
ts, cfg := fakeFeishu(nodes)
defer ts.Close()
c := NewConnector(core.RegionFeishu)
items, err := c.FetchAll(context.Background(), makeConfig(cfg, []string{"space1"}), []string{"space1"})
if err != nil {
t.Fatalf("FetchAll() error: %v", err)
}
// docx + sheet + file + bitable = 4 items; mindnote + slides = core.Skipped
if len(items) != 4 {
t.Errorf("expected 4 items, got %d", len(items))
for i, it := range items {
t.Logf(" item[%d]: %s (obj_type=%s)", i, it.Title, it.Metadata["obj_type"])
}
}
}
func TestFetchAll_LogsSummaryWithSkipBreakdown(t *testing.T) {
nodes := []core.WikiNode{
{NodeToken: "nt1", ObjToken: "obj1", ObjType: "docx", Title: "Doc", NodeEditTime: "1711468800"},
{NodeToken: "nt4", ObjToken: "obj4", ObjType: "mindnote", Title: "Mind", NodeEditTime: "1711468800"},
{NodeToken: "nt5", ObjToken: "obj5", ObjType: "slides", Title: "Slides", NodeEditTime: "1711468800"},
}
ts, cfg := fakeFeishu(nodes)
defer ts.Close()
var buf bytes.Buffer
logger.SetOutput(&buf)
defer logger.SetOutput(os.Stderr)
c := NewConnector(core.RegionFeishu)
if _, err := c.FetchAll(context.Background(), makeConfig(cfg, []string{"space1"}), []string{"space1"}); err != nil {
t.Fatalf("FetchAll() error: %v", err)
}
out := buf.String()
for _, want := range []string{
"stream summary",
"discovered=3",
"fetched=1",
"skipped_unsupported=2",
"mindnote:1",
"slides:1",
} {
if !strings.Contains(out, want) {
t.Errorf("summary log missing %q; got:\n%s", want, out)
}
}
}
func TestFetchAll_ChildNodeListErrorReturnsPartialItems(t *testing.T) {
nodes := []core.WikiNode{
{NodeToken: "nt-parent", ObjToken: "obj-parent", ObjType: "file", Title: "Parent.pdf", NodeEditTime: "100", HasChild: true},
{NodeToken: "nt-peer", ObjToken: "obj-peer", ObjType: "file", Title: "Peer.pdf", NodeEditTime: "200"},
}
ts, cfg := fakeFeishuWithChildFailure(nodes, "nt-parent")
defer ts.Close()
conn := NewConnector(core.RegionFeishu)
items, err := conn.FetchAll(context.Background(), makeConfig(cfg, []string{"space1"}), []string{"space1"})
if err != nil {
t.Fatalf("FetchAll must not abort when one child listing fails: %v", err)
}
if len(items) != 3 {
t.Fatalf("want 3 items (2 fetched + 1 failure placeholder), got %d: %+v", len(items), items)
}
var parent, peer, placeholder *types.FetchedItem
for i := range items {
switch {
case items[i].ExternalID == "nt-parent" && len(items[i].Content) > 0:
parent = &items[i]
case items[i].ExternalID == "nt-peer" && len(items[i].Content) > 0:
peer = &items[i]
case items[i].Metadata["error"] != "":
placeholder = &items[i]
}
}
if parent == nil || peer == nil {
t.Fatalf("expected both successfully listed nodes to be fetched, parent=%v peer=%v", parent != nil, peer != nil)
}
if placeholder == nil {
t.Fatal("expected a failure placeholder for the child listing error")
}
if placeholder.ExternalID != "nt-parent" || placeholder.Title != "Parent.pdf" {
t.Errorf("placeholder identity wrong: %+v", placeholder)
}
if placeholder.Metadata["channel"] != types.ChannelFeishu {
t.Errorf("placeholder channel = %q", placeholder.Metadata["channel"])
}
if placeholder.Metadata["node_token"] != "nt-parent" || placeholder.Metadata["space_id"] != "space1" {
t.Errorf("placeholder missing traceability metadata: %+v", placeholder.Metadata)
}
if !strings.Contains(placeholder.Metadata["error"], "list children of nt-parent") {
t.Errorf("placeholder error = %q", placeholder.Metadata["error"])
}
}
func TestFetchAll_WikiNodeResourceSyncsSelectedSubtree(t *testing.T) {
topNodes := []core.WikiNode{
{NodeToken: "nt-root", ObjToken: "obj-root", ObjType: "file", Title: "Root.pdf", NodeEditTime: "100", HasChild: true},
{NodeToken: "nt-peer", ObjToken: "obj-peer", ObjType: "file", Title: "Peer.pdf", NodeEditTime: "200"},
}
childNodes := map[string][]core.WikiNode{
"nt-root": {
{NodeToken: "nt-child", ObjToken: "obj-child", ObjType: "file", Title: "Child.pdf", NodeEditTime: "300"},
},
}
ts, cfg := fakeFeishuHierarchy(topNodes, childNodes, "")
defer ts.Close()
resourceID := "space1:nt-root"
conn := NewConnector(core.RegionFeishu)
items, err := conn.FetchAll(context.Background(), makeConfig(cfg, []string{resourceID}), []string{resourceID})
if err != nil {
t.Fatalf("FetchAll() error: %v", err)
}
if len(items) != 2 {
t.Fatalf("want selected root and child only, got %d: %+v", len(items), items)
}
got := make(map[string]types.FetchedItem)
for _, item := range items {
got[item.ExternalID] = item
}
if _, ok := got["nt-root"]; !ok {
t.Fatal("selected root node was not fetched")
}
if _, ok := got["nt-child"]; !ok {
t.Fatal("child node was not fetched")
}
if _, ok := got["nt-peer"]; ok {
t.Fatal("peer outside selected subtree must not be fetched")
}
if got["nt-root"].SourceResourceID != resourceID || got["nt-child"].SourceResourceID != resourceID {
t.Fatalf("SourceResourceID should preserve selected resource id: %+v", got)
}
}
// ──────────────────────────────────────────────────────────────────────
// FetchIncremental tests
// ──────────────────────────────────────────────────────────────────────
func TestFetchIncremental_FirstSync(t *testing.T) {
nodes := []core.WikiNode{
{NodeToken: "nt1", ObjToken: "obj1", ObjType: "docx", Title: "Doc1", NodeEditTime: "100"},
{NodeToken: "nt2", ObjToken: "obj2", ObjType: "file", Title: "file.pdf", NodeEditTime: "200"},
}
ts, cfg := fakeFeishu(nodes)
defer ts.Close()
c := NewConnector(core.RegionFeishu)
dsConfig := makeConfig(cfg, []string{"space1"})
// First sync with no cursor → all items should be fetched
items, cursor, err := c.FetchIncremental(context.Background(), dsConfig, nil)
if err != nil {
t.Fatalf("FetchIncremental() error: %v", err)
}
if len(items) != 2 {
t.Fatalf("expected 2 items on first sync, got %d", len(items))
}
if cursor == nil {
t.Fatal("expected non-nil cursor")
}
if cursor.LastSyncTime.IsZero() {
t.Error("cursor.LastSyncTime should not be zero")
}
}
func TestFetchIncremental_NoChanges(t *testing.T) {
nodes := []core.WikiNode{
{NodeToken: "nt1", ObjToken: "obj1", ObjType: "docx", Title: "Doc1", NodeEditTime: "100"},
}
ts, cfg := fakeFeishu(nodes)
defer ts.Close()
c := NewConnector(core.RegionFeishu)
dsConfig := makeConfig(cfg, []string{"space1"})
// First sync
_, cursor1, err := c.FetchIncremental(context.Background(), dsConfig, nil)
if err != nil {
t.Fatalf("first sync error: %v", err)
}
// Second sync with same edit times → should return 0 changed items
items, _, err := c.FetchIncremental(context.Background(), dsConfig, cursor1)
if err != nil {
t.Fatalf("second sync error: %v", err)
}
if len(items) != 0 {
t.Errorf("expected 0 items (no changes), got %d", len(items))
}
}
func TestFetchIncremental_DetectsDeleted(t *testing.T) {
// First sync: 2 nodes
allNodes := []core.WikiNode{
{NodeToken: "nt1", ObjToken: "obj1", ObjType: "docx", Title: "Doc1", NodeEditTime: "100"},
{NodeToken: "nt2", ObjToken: "obj2", ObjType: "docx", Title: "Doc2", NodeEditTime: "200"},
}
ts, cfg := fakeFeishu(allNodes)
c := NewConnector(core.RegionFeishu)
dsConfig := makeConfig(cfg, []string{"space1"})
_, cursor1, err := c.FetchIncremental(context.Background(), dsConfig, nil)
if err != nil {
t.Fatalf("first sync error: %v", err)
}
ts.Close()
// Second sync: only 1 node remains (nt2 was deleted)
ts2, cfg2 := fakeFeishu([]core.WikiNode{
{NodeToken: "nt1", ObjToken: "obj1", ObjType: "docx", Title: "Doc1", NodeEditTime: "100"},
})
defer ts2.Close()
dsConfig2 := makeConfig(cfg2, []string{"space1"})
items, _, err := c.FetchIncremental(context.Background(), dsConfig2, cursor1)
if err != nil {
t.Fatalf("second sync error: %v", err)
}
// Should have 1 deleted item for nt2
var deletedCount int
for _, item := range items {
if item.IsDeleted {
deletedCount++
if item.ExternalID != "nt2" {
t.Errorf("expected deleted ExternalID=nt2, got %q", item.ExternalID)
}
}
}
if deletedCount != 1 {
t.Errorf("expected 1 deleted item, got %d", deletedCount)
}
}
func TestFetchIncremental_NoResourceIDs(t *testing.T) {
ts, cfg := fakeFeishu(nil)
defer ts.Close()
c := NewConnector(core.RegionFeishu)
dsConfig := makeConfig(cfg, nil) // no resource IDs
dsConfig.ResourceIDs = nil
_, _, err := c.FetchIncremental(context.Background(), dsConfig, nil)
if err == nil {
t.Fatal("expected error for empty resource IDs")
}
if !strings.Contains(err.Error(), "no resource IDs") {
t.Errorf("unexpected error: %v", err)
}
}
func TestFetchIncremental_ChildNodeListErrorReturnsPartialItemsAndCursor(t *testing.T) {
nodes := []core.WikiNode{
{NodeToken: "nt-parent", ObjToken: "obj-parent", ObjType: "file", Title: "Parent.pdf", NodeEditTime: "100", HasChild: true},
{NodeToken: "nt-peer", ObjToken: "obj-peer", ObjType: "file", Title: "Peer.pdf", NodeEditTime: "200"},
}
ts, cfg := fakeFeishuWithChildFailure(nodes, "nt-parent")
defer ts.Close()
conn := NewConnector(core.RegionFeishu)
items, cursor, err := conn.FetchIncremental(context.Background(), makeConfig(cfg, []string{"space1"}), nil)
if err != nil {
t.Fatalf("FetchIncremental must not abort when one child listing fails: %v", err)
}
if cursor == nil {
t.Fatal("expected cursor to be returned after partial sync")
}
if len(items) != 3 {
t.Fatalf("want 3 items (2 fetched + 1 failure placeholder), got %d: %+v", len(items), items)
}
var placeholder *types.FetchedItem
for i := range items {
if items[i].Metadata["error"] != "" {
placeholder = &items[i]
break
}
}
if placeholder == nil {
t.Fatal("expected a failure placeholder for the child listing error")
}
if placeholder.Metadata["node_token"] != "nt-parent" {
t.Errorf("placeholder node_token = %q", placeholder.Metadata["node_token"])
}
}
func TestFetchIncremental_ChildNodeListErrorDoesNotDeletePreviouslySeenChildren(t *testing.T) {
firstNodes := []core.WikiNode{
{NodeToken: "nt-parent", ObjToken: "obj-parent", ObjType: "file", Title: "Parent.pdf", NodeEditTime: "100", HasChild: true},
}
firstChildren := map[string][]core.WikiNode{
"nt-parent": {
{NodeToken: "nt-child", ObjToken: "obj-child", ObjType: "file", Title: "Child.pdf", NodeEditTime: "150"},
},
}
ts, cfg := fakeFeishuHierarchy(firstNodes, firstChildren, "")
conn := NewConnector(core.RegionFeishu)
firstItems, cursor, err := conn.FetchIncremental(context.Background(), makeConfig(cfg, []string{"space1"}), nil)
if err != nil {
t.Fatalf("first sync error: %v", err)
}
if len(firstItems) != 2 {
t.Fatalf("want 2 first-sync items, got %d", len(firstItems))
}
ts.Close()
secondNodes := []core.WikiNode{
{NodeToken: "nt-parent", ObjToken: "obj-parent", ObjType: "file", Title: "Parent.pdf", NodeEditTime: "100", HasChild: true},
}
ts2, cfg2 := fakeFeishuHierarchy(secondNodes, nil, "nt-parent")
defer ts2.Close()
items, nextCursor, err := conn.FetchIncremental(context.Background(), makeConfig(cfg2, []string{"space1"}), cursor)
if err != nil {
t.Fatalf("partial second sync error: %v", err)
}
for _, item := range items {
if item.IsDeleted {
t.Fatalf("partial child listing must not mark previously seen children as deleted: %+v", item)
}
}
cursorBytes, _ := json.Marshal(nextCursor.ConnectorCursor)
var restored core.FeishuCursor
if err := json.Unmarshal(cursorBytes, &restored); err != nil {
t.Fatalf("restore cursor: %v", err)
}
if restored.SpaceNodeTimes["space1"]["nt-child"] != "150" {
t.Fatalf("partial sync should preserve previous child cursor entry, got %+v", restored.SpaceNodeTimes["space1"])
}
}
// ──────────────────────────────────────────────────────────────────────
// core.Client tests
// ──────────────────────────────────────────────────────────────────────
func TestClientPing(t *testing.T) {
ts, cfg := fakeFeishu(nil)
defer ts.Close()
client := core.NewClient(cfg)
if err := client.Ping(context.Background()); err != nil {
t.Fatalf("core.Ping() error: %v", err)
}
}
func TestClientTokenCaching(t *testing.T) {
callCount := 0
mux := http.NewServeMux()
mux.HandleFunc("/open-apis/auth/v3/tenant_access_token/internal", func(w http.ResponseWriter, r *http.Request) {
callCount++
writeJSON(w, core.TokenResponse{
ApiResponse: core.ApiResponse{Code: 0},
TenantAccessToken: fmt.Sprintf("token-%d", callCount),
Expire: 7200,
})
})
ts := httptest.NewServer(mux)
defer ts.Close()
client := core.NewClient(&core.Config{AppID: "a", AppSecret: "b", BaseURL: ts.URL})
// First call: fetches token
t1, _ := client.GetTenantAccessToken(context.Background())
// Second call: should use cache
t2, _ := client.GetTenantAccessToken(context.Background())
if t1 != t2 {
t.Errorf("expected cached token, got different tokens: %q vs %q", t1, t2)
}
if callCount != 1 {
t.Errorf("expected 1 auth call, got %d", callCount)
}
}
func TestClientExportAndDownload(t *testing.T) {
ts, cfg := fakeFeishu(nil)
defer ts.Close()
client := core.NewClient(cfg)
data, fileName, err := client.ExportAndDownload(context.Background(), "obj-token-1", "docx")
if err != nil {
t.Fatalf("core.ExportAndDownload() error: %v", err)
}
if string(data) != "fake-docx-content" {
t.Errorf("data = %q, want %q", string(data), "fake-docx-content")
}
if fileName != "exported.docx" {
t.Errorf("fileName = %q, want %q", fileName, "exported.docx")
}
}
func TestClientExportAndDownload_UnsupportedType(t *testing.T) {
ts, cfg := fakeFeishu(nil)
defer ts.Close()
client := core.NewClient(cfg)
_, _, err := client.ExportAndDownload(context.Background(), "obj-token-1", "mindnote")
if err == nil {
t.Fatal("expected error for unsupported type")
}
if !strings.Contains(err.Error(), "unsupported") {
t.Errorf("unexpected error: %v", err)
}
}
func TestClientDownloadDriveFile(t *testing.T) {
ts, cfg := fakeFeishu(nil)
defer ts.Close()
client := core.NewClient(cfg)
data, err := client.DownloadDriveFile(context.Background(), "file-token-abc")
if err != nil {
t.Fatalf("core.DownloadDriveFile() error: %v", err)
}
if string(data) != "fake-pdf-binary" {
t.Errorf("data = %q, want %q", string(data), "fake-pdf-binary")
}
}
func TestClientListWikiSpaces(t *testing.T) {
ts, cfg := fakeFeishu(nil)
defer ts.Close()
client := core.NewClient(cfg)
spaces, err := client.ListWikiSpaces(context.Background())
if err != nil {
t.Fatalf("core.ListWikiSpaces() error: %v", err)
}
if len(spaces) != 1 {
t.Fatalf("expected 1 space, got %d", len(spaces))
}
if spaces[0].SpaceID != "space1" {
t.Errorf("SpaceID = %q", spaces[0].SpaceID)
}
}
// ──────────────────────────────────────────────────────────────────────
// Type mapping tests
// ──────────────────────────────────────────────────────────────────────
func TestObjTypeToExportMappings(t *testing.T) {
// Verify all exportable types have valid mappings
exportable := []string{"docx", "doc", "sheet", "bitable"}
for _, ot := range exportable {
if _, ok := core.ObjTypeToExportFileExtension[ot]; !ok {
t.Errorf("core.ObjTypeToExportFileExtension missing %q", ot)
}
if _, ok := core.ObjTypeToExportType[ot]; !ok {
t.Errorf("core.ObjTypeToExportType missing %q", ot)
}
}
// Verify non-exportable types do NOT have mappings
nonExportable := []string{"file", "mindnote", "slides"}
for _, ot := range nonExportable {
if _, ok := core.ObjTypeToExportFileExtension[ot]; ok {
t.Errorf("core.ObjTypeToExportFileExtension should NOT contain %q", ot)
}
}
}
func TestExportFileExtToSuffix(t *testing.T) {
if core.ExportFileExtToSuffix[core.ExportTypeDocx] != ".docx" {
t.Errorf("docx suffix = %q", core.ExportFileExtToSuffix[core.ExportTypeDocx])
}
if core.ExportFileExtToSuffix[core.ExportTypeXlsx] != ".xlsx" {
t.Errorf("xlsx suffix = %q", core.ExportFileExtToSuffix[core.ExportTypeXlsx])
}
if core.ExportFileExtToSuffix[core.ExportTypePDF] != ".pdf" {
t.Errorf("pdf suffix = %q", core.ExportFileExtToSuffix[core.ExportTypePDF])
}
}
// ──────────────────────────────────────────────────────────────────────
// docx blocks path: multi-item fan-out + attachment sub-items
// ──────────────────────────────────────────────────────────────────────
// fakeFeishuWithBlocks returns a test server that serves the auth/spaces/nodes
// endpoints plus:
// - a blocks endpoint for the given docx document (one text block + one file
// block whose attachment token is attToken and name is attName)
// - a drive file download for attToken returning attContent
//
// The export endpoint is intentionally absent; any call to it returns 404 so the
// test verifies the blocks-API path, not the export-fallback path.
func fakeFeishuWithBlocks(nodes []core.WikiNode, docToken, attToken, attName string, attContent []byte) (*httptest.Server, *core.Config) {
mux := http.NewServeMux()
mux.HandleFunc("/open-apis/auth/v3/tenant_access_token/internal", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, core.TokenResponse{
ApiResponse: core.ApiResponse{Code: 0},
TenantAccessToken: "fake-token",
Expire: 7200,
})
})
mux.HandleFunc("/open-apis/wiki/v2/spaces", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, core.WikiSpaceListResponse{
ApiResponse: core.ApiResponse{Code: 0},
Data: core.WikiSpaceListData{Items: []core.WikiSpace{{SpaceID: "space1", Name: "Test Space"}}},
})
})
mux.HandleFunc("/open-apis/wiki/v2/spaces/space1/nodes", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, core.WikiNodeListResponse{
ApiResponse: core.ApiResponse{Code: 0},
Data: core.WikiNodeListData{Items: nodes},
})
})
// blocks API for the given docx document
blocksPath := "/open-apis/docx/v1/documents/" + docToken + "/blocks"
mux.HandleFunc(blocksPath, func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, core.DocxBlocksResponse{
ApiResponse: core.ApiResponse{Code: 0},
Data: core.DocxBlocksData{
Items: []core.DocxBlock{
{BlockID: "b1", BlockType: core.BlockTypePage},
{BlockID: "b2", BlockType: core.BlockTypeText, Text: &core.BlockText{
Elements: []core.TextElement{{TextRun: &core.TextRun{Content: "Hello blocks"}}},
}},
{BlockID: "b3", BlockType: core.BlockTypeFile, File: &core.BlockFileRef{Token: attToken, Name: attName}},
},
},
})
})
// media download for embedded attachment tokens (File/Image blocks use /medias/ endpoint)
mux.HandleFunc("/open-apis/drive/v1/medias/", func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/download") {
w.Header().Set("Content-Type", "application/octet-stream")
w.Write(attContent)
return
}
http.NotFound(w, r)
})
ts := httptest.NewServer(mux)
return ts, &core.Config{AppID: "test-app-id", AppSecret: "test-app-secret", BaseURL: ts.URL}
}
// TestFetchDocxWithBlocks_MultiItem verifies that a docx node returns a main
// Markdown item plus an attachment sub-item when the blocks API succeeds.
func TestFetchDocxWithBlocks_MultiItem(t *testing.T) {
t.Setenv("FEISHU_DOCX_PARSE_MODE", "blocks")
const (
nodeToken = "nt-docx"
objToken = "obj-docx"
attToken = "ft-att-1"
attName = "report.pdf"
)
// Attachment content must exceed core.MinAttachmentBytes (2 KiB) to not be filtered.
attContent := bytes.Repeat([]byte("x"), core.MinAttachmentBytes+1)
nodes := []core.WikiNode{{
NodeToken: nodeToken,
ObjToken: objToken,
ObjType: "docx",
Title: "My Blocks Doc",
NodeEditTime: "1711468800",
}}
ts, cfg := fakeFeishuWithBlocks(nodes, objToken, attToken, attName, attContent)
defer ts.Close()
conn := NewConnector(core.RegionFeishu)
items, err := conn.FetchAll(context.Background(), makeConfig(cfg, []string{"space1"}), []string{"space1"})
if err != nil {
t.Fatalf("FetchAll() error: %v", err)
}
if len(items) != 2 {
t.Fatalf("want 2 items (main doc + attachment), got %d: %+v", len(items), items)
}
main := items[0]
if main.ExternalID != nodeToken {
t.Errorf("items[0].ExternalID = %q, want %q", main.ExternalID, nodeToken)
}
if main.ContentType != "text/markdown" {
t.Errorf("items[0].ContentType = %q, want text/markdown", main.ContentType)
}
if !main.ReplacesSubtree {
t.Errorf("items[0].ReplacesSubtree = false, want true")
}
if !strings.Contains(string(main.Content), "Hello blocks") {
t.Errorf("items[0].Content missing expected text; got %q", string(main.Content))
}
att := items[1]
wantAttID := nodeToken + "#file#" + attToken
if att.ExternalID != wantAttID {
t.Errorf("items[1].ExternalID = %q, want %q", att.ExternalID, wantAttID)
}
if att.Metadata["attachment"] != "true" {
t.Errorf("items[1].Metadata[attachment] = %q, want true", att.Metadata["attachment"])
}
if att.Title != attName {
t.Errorf("items[1].Title = %q, want %q", att.Title, attName)
}
}
// fakeFeishuWithBlocksAndDownloadStatus is like fakeFeishuWithBlocks but the
// drive download endpoint returns the given HTTP status code instead of 200. Use
// downloadStatus = http.StatusInternalServerError to exercise the
// attachment-download-failure path.
func fakeFeishuWithBlocksAndDownloadStatus(nodes []core.WikiNode, docToken, attToken, attName string, downloadStatus int) (*httptest.Server, *core.Config) {
mux := http.NewServeMux()
mux.HandleFunc("/open-apis/auth/v3/tenant_access_token/internal", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, core.TokenResponse{
ApiResponse: core.ApiResponse{Code: 0},
TenantAccessToken: "fake-token",
Expire: 7200,
})
})
mux.HandleFunc("/open-apis/wiki/v2/spaces", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, core.WikiSpaceListResponse{
ApiResponse: core.ApiResponse{Code: 0},
Data: core.WikiSpaceListData{Items: []core.WikiSpace{{SpaceID: "space1", Name: "Test Space"}}},
})
})
mux.HandleFunc("/open-apis/wiki/v2/spaces/space1/nodes", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, core.WikiNodeListResponse{
ApiResponse: core.ApiResponse{Code: 0},
Data: core.WikiNodeListData{Items: nodes},
})
})
// blocks API — one text block + one parseable .pdf file block
blocksPath := "/open-apis/docx/v1/documents/" + docToken + "/blocks"
mux.HandleFunc(blocksPath, func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, core.DocxBlocksResponse{
ApiResponse: core.ApiResponse{Code: 0},
Data: core.DocxBlocksData{
Items: []core.DocxBlock{
{BlockID: "b1", BlockType: core.BlockTypePage},
{BlockID: "b2", BlockType: core.BlockTypeText, Text: &core.BlockText{
Elements: []core.TextElement{{TextRun: &core.TextRun{Content: "Hello"}}},
}},
{BlockID: "b3", BlockType: core.BlockTypeFile, File: &core.BlockFileRef{Token: attToken, Name: attName}},
},
},
})
})
// media download endpoint returns the requested status (e.g. 500)
mux.HandleFunc("/open-apis/drive/v1/medias/", func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/download") {
w.WriteHeader(downloadStatus)
_, _ = w.Write([]byte(`{"code":99,"msg":"server error"}`))
return
}
http.NotFound(w, r)
})
ts := httptest.NewServer(mux)
return ts, &core.Config{AppID: "test-app-id", AppSecret: "test-app-secret", BaseURL: ts.URL}
}
// TestFetchDocxWithBlocks_AttachmentDownloadFailure verifies that when the blocks
// API succeeds but a single attachment download fails, the document body is still
// ingested (no error, so the node is not pinned into permanent retry), the failure
// is surfaced as a visible per-item error item (not just a server log), and the
// subtree sweep is suppressed so a transient failure never deletes the good prior
// copy of the attachment. One bad attachment must not block the whole document.
func TestFetchDocxWithBlocks_AttachmentDownloadFailure(t *testing.T) {
t.Setenv("FEISHU_DOCX_PARSE_MODE", "blocks")
const (
nodeToken = "nt-docx-fail"
objToken = "obj-docx-fail"
attToken = "ft-att-bad"
attName = "slides.pdf"
)
nodes := []core.WikiNode{{
NodeToken: nodeToken,
ObjToken: objToken,
ObjType: "docx",
Title: "Doc With Bad Attachment",
NodeEditTime: "1711468800",
}}
ts, cfg := fakeFeishuWithBlocksAndDownloadStatus(nodes, objToken, attToken, attName, http.StatusInternalServerError)
defer ts.Close()
conn := NewConnector(core.RegionFeishu)
ctx := context.Background()
client := core.NewClient(cfg)
baseMeta := map[string]string{
"obj_token": objToken,
"obj_type": "docx",
"node_token": nodeToken,
"space_id": "space1",
"channel": types.ChannelFeishu,
}
items, err := core.FetchDocxWithBlocks(ctx, client, core.DocxFetchInput{
DocToken: nodeToken,
ObjToken: objToken,
Title: "Doc With Bad Attachment",
URL: conn.region.WikiURL(nodeToken),
ResourceID: "space1:nt-docx-fail",
EditTime: core.ParseFeishuTimestamp("1711468800"),
BaseMeta: baseMeta,
MultimodalEnabled: true,
})
if err != nil {
t.Fatalf("a failed attachment must not fail the whole node, got error: %v", err)
}
// Main Markdown item + a visible error sub-item for the failed attachment.
if len(items) != 2 {
t.Fatalf("want 2 items (main doc + attachment error item), got %d: %+v", len(items), items)
}
var main, errItem *types.FetchedItem
for _, it := range items {
if it.ExternalID == nodeToken {
main = it
} else {
errItem = it
}
}
if main == nil {
t.Fatal("main doc item missing")
}
if main.ContentType != "text/markdown" {
t.Errorf("main ContentType = %q, want text/markdown", main.ContentType)
}
// The subtree is still reconciled (ReplacesSubtree stays true), but the failed
// attachment must be listed in SubtreeKeep so the sweep preserves its good
// previously-synced copy instead of deleting it with nothing to replace it.
if !main.ReplacesSubtree {
t.Error("main.ReplacesSubtree must stay true; the failed child is preserved via SubtreeKeep, not by suppressing the whole sweep")
}
failedChildID := nodeToken + "#file#" + attToken
if !slices.Contains(main.SubtreeKeep, failedChildID) {
t.Errorf("SubtreeKeep must contain the failed attachment %q so its prior copy is preserved, got %+v",
failedChildID, main.SubtreeKeep)
}
if errItem == nil {
t.Fatal("attachment error item missing")
}
if len(errItem.Content) != 0 {
t.Errorf("attachment error item must carry no content, got %d bytes", len(errItem.Content))
}
if errItem.Metadata["error"] == "" {
t.Error("attachment error item must carry error metadata for UI visibility")
}
}
// TestFetchDocxWithBlocks_EmbeddedImage verifies that embedded images are emitted
// as standalone sub-items (whose bytes flow into the VLM OCR pipeline) only when
// the KB has multimodal enabled, but their external_id is ALWAYS kept in
// SubtreeKeep so toggling VLM off later does not sweep previously OCR'd images.
func TestFetchDocxWithBlocks_EmbeddedImage(t *testing.T) {
t.Setenv("FEISHU_DOCX_PARSE_MODE", "blocks")
const (
nodeToken = "nt-docx-img"
objToken = "obj-docx-img"
imgToken = "media-img-1"
)
// A valid PNG signature + padding so http.DetectContentType returns image/png
// and the bytes exceed core.MinAttachmentBytes.
pngBytes := append([]byte("\x89PNG\r\n\x1a\n"), bytes.Repeat([]byte("x"), core.MinAttachmentBytes)...)
mux := http.NewServeMux()
mux.HandleFunc("/open-apis/auth/v3/tenant_access_token/internal", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, core.TokenResponse{ApiResponse: core.ApiResponse{Code: 0}, TenantAccessToken: "fake-token", Expire: 7200})
})
blocksPath := "/open-apis/docx/v1/documents/" + objToken + "/blocks"
mux.HandleFunc(blocksPath, func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, core.DocxBlocksResponse{
ApiResponse: core.ApiResponse{Code: 0},
Data: core.DocxBlocksData{
Items: []core.DocxBlock{
{BlockID: "b1", BlockType: core.BlockTypePage},
{BlockID: "b2", BlockType: core.BlockTypeText, Text: &core.BlockText{
Elements: []core.TextElement{{TextRun: &core.TextRun{Content: "Hello"}}},
}},
{BlockID: "b3", BlockType: core.BlockTypeImage, Image: &core.BlockTokenRef{Token: imgToken}},
},
},
})
})
mux.HandleFunc("/open-apis/drive/v1/medias/", func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/download") {
_, _ = w.Write(pngBytes)
return
}
http.NotFound(w, r)
})
ts := httptest.NewServer(mux)
defer ts.Close()
cfg := &core.Config{AppID: "test-app-id", AppSecret: "test-app-secret", BaseURL: ts.URL}
conn := NewConnector(core.RegionFeishu)
ctx := context.Background()
client := core.NewClient(cfg)
node := core.WikiNode{NodeToken: nodeToken, ObjToken: objToken, ObjType: "docx", Title: "Doc With Image", NodeEditTime: "1711468800"}
baseMeta := map[string]string{"node_token": nodeToken, "channel": types.ChannelFeishu}
imgChildID := nodeToken + "#image#" + imgToken
// multimodal ON → image emitted as a sub-item and kept.
items, err := core.FetchDocxWithBlocks(ctx, client, core.DocxFetchInput{
DocToken: node.NodeToken,
ObjToken: node.ObjToken,
Title: node.Title,
URL: conn.region.WikiURL(node.NodeToken),
ResourceID: "space1:" + nodeToken,
EditTime: core.ParseFeishuTimestamp("1711468800"),
BaseMeta: baseMeta,
MultimodalEnabled: true,
})
if err != nil {
t.Fatalf("core.FetchDocxWithBlocks (multimodal on): %v", err)
}
var main, img *types.FetchedItem
for _, it := range items {
switch it.ExternalID {
case nodeToken:
main = it
case imgChildID:
img = it
}
}
if main == nil {
t.Fatal("main doc item missing")
}
if img == nil {
t.Fatalf("embedded image sub-item missing; got %+v", items)
}
if !strings.HasSuffix(img.FileName, ".png") {
t.Errorf("image FileName = %q, want a .png extension", img.FileName)
}
if img.Metadata["embedded_image"] != "true" {
t.Errorf("image Metadata[embedded_image] = %q, want true", img.Metadata["embedded_image"])
}
if img.Metadata["attachment"] == "true" {
t.Error("an embedded image must not be marked as a file attachment")
}
if len(img.Content) != len(pngBytes) {
t.Errorf("image Content = %d bytes, want %d", len(img.Content), len(pngBytes))
}
if !slices.Contains(main.SubtreeKeep, imgChildID) {
t.Errorf("SubtreeKeep must contain image %q, got %+v", imgChildID, main.SubtreeKeep)
}
// multimodal OFF → no image sub-item, but the id is still kept (not swept).
itemsOff, err := core.FetchDocxWithBlocks(ctx, client, core.DocxFetchInput{
DocToken: node.NodeToken,
ObjToken: node.ObjToken,
Title: node.Title,
URL: conn.region.WikiURL(node.NodeToken),
ResourceID: "space1:" + nodeToken,
EditTime: core.ParseFeishuTimestamp("1711468800"),
BaseMeta: baseMeta,
MultimodalEnabled: false,
})
if err != nil {
t.Fatalf("core.FetchDocxWithBlocks (multimodal off): %v", err)
}
var mainOff *types.FetchedItem
for _, it := range itemsOff {
if it.ExternalID == imgChildID {
t.Errorf("multimodal off must NOT Emit an image sub-item, got %+v", it)
}
if it.ExternalID == nodeToken {
mainOff = it
}
}
if mainOff == nil {
t.Fatal("main doc item missing (multimodal off)")
}
if !slices.Contains(mainOff.SubtreeKeep, imgChildID) {
t.Errorf("SubtreeKeep must contain image %q even when multimodal off (so it isn't swept), got %+v",
imgChildID, mainOff.SubtreeKeep)
}
}
// TestFetchDocxWithBlocks_ImageDownloadFailure verifies that a failed image
// download (a genuine core.Fetch failure — revoked token, permission gap, transient
// error) surfaces a visible error sub-item exactly like a failed attachment
// download, rather than being silently dropped to a server log. The image is
// still kept in SubtreeKeep so any prior OCR'd copy is preserved.
func TestFetchDocxWithBlocks_ImageDownloadFailure(t *testing.T) {
t.Setenv("FEISHU_DOCX_PARSE_MODE", "blocks")
const (
nodeToken = "nt-docx-img-fail"
objToken = "obj-docx-img-fail"
imgToken = "media-img-bad"
)
mux := http.NewServeMux()
mux.HandleFunc("/open-apis/auth/v3/tenant_access_token/internal", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, core.TokenResponse{ApiResponse: core.ApiResponse{Code: 0}, TenantAccessToken: "fake-token", Expire: 7200})
})
blocksPath := "/open-apis/docx/v1/documents/" + objToken + "/blocks"
mux.HandleFunc(blocksPath, func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, core.DocxBlocksResponse{
ApiResponse: core.ApiResponse{Code: 0},
Data: core.DocxBlocksData{
Items: []core.DocxBlock{
{BlockID: "b1", BlockType: core.BlockTypePage},
{BlockID: "b2", BlockType: core.BlockTypeImage, Image: &core.BlockTokenRef{Token: imgToken}},
},
},
})
})
// Media download always fails.
mux.HandleFunc("/open-apis/drive/v1/medias/", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
})
ts := httptest.NewServer(mux)
defer ts.Close()
cfg := &core.Config{AppID: "test-app-id", AppSecret: "test-app-secret", BaseURL: ts.URL}
conn := NewConnector(core.RegionFeishu)
ctx := context.Background()
client := core.NewClient(cfg)
node := core.WikiNode{NodeToken: nodeToken, ObjToken: objToken, ObjType: "docx", Title: "Doc With Bad Image", NodeEditTime: "1711468800"}
baseMeta := map[string]string{"node_token": nodeToken, "channel": types.ChannelFeishu}
imgChildID := nodeToken + "#image#" + imgToken
// multimodal ON → the download is attempted and fails → a visible error item.
items, err := core.FetchDocxWithBlocks(ctx, client, core.DocxFetchInput{
DocToken: node.NodeToken,
ObjToken: node.ObjToken,
Title: node.Title,
URL: conn.region.WikiURL(node.NodeToken),
ResourceID: "space1:" + nodeToken,
EditTime: core.ParseFeishuTimestamp("1711468800"),
BaseMeta: baseMeta,
MultimodalEnabled: true,
})
if err != nil {
t.Fatalf("a failed image download must not fail the whole node, got error: %v", err)
}
var main, errItem *types.FetchedItem
for _, it := range items {
switch it.ExternalID {
case nodeToken:
main = it
case imgChildID:
errItem = it
}
}
if main == nil {
t.Fatal("main doc item missing")
}
if errItem == nil {
t.Fatalf("failed image download must Emit a visible error sub-item, got %+v", items)
}
if len(errItem.Content) != 0 {
t.Errorf("image error item must carry no content, got %d bytes", len(errItem.Content))
}
if errItem.Metadata["error"] == "" {
t.Error("image error item must carry error metadata for UI visibility")
}
if errItem.Metadata["embedded_image"] != "true" {
t.Errorf("image error item must stay tagged embedded_image=true, got %q", errItem.Metadata["embedded_image"])
}
if !slices.Contains(main.SubtreeKeep, imgChildID) {
t.Errorf("SubtreeKeep must contain the failed image %q so its prior copy is preserved, got %+v",
imgChildID, main.SubtreeKeep)
}
// multimodal OFF → the download is never attempted, so no error item, but the
// id is still kept (not swept).
itemsOff, err := core.FetchDocxWithBlocks(ctx, client, core.DocxFetchInput{
DocToken: node.NodeToken,
ObjToken: node.ObjToken,
Title: node.Title,
URL: conn.region.WikiURL(node.NodeToken),
ResourceID: "space1:" + nodeToken,
EditTime: core.ParseFeishuTimestamp("1711468800"),
BaseMeta: baseMeta,
MultimodalEnabled: false,
})
if err != nil {
t.Fatalf("core.FetchDocxWithBlocks (multimodal off): %v", err)
}
for _, it := range itemsOff {
if it.ExternalID == imgChildID {
t.Errorf("multimodal off must NOT attempt the image download or Emit an item, got %+v", it)
}
}
}
// TestFetchDocxWithBlocks_NonWhitelistedExtNotPromoted verifies that a file block
// whose extension is not in the parseable-attachment whitelist (e.g. .png) is NOT
// promoted to a sub-item, but its inline reference IS present in the main document.
func TestFetchDocxWithBlocks_NonWhitelistedExtNotPromoted(t *testing.T) {
t.Setenv("FEISHU_DOCX_PARSE_MODE", "blocks")
const (
nodeToken = "nt-docx-png"
objToken = "obj-docx-png"
attToken = "ft-icon"
attName = "icon.png"
)
// Content size well above core.MinAttachmentBytes — the filter must be extension, not size.
attContent := bytes.Repeat([]byte("x"), core.MinAttachmentBytes+100)
nodes := []core.WikiNode{{
NodeToken: nodeToken,
ObjToken: objToken,
ObjType: "docx",
Title: "Doc With PNG",
NodeEditTime: "1711468800",
}}
ts, cfg := fakeFeishuWithBlocks(nodes, objToken, attToken, attName, attContent)
defer ts.Close()
conn := NewConnector(core.RegionFeishu)
items, err := conn.FetchAll(context.Background(), makeConfig(cfg, []string{"space1"}), []string{"space1"})
if err != nil {
t.Fatalf("FetchAll() error: %v", err)
}
// Only the main doc — no sub-item for a non-parseable extension.
if len(items) != 1 {
t.Fatalf("want 1 item (main doc only, .png not promoted), got %d: %+v", len(items), items)
}
mainContent := string(items[0].Content)
if !strings.Contains(mainContent, "📎 附件:icon.png") {
t.Errorf("main doc missing inline reference for icon.png; got:\n%s", mainContent)
}
}
// TestFetchDocxWithBlocks_WhitelistedTinyAttachmentNotPromoted verifies that a
// whitelisted-extension file block whose download is smaller than core.MinAttachmentBytes
// is NOT promoted to a sub-item, but its inline reference IS present in the main doc.
func TestFetchDocxWithBlocks_WhitelistedTinyAttachmentNotPromoted(t *testing.T) {
t.Setenv("FEISHU_DOCX_PARSE_MODE", "blocks")
const (
nodeToken = "nt-docx-tiny"
objToken = "obj-docx-tiny"
attToken = "ft-tiny-pdf"
attName = "tiny.pdf"
)
// Content is well below core.MinAttachmentBytes (100 bytes vs 2048 threshold).
attContent := bytes.Repeat([]byte("x"), 100)
nodes := []core.WikiNode{{
NodeToken: nodeToken,
ObjToken: objToken,
ObjType: "docx",
Title: "Doc With Tiny PDF",
NodeEditTime: "1711468800",
}}
ts, cfg := fakeFeishuWithBlocks(nodes, objToken, attToken, attName, attContent)
defer ts.Close()
conn := NewConnector(core.RegionFeishu)
items, err := conn.FetchAll(context.Background(), makeConfig(cfg, []string{"space1"}), []string{"space1"})
if err != nil {
t.Fatalf("FetchAll() error: %v", err)
}
// Only the main doc — no sub-item for a file below the size threshold.
if len(items) != 1 {
t.Fatalf("want 1 item (main doc only, tiny .pdf not promoted), got %d: %+v", len(items), items)
}
mainContent := string(items[0].Content)
if !strings.Contains(mainContent, "📎 附件:tiny.pdf") {
t.Errorf("main doc missing inline reference for tiny.pdf; got:\n%s", mainContent)
}
}
// ──────────────────────────────────────────────────────────────────────
// Feishu cursor serialization
// ──────────────────────────────────────────────────────────────────────
func TestFeishuCursorRoundTrip(t *testing.T) {
original := core.FeishuCursor{
LastSyncTime: time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC),
SpaceNodeTimes: map[string]map[string]string{
"space1": {
"nt1": "100",
"nt2": "200",
},
},
}
// Serialize to map[string]interface{} (as stored in SyncCursor.ConnectorCursor)
data, err := json.Marshal(original)
if err != nil {
t.Fatalf("marshal error: %v", err)
}
var cursorMap map[string]interface{}
if err := json.Unmarshal(data, &cursorMap); err != nil {
t.Fatalf("unmarshal to map error: %v", err)
}
// Deserialize back
data2, _ := json.Marshal(cursorMap)
var restored core.FeishuCursor
if err := json.Unmarshal(data2, &restored); err != nil {
t.Fatalf("restore error: %v", err)
}
if restored.SpaceNodeTimes["space1"]["nt1"] != "100" {
t.Errorf("restored nt1 = %q, want 100", restored.SpaceNodeTimes["space1"]["nt1"])
}
if restored.SpaceNodeTimes["space1"]["nt2"] != "200" {
t.Errorf("restored nt2 = %q, want 200", restored.SpaceNodeTimes["space1"]["nt2"])
}
}
// TestSupportedImageExt covers every arm of the sniff table: the png/jpg/gif
// formats WeKnora accepts (aligned with isValidFileType's image set), and the
// unsupported case which must return ok=false while still surfacing the detected
// content type so the caller can log it without re-sniffing.
func TestSupportedImageExt(t *testing.T) {
cases := []struct {
name string
data []byte
wantExt string
wantCT string
wantOK bool
}{
{"png", []byte("\x89PNG\r\n\x1a\nrest"), ".png", "image/png", true},
{"jpeg", []byte("\xFF\xD8\xFF\xE0\x00\x10JFIF"), ".jpg", "image/jpeg", true},
{"gif", []byte("GIF89a\x01\x00\x01\x00"), ".gif", "image/gif", true},
{"webp_unsupported", append([]byte("RIFF\x00\x00\x00\x00WEBPVP8 "), make([]byte, 8)...), "", "image/webp", false},
{"text_unsupported", []byte("just some plain text, not an image at all"), "", "text/plain; charset=utf-8", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ext, ct, ok := core.SupportedImageExt(tc.data)
if ok != tc.wantOK {
t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
}
if ext != tc.wantExt {
t.Errorf("ext = %q, want %q", ext, tc.wantExt)
}
if ct != tc.wantCT {
t.Errorf("contentType = %q, want %q (must be returned even when !ok)", ct, tc.wantCT)
}
})
}
}