Skip to content

Commit 734729f

Browse files
committed
test: add unit tests for core services and MCP handlers
Add 33 new tests across 4 files, bringing total to 78 passing tests. Core services: - entity: GetByID, UpsertWithEdges, GetImpact, Suggest, Search with keyword - document: GetAll, DeleteByEntityURN, Upsert validation - embedding: HybridSearch keyword, semantic, and hybrid modes MCP handlers (new file): - Entity tools: search, context, impact with parameter validation - Document tools: get_documents with edge cases - Formatters: search results, context graph, impact analysis - Error propagation from services to tool results
1 parent fa8751a commit 734729f

4 files changed

Lines changed: 967 additions & 3 deletions

File tree

core/document/service_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package document
33
import (
44
"context"
55
"database/sql"
6+
"fmt"
67
"testing"
78
"time"
89

@@ -18,6 +19,15 @@ func newMockRepo() *mockRepo {
1819
}
1920

2021
func (m *mockRepo) Upsert(_ context.Context, _ *namespace.Namespace, doc *Document) (string, error) {
22+
if doc.EntityURN == "" {
23+
return "", fmt.Errorf("entity_urn is required")
24+
}
25+
if doc.Title == "" {
26+
return "", fmt.Errorf("title is required")
27+
}
28+
if doc.Body == "" {
29+
return "", fmt.Errorf("body is required")
30+
}
2131
id := doc.EntityURN + "/" + doc.Title
2232
doc.ID = id
2333
doc.CreatedAt = time.Now()
@@ -136,3 +146,87 @@ func TestService_Delete(t *testing.T) {
136146
t.Error("expected error after delete, got nil")
137147
}
138148
}
149+
150+
func TestService_GetAll(t *testing.T) {
151+
svc := NewService(newMockRepo())
152+
ctx := context.Background()
153+
ns := namespace.DefaultNamespace
154+
155+
_, _ = svc.Upsert(ctx, ns, &Document{EntityURN: "urn:table:a", Title: "Doc A", Body: "body a"})
156+
_, _ = svc.Upsert(ctx, ns, &Document{EntityURN: "urn:table:b", Title: "Doc B", Body: "body b"})
157+
_, _ = svc.Upsert(ctx, ns, &Document{EntityURN: "urn:table:c", Title: "Doc C", Body: "body c"})
158+
159+
docs, err := svc.GetAll(ctx, ns, Filter{})
160+
if err != nil {
161+
t.Fatalf("GetAll failed: %v", err)
162+
}
163+
if len(docs) != 3 {
164+
t.Errorf("expected 3 documents, got %d", len(docs))
165+
}
166+
}
167+
168+
func TestService_DeleteByEntityURN(t *testing.T) {
169+
svc := NewService(newMockRepo())
170+
ctx := context.Background()
171+
ns := namespace.DefaultNamespace
172+
173+
_, _ = svc.Upsert(ctx, ns, &Document{EntityURN: "urn:table:orders", Title: "Runbook", Body: "content"})
174+
_, _ = svc.Upsert(ctx, ns, &Document{EntityURN: "urn:table:orders", Title: "Schema", Body: "content"})
175+
_, _ = svc.Upsert(ctx, ns, &Document{EntityURN: "urn:table:users", Title: "Users Doc", Body: "content"})
176+
177+
err := svc.DeleteByEntityURN(ctx, ns, "urn:table:orders")
178+
if err != nil {
179+
t.Fatalf("DeleteByEntityURN failed: %v", err)
180+
}
181+
182+
// orders docs should be gone
183+
docs, err := svc.GetByEntityURN(ctx, ns, "urn:table:orders")
184+
if err != nil {
185+
t.Fatalf("GetByEntityURN failed: %v", err)
186+
}
187+
if len(docs) != 0 {
188+
t.Errorf("expected 0 documents for orders, got %d", len(docs))
189+
}
190+
191+
// users doc should remain
192+
docs, err = svc.GetByEntityURN(ctx, ns, "urn:table:users")
193+
if err != nil {
194+
t.Fatalf("GetByEntityURN failed: %v", err)
195+
}
196+
if len(docs) != 1 {
197+
t.Errorf("expected 1 document for users, got %d", len(docs))
198+
}
199+
}
200+
201+
func TestService_Upsert_Validation(t *testing.T) {
202+
svc := NewService(newMockRepo())
203+
ctx := context.Background()
204+
ns := namespace.DefaultNamespace
205+
206+
tests := []struct {
207+
name string
208+
doc *Document
209+
}{
210+
{
211+
name: "missing entity_urn",
212+
doc: &Document{Title: "Some Title", Body: "some body"},
213+
},
214+
{
215+
name: "missing title",
216+
doc: &Document{EntityURN: "urn:table:x", Body: "some body"},
217+
},
218+
{
219+
name: "missing body",
220+
doc: &Document{EntityURN: "urn:table:x", Title: "Some Title"},
221+
},
222+
}
223+
224+
for _, tt := range tests {
225+
t.Run(tt.name, func(t *testing.T) {
226+
_, err := svc.Upsert(ctx, ns, tt.doc)
227+
if err == nil {
228+
t.Errorf("expected error for %s, got nil", tt.name)
229+
}
230+
})
231+
}
232+
}

core/embedding/search_test.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package embedding
22

33
import (
4+
"context"
45
"testing"
56

67
"github.com/raystack/compass/core/entity"
8+
"github.com/raystack/compass/core/namespace"
79
)
810

911
func TestReciprocalRankFusion(t *testing.T) {
@@ -51,3 +53,137 @@ func TestReciprocalRankFusion_SingleList(t *testing.T) {
5153
t.Errorf("expected first result to be 'a', got %q", fused[0].URN)
5254
}
5355
}
56+
57+
// mockSearchRepo is a simple in-memory search repository for testing HybridSearch.
58+
type mockSearchRepo struct {
59+
results []entity.SearchResult
60+
suggestions []string
61+
}
62+
63+
func (m *mockSearchRepo) Search(_ context.Context, _ entity.SearchConfig) ([]entity.SearchResult, error) {
64+
return m.results, nil
65+
}
66+
67+
func (m *mockSearchRepo) Suggest(_ context.Context, _ *namespace.Namespace, _ string, _ int) ([]string, error) {
68+
return m.suggestions, nil
69+
}
70+
71+
// mockEmbeddingRepo is a simple in-memory embedding repository for testing.
72+
type mockEmbeddingRepo struct {
73+
embeddings []Embedding
74+
}
75+
76+
func (m *mockEmbeddingRepo) UpsertBatch(_ context.Context, _ *namespace.Namespace, _ []Embedding) error {
77+
return nil
78+
}
79+
80+
func (m *mockEmbeddingRepo) DeleteByEntityURN(_ context.Context, _ *namespace.Namespace, _ string) error {
81+
return nil
82+
}
83+
84+
func (m *mockEmbeddingRepo) DeleteByContentID(_ context.Context, _ *namespace.Namespace, _ string) error {
85+
return nil
86+
}
87+
88+
func (m *mockEmbeddingRepo) Search(_ context.Context, _ *namespace.Namespace, _ []float32, _ int) ([]Embedding, error) {
89+
return m.embeddings, nil
90+
}
91+
92+
func TestHybridSearch_KeywordMode(t *testing.T) {
93+
search := &mockSearchRepo{
94+
results: []entity.SearchResult{
95+
{URN: "urn:table:orders", Name: "orders"},
96+
{URN: "urn:table:users", Name: "users"},
97+
},
98+
}
99+
hs := NewHybridSearch(search, nil, nil)
100+
ctx := context.Background()
101+
102+
results, err := hs.Search(ctx, entity.SearchConfig{
103+
Text: "orders",
104+
Mode: entity.SearchModeKeyword,
105+
})
106+
if err != nil {
107+
t.Fatalf("keyword search failed: %v", err)
108+
}
109+
if len(results) != 2 {
110+
t.Fatalf("expected 2 results, got %d", len(results))
111+
}
112+
if results[0].URN != "urn:table:orders" {
113+
t.Errorf("expected first result URN 'urn:table:orders', got %q", results[0].URN)
114+
}
115+
}
116+
117+
func TestHybridSearch_SemanticMode(t *testing.T) {
118+
search := &mockSearchRepo{}
119+
repo := &mockEmbeddingRepo{
120+
embeddings: []Embedding{
121+
{EntityURN: "urn:table:orders", Content: "orders table"},
122+
{EntityURN: "urn:table:payments", Content: "payments table"},
123+
},
124+
}
125+
embedFn := func(_ context.Context, text string) ([]float32, error) {
126+
return []float32{0.1, 0.2, 0.3}, nil
127+
}
128+
129+
hs := NewHybridSearch(search, repo, embedFn)
130+
ctx := context.Background()
131+
132+
results, err := hs.Search(ctx, entity.SearchConfig{
133+
Text: "orders",
134+
Mode: entity.SearchModeSemantic,
135+
MaxResults: 10,
136+
})
137+
if err != nil {
138+
t.Fatalf("semantic search failed: %v", err)
139+
}
140+
if len(results) != 2 {
141+
t.Fatalf("expected 2 results, got %d", len(results))
142+
}
143+
if results[0].URN != "urn:table:orders" {
144+
t.Errorf("expected first result URN 'urn:table:orders', got %q", results[0].URN)
145+
}
146+
}
147+
148+
func TestHybridSearch_HybridMode(t *testing.T) {
149+
search := &mockSearchRepo{
150+
results: []entity.SearchResult{
151+
{URN: "urn:table:orders", Name: "orders"},
152+
{URN: "urn:table:users", Name: "users"},
153+
},
154+
}
155+
repo := &mockEmbeddingRepo{
156+
embeddings: []Embedding{
157+
{EntityURN: "urn:table:orders", Content: "orders table"},
158+
{EntityURN: "urn:table:payments", Content: "payments table"},
159+
},
160+
}
161+
embedFn := func(_ context.Context, text string) ([]float32, error) {
162+
return []float32{0.1, 0.2, 0.3}, nil
163+
}
164+
165+
hs := NewHybridSearch(search, repo, embedFn)
166+
ctx := context.Background()
167+
168+
results, err := hs.Search(ctx, entity.SearchConfig{
169+
Text: "orders",
170+
Mode: entity.SearchModeHybrid,
171+
MaxResults: 10,
172+
})
173+
if err != nil {
174+
t.Fatalf("hybrid search failed: %v", err)
175+
}
176+
if len(results) == 0 {
177+
t.Fatal("expected non-empty results")
178+
}
179+
180+
// "orders" appears in both keyword and semantic results, should be ranked first
181+
if results[0].URN != "urn:table:orders" {
182+
t.Errorf("expected 'urn:table:orders' to be top result (appears in both lists), got %q", results[0].URN)
183+
}
184+
185+
// Should have 3 unique URNs total: orders, users, payments
186+
if len(results) != 3 {
187+
t.Errorf("expected 3 fused results, got %d", len(results))
188+
}
189+
}

0 commit comments

Comments
 (0)