Add validateRecipient() to check that mail recipients correspond to existing agents before sending. This prevents mail from being stored with invalid assignees that won't match inbox queries. The validation queries agent beads and checks if any match the recipient identity. The only special case is "overseer" which is the human operator and doesn't have an agent bead. Tests create a temporary isolated beads database with test agents to validate both success and failure cases. Tests are skipped if bd CLI is not available (e.g., in CI). Fixes gt-0y8qa Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
921 lines
23 KiB
Go
921 lines
23 KiB
Go
package mail
|
|
|
|
import (
|
|
"os"
|
|
"os/exec"
|
|
"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", "hq-mayor"},
|
|
{"mayor/", "hq-mayor"},
|
|
{"deacon", "hq-deacon"},
|
|
{"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 filepath.ToSlash(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 filepath.ToSlash(got2) != want2 {
|
|
t.Errorf("resolveBeadsDir without townRoot = %q, want %q", got2, want2)
|
|
}
|
|
}
|
|
|
|
func TestNewRouterWithTownRoot(t *testing.T) {
|
|
r := NewRouterWithTownRoot("/work/rig", "/home/gt")
|
|
if filepath.ToSlash(r.workDir) != "/work/rig" {
|
|
t.Errorf("workDir = %q, want '/work/rig'", r.workDir)
|
|
}
|
|
if filepath.ToSlash(r.townRoot) != "/home/gt" {
|
|
t.Errorf("townRoot = %q, want '/home/gt'", r.townRoot)
|
|
}
|
|
}
|
|
|
|
// ============ Mailing List Tests ============
|
|
|
|
func TestIsListAddress(t *testing.T) {
|
|
tests := []struct {
|
|
address string
|
|
want bool
|
|
}{
|
|
{"list:oncall", true},
|
|
{"list:cleanup/gastown", true},
|
|
{"list:", true}, // Edge case: empty list name (will fail on expand)
|
|
{"mayor/", false},
|
|
{"gastown/witness", false},
|
|
{"listoncall", false}, // Missing colon
|
|
{"", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.address, func(t *testing.T) {
|
|
got := isListAddress(tt.address)
|
|
if got != tt.want {
|
|
t.Errorf("isListAddress(%q) = %v, want %v", tt.address, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseListName(t *testing.T) {
|
|
tests := []struct {
|
|
address string
|
|
want string
|
|
}{
|
|
{"list:oncall", "oncall"},
|
|
{"list:cleanup/gastown", "cleanup/gastown"},
|
|
{"list:", ""},
|
|
{"list:alerts", "alerts"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.address, func(t *testing.T) {
|
|
got := parseListName(tt.address)
|
|
if got != tt.want {
|
|
t.Errorf("parseListName(%q) = %q, want %q", tt.address, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsQueueAddress(t *testing.T) {
|
|
tests := []struct {
|
|
address string
|
|
want bool
|
|
}{
|
|
{"queue:work", true},
|
|
{"queue:gastown/polecats", true},
|
|
{"queue:", true}, // Edge case: empty queue name (will fail on expand)
|
|
{"mayor/", false},
|
|
{"gastown/witness", false},
|
|
{"queuework", false}, // Missing colon
|
|
{"list:oncall", false},
|
|
{"", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.address, func(t *testing.T) {
|
|
got := isQueueAddress(tt.address)
|
|
if got != tt.want {
|
|
t.Errorf("isQueueAddress(%q) = %v, want %v", tt.address, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseQueueName(t *testing.T) {
|
|
tests := []struct {
|
|
address string
|
|
want string
|
|
}{
|
|
{"queue:work", "work"},
|
|
{"queue:gastown/polecats", "gastown/polecats"},
|
|
{"queue:", ""},
|
|
{"queue:priority-high", "priority-high"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.address, func(t *testing.T) {
|
|
got := parseQueueName(tt.address)
|
|
if got != tt.want {
|
|
t.Errorf("parseQueueName(%q) = %q, want %q", tt.address, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExpandList(t *testing.T) {
|
|
// Create temp directory with messaging config
|
|
tmpDir := t.TempDir()
|
|
configDir := filepath.Join(tmpDir, "config")
|
|
if err := os.MkdirAll(configDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Write messaging.json with test lists
|
|
configContent := `{
|
|
"type": "messaging",
|
|
"version": 1,
|
|
"lists": {
|
|
"oncall": ["mayor/", "gastown/witness"],
|
|
"cleanup/gastown": ["gastown/witness", "deacon/"]
|
|
}
|
|
}`
|
|
if err := os.WriteFile(filepath.Join(configDir, "messaging.json"), []byte(configContent), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
r := NewRouterWithTownRoot(tmpDir, tmpDir)
|
|
|
|
tests := []struct {
|
|
name string
|
|
listName string
|
|
want []string
|
|
wantErr bool
|
|
errString string
|
|
}{
|
|
{
|
|
name: "oncall list",
|
|
listName: "oncall",
|
|
want: []string{"mayor/", "gastown/witness"},
|
|
},
|
|
{
|
|
name: "cleanup/gastown list",
|
|
listName: "cleanup/gastown",
|
|
want: []string{"gastown/witness", "deacon/"},
|
|
},
|
|
{
|
|
name: "unknown list",
|
|
listName: "nonexistent",
|
|
wantErr: true,
|
|
errString: "unknown mailing list",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := r.expandList(tt.listName)
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Errorf("expandList(%q) expected error, got nil", tt.listName)
|
|
} else if tt.errString != "" && !contains(err.Error(), tt.errString) {
|
|
t.Errorf("expandList(%q) error = %v, want containing %q", tt.listName, err, tt.errString)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Errorf("expandList(%q) unexpected error: %v", tt.listName, err)
|
|
return
|
|
}
|
|
if len(got) != len(tt.want) {
|
|
t.Errorf("expandList(%q) = %v, want %v", tt.listName, got, tt.want)
|
|
return
|
|
}
|
|
for i, addr := range got {
|
|
if addr != tt.want[i] {
|
|
t.Errorf("expandList(%q)[%d] = %q, want %q", tt.listName, i, addr, tt.want[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExpandListNoTownRoot(t *testing.T) {
|
|
r := &Router{workDir: "/tmp", townRoot: ""}
|
|
_, err := r.expandList("oncall")
|
|
if err == nil {
|
|
t.Error("expandList with no townRoot should error")
|
|
}
|
|
if !contains(err.Error(), "no town root") {
|
|
t.Errorf("expandList error = %v, want containing 'no town root'", err)
|
|
}
|
|
}
|
|
|
|
func TestExpandQueue(t *testing.T) {
|
|
// Create temp directory with messaging config
|
|
tmpDir := t.TempDir()
|
|
configDir := filepath.Join(tmpDir, "config")
|
|
if err := os.MkdirAll(configDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Write messaging.json with test queues
|
|
configContent := `{
|
|
"type": "messaging",
|
|
"version": 1,
|
|
"queues": {
|
|
"work/gastown": {"workers": ["gastown/polecats/*"], "max_claims": 3},
|
|
"priority-high": {"workers": ["mayor/", "gastown/witness"]}
|
|
}
|
|
}`
|
|
if err := os.WriteFile(filepath.Join(configDir, "messaging.json"), []byte(configContent), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
r := NewRouterWithTownRoot(tmpDir, tmpDir)
|
|
|
|
tests := []struct {
|
|
name string
|
|
queueName string
|
|
wantWorkers []string
|
|
wantMax int
|
|
wantErr bool
|
|
errString string
|
|
}{
|
|
{
|
|
name: "work/gastown queue",
|
|
queueName: "work/gastown",
|
|
wantWorkers: []string{"gastown/polecats/*"},
|
|
wantMax: 3,
|
|
},
|
|
{
|
|
name: "priority-high queue",
|
|
queueName: "priority-high",
|
|
wantWorkers: []string{"mayor/", "gastown/witness"},
|
|
wantMax: 0, // Not specified, defaults to 0
|
|
},
|
|
{
|
|
name: "unknown queue",
|
|
queueName: "nonexistent",
|
|
wantErr: true,
|
|
errString: "unknown queue",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := r.expandQueue(tt.queueName)
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Errorf("expandQueue(%q) expected error, got nil", tt.queueName)
|
|
} else if tt.errString != "" && !contains(err.Error(), tt.errString) {
|
|
t.Errorf("expandQueue(%q) error = %v, want containing %q", tt.queueName, err, tt.errString)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Errorf("expandQueue(%q) unexpected error: %v", tt.queueName, err)
|
|
return
|
|
}
|
|
if len(got.Workers) != len(tt.wantWorkers) {
|
|
t.Errorf("expandQueue(%q).Workers = %v, want %v", tt.queueName, got.Workers, tt.wantWorkers)
|
|
return
|
|
}
|
|
for i, worker := range got.Workers {
|
|
if worker != tt.wantWorkers[i] {
|
|
t.Errorf("expandQueue(%q).Workers[%d] = %q, want %q", tt.queueName, i, worker, tt.wantWorkers[i])
|
|
}
|
|
}
|
|
if got.MaxClaims != tt.wantMax {
|
|
t.Errorf("expandQueue(%q).MaxClaims = %d, want %d", tt.queueName, got.MaxClaims, tt.wantMax)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExpandQueueNoTownRoot(t *testing.T) {
|
|
r := &Router{workDir: "/tmp", townRoot: ""}
|
|
_, err := r.expandQueue("work")
|
|
if err == nil {
|
|
t.Error("expandQueue with no townRoot should error")
|
|
}
|
|
if !contains(err.Error(), "no town root") {
|
|
t.Errorf("expandQueue error = %v, want containing 'no town root'", err)
|
|
}
|
|
}
|
|
|
|
// ============ Announce Address Tests ============
|
|
|
|
func TestIsAnnounceAddress(t *testing.T) {
|
|
tests := []struct {
|
|
address string
|
|
want bool
|
|
}{
|
|
{"announce:bulletin", true},
|
|
{"announce:gastown/updates", true},
|
|
{"announce:", true}, // Edge case: empty announce name (will fail on expand)
|
|
{"mayor/", false},
|
|
{"gastown/witness", false},
|
|
{"announcebulletin", false}, // Missing colon
|
|
{"list:oncall", false},
|
|
{"queue:work", false},
|
|
{"", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.address, func(t *testing.T) {
|
|
got := isAnnounceAddress(tt.address)
|
|
if got != tt.want {
|
|
t.Errorf("isAnnounceAddress(%q) = %v, want %v", tt.address, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseAnnounceName(t *testing.T) {
|
|
tests := []struct {
|
|
address string
|
|
want string
|
|
}{
|
|
{"announce:bulletin", "bulletin"},
|
|
{"announce:gastown/updates", "gastown/updates"},
|
|
{"announce:", ""},
|
|
{"announce:priority-alerts", "priority-alerts"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.address, func(t *testing.T) {
|
|
got := parseAnnounceName(tt.address)
|
|
if got != tt.want {
|
|
t.Errorf("parseAnnounceName(%q) = %q, want %q", tt.address, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// contains checks if s contains substr (helper for error checking)
|
|
func contains(s, substr string) bool {
|
|
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr))
|
|
}
|
|
|
|
func containsHelper(s, substr string) bool {
|
|
for i := 0; i <= len(s)-len(substr); i++ {
|
|
if s[i:i+len(substr)] == substr {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ============ @group Address Tests ============
|
|
|
|
func TestIsGroupAddress(t *testing.T) {
|
|
tests := []struct {
|
|
address string
|
|
want bool
|
|
}{
|
|
{"@rig/gastown", true},
|
|
{"@town", true},
|
|
{"@witnesses", true},
|
|
{"@crew/gastown", true},
|
|
{"@dogs", true},
|
|
{"@overseer", true},
|
|
{"@polecats/gastown", true},
|
|
{"mayor/", false},
|
|
{"gastown/Toast", false},
|
|
{"", false},
|
|
{"rig/gastown", false}, // Missing @
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.address, func(t *testing.T) {
|
|
got := isGroupAddress(tt.address)
|
|
if got != tt.want {
|
|
t.Errorf("isGroupAddress(%q) = %v, want %v", tt.address, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseGroupAddress(t *testing.T) {
|
|
tests := []struct {
|
|
address string
|
|
wantType GroupType
|
|
wantRoleType string
|
|
wantRig string
|
|
wantNil bool
|
|
}{
|
|
// Special patterns
|
|
{"@overseer", GroupTypeOverseer, "", "", false},
|
|
{"@town", GroupTypeTown, "", "", false},
|
|
|
|
// Role-based patterns (all agents of a role type)
|
|
{"@witnesses", GroupTypeRole, "witness", "", false},
|
|
{"@dogs", GroupTypeRole, "dog", "", false},
|
|
{"@refineries", GroupTypeRole, "refinery", "", false},
|
|
{"@deacons", GroupTypeRole, "deacon", "", false},
|
|
|
|
// Rig pattern (all agents in a rig)
|
|
{"@rig/gastown", GroupTypeRig, "", "gastown", false},
|
|
{"@rig/beads", GroupTypeRig, "", "beads", false},
|
|
|
|
// Rig+role patterns
|
|
{"@crew/gastown", GroupTypeRigRole, "crew", "gastown", false},
|
|
{"@polecats/gastown", GroupTypeRigRole, "polecat", "gastown", false},
|
|
|
|
// Invalid patterns
|
|
{"mayor/", "", "", "", true},
|
|
{"@invalid", "", "", "", true},
|
|
{"@crew/", "", "", "", true}, // Empty rig
|
|
{"@rig", "", "", "", true}, // Missing rig name
|
|
{"", "", "", "", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.address, func(t *testing.T) {
|
|
got := parseGroupAddress(tt.address)
|
|
|
|
if tt.wantNil {
|
|
if got != nil {
|
|
t.Errorf("parseGroupAddress(%q) = %+v, want nil", tt.address, got)
|
|
}
|
|
return
|
|
}
|
|
|
|
if got == nil {
|
|
t.Errorf("parseGroupAddress(%q) = nil, want non-nil", tt.address)
|
|
return
|
|
}
|
|
|
|
if got.Type != tt.wantType {
|
|
t.Errorf("parseGroupAddress(%q).Type = %q, want %q", tt.address, got.Type, tt.wantType)
|
|
}
|
|
if got.RoleType != tt.wantRoleType {
|
|
t.Errorf("parseGroupAddress(%q).RoleType = %q, want %q", tt.address, got.RoleType, tt.wantRoleType)
|
|
}
|
|
if got.Rig != tt.wantRig {
|
|
t.Errorf("parseGroupAddress(%q).Rig = %q, want %q", tt.address, got.Rig, tt.wantRig)
|
|
}
|
|
if got.Original != tt.address {
|
|
t.Errorf("parseGroupAddress(%q).Original = %q, want %q", tt.address, got.Original, tt.address)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAgentBeadToAddress(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
bead *agentBead
|
|
want string
|
|
}{
|
|
{
|
|
name: "nil bead",
|
|
bead: nil,
|
|
want: "",
|
|
},
|
|
{
|
|
name: "town-level mayor",
|
|
bead: &agentBead{ID: "gt-mayor"},
|
|
want: "mayor/",
|
|
},
|
|
{
|
|
name: "town-level deacon",
|
|
bead: &agentBead{ID: "gt-deacon"},
|
|
want: "deacon/",
|
|
},
|
|
{
|
|
name: "rig singleton witness",
|
|
bead: &agentBead{ID: "gt-gastown-witness"},
|
|
want: "gastown/witness",
|
|
},
|
|
{
|
|
name: "rig singleton refinery",
|
|
bead: &agentBead{ID: "gt-gastown-refinery"},
|
|
want: "gastown/refinery",
|
|
},
|
|
{
|
|
name: "rig crew worker",
|
|
bead: &agentBead{ID: "gt-gastown-crew-max"},
|
|
want: "gastown/max",
|
|
},
|
|
{
|
|
name: "rig polecat worker",
|
|
bead: &agentBead{ID: "gt-gastown-polecat-Toast"},
|
|
want: "gastown/Toast",
|
|
},
|
|
{
|
|
name: "rig polecat with hyphenated name",
|
|
bead: &agentBead{ID: "gt-gastown-polecat-my-agent"},
|
|
want: "gastown/my-agent",
|
|
},
|
|
{
|
|
name: "non-gt prefix (invalid)",
|
|
bead: &agentBead{ID: "bd-gastown-witness"},
|
|
want: "",
|
|
},
|
|
{
|
|
name: "empty ID",
|
|
bead: &agentBead{ID: ""},
|
|
want: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := agentBeadToAddress(tt.bead)
|
|
if got != tt.want {
|
|
t.Errorf("agentBeadToAddress(%+v) = %q, want %q", tt.bead, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExpandAnnounce(t *testing.T) {
|
|
// Create temp directory with messaging config
|
|
tmpDir := t.TempDir()
|
|
configDir := filepath.Join(tmpDir, "config")
|
|
if err := os.MkdirAll(configDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Write messaging.json with test announces
|
|
configContent := `{
|
|
"type": "messaging",
|
|
"version": 1,
|
|
"announces": {
|
|
"alerts": {"readers": ["@town"], "retain_count": 10},
|
|
"status/gastown": {"readers": ["gastown/witness", "mayor/"], "retain_count": 5}
|
|
}
|
|
}`
|
|
if err := os.WriteFile(filepath.Join(configDir, "messaging.json"), []byte(configContent), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
r := NewRouterWithTownRoot(tmpDir, tmpDir)
|
|
|
|
tests := []struct {
|
|
name string
|
|
announceName string
|
|
wantReaders []string
|
|
wantRetain int
|
|
wantErr bool
|
|
errString string
|
|
}{
|
|
{
|
|
name: "alerts announce",
|
|
announceName: "alerts",
|
|
wantReaders: []string{"@town"},
|
|
wantRetain: 10,
|
|
},
|
|
{
|
|
name: "status/gastown announce",
|
|
announceName: "status/gastown",
|
|
wantReaders: []string{"gastown/witness", "mayor/"},
|
|
wantRetain: 5,
|
|
},
|
|
{
|
|
name: "unknown announce",
|
|
announceName: "nonexistent",
|
|
wantErr: true,
|
|
errString: "unknown announce channel",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := r.expandAnnounce(tt.announceName)
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Errorf("expandAnnounce(%q) expected error, got nil", tt.announceName)
|
|
} else if tt.errString != "" && !contains(err.Error(), tt.errString) {
|
|
t.Errorf("expandAnnounce(%q) error = %v, want containing %q", tt.announceName, err, tt.errString)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Errorf("expandAnnounce(%q) unexpected error: %v", tt.announceName, err)
|
|
return
|
|
}
|
|
if len(got.Readers) != len(tt.wantReaders) {
|
|
t.Errorf("expandAnnounce(%q).Readers = %v, want %v", tt.announceName, got.Readers, tt.wantReaders)
|
|
return
|
|
}
|
|
for i, reader := range got.Readers {
|
|
if reader != tt.wantReaders[i] {
|
|
t.Errorf("expandAnnounce(%q).Readers[%d] = %q, want %q", tt.announceName, i, reader, tt.wantReaders[i])
|
|
}
|
|
}
|
|
if got.RetainCount != tt.wantRetain {
|
|
t.Errorf("expandAnnounce(%q).RetainCount = %d, want %d", tt.announceName, got.RetainCount, tt.wantRetain)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExpandAnnounceNoTownRoot(t *testing.T) {
|
|
r := &Router{workDir: "/tmp", townRoot: ""}
|
|
_, err := r.expandAnnounce("alerts")
|
|
if err == nil {
|
|
t.Error("expandAnnounce with no townRoot should error")
|
|
}
|
|
if !contains(err.Error(), "no town root") {
|
|
t.Errorf("expandAnnounce error = %v, want containing 'no town root'", err)
|
|
}
|
|
}
|
|
|
|
// ============ Recipient Validation Tests ============
|
|
|
|
func TestValidateRecipient(t *testing.T) {
|
|
// Skip if bd CLI is not available (e.g., in CI environment)
|
|
if _, err := exec.LookPath("bd"); err != nil {
|
|
t.Skip("bd CLI not available, skipping test")
|
|
}
|
|
|
|
// Create isolated beads environment for testing
|
|
tmpDir := t.TempDir()
|
|
townRoot := tmpDir
|
|
|
|
// Create .beads directory and initialize
|
|
beadsDir := filepath.Join(townRoot, ".beads")
|
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
|
t.Fatalf("creating beads dir: %v", err)
|
|
}
|
|
|
|
// Initialize beads database with "gt" prefix (matches agent bead IDs)
|
|
cmd := exec.Command("bd", "init", "gt")
|
|
cmd.Dir = townRoot
|
|
cmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("bd init failed: %v\n%s", err, out)
|
|
}
|
|
|
|
// Set issue prefix to "gt" (matches agent bead ID pattern)
|
|
cmd = exec.Command("bd", "config", "set", "issue_prefix", "gt")
|
|
cmd.Dir = townRoot
|
|
cmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("bd config set issue_prefix failed: %v\n%s", err, out)
|
|
}
|
|
|
|
// Register custom types (agent, message, etc.) - required before creating agents
|
|
cmd = exec.Command("bd", "config", "set", "types.custom", "agent,role,rig,convoy,slot,queue,event,message,molecule,gate,merge-request")
|
|
cmd.Dir = townRoot
|
|
cmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("bd config set types.custom failed: %v\n%s", err, out)
|
|
}
|
|
|
|
// Create test agent beads
|
|
createAgent := func(id, title string) {
|
|
cmd := exec.Command("bd", "create", title, "--type=agent", "--id="+id, "--force")
|
|
cmd.Dir = townRoot
|
|
cmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("creating agent %s: %v\n%s", id, err, out)
|
|
}
|
|
}
|
|
|
|
// Create agents that match expected bead ID patterns
|
|
createAgent("gt-mayor", "Mayor agent")
|
|
createAgent("gt-deacon", "Deacon agent")
|
|
createAgent("gt-testrig-witness", "Test witness")
|
|
createAgent("gt-testrig-crew-alice", "Test crew alice")
|
|
createAgent("gt-testrig-polecat-bob", "Test polecat bob")
|
|
|
|
r := NewRouterWithTownRoot(townRoot, townRoot)
|
|
|
|
tests := []struct {
|
|
name string
|
|
identity string
|
|
wantErr bool
|
|
errMsg string
|
|
}{
|
|
// Overseer is always valid (human operator, no agent bead)
|
|
{"overseer", "overseer", false, ""},
|
|
|
|
// Town-level agents (validated against beads)
|
|
{"mayor", "mayor/", false, ""},
|
|
{"deacon", "deacon/", false, ""},
|
|
|
|
// Rig-level agents (validated against beads)
|
|
{"witness", "testrig/witness", false, ""},
|
|
{"crew member", "testrig/alice", false, ""},
|
|
{"polecat", "testrig/bob", false, ""},
|
|
|
|
// Invalid addresses - should fail
|
|
{"bare name", "ruby", true, "no agent found"},
|
|
{"nonexistent rig agent", "testrig/nonexistent", true, "no agent found"},
|
|
{"wrong rig", "wrongrig/alice", true, "no agent found"},
|
|
{"misrouted town agent", "testrig/mayor", true, "no agent found"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := r.validateRecipient(tt.identity)
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Errorf("validateRecipient(%q) expected error, got nil", tt.identity)
|
|
} else if tt.errMsg != "" && !contains(err.Error(), tt.errMsg) {
|
|
t.Errorf("validateRecipient(%q) error = %v, want containing %q", tt.identity, err, tt.errMsg)
|
|
}
|
|
} else {
|
|
if err != nil {
|
|
t.Errorf("validateRecipient(%q) unexpected error: %v", tt.identity, err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|