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>
854 lines
21 KiB
Go
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")
|
|
}
|
|
}
|