Files
gastown/internal/cmd/mail_test.go
toecutter 346a283cc4 feat(mail): Add gt mail announces command to list and read bulletin boards (gt-27bzi)
Add announces subcommand to internal/cmd/mail.go that provides:
- gt mail announces: Lists all announce channels from messaging.json
- gt mail announces <channel>: Reads messages from a specific channel

The command queries beads for messages with announce_channel label and
displays them in reverse chronological order. Messages are NOT marked
as read or removed, preserving bulletin board semantics.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 00:22:17 -08:00

384 lines
9.2 KiB
Go

package cmd
import (
"fmt"
"strings"
"testing"
"github.com/steveyegge/gastown/internal/config"
)
func TestMatchWorkerPattern(t *testing.T) {
tests := []struct {
name string
pattern string
caller string
want bool
}{
// Exact matches
{
name: "exact match",
pattern: "gastown/polecats/capable",
caller: "gastown/polecats/capable",
want: true,
},
{
name: "exact match with different name",
pattern: "gastown/polecats/toast",
caller: "gastown/polecats/capable",
want: false,
},
// Wildcard at end
{
name: "wildcard matches polecat",
pattern: "gastown/polecats/*",
caller: "gastown/polecats/capable",
want: true,
},
{
name: "wildcard matches different polecat",
pattern: "gastown/polecats/*",
caller: "gastown/polecats/toast",
want: true,
},
{
name: "wildcard doesn't match wrong rig",
pattern: "gastown/polecats/*",
caller: "beads/polecats/capable",
want: false,
},
{
name: "wildcard doesn't match nested path",
pattern: "gastown/polecats/*",
caller: "gastown/polecats/sub/capable",
want: false,
},
// Crew patterns
{
name: "crew wildcard matches",
pattern: "gastown/crew/*",
caller: "gastown/crew/max",
want: true,
},
{
name: "crew wildcard doesn't match polecats",
pattern: "gastown/crew/*",
caller: "gastown/polecats/capable",
want: false,
},
// Different rigs
{
name: "different rig wildcard",
pattern: "beads/polecats/*",
caller: "beads/polecats/capable",
want: true,
},
// Edge cases
{
name: "empty pattern",
pattern: "",
caller: "gastown/polecats/capable",
want: false,
},
{
name: "empty caller",
pattern: "gastown/polecats/*",
caller: "",
want: false,
},
{
name: "pattern is just wildcard",
pattern: "*",
caller: "anything",
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := matchWorkerPattern(tt.pattern, tt.caller)
if got != tt.want {
t.Errorf("matchWorkerPattern(%q, %q) = %v, want %v",
tt.pattern, tt.caller, got, tt.want)
}
})
}
}
func TestIsEligibleWorker(t *testing.T) {
tests := []struct {
name string
caller string
patterns []string
want bool
}{
{
name: "matches first pattern",
caller: "gastown/polecats/capable",
patterns: []string{"gastown/polecats/*", "gastown/crew/*"},
want: true,
},
{
name: "matches second pattern",
caller: "gastown/crew/max",
patterns: []string{"gastown/polecats/*", "gastown/crew/*"},
want: true,
},
{
name: "matches none",
caller: "beads/polecats/capable",
patterns: []string{"gastown/polecats/*", "gastown/crew/*"},
want: false,
},
{
name: "empty patterns list",
caller: "gastown/polecats/capable",
patterns: []string{},
want: false,
},
{
name: "nil patterns",
caller: "gastown/polecats/capable",
patterns: nil,
want: false,
},
{
name: "exact match in list",
caller: "mayor/",
patterns: []string{"mayor/", "gastown/witness"},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isEligibleWorker(tt.caller, tt.patterns)
if got != tt.want {
t.Errorf("isEligibleWorker(%q, %v) = %v, want %v",
tt.caller, tt.patterns, got, tt.want)
}
})
}
}
// TestMailReleaseValidation tests the validation logic for the release command.
// This tests that release correctly identifies:
// - Messages not claimed (still in queue)
// - Messages claimed by a different worker
// - Messages without queue labels (non-queue messages)
func TestMailReleaseValidation(t *testing.T) {
tests := []struct {
name string
msgInfo *messageInfo
caller string
wantErr bool
errContains string
}{
{
name: "caller matches assignee - valid release",
msgInfo: &messageInfo{
ID: "hq-test1",
Title: "Test Message",
Assignee: "gastown/polecats/nux",
QueueName: "work/gastown",
Status: "in_progress",
},
caller: "gastown/polecats/nux",
wantErr: false,
},
{
name: "message still in queue - not claimed",
msgInfo: &messageInfo{
ID: "hq-test2",
Title: "Test Message",
Assignee: "queue:work/gastown",
QueueName: "work/gastown",
Status: "open",
},
caller: "gastown/polecats/nux",
wantErr: true,
errContains: "not claimed",
},
{
name: "claimed by different worker",
msgInfo: &messageInfo{
ID: "hq-test3",
Title: "Test Message",
Assignee: "gastown/polecats/other",
QueueName: "work/gastown",
Status: "in_progress",
},
caller: "gastown/polecats/nux",
wantErr: true,
errContains: "was claimed by",
},
{
name: "not a queue message",
msgInfo: &messageInfo{
ID: "hq-test4",
Title: "Test Message",
Assignee: "gastown/polecats/nux",
QueueName: "", // No queue label
Status: "open",
},
caller: "gastown/polecats/nux",
wantErr: true,
errContains: "not a queue message",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateRelease(tt.msgInfo, tt.caller)
if tt.wantErr {
if err == nil {
t.Error("expected error, got nil")
return
}
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("error %q should contain %q", err.Error(), tt.errContains)
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
})
}
}
// validateRelease checks if a message can be released by the caller.
// This is extracted for testing; the actual release command uses this logic inline.
func validateRelease(msgInfo *messageInfo, caller string) error {
// Verify message is a queue message
if msgInfo.QueueName == "" {
return fmt.Errorf("message %s is not a queue message (no queue label)", msgInfo.ID)
}
// Verify caller is the one who claimed it
if msgInfo.Assignee != caller {
if strings.HasPrefix(msgInfo.Assignee, "queue:") {
return fmt.Errorf("message %s is not claimed (still in queue)", msgInfo.ID)
}
return fmt.Errorf("message %s was claimed by %s, not %s", msgInfo.ID, msgInfo.Assignee, caller)
}
return nil
}
// TestMailAnnounces tests the announces command functionality.
func TestMailAnnounces(t *testing.T) {
t.Run("listAnnounceChannels with nil config", func(t *testing.T) {
// Test with nil announces map
cfg := &config.MessagingConfig{
Announces: nil,
}
// Reset flag to default
mailAnnouncesJSON = false
// This should not panic and should handle nil gracefully
// We can't easily capture stdout in unit tests, but we can verify no panic
err := listAnnounceChannels(cfg)
if err != nil {
t.Errorf("listAnnounceChannels with nil announces should not error: %v", err)
}
})
t.Run("listAnnounceChannels with empty config", func(t *testing.T) {
cfg := &config.MessagingConfig{
Announces: make(map[string]config.AnnounceConfig),
}
mailAnnouncesJSON = false
err := listAnnounceChannels(cfg)
if err != nil {
t.Errorf("listAnnounceChannels with empty announces should not error: %v", err)
}
})
t.Run("readAnnounceChannel validates channel exists", func(t *testing.T) {
cfg := &config.MessagingConfig{
Announces: map[string]config.AnnounceConfig{
"alerts": {
Readers: []string{"@town"},
RetainCount: 100,
},
},
}
// Test with unknown channel
err := readAnnounceChannel("/tmp", cfg, "nonexistent")
if err == nil {
t.Error("readAnnounceChannel should error for unknown channel")
}
if !strings.Contains(err.Error(), "unknown announce channel") {
t.Errorf("error should mention 'unknown announce channel', got: %v", err)
}
})
t.Run("readAnnounceChannel errors on nil announces", func(t *testing.T) {
cfg := &config.MessagingConfig{
Announces: nil,
}
err := readAnnounceChannel("/tmp", cfg, "alerts")
if err == nil {
t.Error("readAnnounceChannel should error for nil announces")
}
if !strings.Contains(err.Error(), "no announce channels configured") {
t.Errorf("error should mention 'no announce channels configured', got: %v", err)
}
})
}
// TestAnnounceMessageParsing tests parsing of announce messages from beads output.
func TestAnnounceMessageParsing(t *testing.T) {
tests := []struct {
name string
labels []string
want string
}{
{
name: "extracts from label",
labels: []string{"from:mayor/", "announce_channel:alerts"},
want: "mayor/",
},
{
name: "extracts from with rig path",
labels: []string{"announce_channel:alerts", "from:gastown/witness"},
want: "gastown/witness",
},
{
name: "no from label",
labels: []string{"announce_channel:alerts"},
want: "",
},
{
name: "empty labels",
labels: []string{},
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Simulate the label extraction logic from listAnnounceMessages
var from string
for _, label := range tt.labels {
if strings.HasPrefix(label, "from:") {
from = strings.TrimPrefix(label, "from:")
break
}
}
if from != tt.want {
t.Errorf("extracting from label: got %q, want %q", from, tt.want)
}
})
}
}