Files
beads/internal/storage/dolt/dolt_test.go
mayor 1dc36098a3 feat(storage): add Dolt backend for version-controlled issue storage
Implements a complete Dolt storage backend that mirrors the SQLite implementation
with MySQL-compatible syntax and adds version control capabilities.

Key features:
- Full Storage interface implementation (~50 methods)
- Version control operations: commit, push, pull, branch, merge, checkout
- History queries via AS OF and dolt_history_* tables
- Cell-level merge instead of line-level JSONL merge
- SQL injection protection with input validation

Bug fixes applied during implementation:
- Added missing quality_score, work_type, source_system to scanIssue
- Fixed Status() to properly parse boolean staged column
- Added validation to CreateIssues (was missing in batch create)
- Made RenameDependencyPrefix transactional
- Expanded GetIssueHistory to return more complete data

Test coverage: 17 tests covering CRUD, dependencies, labels, search,
comments, events, statistics, and SQL injection protection.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 21:06:10 -08:00

854 lines
21 KiB
Go

package dolt
import (
"context"
"os"
"testing"
"github.com/steveyegge/beads/internal/types"
)
// skipIfNoDolt skips the test if Dolt is not installed
func skipIfNoDolt(t *testing.T) {
t.Helper()
if _, err := os.Stat("/usr/local/bin/dolt"); os.IsNotExist(err) {
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 := context.Background()
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 := context.Background()
// 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")
}
}