Files
beads/cmd/bd/mail_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

393 lines
9.4 KiB
Go

package main
import (
"context"
"testing"
"time"
"github.com/steveyegge/beads/internal/types"
)
func TestMailSendAndInbox(t *testing.T) {
tmpDir := t.TempDir()
testStore := newTestStore(t, tmpDir+"/.beads/beads.db")
ctx := context.Background()
// Set up global state
oldStore := store
oldRootCtx := rootCtx
oldActor := actor
store = testStore
rootCtx = ctx
actor = "test-user"
defer func() {
store = oldStore
rootCtx = oldRootCtx
actor = oldActor
}()
// Create a message (simulating mail send)
now := time.Now()
msg := &types.Issue{
Title: "Test Subject",
Description: "Test message body",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeMessage,
Assignee: "worker-1",
Sender: "manager",
Ephemeral: true,
CreatedAt: now,
UpdatedAt: now,
}
if err := testStore.CreateIssue(ctx, msg, actor); err != nil {
t.Fatalf("Failed to create message: %v", err)
}
// Query inbox for worker-1
messageType := types.TypeMessage
openStatus := types.StatusOpen
assignee := "worker-1"
filter := types.IssueFilter{
IssueType: &messageType,
Status: &openStatus,
Assignee: &assignee,
}
messages, err := testStore.SearchIssues(ctx, "", filter)
if err != nil {
t.Fatalf("SearchIssues failed: %v", err)
}
if len(messages) != 1 {
t.Fatalf("Expected 1 message, got %d", len(messages))
}
if messages[0].Title != "Test Subject" {
t.Errorf("Title = %q, want %q", messages[0].Title, "Test Subject")
}
if messages[0].Sender != "manager" {
t.Errorf("Sender = %q, want %q", messages[0].Sender, "manager")
}
if !messages[0].Ephemeral {
t.Error("Ephemeral should be true")
}
}
func TestMailInboxEmpty(t *testing.T) {
tmpDir := t.TempDir()
testStore := newTestStore(t, tmpDir+"/.beads/beads.db")
ctx := context.Background()
// Query inbox for non-existent user
messageType := types.TypeMessage
openStatus := types.StatusOpen
assignee := "nobody"
filter := types.IssueFilter{
IssueType: &messageType,
Status: &openStatus,
Assignee: &assignee,
}
messages, err := testStore.SearchIssues(ctx, "", filter)
if err != nil {
t.Fatalf("SearchIssues failed: %v", err)
}
if len(messages) != 0 {
t.Errorf("Expected 0 messages, got %d", len(messages))
}
}
func TestMailAck(t *testing.T) {
tmpDir := t.TempDir()
testStore := newTestStore(t, tmpDir+"/.beads/beads.db")
ctx := context.Background()
// Create a message
now := time.Now()
msg := &types.Issue{
Title: "Ack Test",
Description: "Test body",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeMessage,
Assignee: "recipient",
Sender: "sender",
Ephemeral: true,
CreatedAt: now,
UpdatedAt: now,
}
if err := testStore.CreateIssue(ctx, msg, "test"); err != nil {
t.Fatalf("Failed to create message: %v", err)
}
// Acknowledge (close) the message
if err := testStore.CloseIssue(ctx, msg.ID, "acknowledged", "test"); err != nil {
t.Fatalf("Failed to close message: %v", err)
}
// Verify it's closed
updated, err := testStore.GetIssue(ctx, msg.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)
}
// Verify it no longer appears in inbox
messageType := types.TypeMessage
openStatus := types.StatusOpen
assignee := "recipient"
filter := types.IssueFilter{
IssueType: &messageType,
Status: &openStatus,
Assignee: &assignee,
}
messages, err := testStore.SearchIssues(ctx, "", filter)
if err != nil {
t.Fatalf("SearchIssues failed: %v", err)
}
if len(messages) != 0 {
t.Errorf("Expected 0 messages in inbox after ack, got %d", len(messages))
}
}
func TestMailReply(t *testing.T) {
tmpDir := t.TempDir()
testStore := newTestStore(t, tmpDir+"/.beads/beads.db")
ctx := context.Background()
// Create original message
now := time.Now()
original := &types.Issue{
Title: "Original Subject",
Description: "Original body",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeMessage,
Assignee: "worker",
Sender: "manager",
Ephemeral: true,
CreatedAt: now,
UpdatedAt: now,
}
if err := testStore.CreateIssue(ctx, original, "test"); err != nil {
t.Fatalf("Failed to create original message: %v", err)
}
// Create reply (thread link now done via dependencies per Decision 004)
reply := &types.Issue{
Title: "Re: Original Subject",
Description: "Reply body",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeMessage,
Assignee: "manager", // Reply goes to original sender
Sender: "worker",
Ephemeral: true,
CreatedAt: now.Add(time.Minute),
UpdatedAt: now.Add(time.Minute),
}
if err := testStore.CreateIssue(ctx, reply, "test"); err != nil {
t.Fatalf("Failed to create reply: %v", err)
}
// Add replies-to dependency (thread link per Decision 004)
dep := &types.Dependency{
IssueID: reply.ID,
DependsOnID: original.ID,
Type: types.DepRepliesTo,
}
if err := testStore.AddDependency(ctx, dep, "test"); err != nil {
t.Fatalf("Failed to add replies-to dependency: %v", err)
}
// Verify reply has correct thread link via dependencies
deps, err := testStore.GetDependenciesWithMetadata(ctx, reply.ID)
if err != nil {
t.Fatalf("GetDependenciesWithMetadata failed: %v", err)
}
var foundReplyLink bool
for _, d := range deps {
if d.DependencyType == types.DepRepliesTo && d.ID == original.ID {
foundReplyLink = true
break
}
}
if !foundReplyLink {
t.Errorf("Reply missing replies-to link to original message")
}
}
func TestMailPriority(t *testing.T) {
tmpDir := t.TempDir()
testStore := newTestStore(t, tmpDir+"/.beads/beads.db")
ctx := context.Background()
// Create messages with different priorities
now := time.Now()
messages := []struct {
title string
priority int
}{
{"Normal message", 2},
{"Urgent message", 0},
{"High priority", 1},
}
for i, m := range messages {
msg := &types.Issue{
Title: m.title,
Description: "Body",
Status: types.StatusOpen,
Priority: m.priority,
IssueType: types.TypeMessage,
Assignee: "inbox",
Sender: "sender",
Ephemeral: true,
CreatedAt: now.Add(time.Duration(i) * time.Minute),
UpdatedAt: now.Add(time.Duration(i) * time.Minute),
}
if err := testStore.CreateIssue(ctx, msg, "test"); err != nil {
t.Fatalf("Failed to create message %d: %v", i, err)
}
}
// Query all messages
messageType := types.TypeMessage
openStatus := types.StatusOpen
assignee := "inbox"
filter := types.IssueFilter{
IssueType: &messageType,
Status: &openStatus,
Assignee: &assignee,
}
results, err := testStore.SearchIssues(ctx, "", filter)
if err != nil {
t.Fatalf("SearchIssues failed: %v", err)
}
if len(results) != 3 {
t.Fatalf("Expected 3 messages, got %d", len(results))
}
// Verify we can filter by priority
urgentPriority := 0
urgentFilter := types.IssueFilter{
IssueType: &messageType,
Status: &openStatus,
Assignee: &assignee,
Priority: &urgentPriority,
}
urgent, err := testStore.SearchIssues(ctx, "", urgentFilter)
if err != nil {
t.Fatalf("SearchIssues failed: %v", err)
}
if len(urgent) != 1 {
t.Errorf("Expected 1 urgent message, got %d", len(urgent))
}
}
func TestMailTypeValidation(t *testing.T) {
tmpDir := t.TempDir()
testStore := newTestStore(t, tmpDir+"/.beads/beads.db")
ctx := context.Background()
// Create a regular issue (not a message)
now := time.Now()
task := &types.Issue{
Title: "Regular Task",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
CreatedAt: now,
UpdatedAt: now,
}
if err := testStore.CreateIssue(ctx, task, "test"); err != nil {
t.Fatalf("Failed to create task: %v", err)
}
// Query for messages should not return the task
messageType := types.TypeMessage
filter := types.IssueFilter{
IssueType: &messageType,
}
messages, err := testStore.SearchIssues(ctx, "", filter)
if err != nil {
t.Fatalf("SearchIssues failed: %v", err)
}
for _, m := range messages {
if m.ID == task.ID {
t.Errorf("Task %s should not appear in message query", task.ID)
}
}
}
func TestMailSenderField(t *testing.T) {
tmpDir := t.TempDir()
testStore := newTestStore(t, tmpDir+"/.beads/beads.db")
ctx := context.Background()
// Create messages from different senders
now := time.Now()
senders := []string{"alice", "bob", "charlie"}
for i, sender := range senders {
msg := &types.Issue{
Title: "Message from " + sender,
Description: "Body",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeMessage,
Assignee: "inbox",
Sender: sender,
Ephemeral: true,
CreatedAt: now.Add(time.Duration(i) * time.Minute),
UpdatedAt: now.Add(time.Duration(i) * time.Minute),
}
if err := testStore.CreateIssue(ctx, msg, "test"); err != nil {
t.Fatalf("Failed to create message from %s: %v", sender, err)
}
}
// Query all messages and verify sender
messageType := types.TypeMessage
filter := types.IssueFilter{
IssueType: &messageType,
}
messages, err := testStore.SearchIssues(ctx, "", filter)
if err != nil {
t.Fatalf("SearchIssues failed: %v", err)
}
senderSet := make(map[string]bool)
for _, m := range messages {
if m.Sender != "" {
senderSet[m.Sender] = true
}
}
for _, s := range senders {
if !senderSet[s] {
t.Errorf("Sender %q not found in messages", s)
}
}
}