mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-06-05 23:03:55 +00:00
948 lines
27 KiB
Go
948 lines
27 KiB
Go
// Package sqlite provides SQLite database operations for claude-mnemonic.
|
|
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/stretchr/testify/suite"
|
|
)
|
|
|
|
// testObservationStoreBasic creates an ObservationStore with base tables (no FTS5).
|
|
func testObservationStoreBasic(t *testing.T) (*ObservationStore, *Store, func()) {
|
|
t.Helper()
|
|
|
|
db, _, cleanup := testDB(t)
|
|
createBaseTables(t, db)
|
|
|
|
store := newStoreFromDB(db)
|
|
obsStore := NewObservationStore(store)
|
|
|
|
return obsStore, store, cleanup
|
|
}
|
|
|
|
// testObservationStore creates an ObservationStore with a test database including FTS5.
|
|
func testObservationStore(t *testing.T) (*ObservationStore, *Store, func()) {
|
|
t.Helper()
|
|
|
|
db, _, cleanup := testDB(t)
|
|
createAllTables(t, db)
|
|
|
|
store := newStoreFromDB(db)
|
|
obsStore := NewObservationStore(store)
|
|
|
|
return obsStore, store, cleanup
|
|
}
|
|
|
|
// ObservationStoreSuite is a test suite for ObservationStore operations.
|
|
type ObservationStoreSuite struct {
|
|
suite.Suite
|
|
obsStore *ObservationStore
|
|
store *Store
|
|
cleanup func()
|
|
}
|
|
|
|
func (s *ObservationStoreSuite) SetupTest() {
|
|
s.obsStore, s.store, s.cleanup = testObservationStoreBasic(s.T())
|
|
}
|
|
|
|
func (s *ObservationStoreSuite) TearDownTest() {
|
|
if s.cleanup != nil {
|
|
s.cleanup()
|
|
}
|
|
}
|
|
|
|
func TestObservationStoreSuite(t *testing.T) {
|
|
suite.Run(t, new(ObservationStoreSuite))
|
|
}
|
|
|
|
// TestStoreObservation_TableDriven tests observation storage with various scenarios.
|
|
func (s *ObservationStoreSuite) TestStoreObservation_TableDriven() {
|
|
ctx := context.Background()
|
|
|
|
tests := []struct {
|
|
name string
|
|
sdkSessionID string
|
|
project string
|
|
obs *models.ParsedObservation
|
|
promptNum int
|
|
tokens int64
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "basic discovery observation",
|
|
sdkSessionID: "session-basic",
|
|
project: "project-a",
|
|
obs: &models.ParsedObservation{
|
|
Type: models.ObsTypeDiscovery,
|
|
Title: "Test Title",
|
|
Subtitle: "Test Subtitle",
|
|
Narrative: "Test narrative content",
|
|
Facts: []string{"Fact 1", "Fact 2"},
|
|
Concepts: []string{"testing", "golang"},
|
|
},
|
|
promptNum: 1,
|
|
tokens: 100,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "bugfix observation",
|
|
sdkSessionID: "session-bugfix",
|
|
project: "project-b",
|
|
obs: &models.ParsedObservation{
|
|
Type: models.ObsTypeBugfix,
|
|
Title: "Fixed null pointer",
|
|
Narrative: "Fixed null pointer exception in handler",
|
|
FilesModified: []string{"handler.go"},
|
|
},
|
|
promptNum: 2,
|
|
tokens: 50,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "global scope observation",
|
|
sdkSessionID: "session-global",
|
|
project: "project-c",
|
|
obs: &models.ParsedObservation{
|
|
Type: models.ObsTypeDiscovery,
|
|
Title: "Security best practice",
|
|
Narrative: "Always validate user input",
|
|
Concepts: []string{"security", "best-practice"},
|
|
},
|
|
promptNum: 1,
|
|
tokens: 75,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "observation with files",
|
|
sdkSessionID: "session-files",
|
|
project: "project-d",
|
|
obs: &models.ParsedObservation{
|
|
Type: models.ObsTypeFeature,
|
|
Title: "Added authentication",
|
|
Narrative: "Implemented JWT authentication",
|
|
FilesRead: []string{"config.go", "auth.go"},
|
|
FilesModified: []string{"handler.go", "middleware.go"},
|
|
FileMtimes: map[string]int64{"handler.go": 1234567890, "middleware.go": 1234567891},
|
|
},
|
|
promptNum: 3,
|
|
tokens: 200,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "minimal observation",
|
|
sdkSessionID: "session-minimal",
|
|
project: "project-e",
|
|
obs: &models.ParsedObservation{
|
|
Type: models.ObsTypeChange,
|
|
},
|
|
promptNum: 0,
|
|
tokens: 0,
|
|
wantErr: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
s.Run(tt.name, func() {
|
|
id, epoch, err := s.obsStore.StoreObservation(ctx, tt.sdkSessionID, tt.project, tt.obs, tt.promptNum, tt.tokens)
|
|
if tt.wantErr {
|
|
s.Error(err)
|
|
return
|
|
}
|
|
|
|
s.NoError(err)
|
|
s.Greater(id, int64(0))
|
|
s.Greater(epoch, int64(0))
|
|
|
|
// Retrieve and verify
|
|
retrieved, err := s.obsStore.GetObservationByID(ctx, id)
|
|
s.NoError(err)
|
|
s.NotNil(retrieved)
|
|
s.Equal(id, retrieved.ID)
|
|
s.Equal(tt.project, retrieved.Project)
|
|
s.Equal(tt.obs.Type, retrieved.Type)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGetObservationByID_NotFound tests retrieval of non-existent observation.
|
|
func (s *ObservationStoreSuite) TestGetObservationByID_NotFound() {
|
|
ctx := context.Background()
|
|
|
|
obs, err := s.obsStore.GetObservationByID(ctx, 99999)
|
|
s.NoError(err)
|
|
s.Nil(obs)
|
|
}
|
|
|
|
// TestGetRecentObservations_TableDriven tests recent observations retrieval.
|
|
func (s *ObservationStoreSuite) TestGetRecentObservations_TableDriven() {
|
|
ctx := context.Background()
|
|
|
|
// Create 15 observations
|
|
for i := 0; i < 15; i++ {
|
|
obs := &models.ParsedObservation{
|
|
Type: models.ObsTypeDiscovery,
|
|
Title: "Observation " + string(rune('A'+i)),
|
|
}
|
|
_, _, err := s.obsStore.StoreObservation(ctx, "session-"+string(rune('0'+i)), "project-a", obs, i, 10)
|
|
s.NoError(err)
|
|
time.Sleep(time.Millisecond) // Ensure different timestamps
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
project string
|
|
limit int
|
|
wantCount int
|
|
}{
|
|
{
|
|
name: "limit 5",
|
|
project: "project-a",
|
|
limit: 5,
|
|
wantCount: 5,
|
|
},
|
|
{
|
|
name: "limit 10",
|
|
project: "project-a",
|
|
limit: 10,
|
|
wantCount: 10,
|
|
},
|
|
{
|
|
name: "limit higher than count",
|
|
project: "project-a",
|
|
limit: 50,
|
|
wantCount: 15,
|
|
},
|
|
{
|
|
name: "different project (no results)",
|
|
project: "project-b",
|
|
limit: 10,
|
|
wantCount: 0,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
s.Run(tt.name, func() {
|
|
observations, err := s.obsStore.GetRecentObservations(ctx, tt.project, tt.limit)
|
|
s.NoError(err)
|
|
s.Len(observations, tt.wantCount)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestDeleteObservations_TableDriven tests observation deletion.
|
|
func (s *ObservationStoreSuite) TestDeleteObservations_TableDriven() {
|
|
ctx := context.Background()
|
|
|
|
// Create observations
|
|
var ids []int64
|
|
for i := 0; i < 5; i++ {
|
|
obs := &models.ParsedObservation{
|
|
Type: models.ObsTypeDiscovery,
|
|
Title: "To delete " + string(rune('A'+i)),
|
|
}
|
|
id, _, err := s.obsStore.StoreObservation(ctx, "session-del", "project-del", obs, i, 10)
|
|
s.NoError(err)
|
|
ids = append(ids, id)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
toDelete []int64
|
|
wantDeleted int64
|
|
wantRemain int
|
|
}{
|
|
{
|
|
name: "delete none",
|
|
toDelete: []int64{},
|
|
wantDeleted: 0,
|
|
wantRemain: 5,
|
|
},
|
|
{
|
|
name: "delete one",
|
|
toDelete: ids[0:1],
|
|
wantDeleted: 1,
|
|
wantRemain: 4,
|
|
},
|
|
{
|
|
name: "delete remaining",
|
|
toDelete: ids[1:],
|
|
wantDeleted: 4,
|
|
wantRemain: 0,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
s.Run(tt.name, func() {
|
|
deleted, err := s.obsStore.DeleteObservations(ctx, tt.toDelete)
|
|
s.NoError(err)
|
|
s.Equal(tt.wantDeleted, deleted)
|
|
|
|
remaining, err := s.obsStore.GetAllRecentObservations(ctx, 100)
|
|
s.NoError(err)
|
|
s.Len(remaining, tt.wantRemain)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGetObservationsByIDs tests retrieval by multiple IDs.
|
|
func (s *ObservationStoreSuite) TestGetObservationsByIDs() {
|
|
ctx := context.Background()
|
|
|
|
// Create observations
|
|
var ids []int64
|
|
for i := 0; i < 5; i++ {
|
|
obs := &models.ParsedObservation{
|
|
Type: models.ObsTypeDiscovery,
|
|
Title: "By ID " + string(rune('A'+i)),
|
|
}
|
|
id, _, err := s.obsStore.StoreObservation(ctx, "session-byid", "project-byid", obs, i, 10)
|
|
s.NoError(err)
|
|
ids = append(ids, id)
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
queryIDs []int64
|
|
orderBy string
|
|
limit int
|
|
wantCount int
|
|
}{
|
|
{
|
|
name: "empty IDs",
|
|
queryIDs: []int64{},
|
|
orderBy: "date_desc",
|
|
limit: 10,
|
|
wantCount: 0,
|
|
},
|
|
{
|
|
name: "single ID",
|
|
queryIDs: ids[0:1],
|
|
orderBy: "date_desc",
|
|
limit: 10,
|
|
wantCount: 1,
|
|
},
|
|
{
|
|
name: "all IDs",
|
|
queryIDs: ids,
|
|
orderBy: "date_desc",
|
|
limit: 10,
|
|
wantCount: 5,
|
|
},
|
|
{
|
|
name: "with limit less than IDs",
|
|
queryIDs: ids,
|
|
orderBy: "date_desc",
|
|
limit: 3,
|
|
wantCount: 3,
|
|
},
|
|
{
|
|
name: "ascending order",
|
|
queryIDs: ids,
|
|
orderBy: "date_asc",
|
|
limit: 10,
|
|
wantCount: 5,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
s.Run(tt.name, func() {
|
|
observations, err := s.obsStore.GetObservationsByIDs(ctx, tt.queryIDs, tt.orderBy, tt.limit)
|
|
if tt.wantCount == 0 {
|
|
s.NoError(err)
|
|
s.Nil(observations)
|
|
} else {
|
|
s.NoError(err)
|
|
s.Len(observations, tt.wantCount)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGlobalScope tests global vs project scope.
|
|
func (s *ObservationStoreSuite) TestGlobalScope() {
|
|
ctx := context.Background()
|
|
|
|
// Create project-scoped observation
|
|
projectObs := &models.ParsedObservation{
|
|
Type: models.ObsTypeDiscovery,
|
|
Title: "Project specific",
|
|
Concepts: []string{"project-specific"},
|
|
}
|
|
_, _, err := s.obsStore.StoreObservation(ctx, "session-scope", "project-a", projectObs, 1, 10)
|
|
s.NoError(err)
|
|
|
|
// Create global-scoped observation (security concept triggers global)
|
|
globalObs := &models.ParsedObservation{
|
|
Type: models.ObsTypeDiscovery,
|
|
Title: "Global security",
|
|
Concepts: []string{"security"},
|
|
}
|
|
_, _, err = s.obsStore.StoreObservation(ctx, "session-scope", "project-a", globalObs, 2, 10)
|
|
s.NoError(err)
|
|
|
|
// Project-a should see both
|
|
resultsA, err := s.obsStore.GetRecentObservations(ctx, "project-a", 10)
|
|
s.NoError(err)
|
|
s.Len(resultsA, 2)
|
|
|
|
// Project-b should only see global
|
|
resultsB, err := s.obsStore.GetRecentObservations(ctx, "project-b", 10)
|
|
s.NoError(err)
|
|
s.Len(resultsB, 1)
|
|
s.Equal("Global security", resultsB[0].Title.String)
|
|
s.Equal(models.ScopeGlobal, resultsB[0].Scope)
|
|
}
|
|
|
|
// TestSetCleanupFunc tests the cleanup function callback.
|
|
func (s *ObservationStoreSuite) TestSetCleanupFunc() {
|
|
ctx := context.Background()
|
|
|
|
var calledWith []int64
|
|
s.obsStore.SetCleanupFunc(func(ctx context.Context, deletedIDs []int64) {
|
|
calledWith = deletedIDs
|
|
})
|
|
|
|
// Store an observation
|
|
obs := &models.ParsedObservation{
|
|
Type: models.ObsTypeDiscovery,
|
|
Title: "Test cleanup",
|
|
}
|
|
_, _, err := s.obsStore.StoreObservation(ctx, "session-cleanup", "project-cleanup", obs, 1, 10)
|
|
s.NoError(err)
|
|
|
|
// Cleanup should not have been called since nothing was deleted
|
|
s.Empty(calledWith)
|
|
}
|
|
|
|
// TestGetObservationCount tests observation counting.
|
|
func (s *ObservationStoreSuite) TestGetObservationCount() {
|
|
ctx := context.Background()
|
|
|
|
// Create observations for project-a
|
|
for i := 0; i < 5; i++ {
|
|
obs := &models.ParsedObservation{
|
|
Type: models.ObsTypeDiscovery,
|
|
}
|
|
_, _, err := s.obsStore.StoreObservation(ctx, "session-count", "project-a", obs, i, 10)
|
|
s.NoError(err)
|
|
}
|
|
|
|
// Create global observation
|
|
globalObs := &models.ParsedObservation{
|
|
Type: models.ObsTypeDiscovery,
|
|
Concepts: []string{"security"},
|
|
}
|
|
_, _, err := s.obsStore.StoreObservation(ctx, "session-count", "project-a", globalObs, 6, 10)
|
|
s.NoError(err)
|
|
|
|
// Project-a should count 6 (5 project + 1 global)
|
|
count, err := s.obsStore.GetObservationCount(ctx, "project-a")
|
|
s.NoError(err)
|
|
s.Equal(6, count)
|
|
|
|
// Project-b should count 1 (only global)
|
|
count, err = s.obsStore.GetObservationCount(ctx, "project-b")
|
|
s.NoError(err)
|
|
s.Equal(1, count)
|
|
}
|
|
|
|
func TestObservationStore_StoreAndRetrieve(t *testing.T) {
|
|
obsStore, _, cleanup := testObservationStore(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
obs := &models.ParsedObservation{
|
|
Type: models.ObsTypeDiscovery,
|
|
Title: "Test Observation",
|
|
Subtitle: "A subtitle",
|
|
Narrative: "This is a test observation about testing",
|
|
Facts: []string{"Fact 1", "Fact 2"},
|
|
Concepts: []string{"testing", "golang"},
|
|
FilesRead: []string{"test.go"},
|
|
FilesModified: []string{},
|
|
}
|
|
|
|
id, epoch, err := obsStore.StoreObservation(ctx, "session-1", "project-a", obs, 1, 100)
|
|
require.NoError(t, err)
|
|
assert.Greater(t, id, int64(0))
|
|
assert.Greater(t, epoch, int64(0))
|
|
|
|
// Retrieve by ID
|
|
retrieved, err := obsStore.GetObservationByID(ctx, id)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, retrieved)
|
|
|
|
assert.Equal(t, id, retrieved.ID)
|
|
assert.Equal(t, "session-1", retrieved.SDKSessionID)
|
|
assert.Equal(t, "project-a", retrieved.Project)
|
|
assert.Equal(t, models.ObsTypeDiscovery, retrieved.Type)
|
|
assert.Equal(t, "Test Observation", retrieved.Title.String)
|
|
assert.Equal(t, "A subtitle", retrieved.Subtitle.String)
|
|
assert.Equal(t, "This is a test observation about testing", retrieved.Narrative.String)
|
|
assert.Equal(t, []string{"Fact 1", "Fact 2"}, []string(retrieved.Facts))
|
|
assert.Equal(t, []string{"testing", "golang"}, []string(retrieved.Concepts))
|
|
}
|
|
|
|
func TestObservationStore_GetRecentObservations(t *testing.T) {
|
|
obsStore, _, cleanup := testObservationStore(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create multiple observations
|
|
for i := 0; i < 10; i++ {
|
|
obs := &models.ParsedObservation{
|
|
Type: models.ObsTypeDiscovery,
|
|
Title: "Observation " + string(rune('A'+i)),
|
|
Narrative: "Content " + string(rune('A'+i)),
|
|
Concepts: []string{"test"},
|
|
}
|
|
_, _, err := obsStore.StoreObservation(ctx, "session-1", "project-a", obs, i+1, 100)
|
|
require.NoError(t, err)
|
|
time.Sleep(time.Millisecond) // Ensure different timestamps
|
|
}
|
|
|
|
// Get recent with limit 5
|
|
recent, err := obsStore.GetRecentObservations(ctx, "project-a", 5)
|
|
require.NoError(t, err)
|
|
assert.Len(t, recent, 5)
|
|
|
|
// Get recent with limit 20 (more than exists)
|
|
recent, err = obsStore.GetRecentObservations(ctx, "project-a", 20)
|
|
require.NoError(t, err)
|
|
assert.Len(t, recent, 10)
|
|
}
|
|
|
|
func TestObservationStore_SearchObservationsFTS(t *testing.T) {
|
|
obsStore, _, cleanup := testObservationStore(t)
|
|
defer cleanup()
|
|
|
|
// FTS5 tables are created by testObservationStore via testutil.CreateAllTables
|
|
ctx := context.Background()
|
|
|
|
// Create observations with different content
|
|
observations := []struct {
|
|
title string
|
|
narrative string
|
|
}{
|
|
{"Authentication implementation", "JWT based authentication flow"},
|
|
{"Database setup", "PostgreSQL configuration and migrations"},
|
|
{"Caching layer", "Redis caching implementation"},
|
|
{"User authentication fix", "Fixed authentication bug in login"},
|
|
{"API endpoints", "REST API implementation details"},
|
|
}
|
|
|
|
for _, o := range observations {
|
|
obs := &models.ParsedObservation{
|
|
Type: models.ObsTypeDiscovery,
|
|
Title: o.title,
|
|
Narrative: o.narrative,
|
|
}
|
|
_, _, err := obsStore.StoreObservation(ctx, "session-1", "project-a", obs, 1, 100)
|
|
require.NoError(t, err)
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
|
|
// Search for authentication - should find 2 observations
|
|
results, err := obsStore.SearchObservationsFTS(ctx, "authentication", "project-a", 50)
|
|
require.NoError(t, err)
|
|
assert.GreaterOrEqual(t, len(results), 2, "should find at least 2 authentication-related observations")
|
|
|
|
// Search for database - should find 1 observation
|
|
results, err = obsStore.SearchObservationsFTS(ctx, "database PostgreSQL", "project-a", 50)
|
|
require.NoError(t, err)
|
|
assert.GreaterOrEqual(t, len(results), 1, "should find at least 1 database-related observation")
|
|
}
|
|
|
|
func TestObservationStore_SearchObservationsFTS_LimitRespected(t *testing.T) {
|
|
obsStore, _, cleanup := testObservationStore(t)
|
|
defer cleanup()
|
|
|
|
// FTS5 tables are created by testObservationStore via testutil.CreateAllTables
|
|
ctx := context.Background()
|
|
|
|
// Create 20 observations with similar content
|
|
for i := 0; i < 20; i++ {
|
|
obs := &models.ParsedObservation{
|
|
Type: models.ObsTypeDiscovery,
|
|
Title: "Testing observation " + string(rune('A'+i)),
|
|
Narrative: "This is about testing and quality assurance " + string(rune('A'+i)),
|
|
}
|
|
_, _, err := obsStore.StoreObservation(ctx, "session-1", "project-a", obs, 1, 100)
|
|
require.NoError(t, err)
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
|
|
// Search with limit 5
|
|
results, err := obsStore.SearchObservationsFTS(ctx, "testing quality", "project-a", 5)
|
|
require.NoError(t, err)
|
|
assert.LessOrEqual(t, len(results), 5, "should respect limit of 5")
|
|
|
|
// Search with limit 15
|
|
results, err = obsStore.SearchObservationsFTS(ctx, "testing quality", "project-a", 15)
|
|
require.NoError(t, err)
|
|
assert.LessOrEqual(t, len(results), 15, "should respect limit of 15")
|
|
|
|
// Search with limit 50 (our new default)
|
|
results, err = obsStore.SearchObservationsFTS(ctx, "testing quality", "project-a", 50)
|
|
require.NoError(t, err)
|
|
assert.LessOrEqual(t, len(results), 50, "should respect limit of 50")
|
|
assert.Equal(t, 20, len(results), "should return all 20 matching observations")
|
|
}
|
|
|
|
func TestObservationStore_GlobalScope(t *testing.T) {
|
|
obsStore, _, cleanup := testObservationStore(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create a project-scoped observation
|
|
projectObs := &models.ParsedObservation{
|
|
Type: models.ObsTypeDiscovery,
|
|
Title: "Project specific code",
|
|
Narrative: "This is specific to project-a",
|
|
Concepts: []string{"project-specific"},
|
|
}
|
|
_, _, err := obsStore.StoreObservation(ctx, "session-1", "project-a", projectObs, 1, 100)
|
|
require.NoError(t, err)
|
|
|
|
// Create a global-scoped observation (has a globalizable concept)
|
|
globalObs := &models.ParsedObservation{
|
|
Type: models.ObsTypeDiscovery,
|
|
Title: "Security best practice",
|
|
Narrative: "Always validate user input",
|
|
Concepts: []string{"security", "best-practice"}, // "security" is in GlobalizableConcepts
|
|
}
|
|
_, _, err = obsStore.StoreObservation(ctx, "session-1", "project-a", globalObs, 1, 100)
|
|
require.NoError(t, err)
|
|
|
|
// Get recent for project-a - should see both
|
|
results, err := obsStore.GetRecentObservations(ctx, "project-a", 10)
|
|
require.NoError(t, err)
|
|
assert.Len(t, results, 2)
|
|
|
|
// Get recent for project-b - should only see global observation
|
|
results, err = obsStore.GetRecentObservations(ctx, "project-b", 10)
|
|
require.NoError(t, err)
|
|
assert.Len(t, results, 1)
|
|
assert.Equal(t, "Security best practice", results[0].Title.String)
|
|
assert.Equal(t, models.ScopeGlobal, results[0].Scope)
|
|
}
|
|
|
|
func TestObservationStore_DeleteObservations(t *testing.T) {
|
|
obsStore, _, cleanup := testObservationStore(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create observations
|
|
var ids []int64
|
|
for i := 0; i < 5; i++ {
|
|
obs := &models.ParsedObservation{
|
|
Type: models.ObsTypeDiscovery,
|
|
Title: "Observation " + string(rune('A'+i)),
|
|
}
|
|
id, _, err := obsStore.StoreObservation(ctx, "session-1", "project-a", obs, 1, 100)
|
|
require.NoError(t, err)
|
|
ids = append(ids, id)
|
|
}
|
|
|
|
// Verify all exist
|
|
all, err := obsStore.GetRecentObservations(ctx, "project-a", 10)
|
|
require.NoError(t, err)
|
|
assert.Len(t, all, 5)
|
|
|
|
// Delete first 3
|
|
deleted, err := obsStore.DeleteObservations(ctx, ids[:3])
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(3), deleted)
|
|
|
|
// Verify only 2 remain
|
|
remaining, err := obsStore.GetRecentObservations(ctx, "project-a", 10)
|
|
require.NoError(t, err)
|
|
assert.Len(t, remaining, 2)
|
|
}
|
|
|
|
func TestObservationStore_GetObservationCount(t *testing.T) {
|
|
obsStore, _, cleanup := testObservationStore(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create observations for different projects
|
|
for i := 0; i < 5; i++ {
|
|
obs := &models.ParsedObservation{
|
|
Type: models.ObsTypeDiscovery,
|
|
Title: "Project A observation " + string(rune('0'+i)),
|
|
}
|
|
_, _, err := obsStore.StoreObservation(ctx, "session-1", "project-a", obs, 1, 100)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
for i := 0; i < 3; i++ {
|
|
obs := &models.ParsedObservation{
|
|
Type: models.ObsTypeDiscovery,
|
|
Title: "Project B observation " + string(rune('0'+i)),
|
|
}
|
|
_, _, err := obsStore.StoreObservation(ctx, "session-1", "project-b", obs, 1, 100)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// Create a global observation
|
|
globalObs := &models.ParsedObservation{
|
|
Type: models.ObsTypeDiscovery,
|
|
Title: "Global observation",
|
|
Concepts: []string{"best-practice"}, // Makes it global
|
|
}
|
|
_, _, err := obsStore.StoreObservation(ctx, "session-1", "project-a", globalObs, 1, 100)
|
|
require.NoError(t, err)
|
|
|
|
// Count for project-a includes its own + global
|
|
count, err := obsStore.GetObservationCount(ctx, "project-a")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 6, count) // 5 project-a + 1 global
|
|
|
|
// Count for project-b includes its own + global
|
|
count, err = obsStore.GetObservationCount(ctx, "project-b")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 4, count) // 3 project-b + 1 global
|
|
}
|
|
|
|
func TestObservationStore_CleanupOldObservations(t *testing.T) {
|
|
obsStore, _, cleanup := testObservationStore(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create more observations than the limit (MaxObservationsPerProject = 100)
|
|
// We'll create a smaller number and verify the logic works
|
|
for i := 0; i < 10; i++ {
|
|
obs := &models.ParsedObservation{
|
|
Type: models.ObsTypeDiscovery,
|
|
Title: "Observation " + string(rune('A'+i)),
|
|
}
|
|
_, _, err := obsStore.StoreObservation(ctx, "session-1", "project-a", obs, i+1, 100)
|
|
require.NoError(t, err)
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
|
|
// Cleanup should return empty since we're under the limit
|
|
deletedIDs, err := obsStore.CleanupOldObservations(ctx, "project-a")
|
|
require.NoError(t, err)
|
|
assert.Empty(t, deletedIDs)
|
|
|
|
// All 10 should still exist
|
|
count, err := obsStore.GetObservationCount(ctx, "project-a")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 10, count)
|
|
}
|
|
|
|
func TestObservationStore_SetCleanupFunc(t *testing.T) {
|
|
obsStore, _, cleanup := testObservationStore(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Track cleanup calls
|
|
var cleanupCalledWith []int64
|
|
obsStore.SetCleanupFunc(func(ctx context.Context, deletedIDs []int64) {
|
|
cleanupCalledWith = deletedIDs
|
|
})
|
|
|
|
// Store an observation (should trigger cleanup, but won't delete anything under limit)
|
|
obs := &models.ParsedObservation{
|
|
Type: models.ObsTypeDiscovery,
|
|
Title: "Test observation",
|
|
}
|
|
_, _, err := obsStore.StoreObservation(ctx, "session-1", "project-a", obs, 1, 100)
|
|
require.NoError(t, err)
|
|
|
|
// Cleanup func should not have been called since nothing was deleted
|
|
assert.Empty(t, cleanupCalledWith)
|
|
}
|
|
|
|
func TestExtractKeywords(t *testing.T) {
|
|
tests := []struct {
|
|
query string
|
|
expected []string
|
|
}{
|
|
{
|
|
query: "What is the authentication flow?",
|
|
expected: []string{"authentication", "flow"},
|
|
},
|
|
{
|
|
query: "How does the database connection work?",
|
|
expected: []string{"database", "connection"},
|
|
},
|
|
{
|
|
query: "JWT token validation",
|
|
expected: []string{"token", "validation"},
|
|
},
|
|
{
|
|
query: "the a an is are", // All stop words
|
|
expected: nil,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.query, func(t *testing.T) {
|
|
keywords := extractKeywords(tt.query)
|
|
for _, exp := range tt.expected {
|
|
assert.Contains(t, keywords, exp, "should contain keyword: "+exp)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestObservationStore_GetObservationsByProjectStrict(t *testing.T) {
|
|
obsStore, _, cleanup := testObservationStore(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create project-scoped observation for project-a
|
|
projectObs := &models.ParsedObservation{
|
|
Type: models.ObsTypeDiscovery,
|
|
Title: "Project A specific",
|
|
Narrative: "Only for project-a",
|
|
Concepts: []string{"local-concept"},
|
|
}
|
|
_, _, err := obsStore.StoreObservation(ctx, "session-1", "project-a", projectObs, 1, 100)
|
|
require.NoError(t, err)
|
|
|
|
// Create global observation from project-a
|
|
globalObs := &models.ParsedObservation{
|
|
Type: models.ObsTypeDiscovery,
|
|
Title: "Global security practice",
|
|
Narrative: "Best practice for all",
|
|
Concepts: []string{"security", "best-practice"},
|
|
}
|
|
_, _, err = obsStore.StoreObservation(ctx, "session-1", "project-a", globalObs, 2, 100)
|
|
require.NoError(t, err)
|
|
|
|
// Create observation for project-b
|
|
projectBObs := &models.ParsedObservation{
|
|
Type: models.ObsTypeDiscovery,
|
|
Title: "Project B specific",
|
|
Narrative: "Only for project-b",
|
|
}
|
|
_, _, err = obsStore.StoreObservation(ctx, "session-1", "project-b", projectBObs, 1, 100)
|
|
require.NoError(t, err)
|
|
|
|
// GetObservationsByProjectStrict for project-a should only return project-a observations
|
|
// This is different from GetRecentObservations which includes globals from other projects
|
|
results, err := obsStore.GetObservationsByProjectStrict(ctx, "project-a", 10)
|
|
require.NoError(t, err)
|
|
assert.Len(t, results, 2) // Only observations created in project-a
|
|
|
|
// Verify both are from project-a
|
|
for _, obs := range results {
|
|
assert.Equal(t, "project-a", obs.Project)
|
|
}
|
|
|
|
// GetObservationsByProjectStrict for project-b should only return project-b observations
|
|
results, err = obsStore.GetObservationsByProjectStrict(ctx, "project-b", 10)
|
|
require.NoError(t, err)
|
|
assert.Len(t, results, 1)
|
|
assert.Equal(t, "Project B specific", results[0].Title.String)
|
|
}
|
|
|
|
func TestObservationStore_SearchObservationsFTS_EmptyQuery(t *testing.T) {
|
|
obsStore, _, cleanup := testObservationStore(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create an observation
|
|
obs := &models.ParsedObservation{
|
|
Type: models.ObsTypeDiscovery,
|
|
Title: "Test observation",
|
|
Narrative: "Some content here",
|
|
}
|
|
_, _, err := obsStore.StoreObservation(ctx, "session-1", "project-a", obs, 1, 100)
|
|
require.NoError(t, err)
|
|
|
|
// Search with only stop words (should return nil)
|
|
results, err := obsStore.SearchObservationsFTS(ctx, "the a an is are", "project-a", 10)
|
|
require.NoError(t, err)
|
|
assert.Nil(t, results)
|
|
|
|
// Search with empty query
|
|
results, err = obsStore.SearchObservationsFTS(ctx, "", "project-a", 10)
|
|
require.NoError(t, err)
|
|
assert.Nil(t, results)
|
|
}
|
|
|
|
func TestObservationStore_SearchObservationsFTS_DefaultLimit(t *testing.T) {
|
|
obsStore, _, cleanup := testObservationStore(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create observations
|
|
for i := 0; i < 15; i++ {
|
|
obs := &models.ParsedObservation{
|
|
Type: models.ObsTypeDiscovery,
|
|
Title: "Authentication test " + string(rune('A'+i)),
|
|
Narrative: "Auth related content",
|
|
}
|
|
_, _, err := obsStore.StoreObservation(ctx, "session-1", "project-a", obs, i+1, 100)
|
|
require.NoError(t, err)
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
|
|
// Search with limit 0 (should default to 10)
|
|
results, err := obsStore.SearchObservationsFTS(ctx, "authentication", "project-a", 0)
|
|
require.NoError(t, err)
|
|
assert.LessOrEqual(t, len(results), 10)
|
|
|
|
// Search with negative limit (should default to 10)
|
|
results, err = obsStore.SearchObservationsFTS(ctx, "authentication", "project-a", -5)
|
|
require.NoError(t, err)
|
|
assert.LessOrEqual(t, len(results), 10)
|
|
}
|
|
|
|
func TestObservationStore_GetAllRecentObservations(t *testing.T) {
|
|
obsStore, _, cleanup := testObservationStore(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create observations across different projects
|
|
projects := []string{"project-a", "project-b", "project-c"}
|
|
for _, proj := range projects {
|
|
for i := 0; i < 3; i++ {
|
|
obs := &models.ParsedObservation{
|
|
Type: models.ObsTypeDiscovery,
|
|
Title: proj + " observation " + string(rune('A'+i)),
|
|
Narrative: "Content for " + proj,
|
|
}
|
|
_, _, err := obsStore.StoreObservation(ctx, "session-1", proj, obs, i+1, 100)
|
|
require.NoError(t, err)
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// Get all recent observations
|
|
results, err := obsStore.GetAllRecentObservations(ctx, 100)
|
|
require.NoError(t, err)
|
|
assert.Len(t, results, 9) // 3 projects * 3 observations
|
|
|
|
// Verify they are in descending order by epoch
|
|
for i := 1; i < len(results); i++ {
|
|
assert.GreaterOrEqual(t, results[i-1].CreatedAtEpoch, results[i].CreatedAtEpoch)
|
|
}
|
|
|
|
// Test with limit
|
|
results, err = obsStore.GetAllRecentObservations(ctx, 5)
|
|
require.NoError(t, err)
|
|
assert.Len(t, results, 5)
|
|
}
|