Files
beads/internal/storage/sqlite/graph_links_test.go
beads/crew/dave b362b36824 feat: add session_id field to issue close/update mutations (bd-tksk)
Adds closed_by_session tracking for entity CV building per Gas Town
decision 009-session-events-architecture.md.

Changes:
- Add ClosedBySession field to Issue struct
- Add closed_by_session column to issues table (migration 034)
- Add --session flag to bd close command
- Support CLAUDE_SESSION_ID env var as fallback
- Add --session flag to bd update for status=closed
- Display closed_by_session in bd show output
- Update Storage interface to include session parameter in CloseIssue

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Executed-By: beads/crew/dave
Rig: beads
Role: crew
2025-12-31 13:14:15 -08:00

600 lines
15 KiB
Go

package sqlite
import (
"context"
"testing"
"time"
"github.com/steveyegge/beads/internal/types"
)
// TestRelatesTo verifies relates-to dependencies work via the dependency API.
// Per Decision 004, relates-to links are now stored in the dependencies table.
func TestRelatesTo(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create two issues
issue1 := &types.Issue{
Title: "Issue 1",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
issue2 := &types.Issue{
Title: "Issue 2",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := store.CreateIssue(ctx, issue1, "test"); err != nil {
t.Fatalf("Failed to create issue1: %v", err)
}
if err := store.CreateIssue(ctx, issue2, "test"); err != nil {
t.Fatalf("Failed to create issue2: %v", err)
}
// Add relates-to dependency (bidirectional)
dep1 := &types.Dependency{
IssueID: issue1.ID,
DependsOnID: issue2.ID,
Type: types.DepRelatesTo,
}
if err := store.AddDependency(ctx, dep1, "test"); err != nil {
t.Fatalf("Failed to add relates-to dep1: %v", err)
}
dep2 := &types.Dependency{
IssueID: issue2.ID,
DependsOnID: issue1.ID,
Type: types.DepRelatesTo,
}
if err := store.AddDependency(ctx, dep2, "test"); err != nil {
t.Fatalf("Failed to add relates-to dep2: %v", err)
}
// Verify links via GetDependenciesWithMetadata
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")
}
}
func TestRelatesTo_MultipleLinks(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create three issues
issues := make([]*types.Issue, 3)
for i := range issues {
issues[i] = &types.Issue{
Title: "Issue",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := store.CreateIssue(ctx, issues[i], "test"); err != nil {
t.Fatalf("Failed to create issue %d: %v", i, err)
}
}
// Link issue0 to both issue1 and issue2
for _, targetIssue := range []*types.Issue{issues[1], issues[2]} {
dep := &types.Dependency{
IssueID: issues[0].ID,
DependsOnID: targetIssue.ID,
Type: types.DepRelatesTo,
}
if err := store.AddDependency(ctx, dep, "test"); err != nil {
t.Fatalf("Failed to add relates-to: %v", err)
}
}
// Verify
deps, err := store.GetDependenciesWithMetadata(ctx, issues[0].ID)
if err != nil {
t.Fatalf("GetDependenciesWithMetadata failed: %v", err)
}
relatesCount := 0
for _, d := range deps {
if d.DependencyType == types.DepRelatesTo {
relatesCount++
}
}
if relatesCount != 2 {
t.Errorf("RelatesTo has %d links, want 2", relatesCount)
}
}
// TestDuplicateOf verifies duplicates dependencies work via the dependency API.
func TestDuplicateOf(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create canonical and duplicate issues
canonical := &types.Issue{
Title: "Canonical Issue",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeBug,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
duplicate := &types.Issue{
Title: "Duplicate Issue",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeBug,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := store.CreateIssue(ctx, canonical, "test"); err != nil {
t.Fatalf("Failed to create canonical: %v", err)
}
if err := store.CreateIssue(ctx, duplicate, "test"); err != nil {
t.Fatalf("Failed to create duplicate: %v", err)
}
// Add duplicates dependency
dep := &types.Dependency{
IssueID: duplicate.ID,
DependsOnID: canonical.ID,
Type: types.DepDuplicates,
}
if err := store.AddDependency(ctx, dep, "test"); err != nil {
t.Fatalf("Failed to add duplicates dep: %v", err)
}
// Close the duplicate
if err := store.CloseIssue(ctx, duplicate.ID, "Closed as duplicate", "test", ""); err != nil {
t.Fatalf("Failed to close duplicate: %v", err)
}
// Verify dependency
deps, err := store.GetDependenciesWithMetadata(ctx, duplicate.ID)
if err != nil {
t.Fatalf("GetDependenciesWithMetadata failed: %v", err)
}
found := false
for _, d := range deps {
if d.ID == canonical.ID && d.DependencyType == types.DepDuplicates {
found = true
}
}
if !found {
t.Errorf("duplicate should have duplicates link to canonical")
}
// Verify closed status
updated, err := store.GetIssue(ctx, duplicate.ID)
if err != nil {
t.Fatalf("GetIssue failed: %v", err)
}
if updated.Status != types.StatusClosed {
t.Errorf("Status = %q, want %q", updated.Status, types.StatusClosed)
}
}
// TestSupersededBy verifies supersedes dependencies work via the dependency API.
func TestSupersededBy(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create old and new versions
oldVersion := &types.Issue{
Title: "Design Doc v1",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
newVersion := &types.Issue{
Title: "Design Doc v2",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := store.CreateIssue(ctx, oldVersion, "test"); err != nil {
t.Fatalf("Failed to create old version: %v", err)
}
if err := store.CreateIssue(ctx, newVersion, "test"); err != nil {
t.Fatalf("Failed to create new version: %v", err)
}
// Add supersedes dependency (newVersion supersedes oldVersion)
// Stored as: oldVersion depends on newVersion with type supersedes
dep := &types.Dependency{
IssueID: oldVersion.ID,
DependsOnID: newVersion.ID,
Type: types.DepSupersedes,
}
if err := store.AddDependency(ctx, dep, "test"); err != nil {
t.Fatalf("Failed to add supersedes dep: %v", err)
}
// Close old version
if err := store.CloseIssue(ctx, oldVersion.ID, "Superseded by v2", "test", ""); err != nil {
t.Fatalf("Failed to close old version: %v", err)
}
// Verify dependency
deps, err := store.GetDependenciesWithMetadata(ctx, oldVersion.ID)
if err != nil {
t.Fatalf("GetDependenciesWithMetadata failed: %v", err)
}
found := false
for _, d := range deps {
if d.ID == newVersion.ID && d.DependencyType == types.DepSupersedes {
found = true
}
}
if !found {
t.Errorf("oldVersion should have supersedes link to newVersion")
}
// Verify closed status
updated, err := store.GetIssue(ctx, oldVersion.ID)
if err != nil {
t.Fatalf("GetIssue failed: %v", err)
}
if updated.Status != types.StatusClosed {
t.Errorf("Status = %q, want %q", updated.Status, types.StatusClosed)
}
}
// TestRepliesTo verifies replies-to dependencies work via the dependency API.
func TestRepliesTo(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create original message and reply
original := &types.Issue{
Title: "Original Message",
Description: "Original content",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeMessage,
Sender: "alice",
Assignee: "bob",
Ephemeral: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
reply := &types.Issue{
Title: "Re: Original Message",
Description: "Reply content",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeMessage,
Sender: "bob",
Assignee: "alice",
Ephemeral: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := store.CreateIssue(ctx, original, "test"); err != nil {
t.Fatalf("Failed to create original: %v", err)
}
if err := store.CreateIssue(ctx, reply, "test"); err != nil {
t.Fatalf("Failed to create reply: %v", err)
}
// Add replies-to dependency
dep := &types.Dependency{
IssueID: reply.ID,
DependsOnID: original.ID,
Type: types.DepRepliesTo,
ThreadID: original.ID, // Thread root is the original message
}
if err := store.AddDependency(ctx, dep, "test"); err != nil {
t.Fatalf("Failed to add replies-to dep: %v", err)
}
// Verify thread link
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")
}
}
func TestRepliesTo_Chain(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create a chain of replies
messages := make([]*types.Issue, 3)
var prevID string
for i := range messages {
messages[i] = &types.Issue{
Title: "Message",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeMessage,
Sender: "user",
Assignee: "inbox",
Ephemeral: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := store.CreateIssue(ctx, messages[i], "test"); err != nil {
t.Fatalf("Failed to create message %d: %v", i, err)
}
// Add replies-to dependency for subsequent messages
if prevID != "" {
dep := &types.Dependency{
IssueID: messages[i].ID,
DependsOnID: prevID,
Type: types.DepRepliesTo,
ThreadID: messages[0].ID, // Thread root is the first message
}
if err := store.AddDependency(ctx, dep, "test"); err != nil {
t.Fatalf("Failed to add replies-to dep for message %d: %v", i, err)
}
}
prevID = messages[i].ID
}
// Verify chain by checking dependents
for i := 0; i < len(messages)-1; i++ {
dependents, err := store.GetDependentsWithMetadata(ctx, messages[i].ID)
if err != nil {
t.Fatalf("GetDependentsWithMetadata failed for message %d: %v", i, err)
}
found := false
for _, d := range dependents {
if d.ID == messages[i+1].ID && d.DependencyType == types.DepRepliesTo {
found = true
}
}
if !found {
t.Errorf("Message %d should have reply from message %d", i, i+1)
}
}
}
func TestWispField(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create wisp issue
wisp := &types.Issue{
Title: "Wisp Issue",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeMessage,
Ephemeral: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// Create non-wisp issue
permanent := &types.Issue{
Title: "Permanent Issue",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
Ephemeral: false,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := store.CreateIssue(ctx, wisp, "test"); err != nil {
t.Fatalf("Failed to create wisp: %v", err)
}
if err := store.CreateIssue(ctx, permanent, "test"); err != nil {
t.Fatalf("Failed to create permanent: %v", err)
}
// Verify wisp flag
savedWisp, err := store.GetIssue(ctx, wisp.ID)
if err != nil {
t.Fatalf("GetIssue failed: %v", err)
}
if !savedWisp.Ephemeral {
t.Error("Wisp issue should have Wisp=true")
}
savedPermanent, err := store.GetIssue(ctx, permanent.ID)
if err != nil {
t.Fatalf("GetIssue failed: %v", err)
}
if savedPermanent.Ephemeral {
t.Error("Permanent issue should have Wisp=false")
}
}
func TestWispFilter(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create mix of wisp and non-wisp issues
for i := 0; i < 3; i++ {
wisp := &types.Issue{
Title: "Wisp",
Status: types.StatusClosed, // Closed for cleanup test
Priority: 2,
IssueType: types.TypeMessage,
Ephemeral: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := store.CreateIssue(ctx, wisp, "test"); err != nil {
t.Fatalf("Failed to create wisp %d: %v", i, err)
}
}
for i := 0; i < 2; i++ {
permanent := &types.Issue{
Title: "Permanent",
Status: types.StatusClosed,
Priority: 2,
IssueType: types.TypeTask,
Ephemeral: false,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := store.CreateIssue(ctx, permanent, "test"); err != nil {
t.Fatalf("Failed to create permanent %d: %v", i, err)
}
}
// Filter for wisp only
wispTrue := true
closedStatus := types.StatusClosed
wispFilter := types.IssueFilter{
Status: &closedStatus,
Ephemeral: &wispTrue,
}
wispIssues, err := store.SearchIssues(ctx, "", wispFilter)
if err != nil {
t.Fatalf("SearchIssues failed: %v", err)
}
if len(wispIssues) != 3 {
t.Errorf("Expected 3 wisp issues, got %d", len(wispIssues))
}
// Filter for non-wisp only
wispFalse := false
nonWispFilter := types.IssueFilter{
Status: &closedStatus,
Ephemeral: &wispFalse,
}
permanentIssues, err := store.SearchIssues(ctx, "", nonWispFilter)
if err != nil {
t.Fatalf("SearchIssues failed: %v", err)
}
if len(permanentIssues) != 2 {
t.Errorf("Expected 2 non-wisp issues, got %d", len(permanentIssues))
}
}
func TestSenderField(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create issue with sender
msg := &types.Issue{
Title: "Message",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeMessage,
Sender: "alice@example.com",
Assignee: "bob@example.com",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := store.CreateIssue(ctx, msg, "test"); err != nil {
t.Fatalf("Failed to create message: %v", err)
}
// Verify sender is preserved
saved, err := store.GetIssue(ctx, msg.ID)
if err != nil {
t.Fatalf("GetIssue failed: %v", err)
}
if saved.Sender != "alice@example.com" {
t.Errorf("Sender = %q, want %q", saved.Sender, "alice@example.com")
}
}
func TestMessageType(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create a message type issue
msg := &types.Issue{
Title: "Test Message",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeMessage,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := store.CreateIssue(ctx, msg, "test"); err != nil {
t.Fatalf("Failed to create message: %v", err)
}
// Verify type is preserved
saved, err := store.GetIssue(ctx, msg.ID)
if err != nil {
t.Fatalf("GetIssue failed: %v", err)
}
if saved.IssueType != types.TypeMessage {
t.Errorf("IssueType = %q, want %q", saved.IssueType, types.TypeMessage)
}
// Filter by message type
messageType := types.TypeMessage
filter := types.IssueFilter{
IssueType: &messageType,
}
messages, err := store.SearchIssues(ctx, "", filter)
if err != nil {
t.Fatalf("SearchIssues failed: %v", err)
}
if len(messages) != 1 {
t.Errorf("Expected 1 message, got %d", len(messages))
}
}