From 9c718b0d7d12617e4e8e959f39e667dc0f1aa2ab Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sun, 28 Dec 2025 16:09:54 -0800 Subject: [PATCH] test: Add unit tests for mail router and mailbox (gt-1pelm) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive tests for: - router_test.go: 7 tests covering detectTownRoot, isTownLevelAddress, addressToSessionID, isSelfMail, shouldBeWisp, resolveBeadsDir - mailbox_test.go: 17 tests for legacy mailbox operations - types_test.go: Enhanced with additional priority and message tests Coverage: 57.8% for mail package (legacy mailbox fully tested, beads integration requires runtime infrastructure) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/mail/mailbox_test.go | 509 ++++++++++++++++++++++++++++++++++ internal/mail/router_test.go | 221 +++++++++++++++ internal/mail/types_test.go | 285 ++++++++++++++++++- 3 files changed, 1014 insertions(+), 1 deletion(-) create mode 100644 internal/mail/mailbox_test.go create mode 100644 internal/mail/router_test.go diff --git a/internal/mail/mailbox_test.go b/internal/mail/mailbox_test.go new file mode 100644 index 00000000..5e7eb87b --- /dev/null +++ b/internal/mail/mailbox_test.go @@ -0,0 +1,509 @@ +package mail + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + "time" +) + +func TestNewMailbox(t *testing.T) { + m := NewMailbox("/tmp/test") + if m.path != "/tmp/test/inbox.jsonl" { + t.Errorf("NewMailbox path = %q, want %q", m.path, "/tmp/test/inbox.jsonl") + } + if !m.legacy { + t.Error("NewMailbox should create legacy mailbox") + } +} + +func TestNewMailboxBeads(t *testing.T) { + m := NewMailboxBeads("gastown/Toast", "/work/dir") + if m.identity != "gastown/Toast" { + t.Errorf("identity = %q, want %q", m.identity, "gastown/Toast") + } + if m.legacy { + t.Error("NewMailboxBeads should not create legacy mailbox") + } +} + +func TestMailboxLegacyAppend(t *testing.T) { + tmpDir := t.TempDir() + m := NewMailbox(tmpDir) + + msg := &Message{ + ID: "msg-001", + From: "mayor/", + To: "gastown/Toast", + Subject: "Test message", + Body: "Hello world", + Timestamp: time.Now(), + } + + if err := m.Append(msg); err != nil { + t.Fatalf("Append error: %v", err) + } + + // Verify file exists + if _, err := os.Stat(m.path); os.IsNotExist(err) { + t.Fatal("inbox.jsonl was not created") + } + + // Verify content + content, err := os.ReadFile(m.path) + if err != nil { + t.Fatalf("ReadFile error: %v", err) + } + + var readMsg Message + if err := json.Unmarshal(content[:len(content)-1], &readMsg); err != nil { // -1 for newline + t.Fatalf("Unmarshal error: %v", err) + } + + if readMsg.ID != msg.ID { + t.Errorf("ID = %q, want %q", readMsg.ID, msg.ID) + } +} + +func TestMailboxLegacyList(t *testing.T) { + tmpDir := t.TempDir() + m := NewMailbox(tmpDir) + + // Append multiple messages + msgs := []*Message{ + {ID: "msg-001", Subject: "First", Timestamp: time.Now().Add(-2 * time.Hour)}, + {ID: "msg-002", Subject: "Second", Timestamp: time.Now().Add(-1 * time.Hour)}, + {ID: "msg-003", Subject: "Third", Timestamp: time.Now()}, + } + + for _, msg := range msgs { + if err := m.Append(msg); err != nil { + t.Fatalf("Append error: %v", err) + } + } + + // List should return newest first + listed, err := m.List() + if err != nil { + t.Fatalf("List error: %v", err) + } + + if len(listed) != 3 { + t.Fatalf("List returned %d messages, want 3", len(listed)) + } + + // Verify order (newest first) + if listed[0].ID != "msg-003" { + t.Errorf("First message ID = %q, want msg-003 (newest)", listed[0].ID) + } + if listed[2].ID != "msg-001" { + t.Errorf("Last message ID = %q, want msg-001 (oldest)", listed[2].ID) + } +} + +func TestMailboxLegacyGet(t *testing.T) { + tmpDir := t.TempDir() + m := NewMailbox(tmpDir) + + msg := &Message{ + ID: "msg-001", + Subject: "Test", + Body: "Content", + } + if err := m.Append(msg); err != nil { + t.Fatalf("Append error: %v", err) + } + + // Get existing message + got, err := m.Get("msg-001") + if err != nil { + t.Fatalf("Get error: %v", err) + } + if got.Subject != "Test" { + t.Errorf("Subject = %q, want %q", got.Subject, "Test") + } + + // Get non-existent message + _, err = m.Get("msg-nonexistent") + if err != ErrMessageNotFound { + t.Errorf("Get non-existent = %v, want ErrMessageNotFound", err) + } +} + +func TestMailboxLegacyMarkRead(t *testing.T) { + tmpDir := t.TempDir() + m := NewMailbox(tmpDir) + + msg := &Message{ + ID: "msg-001", + Read: false, + } + if err := m.Append(msg); err != nil { + t.Fatalf("Append error: %v", err) + } + + // Mark as read + if err := m.MarkRead("msg-001"); err != nil { + t.Fatalf("MarkRead error: %v", err) + } + + // Verify it's now read + got, err := m.Get("msg-001") + if err != nil { + t.Fatalf("Get error: %v", err) + } + if !got.Read { + t.Error("Message should be marked as read") + } + + // Mark non-existent + err = m.MarkRead("msg-nonexistent") + if err != ErrMessageNotFound { + t.Errorf("MarkRead non-existent = %v, want ErrMessageNotFound", err) + } +} + +func TestMailboxLegacyDelete(t *testing.T) { + tmpDir := t.TempDir() + m := NewMailbox(tmpDir) + + msgs := []*Message{ + {ID: "msg-001", Subject: "First"}, + {ID: "msg-002", Subject: "Second"}, + } + for _, msg := range msgs { + if err := m.Append(msg); err != nil { + t.Fatalf("Append error: %v", err) + } + } + + // Delete one + if err := m.Delete("msg-001"); err != nil { + t.Fatalf("Delete error: %v", err) + } + + // Verify only one remains + listed, err := m.List() + if err != nil { + t.Fatalf("List error: %v", err) + } + if len(listed) != 1 { + t.Fatalf("List returned %d messages, want 1", len(listed)) + } + if listed[0].ID != "msg-002" { + t.Errorf("Remaining message ID = %q, want msg-002", listed[0].ID) + } + + // Delete non-existent + err = m.Delete("msg-nonexistent") + if err != ErrMessageNotFound { + t.Errorf("Delete non-existent = %v, want ErrMessageNotFound", err) + } +} + +func TestMailboxLegacyCount(t *testing.T) { + tmpDir := t.TempDir() + m := NewMailbox(tmpDir) + + // Empty inbox + total, unread, err := m.Count() + if err != nil { + t.Fatalf("Count error: %v", err) + } + if total != 0 || unread != 0 { + t.Errorf("Empty inbox count = (%d, %d), want (0, 0)", total, unread) + } + + // Add messages + msgs := []*Message{ + {ID: "msg-001", Read: false}, + {ID: "msg-002", Read: true}, + {ID: "msg-003", Read: false}, + } + for _, msg := range msgs { + if err := m.Append(msg); err != nil { + t.Fatalf("Append error: %v", err) + } + } + + total, unread, err = m.Count() + if err != nil { + t.Fatalf("Count error: %v", err) + } + if total != 3 { + t.Errorf("total = %d, want 3", total) + } + if unread != 2 { + t.Errorf("unread = %d, want 2", unread) + } +} + +func TestMailboxLegacyListUnread(t *testing.T) { + tmpDir := t.TempDir() + m := NewMailbox(tmpDir) + + msgs := []*Message{ + {ID: "msg-001", Read: false}, + {ID: "msg-002", Read: true}, + {ID: "msg-003", Read: false}, + } + for _, msg := range msgs { + if err := m.Append(msg); err != nil { + t.Fatalf("Append error: %v", err) + } + } + + unread, err := m.ListUnread() + if err != nil { + t.Fatalf("ListUnread error: %v", err) + } + if len(unread) != 2 { + t.Errorf("ListUnread returned %d, want 2", len(unread)) + } +} + +func TestMailboxLegacyListByThread(t *testing.T) { + tmpDir := t.TempDir() + m := NewMailbox(tmpDir) + + msgs := []*Message{ + {ID: "msg-001", ThreadID: "thread-A", Timestamp: time.Now().Add(-2 * time.Hour)}, + {ID: "msg-002", ThreadID: "thread-B", Timestamp: time.Now().Add(-1 * time.Hour)}, + {ID: "msg-003", ThreadID: "thread-A", Timestamp: time.Now()}, + } + for _, msg := range msgs { + if err := m.Append(msg); err != nil { + t.Fatalf("Append error: %v", err) + } + } + + // Get thread A + thread, err := m.ListByThread("thread-A") + if err != nil { + t.Fatalf("ListByThread error: %v", err) + } + if len(thread) != 2 { + t.Fatalf("thread-A has %d messages, want 2", len(thread)) + } + + // Verify oldest first + if thread[0].ID != "msg-001" { + t.Errorf("First thread message = %q, want msg-001 (oldest)", thread[0].ID) + } + + // Non-existent thread + empty, err := m.ListByThread("thread-nonexistent") + if err != nil { + t.Fatalf("ListByThread error: %v", err) + } + if len(empty) != 0 { + t.Errorf("Non-existent thread has %d messages, want 0", len(empty)) + } +} + +func TestMailboxLegacyEmptyInbox(t *testing.T) { + tmpDir := t.TempDir() + m := NewMailbox(tmpDir) + + // List on non-existent file should return empty, not error + msgs, err := m.List() + if err != nil { + t.Fatalf("List on empty inbox error: %v", err) + } + if len(msgs) != 0 { + t.Errorf("Empty inbox returned %d messages, want 0", len(msgs)) + } +} + +func TestMailboxBeadsAppendError(t *testing.T) { + m := NewMailboxBeads("gastown/Toast", "/work/dir") + + err := m.Append(&Message{}) + if err == nil { + t.Error("Append on beads mailbox should error") + } +} + +func TestMailboxIdentityAndPath(t *testing.T) { + // Legacy mailbox + legacy := NewMailbox("/tmp/test") + if legacy.Identity() != "" { + t.Errorf("Legacy mailbox identity = %q, want empty", legacy.Identity()) + } + if legacy.Path() != "/tmp/test/inbox.jsonl" { + t.Errorf("Legacy mailbox path = %q, want /tmp/test/inbox.jsonl", legacy.Path()) + } + + // Beads mailbox + beads := NewMailboxBeads("gastown/Toast", "/work/dir") + if beads.Identity() != "gastown/Toast" { + t.Errorf("Beads mailbox identity = %q, want gastown/Toast", beads.Identity()) + } + if beads.Path() != "" { + t.Errorf("Beads mailbox path = %q, want empty", beads.Path()) + } +} + +func TestMailboxPersistence(t *testing.T) { + tmpDir := t.TempDir() + + // Create mailbox and add message + m1 := NewMailbox(tmpDir) + msg := &Message{ + ID: "persist-001", + Subject: "Persistent message", + Body: "Should survive reload", + } + if err := m1.Append(msg); err != nil { + t.Fatalf("Append error: %v", err) + } + + // Create new mailbox pointing to same location + m2 := NewMailbox(tmpDir) + msgs, err := m2.List() + if err != nil { + t.Fatalf("List error: %v", err) + } + if len(msgs) != 1 { + t.Fatalf("Reloaded mailbox has %d messages, want 1", len(msgs)) + } + if msgs[0].Subject != "Persistent message" { + t.Errorf("Subject = %q, want 'Persistent message'", msgs[0].Subject) + } +} + +func TestNewMailboxWithBeadsDir(t *testing.T) { + m := NewMailboxWithBeadsDir("gastown/Toast", "/work/dir", "/custom/.beads") + if m.identity != "gastown/Toast" { + t.Errorf("identity = %q, want 'gastown/Toast'", m.identity) + } + if m.beadsDir != "/custom/.beads" { + t.Errorf("beadsDir = %q, want '/custom/.beads'", m.beadsDir) + } +} + +func TestMailboxLegacyMultipleOperations(t *testing.T) { + tmpDir := t.TempDir() + m := NewMailbox(tmpDir) + + // Append multiple messages + for i := 0; i < 5; i++ { + msg := &Message{ + ID: fmt.Sprintf("msg-%03d", i), + Subject: fmt.Sprintf("Subject %d", i), + Body: fmt.Sprintf("Body %d", i), + Read: i%2 == 0, // Alternate read/unread + Timestamp: time.Now().Add(time.Duration(i) * time.Minute), + } + if err := m.Append(msg); err != nil { + t.Fatalf("Append error: %v", err) + } + } + + // Delete middle message + if err := m.Delete("msg-002"); err != nil { + t.Fatalf("Delete error: %v", err) + } + + // Mark one as read + if err := m.MarkRead("msg-001"); err != nil { + t.Fatalf("MarkRead error: %v", err) + } + + // Verify counts + total, unread, err := m.Count() + if err != nil { + t.Fatalf("Count error: %v", err) + } + if total != 4 { + t.Errorf("total = %d, want 4", total) + } + // After marking msg-001 as read, we have: msg-000 (read), msg-001 (read), msg-003 (unread), msg-004 (read) + // So unread = 1 + if unread != 1 { + t.Errorf("unread = %d, want 1", unread) + } +} + +func TestMailboxLegacyAppendWithMissingDir(t *testing.T) { + tmpDir := t.TempDir() + deepPath := filepath.Join(tmpDir, "deep", "nested", "inbox") + m := NewMailbox(deepPath) + + msg := &Message{ + ID: "msg-001", + Subject: "Test", + } + + // Should create directories + if err := m.Append(msg); err != nil { + t.Fatalf("Append error: %v", err) + } + + // Verify file exists + if _, err := os.Stat(m.path); os.IsNotExist(err) { + t.Fatal("inbox.jsonl was not created") + } +} + +func TestMailboxLegacyDeleteAll(t *testing.T) { + tmpDir := t.TempDir() + m := NewMailbox(tmpDir) + + // Add messages + msgs := []*Message{ + {ID: "msg-001"}, + {ID: "msg-002"}, + } + for _, msg := range msgs { + if err := m.Append(msg); err != nil { + t.Fatalf("Append error: %v", err) + } + } + + // Delete all + for _, msg := range msgs { + if err := m.Delete(msg.ID); err != nil { + t.Fatalf("Delete error: %v", err) + } + } + + // Should be empty + total, _, err := m.Count() + if err != nil { + t.Fatalf("Count error: %v", err) + } + if total != 0 { + t.Errorf("total = %d, want 0", total) + } +} + +func TestMailboxLegacyMarkReadTwice(t *testing.T) { + tmpDir := t.TempDir() + m := NewMailbox(tmpDir) + + msg := &Message{ID: "msg-001", Read: false} + if err := m.Append(msg); err != nil { + t.Fatalf("Append error: %v", err) + } + + // Mark as read twice + if err := m.MarkRead("msg-001"); err != nil { + t.Fatalf("First MarkRead error: %v", err) + } + if err := m.MarkRead("msg-001"); err != nil { + t.Fatalf("Second MarkRead error: %v", err) + } + + // Should still be read + got, err := m.Get("msg-001") + if err != nil { + t.Fatalf("Get error: %v", err) + } + if !got.Read { + t.Error("Message should be marked as read") + } +} + diff --git a/internal/mail/router_test.go b/internal/mail/router_test.go new file mode 100644 index 00000000..a01cb268 --- /dev/null +++ b/internal/mail/router_test.go @@ -0,0 +1,221 @@ +package mail + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDetectTownRoot(t *testing.T) { + // Create temp directory structure + tmpDir := t.TempDir() + townRoot := filepath.Join(tmpDir, "town") + mayorDir := filepath.Join(townRoot, "mayor") + rigDir := filepath.Join(townRoot, "gastown", "polecats", "test") + + // Create mayor/town.json marker + if err := os.MkdirAll(mayorDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(mayorDir, "town.json"), []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(rigDir, 0755); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + startDir string + want string + }{ + { + name: "from town root", + startDir: townRoot, + want: townRoot, + }, + { + name: "from rig subdirectory", + startDir: rigDir, + want: townRoot, + }, + { + name: "from mayor directory", + startDir: mayorDir, + want: townRoot, + }, + { + name: "from non-town directory", + startDir: tmpDir, + want: "", // No town.json marker above tmpDir + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := detectTownRoot(tt.startDir) + if got != tt.want { + t.Errorf("detectTownRoot(%q) = %q, want %q", tt.startDir, got, tt.want) + } + }) + } +} + +func TestIsTownLevelAddress(t *testing.T) { + tests := []struct { + address string + want bool + }{ + {"mayor", true}, + {"mayor/", true}, + {"deacon", true}, + {"deacon/", true}, + {"gastown/refinery", false}, + {"gastown/polecats/Toast", false}, + {"gastown/", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.address, func(t *testing.T) { + got := isTownLevelAddress(tt.address) + if got != tt.want { + t.Errorf("isTownLevelAddress(%q) = %v, want %v", tt.address, got, tt.want) + } + }) + } +} + +func TestAddressToSessionID(t *testing.T) { + tests := []struct { + address string + want string + }{ + {"mayor", "gt-mayor"}, + {"mayor/", "gt-mayor"}, + {"gastown/refinery", "gt-gastown-refinery"}, + {"gastown/Toast", "gt-gastown-Toast"}, + {"beads/witness", "gt-beads-witness"}, + {"gastown/", ""}, // Empty target + {"gastown", ""}, // No slash + {"", ""}, // Empty address + } + + for _, tt := range tests { + t.Run(tt.address, func(t *testing.T) { + got := addressToSessionID(tt.address) + if got != tt.want { + t.Errorf("addressToSessionID(%q) = %q, want %q", tt.address, got, tt.want) + } + }) + } +} + +func TestIsSelfMail(t *testing.T) { + tests := []struct { + from string + to string + want bool + }{ + {"mayor/", "mayor/", true}, + {"mayor", "mayor/", true}, + {"mayor/", "mayor", true}, + {"gastown/Toast", "gastown/Toast", true}, + {"gastown/Toast/", "gastown/Toast", true}, + {"mayor/", "deacon/", false}, + {"gastown/Toast", "gastown/Nux", false}, + {"", "", true}, + } + + for _, tt := range tests { + t.Run(tt.from+"->"+tt.to, func(t *testing.T) { + got := isSelfMail(tt.from, tt.to) + if got != tt.want { + t.Errorf("isSelfMail(%q, %q) = %v, want %v", tt.from, tt.to, got, tt.want) + } + }) + } +} + +func TestShouldBeWisp(t *testing.T) { + r := &Router{} + + tests := []struct { + name string + msg *Message + want bool + }{ + { + name: "explicit wisp flag", + msg: &Message{Subject: "Regular message", Wisp: true}, + want: true, + }, + { + name: "POLECAT_STARTED subject", + msg: &Message{Subject: "POLECAT_STARTED: Toast"}, + want: true, + }, + { + name: "polecat_done subject (lowercase)", + msg: &Message{Subject: "polecat_done: work complete"}, + want: true, + }, + { + name: "NUDGE subject", + msg: &Message{Subject: "NUDGE: check your hook"}, + want: true, + }, + { + name: "START_WORK subject", + msg: &Message{Subject: "START_WORK: gt-123"}, + want: true, + }, + { + name: "regular message", + msg: &Message{Subject: "Please review this PR"}, + want: false, + }, + { + name: "handoff message (not auto-wisp)", + msg: &Message{Subject: "HANDOFF: context notes"}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := r.shouldBeWisp(tt.msg) + if got != tt.want { + t.Errorf("shouldBeWisp(%v) = %v, want %v", tt.msg.Subject, got, tt.want) + } + }) + } +} + +func TestResolveBeadsDir(t *testing.T) { + // With town root set + r := NewRouterWithTownRoot("/work/dir", "/home/user/gt") + got := r.resolveBeadsDir("gastown/Toast") + want := "/home/user/gt/.beads" + if got != want { + t.Errorf("resolveBeadsDir with townRoot = %q, want %q", got, want) + } + + // Without town root (fallback to workDir) + r2 := &Router{workDir: "/work/dir", townRoot: ""} + got2 := r2.resolveBeadsDir("mayor/") + want2 := "/work/dir/.beads" + if got2 != want2 { + t.Errorf("resolveBeadsDir without townRoot = %q, want %q", got2, want2) + } +} + +func TestNewRouterWithTownRoot(t *testing.T) { + r := NewRouterWithTownRoot("/work/rig", "/home/gt") + if r.workDir != "/work/rig" { + t.Errorf("workDir = %q, want '/work/rig'", r.workDir) + } + if r.townRoot != "/home/gt" { + t.Errorf("townRoot = %q, want '/home/gt'", r.townRoot) + } +} diff --git a/internal/mail/types_test.go b/internal/mail/types_test.go index d253d948..5678bea5 100644 --- a/internal/mail/types_test.go +++ b/internal/mail/types_test.go @@ -1,6 +1,9 @@ package mail -import "testing" +import ( + "testing" + "time" +) func TestAddressToIdentity(t *testing.T) { tests := []struct { @@ -66,3 +69,283 @@ func TestIdentityToAddress(t *testing.T) { }) } } + +func TestPriorityToBeads(t *testing.T) { + tests := []struct { + priority Priority + expected int + }{ + {PriorityUrgent, 0}, + {PriorityHigh, 1}, + {PriorityNormal, 2}, + {PriorityLow, 3}, + {Priority("unknown"), 2}, // Default to normal + } + + for _, tt := range tests { + t.Run(string(tt.priority), func(t *testing.T) { + got := PriorityToBeads(tt.priority) + if got != tt.expected { + t.Errorf("PriorityToBeads(%q) = %d, want %d", tt.priority, got, tt.expected) + } + }) + } +} + +func TestPriorityFromInt(t *testing.T) { + tests := []struct { + p int + expected Priority + }{ + {0, PriorityUrgent}, + {1, PriorityHigh}, + {2, PriorityNormal}, + {3, PriorityLow}, + {4, PriorityLow}, // Out of range maps to low + {-1, PriorityNormal}, // Negative maps to normal + } + + for _, tt := range tests { + got := PriorityFromInt(tt.p) + if got != tt.expected { + t.Errorf("PriorityFromInt(%d) = %q, want %q", tt.p, got, tt.expected) + } + } +} + +func TestParsePriority(t *testing.T) { + tests := []struct { + s string + expected Priority + }{ + {"urgent", PriorityUrgent}, + {"high", PriorityHigh}, + {"normal", PriorityNormal}, + {"low", PriorityLow}, + {"unknown", PriorityNormal}, // Default + {"", PriorityNormal}, // Empty + {"URGENT", PriorityNormal}, // Case-sensitive, defaults to normal + } + + for _, tt := range tests { + t.Run(tt.s, func(t *testing.T) { + got := ParsePriority(tt.s) + if got != tt.expected { + t.Errorf("ParsePriority(%q) = %q, want %q", tt.s, got, tt.expected) + } + }) + } +} + +func TestParseMessageType(t *testing.T) { + tests := []struct { + s string + expected MessageType + }{ + {"task", TypeTask}, + {"scavenge", TypeScavenge}, + {"notification", TypeNotification}, + {"reply", TypeReply}, + {"unknown", TypeNotification}, // Default + {"", TypeNotification}, // Empty + {"TASK", TypeNotification}, // Case-sensitive, defaults to notification + } + + for _, tt := range tests { + t.Run(tt.s, func(t *testing.T) { + got := ParseMessageType(tt.s) + if got != tt.expected { + t.Errorf("ParseMessageType(%q) = %q, want %q", tt.s, got, tt.expected) + } + }) + } +} + +func TestNewMessage(t *testing.T) { + msg := NewMessage("mayor/", "gastown/Toast", "Test Subject", "Test Body") + + if msg.From != "mayor/" { + t.Errorf("From = %q, want 'mayor/'", msg.From) + } + if msg.To != "gastown/Toast" { + t.Errorf("To = %q, want 'gastown/Toast'", msg.To) + } + if msg.Subject != "Test Subject" { + t.Errorf("Subject = %q, want 'Test Subject'", msg.Subject) + } + if msg.Body != "Test Body" { + t.Errorf("Body = %q, want 'Test Body'", msg.Body) + } + if msg.ID == "" { + t.Error("ID should be generated") + } + if msg.ThreadID == "" { + t.Error("ThreadID should be generated") + } + if msg.Timestamp.IsZero() { + t.Error("Timestamp should be set") + } + if msg.Priority != PriorityNormal { + t.Errorf("Priority = %q, want PriorityNormal", msg.Priority) + } + if msg.Type != TypeNotification { + t.Errorf("Type = %q, want TypeNotification", msg.Type) + } +} + +func TestNewReplyMessage(t *testing.T) { + original := &Message{ + ID: "orig-001", + ThreadID: "thread-001", + From: "gastown/Toast", + To: "mayor/", + Subject: "Original Subject", + } + + reply := NewReplyMessage("mayor/", "gastown/Toast", "Re: Original Subject", "Reply body", original) + + if reply.ThreadID != "thread-001" { + t.Errorf("ThreadID = %q, want 'thread-001'", reply.ThreadID) + } + if reply.ReplyTo != "orig-001" { + t.Errorf("ReplyTo = %q, want 'orig-001'", reply.ReplyTo) + } + if reply.From != "mayor/" { + t.Errorf("From = %q, want 'mayor/'", reply.From) + } + if reply.To != "gastown/Toast" { + t.Errorf("To = %q, want 'gastown/Toast'", reply.To) + } + if reply.Subject != "Re: Original Subject" { + t.Errorf("Subject = %q, want 'Re: Original Subject'", reply.Subject) + } +} + +func TestBeadsMessageToMessage(t *testing.T) { + now := time.Now() + bm := BeadsMessage{ + ID: "hq-test", + Title: "Test Subject", + Description: "Test Body", + Status: "open", + Assignee: "gastown/Toast", + Labels: []string{"from:mayor/", "thread:t-001"}, + CreatedAt: now, + Priority: 1, + } + + msg := bm.ToMessage() + + if msg.ID != "hq-test" { + t.Errorf("ID = %q, want 'hq-test'", msg.ID) + } + if msg.Subject != "Test Subject" { + t.Errorf("Subject = %q, want 'Test Subject'", msg.Subject) + } + if msg.Body != "Test Body" { + t.Errorf("Body = %q, want 'Test Body'", msg.Body) + } + if msg.From != "mayor/" { + t.Errorf("From = %q, want 'mayor/'", msg.From) + } + if msg.ThreadID != "t-001" { + t.Errorf("ThreadID = %q, want 't-001'", msg.ThreadID) + } + if msg.To != "gastown/Toast" { + t.Errorf("To = %q, want 'gastown/Toast'", msg.To) + } + if msg.Priority != PriorityHigh { + t.Errorf("Priority = %q, want PriorityHigh", msg.Priority) + } +} + +func TestBeadsMessageToMessageWithReplyTo(t *testing.T) { + bm := BeadsMessage{ + ID: "hq-reply", + Title: "Reply Subject", + Description: "Reply Body", + Status: "open", + Assignee: "gastown/Toast", + Labels: []string{"from:mayor/", "thread:t-002", "reply-to:orig-001", "msg-type:reply"}, + CreatedAt: time.Now(), + Priority: 2, + } + + msg := bm.ToMessage() + + if msg.ReplyTo != "orig-001" { + t.Errorf("ReplyTo = %q, want 'orig-001'", msg.ReplyTo) + } + if msg.Type != TypeReply { + t.Errorf("Type = %q, want TypeReply", msg.Type) + } +} + +func TestBeadsMessageToMessagePriorities(t *testing.T) { + tests := []struct { + priority int + expected Priority + }{ + {0, PriorityUrgent}, + {1, PriorityHigh}, + {2, PriorityNormal}, + {3, PriorityLow}, + {4, PriorityNormal}, // Out of range defaults to normal + {99, PriorityNormal}, // Out of range defaults to normal + } + + for _, tt := range tests { + bm := BeadsMessage{ + ID: "hq-test", + Priority: tt.priority, + } + msg := bm.ToMessage() + if msg.Priority != tt.expected { + t.Errorf("Priority %d -> %q, want %q", tt.priority, msg.Priority, tt.expected) + } + } +} + +func TestBeadsMessageToMessageTypes(t *testing.T) { + tests := []struct { + msgType string + expected MessageType + }{ + {"task", TypeTask}, + {"scavenge", TypeScavenge}, + {"reply", TypeReply}, + {"notification", TypeNotification}, + {"", TypeNotification}, // Default + } + + for _, tt := range tests { + bm := BeadsMessage{ + ID: "hq-test", + Labels: []string{"msg-type:" + tt.msgType}, + } + msg := bm.ToMessage() + if msg.Type != tt.expected { + t.Errorf("msg-type:%s -> %q, want %q", tt.msgType, msg.Type, tt.expected) + } + } +} + +func TestBeadsMessageToMessageEmptyLabels(t *testing.T) { + bm := BeadsMessage{ + ID: "hq-empty", + Title: "Empty Labels", + Description: "Test with empty labels", + Assignee: "gastown/Toast", + Labels: []string{}, // No labels + Priority: 2, + } + + msg := bm.ToMessage() + + if msg.From != "" { + t.Errorf("From should be empty, got %q", msg.From) + } + if msg.ThreadID != "" { + t.Errorf("ThreadID should be empty, got %q", msg.ThreadID) + } +}