WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
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

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
@@ -0,0 +1,467 @@
package wiki
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/datasource"
"github.com/Tencent/WeKnora/internal/datasource/connector/feishu/core"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
)
// Connector implements the datasource.Connector interface for Feishu and, with
// the same code, for Lark: the two clouds expose an identical wiki/docx/drive
// API surface. A core.Region picks the cloud — see region.go.
type Connector struct {
region core.Region
}
// NewConnector creates a connector for the given region (RegionFeishu or RegionLark).
func NewConnector(region core.Region) *Connector {
return &Connector{region: region}
}
// Feishu supports resumable streaming sync; the service prefers FetchStream over
// FetchAll/FetchIncremental when a connector implements StreamingConnector.
var _ datasource.StreamingConnector = (*Connector)(nil)
// Type returns the connector type identifier.
func (c *Connector) Type() string {
return c.region.ConnectorType
}
// Validate verifies that the Feishu configuration is valid by testing connectivity.
func (c *Connector) Validate(ctx context.Context, config *types.DataSourceConfig) error {
feishuConfig, err := core.ParseFeishuConfig(config, c.region)
if err != nil {
return err
}
client := core.NewClient(feishuConfig)
if err := client.Ping(ctx); err != nil {
return fmt.Errorf("feishu connection failed: %w", err)
}
return nil
}
// ListResources lists Feishu Wiki resources for selection, loading the tree
// lazily one level at a time to avoid traversing the entire wiki up front.
//
// - parentID == "" → list all accessible wiki spaces.
// - parentID == spaceID → list the top-level nodes of that space.
// - parentID == "spaceID:nodeToken" → list the direct children of that node.
//
// Eagerly recursing the whole tree here used to time out for large wikis
// (Tencent/WeKnora#1672); the recursive walk now happens only at sync time.
func (c *Connector) ListResources(
ctx context.Context, config *types.DataSourceConfig, parentID string,
) ([]types.Resource, error) {
feishuConfig, err := core.ParseFeishuConfig(config, c.region)
if err != nil {
return nil, err
}
client := core.NewClient(feishuConfig)
if parentID == "" {
spaces, err := client.ListWikiSpaces(ctx)
if err != nil {
return nil, fmt.Errorf("list feishu wiki spaces: %w", err)
}
resources := make([]types.Resource, 0, len(spaces))
for _, space := range spaces {
resources = append(resources, types.Resource{
ExternalID: space.SpaceID,
Name: space.Name,
Type: "wiki_space",
Description: space.Description,
URL: c.region.WikiURL(space.SpaceID),
HasChildren: true,
Metadata: map[string]interface{}{
"visibility": space.Visibility,
"space_id": space.SpaceID,
},
})
}
return resources, nil
}
// Lazy load: list only the direct children of the given space / node.
spaceID, nodeToken := parseWikiResourceID(parentID)
nodes, err := client.ListWikiNodes(ctx, spaceID, nodeToken)
if err != nil {
return nil, fmt.Errorf("list feishu wiki nodes under %s: %w", parentID, err)
}
resources := make([]types.Resource, 0, len(nodes))
for _, node := range nodes {
resources = append(resources, c.wikiNodeToResource(spaceID, node))
}
return resources, nil
}
// ResolveResourceAncestors returns the resource IDs of every parent that has to
// be expanded so the lazily-loaded picker can reveal each given selection. For a
// selected node "spaceID:nodeToken" that is its space plus every intermediate
// node up the tree; the walk uses GetWikiNode (parent_node_token) and is O(depth)
// per selection, so it never re-traverses the whole wiki.
func (c *Connector) ResolveResourceAncestors(
ctx context.Context, config *types.DataSourceConfig, resourceIDs []string,
) ([]string, error) {
feishuConfig, err := core.ParseFeishuConfig(config, c.region)
if err != nil {
return nil, err
}
client := core.NewClient(feishuConfig)
seen := make(map[string]bool)
ancestors := make([]string, 0)
add := func(id string) {
if id != "" && !seen[id] {
seen[id] = true
ancestors = append(ancestors, id)
}
}
for _, rid := range resourceIDs {
spaceID, nodeToken := parseWikiResourceID(rid)
if spaceID == "" || nodeToken == "" {
// A space-level selection is already a top-level node in the picker;
// there is nothing above it to reveal.
continue
}
// The space's direct children must be loaded to reveal the top-level node.
add(spaceID)
// Walk up from the selection to the top, loading each intermediate
// parent so the path down to the selection becomes visible.
current := nodeToken
for current != "" {
node, err := client.GetWikiNode(ctx, spaceID, current)
if err != nil {
// Best-effort: a broken path just stays collapsed, the rest of
// the selections are still revealed.
logger.Warnf(ctx, "[Feishu] resolve ancestors: get node %s:%s: %v", spaceID, current, err)
break
}
if node.ParentNodeID == "" {
break
}
add(makeWikiNodeResourceID(spaceID, node.ParentNodeID))
current = node.ParentNodeID
}
}
return ancestors, nil
}
// FetchAll performs a full sync of all documents from the specified wiki spaces.
// Defensive fallback path - the service prefers FetchStream when the connector
// implements StreamingConnector.
func (c *Connector) FetchAll(ctx context.Context, config *types.DataSourceConfig, resourceIDs []string) ([]types.FetchedItem, error) {
feishuConfig, err := core.ParseFeishuConfig(config, c.region)
if err != nil {
return nil, err
}
client := core.NewClient(feishuConfig)
return core.FetchAllEngine(ctx, client, config, resourceIDs, wikiOps{region: c.region})
}
// FetchIncremental performs an incremental sync by comparing node edit times
// against the previously recorded state. Defensive fallback path - the service
// prefers FetchStream. Routed through the same engine, so the #2136
// failure-doesn't-advance-cursor semantics apply here too (previously this path
// advanced the cursor before fetching, a latent #2136 bug).
func (c *Connector) FetchIncremental(ctx context.Context, config *types.DataSourceConfig, cursor *types.SyncCursor) ([]types.FetchedItem, *types.SyncCursor, error) {
feishuConfig, err := core.ParseFeishuConfig(config, c.region)
if err != nil {
return nil, nil, err
}
client := core.NewClient(feishuConfig)
ops := wikiOps{region: c.region}
if len(config.ResourceIDs) == 0 {
return nil, nil, errors.New(ops.EmptyResourceIDsError())
}
return core.FetchIncrementalEngine(ctx, client, config, cursor, ops)
}
// FetchStream performs a resumable, memory-bounded sync. It unifies the full
// and incremental paths: with cursor == nil it fetches everything, and with a
// cursor it skips nodes whose recorded edit time is unchanged - the same
// mechanism that lets a sync which timed out mid-traversal resume from the last
// checkpoint instead of restarting (Tencent/WeKnora#2136).
//
// The per-node loop lives in the shared engine (engine.go); this shell only
// wires the wiki NodeOps adapter.
func (c *Connector) FetchStream(
ctx context.Context, config *types.DataSourceConfig,
cursor *types.SyncCursor, h datasource.StreamHandler,
) (*types.SyncCursor, error) {
feishuConfig, err := core.ParseFeishuConfig(config, c.region)
if err != nil {
return nil, err
}
client := core.NewClient(feishuConfig)
ops := wikiOps{region: c.region}
if len(config.ResourceIDs) == 0 {
return nil, errors.New(ops.EmptyResourceIDsError())
}
return core.FetchStreamEngine(ctx, client, config, cursor, h, ops)
}
// wikiOps adapts the wiki Connector to the generic sync engine. It carries the
// region (for URL rendering) and encodes/decodes the wiki cursor wire format
// (core.FeishuCursor / space_node_times) so the engine can stay format-agnostic.
type wikiOps struct {
region core.Region
}
func (o wikiOps) List(ctx context.Context, client *core.Client, resourceID string) ([]core.WikiNode, error, error) {
spaceID, nodeToken := parseWikiResourceID(resourceID)
nodes, err := client.ListWikiNodesRecursiveFrom(ctx, spaceID, nodeToken)
if err == nil {
return nodes, nil, nil
}
var partial *core.PartialWikiNodeListError
if errors.As(err, &partial) {
// Partial listing: nodes are still usable; the failed sub-trees are
// surfaced via ListFailureItems, and the sync continues.
return nodes, err, nil
}
return nodes, nil, err
}
func (o wikiOps) Token(n core.WikiNode) string { return n.NodeToken }
func (o wikiOps) Title(n core.WikiNode) string { return n.Title }
func (o wikiOps) ObjType(n core.WikiNode) string { return n.ObjType }
// EditTime is the change-detection timestamp: ObjEditTime (document content)
// with a NodeEditTime fallback for nodes that lack obj_edit_time. This drives
// the cursor comparison, NOT FetchedItem.UpdatedAt (which uses NodeEditTime).
func (o wikiOps) EditTime(n core.WikiNode) string {
if n.ObjEditTime != "" {
return n.ObjEditTime
}
return n.NodeEditTime
}
func (o wikiOps) Fetch(ctx context.Context, client *core.Client, n core.WikiNode, resourceID string, multimodal bool) ([]*types.FetchedItem, error) {
spaceID, _ := parseWikiResourceID(resourceID)
return fetchNodeContent(ctx, client, n, spaceID, resourceID, multimodal, o.region)
}
func (o wikiOps) ListFailureItems(resourceID string, partial error) []types.FetchedItem {
spaceID, _ := parseWikiResourceID(resourceID)
var pe *core.PartialWikiNodeListError
if errors.As(partial, &pe) {
return appendWikiNodeListFailureItems(nil, spaceID, resourceID, pe.Failures)
}
return nil
}
func (o wikiOps) ResourceNoun() string { return "nodes" }
func (o wikiOps) EmptyResourceIDsError() string {
return "no resource IDs (wiki space IDs or wiki node IDs) configured"
}
func (o wikiOps) LogTag() string { return "[Feishu]" }
func (o wikiOps) DecodeCursorTimes(m map[string]interface{}) map[string]map[string]string {
var prev core.FeishuCursor
b, _ := json.Marshal(m)
_ = json.Unmarshal(b, &prev)
return prev.SpaceNodeTimes
}
func (o wikiOps) EncodeCursor(times map[string]map[string]string, lastSync time.Time) *types.SyncCursor {
fc := core.FeishuCursor{LastSyncTime: lastSync, SpaceNodeTimes: times}
m := make(map[string]interface{})
b, _ := json.Marshal(fc)
_ = json.Unmarshal(b, &m)
return &types.SyncCursor{LastSyncTime: lastSync, ConnectorCursor: m}
}
func appendWikiNodeListFailureItems(items []types.FetchedItem, spaceID string, resourceID string, failures []core.WikiNodeListFailure) []types.FetchedItem {
for _, failure := range failures {
node := failure.Node
title := node.Title
if title == "" {
title = node.NodeToken
}
items = append(items, types.FetchedItem{
ExternalID: node.NodeToken,
Title: title,
SourceResourceID: resourceID,
Metadata: core.FeishuErrorItemMeta(failure.Err, map[string]string{
"channel": types.ChannelFeishu,
"node_token": node.NodeToken,
"space_id": spaceID,
"failure_stage": "list_children",
}),
})
}
return items
}
// fetchNodeContent fetches the content of a single wiki node and converts it to a
// slice of FetchedItems. For docx nodes it fans out into a main Markdown document
// plus optional attachment sub-items. Dispatches to different retrieval strategies
// based on obj_type:
// - docx → blocks API (Markdown) with export fallback; may return attachments
// - doc/sheet/bitable → export API → binary file
// - file → drive download → original file (PDF/Word/image/etc.)
// - mindnote → Skip (no API)
// - slides → Skip (no API)
func fetchNodeContent(ctx context.Context, client *core.Client, node core.WikiNode, spaceID string, resourceID string, multimodalEnabled bool, region core.Region) ([]*types.FetchedItem, error) {
if !core.IsSupportedDocType(node.ObjType) {
return nil, nil
}
editTime := core.ParseFeishuTimestamp(node.NodeEditTime)
baseMeta := map[string]string{
"obj_token": node.ObjToken,
"obj_type": node.ObjType,
"node_token": node.NodeToken,
"space_id": spaceID,
"creator": node.Creator,
"owner": node.Owner,
"channel": types.ChannelFeishu,
}
switch node.ObjType {
case "docx":
return core.FetchDocxWithBlocks(ctx, client, core.DocxFetchInput{
DocToken: node.NodeToken,
ObjToken: node.ObjToken,
Title: node.Title,
URL: region.WikiURL(node.NodeToken),
ResourceID: resourceID,
EditTime: editTime,
BaseMeta: baseMeta,
MultimodalEnabled: multimodalEnabled,
})
case "doc", "sheet", "bitable":
item, err := fetchViaExport(ctx, client, node, resourceID, editTime, baseMeta, region)
if err != nil {
return nil, err
}
return []*types.FetchedItem{item}, nil
case "file":
item, err := fetchDriveFile(ctx, client, node, resourceID, editTime, baseMeta, region)
if err != nil {
return nil, err
}
return []*types.FetchedItem{item}, nil
default:
return nil, nil
}
}
// fetchViaExport exports a doc/sheet/bitable node via the async export API and
// returns a single FetchedItem containing the exported binary.
func fetchViaExport(ctx context.Context, client *core.Client, node core.WikiNode, resourceID string, editTime time.Time, baseMeta map[string]string, region core.Region) (*types.FetchedItem, error) {
// Export as a file via the async export API
data, fileName, err := client.ExportAndDownload(ctx, node.ObjToken, node.ObjType)
if err != nil {
return nil, fmt.Errorf("export %s (%s): %w", node.Title, node.ObjType, err)
}
// Ensure a reasonable file name with correct extension
ext := core.ExportFileExtToSuffix[core.ObjTypeToExportFileExtension[node.ObjType]]
if fileName == "" {
fileName = core.SanitizeFileName(node.Title) + ext
} else if !strings.HasSuffix(strings.ToLower(fileName), ext) {
// Feishu often returns the doc title without extension - append it
fileName = core.SanitizeFileName(fileName) + ext
}
return &types.FetchedItem{
ExternalID: node.NodeToken,
Title: node.Title,
Content: data,
ContentType: "application/octet-stream",
FileName: fileName,
URL: region.WikiURL(node.NodeToken),
UpdatedAt: editTime,
SourceResourceID: resourceID,
Metadata: baseMeta,
}, nil
}
// fetchDriveFile downloads an original uploaded file from Drive and returns a
// single FetchedItem containing the raw bytes.
func fetchDriveFile(ctx context.Context, client *core.Client, node core.WikiNode, resourceID string, editTime time.Time, baseMeta map[string]string, region core.Region) (*types.FetchedItem, error) {
// Download the original uploaded file from Drive
data, err := client.DownloadDriveFile(ctx, node.ObjToken)
if err != nil {
return nil, fmt.Errorf("download file %s (%s): %w", node.Title, node.ObjToken, err)
}
// Use the node title as file name; it usually preserves the original extension
fileName := node.Title
if fileName == "" {
fileName = node.ObjToken
}
return &types.FetchedItem{
ExternalID: node.NodeToken,
Title: node.Title,
Content: data,
ContentType: "application/octet-stream",
FileName: fileName,
URL: region.WikiURL(node.NodeToken),
UpdatedAt: editTime,
SourceResourceID: resourceID,
Metadata: baseMeta,
}, nil
}
// --- Helper functions ---
func makeWikiNodeResourceID(spaceID, nodeToken string) string {
return spaceID + core.FeishuWikiNodeResourceSeparator + nodeToken
}
func parseWikiResourceID(resourceID string) (spaceID string, nodeToken string) {
spaceID, nodeToken, _ = strings.Cut(resourceID, core.FeishuWikiNodeResourceSeparator)
return spaceID, nodeToken
}
func (c *Connector) wikiNodeToResource(spaceID string, node core.WikiNode) types.Resource {
parentID := spaceID
if node.ParentNodeID != "" {
parentID = makeWikiNodeResourceID(spaceID, node.ParentNodeID)
}
name := node.Title
if name == "" {
name = node.NodeToken
}
modifiedAt := core.ParseFeishuTimestamp(node.ObjEditTime)
if modifiedAt.IsZero() {
modifiedAt = core.ParseFeishuTimestamp(node.NodeEditTime)
}
return types.Resource{
ExternalID: makeWikiNodeResourceID(spaceID, node.NodeToken),
Name: name,
Type: "wiki_node",
URL: c.region.WikiURL(node.NodeToken),
ParentID: parentID,
HasChildren: node.HasChild,
ModifiedAt: modifiedAt,
Metadata: map[string]interface{}{
"space_id": spaceID,
"node_token": node.NodeToken,
"obj_token": node.ObjToken,
"obj_type": node.ObjType,
},
}
}
@@ -0,0 +1,267 @@
package wiki
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/Tencent/WeKnora/internal/datasource/connector/feishu/core"
"github.com/Tencent/WeKnora/internal/types"
)
// statefulFeishu is a fake Feishu server whose per-node export outcome can be
// changed between sync passes. A node whose obj_token is in failTokens has its
// export status poll return a failed job, so fetchNodeContent errors for it —
// modelling a transient rate-limit / export failure that later recovers.
type statefulFeishu struct {
mu sync.Mutex
failTokens map[string]bool
}
func (s *statefulFeishu) setFail(tokens ...string) {
s.mu.Lock()
defer s.mu.Unlock()
s.failTokens = map[string]bool{}
for _, t := range tokens {
s.failTokens[t] = true
}
}
func (s *statefulFeishu) shouldFail(token string) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.failTokens[token]
}
func newStatefulFeishu(nodes []core.WikiNode) (*httptest.Server, *core.Config, *statefulFeishu) {
s := &statefulFeishu{failTokens: map[string]bool{}}
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},
})
})
// Export create: ticket == obj_token so the poll below can key on it.
mux.HandleFunc("/open-apis/drive/v1/export_tasks", func(w http.ResponseWriter, r *http.Request) {
var body struct {
Token string `json:"token"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
writeJSON(w, core.ExportTaskCreateResponse{
ApiResponse: core.ApiResponse{Code: 0},
Data: core.ExportTaskCreateData{Ticket: body.Token},
})
})
// Export status poll: /open-apis/drive/v1/export_tasks/<ticket>?token=<objToken>
mux.HandleFunc("/open-apis/drive/v1/export_tasks/", func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/file/") && strings.HasSuffix(r.URL.Path, "/download") {
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write([]byte("fake-docx-content"))
return
}
token := r.URL.Query().Get("token")
status := 0
if s.shouldFail(token) {
status = 3 // failed job
}
resp := core.ExportTaskStatusResponse{ApiResponse: core.ApiResponse{Code: 0}}
resp.Data.Result.FileToken = "file-" + token
resp.Data.Result.FileName = "exported.docx"
resp.Data.Result.JobStatus = status
resp.Data.Result.JobErrorMsg = "rate limited"
writeJSON(w, resp)
})
ts := httptest.NewServer(mux)
return ts, &core.Config{AppID: "test-app-id", AppSecret: "test-app-secret", BaseURL: ts.URL}, s
}
// convergenceHandler models the service side across a resumable sync:
// - Emit ingests content items (mirroring the service's ctx check + apply),
// records failure items separately, and can simulate a mid-tree task timeout
// by canceling the run's context after cancelAfterCalls Emit calls.
// - Checkpoint snapshots the connector cursor (the DB round-trip).
type convergenceHandler struct {
ingested []string // ExternalIDs of successfully ingested content items
failed []string // ExternalIDs surfaced as failures (Metadata["error"])
exportedTokens map[string]int
checkpoints []core.FeishuCursor
calls int
cancelAfterCall int // >0: cancel ctx and abort on the Nth Emit call
cancel context.CancelFunc
}
func (h *convergenceHandler) Emit(ctx context.Context, item types.FetchedItem) error {
if err := ctx.Err(); err != nil { // mirror service Emit
return err
}
h.calls++
if h.cancelAfterCall > 0 && h.calls >= h.cancelAfterCall {
if h.cancel != nil {
h.cancel()
}
return context.Canceled // service returns ctx err → connector aborts
}
if item.Metadata["error"] != "" {
h.failed = append(h.failed, item.ExternalID)
return nil
}
h.ingested = append(h.ingested, item.ExternalID)
return nil
}
func (h *convergenceHandler) Checkpoint(ctx context.Context, cursor *types.SyncCursor) error {
var fc core.FeishuCursor
b, _ := json.Marshal(cursor.ConnectorCursor)
_ = json.Unmarshal(b, &fc)
h.checkpoints = append(h.checkpoints, fc)
return nil
}
func lastCheckpointCursor(h *convergenceHandler, t *testing.T) *types.SyncCursor {
t.Helper()
if len(h.checkpoints) == 0 {
return nil
}
last := h.checkpoints[len(h.checkpoints)-1]
b, _ := json.Marshal(last)
var m map[string]interface{}
if err := json.Unmarshal(b, &m); err != nil {
t.Fatalf("rebuild persisted cursor: %v", err)
}
return &types.SyncCursor{ConnectorCursor: m}
}
// TestFetchStream_ResumeConvergesAfterTimeoutAndTransientFailure is the
// end-to-end proof for Tencent/WeKnora#2136: a large-ish wiki that (a) hits a
// transient per-node export failure and (b) is killed by the 2h task timeout
// mid-traversal must, on the asynq retry, resume from the last Checkpoint,
// re-core.Fetch only what is outstanding, retry the transiently-failed node, and end
// with EVERY document synced exactly once — no permanent core.Skip, no full restart,
// no redundant re-export of already-done nodes.
func TestFetchStream_ResumeConvergesAfterTimeoutAndTransientFailure(t *testing.T) {
// Checkpoint on every processed node so the persisted cursor is precise.
prevN := core.FeishuStreamCheckpointInterval
core.FeishuStreamCheckpointInterval = 1
defer func() { core.FeishuStreamCheckpointInterval = prevN }()
nodes := []core.WikiNode{
{NodeToken: "nt1", ObjToken: "obj1", ObjType: "docx", Title: "Doc1", ObjEditTime: "100"},
{NodeToken: "nt2", ObjToken: "obj2", ObjType: "docx", Title: "Doc2", ObjEditTime: "200"},
{NodeToken: "nt3", ObjToken: "obj3", ObjType: "docx", Title: "Doc3", ObjEditTime: "300"},
{NodeToken: "nt4", ObjToken: "obj4", ObjType: "docx", Title: "Doc4", ObjEditTime: "400"},
{NodeToken: "nt5", ObjToken: "obj5", ObjType: "docx", Title: "Doc5", ObjEditTime: "500"},
}
ts, cfg, srv := newStatefulFeishu(nodes)
defer ts.Close()
cfgDS := makeConfig(cfg, []string{"space1"})
c := NewConnector(core.RegionFeishu)
// ---- Pass 1: obj2 export fails transiently; task "times out" on the 3rd
// Emit call (nt1 success, nt2 failure-item, then cancel as nt3 is emitted).
srv.setFail("obj2")
ctx1, cancel1 := context.WithCancel(context.Background())
h1 := &convergenceHandler{cancelAfterCall: 3, cancel: cancel1}
_, err1 := c.FetchStream(ctx1, cfgDS, nil, h1)
cancel1()
if err1 == nil {
t.Fatalf("pass 1 expected a timeout/abort error, got nil")
}
// Pass 1 ingested only nt1; nt2 surfaced as a failure; nt3+ never reached.
if got := strings.Join(h1.ingested, ","); got != "nt1" {
t.Fatalf("pass 1 ingested = %q, want just nt1", got)
}
// The persisted cursor (last Checkpoint) must contain nt1 but NOT the
// aborted nt3, nor the transiently-failed nt2 — otherwise resume would core.Skip
// them forever.
persisted := lastCheckpointCursor(h1, t)
if persisted == nil {
t.Fatalf("pass 1 wrote no Checkpoint — resume would restart from scratch")
}
var pc core.FeishuCursor
pb, _ := json.Marshal(persisted.ConnectorCursor)
_ = json.Unmarshal(pb, &pc)
p := pc.SpaceNodeTimes["space1"]
if _, ok := p["nt1"]; !ok {
t.Errorf("persisted cursor missing nt1 (done work lost on resume)")
}
if _, ok := p["nt2"]; ok {
t.Errorf("persisted cursor recorded transiently-failed nt2 — it would be core.Skipped forever")
}
if _, ok := p["nt3"]; ok {
t.Errorf("persisted cursor recorded aborted nt3 — it would be core.Skipped forever")
}
// ---- Pass 2: asynq retry. obj2 has recovered; run to completion resuming
// from the persisted cursor.
srv.setFail() // clear all failures
h2 := &convergenceHandler{}
next2, err2 := c.FetchStream(context.Background(), cfgDS, persisted, h2)
if err2 != nil {
t.Fatalf("pass 2 error: %v", err2)
}
// nt1 was already done → must be core.Skipped (not re-exported, not re-ingested).
for _, id := range h2.ingested {
if id == "nt1" {
t.Errorf("pass 2 re-ingested nt1 — redundant re-export of completed work")
}
}
// Everything still outstanding (nt2 transiently-failed, nt3 aborted, nt4, nt5)
// must be ingested in pass 2.
want := map[string]bool{"nt2": true, "nt3": true, "nt4": true, "nt5": true}
got2 := map[string]bool{}
for _, id := range h2.ingested {
got2[id] = true
}
for id := range want {
if !got2[id] {
t.Errorf("pass 2 did not ingest %s (want all outstanding nodes)", id)
}
}
// ---- Convergence: union across both passes covers ALL five nodes exactly.
union := map[string]bool{}
for _, id := range h1.ingested {
union[id] = true
}
for _, id := range h2.ingested {
union[id] = true
}
for _, n := range nodes {
if !union[n.NodeToken] {
t.Errorf("node %s was NEVER synced across both passes — data loss (#2136)", n.NodeToken)
}
}
if len(union) != len(nodes) {
t.Errorf("synced %d distinct nodes, want %d", len(union), len(nodes))
}
// Final cursor is a complete snapshot of every node (next incremental sync
// starts clean).
var fc core.FeishuCursor
nb, _ := json.Marshal(next2.ConnectorCursor)
_ = json.Unmarshal(nb, &fc)
for _, n := range nodes {
if _, ok := fc.SpaceNodeTimes["space1"][n.NodeToken]; !ok {
t.Errorf("final cursor missing %s — incremental sync would re-core.Fetch it", n.NodeToken)
}
}
}
@@ -0,0 +1,266 @@
package wiki
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/datasource/connector/feishu/core"
"github.com/Tencent/WeKnora/internal/types"
)
// ──────────────────────────────────────────────────────────────────────
// Golden end-to-end test: a single rich docx node exercising EVERY new
// capability at once, driven through the real Connector.FetchAll → real
// core.Client → Feishu-shaped fake endpoints (blocks / sheets-v2 / bitable-v1 /
// medias). This is the glue test the per-function unit tests can't be:
// it proves the whole pipeline produces correct, ordered Markdown plus the
// correctly filtered attachment sub-items.
// ──────────────────────────────────────────────────────────────────────
// blk constructors kept local to this file to keep the fixture readable.
func sheetBlk(id, token string) core.DocxBlock {
return core.DocxBlock{BlockID: id, BlockType: core.BlockTypeSheet, Sheet: &core.BlockTokenRef{Token: token}}
}
func bitableBlk(id, token string) core.DocxBlock {
return core.DocxBlock{BlockID: id, BlockType: core.BlockTypeBitable, Bitable: &core.BlockTokenRef{Token: token}}
}
func imageBlk(id, token string) core.DocxBlock {
return core.DocxBlock{BlockID: id, BlockType: core.BlockTypeImage, Image: &core.BlockTokenRef{Token: token}}
}
func fileBlk(id, token, name string) core.DocxBlock {
return core.DocxBlock{BlockID: id, BlockType: core.BlockTypeFile, File: &core.BlockFileRef{Token: token, Name: name}}
}
// cellBlk builds a table_cell container. A real Feishu table_cell holds no
// inline text of its own — its text lives in a child block, so the cell only
// references the child id (see cellTextBlk).
func cellBlk(id string) core.DocxBlock {
return core.DocxBlock{BlockID: id, BlockType: core.BlockTypeTableCell, Children: []string{id + "_txt"}}
}
// cellTextBlk builds the child text block a table cell points at.
func cellTextBlk(id, text string) core.DocxBlock {
return core.DocxBlock{BlockID: id + "_txt", BlockType: core.BlockTypeText, Text: txt(text)}
}
func tableBlk(id string, cols int, cellIDs ...string) core.DocxBlock {
b := core.DocxBlock{BlockID: id, BlockType: core.BlockTypeTable}
b.Table = &core.BlockTable{Cells: cellIDs}
b.Table.Property = &core.BlockTableProperty{ColumnSize: cols}
return b
}
// fakeFeishuGolden serves the full API surface a rich docx node needs.
func fakeFeishuGolden(nodes []core.WikiNode, docToken string, blocks []core.DocxBlock,
mediaByToken map[string][]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}})
})
// docx blocks — paginated across two pages to exercise the paging glue.
mux.HandleFunc("/open-apis/docx/v1/documents/"+docToken+"/blocks", func(w http.ResponseWriter, r *http.Request) {
half := len(blocks) / 2
page := blocks[:half]
hasMore := true
if r.URL.Query().Get("page_token") == "p2" {
page = blocks[half:]
hasMore = false
}
nextTok := "p2"
if !hasMore {
nextTok = ""
}
writeJSON(w, core.DocxBlocksResponse{ApiResponse: core.ApiResponse{Code: 0}, Data: core.DocxBlocksData{Items: page, HasMore: hasMore, PageToken: nextTok}})
})
// sheets-v2 values: sht_spread_0 → spreadsheet "sht_spread", sheet "0".
// Raw JSON keeps the fake honest to the real wire shape without wrestling
// nested anonymous-struct literals.
mux.HandleFunc("/open-apis/sheets/v2/spreadsheets/sht_spread/values/0", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"code":0,"msg":"","data":{"valueRange":{"values":[["名称","数量"],["苹果",3]]}}}`))
})
// bitable-v1 fields + records: bascApp_tblMain → app "bascApp", table "tblMain".
mux.HandleFunc("/open-apis/bitable/v1/apps/bascApp/tables/tblMain/fields", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"code":0,"msg":"","data":{"items":[{"field_name":"任务"},{"field_name":"状态"}]}}`))
})
mux.HandleFunc("/open-apis/bitable/v1/apps/bascApp/tables/tblMain/records/search", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"code":0,"msg":"","data":{"has_more":false,"page_token":"","items":[{"fields":{"任务":"写码","状态":"完成"}}]}}`))
})
// medias download, routed by token embedded in the path.
mux.HandleFunc("/open-apis/drive/v1/medias/", func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/download") {
http.NotFound(w, r)
return
}
// path: /open-apis/drive/v1/medias/<token>/download
p := strings.TrimPrefix(r.URL.Path, "/open-apis/drive/v1/medias/")
tok := strings.TrimSuffix(p, "/download")
data, ok := mediaByToken[tok]
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Write(data)
})
ts := httptest.NewServer(mux)
return ts, &core.Config{AppID: "test-app-id", AppSecret: "test-app-secret", BaseURL: ts.URL}
}
func TestGolden_RichDocxAllCapabilities(t *testing.T) {
t.Setenv("FEISHU_DOCX_PARSE_MODE", "blocks")
const docToken = "obj-golden"
bigPDF := bytes.Repeat([]byte("A"), core.MinAttachmentBytes+512) // kept
tinyPDF := bytes.Repeat([]byte("B"), 100) // whitelisted but too small → dropped
blocks := []core.DocxBlock{
{BlockID: "root", BlockType: core.BlockTypePage},
{BlockID: "h1", BlockType: core.BlockTypeHeading1, Heading1: txt("季度报告")},
{BlockID: "p1", BlockType: core.BlockTypeText, Text: txt("本季度概览。")},
{BlockID: "h2", BlockType: core.BlockTypeHeading1 + 1, Heading2: txt("关键指标")},
{BlockID: "b1", BlockType: core.BlockTypeBullet, Bullet: txt("收入增长")},
{BlockID: "o1", BlockType: core.BlockTypeOrdered, Ordered: txt("第一步立项")},
{BlockID: "code1", BlockType: core.BlockTypeCode, Code: txt("SELECT 1")},
{BlockID: "q1", BlockType: core.BlockTypeQuote, Quote: txt("重要提示")},
{BlockID: "todo1", BlockType: core.BlockTypeTodo, Todo: txt("完成复盘")},
{BlockID: "call1", BlockType: core.BlockTypeCallout, Callout: txt("注意风险")},
{BlockID: "div1", BlockType: core.BlockTypeDivider},
tableBlk("tbl1", 2, "c1", "c2", "c3", "c4"),
sheetBlk("sh1", "sht_spread_0"),
bitableBlk("bt1", "bascApp_tblMain"),
imageBlk("im1", "img-tok-SECRET"),
fileBlk("fbig", "tok-big", "手册.pdf"),
fileBlk("flogo", "tok-logo", "logo.png"), // non-whitelisted ext → no sub-item
fileBlk("fsmall", "tok-small", "small.pdf"), // whitelisted but too small → no sub-item
// table cells (containers) + their child text blocks — both are core.Skipped by
// the main loop and read by the table renderer via the cells' Children.
cellBlk("c1"), cellBlk("c2"), cellBlk("c3"), cellBlk("c4"),
cellTextBlk("c1", "列A"), cellTextBlk("c2", "列B"),
cellTextBlk("c3", "1"), cellTextBlk("c4", "2"),
}
nodes := []core.WikiNode{{
NodeToken: "nt-golden", ObjToken: docToken, ObjType: "docx",
Title: "季度报告文档", NodeEditTime: "1711468800",
}}
media := map[string][]byte{"tok-big": bigPDF, "tok-small": tinyPDF}
ts, cfg := fakeFeishuGolden(nodes, docToken, blocks, media)
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)
}
// ── main markdown item + exactly one attachment sub-item ──
if len(items) != 2 {
t.Fatalf("want 2 items (main + 1 kept attachment), got %d:\n%+v", len(items), items)
}
var main, att *types.FetchedItem
for i := range items {
if items[i].ExternalID == "nt-golden" {
main = &items[i]
} else {
att = &items[i]
}
}
if main == nil {
t.Fatal("main markdown item missing")
}
if main.ContentType != "text/markdown" {
t.Errorf("main ContentType = %q, want text/markdown", main.ContentType)
}
if !main.ReplacesSubtree {
t.Error("main item must set ReplacesSubtree for orphan sweeping")
}
if !strings.HasSuffix(main.FileName, ".md") {
t.Errorf("main FileName = %q, want .md", main.FileName)
}
md := string(main.Content)
// ── every text construct rendered correctly ──
fragments := []string{
"# 季度报告",
"本季度概览。",
"## 关键指标",
"- 收入增长",
"1. 第一步立项",
"```\nSELECT 1\n```",
"> 重要提示",
"- [ ] 完成复盘",
"> 注意风险",
"---",
"| 列A | 列B |", // native docx table header
"| 1 | 2 |", // native docx table row
"| 名称 | 数量 |", // embedded sheet header
"| 苹果 | 3 |", // embedded sheet row
"| 任务 | 状态 |", // embedded bitable header
"| 写码 | 完成 |", // embedded bitable row
"![图片]()", // token-free image placeholder
"📎 附件:手册.pdf",
"📎 附件:logo.png",
"📎 附件:small.pdf",
}
for _, f := range fragments {
if !strings.Contains(md, f) {
t.Errorf("markdown missing fragment %q\n--- full markdown ---\n%s", f, md)
}
}
// ── document order preserved (heading before table before attachments) ──
order := []string{"# 季度报告", "## 关键指标", "| 列A | 列B |", "| 名称 | 数量 |", "| 任务 | 状态 |", "📎 附件:手册.pdf"}
last := -1
for _, f := range order {
idx := strings.Index(md, f)
if idx <= last {
t.Errorf("fragment %q out of order (idx=%d, prev=%d)\n%s", f, idx, last, md)
}
last = idx
}
// ── the internal image media token must NEVER leak into embeddings ──
if strings.Contains(md, "img-tok-SECRET") {
t.Errorf("internal image token leaked into markdown:\n%s", md)
}
// ── attachment filtering: only the big whitelisted PDF becomes a sub-item ──
if att == nil {
t.Fatal("kept attachment sub-item missing")
}
if att.ExternalID != "nt-golden#file#tok-big" {
t.Errorf("attachment ExternalID = %q, want nt-golden#file#tok-big", att.ExternalID)
}
if att.Title != "手册.pdf" {
t.Errorf("attachment Title = %q", att.Title)
}
if !bytes.Equal(att.Content, bigPDF) {
t.Errorf("attachment content mismatch: got %d bytes want %d", len(att.Content), len(bigPDF))
}
if att.Metadata["attachment"] != "true" || att.Metadata["parent_node_token"] != "nt-golden" {
t.Errorf("attachment metadata wrong: %+v", att.Metadata)
}
}
@@ -0,0 +1,234 @@
//go:build feishu_integration
// Real-API integration test for FetchStream against a live Feishu tenant.
//
// It is OPT-IN and never runs in the normal suite or CI: it is guarded by the
// `feishu_integration` build tag AND skips unless the required env vars are set.
//
// Run it with:
//
// export CPLUS_INCLUDE_PATH="$(xcrun --show-sdk-path)/usr/include/c++/v1"
// FEISHU_APP_ID=... FEISHU_APP_SECRET=... FEISHU_TEST_SPACE_ID=... \
// go test -tags feishu_integration -run TestRealAPI -v \
// ./internal/datasource/connector/feishu/
//
// The test is strictly READ-ONLY: it lists and exports documents from the given
// wiki space but never creates, edits or deletes anything. It whitelists
// *.feishu.cn / *.larksuite.com for SSRF in-process so it works from a dev
// machine behind a fake-ip proxy without any production code change.
package wiki
import (
"context"
"encoding/json"
"os"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/datasource/connector/feishu/core"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/utils"
)
// collectHandler records everything a real FetchStream emits so the test can
// assert coverage and resume behaviour. cancelAfter>0 simulates a task timeout
// by cancelling the run after that many successful content emits.
type collectHandler struct {
ingested []string
failed []string
checkpoints []*types.SyncCursor
calls int
cancelAfter int
cancel context.CancelFunc
}
func (h *collectHandler) Emit(ctx context.Context, item types.FetchedItem) error {
if err := ctx.Err(); err != nil {
return err
}
if item.Metadata["error"] != "" {
h.failed = append(h.failed, item.ExternalID)
return nil
}
h.calls++
if h.cancelAfter > 0 && h.calls > h.cancelAfter {
if h.cancel != nil {
h.cancel()
}
return context.Canceled
}
h.ingested = append(h.ingested, item.ExternalID)
return nil
}
func (h *collectHandler) Checkpoint(_ context.Context, cursor *types.SyncCursor) error {
// Snapshot the cursor JSON (mirrors the service serialising synchronously).
b, _ := json.Marshal(cursor.ConnectorCursor)
var m map[string]interface{}
_ = json.Unmarshal(b, &m)
h.checkpoints = append(h.checkpoints, &types.SyncCursor{ConnectorCursor: m})
return nil
}
func nodeTimes(t *testing.T, cur *types.SyncCursor) map[string]map[string]string {
t.Helper()
if cur == nil {
return nil
}
var fc core.FeishuCursor
b, _ := json.Marshal(cur.ConnectorCursor)
_ = json.Unmarshal(b, &fc)
return fc.SpaceNodeTimes
}
func countNodes(m map[string]map[string]string) int {
n := 0
for _, v := range m {
n += len(v)
}
return n
}
// TestRealAPI_ListSpaces discovers which wiki spaces the app can access, so the
// space ID for the other tests can be found without guessing. Read-only.
func TestRealAPI_ListSpaces(t *testing.T) {
appID := os.Getenv("FEISHU_APP_ID")
appSecret := os.Getenv("FEISHU_APP_SECRET")
if appID == "" || appSecret == "" {
t.Skip("set FEISHU_APP_ID, FEISHU_APP_SECRET to run")
}
utils.SetSSRFWhitelistFromRaw("*.feishu.cn,*.larksuite.com")
client := core.NewClient(&core.Config{AppID: appID, AppSecret: appSecret, BaseURL: os.Getenv("FEISHU_BASE_URL")})
spaces, err := client.ListWikiSpaces(context.Background())
if err != nil {
t.Fatalf("ListWikiSpaces: %v", err)
}
if len(spaces) == 0 {
t.Fatalf("app can access 0 wiki spaces — add the app as a member of a knowledge base")
}
for _, s := range spaces {
t.Logf("SPACE space_id=%s name=%q", s.SpaceID, s.Name)
}
}
func TestRealAPI_FetchStreamResumeConverges(t *testing.T) {
appID := os.Getenv("FEISHU_APP_ID")
appSecret := os.Getenv("FEISHU_APP_SECRET")
spaceID := os.Getenv("FEISHU_TEST_SPACE_ID")
if appID == "" || appSecret == "" || spaceID == "" {
t.Skip("set FEISHU_APP_ID, FEISHU_APP_SECRET, FEISHU_TEST_SPACE_ID to run")
}
// Whitelist the Feishu/Lark hosts for SSRF so the dev-machine fake-ip proxy
// (198.18.x) is allowed. No production code change; scoped to these hosts.
utils.SetSSRFWhitelistFromRaw("*.feishu.cn,*.larksuite.com")
baseURL := os.Getenv("FEISHU_BASE_URL") // optional; defaults to region base
cfg := &types.DataSourceConfig{
Type: types.ConnectorTypeFeishu,
Credentials: map[string]interface{}{
"app_id": appID,
"app_secret": appSecret,
"base_url": baseURL,
},
ResourceIDs: []string{spaceID},
}
c := NewConnector(core.RegionFeishu)
// ---- Pass 1: full sync against the real space.
h1 := &collectHandler{}
cur1, err := c.FetchStream(context.Background(), cfg, nil, h1)
if err != nil {
t.Fatalf("pass 1 (full) FetchStream error: %v", err)
}
n := len(h1.ingested)
t.Logf("pass 1: ingested=%d failed=%d cursor_nodes=%d", n, len(h1.failed), countNodes(nodeTimes(t, cur1)))
if n == 0 {
t.Fatalf("pass 1 ingested 0 documents — create a few docs in the space, or check app scopes (wiki/docx/drive read)")
}
// Every ingested *node* must be recorded in the cursor. A multi-item node (a
// docx that fans out into a main document plus attachment/image sub-items)
// also ingests child items whose external_id is "<node>#file#..." or
// "<node>#image#...". Those are not wiki nodes: they ride their parent node's
// edit-time and intentionally carry no independent cursor entry (the parent's
// edit-time gates re-core.Fetch of the whole subtree). Only node-level ids are
// required to be present.
nt1 := nodeTimes(t, cur1)[spaceID]
for _, id := range h1.ingested {
if strings.Contains(id, "#") {
continue // attachment/image sub-item, not a top-level cursor node
}
if _, ok := nt1[id]; !ok {
t.Errorf("pass 1 cursor missing edit time for ingested node %s", id)
}
}
// ---- Pass 2: incremental with pass-1 cursor. Against the REAL API this is
// the load-bearing check a fake can't make: real obj_edit_time must be
// stable for unchanged docs, so NOTHING is re-ingested.
h2 := &collectHandler{}
cur2, err := c.FetchStream(context.Background(), cfg, cur1, h2)
if err != nil {
t.Fatalf("pass 2 (incremental) FetchStream error: %v", err)
}
t.Logf("pass 2: re-ingested=%d (want 0)", len(h2.ingested))
if len(h2.ingested) != 0 {
t.Errorf("pass 2 re-ingested %v — real edit-time is unstable or core.Skip logic is broken", h2.ingested)
}
if got := countNodes(nodeTimes(t, cur2)); got != countNodes(nt1AsMap(nt1)) {
t.Logf("note: cursor node count changed between passes (%d→%d) — space may have been edited concurrently", countNodes(nt1AsMap(nt1)), got)
}
// ---- Pass 3: interrupt mid-sync then resume, against the real API.
// Only meaningful with at least 2 documents.
if n < 2 {
t.Logf("only %d doc(s); skipping interrupt/resume convergence sub-test (needs >=2)", n)
return
}
prevN := core.FeishuStreamCheckpointInterval
core.FeishuStreamCheckpointInterval = 1 // Checkpoint every node so resume is precise
defer func() { core.FeishuStreamCheckpointInterval = prevN }()
ctx3, cancel3 := context.WithCancel(context.Background())
h3 := &collectHandler{cancelAfter: 1, cancel: cancel3} // abort after 1 success
_, err = c.FetchStream(ctx3, cfg, nil, h3)
cancel3()
if err == nil {
t.Fatalf("pass 3 expected an abort error from the simulated timeout")
}
if len(h3.checkpoints) == 0 {
t.Fatalf("pass 3 wrote no Checkpoint — resume would restart from scratch")
}
persisted := h3.checkpoints[len(h3.checkpoints)-1]
t.Logf("pass 3: ingested=%d before abort, persisted cursor nodes=%d", len(h3.ingested), countNodes(nodeTimes(t, persisted)))
// Resume from the persisted Checkpoint; must converge to full coverage.
h4 := &collectHandler{}
_, err = c.FetchStream(context.Background(), cfg, persisted, h4)
if err != nil {
t.Fatalf("pass 4 (resume) FetchStream error: %v", err)
}
union := map[string]bool{}
for _, id := range h3.ingested {
union[id] = true
}
for _, id := range h4.ingested {
union[id] = true
}
t.Logf("pass 3+4 union ingested=%d, pass-1 full=%d", len(union), n)
if len(union) < n {
t.Errorf("resume converged to %d docs but full sync had %d — a document was lost across interrupt+resume", len(union), n)
}
// Resume must not redo already-checkpointed work.
done := nodeTimes(t, persisted)[spaceID]
for _, id := range h4.ingested {
if _, ok := done[id]; ok {
t.Errorf("pass 4 re-ingested already-checkpointed node %s (redundant re-export)", id)
}
}
}
func nt1AsMap(m map[string]string) map[string]map[string]string {
return map[string]map[string]string{"_": m}
}
@@ -0,0 +1,152 @@
//go:build feishu_integration
// Seeder for the real-API integration test. OPT-IN (feishu_integration tag +
// env vars) so it never runs in the normal suite or CI.
//
// It WRITES to your Feishu tenant: it creates a handful of docx documents under
// the given wiki space, each titled "WeKnora sync test doc N" so you can find
// and delete them afterwards. Requires app write scopes:
// - wiki:wiki (create wiki nodes)
// - docx:document (write docx content)
//
// plus the app being a member of the space.
//
// Run:
//
// export CPLUS_INCLUDE_PATH="$(xcrun --show-sdk-path)/usr/include/c++/v1"
// FEISHU_APP_ID=... FEISHU_APP_SECRET=... FEISHU_TEST_SPACE_ID=... FEISHU_SEED_COUNT=3 \
// go test -tags feishu_integration -run TestRealAPI_SeedDocs -v \
// ./internal/datasource/connector/feishu/
package wiki
import (
"context"
"fmt"
"os"
"strconv"
"testing"
"github.com/Tencent/WeKnora/internal/datasource/connector/feishu/core"
"github.com/Tencent/WeKnora/internal/utils"
)
// wikiNodeCreateResp is the subset of POST /wiki/v2/spaces/:id/nodes we need.
type wikiNodeCreateResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Node struct {
NodeToken string `json:"node_token"`
ObjToken string `json:"obj_token"`
ObjType string `json:"obj_type"`
Title string `json:"title"`
} `json:"node"`
} `json:"data"`
}
// TestRealAPI_CreateSpace creates a fresh knowledge base owned by the app, so
// the app can seed and read it without a human adding it to an existing space.
// It prints the new space_id for use as FEISHU_TEST_SPACE_ID.
func TestRealAPI_CreateSpace(t *testing.T) {
appID := os.Getenv("FEISHU_APP_ID")
appSecret := os.Getenv("FEISHU_APP_SECRET")
if appID == "" || appSecret == "" {
t.Skip("set FEISHU_APP_ID, FEISHU_APP_SECRET to run")
}
utils.SetSSRFWhitelistFromRaw("*.feishu.cn,*.larksuite.com")
client := core.NewClient(&core.Config{AppID: appID, AppSecret: appSecret, BaseURL: os.Getenv("FEISHU_BASE_URL")})
var resp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Space struct {
SpaceID string `json:"space_id"`
Name string `json:"name"`
} `json:"space"`
} `json:"data"`
}
err := client.DoRequest(context.Background(), "POST", "/open-apis/wiki/v2/spaces",
map[string]interface{}{
"name": "WeKnora Resilience Test KB",
"description": "Auto-created for connector resilience testing (#2136). Safe to delete.",
}, &resp)
if err != nil {
t.Fatalf("create space: %v", err)
}
if resp.Code != 0 {
t.Fatalf("create space: code=%d msg=%s (needs wiki:wiki write scope)", resp.Code, resp.Msg)
}
t.Logf("CREATED_SPACE space_id=%s name=%q", resp.Data.Space.SpaceID, resp.Data.Space.Name)
}
func TestRealAPI_SeedDocs(t *testing.T) {
appID := os.Getenv("FEISHU_APP_ID")
appSecret := os.Getenv("FEISHU_APP_SECRET")
spaceID := os.Getenv("FEISHU_TEST_SPACE_ID")
if appID == "" || appSecret == "" || spaceID == "" {
t.Skip("set FEISHU_APP_ID, FEISHU_APP_SECRET, FEISHU_TEST_SPACE_ID to run")
}
count := 3
if v := os.Getenv("FEISHU_SEED_COUNT"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
count = n
}
}
utils.SetSSRFWhitelistFromRaw("*.feishu.cn,*.larksuite.com")
baseURL := os.Getenv("FEISHU_BASE_URL")
client := core.NewClient(&core.Config{AppID: appID, AppSecret: appSecret, BaseURL: baseURL})
ctx := context.Background()
for i := 1; i <= count; i++ {
title := fmt.Sprintf("WeKnora sync test doc %d", i)
// 1) Create a docx wiki node.
var created wikiNodeCreateResp
err := client.DoRequest(ctx, "POST",
fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", spaceID),
map[string]interface{}{
"obj_type": "docx",
"node_type": "origin",
"title": title,
}, &created)
if err != nil {
t.Fatalf("create wiki node %d: %v", i, err)
}
if created.Code != 0 {
t.Fatalf("create wiki node %d: code=%d msg=%s (check wiki:wiki write scope + space membership)", i, created.Code, created.Msg)
}
docID := created.Data.Node.ObjToken // for docx, obj_token == document_id
// 2) Add a text block so the export is non-empty.
var blockResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
berr := client.DoRequest(ctx, "POST",
fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s/children", docID, docID),
map[string]interface{}{
"index": 0,
"children": []map[string]interface{}{{
"block_type": 2, // text
"text": map[string]interface{}{
"elements": []map[string]interface{}{{
"text_run": map[string]interface{}{
"content": fmt.Sprintf("This is WeKnora sync test document number %d. It exists to validate connector resilience (issue #2136).", i),
},
}},
},
}},
}, &blockResp)
if berr != nil {
t.Logf("warn: add content to doc %d failed (%v) — doc still created, export may be empty", i, berr)
} else if blockResp.Code != 0 {
t.Logf("warn: add content to doc %d: code=%d msg=%s (needs docx:document write scope) — doc still created", i, blockResp.Code, blockResp.Msg)
}
t.Logf("created doc %d: title=%q node_token=%s obj_token=%s", i, title, created.Data.Node.NodeToken, docID)
}
t.Logf("seeded %d docs under space %s. Now run TestRealAPI_FetchStreamResumeConverges.", count, spaceID)
}
@@ -0,0 +1,490 @@
package wiki
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/datasource/connector/feishu/core"
"github.com/Tencent/WeKnora/internal/types"
)
// fakeFeishuFailingExport serves the auth/spaces/nodes endpoints normally but
// fails every document export (code != 0), so fetchNodeContent returns an error
// for each supported node — modelling a rate-limited / broken export.
func fakeFeishuFailingExport(nodes []core.WikiNode) (*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},
})
})
// Export creation fails for every document.
mux.HandleFunc("/open-apis/drive/v1/export_tasks", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, core.ApiResponse{Code: 1, Msg: "export unavailable"})
})
ts := httptest.NewServer(mux)
return ts, &core.Config{AppID: "test-app-id", AppSecret: "test-app-secret", BaseURL: ts.URL}
}
// A node whose core.Fetch fails must NOT have its new edit time recorded in the
// returned cursor: recording it would make the next sync's unchanged fast-path
// core.Skip it forever, silently dropping a document on a transient export failure
// (Tencent/WeKnora#2136). With a prior edit time known, the prior value is
// retained so prev != current next run and the node is retried.
func TestFetchStream_FailedFetchRetainsPriorCursor(t *testing.T) {
nodes := []core.WikiNode{{NodeToken: "nt1", ObjToken: "obj1", ObjType: "docx", Title: "Doc", ObjEditTime: "100"}}
ts, cfg := fakeFeishuFailingExport(nodes)
defer ts.Close()
cursor := makeStreamCursor(t, map[string]map[string]string{"space1": {"nt1": "50"}}) // prior, older
c := NewConnector(core.RegionFeishu)
h := &recordingHandler{}
next, err := c.FetchStream(context.Background(), makeConfig(cfg, []string{"space1"}), cursor, h)
if err != nil {
t.Fatalf("FetchStream() error: %v", err)
}
// A failure item must be surfaced, not silently dropped.
if len(h.emitted) != 1 || h.emitted[0].Metadata["error"] == "" {
t.Fatalf("expected 1 emitted failure item with error metadata, got %+v", h.emitted)
}
var fc core.FeishuCursor
b, _ := json.Marshal(next.ConnectorCursor)
_ = json.Unmarshal(b, &fc)
got := fc.SpaceNodeTimes["space1"]["nt1"]
if got == "100" {
t.Fatalf("failed node advanced to current edit time %q — it will be core.Skipped forever", got)
}
if got != "50" {
t.Errorf("failed node cursor = %q, want prior value \"50\" (retry next run)", got)
}
}
// With no prior cursor entry, a failed core.Fetch must leave the node out of the
// returned cursor entirely, so the next run treats it as new and retries it.
func TestFetchStream_FailedFetchNoPriorOmitsFromCursor(t *testing.T) {
nodes := []core.WikiNode{{NodeToken: "nt1", ObjToken: "obj1", ObjType: "docx", Title: "Doc", ObjEditTime: "100"}}
ts, cfg := fakeFeishuFailingExport(nodes)
defer ts.Close()
c := NewConnector(core.RegionFeishu)
h := &recordingHandler{}
next, err := c.FetchStream(context.Background(), makeConfig(cfg, []string{"space1"}), nil, h)
if err != nil {
t.Fatalf("FetchStream() error: %v", err)
}
var fc core.FeishuCursor
b, _ := json.Marshal(next.ConnectorCursor)
_ = json.Unmarshal(b, &fc)
if v, ok := fc.SpaceNodeTimes["space1"]["nt1"]; ok {
t.Errorf("failed node recorded in cursor as %q; want absent so it is retried next run", v)
}
}
// recordingHandler captures the items and checkpoints a streaming core.Fetch emits.
// Checkpoints are snapshotted (JSON-encoded) at call time — mirroring the
// service, which serializes the cursor synchronously inside Checkpoint — so the
// test observes the cursor state as it was when Checkpoint was called, not the
// connector's later-mutated map.
type recordingHandler struct {
emitted []types.FetchedItem
checkpoints []core.FeishuCursor
emitErr func(item types.FetchedItem) error
}
func (h *recordingHandler) Emit(ctx context.Context, item types.FetchedItem) error {
if h.emitErr != nil {
if err := h.emitErr(item); err != nil {
return err
}
}
h.emitted = append(h.emitted, item)
return nil
}
func (h *recordingHandler) Checkpoint(ctx context.Context, cursor *types.SyncCursor) error {
var fc core.FeishuCursor
b, _ := json.Marshal(cursor.ConnectorCursor)
_ = json.Unmarshal(b, &fc)
h.checkpoints = append(h.checkpoints, fc)
return nil
}
func makeStreamCursor(t *testing.T, spaceNodeTimes map[string]map[string]string) *types.SyncCursor {
t.Helper()
prev := core.FeishuCursor{SpaceNodeTimes: spaceNodeTimes}
b, _ := json.Marshal(prev)
var m map[string]interface{}
if err := json.Unmarshal(b, &m); err != nil {
t.Fatalf("build cursor: %v", err)
}
return &types.SyncCursor{ConnectorCursor: m}
}
// A nil-cursor stream emits every supported node once and skips unsupported
// types, and the returned cursor records the edit time of every discovered node
// (so the next incremental run can detect changes).
func TestFetchStream_EmitsSupportedSkipsUnsupported(t *testing.T) {
nodes := []core.WikiNode{
{NodeToken: "nt1", ObjToken: "obj1", ObjType: "docx", Title: "Doc", ObjEditTime: "100"},
{NodeToken: "nt2", ObjToken: "obj2", ObjType: "mindnote", Title: "Brain", ObjEditTime: "200"},
{NodeToken: "nt3", ObjToken: "obj3", ObjType: "docx", Title: "Doc3", ObjEditTime: "300"},
}
ts, cfg := fakeFeishu(nodes)
defer ts.Close()
c := NewConnector(core.RegionFeishu)
h := &recordingHandler{}
next, err := c.FetchStream(context.Background(), makeConfig(cfg, []string{"space1"}), nil, h)
if err != nil {
t.Fatalf("FetchStream() error: %v", err)
}
if len(h.emitted) != 2 {
t.Fatalf("emitted %d items, want 2 (nt1, nt3)", len(h.emitted))
}
if h.emitted[0].ExternalID != "nt1" || h.emitted[1].ExternalID != "nt3" {
t.Errorf("emitted ids = %q,%q; want nt1,nt3", h.emitted[0].ExternalID, h.emitted[1].ExternalID)
}
var fc core.FeishuCursor
b, _ := json.Marshal(next.ConnectorCursor)
_ = json.Unmarshal(b, &fc)
times := fc.SpaceNodeTimes["space1"]
for _, tok := range []string{"nt1", "nt2", "nt3"} {
if _, ok := times[tok]; !ok {
t.Errorf("returned cursor missing edit time for %s", tok)
}
}
}
// When a cursor already records a node at its current edit time, that node is
// unchanged and must not be re-emitted; only new/changed nodes stream through.
// This is the resume/incremental-core.Skip behavior that lets a timed-out sync
// converge across retries instead of re-exporting everything.
func TestFetchStream_SkipsUnchangedNodesFromCursor(t *testing.T) {
nodes := []core.WikiNode{
{NodeToken: "nt1", ObjToken: "obj1", ObjType: "docx", Title: "Doc", ObjEditTime: "100"},
{NodeToken: "nt3", ObjToken: "obj3", ObjType: "docx", Title: "Doc3", ObjEditTime: "300"},
}
ts, cfg := fakeFeishu(nodes)
defer ts.Close()
cursor := makeStreamCursor(t, map[string]map[string]string{
"space1": {"nt1": "100"}, // nt1 unchanged; nt3 unknown → new
})
c := NewConnector(core.RegionFeishu)
h := &recordingHandler{}
if _, err := c.FetchStream(context.Background(), makeConfig(cfg, []string{"space1"}), cursor, h); err != nil {
t.Fatalf("FetchStream() error: %v", err)
}
if len(h.emitted) != 1 {
t.Fatalf("emitted %d items, want 1 (only changed nt3)", len(h.emitted))
}
if h.emitted[0].ExternalID != "nt3" {
t.Errorf("emitted id = %q, want nt3", h.emitted[0].ExternalID)
}
}
// Checkpoints must persist progress at page boundaries so a crash mid-sync
// resumes from the last Checkpoint. With the interval set to 1, each emitted
// item triggers a Checkpoint, and the first Checkpoint must already contain the
// first node's edit time.
func TestFetchStream_CheckpointsProgress(t *testing.T) {
prev := core.FeishuStreamCheckpointInterval
core.FeishuStreamCheckpointInterval = 1
defer func() { core.FeishuStreamCheckpointInterval = prev }()
nodes := []core.WikiNode{
{NodeToken: "nt1", ObjToken: "obj1", ObjType: "docx", Title: "Doc", ObjEditTime: "100"},
{NodeToken: "nt3", ObjToken: "obj3", ObjType: "docx", Title: "Doc3", ObjEditTime: "300"},
}
ts, cfg := fakeFeishu(nodes)
defer ts.Close()
c := NewConnector(core.RegionFeishu)
h := &recordingHandler{}
if _, err := c.FetchStream(context.Background(), makeConfig(cfg, []string{"space1"}), nil, h); err != nil {
t.Fatalf("FetchStream() error: %v", err)
}
if len(h.checkpoints) == 0 {
t.Fatalf("expected at least one Checkpoint")
}
first := h.checkpoints[0]
if _, ok := first.SpaceNodeTimes["space1"]["nt1"]; !ok {
t.Errorf("first Checkpoint missing nt1 progress: %+v", first.SpaceNodeTimes)
}
}
// Checkpoints must ALSO fire on elapsed time, not only every N nodes.
// Otherwise a sync with fewer than the node interval of slow (rate-limited)
// exports reaches the 2h task timeout having never checkpointed, and resumes
// from scratch forever — exactly the #2136 "never fully syncs" case. With the
// node interval effectively disabled and the time interval at 0, every
// processed node must still produce a Checkpoint.
func TestFetchStream_CheckpointsOnElapsedTime(t *testing.T) {
prevN := core.FeishuStreamCheckpointInterval
prevT := core.FeishuStreamCheckpointMaxInterval
core.FeishuStreamCheckpointInterval = 1 << 30 // never fires by count
core.FeishuStreamCheckpointMaxInterval = 0 // fires by elapsed time every node
defer func() {
core.FeishuStreamCheckpointInterval = prevN
core.FeishuStreamCheckpointMaxInterval = prevT
}()
nodes := []core.WikiNode{
{NodeToken: "nt1", ObjToken: "obj1", ObjType: "docx", Title: "Doc", ObjEditTime: "100"},
{NodeToken: "nt3", ObjToken: "obj3", ObjType: "docx", Title: "Doc3", ObjEditTime: "300"},
}
ts, cfg := fakeFeishu(nodes)
defer ts.Close()
c := NewConnector(core.RegionFeishu)
h := &recordingHandler{}
if _, err := c.FetchStream(context.Background(), makeConfig(cfg, []string{"space1"}), nil, h); err != nil {
t.Fatalf("FetchStream() error: %v", err)
}
if len(h.checkpoints) == 0 {
t.Fatalf("expected time-based checkpoints even though node interval never fires")
}
}
// An Emit error aborts the stream immediately — the connector must return the
// error and stop fetching further nodes (the sync is failing; do not burn API
// budget on the rest of the tree).
func TestFetchStream_EmitErrorAborts(t *testing.T) {
nodes := []core.WikiNode{
{NodeToken: "nt1", ObjToken: "obj1", ObjType: "docx", Title: "Doc", ObjEditTime: "100"},
{NodeToken: "nt3", ObjToken: "obj3", ObjType: "docx", Title: "Doc3", ObjEditTime: "300"},
}
ts, cfg := fakeFeishu(nodes)
defer ts.Close()
boom := errors.New("ingest failed")
c := NewConnector(core.RegionFeishu)
h := &recordingHandler{emitErr: func(item types.FetchedItem) error { return boom }}
_, err := c.FetchStream(context.Background(), makeConfig(cfg, []string{"space1"}), nil, h)
if !errors.Is(err, boom) {
t.Fatalf("FetchStream() error = %v, want %v", err, boom)
}
if len(h.emitted) != 0 {
t.Errorf("emitted %d items, want 0 (aborted on first Emit)", len(h.emitted))
}
}
// ──────────────────────────────────────────────────────────────────────
// Stream integration: docx blocks fan-out (multi-item) through FetchStream
// ──────────────────────────────────────────────────────────────────────
// TestFetchStream_DocxMultiItem is a stream-level integration test proving that a
// docx node fans out to a main Markdown item + an attachment item through the real
// FetchStream → fetchNodeContent → core.FetchDocxWithBlocks path. The fake server
// serves the blocks API (one text block + one file block) and the drive download;
// no export endpoint is registered, so any accidental fall-through to the export
// path would 404 and surface as a core.Fetch error.
func TestFetchStream_DocxMultiItem(t *testing.T) {
t.Setenv("FEISHU_DOCX_PARSE_MODE", "blocks")
const (
nodeToken = "nt-blocks"
objToken = "obj-blocks"
attToken = "ft-stream-att"
attName = "slides.pdf"
)
// Attachment content must exceed core.MinAttachmentBytes (2 KiB).
attContent := bytes.Repeat([]byte("a"), core.MinAttachmentBytes+1)
nodes := []core.WikiNode{{
NodeToken: nodeToken,
ObjToken: objToken,
ObjType: "docx",
Title: "Stream Blocks Doc",
ObjEditTime: "500",
}}
ts, cfg := fakeFeishuWithBlocks(nodes, objToken, attToken, attName, attContent)
defer ts.Close()
c := NewConnector(core.RegionFeishu)
h := &recordingHandler{}
_, err := c.FetchStream(context.Background(), makeConfig(cfg, []string{"space1"}), nil, h)
if err != nil {
t.Fatalf("FetchStream() error: %v", err)
}
if len(h.emitted) != 2 {
t.Fatalf("expected 2 emitted items (main doc + attachment), got %d: %+v", len(h.emitted), h.emitted)
}
main := h.emitted[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")
}
att := h.emitted[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"])
}
}
// fakeFeishuWithBlocksFallback returns a server where the blocks API returns HTTP 500
// (simulating a permission/scope error) and the export task path returns success.
// This wires the fallback path: core.FetchDocxWithBlocks → blocks error → fetchViaExport.
func fakeFeishuWithBlocksFallback(nodes []core.WikiNode, docToken string) (*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 — always returns HTTP 500 (missing scope / permission denied).
blocksPath := "/open-apis/docx/v1/documents/" + docToken + "/blocks"
mux.HandleFunc(blocksPath, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"code":99991400,"msg":"insufficient scope"}`))
})
// Export task creation → returns ticket.
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-fb"},
})
return
}
http.NotFound(w, r)
})
// Export task status polling.
mux.HandleFunc("/open-apis/drive/v1/export_tasks/ticket-fb", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, core.ExportTaskStatusResponse{
ApiResponse: core.ApiResponse{Code: 0},
Data: core.ExportTaskStatusData{
Result: core.ExportTaskResult{
FileToken: "ft-export-fallback",
FileSize: 512,
JobStatus: 0, // done
FileName: "fallback.docx",
},
},
})
})
// Export file download.
mux.HandleFunc("/open-apis/drive/v1/export_tasks/file/ft-export-fallback/download", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write([]byte("fake-exported-fallback-binary"))
})
// Drive file download endpoint (not expected to be called in the fallback path,
// but registered to avoid 404 panic if the mux catches a stray request).
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("should-not-be-called"))
return
}
http.NotFound(w, r)
})
ts := httptest.NewServer(mux)
return ts, &core.Config{AppID: "test-app-id", AppSecret: "test-app-secret", BaseURL: ts.URL}
}
// TestFetchStream_DocxBlocksFallback proves that when the blocks API returns HTTP 500
// (e.g. missing docx:document:readonly scope), the connector automatically falls back
// to the export-API path and emits exactly one item with ContentType
// "application/octet-stream", not "text/markdown". No hard failure occurs —
// the sync completes successfully with the exported binary.
func TestFetchStream_DocxBlocksFallback(t *testing.T) {
t.Setenv("FEISHU_DOCX_PARSE_MODE", "blocks")
const (
nodeToken = "nt-fallback"
objToken = "obj-fallback"
)
nodes := []core.WikiNode{{
NodeToken: nodeToken,
ObjToken: objToken,
ObjType: "docx",
Title: "Fallback Doc",
ObjEditTime: "600",
}}
ts, cfg := fakeFeishuWithBlocksFallback(nodes, objToken)
defer ts.Close()
c := NewConnector(core.RegionFeishu)
h := &recordingHandler{}
_, err := c.FetchStream(context.Background(), makeConfig(cfg, []string{"space1"}), nil, h)
if err != nil {
t.Fatalf("FetchStream() error: %v", err)
}
// The fallback path must Emit exactly one item (exported binary, not multi-item).
if len(h.emitted) != 1 {
t.Fatalf("expected 1 emitted item (export fallback), got %d: %+v", len(h.emitted), h.emitted)
}
item := h.emitted[0]
if item.ExternalID != nodeToken {
t.Errorf("item.ExternalID = %q, want %q", item.ExternalID, nodeToken)
}
if item.ContentType == "text/markdown" {
t.Errorf("item.ContentType = %q; want application/octet-stream (export fallback, not blocks path)", item.ContentType)
}
if item.ContentType != "application/octet-stream" {
t.Errorf("item.ContentType = %q, want application/octet-stream", item.ContentType)
}
if item.Metadata["error"] != "" {
t.Errorf("fallback item should have no error metadata, got %q", item.Metadata["error"])
}
// The export fallback cannot re-enumerate the doc's attachments, so it must
// NOT set ReplacesSubtree — otherwise a transient blocks-API failure would
// sweep and permanently delete the good attachment children from the prior
// blocks-path sync with nothing to replace them.
if item.ReplacesSubtree {
t.Error("export-fallback item must not set ReplacesSubtree (would delete good prior attachments on a transient failure)")
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
package wiki
import "github.com/Tencent/WeKnora/internal/datasource/connector/feishu/core"
func txt(s string) *core.BlockText {
return &core.BlockText{Elements: []core.TextElement{{TextRun: &core.TextRun{Content: s}}}}
}
@@ -0,0 +1,86 @@
package wiki
import (
"context"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/datasource/connector/feishu/core"
"github.com/Tencent/WeKnora/internal/types"
)
// A Lark connector must surface wiki links on the Lark host. Before Lark became
// its own connector type, every resource URL was hardcoded to feishu.cn, so Lark
// users got links into a cloud their content does not live on.
//
// The fake server stands in for the Open Platform (base_url points at it), but
// the resource URL is built from the region's web host, so this exercises the
// real ListResources → wikiNodeToResource → region.WikiURL path.
func TestListResources_ResourceURLFollowsRegion(t *testing.T) {
cases := []struct {
region core.Region
wantURL string
forbiddenIn string
}{
{core.RegionFeishu, "https://feishu.cn/wiki/space1", "larksuite"},
{core.RegionLark, "https://larksuite.com/wiki/space1", "feishu"},
}
for _, c := range cases {
t.Run(c.region.Label, func(t *testing.T) {
ts, cfg := fakeFeishu(nil)
defer ts.Close()
conn := NewConnector(c.region)
resources, err := conn.ListResources(context.Background(), makeConfig(cfg, nil), "")
if err != nil {
t.Fatalf("ListResources: %v", err)
}
if len(resources) != 1 {
t.Fatalf("got %d resources, want 1", len(resources))
}
if got := resources[0].URL; got != c.wantURL {
t.Errorf("resource URL = %q, want %q", got, c.wantURL)
}
if strings.Contains(resources[0].URL, c.forbiddenIn) {
t.Errorf("%s resource URL leaks the other cloud's host: %q", c.region.Label, resources[0].URL)
}
})
}
}
// A Lark connector with no base_url override must reach open.larksuite.com,
// never open.feishu.cn — the SDK-less client has no default of its own, so the
// region is the only thing standing between a Lark app and the wrong cloud.
func TestClient_ResolvesRegionHostWithoutOverride(t *testing.T) {
cases := []struct {
region core.Region
want string
}{
{core.RegionFeishu, "https://open.feishu.cn"},
{core.RegionLark, "https://open.larksuite.com"},
}
for _, c := range cases {
t.Run(c.region.Label, func(t *testing.T) {
cfg, err := core.ParseFeishuConfig(makeConfigNoBaseURL(), c.region)
if err != nil {
t.Fatalf("ParseFeishuConfig: %v", err)
}
if cfg.GetBaseURL() != c.want {
t.Errorf("baseURL = %q, want %q", cfg.GetBaseURL(), c.want)
}
})
}
}
// makeConfigNoBaseURL builds credentials without base_url, so the region default
// is what resolves.
func makeConfigNoBaseURL() *types.DataSourceConfig {
return &types.DataSourceConfig{
Credentials: map[string]interface{}{
"app_id": "cli_x",
"app_secret": "s",
},
}
}