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

587 lines
19 KiB
Go

package service
import (
"context"
stderrors "errors"
"io"
"mime/multipart"
"strings"
"testing"
"time"
"github.com/Tencent/WeKnora/internal/sandbox"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// -----------------------------------------------------------------------------
// Test doubles
// -----------------------------------------------------------------------------
// fakeSandboxSource is an in-memory stand-in for SessionBoundManager that
// records the calls the collector makes and returns pre-programmed
// responses. Concrete tests populate `entries` (one map per session) and
// `contents` (one map per absolute path).
type fakeSandboxSource struct {
entries map[string][]sandbox.RemoteDirEntry
contents map[string][]byte
listErr error
readErr error
listCalls int
readCalls []string
}
func (f *fakeSandboxSource) ListSessionFiles(_ context.Context, sessionID, _ string) ([]sandbox.RemoteDirEntry, error) {
f.listCalls++
if f.listErr != nil {
return nil, f.listErr
}
return f.entries[sessionID], nil
}
func (f *fakeSandboxSource) ReadSessionFile(_ context.Context, _ string, path string) ([]byte, error) {
f.readCalls = append(f.readCalls, path)
if f.readErr != nil {
return nil, f.readErr
}
if data, ok := f.contents[path]; ok {
return data, nil
}
return nil, stderrors.New("fake source: not found: " + path)
}
// fakeStore lets a test declare which (path, mtime) tuples the collector
// should treat as already recorded.
type fakeStore struct {
prev []types.MessageArtifact
err error
}
func (s *fakeStore) KnownArtifacts(_ context.Context, _ string) ([]types.MessageArtifact, error) {
return s.prev, s.err
}
// fakeFileService captures uploads and returns a deterministic provider URL.
// Only SaveBytes is exercised by the collector; the other methods are
// implemented to satisfy interfaces.FileService but panic on use so a
// regression that starts calling them doesn't fail silently.
type fakeFileService struct {
saved map[string][]byte
saveErr error
seq int
tenantIDs []uint64
}
func (f *fakeFileService) CheckConnectivity(_ context.Context) error { return nil }
func (f *fakeFileService) SaveFile(_ context.Context, _ *multipart.FileHeader, _ uint64, _ string) (string, error) {
panic("SaveFile should not be called by ArtifactCollector")
}
func (f *fakeFileService) SaveBytes(_ context.Context, data []byte, tenantID uint64, fileName string, _ bool) (string, error) {
if f.saveErr != nil {
return "", f.saveErr
}
if f.saved == nil {
f.saved = map[string][]byte{}
}
f.seq++
key := "fake://tenant-" + fileName
f.saved[key] = append([]byte(nil), data...)
f.tenantIDs = append(f.tenantIDs, tenantID)
return key, nil
}
func (f *fakeFileService) GetFile(_ context.Context, _ string) (io.ReadCloser, error) {
panic("GetFile should not be called by ArtifactCollector")
}
func (f *fakeFileService) GetFileURL(_ context.Context, _ string) (string, error) {
panic("GetFileURL should not be called by ArtifactCollector")
}
func (f *fakeFileService) DeleteFile(_ context.Context, _ string) error { return nil }
func (f *fakeFileService) CopyFile(_ context.Context, _ string, _ uint64, _ string) (string, error) {
panic("CopyFile should not be called by ArtifactCollector")
}
// resourceRefFileService is a fileService whose SaveBytes returns a valid
// resource:// reference, so the collector's binding path is exercised.
type resourceRefFileService struct {
fakeFileService
handle string
}
func (f *resourceRefFileService) SaveBytes(_ context.Context, data []byte, tenantID uint64, fileName string, _ bool) (string, error) {
if f.saved == nil {
f.saved = map[string][]byte{}
}
f.seq++
ref := types.BuildResourcePath(f.handle)
f.saved[ref] = append([]byte(nil), data...)
f.tenantIDs = append(f.tenantIDs, tenantID)
return ref, nil
}
// bindCall records one Bind invocation for assertions.
type bindCall struct {
ref string
ownerType string
ownerID string
relation string
}
// fakeCatalog captures Bind calls; every other ResourceCatalog method is a
// no-op stub because the collector only ever calls Bind.
type fakeCatalog struct {
binds []bindCall
bindErr error
// Release scripting, used by the deletion-guard tests. releaseRemaining
// maps a reference to the binding count left after the release; a missing
// entry answers -1 ("not a catalog handle").
releaseRemaining map[string]int64
releaseErr error
releases []string
}
func (c *fakeCatalog) Register(context.Context, uint64, string, interfaces.ResourceRegistration) (string, error) {
return "", nil
}
func (c *fakeCatalog) Resolve(context.Context, string) (*types.StoredResource, error) {
return nil, nil
}
func (c *fakeCatalog) ResolvePath(_ context.Context, v string) (string, *types.StoredResource, error) {
return v, nil, nil
}
func (c *fakeCatalog) Bind(_ context.Context, ref, ownerType, ownerID, relation string) error {
c.binds = append(c.binds, bindCall{ref, ownerType, ownerID, relation})
return c.bindErr
}
func (c *fakeCatalog) MarkDeleted(context.Context, string) error { return nil }
func (c *fakeCatalog) Release(_ context.Context, ref, ownerType, ownerID string) (int64, error) {
c.releases = append(c.releases, ref+"|"+ownerType+"|"+ownerID)
if c.releaseErr != nil {
return -1, c.releaseErr
}
if remaining, ok := c.releaseRemaining[ref]; ok {
return remaining, nil
}
return -1, nil
}
func (c *fakeCatalog) CreateAccessGrant(context.Context, string, time.Duration) (string, error) {
return "", nil
}
func (c *fakeCatalog) ResolveAccessGrant(context.Context, string) (*types.StoredResource, error) {
return nil, nil
}
// -----------------------------------------------------------------------------
// Tests
// -----------------------------------------------------------------------------
func newTestCollector(src *fakeSandboxSource, store *fakeStore, fs *fakeFileService, max int64) *ArtifactCollector {
// catalog is nil here: these tests use a fakeFileService that returns raw
// "fake://" paths, so no resource binding is attempted. Binding behaviour
// is covered separately in TestArtifactCollector_BindsResourceToMessage.
return NewArtifactCollector(src, fs, store, nil, ArtifactCollectorConfig{MaxFileBytes: max})
}
func TestArtifactCollector_CollectsNewFiles(t *testing.T) {
ctx := context.Background()
src := &fakeSandboxSource{
entries: map[string][]sandbox.RemoteDirEntry{
"sess-1": {
{Name: "report.pptx", Path: "/workspace/output/report.pptx", Type: sandbox.RemoteEntryFile, Size: 4, ModTime: mustParseTime("2026-07-10T10:20:33Z")},
{Name: "summary.txt", Path: "/workspace/output/summary.txt", Type: sandbox.RemoteEntryFile, Size: 3, ModTime: mustParseTime("2026-07-10T10:20:34Z")},
},
},
contents: map[string][]byte{
"/workspace/output/report.pptx": []byte("PPTX"),
"/workspace/output/summary.txt": []byte("hey"),
},
}
store := &fakeStore{}
fs := &fakeFileService{}
c := newTestCollector(src, store, fs, 1<<20)
got, err := c.Collect(ctx, "sess-1", "msg-1", 42, "/workspace/output")
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
if len(got) != 2 {
t.Fatalf("Collect() len = %d, want 2 (%+v)", len(got), got)
}
// Files must be uploaded with the tenant ID passed by the caller.
for _, tID := range fs.tenantIDs {
if tID != 42 {
t.Fatalf("SaveBytes tenant = %d, want 42", tID)
}
}
// Both files must have populated URL + FileType + FileSize.
for _, art := range got {
if art.URL == "" {
t.Fatalf("artifact URL empty: %+v", art)
}
if art.FileType == "" {
t.Fatalf("artifact FileType empty: %+v", art)
}
if art.FileSize == 0 {
t.Fatalf("artifact FileSize zero: %+v", art)
}
}
}
func TestArtifactCollector_NotifyFiresBeforeUpload(t *testing.T) {
ctx := context.Background()
src := &fakeSandboxSource{
entries: map[string][]sandbox.RemoteDirEntry{
"sess-1": {
{
Name: "a.html", Path: "/workspace/output/a.html",
Type: sandbox.RemoteEntryFile, Size: 2,
ModTime: mustParseTime("2026-07-10T10:20:33Z"),
},
{
Name: "b.csv", Path: "/workspace/output/b.csv",
Type: sandbox.RemoteEntryFile, Size: 2,
ModTime: mustParseTime("2026-07-10T10:20:34Z"),
},
},
},
contents: map[string][]byte{
"/workspace/output/a.html": []byte("ok"),
"/workspace/output/b.csv": []byte("x,"),
},
}
c := newTestCollector(src, &fakeStore{}, &fakeFileService{}, 1<<20)
var notified int
got, err := c.CollectWithNotify(ctx, "sess-1", "msg-1", 42, "/workspace/output", func(n int) {
if src.readCalls != nil {
t.Fatalf("notify ran after ReadSessionFile: %v", src.readCalls)
}
notified = n
})
if err != nil {
t.Fatalf("CollectWithNotify() error = %v", err)
}
if notified != 2 {
t.Fatalf("notify count = %d, want 2", notified)
}
if len(got) != 2 {
t.Fatalf("CollectWithNotify() len = %d, want 2", len(got))
}
}
func TestArtifactCollector_SkipsAlreadyKnown(t *testing.T) {
ctx := context.Background()
mod, _ := time.Parse(time.RFC3339, "2026-07-10T10:20:33Z")
src := &fakeSandboxSource{
entries: map[string][]sandbox.RemoteDirEntry{
"sess-1": {
{Name: "report.pptx", Path: "/workspace/output/report.pptx", Type: sandbox.RemoteEntryFile, Size: 4, ModTime: mustParseTime("2026-07-10T10:20:33Z")},
},
},
contents: map[string][]byte{
"/workspace/output/report.pptx": []byte("PPTX"),
},
}
store := &fakeStore{prev: []types.MessageArtifact{
{SourcePath: "/workspace/output/report.pptx", ModTime: mod},
}}
fs := &fakeFileService{}
c := newTestCollector(src, store, fs, 1<<20)
got, err := c.Collect(ctx, "sess-1", "msg-1", 42, "/workspace/output")
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
if len(got) != 0 {
t.Fatalf("Collect() len = %d, want 0 (dedupe should have kicked in)", len(got))
}
if len(fs.saved) != 0 {
t.Fatalf("SaveBytes should not have been called; saved=%v", fs.saved)
}
if len(src.readCalls) != 0 {
t.Fatalf("ReadSessionFile should not have been called; readCalls=%v", src.readCalls)
}
}
func TestArtifactCollector_ReattachesOnMtimeChange(t *testing.T) {
ctx := context.Background()
oldMod, _ := time.Parse(time.RFC3339, "2026-07-10T10:20:33Z")
src := &fakeSandboxSource{
entries: map[string][]sandbox.RemoteDirEntry{
"sess-1": {
// Same path as the known set, but a *newer* mtime — must be re-attached.
{Name: "report.pptx", Path: "/workspace/output/report.pptx", Type: sandbox.RemoteEntryFile, Size: 4, ModTime: mustParseTime("2026-07-10T10:21:00Z")},
},
},
contents: map[string][]byte{
"/workspace/output/report.pptx": []byte("PPTX"),
},
}
store := &fakeStore{prev: []types.MessageArtifact{
{SourcePath: "/workspace/output/report.pptx", ModTime: oldMod},
}}
fs := &fakeFileService{}
c := newTestCollector(src, store, fs, 1<<20)
got, err := c.Collect(ctx, "sess-1", "msg-1", 42, "/workspace/output")
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
if len(got) != 1 {
t.Fatalf("Collect() len = %d, want 1 (mtime-change should re-attach)", len(got))
}
if len(fs.saved) != 1 {
t.Fatalf("SaveBytes calls = %d, want 1", len(fs.saved))
}
}
func TestArtifactCollector_SkipsOversize(t *testing.T) {
ctx := context.Background()
src := &fakeSandboxSource{
entries: map[string][]sandbox.RemoteDirEntry{
"sess-1": {
{Name: "huge.bin", Path: "/workspace/output/huge.bin", Type: sandbox.RemoteEntryFile, Size: 1024, ModTime: mustParseTime("2026-07-10T10:20:33Z")},
{Name: "ok.txt", Path: "/workspace/output/ok.txt", Type: sandbox.RemoteEntryFile, Size: 3, ModTime: mustParseTime("2026-07-10T10:20:34Z")},
},
},
contents: map[string][]byte{
"/workspace/output/huge.bin": bytesN(2048),
"/workspace/output/ok.txt": []byte("hey"),
},
}
fs := &fakeFileService{}
c := newTestCollector(src, &fakeStore{}, fs, 100)
got, err := c.Collect(ctx, "sess-1", "msg-1", 42, "/workspace/output")
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
if len(got) != 1 {
t.Fatalf("Collect() len = %d, want 1 (oversize file must be skipped)", len(got))
}
if got[0].FileName != "ok.txt" {
t.Fatalf("Collect() kept the wrong file: %+v", got[0])
}
}
func TestArtifactCollector_SkipsOversizeAfterRead(t *testing.T) {
// Envd can report a stale size while the file grows; the collector must
// enforce the cap against the actual bytes too.
ctx := context.Background()
src := &fakeSandboxSource{
entries: map[string][]sandbox.RemoteDirEntry{
"sess-1": {
{Name: "lying.bin", Path: "/workspace/output/lying.bin", Type: sandbox.RemoteEntryFile, Size: 4, ModTime: mustParseTime("2026-07-10T10:20:33Z")},
},
},
contents: map[string][]byte{
"/workspace/output/lying.bin": bytesN(2048),
},
}
fs := &fakeFileService{}
c := newTestCollector(src, &fakeStore{}, fs, 100)
got, err := c.Collect(ctx, "sess-1", "msg-1", 42, "/workspace/output")
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
if len(got) != 0 {
t.Fatalf("Collect() len = %d, want 0", len(got))
}
}
func TestArtifactCollector_EmptyWhenNoEntries(t *testing.T) {
ctx := context.Background()
src := &fakeSandboxSource{}
c := newTestCollector(src, &fakeStore{}, &fakeFileService{}, 1<<20)
got, err := c.Collect(ctx, "sess-1", "msg-1", 42, "/workspace/output")
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
if len(got) != 0 {
t.Fatalf("Collect() len = %d, want 0", len(got))
}
}
func TestArtifactCollector_EmptyWhenNoSessionID(t *testing.T) {
// The collector treats empty session ID as "nothing to do" — the design
// spec forbids sandbox lookups for chat-only sessions.
ctx := context.Background()
src := &fakeSandboxSource{}
c := newTestCollector(src, &fakeStore{}, &fakeFileService{}, 1<<20)
got, err := c.Collect(ctx, "", "msg-1", 42, "/workspace/output")
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
if len(got) != 0 {
t.Fatalf("Collect() len = %d, want 0", len(got))
}
if src.listCalls != 0 {
t.Fatalf("expected 0 list calls for empty session id, got %d", src.listCalls)
}
}
func TestArtifactCollector_ListErrorDegrades(t *testing.T) {
ctx := context.Background()
src := &fakeSandboxSource{listErr: stderrors.New("envd timeout")}
c := newTestCollector(src, &fakeStore{}, &fakeFileService{}, 1<<20)
got, err := c.Collect(ctx, "sess-1", "msg-1", 42, "/workspace/output")
if err != nil {
t.Fatalf("Collect() error = %v (should degrade gracefully)", err)
}
if len(got) != 0 {
t.Fatalf("Collect() len = %d, want 0", len(got))
}
}
func TestArtifactCollector_UploadFailureIsPerFile(t *testing.T) {
// A single failed upload must NOT abort the whole batch — the other
// files should still be persisted, matching the best-effort contract.
ctx := context.Background()
src := &fakeSandboxSource{
entries: map[string][]sandbox.RemoteDirEntry{
"sess-1": {
{Name: "a.txt", Path: "/workspace/output/a.txt", Type: sandbox.RemoteEntryFile, Size: 1, ModTime: mustParseTime("2026-07-10T10:20:33Z")},
{Name: "b.txt", Path: "/workspace/output/b.txt", Type: sandbox.RemoteEntryFile, Size: 1, ModTime: mustParseTime("2026-07-10T10:20:34Z")},
},
},
contents: map[string][]byte{
"/workspace/output/a.txt": []byte("a"),
"/workspace/output/b.txt": []byte("b"),
},
}
// FileService fails on every call — both files should be skipped without
// propagating an error.
fs := &fakeFileService{saveErr: stderrors.New("s3 dead")}
c := newTestCollector(src, &fakeStore{}, fs, 1<<20)
got, err := c.Collect(ctx, "sess-1", "msg-1", 42, "/workspace/output")
if err != nil {
t.Fatalf("Collect() error = %v (want best-effort)", err)
}
if len(got) != 0 {
t.Fatalf("Collect() len = %d, want 0 (all uploads failed)", len(got))
}
}
func TestArtifactCollector_FiltersDirectories(t *testing.T) {
ctx := context.Background()
src := &fakeSandboxSource{
entries: map[string][]sandbox.RemoteDirEntry{
"sess-1": {
// The production ListSessionFiles never yields dirs, but the
// collector must defensively skip anything with Type=="dir"
// so alternate SandboxArtifactSource impls stay safe.
{Name: "sub", Path: "/workspace/output/sub", Type: sandbox.RemoteEntryDir, Size: 0, ModTime: mustParseTime("2026-07-10T10:20:33Z")},
{Name: "a.txt", Path: "/workspace/output/a.txt", Type: sandbox.RemoteEntryFile, Size: 1, ModTime: mustParseTime("2026-07-10T10:20:34Z")},
},
},
contents: map[string][]byte{
"/workspace/output/a.txt": []byte("a"),
},
}
c := newTestCollector(src, &fakeStore{}, &fakeFileService{}, 1<<20)
got, err := c.Collect(ctx, "sess-1", "msg-1", 42, "/workspace/output")
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
if len(got) != 1 {
t.Fatalf("Collect() len = %d, want 1 (dir must be skipped)", len(got))
}
if !strings.HasSuffix(got[0].SourcePath, "a.txt") {
t.Fatalf("wrong file kept: %+v", got[0])
}
}
func TestArtifactCollector_BindsResourceToMessage(t *testing.T) {
ctx := context.Background()
src := &fakeSandboxSource{
entries: map[string][]sandbox.RemoteDirEntry{
"sess-1": {
{Name: "report.pptx", Path: "/workspace/output/report.pptx", Type: sandbox.RemoteEntryFile, Size: 4, ModTime: mustParseTime("2026-07-10T10:20:33Z")},
},
},
contents: map[string][]byte{
"/workspace/output/report.pptx": []byte("PPTX"),
},
}
// 22-char handle so BuildResourcePath yields a valid resource:// ref.
fs := &resourceRefFileService{handle: "abcdefghijklmnopqrstuv"}
cat := &fakeCatalog{}
c := NewArtifactCollector(src, fs, &fakeStore{}, cat, ArtifactCollectorConfig{MaxFileBytes: 1 << 20})
got, err := c.Collect(ctx, "sess-1", "msg-42", 7, "/workspace/output")
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
if len(got) != 1 {
t.Fatalf("Collect() len = %d, want 1", len(got))
}
if len(cat.binds) != 1 {
t.Fatalf("Bind calls = %d, want 1 (%+v)", len(cat.binds), cat.binds)
}
b := cat.binds[0]
if b.ownerType != "message" || b.ownerID != "msg-42" || b.relation != "artifact" {
t.Fatalf("unexpected binding: %+v", b)
}
if _, ok := types.ParseResourcePath(b.ref); !ok {
t.Fatalf("binding ref is not a resource reference: %q", b.ref)
}
}
func TestArtifactCollector_BindFailureDoesNotDropArtifact(t *testing.T) {
ctx := context.Background()
src := &fakeSandboxSource{
entries: map[string][]sandbox.RemoteDirEntry{
"sess-1": {
{Name: "a.txt", Path: "/workspace/output/a.txt", Type: sandbox.RemoteEntryFile, Size: 1, ModTime: mustParseTime("2026-07-10T10:20:33Z")},
},
},
contents: map[string][]byte{"/workspace/output/a.txt": []byte("a")},
}
fs := &resourceRefFileService{handle: "abcdefghijklmnopqrstuv"}
cat := &fakeCatalog{bindErr: stderrors.New("db down")}
c := NewArtifactCollector(src, fs, &fakeStore{}, cat, ArtifactCollectorConfig{MaxFileBytes: 1 << 20})
got, err := c.Collect(ctx, "sess-1", "msg-1", 7, "/workspace/output")
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
if len(got) != 1 {
t.Fatalf("Collect() len = %d, want 1 (bind failure must not drop the artifact)", len(got))
}
}
// mustParseTime parses an RFC3339 timestamp for test data. It fails the
// program on error because test fixtures should never contain malformed
// timestamps.
func mustParseTime(raw string) time.Time {
t, err := time.Parse(time.RFC3339Nano, raw)
if err != nil {
t, err = time.Parse(time.RFC3339, raw)
}
if err != nil {
panic("mustParseTime: " + err.Error())
}
return t
}
// bytesN returns a byte slice of length n filled with 'x'. Kept local to
// avoid pulling in a dependency; matches "generate N bytes" test helpers
// elsewhere in the codebase.
func bytesN(n int) []byte {
b := make([]byte, n)
for i := range b {
b[i] = 'x'
}
return b
}