//go:build fts5 // Package gorm provides GORM-based database operations for claude-mnemonic. package gorm import ( "context" "os" "path/filepath" "sync" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm/logger" ) // testPromptStore creates a PromptStore with a temporary database for testing. func testPromptStore(t *testing.T) (*PromptStore, *Store, func()) { t.Helper() tmpDir, err := os.MkdirTemp("", "gorm_prompt_test_*") if err != nil { t.Fatalf("create temp dir: %v", err) } dbPath := filepath.Join(tmpDir, "test.db") cfg := Config{ Path: dbPath, MaxConns: 4, LogLevel: logger.Silent, } store, err := NewStore(cfg) if err != nil { os.RemoveAll(tmpDir) t.Fatalf("NewStore failed: %v", err) } promptStore := NewPromptStore(store, nil) cleanup := func() { store.Close() os.RemoveAll(tmpDir) } return promptStore, store, cleanup } func TestPromptStore_SaveUserPromptWithMatches(t *testing.T) { promptStore, store, cleanup := testPromptStore(t) defer cleanup() ctx := context.Background() // Create a session first sessionStore := NewSessionStore(store) _, err := sessionStore.CreateSDKSession(ctx, "claude-1", "test-project", "") require.NoError(t, err) // Save a prompt id, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", 1, "What is the codebase structure?", 5) require.NoError(t, err) assert.Greater(t, id, int64(0)) } func TestPromptStore_SaveUserPromptWithMatches_Idempotency(t *testing.T) { promptStore, _, cleanup := testPromptStore(t) defer cleanup() ctx := context.Background() // Save the same prompt twice (same claudeSessionID + promptNumber) id1, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", 1, "Test prompt", 3) require.NoError(t, err) id2, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", 1, "Different text", 5) require.NoError(t, err) // Should return the same ID (INSERT OR IGNORE) assert.Equal(t, id1, id2, "Duplicate prompts should return same ID") } func TestPromptStore_SaveUserPromptWithMatches_AsyncCleanup(t *testing.T) { promptStore, _, cleanup := testPromptStore(t) defer cleanup() ctx := context.Background() // Track cleanup calls var cleanupMutex sync.Mutex cleanupCalled := false var cleanupIDs []int64 cleanupFunc := func(ctx context.Context, deletedIDs []int64) { cleanupMutex.Lock() defer cleanupMutex.Unlock() cleanupCalled = true cleanupIDs = deletedIDs } promptStore.cleanupFunc = cleanupFunc // Save prompts beyond the global limit (MaxPromptsGlobal = 500) // Insert with slower pacing to avoid database lock contention for i := 0; i < 505; i++ { _, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", i+1, "Prompt", 1) require.NoError(t, err) if i > 500 { time.Sleep(5 * time.Millisecond) // Slow down after hitting limit } } // Wait longer for async cleanup to complete time.Sleep(500 * time.Millisecond) // Verify cleanup was called cleanupMutex.Lock() defer cleanupMutex.Unlock() assert.True(t, cleanupCalled, "Cleanup function should have been called") assert.NotEmpty(t, cleanupIDs, "Cleanup should have deleted some prompts") } func TestPromptStore_CleanupOldPrompts(t *testing.T) { promptStore, _, cleanup := testPromptStore(t) defer cleanup() ctx := context.Background() // Save prompts beyond the limit // Async cleanup should fire after each insert beyond 500 for i := 0; i < 505; i++ { _, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", i+1, "Prompt", 1) require.NoError(t, err) } // Wait for all async cleanups to complete time.Sleep(1 * time.Second) // After async cleanup, we should have at most 500 prompts remaining, err := promptStore.GetAllPrompts(ctx) require.NoError(t, err) assert.LessOrEqual(t, len(remaining), MaxPromptsGlobal, "Should have at most %d prompts after async cleanup", MaxPromptsGlobal) } func TestPromptStore_GetPromptsByIDs(t *testing.T) { promptStore, _, cleanup := testPromptStore(t) defer cleanup() ctx := context.Background() // Save multiple prompts var ids []int64 for i := 1; i <= 3; i++ { id, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", i, "Prompt", i) require.NoError(t, err) ids = append(ids, id) time.Sleep(10 * time.Millisecond) // Ensure different timestamps } tests := []struct { name string orderBy string expected []int64 }{ { name: "Default ordering - date desc", orderBy: "default", expected: []int64{ids[2], ids[1], ids[0]}, // Newest to oldest }, { name: "Date ascending", orderBy: "date_asc", expected: []int64{ids[0], ids[1], ids[2]}, // Oldest to newest }, { name: "Date descending", orderBy: "date_desc", expected: []int64{ids[2], ids[1], ids[0]}, // Newest to oldest }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { prompts, err := promptStore.GetPromptsByIDs(ctx, ids, tt.orderBy, 10) require.NoError(t, err) require.Len(t, prompts, 3) // Verify ordering for i, prompt := range prompts { assert.Equal(t, tt.expected[i], prompt.ID, "Position %d should have ID %d", i, tt.expected[i]) } }) } } func TestPromptStore_GetPromptsByIDs_Limit(t *testing.T) { promptStore, _, cleanup := testPromptStore(t) defer cleanup() ctx := context.Background() // Save multiple prompts var ids []int64 for i := 1; i <= 5; i++ { id, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", i, "Prompt", i) require.NoError(t, err) ids = append(ids, id) } // Get with limit prompts, err := promptStore.GetPromptsByIDs(ctx, ids, "default", 3) require.NoError(t, err) assert.Len(t, prompts, 3) } func TestPromptStore_GetPromptsByIDs_EmptyInput(t *testing.T) { promptStore, _, cleanup := testPromptStore(t) defer cleanup() ctx := context.Background() // Get with empty IDs prompts, err := promptStore.GetPromptsByIDs(ctx, []int64{}, "default", 10) require.NoError(t, err) assert.Nil(t, prompts) } func TestPromptStore_GetPromptsByIDs_WithSession(t *testing.T) { promptStore, store, cleanup := testPromptStore(t) defer cleanup() ctx := context.Background() // Create a session sessionStore := NewSessionStore(store) _, err := sessionStore.CreateSDKSession(ctx, "claude-1", "test-project", "") require.NoError(t, err) // Save a prompt id, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", 1, "Test prompt", 5) require.NoError(t, err) // Get with session join prompts, err := promptStore.GetPromptsByIDs(ctx, []int64{id}, "default", 10) require.NoError(t, err) require.Len(t, prompts, 1) // Verify session data is populated assert.Equal(t, "test-project", prompts[0].Project) assert.NotEmpty(t, prompts[0].SDKSessionID) } func TestPromptStore_GetAllRecentUserPrompts(t *testing.T) { promptStore, _, cleanup := testPromptStore(t) defer cleanup() ctx := context.Background() // Save prompts across multiple sessions with timestamps for i := 1; i <= 3; i++ { _, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", i, "Prompt A", i) require.NoError(t, err) time.Sleep(10 * time.Millisecond) // Ensure different timestamps } for i := 1; i <= 2; i++ { _, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-2", i, "Prompt B", i) require.NoError(t, err) time.Sleep(10 * time.Millisecond) // Ensure different timestamps } // Get all recent prompts prompts, err := promptStore.GetAllRecentUserPrompts(ctx, 10) require.NoError(t, err) assert.Len(t, prompts, 5) // Verify ordering (most recent first) - last inserted should be first assert.Equal(t, "claude-2", prompts[0].ClaudeSessionID) assert.Equal(t, 2, prompts[0].PromptNumber) } func TestPromptStore_GetAllPrompts(t *testing.T) { promptStore, _, cleanup := testPromptStore(t) defer cleanup() ctx := context.Background() // Save prompts for i := 1; i <= 5; i++ { _, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", i, "Prompt", i) require.NoError(t, err) } // Wait for any async cleanup to complete (longer wait for race detector) time.Sleep(500 * time.Millisecond) // Get all prompts (for vector rebuild) prompts, err := promptStore.GetAllPrompts(ctx) require.NoError(t, err) assert.Len(t, prompts, 5) // Verify ordering by ID for i := 0; i < len(prompts)-1; i++ { assert.Less(t, prompts[i].ID, prompts[i+1].ID) } } func TestPromptStore_FindRecentPromptByText(t *testing.T) { promptStore, _, cleanup := testPromptStore(t) defer cleanup() ctx := context.Background() // Save a prompt id, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", 1, "What is the architecture?", 3) require.NoError(t, err) // Find by exact text match within time window foundID, foundNumber, found := promptStore.FindRecentPromptByText(ctx, "claude-1", "What is the architecture?", 60) assert.True(t, found, "Should find the prompt") assert.Equal(t, id, foundID) assert.Equal(t, 1, foundNumber) // Try to find with different text _, _, notFound := promptStore.FindRecentPromptByText(ctx, "claude-1", "Different text", 60) assert.False(t, notFound, "Should not find a different prompt") // Try to find outside time window time.Sleep(100 * time.Millisecond) _, _, notFound = promptStore.FindRecentPromptByText(ctx, "claude-1", "What is the architecture?", 0) assert.False(t, notFound, "Should not find prompt outside time window") } func TestPromptStore_FindRecentPromptByTextGlobal(t *testing.T) { promptStore, _, cleanup := testPromptStore(t) defer cleanup() ctx := context.Background() // Save a prompt under session "claude-1" id, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", 1, "What is the architecture?", 3) require.NoError(t, err) // Global search should find it without specifying session ID foundID, foundNumber, found := promptStore.FindRecentPromptByTextGlobal(ctx, "What is the architecture?", 60) assert.True(t, found, "Should find the prompt globally") assert.Equal(t, id, foundID) assert.Equal(t, 1, foundNumber) // Global search should find it even when a different session ID would have been used // (This is the core cross-session dedup scenario) foundID2, foundNumber2, found2 := promptStore.FindRecentPromptByTextGlobal(ctx, "What is the architecture?", 60) assert.True(t, found2, "Should find the prompt from any session") assert.Equal(t, id, foundID2) assert.Equal(t, 1, foundNumber2) // Different text should not match _, _, notFound := promptStore.FindRecentPromptByTextGlobal(ctx, "Different text", 60) assert.False(t, notFound, "Should not find a different prompt") // Should not find prompt outside time window time.Sleep(100 * time.Millisecond) _, _, notFound = promptStore.FindRecentPromptByTextGlobal(ctx, "What is the architecture?", 0) assert.False(t, notFound, "Should not find prompt outside time window") } func TestPromptStore_GetRecentUserPromptsByProject(t *testing.T) { promptStore, store, cleanup := testPromptStore(t) defer cleanup() ctx := context.Background() // Create sessions for different projects sessionStore := NewSessionStore(store) _, err := sessionStore.CreateSDKSession(ctx, "claude-1", "project-a", "") require.NoError(t, err) _, err = sessionStore.CreateSDKSession(ctx, "claude-2", "project-b", "") require.NoError(t, err) // Save prompts for project-a for i := 1; i <= 3; i++ { _, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", i, "Prompt A", i) require.NoError(t, err) } // Save prompts for project-b for i := 1; i <= 2; i++ { _, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-2", i, "Prompt B", i) require.NoError(t, err) } // Get prompts for project-a prompts, err := promptStore.GetRecentUserPromptsByProject(ctx, "project-a", 10) require.NoError(t, err) assert.Len(t, prompts, 3) // Verify all prompts are from project-a for _, prompt := range prompts { assert.Equal(t, "project-a", prompt.Project) } } func TestPromptStore_GetRecentUserPromptsByProject_Limit(t *testing.T) { promptStore, store, cleanup := testPromptStore(t) defer cleanup() ctx := context.Background() // Create session sessionStore := NewSessionStore(store) _, err := sessionStore.CreateSDKSession(ctx, "claude-1", "test-project", "") require.NoError(t, err) // Save multiple prompts for i := 1; i <= 10; i++ { _, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", i, "Prompt", i) require.NoError(t, err) } // Wait for any async cleanup to complete time.Sleep(100 * time.Millisecond) // Get with limit prompts, err := promptStore.GetRecentUserPromptsByProject(ctx, "test-project", 5) require.NoError(t, err) assert.Len(t, prompts, 5) }