bd sync: 2025-11-24 20:26:24
This commit is contained in:
@@ -884,3 +884,266 @@ func (t *sqliteTxStorage) AddComment(ctx context.Context, issueID, actor, commen
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SearchIssues finds issues matching query and filters within the transaction.
|
||||
// This enables read-your-writes semantics for searching within a transaction.
|
||||
func (t *sqliteTxStorage) SearchIssues(ctx context.Context, query string, filter types.IssueFilter) ([]*types.Issue, error) {
|
||||
whereClauses := []string{}
|
||||
args := []interface{}{}
|
||||
|
||||
if query != "" {
|
||||
whereClauses = append(whereClauses, "(title LIKE ? OR description LIKE ? OR id LIKE ?)")
|
||||
pattern := "%" + query + "%"
|
||||
args = append(args, pattern, pattern, pattern)
|
||||
}
|
||||
|
||||
if filter.TitleSearch != "" {
|
||||
whereClauses = append(whereClauses, "title LIKE ?")
|
||||
pattern := "%" + filter.TitleSearch + "%"
|
||||
args = append(args, pattern)
|
||||
}
|
||||
|
||||
// Pattern matching
|
||||
if filter.TitleContains != "" {
|
||||
whereClauses = append(whereClauses, "title LIKE ?")
|
||||
args = append(args, "%"+filter.TitleContains+"%")
|
||||
}
|
||||
if filter.DescriptionContains != "" {
|
||||
whereClauses = append(whereClauses, "description LIKE ?")
|
||||
args = append(args, "%"+filter.DescriptionContains+"%")
|
||||
}
|
||||
if filter.NotesContains != "" {
|
||||
whereClauses = append(whereClauses, "notes LIKE ?")
|
||||
args = append(args, "%"+filter.NotesContains+"%")
|
||||
}
|
||||
|
||||
if filter.Status != nil {
|
||||
whereClauses = append(whereClauses, "status = ?")
|
||||
args = append(args, *filter.Status)
|
||||
}
|
||||
|
||||
if filter.Priority != nil {
|
||||
whereClauses = append(whereClauses, "priority = ?")
|
||||
args = append(args, *filter.Priority)
|
||||
}
|
||||
|
||||
// Priority ranges
|
||||
if filter.PriorityMin != nil {
|
||||
whereClauses = append(whereClauses, "priority >= ?")
|
||||
args = append(args, *filter.PriorityMin)
|
||||
}
|
||||
if filter.PriorityMax != nil {
|
||||
whereClauses = append(whereClauses, "priority <= ?")
|
||||
args = append(args, *filter.PriorityMax)
|
||||
}
|
||||
|
||||
if filter.IssueType != nil {
|
||||
whereClauses = append(whereClauses, "issue_type = ?")
|
||||
args = append(args, *filter.IssueType)
|
||||
}
|
||||
|
||||
if filter.Assignee != nil {
|
||||
whereClauses = append(whereClauses, "assignee = ?")
|
||||
args = append(args, *filter.Assignee)
|
||||
}
|
||||
|
||||
// Date ranges
|
||||
if filter.CreatedAfter != nil {
|
||||
whereClauses = append(whereClauses, "created_at > ?")
|
||||
args = append(args, filter.CreatedAfter.Format(time.RFC3339))
|
||||
}
|
||||
if filter.CreatedBefore != nil {
|
||||
whereClauses = append(whereClauses, "created_at < ?")
|
||||
args = append(args, filter.CreatedBefore.Format(time.RFC3339))
|
||||
}
|
||||
if filter.UpdatedAfter != nil {
|
||||
whereClauses = append(whereClauses, "updated_at > ?")
|
||||
args = append(args, filter.UpdatedAfter.Format(time.RFC3339))
|
||||
}
|
||||
if filter.UpdatedBefore != nil {
|
||||
whereClauses = append(whereClauses, "updated_at < ?")
|
||||
args = append(args, filter.UpdatedBefore.Format(time.RFC3339))
|
||||
}
|
||||
if filter.ClosedAfter != nil {
|
||||
whereClauses = append(whereClauses, "closed_at > ?")
|
||||
args = append(args, filter.ClosedAfter.Format(time.RFC3339))
|
||||
}
|
||||
if filter.ClosedBefore != nil {
|
||||
whereClauses = append(whereClauses, "closed_at < ?")
|
||||
args = append(args, filter.ClosedBefore.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// Empty/null checks
|
||||
if filter.EmptyDescription {
|
||||
whereClauses = append(whereClauses, "(description IS NULL OR description = '')")
|
||||
}
|
||||
if filter.NoAssignee {
|
||||
whereClauses = append(whereClauses, "(assignee IS NULL OR assignee = '')")
|
||||
}
|
||||
if filter.NoLabels {
|
||||
whereClauses = append(whereClauses, "id NOT IN (SELECT DISTINCT issue_id FROM labels)")
|
||||
}
|
||||
|
||||
// Label filtering: issue must have ALL specified labels
|
||||
if len(filter.Labels) > 0 {
|
||||
for _, label := range filter.Labels {
|
||||
whereClauses = append(whereClauses, "id IN (SELECT issue_id FROM labels WHERE label = ?)")
|
||||
args = append(args, label)
|
||||
}
|
||||
}
|
||||
|
||||
// Label filtering (OR): issue must have AT LEAST ONE of these labels
|
||||
if len(filter.LabelsAny) > 0 {
|
||||
placeholders := make([]string, len(filter.LabelsAny))
|
||||
for i, label := range filter.LabelsAny {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, label)
|
||||
}
|
||||
whereClauses = append(whereClauses, fmt.Sprintf("id IN (SELECT issue_id FROM labels WHERE label IN (%s))", strings.Join(placeholders, ", ")))
|
||||
}
|
||||
|
||||
// ID filtering: match specific issue IDs
|
||||
if len(filter.IDs) > 0 {
|
||||
placeholders := make([]string, len(filter.IDs))
|
||||
for i, id := range filter.IDs {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, id)
|
||||
}
|
||||
whereClauses = append(whereClauses, fmt.Sprintf("id IN (%s)", strings.Join(placeholders, ", ")))
|
||||
}
|
||||
|
||||
whereSQL := ""
|
||||
if len(whereClauses) > 0 {
|
||||
whereSQL = "WHERE " + strings.Join(whereClauses, " AND ")
|
||||
}
|
||||
|
||||
limitSQL := ""
|
||||
if filter.Limit > 0 {
|
||||
limitSQL = " LIMIT ?"
|
||||
args = append(args, filter.Limit)
|
||||
}
|
||||
|
||||
// #nosec G201 - safe SQL with controlled formatting
|
||||
querySQL := fmt.Sprintf(`
|
||||
SELECT id, content_hash, title, description, design, acceptance_criteria, notes,
|
||||
status, priority, issue_type, assignee, estimated_minutes,
|
||||
created_at, updated_at, closed_at, external_ref, source_repo
|
||||
FROM issues
|
||||
%s
|
||||
ORDER BY priority ASC, created_at DESC
|
||||
%s
|
||||
`, whereSQL, limitSQL)
|
||||
|
||||
rows, err := t.conn.QueryContext(ctx, querySQL, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to search issues: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
return t.scanIssues(ctx, rows)
|
||||
}
|
||||
|
||||
// scanIssues scans issue rows and fetches labels using the transaction connection.
|
||||
func (t *sqliteTxStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*types.Issue, error) {
|
||||
var issues []*types.Issue
|
||||
var issueIDs []string
|
||||
|
||||
// First pass: scan all issues
|
||||
for rows.Next() {
|
||||
var issue types.Issue
|
||||
var contentHash sql.NullString
|
||||
var closedAt sql.NullTime
|
||||
var estimatedMinutes sql.NullInt64
|
||||
var assignee sql.NullString
|
||||
var externalRef sql.NullString
|
||||
var sourceRepo sql.NullString
|
||||
|
||||
err := rows.Scan(
|
||||
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
|
||||
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
|
||||
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
|
||||
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan issue: %w", err)
|
||||
}
|
||||
|
||||
if contentHash.Valid {
|
||||
issue.ContentHash = contentHash.String
|
||||
}
|
||||
if closedAt.Valid {
|
||||
issue.ClosedAt = &closedAt.Time
|
||||
}
|
||||
if estimatedMinutes.Valid {
|
||||
mins := int(estimatedMinutes.Int64)
|
||||
issue.EstimatedMinutes = &mins
|
||||
}
|
||||
if assignee.Valid {
|
||||
issue.Assignee = assignee.String
|
||||
}
|
||||
if externalRef.Valid {
|
||||
issue.ExternalRef = &externalRef.String
|
||||
}
|
||||
if sourceRepo.Valid {
|
||||
issue.SourceRepo = sourceRepo.String
|
||||
}
|
||||
|
||||
issues = append(issues, &issue)
|
||||
issueIDs = append(issueIDs, issue.ID)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating rows: %w", err)
|
||||
}
|
||||
|
||||
// Second pass: batch-load labels for all issues using transaction connection
|
||||
labelsMap, err := t.getLabelsForIssues(ctx, issueIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get labels: %w", err)
|
||||
}
|
||||
|
||||
// Attach labels to issues
|
||||
for _, issue := range issues {
|
||||
issue.Labels = labelsMap[issue.ID]
|
||||
}
|
||||
|
||||
return issues, nil
|
||||
}
|
||||
|
||||
// getLabelsForIssues retrieves labels for multiple issues using the transaction connection.
|
||||
func (t *sqliteTxStorage) getLabelsForIssues(ctx context.Context, issueIDs []string) (map[string][]string, error) {
|
||||
result := make(map[string][]string)
|
||||
if len(issueIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Build placeholders for IN clause
|
||||
placeholders := make([]string, len(issueIDs))
|
||||
args := make([]interface{}, len(issueIDs))
|
||||
for i, id := range issueIDs {
|
||||
placeholders[i] = "?"
|
||||
args[i] = id
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT issue_id, label FROM labels
|
||||
WHERE issue_id IN (%s)
|
||||
ORDER BY issue_id, label
|
||||
`, strings.Join(placeholders, ", "))
|
||||
|
||||
rows, err := t.conn.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get labels: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
for rows.Next() {
|
||||
var issueID, label string
|
||||
if err := rows.Scan(&issueID, &label); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[issueID] = append(result[issueID], label)
|
||||
}
|
||||
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package sqlite
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/storage"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
@@ -1298,3 +1299,367 @@ type testError struct {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user