//go:build integration // Package tests holds Relata's integration and security-regression tests. // // They run against real MongoDB and Qdrant instances. Each run uses its own // database and its own Qdrant collection, both named after the run and dropped // afterwards, so runs are disposable and cannot interfere with each other or // with a developer's working data. // // make test-integration // // To point the tests at instances you already have: // // RELATA_TEST_MONGO_URI=mongodb://127.0.0.1:27017 \ // RELATA_TEST_QDRANT_URL=http://127.0.0.1:6333 \ // go test -tags=integration ./tests/... package tests import ( "context" "crypto/sha256" "fmt" "math" "net/http" "os" "strings" "sync" "testing" "time" "github.com/andydixon/relata/internal/analysis" "github.com/andydixon/relata/internal/audit" "github.com/andydixon/relata/internal/auth" "github.com/andydixon/relata/internal/config" "github.com/andydixon/relata/internal/domain" "github.com/andydixon/relata/internal/entities" "github.com/andydixon/relata/internal/ids" "github.com/andydixon/relata/internal/ingest" "github.com/andydixon/relata/internal/jobs" "github.com/andydixon/relata/internal/observability" "github.com/andydixon/relata/internal/relationships" "github.com/andydixon/relata/internal/search" "github.com/andydixon/relata/internal/storage/mongo" "github.com/andydixon/relata/internal/storage/qdrant" ) // --- Fake AI providers ----------------------------------------------------- // fakeEmbedder produces deterministic vectors from text, so integration tests // exercise real storage and retrieval without needing a model. // // Vectors are built from token hashes, which makes similarity behave sensibly: // texts sharing words are closer than texts that do not. type fakeEmbedder struct { dims int calls int mu sync.Mutex } func newFakeEmbedder() *fakeEmbedder { return &fakeEmbedder{dims: 64} } func (f *fakeEmbedder) ModelID() string { return "fake-embed-v1" } func (f *fakeEmbedder) Dimensions() int { return f.dims } func (f *fakeEmbedder) Embed(ctx context.Context, texts []string) ([][]float32, error) { f.mu.Lock() f.calls += len(texts) f.mu.Unlock() out := make([][]float32, len(texts)) for i, t := range texts { vec := make([]float32, f.dims) for _, word := range strings.Fields(strings.ToLower(t)) { sum := sha256.Sum256([]byte(word)) for j := 0; j < 8; j++ { idx := int(sum[j]) % f.dims vec[idx] += 1 } } // Normalise, so cosine similarity is meaningful. var norm float64 for _, v := range vec { norm += float64(v) * float64(v) } norm = math.Sqrt(norm) if norm == 0 { vec[0] = 1 norm = 1 } for j := range vec { vec[j] = float32(float64(vec[j]) / norm) } out[i] = vec } return out, nil } // fakeAnalyser returns a canned reply, or an error when disabled. type fakeAnalyser struct { reply string err error mu sync.Mutex seen []string } func (f *fakeAnalyser) ModelID() string { return "fake-analysis-v1" } func (f *fakeAnalyser) Complete(_ context.Context, req domain.CompletionRequest) (*domain.CompletionResponse, error) { f.mu.Lock() for _, m := range req.Messages { f.seen = append(f.seen, m.Content) } f.mu.Unlock() if f.err != nil { return nil, f.err } return &domain.CompletionResponse{Content: f.reply, Model: f.ModelID()}, nil } func (f *fakeAnalyser) prompts() []string { f.mu.Lock() defer f.mu.Unlock() return append([]string(nil), f.seen...) } // --- Harness --------------------------------------------------------------- // harness is a fully-wired Relata with disposable storage. type harness struct { t *testing.T cfg *config.Config store *mongo.Store vectors *qdrant.Client embedder *fakeEmbedder analyser *fakeAnalyser analysis *analysis.Service ingest *ingest.Service pipeline *ingest.Pipeline search *search.Service answer *search.Answerer reviewer *relationships.Reviewer auth *auth.Service pool *jobs.Pool workspace *domain.Workspace principal *domain.Principal cancel context.CancelFunc } func mongoURI() string { if v := os.Getenv("RELATA_TEST_MONGO_URI"); v != "" { return v } return "mongodb://127.0.0.1:27017" } func qdrantURL() string { if v := os.Getenv("RELATA_TEST_QDRANT_URL"); v != "" { return v } return "http://127.0.0.1:6333" } // requireServices skips the test with a clear message when the backing // services are not reachable, rather than failing with a connection error. func requireServices(t *testing.T) { t.Helper() client := &http.Client{Timeout: 3 * time.Second} resp, err := client.Get(qdrantURL() + "/healthz") if err != nil { t.Skipf("Qdrant is not reachable at %s (start it with `make compose-up`): %v", qdrantURL(), err) } _ = resp.Body.Close() } // newHarness wires a complete Relata against disposable storage. func newHarness(t *testing.T) *harness { t.Helper() requireServices(t) ctx, cancel := context.WithCancel(context.Background()) logger := observability.NewLogger("error", false) ctx = observability.WithLogger(ctx, logger) // A database and collection unique to this run, dropped on cleanup. suffix := strings.ReplaceAll(ids.New(), "-", "")[:12] cfg := testConfig(t, suffix) store, err := mongo.Connect(ctx, cfg.Mongo, nil) if err != nil { cancel() t.Skipf("MongoDB is not reachable at %s (start it with `make compose-up`): %v", mongoURI(), err) } // Index creation is retried: on a loaded machine MongoDB occasionally // resets a connection mid-command, and a flaky harness is worse than a // slow one. if err := retry(3, 2*time.Second, func() error { return store.EnsureIndexes(ctx) }); err != nil { cancel() t.Fatalf("create indexes: %v", err) } vectors := qdrant.New(cfg.Qdrant, nil) embedder := newFakeEmbedder() if err := retry(3, 2*time.Second, func() error { return vectors.EnsureCollection(ctx, embedder.Dimensions()) }); err != nil { cancel() t.Fatalf("create qdrant collection: %v", err) } h := &harness{ t: t, cfg: cfg, store: store, vectors: vectors, embedder: embedder, cancel: cancel, } // Analysis is off by default: most integration tests exercise the // deterministic pipeline, which must work on its own. h.analyser = &fakeAnalyser{err: analysis.ErrDisabled} h.analysis = analysis.NewService(nil, nil, false) h.build(ctx) t.Cleanup(func() { h.pool.Stop(5 * time.Second) cancel() cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) defer cleanupCancel() _ = vectors.DropCollection(cleanupCtx) _ = store.Drop(cleanupCtx) _ = store.Close(cleanupCtx) _ = os.RemoveAll(cfg.Uploads.Dir) }) h.workspace = h.ensureWorkspace(ctx, "default") h.principal = h.createUser(ctx, "tester", domain.RoleAdmin, h.workspace.ID) h.pool.Start(ctx) return h } // build wires the services. Called again by enableAnalysis. func (h *harness) build(ctx context.Context) { recorder := audit.NewRecorder(h.store.Audit) entitySvc := entities.NewService(h.store.Entities, h.store.Aliases, h.store.Claims, h.analysis, h.cfg) discoverer := relationships.NewDiscoverer(h.store.Items, h.store.Chunks, h.store.Entities, h.store.Aliases, h.store.Claims, h.store.Relationships, h.vectors, h.embedder, h.analysis, h.cfg) h.ingest = ingest.NewService(h.store.Items, h.store.Jobs, h.cfg, recorder, nil) h.search = search.NewService(h.store.Items, h.store.Chunks, h.store.Entities, h.store.Relationships, h.vectors, h.embedder, h.cfg, nil) h.answer = search.NewAnswerer(h.search, h.analysis, h.store.Claims, true) h.reviewer = relationships.NewReviewer(h.store.Relationships, h.store.Items, recorder) h.auth = auth.NewService(h.store.Users, h.store.Sessions, h.cfg.Session, false, nil) h.pool = jobs.NewPool(h.store.Jobs, h.cfg.Workers, nil) h.pipeline = ingest.NewPipeline(ingest.Repos{ Items: h.store.Items, Chunks: h.store.Chunks, Entities: h.store.Entities, Aliases: h.store.Aliases, Claims: h.store.Claims, Rels: h.store.Relationships, Jobs: h.store.Jobs, Runs: h.store.Runs, Models: h.store.ModelVersions, }, h.vectors, h.embedder, h.analysis, entitySvc, discoverer, h.ingest, h.cfg, h.pool, recorder) h.pipeline.Register(h.pool) } func testConfig(t *testing.T, suffix string) *config.Config { t.Helper() uploadDir := t.TempDir() cfg := &config.Config{LogLevel: "error"} cfg.HTTP.Addr = "127.0.0.1:0" cfg.HTTP.BaseURL = "http://127.0.0.1:8080" cfg.HTTP.MaxRequestBytes = 1 << 20 cfg.Mongo.URI = mongoURI() cfg.Mongo.Database = "relata_test_" + suffix // Generous, because these tests often run alongside a developer's own // Relata instance competing for the same MongoDB. cfg.Mongo.Timeout = 30 * time.Second cfg.Qdrant.URL = qdrantURL() cfg.Qdrant.Collection = "relata_test_" + suffix cfg.Qdrant.Timeout = 30 * time.Second cfg.Ollama.EmbeddingModel = "fake-embed-v1" cfg.Ollama.AnalysisModel = "fake-analysis-v1" cfg.Ollama.EmbeddingDims = 64 cfg.Ollama.AnalysisEnabled = false cfg.Uploads.Dir = uploadDir cfg.Uploads.MaxUploadBytes = 5 << 20 cfg.Uploads.MaxExtractedTextBytes = 1 << 20 cfg.Uploads.MaxJSONDepth = 20 cfg.Uploads.MaxMetadataKeys = 40 cfg.Uploads.MaxMetadataValueLen = 2000 cfg.Uploads.MaxTitleLen = 300 cfg.Uploads.MaxConcurrentIngest = 4 cfg.Chunking.MaxChars = 800 cfg.Chunking.TargetChars = 500 cfg.Chunking.MinChars = 100 cfg.Chunking.OverlapChars = 60 cfg.Chunking.MaxPerItem = 50 cfg.Chunking.MinSplitChars = 400 // Short intervals so tests do not wait on polling. cfg.Workers.Count = 3 cfg.Workers.AIConcurrency = 2 cfg.Workers.PollInterval = 20 * time.Millisecond cfg.Workers.LeaseDuration = 10 * time.Second cfg.Workers.HeartbeatPeriod = 2 * time.Second cfg.Workers.MaxAttempts = 3 cfg.Workers.JobTimeout = 60 * time.Second cfg.Workers.Enabled = true cfg.Search.VectorCandidates = 50 cfg.Search.LexicalCandidates = 50 cfg.Search.DefaultLimit = 20 cfg.Search.MaxResults = 100 cfg.Search.ExpansionMinScore = 0.3 cfg.Search.MaxExpandedItems = 20 cfg.Search.AnswerEnabled = true cfg.Search.AnswerMaxChunks = 10 cfg.Search.AnswerMaxCharsPerChunk = 1000 cfg.Rel.MaxCandidates = 40 cfg.Rel.MinScore = 0.15 cfg.Rel.StoreScore = 0.2 cfg.Rel.MaxModelCalls = 5 cfg.Rel.NearDuplicateSimilarity = 0.94 cfg.Rel.TemporalWindow = 168 * time.Hour cfg.Rel.MaxNeighboursPerChunk = 10 cfg.Session.CookieName = "relata_session" cfg.Session.IdleTimeout = time.Hour cfg.Session.AbsoluteTimeout = 24 * time.Hour cfg.Session.LoginRateBurst = 5 cfg.Session.LoginRateWindow = time.Minute return cfg } // enableAnalysis rewires the harness with a fake analyser returning reply. func (h *harness) enableAnalysis(ctx context.Context, reply string) *fakeAnalyser { h.pool.Stop(2 * time.Second) h.analyser = &fakeAnalyser{reply: reply} h.analysis = analysis.NewService(h.analyser, nil, true) h.cfg.Ollama.AnalysisEnabled = true h.build(ctx) h.pool.Start(ctx) return h.analyser } func (h *harness) ensureWorkspace(ctx context.Context, slug string) *domain.Workspace { h.t.Helper() ws := &domain.Workspace{ ID: ids.New(), Name: slug, Slug: slug, CreatedAt: time.Now().UTC(), } if err := h.store.Workspaces.Create(ctx, ws); err != nil { h.t.Fatalf("create workspace: %v", err) } return ws } func (h *harness) createUser(ctx context.Context, username string, role domain.Role, workspaceID string) *domain.Principal { h.t.Helper() hash, err := auth.HashPassword("a sufficiently long test password") if err != nil { h.t.Fatalf("hash password: %v", err) } u := &domain.User{ ID: ids.New(), Username: username, PasswordHash: hash, Role: role, WorkspaceID: workspaceID, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), } if err := h.store.Users.Create(ctx, u); err != nil { h.t.Fatalf("create user: %v", err) } return &domain.Principal{ UserID: u.ID, Username: u.Username, Role: u.Role, WorkspaceID: u.WorkspaceID, } } // ingestText stores a note and returns it. func (h *harness) ingestText(ctx context.Context, title, body string, opts ...func(*ingest.Request)) *domain.Item { h.t.Helper() req := ingest.Request{ Title: title, Content: []byte(body), SourceType: domain.SourceTypeNote, } for _, o := range opts { o(&req) } res, err := h.ingest.Ingest(ctx, h.principal, req) if err != nil { h.t.Fatalf("ingest %q: %v", title, err) } return res.Item } func withMetadata(m map[string]string) func(*ingest.Request) { return func(r *ingest.Request) { r.Metadata = m } } func withCreatedAt(t time.Time) func(*ingest.Request) { return func(r *ingest.Request) { r.CreatedAt = &t } } // waitForItem blocks until an item reaches a terminal status. func (h *harness) waitForItem(ctx context.Context, itemID string, timeout time.Duration) *domain.Item { h.t.Helper() deadline := time.Now().Add(timeout) var last domain.ProcessingStatus for time.Now().Before(deadline) { item, err := h.store.Items.Get(ctx, h.principal.WorkspaceID, itemID) if err == nil { last = item.Status switch item.Status { case domain.StatusReady, domain.StatusPartial, domain.StatusFailed: return item } } time.Sleep(50 * time.Millisecond) } h.dumpJobs(ctx) h.t.Fatalf("item %s did not finish within %s (last status: %s)", itemID, timeout, last) return nil } // waitForQueue blocks until nothing is pending or running. func (h *harness) waitForQueue(ctx context.Context, timeout time.Duration) { h.t.Helper() deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { counts, err := h.store.Jobs.CountByStatus(ctx, h.principal.WorkspaceID) if err == nil && counts[domain.JobPending]+counts[domain.JobRunning] == 0 { // Let any final write settle. time.Sleep(100 * time.Millisecond) counts, _ = h.store.Jobs.CountByStatus(ctx, h.principal.WorkspaceID) if counts[domain.JobPending]+counts[domain.JobRunning] == 0 { return } } time.Sleep(50 * time.Millisecond) } h.dumpJobs(ctx) h.t.Fatalf("the queue did not drain within %s", timeout) } // dumpJobs prints the queue, to make a timeout diagnosable. func (h *harness) dumpJobs(ctx context.Context) { jobsList, _, err := h.store.Jobs.List(ctx, h.principal.WorkspaceID, nil, nil, domain.Page{Limit: 100}) if err != nil { return } var b strings.Builder b.WriteString("\nqueue state:\n") for _, j := range jobsList { fmt.Fprintf(&b, " %-24s %-10s attempts=%d %s\n", j.Type, j.Status, j.Attempts, j.LastError) } h.t.Log(b.String()) } // ingestAndWait stores a note and waits for it to finish processing. func (h *harness) ingestAndWait(ctx context.Context, title, body string, opts ...func(*ingest.Request)) *domain.Item { h.t.Helper() item := h.ingestText(ctx, title, body, opts...) return h.waitForItem(ctx, item.ID, 120*time.Second) } // pauseWorkers stops the worker pool for the remainder of the test, so a test // can observe queue state without workers changing it underneath. func (h *harness) pauseWorkers() { h.t.Helper() h.pool.Stop(5 * time.Second) } // retry runs fn up to attempts times, pausing between failures. func retry(attempts int, pause time.Duration, fn func() error) error { var err error for i := 0; i < attempts; i++ { if err = fn(); err == nil { return nil } if i < attempts-1 { time.Sleep(pause) } } return err } // vectorCount reports how many vectors the workspace holds. func (h *harness) vectorCount(ctx context.Context, workspaceID string) int64 { h.t.Helper() n, err := h.vectors.CountByWorkspace(ctx, workspaceID) if err != nil { h.t.Fatalf("count vectors: %v", err) } return n }