mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 12:14:40 +00:00
411 lines
12 KiB
Go
411 lines
12 KiB
Go
//go:build integration
|
|
|
|
package integration
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
|
|
)
|
|
|
|
func newEpisodicStore(t *testing.T) *pg.PGEpisodicStore {
|
|
t.Helper()
|
|
db := testDB(t)
|
|
pg.InitSqlx(db)
|
|
return pg.NewPGEpisodicStore(db)
|
|
}
|
|
|
|
func episodicTimePtr(t time.Time) *time.Time { return &t }
|
|
|
|
func TestStoreEpisodic_CreateAndGet(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentID := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
s := newEpisodicStore(t)
|
|
|
|
ep := &store.EpisodicSummary{
|
|
TenantID: tenantID,
|
|
AgentID: agentID,
|
|
UserID: "ep-user-" + tenantID.String()[:8],
|
|
SessionKey: "sess-001",
|
|
Summary: "User discussed project deadlines and team coordination",
|
|
KeyTopics: []string{"deadlines", "coordination"},
|
|
L0Abstract: "Project deadline discussion",
|
|
SourceType: "session",
|
|
SourceID: "src-" + uuid.New().String()[:8],
|
|
TurnCount: 10,
|
|
TokenCount: 500,
|
|
}
|
|
if err := s.Create(ctx, ep); err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
if ep.ID == uuid.Nil {
|
|
t.Fatal("expected non-nil ID after Create")
|
|
}
|
|
|
|
got, err := s.Get(ctx, ep.ID.String())
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if got.Summary != ep.Summary {
|
|
t.Errorf("Summary = %q, want %q", got.Summary, ep.Summary)
|
|
}
|
|
if got.L0Abstract != "Project deadline discussion" {
|
|
t.Errorf("L0Abstract = %q, want %q", got.L0Abstract, "Project deadline discussion")
|
|
}
|
|
if got.TurnCount != 10 {
|
|
t.Errorf("TurnCount = %d, want 10", got.TurnCount)
|
|
}
|
|
if got.TokenCount != 500 {
|
|
t.Errorf("TokenCount = %d, want 500", got.TokenCount)
|
|
}
|
|
if len(got.KeyTopics) != 2 {
|
|
t.Errorf("KeyTopics len = %d, want 2", len(got.KeyTopics))
|
|
}
|
|
|
|
// ExistsBySourceID
|
|
exists, err := s.ExistsBySourceID(ctx, agentID.String(), ep.UserID, ep.SourceID)
|
|
if err != nil {
|
|
t.Fatalf("ExistsBySourceID: %v", err)
|
|
}
|
|
if !exists {
|
|
t.Error("ExistsBySourceID returned false")
|
|
}
|
|
}
|
|
|
|
func TestStoreEpisodic_List(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentID := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
s := newEpisodicStore(t)
|
|
userID := "list-user-" + tenantID.String()[:8]
|
|
|
|
// Create 3 summaries
|
|
for i := 0; i < 3; i++ {
|
|
ep := &store.EpisodicSummary{
|
|
TenantID: tenantID,
|
|
AgentID: agentID,
|
|
UserID: userID,
|
|
SessionKey: fmt.Sprintf("sess-%03d", i),
|
|
Summary: fmt.Sprintf("Summary %d", i),
|
|
L0Abstract: fmt.Sprintf("Abstract %d", i),
|
|
SourceType: "session",
|
|
SourceID: fmt.Sprintf("list-src-%d-%s", i, tenantID.String()[:8]),
|
|
TurnCount: 5,
|
|
TokenCount: 200,
|
|
}
|
|
if err := s.Create(ctx, ep); err != nil {
|
|
t.Fatalf("Create %d: %v", i, err)
|
|
}
|
|
// Small delay for ordering by created_at
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
|
|
// List with limit
|
|
results, err := s.List(ctx, agentID.String(), userID, 2, 0)
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
if len(results) != 2 {
|
|
t.Errorf("List len = %d, want 2", len(results))
|
|
}
|
|
|
|
// Verify DESC order (newest first)
|
|
if len(results) == 2 && results[0].CreatedAt.Before(results[1].CreatedAt) {
|
|
t.Error("expected DESC order (newest first)")
|
|
}
|
|
|
|
// List all
|
|
all, err := s.List(ctx, agentID.String(), userID, 10, 0)
|
|
if err != nil {
|
|
t.Fatalf("List all: %v", err)
|
|
}
|
|
if len(all) != 3 {
|
|
t.Errorf("List all len = %d, want 3", len(all))
|
|
}
|
|
|
|
// ListUnpromoted (all should be unpromoted)
|
|
unpromoted, err := s.ListUnpromoted(ctx, agentID.String(), userID, 10)
|
|
if err != nil {
|
|
t.Fatalf("ListUnpromoted: %v", err)
|
|
}
|
|
if len(unpromoted) != 3 {
|
|
t.Errorf("ListUnpromoted len = %d, want 3", len(unpromoted))
|
|
}
|
|
|
|
// MarkPromoted, then CountUnpromoted
|
|
if len(unpromoted) > 0 {
|
|
if err := s.MarkPromoted(ctx, []string{unpromoted[0].ID.String()}); err != nil {
|
|
t.Fatalf("MarkPromoted: %v", err)
|
|
}
|
|
count, err := s.CountUnpromoted(ctx, agentID.String(), userID)
|
|
if err != nil {
|
|
t.Fatalf("CountUnpromoted: %v", err)
|
|
}
|
|
if count != 2 {
|
|
t.Errorf("CountUnpromoted = %d, want 2", count)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStoreEpisodic_FTSSearch(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentID := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
s := newEpisodicStore(t)
|
|
userID := "fts-user-" + tenantID.String()[:8]
|
|
|
|
summaries := []struct {
|
|
summary string
|
|
l0 string
|
|
srcID string
|
|
}{
|
|
{"The deployment pipeline uses Docker containers for isolation", "Docker deployment", "fts-1-" + tenantID.String()[:8]},
|
|
{"Database migration strategy with PostgreSQL and pgvector", "DB migration", "fts-2-" + tenantID.String()[:8]},
|
|
{"Frontend React components with TypeScript type safety", "React frontend", "fts-3-" + tenantID.String()[:8]},
|
|
}
|
|
for _, item := range summaries {
|
|
ep := &store.EpisodicSummary{
|
|
TenantID: tenantID,
|
|
AgentID: agentID,
|
|
UserID: userID,
|
|
SessionKey: "fts-sess",
|
|
Summary: item.summary,
|
|
L0Abstract: item.l0,
|
|
SourceType: "session",
|
|
SourceID: item.srcID,
|
|
TurnCount: 5,
|
|
TokenCount: 200,
|
|
}
|
|
if err := s.Create(ctx, ep); err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
}
|
|
|
|
// Search for "Docker" — should match first summary
|
|
results, err := s.Search(ctx, "Docker containers deployment", agentID.String(), userID, store.EpisodicSearchOptions{
|
|
MaxResults: 10,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Search: %v", err)
|
|
}
|
|
if len(results) == 0 {
|
|
t.Fatal("Search returned 0 results, expected at least 1")
|
|
}
|
|
if results[0].L0Abstract != "Docker deployment" {
|
|
t.Errorf("top result L0 = %q, want %q", results[0].L0Abstract, "Docker deployment")
|
|
}
|
|
|
|
// Search for "PostgreSQL" — should match DB migration summary
|
|
results2, err := s.Search(ctx, "PostgreSQL migration", agentID.String(), userID, store.EpisodicSearchOptions{
|
|
MaxResults: 10,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Search PostgreSQL: %v", err)
|
|
}
|
|
if len(results2) == 0 {
|
|
t.Fatal("PostgreSQL search returned 0 results")
|
|
}
|
|
}
|
|
|
|
func TestStoreEpisodic_SearchSharedBlankL0FallsBackToSummary(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentID := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
s := newEpisodicStore(t)
|
|
|
|
summary := "Project Alpha workshop notes mention visual hierarchy as a focus area"
|
|
ep := &store.EpisodicSummary{
|
|
TenantID: tenantID,
|
|
AgentID: agentID,
|
|
UserID: "",
|
|
SessionKey: "channel:discord",
|
|
Summary: summary,
|
|
KeyTopics: []string{"project-alpha", "workshop-notes", "visual-hierarchy"},
|
|
SourceType: "channel",
|
|
SourceID: "channel-blank-l0-" + tenantID.String()[:8],
|
|
}
|
|
if err := s.Create(ctx, ep); err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
if _, err := db.ExecContext(ctx, `UPDATE episodic_summaries SET l0_abstract = '' WHERE id = $1`, ep.ID); err != nil {
|
|
t.Fatalf("force blank l0: %v", err)
|
|
}
|
|
|
|
results, err := s.Search(ctx, "Project Alpha visual hierarchy", agentID.String(), "guild:discord:user:sample", store.EpisodicSearchOptions{
|
|
MaxResults: 10,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Search: %v", err)
|
|
}
|
|
if len(results) == 0 {
|
|
t.Fatal("Search returned 0 results, expected shared channel memory")
|
|
}
|
|
if results[0].L0Abstract == "" {
|
|
t.Fatal("Search returned blank L0Abstract, want summary fallback")
|
|
}
|
|
if results[0].L0Abstract != summary {
|
|
t.Fatalf("Search L0Abstract = %q, want summary fallback", results[0].L0Abstract)
|
|
}
|
|
}
|
|
|
|
func TestStoreEpisodic_SearchCreatedAtAndExpiryFilters(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentID := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
s := newEpisodicStore(t)
|
|
userID := "time-user-" + tenantID.String()[:8]
|
|
base := time.Date(2026, 7, 9, 12, 0, 0, 0, time.UTC)
|
|
|
|
items := []struct {
|
|
sourceID string
|
|
summary string
|
|
createdAt time.Time
|
|
expiresAt *time.Time
|
|
}{
|
|
{
|
|
sourceID: "old",
|
|
summary: "Project Alpha old planning note",
|
|
createdAt: base.Add(-48 * time.Hour),
|
|
},
|
|
{
|
|
sourceID: "recent",
|
|
summary: "Project Alpha recent planning note",
|
|
createdAt: base.Add(-2 * time.Hour),
|
|
},
|
|
{
|
|
sourceID: "expired",
|
|
summary: "Project Alpha expired planning note",
|
|
createdAt: base.Add(-1 * time.Hour),
|
|
expiresAt: episodicTimePtr(base.Add(-30 * time.Minute)),
|
|
},
|
|
}
|
|
for _, item := range items {
|
|
ep := &store.EpisodicSummary{
|
|
TenantID: tenantID,
|
|
AgentID: agentID,
|
|
UserID: userID,
|
|
SessionKey: "time-sess",
|
|
Summary: item.summary,
|
|
KeyTopics: []string{"project-alpha", "planning"},
|
|
SourceType: "session",
|
|
SourceID: "time-filter-" + item.sourceID + "-" + tenantID.String()[:8],
|
|
}
|
|
if item.expiresAt != nil {
|
|
ep.ExpiresAt = item.expiresAt
|
|
}
|
|
if err := s.Create(ctx, ep); err != nil {
|
|
t.Fatalf("Create %s: %v", item.sourceID, err)
|
|
}
|
|
if _, err := db.ExecContext(ctx, `UPDATE episodic_summaries SET created_at = $1 WHERE id = $2`, item.createdAt, ep.ID); err != nil {
|
|
t.Fatalf("set created_at %s: %v", item.sourceID, err)
|
|
}
|
|
}
|
|
|
|
createdAfter := base.Add(-24 * time.Hour)
|
|
results, err := s.Search(ctx, "Project Alpha planning", agentID.String(), userID, store.EpisodicSearchOptions{
|
|
MaxResults: 10,
|
|
CreatedAfter: &createdAfter,
|
|
IncludeExpired: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Search with CreatedAfter: %v", err)
|
|
}
|
|
if len(results) != 2 {
|
|
t.Fatalf("CreatedAfter results = %d, want 2 recent+expired", len(results))
|
|
}
|
|
for _, r := range results {
|
|
if r.CreatedAt.Before(createdAfter) {
|
|
t.Fatalf("result before CreatedAfter: %s < %s", r.CreatedAt, createdAfter)
|
|
}
|
|
}
|
|
|
|
results, err = s.Search(ctx, "*", agentID.String(), userID, store.EpisodicSearchOptions{
|
|
MaxResults: 10,
|
|
CreatedAfter: &createdAfter,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Recent list search: %v", err)
|
|
}
|
|
if len(results) != 1 {
|
|
t.Fatalf("non-expired recent results = %d, want 1", len(results))
|
|
}
|
|
if results[0].L0Abstract != "Project Alpha recent planning note" {
|
|
t.Fatalf("recent result = %q, want non-expired recent note", results[0].L0Abstract)
|
|
}
|
|
if results[0].ExpiresAt != nil {
|
|
t.Fatalf("non-expired result ExpiresAt = %v, want nil", results[0].ExpiresAt)
|
|
}
|
|
|
|
createdBefore := base.Add(-24 * time.Hour)
|
|
results, err = s.Search(ctx, "*", agentID.String(), userID, store.EpisodicSearchOptions{
|
|
MaxResults: 10,
|
|
CreatedBefore: &createdBefore,
|
|
IncludeExpired: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreatedBefore list search: %v", err)
|
|
}
|
|
if len(results) != 1 {
|
|
t.Fatalf("CreatedBefore results = %d, want 1 old note", len(results))
|
|
}
|
|
if !results[0].CreatedAt.Before(createdBefore) {
|
|
t.Fatalf("result not before CreatedBefore: %s >= %s", results[0].CreatedAt, createdBefore)
|
|
}
|
|
}
|
|
|
|
func TestStoreEpisodic_TenantIsolation(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantA, agentA := seedTenantAgent(t, db)
|
|
tenantB, _ := seedTenantAgent(t, db)
|
|
ctxA := tenantCtx(tenantA)
|
|
ctxB := tenantCtx(tenantB)
|
|
s := newEpisodicStore(t)
|
|
userID := "iso-user-" + tenantA.String()[:8]
|
|
|
|
ep := &store.EpisodicSummary{
|
|
TenantID: tenantA,
|
|
AgentID: agentA,
|
|
UserID: userID,
|
|
SessionKey: "iso-sess",
|
|
Summary: "Tenant A secret discussion about product strategy",
|
|
L0Abstract: "Product strategy",
|
|
SourceType: "session",
|
|
SourceID: "iso-src-" + tenantA.String()[:8],
|
|
TurnCount: 5,
|
|
TokenCount: 200,
|
|
}
|
|
if err := s.Create(ctxA, ep); err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
// Tenant B cannot Get
|
|
_, err := s.Get(ctxB, ep.ID.String())
|
|
if err == nil {
|
|
t.Error("tenant B can Get tenant A's episodic — isolation broken")
|
|
}
|
|
|
|
// Tenant B cannot List
|
|
list, err := s.List(ctxB, agentA.String(), userID, 10, 0)
|
|
if err != nil {
|
|
t.Fatalf("List from B: %v", err)
|
|
}
|
|
if len(list) != 0 {
|
|
t.Errorf("tenant B sees %d episodic summaries — isolation broken", len(list))
|
|
}
|
|
|
|
// Tenant A can see its own
|
|
listA, err := s.List(ctxA, agentA.String(), userID, 10, 0)
|
|
if err != nil {
|
|
t.Fatalf("List from A: %v", err)
|
|
}
|
|
if len(listA) != 1 {
|
|
t.Errorf("tenant A sees %d, want 1", len(listA))
|
|
}
|
|
}
|