Files
goclaw/tests/integration/v3_episodic_store_test.go
2026-07-10 00:55:13 +07:00

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))
}
}