Files
beads/internal/storage/sqlite/graph_links_test.go
Steve Yegge 7c8b69f5b3 Phase 4: Remove deprecated edge fields from Issue struct (Decision 004)
This is the final phase of the Edge Schema Consolidation. It removes
the deprecated edge fields (RepliesTo, RelatesTo, DuplicateOf, SupersededBy)
from the Issue struct and all related code.

Changes:
- Remove edge fields from types.Issue struct
- Remove edge field scanning from queries.go and transaction.go
- Update graph_links_test.go to use dependency API exclusively
- Update relate.go to use AddDependency/RemoveDependency
- Update show.go with helper functions for thread traversal via deps
- Update mail_test.go to verify thread links via dependencies
- Add migration 022 to drop columns from issues table
- Fix cycle detection to allow bidirectional relates-to links
- Fix migration 022 to disable foreign keys before table recreation

All edge relationships now use the dependencies table exclusively.
The old Issue fields are fully removed.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 02:48:13 -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 TestEphemeralField(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create ephemeral issue
ephemeral := &types.Issue{
Title: "Ephemeral Issue",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeMessage,
Ephemeral: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// Create non-ephemeral 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, ephemeral, "test"); err != nil {
t.Fatalf("Failed to create ephemeral: %v", err)
}
if err := store.CreateIssue(ctx, permanent, "test"); err != nil {
t.Fatalf("Failed to create permanent: %v", err)
}
// Verify ephemeral flag
savedEphemeral, err := store.GetIssue(ctx, ephemeral.ID)
if err != nil {
t.Fatalf("GetIssue failed: %v", err)
}
if !savedEphemeral.Ephemeral {
t.Error("Ephemeral issue should have Ephemeral=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 Ephemeral=false")
}
}
func TestEphemeralFilter(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create mix of ephemeral and non-ephemeral issues
for i := 0; i < 3; i++ {
ephemeral := &types.Issue{
Title: "Ephemeral",
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, ephemeral, "test"); err != nil {
t.Fatalf("Failed to create ephemeral %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 ephemeral only
ephemeralTrue := true
closedStatus := types.StatusClosed
ephemeralFilter := types.IssueFilter{
Status: &closedStatus,
Ephemeral: &ephemeralTrue,
}
ephemeralIssues, err := store.SearchIssues(ctx, "", ephemeralFilter)
if err != nil {
t.Fatalf("SearchIssues failed: %v", err)
}
if len(ephemeralIssues) != 3 {
t.Errorf("Expected 3 ephemeral issues, got %d", len(ephemeralIssues))
}
// Filter for non-ephemeral only
ephemeralFalse := false
nonEphemeralFilter := types.IssueFilter{
Status: &closedStatus,
Ephemeral: &ephemeralFalse,
}
permanentIssues, err := store.SearchIssues(ctx, "", nonEphemeralFilter)
if err != nil {
t.Fatalf("SearchIssues failed: %v", err)
}
if len(permanentIssues) != 2 {
t.Errorf("Expected 2 non-ephemeral 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))
}
}