package sqlite import ( "context" "fmt" "testing" "time" "github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/types" ) // TestRunInTransactionBasic verifies the RunInTransaction method exists and // can be called. func TestRunInTransactionBasic(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() // Test that we can call RunInTransaction callCount := 0 err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { callCount++ return nil }) if err != nil { t.Errorf("RunInTransaction returned error: %v", err) } if callCount != 1 { t.Errorf("expected callback to be called once, got %d", callCount) } } // TestRunInTransactionRollbackOnError verifies that returning an error // from the callback does not cause a panic and the error is propagated. func TestRunInTransactionRollbackOnError(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() expectedErr := "intentional test error" err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { return &testError{msg: expectedErr} }) if err == nil { t.Error("expected error to be returned, got nil") } if err.Error() != expectedErr { t.Errorf("expected error %q, got %q", expectedErr, err.Error()) } } // TestRunInTransactionPanicRecovery verifies that panics in the callback // are recovered and re-raised after rollback. func TestRunInTransactionPanicRecovery(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() defer func() { if r := recover(); r == nil { t.Error("expected panic to be re-raised, but no panic occurred") } else if r != "test panic" { t.Errorf("unexpected panic value: %v", r) } }() _ = store.RunInTransaction(ctx, func(tx storage.Transaction) error { panic("test panic") }) t.Error("should not reach here - panic should have been re-raised") } // TestTransactionCreateIssue tests creating an issue within a transaction. func TestTransactionCreateIssue(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() var createdID string err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { issue := &types.Issue{ Title: "Test Issue", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, } if err := tx.CreateIssue(ctx, issue, "test-actor"); err != nil { return err } createdID = issue.ID return nil }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } if createdID == "" { t.Error("expected issue ID to be set after creation") } // Verify issue exists after commit issue, err := store.GetIssue(ctx, createdID) if err != nil { t.Fatalf("GetIssue failed: %v", err) } if issue == nil { t.Error("expected issue to exist after transaction commit") } if issue.Title != "Test Issue" { t.Errorf("expected title 'Test Issue', got %q", issue.Title) } } // TestTransactionRollbackOnCreateError tests that issues are not created // when transaction rolls back due to error. func TestTransactionRollbackOnCreateError(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() var createdID string err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { issue := &types.Issue{ Title: "Test Issue", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, } if err := tx.CreateIssue(ctx, issue, "test-actor"); err != nil { return err } createdID = issue.ID // Return error to trigger rollback return &testError{msg: "intentional rollback"} }) if err == nil { t.Error("expected error from transaction") } // Verify issue does NOT exist after rollback if createdID != "" { issue, err := store.GetIssue(ctx, createdID) if err != nil { t.Fatalf("GetIssue failed: %v", err) } if issue != nil { t.Error("expected issue to NOT exist after transaction rollback") } } } // TestTransactionMultipleIssues tests creating multiple issues atomically. func TestTransactionMultipleIssues(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() var ids []string err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { for i := 0; i < 3; i++ { issue := &types.Issue{ Title: "Test Issue", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, } if err := tx.CreateIssue(ctx, issue, "test-actor"); err != nil { return err } ids = append(ids, issue.ID) } return nil }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } // Verify all issues exist for _, id := range ids { issue, err := store.GetIssue(ctx, id) if err != nil { t.Fatalf("GetIssue failed for %s: %v", id, err) } if issue == nil { t.Errorf("expected issue %s to exist", id) } } } // TestTransactionUpdateIssue tests updating an issue within a transaction. func TestTransactionUpdateIssue(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() // Create issue first issue := &types.Issue{ Title: "Original Title", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, } if err := store.CreateIssue(ctx, issue, "test-actor"); err != nil { t.Fatalf("CreateIssue failed: %v", err) } // Update in transaction err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { return tx.UpdateIssue(ctx, issue.ID, map[string]interface{}{ "title": "Updated Title", }, "test-actor") }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } // Verify update updated, err := store.GetIssue(ctx, issue.ID) if err != nil { t.Fatalf("GetIssue failed: %v", err) } if updated.Title != "Updated Title" { t.Errorf("expected title 'Updated Title', got %q", updated.Title) } } // TestTransactionCloseIssue tests closing an issue within a transaction. func TestTransactionCloseIssue(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() // Create issue first issue := &types.Issue{ Title: "Test Issue", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, } if err := store.CreateIssue(ctx, issue, "test-actor"); err != nil { t.Fatalf("CreateIssue failed: %v", err) } // Close in transaction err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { return tx.CloseIssue(ctx, issue.ID, "Done", "test-actor", "") }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } // Verify closed closed, err := store.GetIssue(ctx, issue.ID) if err != nil { t.Fatalf("GetIssue failed: %v", err) } if closed.Status != types.StatusClosed { t.Errorf("expected status 'closed', got %q", closed.Status) } if closed.CloseReason != "Done" { t.Errorf("expected close_reason 'Done', got %q", closed.CloseReason) } } // TestTransactionDeleteIssue tests deleting an issue within a transaction. func TestTransactionDeleteIssue(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() // Create issue first issue := &types.Issue{ Title: "Test Issue", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, } if err := store.CreateIssue(ctx, issue, "test-actor"); err != nil { t.Fatalf("CreateIssue failed: %v", err) } // Delete in transaction err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { return tx.DeleteIssue(ctx, issue.ID) }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } // Verify deleted deleted, err := store.GetIssue(ctx, issue.ID) if err != nil { t.Fatalf("GetIssue failed: %v", err) } if deleted != nil { t.Error("expected issue to be deleted") } } // TestTransactionGetIssue tests read-your-writes within a transaction. func TestTransactionGetIssue(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { // Create issue issue := &types.Issue{ Title: "Test Issue", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, } if err := tx.CreateIssue(ctx, issue, "test-actor"); err != nil { return err } // Read it back within same transaction (read-your-writes) retrieved, err := tx.GetIssue(ctx, issue.ID) if err != nil { return err } if retrieved == nil { t.Error("expected to read issue within transaction") } if retrieved.Title != "Test Issue" { t.Errorf("expected title 'Test Issue', got %q", retrieved.Title) } return nil }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } } // TestTransactionCreateIssues tests batch issue creation within a transaction. func TestTransactionCreateIssues(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() var ids []string err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { issues := []*types.Issue{ {Title: "Issue 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}, {Title: "Issue 2", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}, {Title: "Issue 3", Status: types.StatusOpen, Priority: 3, IssueType: types.TypeTask}, } if err := tx.CreateIssues(ctx, issues, "test-actor"); err != nil { return err } for _, issue := range issues { ids = append(ids, issue.ID) } return nil }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } // Verify all issues exist for i, id := range ids { issue, err := store.GetIssue(ctx, id) if err != nil { t.Fatalf("GetIssue failed for %s: %v", id, err) } if issue == nil { t.Errorf("expected issue %s to exist", id) } expectedTitle := "Issue " + string(rune('1'+i)) if issue.Title != expectedTitle { t.Errorf("expected title %q, got %q", expectedTitle, issue.Title) } } } // TestTransactionAddDependency tests adding a dependency within a transaction. func TestTransactionAddDependency(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() // Create two issues first issue1 := &types.Issue{Title: "Issue 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue2 := &types.Issue{Title: "Issue 2", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask} if err := store.CreateIssue(ctx, issue1, "test-actor"); err != nil { t.Fatalf("CreateIssue failed: %v", err) } if err := store.CreateIssue(ctx, issue2, "test-actor"); err != nil { t.Fatalf("CreateIssue failed: %v", err) } // Add dependency in transaction err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { dep := &types.Dependency{ IssueID: issue1.ID, DependsOnID: issue2.ID, Type: types.DepBlocks, } return tx.AddDependency(ctx, dep, "test-actor") }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } // Verify dependency exists deps, err := store.GetDependencies(ctx, issue1.ID) if err != nil { t.Fatalf("GetDependencies failed: %v", err) } if len(deps) != 1 { t.Errorf("expected 1 dependency, got %d", len(deps)) } if deps[0].ID != issue2.ID { t.Errorf("expected dependency on %s, got %s", issue2.ID, deps[0].ID) } } // TestTransactionAddDependency_RelatesTo tests that bidirectional relates-to // dependencies work in transaction context. This is a regression test for // Decision 004 Phase 4 - the cycle detection must exempt relates-to type // since bidirectional relationships are semantically valid. func TestTransactionAddDependency_RelatesTo(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() // Create two issues issue1 := &types.Issue{Title: "Issue 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue2 := &types.Issue{Title: "Issue 2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} if err := store.CreateIssue(ctx, issue1, "test-actor"); err != nil { t.Fatalf("CreateIssue failed: %v", err) } if err := store.CreateIssue(ctx, issue2, "test-actor"); err != nil { t.Fatalf("CreateIssue failed: %v", err) } // Add bidirectional relates-to in a single transaction // This should NOT fail cycle detection since relates-to is exempt err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { // First direction: issue1 relates-to issue2 dep1 := &types.Dependency{ IssueID: issue1.ID, DependsOnID: issue2.ID, Type: types.DepRelatesTo, } if err := tx.AddDependency(ctx, dep1, "test-actor"); err != nil { return fmt.Errorf("first relates-to failed: %w", err) } // Second direction: issue2 relates-to issue1 (would be a cycle for other types) dep2 := &types.Dependency{ IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepRelatesTo, } if err := tx.AddDependency(ctx, dep2, "test-actor"); err != nil { return fmt.Errorf("second relates-to failed: %w", err) } return nil }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } // Verify both directions exist deps1, err := store.GetDependenciesWithMetadata(ctx, issue1.ID) if err != nil { t.Fatalf("GetDependenciesWithMetadata failed: %v", err) } found1 := false for _, d := range deps1 { if d.ID == issue2.ID && d.DependencyType == types.DepRelatesTo { found1 = true } } if !found1 { t.Errorf("issue1 should have relates-to link to issue2") } deps2, err := store.GetDependenciesWithMetadata(ctx, issue2.ID) if err != nil { t.Fatalf("GetDependenciesWithMetadata failed: %v", err) } found2 := false for _, d := range deps2 { if d.ID == issue1.ID && d.DependencyType == types.DepRelatesTo { found2 = true } } if !found2 { t.Errorf("issue2 should have relates-to link to issue1") } } // TestTransactionAddDependency_RepliesTo tests that replies-to dependencies // preserve thread_id in transaction context (Decision 004 Phase 4). func TestTransactionAddDependency_RepliesTo(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() // Create original message and reply original := &types.Issue{ Title: "Original Message", Status: types.StatusOpen, Priority: 2, IssueType: "message", Sender: "alice", } reply := &types.Issue{ Title: "Re: Original Message", Status: types.StatusOpen, Priority: 2, IssueType: "message", Sender: "bob", } if err := store.CreateIssue(ctx, original, "test-actor"); err != nil { t.Fatalf("CreateIssue failed: %v", err) } if err := store.CreateIssue(ctx, reply, "test-actor"); err != nil { t.Fatalf("CreateIssue failed: %v", err) } // Add replies-to with thread_id in transaction err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { dep := &types.Dependency{ IssueID: reply.ID, DependsOnID: original.ID, Type: types.DepRepliesTo, ThreadID: original.ID, // Thread root } return tx.AddDependency(ctx, dep, "test-actor") }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } // Verify the dependency and thread_id were preserved deps, err := store.GetDependenciesWithMetadata(ctx, reply.ID) if err != nil { t.Fatalf("GetDependenciesWithMetadata failed: %v", err) } found := false for _, d := range deps { if d.ID == original.ID && d.DependencyType == types.DepRepliesTo { found = true } } if !found { t.Errorf("reply should have replies-to link to original") } // Verify thread_id by querying dependencies table directly var threadID string err = store.UnderlyingDB().QueryRowContext(ctx, `SELECT thread_id FROM dependencies WHERE issue_id = ? AND depends_on_id = ?`, reply.ID, original.ID).Scan(&threadID) if err != nil { t.Fatalf("Failed to query thread_id: %v", err) } if threadID != original.ID { t.Errorf("thread_id = %q, want %q", threadID, original.ID) } } // TestTransactionRemoveDependency tests removing a dependency within a transaction. func TestTransactionRemoveDependency(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() // Create two issues and add dependency issue1 := &types.Issue{Title: "Issue 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue2 := &types.Issue{Title: "Issue 2", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask} if err := store.CreateIssue(ctx, issue1, "test-actor"); err != nil { t.Fatalf("CreateIssue failed: %v", err) } if err := store.CreateIssue(ctx, issue2, "test-actor"); err != nil { t.Fatalf("CreateIssue failed: %v", err) } dep := &types.Dependency{IssueID: issue1.ID, DependsOnID: issue2.ID, Type: types.DepBlocks} if err := store.AddDependency(ctx, dep, "test-actor"); err != nil { t.Fatalf("AddDependency failed: %v", err) } // Remove dependency in transaction err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { return tx.RemoveDependency(ctx, issue1.ID, issue2.ID, "test-actor") }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } // Verify dependency is gone deps, err := store.GetDependencies(ctx, issue1.ID) if err != nil { t.Fatalf("GetDependencies failed: %v", err) } if len(deps) != 0 { t.Errorf("expected 0 dependencies, got %d", len(deps)) } } // TestTransactionAddLabel tests adding a label within a transaction. func TestTransactionAddLabel(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() // Create issue first issue := &types.Issue{Title: "Test Issue", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} if err := store.CreateIssue(ctx, issue, "test-actor"); err != nil { t.Fatalf("CreateIssue failed: %v", err) } // Add label in transaction err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { return tx.AddLabel(ctx, issue.ID, "test-label", "test-actor") }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } // Verify label exists labels, err := store.GetLabels(ctx, issue.ID) if err != nil { t.Fatalf("GetLabels failed: %v", err) } if len(labels) != 1 { t.Errorf("expected 1 label, got %d", len(labels)) } if labels[0] != "test-label" { t.Errorf("expected label 'test-label', got %s", labels[0]) } } // TestTransactionRemoveLabel tests removing a label within a transaction. func TestTransactionRemoveLabel(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() // Create issue and add label issue := &types.Issue{Title: "Test Issue", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} if err := store.CreateIssue(ctx, issue, "test-actor"); err != nil { t.Fatalf("CreateIssue failed: %v", err) } if err := store.AddLabel(ctx, issue.ID, "test-label", "test-actor"); err != nil { t.Fatalf("AddLabel failed: %v", err) } // Remove label in transaction err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { return tx.RemoveLabel(ctx, issue.ID, "test-label", "test-actor") }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } // Verify label is gone labels, err := store.GetLabels(ctx, issue.ID) if err != nil { t.Fatalf("GetLabels failed: %v", err) } if len(labels) != 0 { t.Errorf("expected 0 labels, got %d", len(labels)) } } // TestTransactionAtomicIssueWithDependency tests creating issue + adding dependency atomically. func TestTransactionAtomicIssueWithDependency(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() // Create parent issue first parent := &types.Issue{Title: "Parent", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} if err := store.CreateIssue(ctx, parent, "test-actor"); err != nil { t.Fatalf("CreateIssue failed: %v", err) } var childID string err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { // Create child issue child := &types.Issue{Title: "Child", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask} if err := tx.CreateIssue(ctx, child, "test-actor"); err != nil { return err } childID = child.ID // Add dependency: child blocks parent (child must be done before parent) dep := &types.Dependency{ IssueID: parent.ID, DependsOnID: child.ID, Type: types.DepBlocks, } return tx.AddDependency(ctx, dep, "test-actor") }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } // Verify both issue and dependency exist child, err := store.GetIssue(ctx, childID) if err != nil || child == nil { t.Error("expected child issue to exist") } deps, err := store.GetDependencies(ctx, parent.ID) if err != nil { t.Fatalf("GetDependencies failed: %v", err) } if len(deps) != 1 || deps[0].ID != childID { t.Error("expected dependency from parent to child") } } // TestTransactionAtomicIssueWithLabels tests creating issue + adding labels atomically. func TestTransactionAtomicIssueWithLabels(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() var issueID string err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { // Create issue issue := &types.Issue{Title: "Test Issue", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} if err := tx.CreateIssue(ctx, issue, "test-actor"); err != nil { return err } issueID = issue.ID // Add multiple labels for _, label := range []string{"label1", "label2", "label3"} { if err := tx.AddLabel(ctx, issue.ID, label, "test-actor"); err != nil { return err } } return nil }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } // Verify issue and all labels exist issue, err := store.GetIssue(ctx, issueID) if err != nil || issue == nil { t.Error("expected issue to exist") } labels, err := store.GetLabels(ctx, issueID) if err != nil { t.Fatalf("GetLabels failed: %v", err) } if len(labels) != 3 { t.Errorf("expected 3 labels, got %d", len(labels)) } } // TestTransactionEmpty tests that an empty transaction commits successfully. func TestTransactionEmpty(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { // Do nothing - empty transaction return nil }) if err != nil { t.Errorf("empty transaction should succeed, got error: %v", err) } } // TestTransactionConcurrent tests multiple concurrent transactions. func TestTransactionConcurrent(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() const numGoroutines = 10 errors := make(chan error, numGoroutines) ids := make(chan string, numGoroutines) // Launch concurrent transactions for i := 0; i < numGoroutines; i++ { go func(index int) { err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { issue := &types.Issue{ Title: "Concurrent Issue", Status: types.StatusOpen, Priority: index % 4, IssueType: types.TypeTask, } if err := tx.CreateIssue(ctx, issue, "test-actor"); err != nil { return err } ids <- issue.ID return nil }) errors <- err }(i) } // Collect results var errs []error var createdIDs []string for i := 0; i < numGoroutines; i++ { if err := <-errors; err != nil { errs = append(errs, err) } } close(ids) for id := range ids { createdIDs = append(createdIDs, id) } if len(errs) > 0 { t.Errorf("some transactions failed: %v", errs) } if len(createdIDs) != numGoroutines { t.Errorf("expected %d issues created, got %d", numGoroutines, len(createdIDs)) } // Verify all issues exist for _, id := range createdIDs { issue, err := store.GetIssue(ctx, id) if err != nil || issue == nil { t.Errorf("expected issue %s to exist", id) } } } // TestTransactionNestedFailure tests that when first op succeeds but second fails, // both are rolled back. func TestTransactionNestedFailure(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() var firstIssueID string err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { // First operation succeeds issue1 := &types.Issue{ Title: "First Issue", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, } if err := tx.CreateIssue(ctx, issue1, "test-actor"); err != nil { return err } firstIssueID = issue1.ID // Second operation fails issue2 := &types.Issue{ Title: "", // Invalid - missing title Status: types.StatusOpen, Priority: 2, } return tx.CreateIssue(ctx, issue2, "test-actor") }) if err == nil { t.Error("expected error from invalid second issue") } // Verify first issue was NOT created (rolled back) if firstIssueID != "" { issue, err := store.GetIssue(ctx, firstIssueID) if err != nil { t.Fatalf("GetIssue failed: %v", err) } if issue != nil { t.Error("expected first issue to be rolled back, but it exists") } } } // TestTransactionAtomicPlanApproval simulates a VC plan approval workflow: // creating multiple issues with dependencies and labels atomically. func TestTransactionAtomicPlanApproval(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() var epicID, task1ID, task2ID string err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { // Create epic epic := &types.Issue{ Title: "Epic: Feature Implementation", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic, } if err := tx.CreateIssue(ctx, epic, "test-actor"); err != nil { return err } epicID = epic.ID // Create task 1 task1 := &types.Issue{ Title: "Task 1: Setup", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, } if err := tx.CreateIssue(ctx, task1, "test-actor"); err != nil { return err } task1ID = task1.ID // Create task 2 task2 := &types.Issue{ Title: "Task 2: Implementation", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, } if err := tx.CreateIssue(ctx, task2, "test-actor"); err != nil { return err } task2ID = task2.ID // Add dependencies: task2 depends on task1 dep := &types.Dependency{ IssueID: task2ID, DependsOnID: task1ID, Type: types.DepBlocks, } if err := tx.AddDependency(ctx, dep, "test-actor"); err != nil { return err } // Add labels to all issues for _, id := range []string{epicID, task1ID, task2ID} { if err := tx.AddLabel(ctx, id, "feature-x", "test-actor"); err != nil { return err } } return nil }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } // Verify all issues exist for _, id := range []string{epicID, task1ID, task2ID} { issue, err := store.GetIssue(ctx, id) if err != nil || issue == nil { t.Errorf("expected issue %s to exist", id) } } // Verify dependency deps, err := store.GetDependencies(ctx, task2ID) if err != nil { t.Fatalf("GetDependencies failed: %v", err) } if len(deps) != 1 || deps[0].ID != task1ID { t.Error("expected task2 to depend on task1") } // Verify labels for _, id := range []string{epicID, task1ID, task2ID} { labels, err := store.GetLabels(ctx, id) if err != nil { t.Fatalf("GetLabels failed: %v", err) } if len(labels) != 1 || labels[0] != "feature-x" { t.Errorf("expected 'feature-x' label on %s", id) } } } // TestTransactionSetConfig tests setting a config value within a transaction. func TestTransactionSetConfig(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { return tx.SetConfig(ctx, "test.key", "test-value") }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } // Verify config was set value, err := store.GetConfig(ctx, "test.key") if err != nil { t.Fatalf("GetConfig failed: %v", err) } if value != "test-value" { t.Errorf("expected 'test-value', got %q", value) } } // TestTransactionGetConfig tests reading config within a transaction (read-your-writes). func TestTransactionGetConfig(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { // Set config if err := tx.SetConfig(ctx, "test.key", "test-value"); err != nil { return err } // Read it back within same transaction value, err := tx.GetConfig(ctx, "test.key") if err != nil { return err } if value != "test-value" { t.Errorf("expected 'test-value' within transaction, got %q", value) } return nil }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } } // TestTransactionConfigRollback tests that config changes are rolled back on error. func TestTransactionConfigRollback(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { if err := tx.SetConfig(ctx, "test.key", "test-value"); err != nil { return err } return &testError{msg: "intentional rollback"} }) if err == nil { t.Error("expected error from transaction") } // Verify config was NOT set (rolled back) value, err := store.GetConfig(ctx, "test.key") if err != nil { t.Fatalf("GetConfig failed: %v", err) } if value != "" { t.Errorf("expected empty value after rollback, got %q", value) } } // TestTransactionSetMetadata tests setting a metadata value within a transaction. func TestTransactionSetMetadata(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { return tx.SetMetadata(ctx, "test.metadata", "metadata-value") }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } // Verify metadata was set value, err := store.GetMetadata(ctx, "test.metadata") if err != nil { t.Fatalf("GetMetadata failed: %v", err) } if value != "metadata-value" { t.Errorf("expected 'metadata-value', got %q", value) } } // TestTransactionGetMetadata tests reading metadata within a transaction (read-your-writes). func TestTransactionGetMetadata(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { // Set metadata if err := tx.SetMetadata(ctx, "test.metadata", "metadata-value"); err != nil { return err } // Read it back within same transaction value, err := tx.GetMetadata(ctx, "test.metadata") if err != nil { return err } if value != "metadata-value" { t.Errorf("expected 'metadata-value' within transaction, got %q", value) } return nil }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } } // TestTransactionMetadataRollback tests that metadata changes are rolled back on error. func TestTransactionMetadataRollback(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { if err := tx.SetMetadata(ctx, "test.metadata", "metadata-value"); err != nil { return err } return &testError{msg: "intentional rollback"} }) if err == nil { t.Error("expected error from transaction") } // Verify metadata was NOT set (rolled back) value, err := store.GetMetadata(ctx, "test.metadata") if err != nil { t.Fatalf("GetMetadata failed: %v", err) } if value != "" { t.Errorf("expected empty value after rollback, got %q", value) } } // TestTransactionAddComment tests adding a comment within a transaction. func TestTransactionAddComment(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() // Create issue first issue := &types.Issue{ Title: "Test Issue", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, } if err := store.CreateIssue(ctx, issue, "test-actor"); err != nil { t.Fatalf("CreateIssue failed: %v", err) } // Add comment in transaction err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { return tx.AddComment(ctx, issue.ID, "commenter", "This is a test comment") }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } // Verify comment exists via events events, err := store.GetEvents(ctx, issue.ID, 10) if err != nil { t.Fatalf("GetEvents failed: %v", err) } found := false for _, e := range events { if e.EventType == types.EventCommented && e.Comment != nil && *e.Comment == "This is a test comment" { found = true break } } if !found { t.Error("expected comment event to exist") } } // TestTransactionAddCommentToCreatedIssue tests adding a comment to an issue created in the same transaction. func TestTransactionAddCommentToCreatedIssue(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() var issueID string err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { // Create issue issue := &types.Issue{ Title: "Test Issue", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, } if err := tx.CreateIssue(ctx, issue, "test-actor"); err != nil { return err } issueID = issue.ID // Add comment to the issue we just created return tx.AddComment(ctx, issue.ID, "commenter", "Comment on new issue") }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } // Verify both issue and comment exist issue, err := store.GetIssue(ctx, issueID) if err != nil || issue == nil { t.Error("expected issue to exist") } events, err := store.GetEvents(ctx, issueID, 10) if err != nil { t.Fatalf("GetEvents failed: %v", err) } found := false for _, e := range events { if e.EventType == types.EventCommented && e.Comment != nil && *e.Comment == "Comment on new issue" { found = true break } } if !found { t.Error("expected comment event to exist") } } // TestTransactionAddCommentNonexistentIssue tests that adding a comment to a nonexistent issue fails. func TestTransactionAddCommentNonexistentIssue(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { return tx.AddComment(ctx, "nonexistent-id", "commenter", "This should fail") }) if err == nil { t.Error("expected error when commenting on nonexistent issue") } } // TestTransactionCommentRollback tests that comments are rolled back on error. func TestTransactionCommentRollback(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() // Create issue first issue := &types.Issue{ Title: "Test Issue", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, } if err := store.CreateIssue(ctx, issue, "test-actor"); err != nil { t.Fatalf("CreateIssue failed: %v", err) } err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { if err := tx.AddComment(ctx, issue.ID, "commenter", "This comment should be rolled back"); err != nil { return err } return &testError{msg: "intentional rollback"} }) if err == nil { t.Error("expected error from transaction") } // Verify comment was NOT added (rolled back) events, err := store.GetEvents(ctx, issue.ID, 10) if err != nil { t.Fatalf("GetEvents failed: %v", err) } for _, e := range events { if e.EventType == types.EventCommented { t.Error("expected no comment events after rollback") } } } // TestTransactionAtomicConfigWithIssue tests atomically creating an issue and setting config. func TestTransactionAtomicConfigWithIssue(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() var issueID string err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { // Create issue issue := &types.Issue{ Title: "Test Issue", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, } if err := tx.CreateIssue(ctx, issue, "test-actor"); err != nil { return err } issueID = issue.ID // Set config referencing the issue if err := tx.SetConfig(ctx, "last_created_issue", issue.ID); err != nil { return err } // Set metadata if err := tx.SetMetadata(ctx, "import_marker", "test-import-123"); err != nil { return err } return nil }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } // Verify all three operations succeeded issue, err := store.GetIssue(ctx, issueID) if err != nil || issue == nil { t.Error("expected issue to exist") } configValue, err := store.GetConfig(ctx, "last_created_issue") if err != nil { t.Fatalf("GetConfig failed: %v", err) } if configValue != issueID { t.Errorf("expected config value %q, got %q", issueID, configValue) } metadataValue, err := store.GetMetadata(ctx, "import_marker") if err != nil { t.Fatalf("GetMetadata failed: %v", err) } if metadataValue != "test-import-123" { t.Errorf("expected metadata value 'test-import-123', got %q", metadataValue) } } // TestTransactionConfigOverwrite tests that SetConfig overwrites existing values. func TestTransactionConfigOverwrite(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() // Set initial value if err := store.SetConfig(ctx, "test.key", "initial"); err != nil { t.Fatalf("SetConfig failed: %v", err) } // Overwrite in transaction err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { return tx.SetConfig(ctx, "test.key", "updated") }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } // Verify overwrite value, err := store.GetConfig(ctx, "test.key") if err != nil { t.Fatalf("GetConfig failed: %v", err) } if value != "updated" { t.Errorf("expected 'updated', got %q", value) } } // TestTransactionGetConfigNonexistent tests getting a nonexistent config key returns empty string. func TestTransactionGetConfigNonexistent(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { value, err := tx.GetConfig(ctx, "nonexistent.key") if err != nil { return err } if value != "" { t.Errorf("expected empty string for nonexistent key, got %q", value) } return nil }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } } // TestTransactionGetMetadataNonexistent tests getting a nonexistent metadata key returns empty string. func TestTransactionGetMetadataNonexistent(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { value, err := tx.GetMetadata(ctx, "nonexistent.metadata") if err != nil { return err } if value != "" { t.Errorf("expected empty string for nonexistent metadata, got %q", value) } return nil }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } } // testError is a simple error type for testing type testError struct { msg string } func (e *testError) Error() string { return e.msg } // TestTransactionSearchIssuesBasic tests basic search within a transaction. func TestTransactionSearchIssuesBasic(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() // Create some issues first closedAt := time.Now() issues := []*types.Issue{ {Title: "Alpha task", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}, {Title: "Beta task", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}, {Title: "Gamma feature", Status: types.StatusClosed, Priority: 3, IssueType: types.TypeFeature, ClosedAt: &closedAt}, } for _, issue := range issues { if err := store.CreateIssue(ctx, issue, "test-actor"); err != nil { t.Fatalf("CreateIssue failed: %v", err) } } // Search within transaction err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { // Search by query results, err := tx.SearchIssues(ctx, "task", types.IssueFilter{}) if err != nil { return err } if len(results) != 2 { t.Errorf("expected 2 issues matching 'task', got %d", len(results)) } // Search by status closedStatus := types.StatusClosed results, err = tx.SearchIssues(ctx, "", types.IssueFilter{Status: &closedStatus}) if err != nil { return err } if len(results) != 1 { t.Errorf("expected 1 closed issue, got %d", len(results)) } return nil }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } } // TestTransactionSearchIssuesReadYourWrites is the KEY test: create an issue and search // for it within the same transaction (read-your-writes consistency). func TestTransactionSearchIssuesReadYourWrites(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() // Create an existing issue outside the transaction existingIssue := &types.Issue{ Title: "Existing Issue", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, } if err := store.CreateIssue(ctx, existingIssue, "test-actor"); err != nil { t.Fatalf("CreateIssue failed: %v", err) } var newIssueID string err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { // Create a new issue within the transaction newIssue := &types.Issue{ Title: "Unique Searchable Title XYZ123", Description: "This has special content ABC789", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeFeature, } if err := tx.CreateIssue(ctx, newIssue, "test-actor"); err != nil { return err } newIssueID = newIssue.ID // CRITICAL: Search for the just-created issue by title results, err := tx.SearchIssues(ctx, "XYZ123", types.IssueFilter{}) if err != nil { return err } if len(results) != 1 { t.Errorf("read-your-writes FAILED: expected 1 issue with title 'XYZ123', got %d", len(results)) return nil } if results[0].ID != newIssueID { t.Errorf("read-your-writes FAILED: found wrong issue, expected %s, got %s", newIssueID, results[0].ID) } // Search for it by description results, err = tx.SearchIssues(ctx, "ABC789", types.IssueFilter{}) if err != nil { return err } if len(results) != 1 { t.Errorf("read-your-writes FAILED: expected 1 issue with description 'ABC789', got %d", len(results)) } // Search by type filter featureType := types.TypeFeature results, err = tx.SearchIssues(ctx, "", types.IssueFilter{IssueType: &featureType}) if err != nil { return err } if len(results) != 1 { t.Errorf("read-your-writes FAILED: expected 1 feature type issue, got %d", len(results)) } return nil }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } // Verify the issue was committed issue, err := store.GetIssue(ctx, newIssueID) if err != nil { t.Fatalf("GetIssue failed: %v", err) } if issue == nil { t.Error("expected issue to be committed, but it wasn't found") } } // TestTransactionSearchIssuesWithFilters tests various filter options within transaction. func TestTransactionSearchIssuesWithFilters(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() closedAt := time.Now() err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { // Create issues with different attributes issues := []*types.Issue{ {Title: "P1 Bug", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeBug, Assignee: "alice"}, {Title: "P2 Task", Status: types.StatusInProgress, Priority: 2, IssueType: types.TypeTask, Assignee: "bob"}, {Title: "P3 Feature", Status: types.StatusClosed, Priority: 3, IssueType: types.TypeFeature, Assignee: "alice", ClosedAt: &closedAt}, } for _, issue := range issues { if err := tx.CreateIssue(ctx, issue, "test-actor"); err != nil { return err } } // Filter by assignee assignee := "alice" results, err := tx.SearchIssues(ctx, "", types.IssueFilter{Assignee: &assignee}) if err != nil { return err } if len(results) != 2 { t.Errorf("expected 2 issues assigned to alice, got %d", len(results)) } // Filter by priority priority := 1 results, err = tx.SearchIssues(ctx, "", types.IssueFilter{Priority: &priority}) if err != nil { return err } if len(results) != 1 { t.Errorf("expected 1 P1 issue, got %d", len(results)) } // Filter by type bugType := types.TypeBug results, err = tx.SearchIssues(ctx, "", types.IssueFilter{IssueType: &bugType}) if err != nil { return err } if len(results) != 1 { t.Errorf("expected 1 bug, got %d", len(results)) } // Combined filter: status + assignee inProgressStatus := types.StatusInProgress bobAssignee := "bob" results, err = tx.SearchIssues(ctx, "", types.IssueFilter{ Status: &inProgressStatus, Assignee: &bobAssignee, }) if err != nil { return err } if len(results) != 1 { t.Errorf("expected 1 in_progress issue assigned to bob, got %d", len(results)) } return nil }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } } // TestTransactionSearchIssuesWithLabels tests label filtering within transaction. func TestTransactionSearchIssuesWithLabels(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { // Create issues issue1 := &types.Issue{Title: "Issue 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue2 := &types.Issue{Title: "Issue 2", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask} if err := tx.CreateIssue(ctx, issue1, "test-actor"); err != nil { return err } if err := tx.CreateIssue(ctx, issue2, "test-actor"); err != nil { return err } // Add labels if err := tx.AddLabel(ctx, issue1.ID, "frontend", "test-actor"); err != nil { return err } if err := tx.AddLabel(ctx, issue1.ID, "urgent", "test-actor"); err != nil { return err } if err := tx.AddLabel(ctx, issue2.ID, "backend", "test-actor"); err != nil { return err } // Search by label (must have ALL labels) results, err := tx.SearchIssues(ctx, "", types.IssueFilter{Labels: []string{"frontend"}}) if err != nil { return err } if len(results) != 1 { t.Errorf("expected 1 issue with 'frontend' label, got %d", len(results)) } if len(results) > 0 && results[0].ID != issue1.ID { t.Errorf("expected issue1, got %s", results[0].ID) } // Verify labels are attached to the issue if len(results) > 0 && len(results[0].Labels) != 2 { t.Errorf("expected 2 labels on issue, got %d", len(results[0].Labels)) } // Search by multiple labels (AND) results, err = tx.SearchIssues(ctx, "", types.IssueFilter{Labels: []string{"frontend", "urgent"}}) if err != nil { return err } if len(results) != 1 { t.Errorf("expected 1 issue with both 'frontend' and 'urgent' labels, got %d", len(results)) } // Search by any label (OR) results, err = tx.SearchIssues(ctx, "", types.IssueFilter{LabelsAny: []string{"frontend", "backend"}}) if err != nil { return err } if len(results) != 2 { t.Errorf("expected 2 issues with either 'frontend' or 'backend' label, got %d", len(results)) } return nil }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } } // TestTransactionSearchIssuesRollback verifies uncommitted issues aren't visible outside transaction. func TestTransactionSearchIssuesRollback(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() // Try to create an issue but rollback (by returning an error) err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { issue := &types.Issue{ Title: "RollbackTestIssue999", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, } if err := tx.CreateIssue(ctx, issue, "test-actor"); err != nil { return err } // Verify it's visible within the transaction results, err := tx.SearchIssues(ctx, "RollbackTestIssue999", types.IssueFilter{}) if err != nil { return err } if len(results) != 1 { t.Errorf("expected issue to be visible within transaction, got %d results", len(results)) } // Return error to trigger rollback return &testError{msg: "intentional rollback"} }) if err == nil { t.Fatal("expected error from rollback, got nil") } // Verify the issue is NOT visible outside the transaction (it was rolled back) results, err := store.SearchIssues(ctx, "RollbackTestIssue999", types.IssueFilter{}) if err != nil { t.Fatalf("SearchIssues failed: %v", err) } if len(results) != 0 { t.Errorf("expected 0 issues after rollback, got %d - rollback didn't work!", len(results)) } } // TestTransactionSearchIssuesLimit tests the limit filter within transaction. func TestTransactionSearchIssuesLimit(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { // Create several issues for i := 0; i < 10; i++ { issue := &types.Issue{ Title: "Limit Test Issue", Status: types.StatusOpen, Priority: i % 5, IssueType: types.TypeTask, } if err := tx.CreateIssue(ctx, issue, "test-actor"); err != nil { return err } } // Search with limit results, err := tx.SearchIssues(ctx, "", types.IssueFilter{Limit: 3}) if err != nil { return err } if len(results) != 3 { t.Errorf("expected 3 issues with limit, got %d", len(results)) } // Search without limit should return all results, err = tx.SearchIssues(ctx, "", types.IssueFilter{}) if err != nil { return err } if len(results) != 10 { t.Errorf("expected 10 issues without limit, got %d", len(results)) } return nil }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } } // TestTransactionSearchIssuesWithPriorityRange tests priority range filters within transaction. func TestTransactionSearchIssuesWithPriorityRange(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { // Create issues with different priorities for i := 0; i < 5; i++ { issue := &types.Issue{ Title: "Priority Range Test", Status: types.StatusOpen, Priority: i, // P0, P1, P2, P3, P4 IssueType: types.TypeTask, } if err := tx.CreateIssue(ctx, issue, "test-actor"); err != nil { return err } } // Filter by PriorityMin only (P2 and higher priority = lower number) minPriority := 2 results, err := tx.SearchIssues(ctx, "", types.IssueFilter{PriorityMin: &minPriority}) if err != nil { return err } // Should get P2, P3, P4 if len(results) != 3 { t.Errorf("expected 3 issues with priority >= 2, got %d", len(results)) } // Filter by PriorityMax only maxPriority := 1 results, err = tx.SearchIssues(ctx, "", types.IssueFilter{PriorityMax: &maxPriority}) if err != nil { return err } // Should get P0, P1 if len(results) != 2 { t.Errorf("expected 2 issues with priority <= 1, got %d", len(results)) } // Filter by priority range minP := 1 maxP := 3 results, err = tx.SearchIssues(ctx, "", types.IssueFilter{PriorityMin: &minP, PriorityMax: &maxP}) if err != nil { return err } // Should get P1, P2, P3 if len(results) != 3 { t.Errorf("expected 3 issues with priority 1-3, got %d", len(results)) } return nil }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } } // TestTransactionSearchIssuesWithDateRange tests date range filters within transaction. func TestTransactionSearchIssuesWithDateRange(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { now := time.Now() past := now.Add(-48 * time.Hour) future := now.Add(24 * time.Hour) // Create issues - CreatedAt is set automatically issue1 := &types.Issue{ Title: "Recent Issue", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, } if err := tx.CreateIssue(ctx, issue1, "test-actor"); err != nil { return err } // Filter by CreatedAfter (should find the issue created just now) createdAfter := past results, err := tx.SearchIssues(ctx, "", types.IssueFilter{CreatedAfter: &createdAfter}) if err != nil { return err } if len(results) != 1 { t.Errorf("expected 1 issue created after past, got %d", len(results)) } // Filter by CreatedBefore with future time (should find the issue) results, err = tx.SearchIssues(ctx, "", types.IssueFilter{CreatedBefore: &future}) if err != nil { return err } if len(results) != 1 { t.Errorf("expected 1 issue created before future, got %d", len(results)) } // Filter by CreatedBefore with past time (should find nothing) results, err = tx.SearchIssues(ctx, "", types.IssueFilter{CreatedBefore: &past}) if err != nil { return err } if len(results) != 0 { t.Errorf("expected 0 issues created before past, got %d", len(results)) } return nil }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } } // TestTransactionSearchIssuesWithIDs tests IDs filter within transaction. func TestTransactionSearchIssuesWithIDs(t *testing.T) { ctx := context.Background() store, cleanup := setupTestDB(t) defer cleanup() err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { // Create several issues and collect their IDs var issueIDs []string for i := 0; i < 5; i++ { issue := &types.Issue{ Title: "IDs Filter Test", Status: types.StatusOpen, Priority: i, IssueType: types.TypeTask, } if err := tx.CreateIssue(ctx, issue, "test-actor"); err != nil { return err } issueIDs = append(issueIDs, issue.ID) } // Filter by specific IDs (first 2) results, err := tx.SearchIssues(ctx, "", types.IssueFilter{IDs: issueIDs[:2]}) if err != nil { return err } if len(results) != 2 { t.Errorf("expected 2 issues when filtering by 2 IDs, got %d", len(results)) } // Filter by single ID results, err = tx.SearchIssues(ctx, "", types.IssueFilter{IDs: []string{issueIDs[0]}}) if err != nil { return err } if len(results) != 1 { t.Errorf("expected 1 issue when filtering by 1 ID, got %d", len(results)) } if results[0].ID != issueIDs[0] { t.Errorf("expected issue ID %s, got %s", issueIDs[0], results[0].ID) } // Filter by non-existent ID results, err = tx.SearchIssues(ctx, "", types.IssueFilter{IDs: []string{"nonexistent-id"}}) if err != nil { return err } if len(results) != 0 { t.Errorf("expected 0 issues for non-existent ID, got %d", len(results)) } // Empty IDs filter should return all issues results, err = tx.SearchIssues(ctx, "", types.IssueFilter{IDs: []string{}}) if err != nil { return err } if len(results) != 5 { t.Errorf("expected 5 issues with empty IDs filter, got %d", len(results)) } return nil }) if err != nil { t.Fatalf("RunInTransaction failed: %v", err) } }