Files
gastown/internal/config/loader_test.go
gastown/polecats/toast d94fb4669b Add nudge_channels to MessagingConfig schema (gt-3shmx)
- Add NudgeChannels field to MessagingConfig struct in types.go
- Initialize NudgeChannels map in NewMessagingConfig()
- Add validation in validateMessagingConfig(): channel names must be
  non-empty and each channel must have at least one recipient
- Add tests for valid nudge channels and empty recipient validation

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 22:49:50 -08:00

805 lines
19 KiB
Go

package config
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestTownConfigRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "mayor", "town.json")
original := &TownConfig{
Type: "town",
Version: 1,
Name: "test-town",
CreatedAt: time.Now().Truncate(time.Second),
}
if err := SaveTownConfig(path, original); err != nil {
t.Fatalf("SaveTownConfig: %v", err)
}
loaded, err := LoadTownConfig(path)
if err != nil {
t.Fatalf("LoadTownConfig: %v", err)
}
if loaded.Name != original.Name {
t.Errorf("Name = %q, want %q", loaded.Name, original.Name)
}
if loaded.Type != original.Type {
t.Errorf("Type = %q, want %q", loaded.Type, original.Type)
}
}
func TestRigsConfigRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "mayor", "rigs.json")
original := &RigsConfig{
Version: 1,
Rigs: map[string]RigEntry{
"gastown": {
GitURL: "git@github.com:steveyegge/gastown.git",
AddedAt: time.Now().Truncate(time.Second),
BeadsConfig: &BeadsConfig{
Repo: "local",
Prefix: "gt-",
},
},
},
}
if err := SaveRigsConfig(path, original); err != nil {
t.Fatalf("SaveRigsConfig: %v", err)
}
loaded, err := LoadRigsConfig(path)
if err != nil {
t.Fatalf("LoadRigsConfig: %v", err)
}
if len(loaded.Rigs) != 1 {
t.Errorf("Rigs count = %d, want 1", len(loaded.Rigs))
}
rig, ok := loaded.Rigs["gastown"]
if !ok {
t.Fatal("missing 'gastown' rig")
}
if rig.BeadsConfig == nil || rig.BeadsConfig.Prefix != "gt-" {
t.Errorf("BeadsConfig.Prefix = %v, want 'gt-'", rig.BeadsConfig)
}
}
func TestAgentStateRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "state.json")
original := &AgentState{
Role: "mayor",
LastActive: time.Now().Truncate(time.Second),
Session: "abc123",
Extra: map[string]any{
"custom": "value",
},
}
if err := SaveAgentState(path, original); err != nil {
t.Fatalf("SaveAgentState: %v", err)
}
loaded, err := LoadAgentState(path)
if err != nil {
t.Fatalf("LoadAgentState: %v", err)
}
if loaded.Role != original.Role {
t.Errorf("Role = %q, want %q", loaded.Role, original.Role)
}
if loaded.Session != original.Session {
t.Errorf("Session = %q, want %q", loaded.Session, original.Session)
}
}
func TestLoadTownConfigNotFound(t *testing.T) {
_, err := LoadTownConfig("/nonexistent/path.json")
if err == nil {
t.Fatal("expected error for nonexistent file")
}
}
func TestValidationErrors(t *testing.T) {
// Missing name
tc := &TownConfig{Type: "town", Version: 1}
if err := validateTownConfig(tc); err == nil {
t.Error("expected error for missing name")
}
// Wrong type
tc = &TownConfig{Type: "wrong", Version: 1, Name: "test"}
if err := validateTownConfig(tc); err == nil {
t.Error("expected error for wrong type")
}
// Missing role
as := &AgentState{}
if err := validateAgentState(as); err == nil {
t.Error("expected error for missing role")
}
}
func TestRigConfigRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
original := NewRigConfig("gastown", "git@github.com:test/gastown.git")
original.CreatedAt = time.Now().Truncate(time.Second)
original.Beads = &BeadsConfig{Prefix: "gt-"}
if err := SaveRigConfig(path, original); err != nil {
t.Fatalf("SaveRigConfig: %v", err)
}
loaded, err := LoadRigConfig(path)
if err != nil {
t.Fatalf("LoadRigConfig: %v", err)
}
if loaded.Type != "rig" {
t.Errorf("Type = %q, want 'rig'", loaded.Type)
}
if loaded.Version != CurrentRigConfigVersion {
t.Errorf("Version = %d, want %d", loaded.Version, CurrentRigConfigVersion)
}
if loaded.Name != "gastown" {
t.Errorf("Name = %q, want 'gastown'", loaded.Name)
}
if loaded.GitURL != "git@github.com:test/gastown.git" {
t.Errorf("GitURL = %q, want expected URL", loaded.GitURL)
}
if loaded.Beads == nil || loaded.Beads.Prefix != "gt-" {
t.Error("Beads.Prefix not preserved")
}
}
func TestRigSettingsRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "settings", "config.json")
original := NewRigSettings()
if err := SaveRigSettings(path, original); err != nil {
t.Fatalf("SaveRigSettings: %v", err)
}
loaded, err := LoadRigSettings(path)
if err != nil {
t.Fatalf("LoadRigSettings: %v", err)
}
if loaded.Type != "rig-settings" {
t.Errorf("Type = %q, want 'rig-settings'", loaded.Type)
}
if loaded.MergeQueue == nil {
t.Fatal("MergeQueue is nil")
}
if !loaded.MergeQueue.Enabled {
t.Error("MergeQueue.Enabled = false, want true")
}
if loaded.MergeQueue.TargetBranch != "main" {
t.Errorf("MergeQueue.TargetBranch = %q, want 'main'", loaded.MergeQueue.TargetBranch)
}
}
func TestRigSettingsWithCustomMergeQueue(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "settings.json")
original := &RigSettings{
Type: "rig-settings",
Version: 1,
MergeQueue: &MergeQueueConfig{
Enabled: true,
TargetBranch: "develop",
IntegrationBranches: false,
OnConflict: OnConflictAutoRebase,
RunTests: true,
TestCommand: "make test",
DeleteMergedBranches: false,
RetryFlakyTests: 3,
PollInterval: "1m",
MaxConcurrent: 2,
},
}
if err := SaveRigSettings(path, original); err != nil {
t.Fatalf("SaveRigSettings: %v", err)
}
loaded, err := LoadRigSettings(path)
if err != nil {
t.Fatalf("LoadRigSettings: %v", err)
}
mq := loaded.MergeQueue
if mq.TargetBranch != "develop" {
t.Errorf("TargetBranch = %q, want 'develop'", mq.TargetBranch)
}
if mq.OnConflict != OnConflictAutoRebase {
t.Errorf("OnConflict = %q, want %q", mq.OnConflict, OnConflictAutoRebase)
}
if mq.TestCommand != "make test" {
t.Errorf("TestCommand = %q, want 'make test'", mq.TestCommand)
}
if mq.RetryFlakyTests != 3 {
t.Errorf("RetryFlakyTests = %d, want 3", mq.RetryFlakyTests)
}
}
func TestRigConfigValidation(t *testing.T) {
tests := []struct {
name string
config *RigConfig
wantErr bool
}{
{
name: "valid config",
config: &RigConfig{
Type: "rig",
Version: 1,
Name: "test-rig",
},
wantErr: false,
},
{
name: "missing name",
config: &RigConfig{
Type: "rig",
Version: 1,
},
wantErr: true,
},
{
name: "wrong type",
config: &RigConfig{
Type: "wrong",
Version: 1,
Name: "test",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateRigConfig(tt.config)
if (err != nil) != tt.wantErr {
t.Errorf("validateRigConfig() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestRigSettingsValidation(t *testing.T) {
tests := []struct {
name string
settings *RigSettings
wantErr bool
}{
{
name: "valid settings",
settings: &RigSettings{
Type: "rig-settings",
Version: 1,
MergeQueue: DefaultMergeQueueConfig(),
},
wantErr: false,
},
{
name: "valid settings without merge queue",
settings: &RigSettings{
Type: "rig-settings",
Version: 1,
},
wantErr: false,
},
{
name: "wrong type",
settings: &RigSettings{
Type: "wrong",
Version: 1,
},
wantErr: true,
},
{
name: "invalid on_conflict",
settings: &RigSettings{
Type: "rig-settings",
Version: 1,
MergeQueue: &MergeQueueConfig{
OnConflict: "invalid",
},
},
wantErr: true,
},
{
name: "invalid poll_interval",
settings: &RigSettings{
Type: "rig-settings",
Version: 1,
MergeQueue: &MergeQueueConfig{
PollInterval: "not-a-duration",
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateRigSettings(tt.settings)
if (err != nil) != tt.wantErr {
t.Errorf("validateRigSettings() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestDefaultMergeQueueConfig(t *testing.T) {
cfg := DefaultMergeQueueConfig()
if !cfg.Enabled {
t.Error("Enabled should be true by default")
}
if cfg.TargetBranch != "main" {
t.Errorf("TargetBranch = %q, want 'main'", cfg.TargetBranch)
}
if !cfg.IntegrationBranches {
t.Error("IntegrationBranches should be true by default")
}
if cfg.OnConflict != OnConflictAssignBack {
t.Errorf("OnConflict = %q, want %q", cfg.OnConflict, OnConflictAssignBack)
}
if !cfg.RunTests {
t.Error("RunTests should be true by default")
}
if cfg.TestCommand != "go test ./..." {
t.Errorf("TestCommand = %q, want 'go test ./...'", cfg.TestCommand)
}
if !cfg.DeleteMergedBranches {
t.Error("DeleteMergedBranches should be true by default")
}
if cfg.RetryFlakyTests != 1 {
t.Errorf("RetryFlakyTests = %d, want 1", cfg.RetryFlakyTests)
}
if cfg.PollInterval != "30s" {
t.Errorf("PollInterval = %q, want '30s'", cfg.PollInterval)
}
if cfg.MaxConcurrent != 1 {
t.Errorf("MaxConcurrent = %d, want 1", cfg.MaxConcurrent)
}
}
func TestLoadRigConfigNotFound(t *testing.T) {
_, err := LoadRigConfig("/nonexistent/path.json")
if err == nil {
t.Fatal("expected error for nonexistent file")
}
}
func TestLoadRigSettingsNotFound(t *testing.T) {
_, err := LoadRigSettings("/nonexistent/path.json")
if err == nil {
t.Fatal("expected error for nonexistent file")
}
}
func TestMayorConfigRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "mayor", "config.json")
original := NewMayorConfig()
original.Theme = &TownThemeConfig{
RoleDefaults: map[string]string{
"witness": "rust",
},
}
if err := SaveMayorConfig(path, original); err != nil {
t.Fatalf("SaveMayorConfig: %v", err)
}
loaded, err := LoadMayorConfig(path)
if err != nil {
t.Fatalf("LoadMayorConfig: %v", err)
}
if loaded.Type != "mayor-config" {
t.Errorf("Type = %q, want 'mayor-config'", loaded.Type)
}
if loaded.Version != CurrentMayorConfigVersion {
t.Errorf("Version = %d, want %d", loaded.Version, CurrentMayorConfigVersion)
}
if loaded.Theme == nil || loaded.Theme.RoleDefaults["witness"] != "rust" {
t.Error("Theme.RoleDefaults not preserved")
}
}
func TestLoadMayorConfigNotFound(t *testing.T) {
_, err := LoadMayorConfig("/nonexistent/path.json")
if err == nil {
t.Fatal("expected error for nonexistent file")
}
}
func TestAccountsConfigRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "mayor", "accounts.json")
original := NewAccountsConfig()
original.Accounts["yegge"] = Account{
Email: "steve.yegge@gmail.com",
Description: "Personal account",
ConfigDir: "~/.claude-accounts/yegge",
}
original.Accounts["ghosttrack"] = Account{
Email: "steve@ghosttrack.com",
Description: "Business account",
ConfigDir: "~/.claude-accounts/ghosttrack",
}
original.Default = "ghosttrack"
if err := SaveAccountsConfig(path, original); err != nil {
t.Fatalf("SaveAccountsConfig: %v", err)
}
loaded, err := LoadAccountsConfig(path)
if err != nil {
t.Fatalf("LoadAccountsConfig: %v", err)
}
if loaded.Version != CurrentAccountsVersion {
t.Errorf("Version = %d, want %d", loaded.Version, CurrentAccountsVersion)
}
if len(loaded.Accounts) != 2 {
t.Errorf("Accounts count = %d, want 2", len(loaded.Accounts))
}
if loaded.Default != "ghosttrack" {
t.Errorf("Default = %q, want 'ghosttrack'", loaded.Default)
}
yegge := loaded.GetAccount("yegge")
if yegge == nil {
t.Fatal("GetAccount('yegge') returned nil")
}
if yegge.Email != "steve.yegge@gmail.com" {
t.Errorf("yegge.Email = %q, want 'steve.yegge@gmail.com'", yegge.Email)
}
defAcct := loaded.GetDefaultAccount()
if defAcct == nil {
t.Fatal("GetDefaultAccount() returned nil")
}
if defAcct.Email != "steve@ghosttrack.com" {
t.Errorf("default.Email = %q, want 'steve@ghosttrack.com'", defAcct.Email)
}
}
func TestAccountsConfigValidation(t *testing.T) {
tests := []struct {
name string
config *AccountsConfig
wantErr bool
}{
{
name: "valid empty config",
config: NewAccountsConfig(),
wantErr: false,
},
{
name: "valid config with accounts",
config: &AccountsConfig{
Version: 1,
Accounts: map[string]Account{
"test": {Email: "test@example.com", ConfigDir: "~/.claude-accounts/test"},
},
Default: "test",
},
wantErr: false,
},
{
name: "default refers to nonexistent account",
config: &AccountsConfig{
Version: 1,
Accounts: map[string]Account{
"test": {Email: "test@example.com", ConfigDir: "~/.claude-accounts/test"},
},
Default: "nonexistent",
},
wantErr: true,
},
{
name: "account missing config_dir",
config: &AccountsConfig{
Version: 1,
Accounts: map[string]Account{
"test": {Email: "test@example.com"},
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateAccountsConfig(tt.config)
if (err != nil) != tt.wantErr {
t.Errorf("validateAccountsConfig() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestLoadAccountsConfigNotFound(t *testing.T) {
_, err := LoadAccountsConfig("/nonexistent/path.json")
if err == nil {
t.Fatal("expected error for nonexistent file")
}
}
func TestMessagingConfigRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config", "messaging.json")
original := NewMessagingConfig()
original.Lists["oncall"] = []string{"mayor/", "gastown/witness"}
original.Lists["cleanup"] = []string{"gastown/witness", "deacon/"}
original.Queues["work/gastown"] = QueueConfig{
Workers: []string{"gastown/polecats/*"},
MaxClaims: 5,
}
original.Announces["alerts"] = AnnounceConfig{
Readers: []string{"@town"},
RetainCount: 100,
}
original.NudgeChannels["workers"] = []string{"gastown/polecats/*", "gastown/crew/*"}
original.NudgeChannels["witnesses"] = []string{"*/witness"}
if err := SaveMessagingConfig(path, original); err != nil {
t.Fatalf("SaveMessagingConfig: %v", err)
}
loaded, err := LoadMessagingConfig(path)
if err != nil {
t.Fatalf("LoadMessagingConfig: %v", err)
}
if loaded.Type != "messaging" {
t.Errorf("Type = %q, want 'messaging'", loaded.Type)
}
if loaded.Version != CurrentMessagingVersion {
t.Errorf("Version = %d, want %d", loaded.Version, CurrentMessagingVersion)
}
// Check lists
if len(loaded.Lists) != 2 {
t.Errorf("Lists count = %d, want 2", len(loaded.Lists))
}
if oncall, ok := loaded.Lists["oncall"]; !ok || len(oncall) != 2 {
t.Error("oncall list not preserved")
}
// Check queues
if len(loaded.Queues) != 1 {
t.Errorf("Queues count = %d, want 1", len(loaded.Queues))
}
if q, ok := loaded.Queues["work/gastown"]; !ok || q.MaxClaims != 5 {
t.Error("queue not preserved")
}
// Check announces
if len(loaded.Announces) != 1 {
t.Errorf("Announces count = %d, want 1", len(loaded.Announces))
}
if a, ok := loaded.Announces["alerts"]; !ok || a.RetainCount != 100 {
t.Error("announce not preserved")
}
// Check nudge channels
if len(loaded.NudgeChannels) != 2 {
t.Errorf("NudgeChannels count = %d, want 2", len(loaded.NudgeChannels))
}
if workers, ok := loaded.NudgeChannels["workers"]; !ok || len(workers) != 2 {
t.Error("workers nudge channel not preserved")
}
if witnesses, ok := loaded.NudgeChannels["witnesses"]; !ok || len(witnesses) != 1 {
t.Error("witnesses nudge channel not preserved")
}
}
func TestMessagingConfigValidation(t *testing.T) {
tests := []struct {
name string
config *MessagingConfig
wantErr bool
}{
{
name: "valid empty config",
config: NewMessagingConfig(),
wantErr: false,
},
{
name: "valid config with lists",
config: &MessagingConfig{
Type: "messaging",
Version: 1,
Lists: map[string][]string{
"oncall": {"mayor/", "gastown/witness"},
},
},
wantErr: false,
},
{
name: "wrong type",
config: &MessagingConfig{
Type: "wrong",
Version: 1,
},
wantErr: true,
},
{
name: "future version rejected",
config: &MessagingConfig{
Type: "messaging",
Version: 999,
},
wantErr: true,
},
{
name: "list with no recipients",
config: &MessagingConfig{
Version: 1,
Lists: map[string][]string{
"empty": {},
},
},
wantErr: true,
},
{
name: "queue with no workers",
config: &MessagingConfig{
Version: 1,
Queues: map[string]QueueConfig{
"work": {Workers: []string{}},
},
},
wantErr: true,
},
{
name: "queue with negative max_claims",
config: &MessagingConfig{
Version: 1,
Queues: map[string]QueueConfig{
"work": {Workers: []string{"worker/"}, MaxClaims: -1},
},
},
wantErr: true,
},
{
name: "announce with no readers",
config: &MessagingConfig{
Version: 1,
Announces: map[string]AnnounceConfig{
"alerts": {Readers: []string{}},
},
},
wantErr: true,
},
{
name: "announce with negative retain_count",
config: &MessagingConfig{
Version: 1,
Announces: map[string]AnnounceConfig{
"alerts": {Readers: []string{"@town"}, RetainCount: -1},
},
},
wantErr: true,
},
{
name: "valid config with nudge channels",
config: &MessagingConfig{
Type: "messaging",
Version: 1,
NudgeChannels: map[string][]string{
"workers": {"gastown/polecats/*", "gastown/crew/*"},
},
},
wantErr: false,
},
{
name: "nudge channel with no recipients",
config: &MessagingConfig{
Version: 1,
NudgeChannels: map[string][]string{
"empty": {},
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateMessagingConfig(tt.config)
if (err != nil) != tt.wantErr {
t.Errorf("validateMessagingConfig() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestLoadMessagingConfigNotFound(t *testing.T) {
_, err := LoadMessagingConfig("/nonexistent/path.json")
if err == nil {
t.Fatal("expected error for nonexistent file")
}
}
func TestLoadMessagingConfigMalformedJSON(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "messaging.json")
// Write malformed JSON
if err := os.WriteFile(path, []byte("{not valid json"), 0644); err != nil {
t.Fatalf("writing test file: %v", err)
}
_, err := LoadMessagingConfig(path)
if err == nil {
t.Fatal("expected error for malformed JSON")
}
}
func TestLoadOrCreateMessagingConfig(t *testing.T) {
// Test creating default when not found
config, err := LoadOrCreateMessagingConfig("/nonexistent/path.json")
if err != nil {
t.Fatalf("LoadOrCreateMessagingConfig: %v", err)
}
if config == nil {
t.Fatal("expected non-nil config")
}
if config.Version != CurrentMessagingVersion {
t.Errorf("Version = %d, want %d", config.Version, CurrentMessagingVersion)
}
// Test loading existing
dir := t.TempDir()
path := filepath.Join(dir, "messaging.json")
original := NewMessagingConfig()
original.Lists["test"] = []string{"mayor/"}
if err := SaveMessagingConfig(path, original); err != nil {
t.Fatalf("SaveMessagingConfig: %v", err)
}
loaded, err := LoadOrCreateMessagingConfig(path)
if err != nil {
t.Fatalf("LoadOrCreateMessagingConfig: %v", err)
}
if _, ok := loaded.Lists["test"]; !ok {
t.Error("existing config not loaded")
}
}
func TestMessagingConfigPath(t *testing.T) {
path := MessagingConfigPath("/home/user/gt")
expected := "/home/user/gt/config/messaging.json"
if path != expected {
t.Errorf("MessagingConfigPath = %q, want %q", path, expected)
}
}