mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-09-10 04:19:48 +00:00
Add comprehensive integration tests for core v3 store APIs: - v3_session_store_ext_test.go: pagination, isolation, rich-list - v3_agent_store_ext_test.go: context files, profiles, overrides, types - v3_agent_links_store_test.go: delegation links CRUD - v3_cron_store_ext_test.go: cron CRUD, enable toggle, run log - v3_vault_store_ext_test.go: vault docs CRUD/search/links - v3_memory_store_ext_test.go: memory BM25 search/isolation
369 lines
10 KiB
Go
369 lines
10 KiB
Go
//go:build integration
|
|
|
|
package integration
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
|
|
)
|
|
|
|
// --- Context files (agent-level) ---
|
|
|
|
func TestStoreAgent_ContextFiles_SetGetList(t *testing.T) {
|
|
db := testDB(t)
|
|
pg.InitSqlx(db)
|
|
tenantID, agentID := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
as := pg.NewPGAgentStore(db)
|
|
|
|
t.Run("set_and_list", func(t *testing.T) {
|
|
for _, f := range []struct{ name, content string }{
|
|
{"SOUL.md", "You are a helpful agent."},
|
|
{"IDENTITY.md", "Name: TestAgent"},
|
|
} {
|
|
if err := as.SetAgentContextFile(ctx, agentID, f.name, f.content); err != nil {
|
|
t.Fatalf("SetAgentContextFile %q: %v", f.name, err)
|
|
}
|
|
}
|
|
|
|
files, err := as.GetAgentContextFiles(ctx, agentID)
|
|
if err != nil {
|
|
t.Fatalf("GetAgentContextFiles: %v", err)
|
|
}
|
|
byName := map[string]string{}
|
|
for _, f := range files {
|
|
byName[f.FileName] = f.Content
|
|
}
|
|
if byName["SOUL.md"] != "You are a helpful agent." {
|
|
t.Errorf("SOUL.md content = %q", byName["SOUL.md"])
|
|
}
|
|
if byName["IDENTITY.md"] != "Name: TestAgent" {
|
|
t.Errorf("IDENTITY.md content = %q", byName["IDENTITY.md"])
|
|
}
|
|
})
|
|
|
|
t.Run("upsert_overwrite", func(t *testing.T) {
|
|
if err := as.SetAgentContextFile(ctx, agentID, "SOUL.md", "Updated soul content"); err != nil {
|
|
t.Fatalf("SetAgentContextFile overwrite: %v", err)
|
|
}
|
|
files, err := as.GetAgentContextFiles(ctx, agentID)
|
|
if err != nil {
|
|
t.Fatalf("GetAgentContextFiles after overwrite: %v", err)
|
|
}
|
|
for _, f := range files {
|
|
if f.FileName == "SOUL.md" && f.Content != "Updated soul content" {
|
|
t.Errorf("SOUL.md not updated: %q", f.Content)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestStoreAgent_ContextFiles_TenantIsolation(t *testing.T) {
|
|
db := testDB(t)
|
|
pg.InitSqlx(db)
|
|
tenantA, agentA := seedTenantAgent(t, db)
|
|
tenantB, agentB := seedTenantAgent(t, db)
|
|
ctxA := tenantCtx(tenantA)
|
|
ctxB := tenantCtx(tenantB)
|
|
as := pg.NewPGAgentStore(db)
|
|
|
|
// Set a file in tenant A.
|
|
if err := as.SetAgentContextFile(ctxA, agentA, "SECRET.md", "tenant A secret"); err != nil {
|
|
t.Fatalf("SetAgentContextFile A: %v", err)
|
|
}
|
|
|
|
// Tenant B listing its agent should see zero files from tenant A.
|
|
filesB, err := as.GetAgentContextFiles(ctxB, agentB)
|
|
if err != nil {
|
|
t.Fatalf("GetAgentContextFiles B: %v", err)
|
|
}
|
|
for _, f := range filesB {
|
|
if f.FileName == "SECRET.md" {
|
|
t.Error("tenant B sees tenant A's context file — isolation broken")
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Per-user context files ---
|
|
|
|
func TestStoreAgent_UserContextFiles_SetGetDelete(t *testing.T) {
|
|
db := testDB(t)
|
|
pg.InitSqlx(db)
|
|
tenantID, agentID := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
as := pg.NewPGAgentStore(db)
|
|
userID := "ucf-user-" + uuid.New().String()[:8]
|
|
|
|
t.Run("set_and_get", func(t *testing.T) {
|
|
if err := as.SetUserContextFile(ctx, agentID, userID, "USER.md", "User profile content"); err != nil {
|
|
t.Fatalf("SetUserContextFile: %v", err)
|
|
}
|
|
files, err := as.GetUserContextFiles(ctx, agentID, userID)
|
|
if err != nil {
|
|
t.Fatalf("GetUserContextFiles: %v", err)
|
|
}
|
|
if len(files) == 0 {
|
|
t.Fatal("expected at least 1 user context file")
|
|
}
|
|
found := false
|
|
for _, f := range files {
|
|
if f.FileName == "USER.md" && f.Content == "User profile content" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("USER.md not found in user context files")
|
|
}
|
|
})
|
|
|
|
t.Run("delete", func(t *testing.T) {
|
|
if err := as.SetUserContextFile(ctx, agentID, userID, "TEMP.md", "to delete"); err != nil {
|
|
t.Fatalf("SetUserContextFile TEMP: %v", err)
|
|
}
|
|
if err := as.DeleteUserContextFile(ctx, agentID, userID, "TEMP.md"); err != nil {
|
|
t.Fatalf("DeleteUserContextFile: %v", err)
|
|
}
|
|
files, err := as.GetUserContextFiles(ctx, agentID, userID)
|
|
if err != nil {
|
|
t.Fatalf("GetUserContextFiles after delete: %v", err)
|
|
}
|
|
for _, f := range files {
|
|
if f.FileName == "TEMP.md" {
|
|
t.Error("TEMP.md still present after DeleteUserContextFile")
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("list_by_name_cross_users", func(t *testing.T) {
|
|
user2 := "ucf-user2-" + uuid.New().String()[:8]
|
|
if err := as.SetUserContextFile(ctx, agentID, userID, "SHARED.md", "user1 copy"); err != nil {
|
|
t.Fatalf("SetUserContextFile user1: %v", err)
|
|
}
|
|
if err := as.SetUserContextFile(ctx, agentID, user2, "SHARED.md", "user2 copy"); err != nil {
|
|
t.Fatalf("SetUserContextFile user2: %v", err)
|
|
}
|
|
all, err := as.ListUserContextFilesByName(ctx, agentID, "SHARED.md")
|
|
if err != nil {
|
|
t.Fatalf("ListUserContextFilesByName: %v", err)
|
|
}
|
|
if len(all) < 2 {
|
|
t.Errorf("ListUserContextFilesByName: expected >= 2, got %d", len(all))
|
|
}
|
|
})
|
|
}
|
|
|
|
// --- User profiles ---
|
|
|
|
func TestStoreAgent_UserProfile_GetOrCreate(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentID := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
as := pg.NewPGAgentStore(db)
|
|
userID := "profile-" + uuid.New().String()[:8]
|
|
|
|
// First call — insert.
|
|
isNew, ws, err := as.GetOrCreateUserProfile(ctx, agentID, userID, "/workspace", "telegram")
|
|
if err != nil {
|
|
t.Fatalf("GetOrCreateUserProfile: %v", err)
|
|
}
|
|
if !isNew {
|
|
t.Error("first call should be new insert")
|
|
}
|
|
if ws == "" {
|
|
t.Error("workspace should not be empty")
|
|
}
|
|
|
|
// Second call — should not be new.
|
|
isNew2, _, err2 := as.GetOrCreateUserProfile(ctx, agentID, userID, "/workspace", "telegram")
|
|
if err2 != nil {
|
|
t.Fatalf("GetOrCreateUserProfile second call: %v", err2)
|
|
}
|
|
if isNew2 {
|
|
t.Error("second call should not be new")
|
|
}
|
|
}
|
|
|
|
func TestStoreAgent_UserProfile_ListInstances(t *testing.T) {
|
|
db := testDB(t)
|
|
pg.InitSqlx(db)
|
|
tenantID, agentID := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
as := pg.NewPGAgentStore(db)
|
|
|
|
for _, uid := range []string{
|
|
"inst-a-" + uuid.New().String()[:8],
|
|
"inst-b-" + uuid.New().String()[:8],
|
|
} {
|
|
if err := as.EnsureUserProfile(ctx, agentID, uid); err != nil {
|
|
t.Fatalf("EnsureUserProfile %q: %v", uid, err)
|
|
}
|
|
}
|
|
|
|
instances, err := as.ListUserInstances(ctx, agentID)
|
|
if err != nil {
|
|
t.Fatalf("ListUserInstances: %v", err)
|
|
}
|
|
if len(instances) < 2 {
|
|
t.Errorf("expected >= 2 instances, got %d", len(instances))
|
|
}
|
|
}
|
|
|
|
// --- User overrides ---
|
|
|
|
func TestStoreAgent_UserOverride_SetAndGet(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentID := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
as := pg.NewPGAgentStore(db)
|
|
userID := "override-" + uuid.New().String()[:8]
|
|
|
|
override := &store.UserAgentOverrideData{
|
|
AgentID: agentID,
|
|
UserID: userID,
|
|
Provider: "anthropic",
|
|
Model: "claude-opus-4",
|
|
}
|
|
if err := as.SetUserOverride(ctx, override); err != nil {
|
|
t.Fatalf("SetUserOverride: %v", err)
|
|
}
|
|
|
|
got, err := as.GetUserOverride(ctx, agentID, userID)
|
|
if err != nil {
|
|
t.Fatalf("GetUserOverride: %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Fatal("GetUserOverride returned nil")
|
|
}
|
|
if got.Provider != "anthropic" {
|
|
t.Errorf("Provider = %q, want %q", got.Provider, "anthropic")
|
|
}
|
|
if got.Model != "claude-opus-4" {
|
|
t.Errorf("Model = %q, want %q", got.Model, "claude-opus-4")
|
|
}
|
|
|
|
// Unknown user returns nil override (not an error).
|
|
notFound, err2 := as.GetUserOverride(ctx, agentID, "no-such-user")
|
|
if err2 != nil {
|
|
t.Fatalf("GetUserOverride unknown: %v", err2)
|
|
}
|
|
if notFound != nil {
|
|
t.Error("expected nil for unknown user override")
|
|
}
|
|
}
|
|
|
|
// --- Open vs Predefined agent type ---
|
|
|
|
func TestStoreAgent_AgentType_OpenVsPredefined(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, _ := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
as := pg.NewPGAgentStore(db)
|
|
|
|
cases := []struct {
|
|
agentType string
|
|
ownerID string
|
|
}{
|
|
{"open", "type-user-" + uuid.New().String()[:8]},
|
|
{"predefined", "type-user-" + uuid.New().String()[:8]},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
a := newTestAgent(tenantID, uuid.New().String()[:8])
|
|
a.AgentType = tc.agentType
|
|
a.OwnerID = tc.ownerID
|
|
if err := as.Create(ctx, &a); err != nil {
|
|
t.Fatalf("Create %s agent: %v", tc.agentType, err)
|
|
}
|
|
t.Cleanup(func() { db.Exec("DELETE FROM agents WHERE id = $1", a.ID) })
|
|
|
|
got, err := as.GetByID(ctx, a.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetByID %s: %v", tc.agentType, err)
|
|
}
|
|
if got.AgentType != tc.agentType {
|
|
t.Errorf("AgentType = %q, want %q", got.AgentType, tc.agentType)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestStoreAgent_DuplicateKey_Errors verifies that inserting two agents
|
|
// with the same agent_key in the same tenant errors.
|
|
func TestStoreAgent_DuplicateKey_Errors(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, _ := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
as := pg.NewPGAgentStore(db)
|
|
|
|
sharedKey := "dup-key-" + uuid.New().String()[:8]
|
|
a1 := newTestAgent(tenantID, "")
|
|
a1.AgentKey = sharedKey
|
|
if err := as.Create(ctx, &a1); err != nil {
|
|
t.Fatalf("Create first agent: %v", err)
|
|
}
|
|
t.Cleanup(func() { db.Exec("DELETE FROM agents WHERE id = $1", a1.ID) })
|
|
|
|
a2 := newTestAgent(tenantID, "")
|
|
a2.AgentKey = sharedKey
|
|
err := as.Create(ctx, &a2)
|
|
if err == nil {
|
|
t.Error("expected error on duplicate agent_key, got nil")
|
|
db.Exec("DELETE FROM agents WHERE id = $1", a2.ID)
|
|
}
|
|
}
|
|
|
|
// TestStoreAgent_ListByStatus verifies agents can be filtered by status via List.
|
|
func TestStoreAgent_List_MultipleOwners(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, _ := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
as := pg.NewPGAgentStore(db)
|
|
|
|
ownerA := "multi-owner-A-" + uuid.New().String()[:8]
|
|
ownerB := "multi-owner-B-" + uuid.New().String()[:8]
|
|
var idsA, idsB []uuid.UUID
|
|
|
|
for i := 0; i < 2; i++ {
|
|
a := newTestAgent(tenantID, uuid.New().String()[:8])
|
|
a.OwnerID = ownerA
|
|
if err := as.Create(ctx, &a); err != nil {
|
|
t.Fatalf("Create ownerA[%d]: %v", i, err)
|
|
}
|
|
idsA = append(idsA, a.ID)
|
|
}
|
|
for i := 0; i < 3; i++ {
|
|
a := newTestAgent(tenantID, uuid.New().String()[:8])
|
|
a.OwnerID = ownerB
|
|
if err := as.Create(ctx, &a); err != nil {
|
|
t.Fatalf("Create ownerB[%d]: %v", i, err)
|
|
}
|
|
idsB = append(idsB, a.ID)
|
|
}
|
|
t.Cleanup(func() {
|
|
for _, id := range append(idsA, idsB...) {
|
|
db.Exec("DELETE FROM agents WHERE id = $1", id)
|
|
}
|
|
})
|
|
|
|
listA, err := as.List(ctx, ownerA)
|
|
if err != nil {
|
|
t.Fatalf("List ownerA: %v", err)
|
|
}
|
|
if countInList(listA, idsA) != 2 {
|
|
t.Errorf("ownerA list: expected 2 own agents, got %d", countInList(listA, idsA))
|
|
}
|
|
if countInList(listA, idsB) != 0 {
|
|
t.Error("ownerA list contains ownerB agents — isolation broken")
|
|
}
|
|
|
|
listB, err := as.List(ctx, ownerB)
|
|
if err != nil {
|
|
t.Fatalf("List ownerB: %v", err)
|
|
}
|
|
if countInList(listB, idsB) != 3 {
|
|
t.Errorf("ownerB list: expected 3 own agents, got %d", countInList(listB, idsB))
|
|
}
|
|
}
|