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
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:
@@ -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",
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user