diff --git a/internal/storage/memory/memory_more_coverage_test.go b/internal/storage/memory/memory_more_coverage_test.go new file mode 100644 index 00000000..401be669 --- /dev/null +++ b/internal/storage/memory/memory_more_coverage_test.go @@ -0,0 +1,921 @@ +package memory + +import ( + "context" + "testing" + "time" + + "github.com/steveyegge/beads/internal/storage" + "github.com/steveyegge/beads/internal/types" +) + +func TestMemoryStorage_LoadFromIssues_IndexesAndCounters(t *testing.T) { + store := New("/tmp/example.jsonl") + defer store.Close() + + extRef := "ext-1" + issues := []*types.Issue{ + nil, + { + ID: "bd-10", + Title: "Ten", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + ExternalRef: &extRef, + Dependencies: []*types.Dependency{{ + IssueID: "bd-10", + DependsOnID: "bd-2", + Type: types.DepBlocks, + }}, + Labels: []string{"l1"}, + Comments: []*types.Comment{{ID: 1, IssueID: "bd-10", Author: "a", Text: "c"}}, + }, + {ID: "bd-2", Title: "Two", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}, + {ID: "bd-a3f8e9", Title: "Parent", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}, + {ID: "bd-a3f8e9.3", Title: "Child", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}, + } + + if err := store.LoadFromIssues(issues); err != nil { + t.Fatalf("LoadFromIssues: %v", err) + } + + ctx := context.Background() + + got, err := store.GetIssueByExternalRef(ctx, "ext-1") + if err != nil { + t.Fatalf("GetIssueByExternalRef: %v", err) + } + if got == nil || got.ID != "bd-10" { + t.Fatalf("GetIssueByExternalRef got=%v", got) + } + if len(got.Dependencies) != 1 || got.Dependencies[0].DependsOnID != "bd-2" { + t.Fatalf("expected deps attached") + } + if len(got.Labels) != 1 || got.Labels[0] != "l1" { + t.Fatalf("expected labels attached") + } + + // Exercise CreateIssue ID generation based on the loaded counter (bd-10 => next should be bd-11). + if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil { + t.Fatalf("SetConfig: %v", err) + } + newIssue := &types.Issue{Title: "New", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + if err := store.CreateIssue(ctx, newIssue, "actor"); err != nil { + t.Fatalf("CreateIssue: %v", err) + } + if newIssue.ID != "bd-11" { + t.Fatalf("expected generated id bd-11, got %q", newIssue.ID) + } + + // Hierarchical counter for parent extracted from bd-a3f8e9.3. + childID, err := store.GetNextChildID(ctx, "bd-a3f8e9") + if err != nil { + t.Fatalf("GetNextChildID: %v", err) + } + if childID != "bd-a3f8e9.4" { + t.Fatalf("expected bd-a3f8e9.4, got %q", childID) + } +} + +func TestMemoryStorage_GetAllIssues_SortsAndCopies(t *testing.T) { + store := setupTestMemory(t) + defer store.Close() + ctx := context.Background() + + // Create out-of-order IDs. + a := &types.Issue{ID: "bd-2", Title: "A", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + b := &types.Issue{ID: "bd-1", Title: "B", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + if err := store.CreateIssue(ctx, a, "actor"); err != nil { + t.Fatalf("CreateIssue a: %v", err) + } + if err := store.CreateIssue(ctx, b, "actor"); err != nil { + t.Fatalf("CreateIssue b: %v", err) + } + + if err := store.AddLabel(ctx, a.ID, "l1", "actor"); err != nil { + t.Fatalf("AddLabel: %v", err) + } + + all := store.GetAllIssues() + if len(all) != 2 { + t.Fatalf("expected 2 issues, got %d", len(all)) + } + if all[0].ID != "bd-1" || all[1].ID != "bd-2" { + t.Fatalf("expected sorted by ID, got %q then %q", all[0].ID, all[1].ID) + } + + // Returned issues must be copies (mutating should not affect stored issue struct). + all[1].Title = "mutated" + got, err := store.GetIssue(ctx, "bd-2") + if err != nil { + t.Fatalf("GetIssue: %v", err) + } + if got.Title != "A" { + t.Fatalf("expected stored title unchanged, got %q", got.Title) + } +} + +func TestMemoryStorage_CreateIssues_DefaultPrefix_DuplicateExisting_ExternalRef(t *testing.T) { + store := New("") + defer store.Close() + ctx := context.Background() + + // Default prefix should be "bd" when unset. + issues := []*types.Issue{{Title: "A", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}} + if err := store.CreateIssues(ctx, issues, "actor"); err != nil { + t.Fatalf("CreateIssues: %v", err) + } + if issues[0].ID != "bd-1" { + t.Fatalf("expected bd-1, got %q", issues[0].ID) + } + + ext := "ext" + batch := []*types.Issue{{ID: "bd-x", Title: "B", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, ExternalRef: &ext}} + if err := store.CreateIssues(ctx, batch, "actor"); err != nil { + t.Fatalf("CreateIssues: %v", err) + } + if got, _ := store.GetIssueByExternalRef(ctx, "ext"); got == nil || got.ID != "bd-x" { + t.Fatalf("expected external ref indexed") + } + + // Duplicate existing issue ID branch. + dup := []*types.Issue{{ID: "bd-x", Title: "Dup", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}} + if err := store.CreateIssues(ctx, dup, "actor"); err == nil { + t.Fatalf("expected duplicate existing issue error") + } +} + +func TestMemoryStorage_GetIssueByExternalRef_IndexPointsToMissingIssue(t *testing.T) { + store := setupTestMemory(t) + defer store.Close() + ctx := context.Background() + + store.mu.Lock() + store.externalRefToID["dangling"] = "bd-nope" + store.mu.Unlock() + + got, err := store.GetIssueByExternalRef(ctx, "dangling") + if err != nil { + t.Fatalf("GetIssueByExternalRef: %v", err) + } + if got != nil { + t.Fatalf("expected nil for dangling ref") + } +} + +func TestMemoryStorage_DependencyCounts_Records_Tree_Cycles(t *testing.T) { + store := setupTestMemory(t) + defer store.Close() + ctx := context.Background() + + a := &types.Issue{ID: "bd-1", Title: "A", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + b := &types.Issue{ID: "bd-2", Title: "B", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + c := &types.Issue{ID: "bd-3", Title: "C", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + d := &types.Issue{ID: "bd-4", Title: "D", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + for _, iss := range []*types.Issue{a, b, c, d} { + if err := store.CreateIssue(ctx, iss, "actor"); err != nil { + t.Fatalf("CreateIssue %s: %v", iss.ID, err) + } + } + + if err := store.AddDependency(ctx, &types.Dependency{IssueID: a.ID, DependsOnID: b.ID, Type: types.DepBlocks}, "actor"); err != nil { + t.Fatalf("AddDependency: %v", err) + } + if err := store.AddDependency(ctx, &types.Dependency{IssueID: a.ID, DependsOnID: c.ID, Type: types.DepBlocks}, "actor"); err != nil { + t.Fatalf("AddDependency: %v", err) + } + if err := store.AddDependency(ctx, &types.Dependency{IssueID: d.ID, DependsOnID: b.ID, Type: types.DepBlocks}, "actor"); err != nil { + t.Fatalf("AddDependency: %v", err) + } + + counts, err := store.GetDependencyCounts(ctx, []string{a.ID, b.ID, "bd-missing"}) + if err != nil { + t.Fatalf("GetDependencyCounts: %v", err) + } + if counts[a.ID].DependencyCount != 2 || counts[a.ID].DependentCount != 0 { + t.Fatalf("unexpected counts for A: %+v", counts[a.ID]) + } + if counts[b.ID].DependencyCount != 0 || counts[b.ID].DependentCount != 2 { + t.Fatalf("unexpected counts for B: %+v", counts[b.ID]) + } + if counts["bd-missing"].DependencyCount != 0 || counts["bd-missing"].DependentCount != 0 { + t.Fatalf("unexpected counts for missing: %+v", counts["bd-missing"]) + } + + deps, err := store.GetDependencyRecords(ctx, a.ID) + if err != nil { + t.Fatalf("GetDependencyRecords: %v", err) + } + if len(deps) != 2 { + t.Fatalf("expected 2 deps, got %d", len(deps)) + } + + allDeps, err := store.GetAllDependencyRecords(ctx) + if err != nil { + t.Fatalf("GetAllDependencyRecords: %v", err) + } + if len(allDeps[a.ID]) != 2 { + t.Fatalf("expected all deps for A") + } + + nodes, err := store.GetDependencyTree(ctx, a.ID, 3, false, false) + if err != nil { + t.Fatalf("GetDependencyTree: %v", err) + } + if len(nodes) != 2 || nodes[0].Depth != 1 { + t.Fatalf("unexpected tree: %+v", nodes) + } + + cycles, err := store.DetectCycles(ctx) + if err != nil { + t.Fatalf("DetectCycles: %v", err) + } + if cycles != nil { + t.Fatalf("expected nil cycles, got %+v", cycles) + } +} + +func TestMemoryStorage_HashTracking_NoOps(t *testing.T) { + store := setupTestMemory(t) + defer store.Close() + ctx := context.Background() + + if hash, err := store.GetDirtyIssueHash(ctx, "bd-1"); err != nil || hash != "" { + t.Fatalf("GetDirtyIssueHash: hash=%q err=%v", hash, err) + } + if hash, err := store.GetExportHash(ctx, "bd-1"); err != nil || hash != "" { + t.Fatalf("GetExportHash: hash=%q err=%v", hash, err) + } + if err := store.SetExportHash(ctx, "bd-1", "h"); err != nil { + t.Fatalf("SetExportHash: %v", err) + } + if err := store.ClearAllExportHashes(ctx); err != nil { + t.Fatalf("ClearAllExportHashes: %v", err) + } + if hash, err := store.GetJSONLFileHash(ctx); err != nil || hash != "" { + t.Fatalf("GetJSONLFileHash: hash=%q err=%v", hash, err) + } + if err := store.SetJSONLFileHash(ctx, "h"); err != nil { + t.Fatalf("SetJSONLFileHash: %v", err) + } +} + +func TestMemoryStorage_LabelsAndCommentsHelpers(t *testing.T) { + store := setupTestMemory(t) + defer store.Close() + ctx := context.Background() + + a := &types.Issue{ID: "bd-1", Title: "A", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + b := &types.Issue{ID: "bd-2", Title: "B", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + if err := store.CreateIssue(ctx, a, "actor"); err != nil { + t.Fatalf("CreateIssue a: %v", err) + } + if err := store.CreateIssue(ctx, b, "actor"); err != nil { + t.Fatalf("CreateIssue b: %v", err) + } + + if err := store.AddLabel(ctx, a.ID, "l1", "actor"); err != nil { + t.Fatalf("AddLabel: %v", err) + } + if err := store.AddLabel(ctx, b.ID, "l2", "actor"); err != nil { + t.Fatalf("AddLabel: %v", err) + } + + labels, err := store.GetLabelsForIssues(ctx, []string{a.ID, b.ID, "bd-missing"}) + if err != nil { + t.Fatalf("GetLabelsForIssues: %v", err) + } + if len(labels) != 2 { + t.Fatalf("expected 2 entries, got %d", len(labels)) + } + if labels[a.ID][0] != "l1" { + t.Fatalf("unexpected labels for A: %+v", labels[a.ID]) + } + + issues, err := store.GetIssuesByLabel(ctx, "l1") + if err != nil { + t.Fatalf("GetIssuesByLabel: %v", err) + } + if len(issues) != 1 || issues[0].ID != a.ID { + t.Fatalf("unexpected issues: %+v", issues) + } + + if _, err := store.AddIssueComment(ctx, a.ID, "author", "text"); err != nil { + t.Fatalf("AddIssueComment: %v", err) + } + comments, err := store.GetCommentsForIssues(ctx, []string{a.ID, b.ID}) + if err != nil { + t.Fatalf("GetCommentsForIssues: %v", err) + } + if len(comments[a.ID]) != 1 { + t.Fatalf("expected comments for A") + } +} + +func TestMemoryStorage_StaleEventsCustomStatusAndLifecycleHelpers(t *testing.T) { + store := New("/tmp/x.jsonl") + defer store.Close() + ctx := context.Background() + + if store.Path() != "/tmp/x.jsonl" { + t.Fatalf("Path mismatch") + } + if store.UnderlyingDB() != nil { + t.Fatalf("expected nil UnderlyingDB") + } + if _, err := store.UnderlyingConn(ctx); err == nil { + t.Fatalf("expected UnderlyingConn error") + } + if err := store.RunInTransaction(ctx, func(tx storage.Transaction) error { return nil }); err == nil { + t.Fatalf("expected RunInTransaction error") + } + + if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil { + t.Fatalf("SetConfig: %v", err) + } + a := &types.Issue{ID: "bd-1", Title: "A", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + if err := store.CreateIssue(ctx, a, "actor"); err != nil { + t.Fatalf("CreateIssue: %v", err) + } + + // Force updated_at into the past for stale detection. + store.mu.Lock() + a.UpdatedAt = time.Now().Add(-10 * 24 * time.Hour) + store.mu.Unlock() + + stale, err := store.GetStaleIssues(ctx, types.StaleFilter{Days: 7, Limit: 10}) + if err != nil { + t.Fatalf("GetStaleIssues: %v", err) + } + if len(stale) != 1 || stale[0].ID != a.ID { + t.Fatalf("unexpected stale: %+v", stale) + } + + if err := store.AddComment(ctx, a.ID, "actor", "c"); err != nil { + t.Fatalf("AddComment: %v", err) + } + if err := store.MarkIssueDirty(ctx, a.ID); err != nil { + t.Fatalf("MarkIssueDirty: %v", err) + } + + // Generate multiple events and ensure limiting returns the last N. + if err := store.UpdateIssue(ctx, a.ID, map[string]interface{}{"title": "t1"}, "actor"); err != nil { + t.Fatalf("UpdateIssue: %v", err) + } + if err := store.UpdateIssue(ctx, a.ID, map[string]interface{}{"title": "t2"}, "actor"); err != nil { + t.Fatalf("UpdateIssue: %v", err) + } + evs, err := store.GetEvents(ctx, a.ID, 2) + if err != nil { + t.Fatalf("GetEvents: %v", err) + } + if len(evs) != 2 { + t.Fatalf("expected 2 events, got %d", len(evs)) + } + + if err := store.SetConfig(ctx, "status.custom", " triage, blocked , ,done "); err != nil { + t.Fatalf("SetConfig: %v", err) + } + statuses, err := store.GetCustomStatuses(ctx) + if err != nil { + t.Fatalf("GetCustomStatuses: %v", err) + } + if len(statuses) != 3 || statuses[0] != "triage" || statuses[1] != "blocked" || statuses[2] != "done" { + t.Fatalf("unexpected statuses: %+v", statuses) + } + if got := parseCustomStatuses(""); got != nil { + t.Fatalf("expected nil for empty parseCustomStatuses") + } + + // Empty custom statuses. + if err := store.DeleteConfig(ctx, "status.custom"); err != nil { + t.Fatalf("DeleteConfig: %v", err) + } + statuses, err = store.GetCustomStatuses(ctx) + if err != nil { + t.Fatalf("GetCustomStatuses(empty): %v", err) + } + if statuses != nil { + t.Fatalf("expected nil statuses when unset, got %+v", statuses) + } + + if _, err := store.GetEpicsEligibleForClosure(ctx); err != nil { + t.Fatalf("GetEpicsEligibleForClosure: %v", err) + } + + if err := store.UpdateIssueID(ctx, "old", "new", nil, "actor"); err == nil { + t.Fatalf("expected UpdateIssueID error") + } + if err := store.RenameDependencyPrefix(ctx, "old", "new"); err != nil { + t.Fatalf("RenameDependencyPrefix: %v", err) + } + if err := store.RenameCounterPrefix(ctx, "old", "new"); err != nil { + t.Fatalf("RenameCounterPrefix: %v", err) + } +} + +func TestMemoryStorage_AddLabelAndAddDependency_ErrorPaths(t *testing.T) { + store := setupTestMemory(t) + defer store.Close() + ctx := context.Background() + + issue := &types.Issue{ID: "bd-1", Title: "A", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + if err := store.CreateIssue(ctx, issue, "actor"); err != nil { + t.Fatalf("CreateIssue: %v", err) + } + + if err := store.AddLabel(ctx, "bd-missing", "l", "actor"); err == nil { + t.Fatalf("expected AddLabel error for missing issue") + } + if err := store.AddLabel(ctx, issue.ID, "l", "actor"); err != nil { + t.Fatalf("AddLabel: %v", err) + } + // Duplicate label is a no-op. + if err := store.AddLabel(ctx, issue.ID, "l", "actor"); err != nil { + t.Fatalf("AddLabel duplicate: %v", err) + } + + // AddDependency error paths. + if err := store.AddDependency(ctx, &types.Dependency{IssueID: "bd-missing", DependsOnID: issue.ID, Type: types.DepBlocks}, "actor"); err == nil { + t.Fatalf("expected AddDependency error for missing IssueID") + } + if err := store.AddDependency(ctx, &types.Dependency{IssueID: issue.ID, DependsOnID: "bd-missing", Type: types.DepBlocks}, "actor"); err == nil { + t.Fatalf("expected AddDependency error for missing DependsOnID") + } +} + +func TestMemoryStorage_GetNextChildID_Errors(t *testing.T) { + store := setupTestMemory(t) + defer store.Close() + ctx := context.Background() + + if _, err := store.GetNextChildID(ctx, "bd-missing"); err == nil { + t.Fatalf("expected error for missing parent") + } + + deep := &types.Issue{ID: "bd-1.1.1.1", Title: "Deep", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + if err := store.CreateIssue(ctx, deep, "actor"); err != nil { + t.Fatalf("CreateIssue: %v", err) + } + if _, err := store.GetNextChildID(ctx, deep.ID); err == nil { + t.Fatalf("expected max depth error") + } +} + +func TestMemoryStorage_GetAllIssues_AttachesDependenciesAndComments(t *testing.T) { + store := setupTestMemory(t) + defer store.Close() + ctx := context.Background() + + a := &types.Issue{ID: "bd-1", Title: "A", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + b := &types.Issue{ID: "bd-2", Title: "B", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + if err := store.CreateIssue(ctx, a, "actor"); err != nil { + t.Fatalf("CreateIssue a: %v", err) + } + if err := store.CreateIssue(ctx, b, "actor"); err != nil { + t.Fatalf("CreateIssue b: %v", err) + } + if err := store.AddDependency(ctx, &types.Dependency{IssueID: a.ID, DependsOnID: b.ID, Type: types.DepBlocks}, "actor"); err != nil { + t.Fatalf("AddDependency: %v", err) + } + if _, err := store.AddIssueComment(ctx, a.ID, "author", "text"); err != nil { + t.Fatalf("AddIssueComment: %v", err) + } + + all := store.GetAllIssues() + var gotA *types.Issue + for _, iss := range all { + if iss.ID == a.ID { + gotA = iss + break + } + } + if gotA == nil { + t.Fatalf("expected to find issue A") + } + if len(gotA.Dependencies) != 1 || gotA.Dependencies[0].DependsOnID != b.ID { + t.Fatalf("expected deps attached") + } + if len(gotA.Comments) != 1 || gotA.Comments[0].Text != "text" { + t.Fatalf("expected comments attached") + } +} + +func TestMemoryStorage_GetStaleIssues_FilteringAndLimit(t *testing.T) { + store := setupTestMemory(t) + defer store.Close() + ctx := context.Background() + + old := &types.Issue{ID: "bd-1", Title: "Old", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + newer := &types.Issue{ID: "bd-2", Title: "Newer", Status: types.StatusInProgress, Priority: 1, IssueType: types.TypeTask} + closed := &types.Issue{ID: "bd-3", Title: "Closed", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + for _, iss := range []*types.Issue{old, newer, closed} { + if err := store.CreateIssue(ctx, iss, "actor"); err != nil { + t.Fatalf("CreateIssue %s: %v", iss.ID, err) + } + } + if err := store.CloseIssue(ctx, closed.ID, "done", "actor"); err != nil { + t.Fatalf("CloseIssue: %v", err) + } + + store.mu.Lock() + store.issues[old.ID].UpdatedAt = time.Now().Add(-20 * 24 * time.Hour) + store.issues[newer.ID].UpdatedAt = time.Now().Add(-10 * 24 * time.Hour) + store.issues[closed.ID].UpdatedAt = time.Now().Add(-30 * 24 * time.Hour) + store.mu.Unlock() + + stale, err := store.GetStaleIssues(ctx, types.StaleFilter{Days: 7, Status: "in_progress"}) + if err != nil { + t.Fatalf("GetStaleIssues: %v", err) + } + if len(stale) != 1 || stale[0].ID != newer.ID { + t.Fatalf("unexpected stale filtered: %+v", stale) + } + + stale, err = store.GetStaleIssues(ctx, types.StaleFilter{Days: 7, Limit: 1}) + if err != nil { + t.Fatalf("GetStaleIssues: %v", err) + } + if len(stale) != 1 || stale[0].ID != old.ID { + t.Fatalf("expected oldest stale first, got %+v", stale) + } +} + +func TestMemoryStorage_Statistics_EpicsEligibleForClosure_Counting(t *testing.T) { + store := setupTestMemory(t) + defer store.Close() + ctx := context.Background() + + ep := &types.Issue{ID: "bd-1", Title: "Epic", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic} + c1 := &types.Issue{ID: "bd-2", Title: "Child1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + c2 := &types.Issue{ID: "bd-3", Title: "Child2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + for _, iss := range []*types.Issue{ep, c1, c2} { + if err := store.CreateIssue(ctx, iss, "actor"); err != nil { + t.Fatalf("CreateIssue %s: %v", iss.ID, err) + } + } + if err := store.CloseIssue(ctx, c1.ID, "done", "actor"); err != nil { + t.Fatalf("CloseIssue c1: %v", err) + } + if err := store.CloseIssue(ctx, c2.ID, "done", "actor"); err != nil { + t.Fatalf("CloseIssue c2: %v", err) + } + // Parent-child deps: child -> epic. + if err := store.AddDependency(ctx, &types.Dependency{IssueID: c1.ID, DependsOnID: ep.ID, Type: types.DepParentChild}, "actor"); err != nil { + t.Fatalf("AddDependency: %v", err) + } + if err := store.AddDependency(ctx, &types.Dependency{IssueID: c2.ID, DependsOnID: ep.ID, Type: types.DepParentChild}, "actor"); err != nil { + t.Fatalf("AddDependency: %v", err) + } + + stats, err := store.GetStatistics(ctx) + if err != nil { + t.Fatalf("GetStatistics: %v", err) + } + if stats.EpicsEligibleForClosure != 1 { + t.Fatalf("expected 1 epic eligible, got %d", stats.EpicsEligibleForClosure) + } +} + +func TestMemoryStorage_UpdateIssue_SearchIssues_ReadyWork_BlockedIssues(t *testing.T) { + store := setupTestMemory(t) + defer store.Close() + ctx := context.Background() + + now := time.Now() + assignee := "alice" + + parent := &types.Issue{ID: "bd-1", Title: "Parent", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic} + child := &types.Issue{ID: "bd-2", Title: "Child", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, Assignee: assignee} + blocker := &types.Issue{ID: "bd-3", Title: "Blocker", Status: types.StatusOpen, Priority: 3, IssueType: types.TypeTask} + pinned := &types.Issue{ID: "bd-4", Title: "Pinned", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, Pinned: true} + workflow := &types.Issue{ID: "bd-5", Title: "Workflow", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeMergeRequest} + for _, iss := range []*types.Issue{parent, child, blocker, pinned, workflow} { + if err := store.CreateIssue(ctx, iss, "actor"); err != nil { + t.Fatalf("CreateIssue %s: %v", iss.ID, err) + } + } + + // Make created_at deterministic for sorting. + store.mu.Lock() + store.issues[parent.ID].CreatedAt = now.Add(-100 * time.Hour) + store.issues[child.ID].CreatedAt = now.Add(-1 * time.Hour) + store.issues[blocker.ID].CreatedAt = now.Add(-2 * time.Hour) + store.issues[pinned.ID].CreatedAt = now.Add(-3 * time.Hour) + store.issues[workflow.ID].CreatedAt = now.Add(-4 * time.Hour) + store.mu.Unlock() + + // Dependencies: child is a child of parent; child is blocked by blocker. + if err := store.AddDependency(ctx, &types.Dependency{IssueID: child.ID, DependsOnID: parent.ID, Type: types.DepParentChild}, "actor"); err != nil { + t.Fatalf("AddDependency parent-child: %v", err) + } + if err := store.AddDependency(ctx, &types.Dependency{IssueID: child.ID, DependsOnID: blocker.ID, Type: types.DepBlocks}, "actor"); err != nil { + t.Fatalf("AddDependency blocks: %v", err) + } + + // AddDependency duplicate error path. + if err := store.AddDependency(ctx, &types.Dependency{IssueID: child.ID, DependsOnID: blocker.ID, Type: types.DepBlocks}, "actor"); err == nil { + t.Fatalf("expected duplicate dependency error") + } + + // UpdateIssue: exercise assignee nil, external_ref update+clear, and closed_at behavior. + ext := "old-ext" + store.mu.Lock() + store.issues[child.ID].ExternalRef = &ext + store.externalRefToID[ext] = child.ID + store.mu.Unlock() + + if err := store.UpdateIssue(ctx, child.ID, map[string]interface{}{"assignee": nil, "external_ref": "new-ext"}, "actor"); err != nil { + t.Fatalf("UpdateIssue: %v", err) + } + if got, _ := store.GetIssueByExternalRef(ctx, "old-ext"); got != nil { + t.Fatalf("expected old-ext removed") + } + if got, _ := store.GetIssueByExternalRef(ctx, "new-ext"); got == nil || got.ID != child.ID { + t.Fatalf("expected new-ext mapping") + } + + if err := store.UpdateIssue(ctx, child.ID, map[string]interface{}{"status": string(types.StatusClosed)}, "actor"); err != nil { + t.Fatalf("UpdateIssue close: %v", err) + } + closed, _ := store.GetIssue(ctx, child.ID) + if closed.ClosedAt == nil { + t.Fatalf("expected ClosedAt set") + } + if err := store.UpdateIssue(ctx, child.ID, map[string]interface{}{"status": string(types.StatusOpen), "external_ref": nil}, "actor"); err != nil { + t.Fatalf("UpdateIssue reopen: %v", err) + } + reopened, _ := store.GetIssue(ctx, child.ID) + if reopened.ClosedAt != nil { + t.Fatalf("expected ClosedAt cleared") + } + if got, _ := store.GetIssueByExternalRef(ctx, "new-ext"); got != nil { + t.Fatalf("expected new-ext cleared") + } + + // SearchIssues: query, label AND/OR, IDs filter, ParentID filter, limit. + if err := store.AddLabel(ctx, parent.ID, "l1", "actor"); err != nil { + t.Fatalf("AddLabel: %v", err) + } + if err := store.AddLabel(ctx, child.ID, "l1", "actor"); err != nil { + t.Fatalf("AddLabel: %v", err) + } + if err := store.AddLabel(ctx, child.ID, "l2", "actor"); err != nil { + t.Fatalf("AddLabel: %v", err) + } + + st := types.StatusOpen + res, err := store.SearchIssues(ctx, "parent", types.IssueFilter{Status: &st}) + if err != nil { + t.Fatalf("SearchIssues: %v", err) + } + if len(res) != 1 || res[0].ID != parent.ID { + t.Fatalf("unexpected SearchIssues results: %+v", res) + } + + res, err = store.SearchIssues(ctx, "", types.IssueFilter{Labels: []string{"l1", "l2"}}) + if err != nil { + t.Fatalf("SearchIssues labels AND: %v", err) + } + if len(res) != 1 || res[0].ID != child.ID { + t.Fatalf("unexpected labels AND results: %+v", res) + } + + res, err = store.SearchIssues(ctx, "", types.IssueFilter{IDs: []string{child.ID}}) + if err != nil { + t.Fatalf("SearchIssues IDs: %v", err) + } + if len(res) != 1 || res[0].ID != child.ID { + t.Fatalf("unexpected IDs results: %+v", res) + } + + res, err = store.SearchIssues(ctx, "", types.IssueFilter{ParentID: &parent.ID}) + if err != nil { + t.Fatalf("SearchIssues ParentID: %v", err) + } + if len(res) != 1 || res[0].ID != child.ID { + t.Fatalf("unexpected ParentID results: %+v", res) + } + + res, err = store.SearchIssues(ctx, "", types.IssueFilter{LabelsAny: []string{"l2", "missing"}, Limit: 1}) + if err != nil { + t.Fatalf("SearchIssues labels OR: %v", err) + } + if len(res) != 1 { + t.Fatalf("expected limit 1") + } + + // Ready work: child is blocked, pinned excluded, workflow excluded by default. + ready, err := store.GetReadyWork(ctx, types.WorkFilter{}) + if err != nil { + t.Fatalf("GetReadyWork: %v", err) + } + if len(ready) != 2 { // parent + blocker + t.Fatalf("expected 2 ready issues, got %d: %+v", len(ready), ready) + } + + // Filter by workflow type explicitly. + ready, err = store.GetReadyWork(ctx, types.WorkFilter{Type: string(types.TypeMergeRequest)}) + if err != nil { + t.Fatalf("GetReadyWork type: %v", err) + } + if len(ready) != 1 || ready[0].ID != workflow.ID { + t.Fatalf("expected only workflow issue, got %+v", ready) + } + + // Status + priority filters. + prio := 3 + ready, err = store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen, Priority: &prio}) + if err != nil { + t.Fatalf("GetReadyWork status+priority: %v", err) + } + if len(ready) != 1 || ready[0].ID != blocker.ID { + t.Fatalf("expected blocker only, got %+v", ready) + } + + // Label filters. + ready, err = store.GetReadyWork(ctx, types.WorkFilter{Labels: []string{"l1"}}) + if err != nil { + t.Fatalf("GetReadyWork labels AND: %v", err) + } + if len(ready) != 1 || ready[0].ID != parent.ID { + t.Fatalf("expected parent only, got %+v", ready) + } + ready, err = store.GetReadyWork(ctx, types.WorkFilter{LabelsAny: []string{"l2"}}) + if err != nil { + t.Fatalf("GetReadyWork labels OR: %v", err) + } + if len(ready) != 0 { + t.Fatalf("expected 0 because only l2 issue is blocked") + } + + // Assignee filter vs Unassigned precedence. + ready, err = store.GetReadyWork(ctx, types.WorkFilter{Assignee: &assignee}) + if err != nil { + t.Fatalf("GetReadyWork assignee: %v", err) + } + if len(ready) != 0 { + t.Fatalf("expected 0 due to child being blocked") + } + ready, err = store.GetReadyWork(ctx, types.WorkFilter{Unassigned: true}) + if err != nil { + t.Fatalf("GetReadyWork unassigned: %v", err) + } + for _, iss := range ready { + if iss.Assignee != "" { + t.Fatalf("expected unassigned only") + } + } + + // Sort policies + limit. + ready, err = store.GetReadyWork(ctx, types.WorkFilter{SortPolicy: types.SortPolicyOldest, Limit: 1}) + if err != nil { + t.Fatalf("GetReadyWork oldest: %v", err) + } + if len(ready) != 1 || ready[0].ID != parent.ID { + t.Fatalf("expected oldest=parent, got %+v", ready) + } + ready, err = store.GetReadyWork(ctx, types.WorkFilter{SortPolicy: types.SortPolicyPriority}) + if err != nil { + t.Fatalf("GetReadyWork priority: %v", err) + } + if len(ready) < 2 || ready[0].Priority > ready[1].Priority { + t.Fatalf("expected priority sort") + } + // Hybrid: recent issues first. + ready, err = store.GetReadyWork(ctx, types.WorkFilter{SortPolicy: types.SortPolicyHybrid}) + if err != nil { + t.Fatalf("GetReadyWork hybrid: %v", err) + } + if len(ready) != 2 || ready[0].ID != blocker.ID { + t.Fatalf("expected recent (blocker) first in hybrid, got %+v", ready) + } + + // Blocked issues: child is blocked by an open blocker. + blocked, err := store.GetBlockedIssues(ctx) + if err != nil { + t.Fatalf("GetBlockedIssues: %v", err) + } + if len(blocked) != 1 || blocked[0].ID != child.ID || blocked[0].BlockedByCount != 1 { + t.Fatalf("unexpected blocked issues: %+v", blocked) + } + + // Cover getOpenBlockers missing-blocker branch. + missing := &types.Issue{ID: "bd-6", Title: "Missing blocker dep", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + if err := store.CreateIssue(ctx, missing, "actor"); err != nil { + t.Fatalf("CreateIssue: %v", err) + } + // Bypass AddDependency validation to cover the missing-blocker branch in getOpenBlockers. + store.mu.Lock() + store.dependencies[missing.ID] = append(store.dependencies[missing.ID], &types.Dependency{IssueID: missing.ID, DependsOnID: "bd-does-not-exist", Type: types.DepBlocks}) + store.mu.Unlock() + blocked, err = store.GetBlockedIssues(ctx) + if err != nil { + t.Fatalf("GetBlockedIssues: %v", err) + } + if len(blocked) != 2 { + t.Fatalf("expected 2 blocked issues, got %d", len(blocked)) + } +} + +func TestMemoryStorage_UpdateIssue_CoversMoreFields(t *testing.T) { + store := setupTestMemory(t) + defer store.Close() + ctx := context.Background() + + iss := &types.Issue{ID: "bd-1", Title: "A", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + if err := store.CreateIssue(ctx, iss, "actor"); err != nil { + t.Fatalf("CreateIssue: %v", err) + } + + if err := store.UpdateIssue(ctx, iss.ID, map[string]interface{}{ + "description": "d", + "design": "design", + "acceptance_criteria": "ac", + "notes": "n", + "priority": 2, + "issue_type": string(types.TypeBug), + "assignee": "bob", + "status": string(types.StatusInProgress), + }, "actor"); err != nil { + t.Fatalf("UpdateIssue: %v", err) + } + + got, _ := store.GetIssue(ctx, iss.ID) + if got.Description != "d" || got.Design != "design" || got.AcceptanceCriteria != "ac" || got.Notes != "n" { + t.Fatalf("expected text fields updated") + } + if got.Priority != 2 || got.IssueType != types.TypeBug || got.Assignee != "bob" || got.Status != types.StatusInProgress { + t.Fatalf("expected fields updated") + } + + // Status closed when already closed should not clear ClosedAt. + if err := store.CloseIssue(ctx, iss.ID, "done", "actor"); err != nil { + t.Fatalf("CloseIssue: %v", err) + } + closedOnce, _ := store.GetIssue(ctx, iss.ID) + if closedOnce.ClosedAt == nil { + t.Fatalf("expected ClosedAt") + } + if err := store.UpdateIssue(ctx, iss.ID, map[string]interface{}{"status": string(types.StatusClosed)}, "actor"); err != nil { + t.Fatalf("UpdateIssue closed->closed: %v", err) + } + closedTwice, _ := store.GetIssue(ctx, iss.ID) + if closedTwice.ClosedAt == nil { + t.Fatalf("expected ClosedAt preserved") + } +} + +func TestMemoryStorage_CountEpicsEligibleForClosure_CoversBranches(t *testing.T) { + store := setupTestMemory(t) + defer store.Close() + ctx := context.Background() + + ep1 := &types.Issue{ID: "bd-1", Title: "Epic1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic} + epClosed := &types.Issue{ID: "bd-2", Title: "EpicClosed", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic} + nonEpic := &types.Issue{ID: "bd-3", Title: "NotEpic", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + c := &types.Issue{ID: "bd-4", Title: "Child", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} + for _, iss := range []*types.Issue{ep1, epClosed, nonEpic, c} { + if err := store.CreateIssue(ctx, iss, "actor"); err != nil { + t.Fatalf("CreateIssue %s: %v", iss.ID, err) + } + } + if err := store.CloseIssue(ctx, epClosed.ID, "done", "actor"); err != nil { + t.Fatalf("CloseIssue: %v", err) + } + // Child -> ep1 (eligible once child is closed). + if err := store.AddDependency(ctx, &types.Dependency{IssueID: c.ID, DependsOnID: ep1.ID, Type: types.DepParentChild}, "actor"); err != nil { + t.Fatalf("AddDependency: %v", err) + } + // Child -> nonEpic should not count. + if err := store.AddDependency(ctx, &types.Dependency{IssueID: c.ID, DependsOnID: nonEpic.ID, Type: types.DepParentChild}, "actor"); err != nil { + t.Fatalf("AddDependency: %v", err) + } + // Child -> missing epic should not count. + store.mu.Lock() + store.dependencies[c.ID] = append(store.dependencies[c.ID], &types.Dependency{IssueID: c.ID, DependsOnID: "bd-missing", Type: types.DepParentChild}) + store.mu.Unlock() + + // Close child to make ep1 eligible. + if err := store.CloseIssue(ctx, c.ID, "done", "actor"); err != nil { + t.Fatalf("CloseIssue child: %v", err) + } + + stats, err := store.GetStatistics(ctx) + if err != nil { + t.Fatalf("GetStatistics: %v", err) + } + if stats.EpicsEligibleForClosure != 1 { + t.Fatalf("expected 1 eligible epic, got %d", stats.EpicsEligibleForClosure) + } +} + +func TestExtractParentAndChildNumber_CoversFailures(t *testing.T) { + if _, _, ok := extractParentAndChildNumber("no-dot"); ok { + t.Fatalf("expected ok=false") + } + if _, _, ok := extractParentAndChildNumber("parent.bad"); ok { + t.Fatalf("expected ok=false") + } +}