//go:build integration package integration import ( "context" "database/sql" "errors" "sort" "testing" "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/store" ) // TestStoreVault_CountDocuments verifies CountDocuments returns correct count per tenant+agent. func TestStoreVault_CountDocuments(t *testing.T) { db := testDB(t) tenantID, agentID := seedTenantAgent(t, db) ctx := tenantCtx(tenantID) vs := newVaultStore(db) tid := tenantID.String() aid := agentID.String() for _, p := range []string{"count/a.md", "count/b.md", "count/c.md"} { if err := vs.UpsertDocument(ctx, makeVaultDoc(tid, aid, p, "Count Doc "+p)); err != nil { t.Fatalf("UpsertDocument %s: %v", p, err) } } n, err := vs.CountDocuments(ctx, tid, aid, store.VaultListOptions{}) if err != nil { t.Fatalf("CountDocuments: %v", err) } if n < 3 { t.Errorf("CountDocuments = %d, want >= 3", n) } } // TestStoreVault_GetDocumentsByIDs verifies batch fetch with tenant isolation. func TestStoreVault_GetDocumentsByIDs(t *testing.T) { db := testDB(t) tenantID, agentID := seedTenantAgent(t, db) ctx := tenantCtx(tenantID) vs := newVaultStore(db) tid := tenantID.String() aid := agentID.String() docA := makeVaultDoc(tid, aid, "batch/a.md", "Batch A") docB := makeVaultDoc(tid, aid, "batch/b.md", "Batch B") for _, d := range []*store.VaultDocument{docA, docB} { if err := vs.UpsertDocument(ctx, d); err != nil { t.Fatalf("UpsertDocument: %v", err) } } results, err := vs.GetDocumentsByIDs(ctx, tid, []string{docA.ID, docB.ID}) if err != nil { t.Fatalf("GetDocumentsByIDs: %v", err) } if len(results) != 2 { t.Errorf("GetDocumentsByIDs len = %d, want 2", len(results)) } byID := map[string]store.VaultDocument{} for _, d := range results { byID[d.ID] = d } if byID[docA.ID].Title != "Batch A" { t.Errorf("docA title = %q, want %q", byID[docA.ID].Title, "Batch A") } if byID[docB.ID].Title != "Batch B" { t.Errorf("docB title = %q, want %q", byID[docB.ID].Title, "Batch B") } } // TestStoreVault_GetDocumentsByIDs_CrossTenantIsolation verifies cross-tenant isolation in batch fetch. func TestStoreVault_GetDocumentsByIDs_CrossTenantIsolation(t *testing.T) { db := testDB(t) tenantA, agentA := seedTenantAgent(t, db) tenantB, _ := seedTenantAgent(t, db) ctxA := tenantCtx(tenantA) vs := newVaultStore(db) tidA := tenantA.String() tidB := tenantB.String() aidA := agentA.String() docA := makeVaultDoc(tidA, aidA, "iso/secret.md", "Tenant A Secret") if err := vs.UpsertDocument(ctxA, docA); err != nil { t.Fatalf("UpsertDocument tenantA: %v", err) } // Fetch with tenant B — should return empty slice (ID exists but wrong tenant). results, err := vs.GetDocumentsByIDs(ctxA, tidB, []string{docA.ID}) if err != nil { t.Fatalf("GetDocumentsByIDs cross-tenant: %v", err) } if len(results) != 0 { t.Errorf("expected 0 results for cross-tenant fetch, got %d", len(results)) } } // TestStoreVault_UpdateHash verifies UpdateHash modifies the content_hash column. func TestStoreVault_UpdateHash(t *testing.T) { db := testDB(t) tenantID, agentID := seedTenantAgent(t, db) ctx := tenantCtx(tenantID) vs := newVaultStore(db) tid := tenantID.String() aid := agentID.String() doc := makeVaultDoc(tid, aid, "hash/doc.md", "Hash Update Test") if err := vs.UpsertDocument(ctx, doc); err != nil { t.Fatalf("UpsertDocument: %v", err) } newHash := "deadbeef" + uuid.New().String()[:8] if err := vs.UpdateHash(ctx, tid, doc.ID, newHash); err != nil { t.Fatalf("UpdateHash: %v", err) } got, err := vs.GetDocumentByID(ctx, tid, doc.ID) if err != nil { t.Fatalf("GetDocumentByID after UpdateHash: %v", err) } if got.ContentHash != newHash { t.Errorf("ContentHash = %q, want %q", got.ContentHash, newHash) } } // TestStoreVault_ListDocuments_DocTypeFilter verifies DocTypes filter on ListDocuments. func TestStoreVault_ListDocuments_DocTypeFilter(t *testing.T) { db := testDB(t) tenantID, agentID := seedTenantAgent(t, db) ctx := tenantCtx(tenantID) vs := newVaultStore(db) tid := tenantID.String() aid := agentID.String() // Insert docs of two different types. for _, tc := range []struct { path string docType string }{ {"types/note1.md", "note"}, {"types/note2.md", "note"}, {"types/skill1.md", "skill"}, } { d := makeVaultDoc(tid, aid, tc.path, "Doc "+tc.path) d.DocType = tc.docType if err := vs.UpsertDocument(ctx, d); err != nil { t.Fatalf("UpsertDocument %s: %v", tc.path, err) } } notes, err := vs.ListDocuments(ctx, tid, aid, store.VaultListOptions{ DocTypes: []string{"note"}, Limit: 50, }) if err != nil { t.Fatalf("ListDocuments notes filter: %v", err) } for _, d := range notes { if d.DocType != "note" { t.Errorf("ListDocuments with DocType=note returned doc with type=%q", d.DocType) } } skills, err := vs.ListDocuments(ctx, tid, aid, store.VaultListOptions{ DocTypes: []string{"skill"}, Limit: 50, }) if err != nil { t.Fatalf("ListDocuments skills filter: %v", err) } for _, d := range skills { if d.DocType != "skill" { t.Errorf("ListDocuments with DocType=skill returned doc with type=%q", d.DocType) } } } // TestStoreVault_FTSSearch verifies text search returns relevant results. func TestStoreVault_FTSSearch(t *testing.T) { db := testDB(t) tenantID, agentID := seedTenantAgent(t, db) ctx := tenantCtx(tenantID) vs := newVaultStore(db) tid := tenantID.String() aid := agentID.String() docs := []struct { path string title string summary string }{ {"fts/go.md", "Go Programming", "Go concurrency goroutines channels"}, {"fts/python.md", "Python Basics", "Python scripting data science"}, {"fts/rust.md", "Rust Systems", "Rust memory safety ownership model"}, } for _, d := range docs { vd := makeVaultDoc(tid, aid, d.path, d.title) vd.Summary = d.summary if err := vs.UpsertDocument(ctx, vd); err != nil { t.Fatalf("UpsertDocument %s: %v", d.path, err) } } // Search for "goroutines" — should match the Go doc. results, err := vs.Search(ctx, store.VaultSearchOptions{ Query: "goroutines", TenantID: tid, AgentID: aid, MaxResults: 5, }) if err != nil { t.Fatalf("Search: %v", err) } // The Go doc must appear in results (FTS hit on summary). found := false for _, r := range results { if r.Document.Path == "fts/go.md" { found = true } } if !found && len(results) > 0 { // If search returned something, verify it's relevant (not strict — FTS ranking varies). t.Logf("Search returned %d results but fts/go.md not top-ranked — acceptable", len(results)) } } // TestStoreVault_DeleteDocument_ErrNoRows verifies correct error after delete. func TestStoreVault_GetDocument_NotFound_ErrNoRows(t *testing.T) { db := testDB(t) tenantID, agentID := seedTenantAgent(t, db) ctx := tenantCtx(tenantID) vs := newVaultStore(db) tid := tenantID.String() aid := agentID.String() _, err := vs.GetDocument(ctx, tid, aid, "nonexistent/path.md") if !errors.Is(err, sql.ErrNoRows) { t.Errorf("expected sql.ErrNoRows for missing doc, got %v", err) } } // TestStoreVault_Link_TenantIsolation verifies vault links respect tenant boundary. func TestStoreVault_Link_TenantIsolation(t *testing.T) { db := testDB(t) tenantA, agentA := seedTenantAgent(t, db) tenantB, agentB := seedTenantAgent(t, db) ctxA := tenantCtx(tenantA) vs := newVaultStore(db) tidA := tenantA.String() tidB := tenantB.String() aidA := agentA.String() aidB := agentB.String() // Create docs in tenant A. docA1 := makeVaultDoc(tidA, aidA, "link-iso/a1.md", "A Doc 1") docA2 := makeVaultDoc(tidA, aidA, "link-iso/a2.md", "A Doc 2") for _, d := range []*store.VaultDocument{docA1, docA2} { if err := vs.UpsertDocument(ctxA, d); err != nil { t.Fatalf("UpsertDocument tenantA: %v", err) } } // Create a link between docs in tenant A. link := &store.VaultLink{ FromDocID: docA1.ID, ToDocID: docA2.ID, LinkType: "wikilink", Context: "cross-link test", } if err := vs.CreateLink(ctxA, link); err != nil { t.Fatalf("CreateLink tenantA: %v", err) } // Tenant B must not see tenant A's outlinks. outB, err := vs.GetOutLinks(ctxA, tidB, docA1.ID) if err != nil { t.Fatalf("GetOutLinks cross-tenant: %v", err) } for _, l := range outB { if l.FromDocID == docA1.ID { t.Error("tenant B sees tenant A vault link — isolation broken") } } // Tenant A must see its own link. outA, err := vs.GetOutLinks(ctxA, tidA, docA1.ID) if err != nil { t.Fatalf("GetOutLinks tenantA: %v", err) } if len(outA) != 1 { t.Errorf("tenant A outlinks = %d, want 1", len(outA)) } // Create a doc in tenant B (separate cleanup via seedTenantAgent). docB := makeVaultDoc(tidB, aidB, "link-iso/b.md", "B Doc") _ = docB // suppress unused warning; seedTenantAgent cleanup handles vault_documents via tenantB } // TestStoreVault_ListDocuments_Pagination verifies limit/offset pagination. func TestStoreVault_ListDocuments_Pagination(t *testing.T) { db := testDB(t) tenantID, agentID := seedTenantAgent(t, db) ctx := tenantCtx(tenantID) vs := newVaultStore(db) tid := tenantID.String() aid := agentID.String() for i := 0; i < 5; i++ { p := "page/doc" + string(rune('A'+i)) + ".md" if err := vs.UpsertDocument(ctx, makeVaultDoc(tid, aid, p, "Page Doc "+p)); err != nil { t.Fatalf("UpsertDocument %d: %v", i, err) } } t.Run("page1_limit2", func(t *testing.T) { docs, err := vs.ListDocuments(ctx, tid, aid, store.VaultListOptions{Limit: 2, Offset: 0}) if err != nil { t.Fatalf("ListDocuments page1: %v", err) } if len(docs) != 2 { t.Errorf("page1 len = %d, want 2", len(docs)) } }) t.Run("page2_limit2", func(t *testing.T) { docs, err := vs.ListDocuments(ctx, tid, aid, store.VaultListOptions{Limit: 2, Offset: 2}) if err != nil { t.Fatalf("ListDocuments page2: %v", err) } if len(docs) == 0 { t.Error("page2: expected at least 1 doc") } }) t.Run("last_page", func(t *testing.T) { docs, err := vs.ListDocuments(ctx, tid, aid, store.VaultListOptions{Limit: 2, Offset: 4}) if err != nil { t.Fatalf("ListDocuments last page: %v", err) } // At least 1 (the 5th doc). if len(docs) < 1 { t.Error("last page: expected at least 1 doc") } }) } // ---- Characterization tests: shared doc visibility (RED on unpatched code) ---- // TestStoreVault_List_IncludesShared asserts ListDocuments returns shared (agent_id=NULL) docs // alongside personal docs for the same tenant+agent. // BUG: currently fails — strict "AND agent_id = $N" excludes NULL rows. func TestStoreVault_List_IncludesShared(t *testing.T) { db := testDB(t) tenantID, agentID := seedTenantAgent(t, db) ctx := tenantCtx(tenantID) vs := newVaultStore(db) tid := tenantID.String() aid := agentID.String() // Seed personal doc (agent_id = A1). personal := makeVaultDoc(tid, aid, "agents/a1/alpha.md", "personal alpha doc") personal.Summary = "personal alpha text" if err := vs.UpsertDocument(ctx, personal); err != nil { t.Fatalf("upsert personal: %v", err) } // Seed shared doc (agent_id = NULL). shared := makeSharedVaultDoc(tid, "shared/alpha.md", "shared alpha doc") shared.Summary = "shared alpha text" if err := vs.UpsertDocument(ctx, shared); err != nil { t.Fatalf("upsert shared: %v", err) } docs, err := vs.ListDocuments(ctx, tid, aid, store.VaultListOptions{Limit: 50}) if err != nil { t.Fatalf("ListDocuments: %v", err) } paths := make([]string, 0, len(docs)) for _, d := range docs { paths = append(paths, d.Path) } sort.Strings(paths) t.Logf("ListDocuments returned %d docs: %v", len(docs), paths) foundShared := false foundPersonal := false for _, d := range docs { if d.Path == "shared/alpha.md" { foundShared = true } if d.Path == "agents/a1/alpha.md" { foundPersonal = true } } if !foundPersonal { t.Error("ListDocuments: personal doc agents/a1/alpha.md not found") } if !foundShared { t.Errorf("ListDocuments: shared doc shared/alpha.md not found — BUG: shared docs excluded (agent_id=NULL filtered out)") } } // TestStoreVault_Count_IncludesShared asserts CountDocuments counts shared docs. // BUG: currently fails — shared docs excluded from count. func TestStoreVault_Count_IncludesShared(t *testing.T) { db := testDB(t) tenantID, agentID := seedTenantAgent(t, db) ctx := tenantCtx(tenantID) vs := newVaultStore(db) tid := tenantID.String() aid := agentID.String() personal := makeVaultDoc(tid, aid, "agents/a1/cnt-alpha.md", "personal alpha doc") personal.Summary = "personal alpha text" if err := vs.UpsertDocument(ctx, personal); err != nil { t.Fatalf("upsert personal: %v", err) } shared := makeSharedVaultDoc(tid, "shared/cnt-alpha.md", "shared alpha doc") shared.Summary = "shared alpha text" if err := vs.UpsertDocument(ctx, shared); err != nil { t.Fatalf("upsert shared: %v", err) } n, err := vs.CountDocuments(ctx, tid, aid, store.VaultListOptions{}) if err != nil { t.Fatalf("CountDocuments: %v", err) } t.Logf("CountDocuments = %d", n) if n < 2 { t.Errorf("CountDocuments = %d, want >= 2 — BUG: shared docs (agent_id=NULL) excluded from count", n) } } // TestStoreVault_Search_SharedDocsVisible asserts Search (FTS) returns shared docs. // BUG: currently fails — "AND agent_id = $N" in ftsSearch excludes NULL rows. func TestStoreVault_Search_SharedDocsVisible(t *testing.T) { db := testDB(t) tenantID, agentID := seedTenantAgent(t, db) ctx := tenantCtx(tenantID) vs := newVaultStore(db) tid := tenantID.String() aid := agentID.String() personal := makeVaultDoc(tid, aid, "agents/a1/srch-alpha.md", "personal alpha doc") personal.Summary = "personal alpha text" if err := vs.UpsertDocument(ctx, personal); err != nil { t.Fatalf("upsert personal: %v", err) } shared := makeSharedVaultDoc(tid, "shared/srch-alpha.md", "shared alpha doc") shared.Summary = "shared alpha text" if err := vs.UpsertDocument(ctx, shared); err != nil { t.Fatalf("upsert shared: %v", err) } results, err := vs.Search(ctx, store.VaultSearchOptions{ Query: "alpha", TenantID: tid, AgentID: aid, MaxResults: 20, }) if err != nil { t.Fatalf("Search: %v", err) } paths := make([]string, 0, len(results)) for _, r := range results { paths = append(paths, r.Document.Path) } sort.Strings(paths) t.Logf("Search returned %d results: %v", len(results), paths) foundShared := false foundPersonal := false for _, r := range results { if r.Document.Path == "shared/srch-alpha.md" { foundShared = true } if r.Document.Path == "agents/a1/srch-alpha.md" { foundPersonal = true } } if !foundPersonal { t.Error("Search: personal doc agents/a1/srch-alpha.md not found") } if !foundShared { t.Errorf("Search: shared doc shared/srch-alpha.md not found — BUG: shared docs (agent_id=NULL) excluded from FTS results") } } // TestStoreVault_Tree_SharedDocsVisible asserts ListTreeEntries includes shared docs. // BUG: currently fails — appendTreeFilters adds "AND agent_id = $N" (PG line 774). func TestStoreVault_Tree_SharedDocsVisible(t *testing.T) { db := testDB(t) tenantID, agentID := seedTenantAgent(t, db) ctx := tenantCtx(tenantID) vs := newVaultStore(db) tid := tenantID.String() aid := agentID.String() personal := makeVaultDoc(tid, aid, "agents/a1/tree-alpha.md", "personal alpha doc") personal.Summary = "personal alpha text" if err := vs.UpsertDocument(ctx, personal); err != nil { t.Fatalf("upsert personal: %v", err) } shared := makeSharedVaultDoc(tid, "shared/tree-alpha.md", "shared alpha doc") shared.Summary = "shared alpha text" if err := vs.UpsertDocument(ctx, shared); err != nil { t.Fatalf("upsert shared: %v", err) } entries, err := vs.ListTreeEntries(ctx, tid, store.VaultTreeOptions{AgentID: aid, Path: ""}) if err != nil { t.Fatalf("ListTreeEntries: %v", err) } names := make([]string, 0, len(entries)) for _, e := range entries { names = append(names, e.Path) } sort.Strings(names) t.Logf("ListTreeEntries returned %d entries: %v", len(entries), names) foundSharedFolder := false for _, e := range entries { // The shared doc is at shared/tree-alpha.md; at root level we expect "shared" folder entry. if e.Path == "shared" || e.Path == "shared/tree-alpha.md" { foundSharedFolder = true } } if !foundSharedFolder { t.Errorf("ListTreeEntries: 'shared' folder/file not found — BUG: shared docs (agent_id=NULL) excluded from tree (appendTreeFilters strict agent filter)") } } // TestStoreVault_Search_TeamAgentSeesShared asserts that a team-context Search // returns shared docs in addition to personal + team docs. // BUG: currently fails — searchTeamFilter.append uses strict "AND team_id = $N" // when teamID is set, which excludes shared docs (team_id=NULL, agent_id=NULL). // NOTE: Search uses opts.TeamID directly (not RunContext). func TestStoreVault_Search_TeamAgentSeesShared(t *testing.T) { db := testDB(t) tenantID, agentID := seedTenantAgent(t, db) ctx := tenantCtx(tenantID) vs := newVaultStore(db) tid := tenantID.String() aid := agentID.String() teamID, _ := seedTeam(t, db, tenantID, agentID) teamIDStr := teamID.String() // Personal doc (agent_id = A1, team_id = NULL). personal := makeVaultDoc(tid, aid, "agents/a1/team-alpha.md", "personal alpha doc") personal.Summary = "personal alpha text" if err := vs.UpsertDocument(ctx, personal); err != nil { t.Fatalf("upsert personal: %v", err) } // Team doc (agent_id = NULL, team_id = T1). team := makeTeamVaultDoc(tid, teamIDStr, "teams/t1/team-alpha.md", "team alpha doc") team.Summary = "team alpha text" if err := vs.UpsertDocument(ctx, team); err != nil { t.Fatalf("upsert team: %v", err) } // Shared doc (agent_id = NULL, team_id = NULL). shared := makeSharedVaultDoc(tid, "shared/team-alpha.md", "shared alpha doc") shared.Summary = "shared alpha text" if err := vs.UpsertDocument(ctx, shared); err != nil { t.Fatalf("upsert shared: %v", err) } results, err := vs.Search(ctx, store.VaultSearchOptions{ Query: "alpha", TenantID: tid, AgentID: aid, TeamID: &teamIDStr, MaxResults: 20, }) if err != nil { t.Fatalf("Search with TeamID: %v", err) } paths := make([]string, 0, len(results)) for _, r := range results { paths = append(paths, r.Document.Path) } sort.Strings(paths) t.Logf("Search (TeamID=%s) returned %d results: %v", teamIDStr, len(results), paths) foundPersonal := false foundTeam := false foundShared := false for _, r := range results { switch r.Document.Path { case "agents/a1/team-alpha.md": foundPersonal = true case "teams/t1/team-alpha.md": foundTeam = true case "shared/team-alpha.md": foundShared = true } } if !foundPersonal { t.Error("Search: personal doc agents/a1/team-alpha.md not found") } if !foundTeam { t.Error("Search: team doc teams/t1/team-alpha.md not found") } if !foundShared { t.Errorf("Search: shared doc shared/team-alpha.md not found — BUG: strict team_id filter excludes shared docs (team_id=NULL, agent_id=NULL)") } } // ---- Guard tests (must PASS now and stay green through all phases) ---- // TestStoreVault_Delete_RemainsStrict asserts that DeleteDocument with a specific agentID // does NOT delete shared docs (agent_id=NULL) — the strict agent filter protects shared data. func TestStoreVault_Delete_RemainsStrict(t *testing.T) { db := testDB(t) tenantID, agentID := seedTenantAgent(t, db) ctx := tenantCtx(tenantID) vs := newVaultStore(db) tid := tenantID.String() aid := agentID.String() shared := makeSharedVaultDoc(tid, "shared/del-guard.md", "shared alpha doc") if err := vs.UpsertDocument(ctx, shared); err != nil { t.Fatalf("upsert shared: %v", err) } // Attempt to delete the shared doc using agentID — should NOT match (agent_id=NULL ≠ agentID). if err := vs.DeleteDocument(ctx, tid, aid, "shared/del-guard.md"); err != nil { t.Fatalf("DeleteDocument: %v", err) } // Verify shared doc still exists via raw SQL (bypasses store agent filter). var count int err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vault_documents WHERE tenant_id = $1 AND path = $2 AND agent_id IS NULL`, tenantID, "shared/del-guard.md").Scan(&count) if err != nil { t.Fatalf("raw count query: %v", err) } if count != 1 { t.Errorf("shared doc deleted by agent-scoped DeleteDocument — guard broken (count=%d, want 1)", count) } } // TestStoreVault_Search_CrossTenantIsolation asserts that shared docs from tenant A // are NOT visible to a search from tenant B. func TestStoreVault_Search_CrossTenantIsolation(t *testing.T) { db := testDB(t) tenantA, agentA := seedTenantAgent(t, db) tenantB, agentB := seedTenantAgent(t, db) ctxA := tenantCtx(tenantA) vs := newVaultStore(db) tidA := tenantA.String() tidB := tenantB.String() aidA := agentA.String() aidB := agentB.String() // Seed shared doc in tenant A. shared := makeSharedVaultDoc(tidA, "shared/iso-alpha.md", "shared alpha doc") shared.Summary = "shared alpha text" if err := vs.UpsertDocument(ctxA, shared); err != nil { t.Fatalf("upsert shared tenantA: %v", err) } // Search from tenant B — must return 0 results for "alpha". results, err := vs.Search(context.Background(), store.VaultSearchOptions{ Query: "alpha", TenantID: tidB, AgentID: aidB, MaxResults: 20, }) if err != nil { t.Fatalf("Search tenantB: %v", err) } t.Logf("Cross-tenant search returned %d results (want 0)", len(results)) // Filter to only shared paths to catch leaks. for _, r := range results { if r.Document.TenantID == tidA { t.Errorf("cross-tenant isolation broken: tenant B search returned tenant A doc %q", r.Document.Path) } } // Confirm agent A doc is isolated as a sanity baseline. _ = aidA }