Files
beads/internal/storage/dolt/dolt_test.go
obsidian d05f7cee8f fix(dolt): improve test reliability with timeouts and PATH detection
- Use exec.LookPath instead of hardcoded path for Dolt detection
- Add test context with timeout to prevent tests from hanging
- Document known issues with embedded Dolt driver async operations

The embedded Dolt driver can hang on complex JOIN queries. This change
ensures tests fail gracefully with timeout rather than hanging.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 12:30:26 -08:00

871 lines
21 KiB
Go

package dolt
import (
"context"
"os"
"os/exec"
"testing"
"time"
"github.com/steveyegge/beads/internal/types"
)
// testTimeout is the maximum time for any single test operation.
// The embedded Dolt driver can be slow, especially for complex JOIN queries.
// If tests are timing out, it may indicate an issue with the embedded Dolt
// driver's async operations rather than with the DoltStore implementation.
const testTimeout = 30 * time.Second
// testContext returns a context with timeout for test operations
func testContext(t *testing.T) (context.Context, context.CancelFunc) {
t.Helper()
return context.WithTimeout(context.Background(), testTimeout)
}
// skipIfNoDolt skips the test if Dolt is not installed
func skipIfNoDolt(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("dolt"); err != nil {
t.Skip("Dolt not installed, skipping test")
}
}
// setupTestStore creates a test store with a temporary directory
func setupTestStore(t *testing.T) (*DoltStore, func()) {
t.Helper()
skipIfNoDolt(t)
ctx, cancel := testContext(t)
defer cancel()
tmpDir, err := os.MkdirTemp("", "dolt-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
cfg := &Config{
Path: tmpDir,
CommitterName: "test",
CommitterEmail: "test@example.com",
Database: "testdb",
}
store, err := New(ctx, cfg)
if err != nil {
os.RemoveAll(tmpDir)
t.Fatalf("failed to create Dolt store: %v", err)
}
// Set up issue prefix
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
store.Close()
os.RemoveAll(tmpDir)
t.Fatalf("failed to set prefix: %v", err)
}
cleanup := func() {
store.Close()
os.RemoveAll(tmpDir)
}
return store, cleanup
}
func TestNewDoltStore(t *testing.T) {
skipIfNoDolt(t)
ctx := context.Background()
tmpDir, err := os.MkdirTemp("", "dolt-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &Config{
Path: tmpDir,
CommitterName: "test",
CommitterEmail: "test@example.com",
Database: "testdb",
}
store, err := New(ctx, cfg)
if err != nil {
t.Fatalf("failed to create Dolt store: %v", err)
}
defer store.Close()
// Verify store path
if store.Path() != tmpDir {
t.Errorf("expected path %s, got %s", tmpDir, store.Path())
}
// Verify not closed
if store.IsClosed() {
t.Error("store should not be closed")
}
}
func TestDoltStoreConfig(t *testing.T) {
store, cleanup := setupTestStore(t)
defer cleanup()
ctx := context.Background()
// Test SetConfig
if err := store.SetConfig(ctx, "test_key", "test_value"); err != nil {
t.Fatalf("failed to set config: %v", err)
}
// Test GetConfig
value, err := store.GetConfig(ctx, "test_key")
if err != nil {
t.Fatalf("failed to get config: %v", err)
}
if value != "test_value" {
t.Errorf("expected 'test_value', got %q", value)
}
// Test GetAllConfig
allConfig, err := store.GetAllConfig(ctx)
if err != nil {
t.Fatalf("failed to get all config: %v", err)
}
if allConfig["test_key"] != "test_value" {
t.Errorf("expected test_key in all config")
}
// Test DeleteConfig
if err := store.DeleteConfig(ctx, "test_key"); err != nil {
t.Fatalf("failed to delete config: %v", err)
}
value, err = store.GetConfig(ctx, "test_key")
if err != nil {
t.Fatalf("failed to get deleted config: %v", err)
}
if value != "" {
t.Errorf("expected empty value after delete, got %q", value)
}
}
func TestDoltStoreIssue(t *testing.T) {
store, cleanup := setupTestStore(t)
defer cleanup()
ctx := context.Background()
// Create an issue
issue := &types.Issue{
Title: "Test Issue",
Description: "Test description",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, issue, "tester"); err != nil {
t.Fatalf("failed to create issue: %v", err)
}
// Verify ID was generated
if issue.ID == "" {
t.Error("expected issue ID to be generated")
}
// Get the issue back
retrieved, err := store.GetIssue(ctx, issue.ID)
if err != nil {
t.Fatalf("failed to get issue: %v", err)
}
if retrieved == nil {
t.Fatal("expected to retrieve issue")
}
if retrieved.Title != issue.Title {
t.Errorf("expected title %q, got %q", issue.Title, retrieved.Title)
}
}
func TestDoltStoreIssueUpdate(t *testing.T) {
store, cleanup := setupTestStore(t)
defer cleanup()
ctx := context.Background()
// Create an issue
issue := &types.Issue{
Title: "Original Title",
Description: "Original description",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, issue, "tester"); err != nil {
t.Fatalf("failed to create issue: %v", err)
}
// Update the issue
updates := map[string]interface{}{
"title": "Updated Title",
"priority": 1,
"status": string(types.StatusInProgress),
}
if err := store.UpdateIssue(ctx, issue.ID, updates, "tester"); err != nil {
t.Fatalf("failed to update issue: %v", err)
}
// Get the updated issue
retrieved, err := store.GetIssue(ctx, issue.ID)
if err != nil {
t.Fatalf("failed to get issue: %v", err)
}
if retrieved.Title != "Updated Title" {
t.Errorf("expected title 'Updated Title', got %q", retrieved.Title)
}
if retrieved.Priority != 1 {
t.Errorf("expected priority 1, got %d", retrieved.Priority)
}
if retrieved.Status != types.StatusInProgress {
t.Errorf("expected status in_progress, got %s", retrieved.Status)
}
}
func TestDoltStoreIssueClose(t *testing.T) {
store, cleanup := setupTestStore(t)
defer cleanup()
ctx := context.Background()
// Create an issue
issue := &types.Issue{
Title: "Issue to Close",
Description: "Will be closed",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, issue, "tester"); err != nil {
t.Fatalf("failed to create issue: %v", err)
}
// Close the issue
if err := store.CloseIssue(ctx, issue.ID, "completed", "tester", "session123"); err != nil {
t.Fatalf("failed to close issue: %v", err)
}
// Get the closed issue
retrieved, err := store.GetIssue(ctx, issue.ID)
if err != nil {
t.Fatalf("failed to get issue: %v", err)
}
if retrieved.Status != types.StatusClosed {
t.Errorf("expected status closed, got %s", retrieved.Status)
}
if retrieved.ClosedAt == nil {
t.Error("expected closed_at to be set")
}
}
func TestDoltStoreLabels(t *testing.T) {
store, cleanup := setupTestStore(t)
defer cleanup()
ctx := context.Background()
// Create an issue
issue := &types.Issue{
Title: "Issue with Labels",
Description: "Test labels",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, issue, "tester"); err != nil {
t.Fatalf("failed to create issue: %v", err)
}
// Add labels
if err := store.AddLabel(ctx, issue.ID, "bug", "tester"); err != nil {
t.Fatalf("failed to add label: %v", err)
}
if err := store.AddLabel(ctx, issue.ID, "priority", "tester"); err != nil {
t.Fatalf("failed to add second label: %v", err)
}
// Get labels
labels, err := store.GetLabels(ctx, issue.ID)
if err != nil {
t.Fatalf("failed to get labels: %v", err)
}
if len(labels) != 2 {
t.Errorf("expected 2 labels, got %d", len(labels))
}
// Remove label
if err := store.RemoveLabel(ctx, issue.ID, "bug", "tester"); err != nil {
t.Fatalf("failed to remove label: %v", err)
}
// Verify removal
labels, err = store.GetLabels(ctx, issue.ID)
if err != nil {
t.Fatalf("failed to get labels after removal: %v", err)
}
if len(labels) != 1 {
t.Errorf("expected 1 label after removal, got %d", len(labels))
}
}
func TestDoltStoreDependencies(t *testing.T) {
store, cleanup := setupTestStore(t)
defer cleanup()
ctx, cancel := testContext(t)
defer cancel()
// Create parent and child issues
parent := &types.Issue{
ID: "test-parent",
Title: "Parent Issue",
Description: "Parent description",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeEpic,
}
child := &types.Issue{
ID: "test-child",
Title: "Child Issue",
Description: "Child description",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, parent, "tester"); err != nil {
t.Fatalf("failed to create parent issue: %v", err)
}
if err := store.CreateIssue(ctx, child, "tester"); err != nil {
t.Fatalf("failed to create child issue: %v", err)
}
// Add dependency (child depends on parent)
dep := &types.Dependency{
IssueID: child.ID,
DependsOnID: parent.ID,
Type: types.DepBlocks,
}
if err := store.AddDependency(ctx, dep, "tester"); err != nil {
t.Fatalf("failed to add dependency: %v", err)
}
// Get dependencies
deps, err := store.GetDependencies(ctx, child.ID)
if err != nil {
t.Fatalf("failed to get dependencies: %v", err)
}
if len(deps) != 1 {
t.Errorf("expected 1 dependency, got %d", len(deps))
}
if deps[0].ID != parent.ID {
t.Errorf("expected dependency on %s, got %s", parent.ID, deps[0].ID)
}
// Get dependents
dependents, err := store.GetDependents(ctx, parent.ID)
if err != nil {
t.Fatalf("failed to get dependents: %v", err)
}
if len(dependents) != 1 {
t.Errorf("expected 1 dependent, got %d", len(dependents))
}
// Check if blocked
blocked, blockers, err := store.IsBlocked(ctx, child.ID)
if err != nil {
t.Fatalf("failed to check if blocked: %v", err)
}
if !blocked {
t.Error("expected child to be blocked")
}
if len(blockers) != 1 || blockers[0] != parent.ID {
t.Errorf("expected blocker %s, got %v", parent.ID, blockers)
}
// Remove dependency
if err := store.RemoveDependency(ctx, child.ID, parent.ID, "tester"); err != nil {
t.Fatalf("failed to remove dependency: %v", err)
}
// Verify removal
deps, err = store.GetDependencies(ctx, child.ID)
if err != nil {
t.Fatalf("failed to get dependencies after removal: %v", err)
}
if len(deps) != 0 {
t.Errorf("expected 0 dependencies after removal, got %d", len(deps))
}
}
func TestDoltStoreSearch(t *testing.T) {
store, cleanup := setupTestStore(t)
defer cleanup()
ctx := context.Background()
// Create multiple issues
issues := []*types.Issue{
{
ID: "test-search-1",
Title: "First Issue",
Description: "Search test one",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
},
{
ID: "test-search-2",
Title: "Second Issue",
Description: "Search test two",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeBug,
},
{
ID: "test-search-3",
Title: "Third Issue",
Description: "Different content",
Status: types.StatusClosed,
Priority: 3,
IssueType: types.TypeTask,
},
}
for _, issue := range issues {
if err := store.CreateIssue(ctx, issue, "tester"); err != nil {
t.Fatalf("failed to create issue %s: %v", issue.ID, err)
}
}
// Search by query
results, err := store.SearchIssues(ctx, "Search test", types.IssueFilter{})
if err != nil {
t.Fatalf("failed to search issues: %v", err)
}
if len(results) != 2 {
t.Errorf("expected 2 results for 'Search test', got %d", len(results))
}
// Search with status filter
openStatus := types.StatusOpen
results, err = store.SearchIssues(ctx, "", types.IssueFilter{Status: &openStatus})
if err != nil {
t.Fatalf("failed to search with status filter: %v", err)
}
if len(results) != 2 {
t.Errorf("expected 2 open issues, got %d", len(results))
}
// Search by issue type
bugType := types.TypeBug
results, err = store.SearchIssues(ctx, "", types.IssueFilter{IssueType: &bugType})
if err != nil {
t.Fatalf("failed to search by type: %v", err)
}
if len(results) != 1 {
t.Errorf("expected 1 bug, got %d", len(results))
}
}
func TestDoltStoreCreateIssues(t *testing.T) {
store, cleanup := setupTestStore(t)
defer cleanup()
ctx := context.Background()
// Create multiple issues in batch
issues := []*types.Issue{
{
ID: "test-batch-1",
Title: "Batch Issue 1",
Description: "First batch issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
},
{
ID: "test-batch-2",
Title: "Batch Issue 2",
Description: "Second batch issue",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
},
}
if err := store.CreateIssues(ctx, issues, "tester"); err != nil {
t.Fatalf("failed to create issues: %v", err)
}
// Verify all issues were created
for _, issue := range issues {
retrieved, err := store.GetIssue(ctx, issue.ID)
if err != nil {
t.Fatalf("failed to get issue %s: %v", issue.ID, err)
}
if retrieved == nil {
t.Errorf("expected to retrieve issue %s", issue.ID)
}
if retrieved.Title != issue.Title {
t.Errorf("expected title %q, got %q", issue.Title, retrieved.Title)
}
}
}
func TestDoltStoreComments(t *testing.T) {
store, cleanup := setupTestStore(t)
defer cleanup()
ctx := context.Background()
// Create an issue
issue := &types.Issue{
ID: "test-comment-issue",
Title: "Issue with Comments",
Description: "Test comments",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, issue, "tester"); err != nil {
t.Fatalf("failed to create issue: %v", err)
}
// Add comments
comment1, err := store.AddIssueComment(ctx, issue.ID, "user1", "First comment")
if err != nil {
t.Fatalf("failed to add first comment: %v", err)
}
if comment1.ID == 0 {
t.Error("expected comment ID to be generated")
}
_, err = store.AddIssueComment(ctx, issue.ID, "user2", "Second comment")
if err != nil {
t.Fatalf("failed to add second comment: %v", err)
}
// Get comments
comments, err := store.GetIssueComments(ctx, issue.ID)
if err != nil {
t.Fatalf("failed to get comments: %v", err)
}
if len(comments) != 2 {
t.Errorf("expected 2 comments, got %d", len(comments))
}
if comments[0].Text != "First comment" {
t.Errorf("expected 'First comment', got %q", comments[0].Text)
}
}
func TestDoltStoreEvents(t *testing.T) {
store, cleanup := setupTestStore(t)
defer cleanup()
ctx := context.Background()
// Create an issue (this creates a creation event)
issue := &types.Issue{
ID: "test-event-issue",
Title: "Issue with Events",
Description: "Test events",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, issue, "tester"); err != nil {
t.Fatalf("failed to create issue: %v", err)
}
// Add a comment event
if err := store.AddComment(ctx, issue.ID, "user1", "A comment"); err != nil {
t.Fatalf("failed to add comment: %v", err)
}
// Get events
events, err := store.GetEvents(ctx, issue.ID, 10)
if err != nil {
t.Fatalf("failed to get events: %v", err)
}
if len(events) < 2 {
t.Errorf("expected at least 2 events, got %d", len(events))
}
}
func TestDoltStoreDeleteIssue(t *testing.T) {
store, cleanup := setupTestStore(t)
defer cleanup()
ctx := context.Background()
// Create an issue
issue := &types.Issue{
ID: "test-delete-issue",
Title: "Issue to Delete",
Description: "Will be deleted",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, issue, "tester"); err != nil {
t.Fatalf("failed to create issue: %v", err)
}
// Verify it exists
retrieved, err := store.GetIssue(ctx, issue.ID)
if err != nil || retrieved == nil {
t.Fatalf("issue should exist before delete")
}
// Delete the issue
if err := store.DeleteIssue(ctx, issue.ID); err != nil {
t.Fatalf("failed to delete issue: %v", err)
}
// Verify it's gone
retrieved, err = store.GetIssue(ctx, issue.ID)
if err != nil {
t.Fatalf("failed to get issue after delete: %v", err)
}
if retrieved != nil {
t.Error("expected issue to be deleted")
}
}
func TestDoltStoreDirtyTracking(t *testing.T) {
store, cleanup := setupTestStore(t)
defer cleanup()
ctx := context.Background()
// Create an issue (marks it dirty)
issue := &types.Issue{
ID: "test-dirty-issue",
Title: "Dirty Issue",
Description: "Will be dirty",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, issue, "tester"); err != nil {
t.Fatalf("failed to create issue: %v", err)
}
// Get dirty issues
dirtyIDs, err := store.GetDirtyIssues(ctx)
if err != nil {
t.Fatalf("failed to get dirty issues: %v", err)
}
found := false
for _, id := range dirtyIDs {
if id == issue.ID {
found = true
break
}
}
if !found {
t.Error("expected issue to be in dirty list")
}
// Clear dirty issues
if err := store.ClearDirtyIssuesByID(ctx, []string{issue.ID}); err != nil {
t.Fatalf("failed to clear dirty issues: %v", err)
}
// Verify it's cleared
dirtyIDs, err = store.GetDirtyIssues(ctx)
if err != nil {
t.Fatalf("failed to get dirty issues after clear: %v", err)
}
for _, id := range dirtyIDs {
if id == issue.ID {
t.Error("expected issue to be cleared from dirty list")
}
}
}
func TestDoltStoreStatistics(t *testing.T) {
store, cleanup := setupTestStore(t)
defer cleanup()
ctx := context.Background()
// Create some issues
issues := []*types.Issue{
{ID: "test-stat-1", Title: "Open 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask},
{ID: "test-stat-2", Title: "Open 2", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask},
{ID: "test-stat-3", Title: "Closed", Status: types.StatusClosed, Priority: 1, IssueType: types.TypeTask},
}
for _, issue := range issues {
if err := store.CreateIssue(ctx, issue, "tester"); err != nil {
t.Fatalf("failed to create issue: %v", err)
}
}
// Get statistics
stats, err := store.GetStatistics(ctx)
if err != nil {
t.Fatalf("failed to get statistics: %v", err)
}
if stats.OpenIssues < 2 {
t.Errorf("expected at least 2 open issues, got %d", stats.OpenIssues)
}
if stats.ClosedIssues < 1 {
t.Errorf("expected at least 1 closed issue, got %d", stats.ClosedIssues)
}
}
// Test SQL injection protection
func TestValidateRef(t *testing.T) {
tests := []struct {
name string
ref string
wantErr bool
}{
{"valid hash", "abc123def456", false},
{"valid branch", "main", false},
{"valid with underscore", "feature_branch", false},
{"valid with dash", "feature-branch", false},
{"empty", "", true},
{"too long", string(make([]byte, 200)), true},
{"with SQL injection", "main'; DROP TABLE issues; --", true},
{"with quotes", "main'test", true},
{"with semicolon", "main;test", true},
{"with space", "main test", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateRef(tt.ref)
if (err != nil) != tt.wantErr {
t.Errorf("validateRef(%q) error = %v, wantErr %v", tt.ref, err, tt.wantErr)
}
})
}
}
func TestValidateTableName(t *testing.T) {
tests := []struct {
name string
tableName string
wantErr bool
}{
{"valid table", "issues", false},
{"valid with underscore", "dirty_issues", false},
{"valid with numbers", "table123", false},
{"empty", "", true},
{"too long", string(make([]byte, 100)), true},
{"starts with number", "123table", true},
{"with SQL injection", "issues'; DROP TABLE issues; --", true},
{"with space", "my table", true},
{"with dash", "my-table", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateTableName(tt.tableName)
if (err != nil) != tt.wantErr {
t.Errorf("validateTableName(%q) error = %v, wantErr %v", tt.tableName, err, tt.wantErr)
}
})
}
}
func TestDoltStoreGetReadyWork(t *testing.T) {
store, cleanup := setupTestStore(t)
defer cleanup()
ctx := context.Background()
// Create issues: one blocked, one ready
blocker := &types.Issue{
ID: "test-blocker",
Title: "Blocker",
Description: "Blocks another issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
blocked := &types.Issue{
ID: "test-blocked",
Title: "Blocked",
Description: "Is blocked",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
ready := &types.Issue{
ID: "test-ready",
Title: "Ready",
Description: "Is ready",
Status: types.StatusOpen,
Priority: 3,
IssueType: types.TypeTask,
}
for _, issue := range []*types.Issue{blocker, blocked, ready} {
if err := store.CreateIssue(ctx, issue, "tester"); err != nil {
t.Fatalf("failed to create issue %s: %v", issue.ID, err)
}
}
// Add blocking dependency
dep := &types.Dependency{
IssueID: blocked.ID,
DependsOnID: blocker.ID,
Type: types.DepBlocks,
}
if err := store.AddDependency(ctx, dep, "tester"); err != nil {
t.Fatalf("failed to add dependency: %v", err)
}
// Get ready work
readyWork, err := store.GetReadyWork(ctx, types.WorkFilter{})
if err != nil {
t.Fatalf("failed to get ready work: %v", err)
}
// Should include blocker and ready, but not blocked
foundBlocker := false
foundBlocked := false
foundReady := false
for _, issue := range readyWork {
switch issue.ID {
case blocker.ID:
foundBlocker = true
case blocked.ID:
foundBlocked = true
case ready.ID:
foundReady = true
}
}
if !foundBlocker {
t.Error("expected blocker to be in ready work")
}
if foundBlocked {
t.Error("expected blocked issue to NOT be in ready work")
}
if !foundReady {
t.Error("expected ready issue to be in ready work")
}
}