// contributor_routing_e2e_test.go - E2E tests for contributor routing // // These tests verify that issues are correctly routed to the planning repo // when the user is detected as a contributor with auto-routing enabled. //go:build integration // +build integration package main import ( "context" "os" "path/filepath" "testing" "time" "github.com/steveyegge/beads/internal/routing" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" ) // TestContributorRoutingTracer is the Phase 1 tracer bullet test. // It proves that: // 1. ExpandPath correctly expands ~ and relative paths // 2. Routing config is correctly read (including backward compat) // 3. DetermineTargetRepo returns the correct repo for contributors // // Full store switching is deferred to Phase 2. func TestContributorRoutingTracer(t *testing.T) { t.Run("ExpandPath_tilde_expansion", func(t *testing.T) { home, err := os.UserHomeDir() if err != nil { t.Skipf("cannot get home dir: %v", err) } tests := []struct { input string want string }{ {"~/foo", filepath.Join(home, "foo")}, {"~/bar/baz", filepath.Join(home, "bar", "baz")}, {".", "."}, {"", ""}, } for _, tt := range tests { got := routing.ExpandPath(tt.input) if got != tt.want { t.Errorf("ExpandPath(%q) = %q, want %q", tt.input, got, tt.want) } } }) t.Run("DetermineTargetRepo_contributor_routes_to_planning", func(t *testing.T) { config := &routing.RoutingConfig{ Mode: "auto", ContributorRepo: "~/.beads-planning", } got := routing.DetermineTargetRepo(config, routing.Contributor, ".") if got != "~/.beads-planning" { t.Errorf("DetermineTargetRepo() = %q, want %q", got, "~/.beads-planning") } }) t.Run("DetermineTargetRepo_maintainer_stays_local", func(t *testing.T) { config := &routing.RoutingConfig{ Mode: "auto", MaintainerRepo: ".", ContributorRepo: "~/.beads-planning", } got := routing.DetermineTargetRepo(config, routing.Maintainer, ".") if got != "." { t.Errorf("DetermineTargetRepo() = %q, want %q", got, ".") } }) t.Run("E2E_routing_decision_with_store", func(t *testing.T) { // Set up temporary directory structure tmpDir := t.TempDir() projectDir := filepath.Join(tmpDir, "project") planningDir := filepath.Join(tmpDir, "planning") // Create project .beads directory projectBeadsDir := filepath.Join(projectDir, ".beads") if err := os.MkdirAll(projectBeadsDir, 0755); err != nil { t.Fatalf("failed to create project .beads dir: %v", err) } // Create planning .beads directory planningBeadsDir := filepath.Join(planningDir, ".beads") if err := os.MkdirAll(planningBeadsDir, 0755); err != nil { t.Fatalf("failed to create planning .beads dir: %v", err) } // Initialize project database projectDBPath := filepath.Join(projectBeadsDir, "beads.db") ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() projectStore, err := sqlite.New(ctx, projectDBPath) if err != nil { t.Fatalf("failed to create project store: %v", err) } defer projectStore.Close() // Set routing config in project store (canonical keys) if err := projectStore.SetConfig(ctx, "routing.mode", "auto"); err != nil { t.Fatalf("failed to set routing.mode: %v", err) } if err := projectStore.SetConfig(ctx, "routing.contributor", planningDir); err != nil { t.Fatalf("failed to set routing.contributor: %v", err) } // Verify config was stored correctly mode, err := projectStore.GetConfig(ctx, "routing.mode") if err != nil { t.Fatalf("failed to get routing.mode: %v", err) } if mode != "auto" { t.Errorf("routing.mode = %q, want %q", mode, "auto") } contributorPath, err := projectStore.GetConfig(ctx, "routing.contributor") if err != nil { t.Fatalf("failed to get routing.contributor: %v", err) } if contributorPath != planningDir { t.Errorf("routing.contributor = %q, want %q", contributorPath, planningDir) } // Build routing config from stored values routingConfig := &routing.RoutingConfig{ Mode: mode, ContributorRepo: contributorPath, } // Verify routing decision for contributor targetRepo := routing.DetermineTargetRepo(routingConfig, routing.Contributor, projectDir) if targetRepo != planningDir { t.Errorf("DetermineTargetRepo() = %q, want %q", targetRepo, planningDir) } // Verify routing decision for maintainer stays local targetRepo = routing.DetermineTargetRepo(routingConfig, routing.Maintainer, projectDir) if targetRepo != "." { t.Errorf("DetermineTargetRepo() for maintainer = %q, want %q", targetRepo, ".") } // Initialize planning database and verify we can create issues there planningDBPath := filepath.Join(planningBeadsDir, "beads.db") planningStore, err := sqlite.New(ctx, planningDBPath) if err != nil { t.Fatalf("failed to create planning store: %v", err) } defer planningStore.Close() // Initialize planning store with required config if err := planningStore.SetConfig(ctx, "issue_prefix", "plan-"); err != nil { t.Fatalf("failed to set issue_prefix in planning store: %v", err) } // Create a test issue in planning store (simulating what Phase 2 will do) issue := &types.Issue{ Title: "Test contributor issue", IssueType: types.TypeTask, Status: types.StatusOpen, Priority: 2, } if err := planningStore.CreateIssue(ctx, issue, "test"); err != nil { t.Fatalf("failed to create issue in planning store: %v", err) } // Verify issue exists in planning store retrieved, err := planningStore.GetIssue(ctx, issue.ID) if err != nil { t.Fatalf("failed to get issue from planning store: %v", err) } if retrieved.Title != "Test contributor issue" { t.Errorf("issue title = %q, want %q", retrieved.Title, "Test contributor issue") } // Verify issue does NOT exist in project store (isolation check) projectIssue, _ := projectStore.GetIssue(ctx, issue.ID) if projectIssue != nil { t.Error("issue should NOT exist in project store (isolation failure)") } }) } // TestBackwardCompatContributorConfig verifies legacy contributor.* keys still work func TestBackwardCompatContributorConfig(t *testing.T) { // Set up temporary directory tmpDir := t.TempDir() beadsDir := filepath.Join(tmpDir, ".beads") if err := os.MkdirAll(beadsDir, 0755); err != nil { t.Fatalf("failed to create .beads dir: %v", err) } // Initialize database dbPath := filepath.Join(beadsDir, "beads.db") ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() store, err := sqlite.New(ctx, dbPath) if err != nil { t.Fatalf("failed to create store: %v", err) } defer store.Close() // Set LEGACY contributor.* keys (what old versions of bd init --contributor would set) if err := store.SetConfig(ctx, "contributor.auto_route", "true"); err != nil { t.Fatalf("failed to set contributor.auto_route: %v", err) } if err := store.SetConfig(ctx, "contributor.planning_repo", "/legacy/planning"); err != nil { t.Fatalf("failed to set contributor.planning_repo: %v", err) } // Simulate backward compat read (as done in create.go) routingMode, _ := store.GetConfig(ctx, "routing.mode") contributorRepo, _ := store.GetConfig(ctx, "routing.contributor") // Fallback to legacy keys if routingMode == "" { legacyAutoRoute, _ := store.GetConfig(ctx, "contributor.auto_route") if legacyAutoRoute == "true" { routingMode = "auto" } } if contributorRepo == "" { legacyPlanningRepo, _ := store.GetConfig(ctx, "contributor.planning_repo") contributorRepo = legacyPlanningRepo } // Verify backward compat works if routingMode != "auto" { t.Errorf("backward compat routing.mode = %q, want %q", routingMode, "auto") } if contributorRepo != "/legacy/planning" { t.Errorf("backward compat routing.contributor = %q, want %q", contributorRepo, "/legacy/planning") } // Build routing config and verify it routes correctly config := &routing.RoutingConfig{ Mode: routingMode, ContributorRepo: contributorRepo, } targetRepo := routing.DetermineTargetRepo(config, routing.Contributor, ".") if targetRepo != "/legacy/planning" { t.Errorf("DetermineTargetRepo() with legacy config = %q, want %q", targetRepo, "/legacy/planning") } } // ============================================================================= // Phase 4: E2E Tests for all sync modes and routing scenarios // ============================================================================= // contributorRoutingEnv provides a reusable test environment for contributor routing tests type contributorRoutingEnv struct { t *testing.T tmpDir string projectDir string planningDir string ctx context.Context cancel context.CancelFunc } // setupContributorRoutingEnv creates isolated project and planning directories with git repos func setupContributorRoutingEnv(t *testing.T) *contributorRoutingEnv { t.Helper() tmpDir := t.TempDir() projectDir := filepath.Join(tmpDir, "project") planningDir := filepath.Join(tmpDir, "planning") // Create project directory with git init projectBeadsDir := filepath.Join(projectDir, ".beads") if err := os.MkdirAll(projectBeadsDir, 0755); err != nil { t.Fatalf("failed to create project .beads dir: %v", err) } // Create planning directory with git init planningBeadsDir := filepath.Join(planningDir, ".beads") if err := os.MkdirAll(planningBeadsDir, 0755); err != nil { t.Fatalf("failed to create planning .beads dir: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) return &contributorRoutingEnv{ t: t, tmpDir: tmpDir, projectDir: projectDir, planningDir: planningDir, ctx: ctx, cancel: cancel, } } // cleanup releases resources func (env *contributorRoutingEnv) cleanup() { env.cancel() } // initProjectStore initializes the project store with routing config func (env *contributorRoutingEnv) initProjectStore(syncMode string) *sqlite.SQLiteStorage { env.t.Helper() projectDBPath := filepath.Join(env.projectDir, ".beads", "beads.db") store, err := sqlite.New(env.ctx, projectDBPath) if err != nil { env.t.Fatalf("failed to create project store: %v", err) } // Set routing config if err := store.SetConfig(env.ctx, "routing.mode", "auto"); err != nil { env.t.Fatalf("failed to set routing.mode: %v", err) } if err := store.SetConfig(env.ctx, "routing.contributor", env.planningDir); err != nil { env.t.Fatalf("failed to set routing.contributor: %v", err) } if err := store.SetConfig(env.ctx, "issue_prefix", "proj-"); err != nil { env.t.Fatalf("failed to set issue_prefix: %v", err) } // Set sync mode-specific config switch syncMode { case "direct": // No special config needed - direct is default case "sync-branch": if err := store.SetConfig(env.ctx, "sync.branch", "beads-sync"); err != nil { env.t.Fatalf("failed to set sync.branch: %v", err) } case "no-db": if err := store.SetConfig(env.ctx, "sync.nodb", "true"); err != nil { env.t.Fatalf("failed to set sync.nodb: %v", err) } case "local-only": if err := store.SetConfig(env.ctx, "sync.local-only", "true"); err != nil { env.t.Fatalf("failed to set sync.local-only: %v", err) } } return store } // initPlanningStore initializes the planning store func (env *contributorRoutingEnv) initPlanningStore() *sqlite.SQLiteStorage { env.t.Helper() planningDBPath := filepath.Join(env.planningDir, ".beads", "beads.db") store, err := sqlite.New(env.ctx, planningDBPath) if err != nil { env.t.Fatalf("failed to create planning store: %v", err) } if err := store.SetConfig(env.ctx, "issue_prefix", "plan-"); err != nil { env.t.Fatalf("failed to set issue_prefix in planning store: %v", err) } return store } // verifyIssueRouting creates an issue via routing and verifies it lands in the correct store func verifyIssueRouting( t *testing.T, ctx context.Context, routingConfig *routing.RoutingConfig, userRole routing.UserRole, targetStore *sqlite.SQLiteStorage, otherStore *sqlite.SQLiteStorage, expectedRepoPath string, description string, ) { t.Helper() // Verify routing decision targetRepo := routing.DetermineTargetRepo(routingConfig, userRole, ".") if targetRepo != expectedRepoPath { t.Errorf("%s: DetermineTargetRepo() = %q, want %q", description, targetRepo, expectedRepoPath) return } // Create issue in target store issue := &types.Issue{ Title: "Test " + description, IssueType: types.TypeTask, Status: types.StatusOpen, Priority: 2, } if err := targetStore.CreateIssue(ctx, issue, "test"); err != nil { t.Fatalf("%s: failed to create issue: %v", description, err) } // Verify issue exists in target store retrieved, err := targetStore.GetIssue(ctx, issue.ID) if err != nil { t.Fatalf("%s: failed to get issue from target store: %v", description, err) } if retrieved == nil { t.Errorf("%s: issue not found in target store", description) return } // Verify issue does NOT exist in other store (isolation check) if otherStore != nil { otherIssue, _ := otherStore.GetIssue(ctx, issue.ID) if otherIssue != nil { t.Errorf("%s: issue should NOT exist in other store (isolation failure)", description) } } } // TestContributorRoutingDirect verifies routing works in direct mode (no sync-branch, no no-db) func TestContributorRoutingDirect(t *testing.T) { env := setupContributorRoutingEnv(t) defer env.cleanup() projectStore := env.initProjectStore("direct") defer projectStore.Close() planningStore := env.initPlanningStore() defer planningStore.Close() // Build routing config from stored values mode, _ := projectStore.GetConfig(env.ctx, "routing.mode") contributorPath, _ := projectStore.GetConfig(env.ctx, "routing.contributor") routingConfig := &routing.RoutingConfig{ Mode: mode, ContributorRepo: contributorPath, MaintainerRepo: ".", } verifyIssueRouting( t, env.ctx, routingConfig, routing.Contributor, planningStore, projectStore, env.planningDir, "direct mode contributor routing", ) } // TestContributorRoutingSyncBranch verifies routing works when sync.branch is configured func TestContributorRoutingSyncBranch(t *testing.T) { env := setupContributorRoutingEnv(t) defer env.cleanup() projectStore := env.initProjectStore("sync-branch") defer projectStore.Close() planningStore := env.initPlanningStore() defer planningStore.Close() // Verify sync.branch is set syncBranch, _ := projectStore.GetConfig(env.ctx, "sync.branch") if syncBranch != "beads-sync" { t.Fatalf("sync.branch not set correctly: got %q, want %q", syncBranch, "beads-sync") } // Build routing config mode, _ := projectStore.GetConfig(env.ctx, "routing.mode") contributorPath, _ := projectStore.GetConfig(env.ctx, "routing.contributor") routingConfig := &routing.RoutingConfig{ Mode: mode, ContributorRepo: contributorPath, MaintainerRepo: ".", } verifyIssueRouting( t, env.ctx, routingConfig, routing.Contributor, planningStore, projectStore, env.planningDir, "sync-branch mode contributor routing", ) } // TestContributorRoutingNoDb verifies routing works when sync.nodb is enabled func TestContributorRoutingNoDb(t *testing.T) { env := setupContributorRoutingEnv(t) defer env.cleanup() projectStore := env.initProjectStore("no-db") defer projectStore.Close() planningStore := env.initPlanningStore() defer planningStore.Close() // Verify no-db is set nodb, _ := projectStore.GetConfig(env.ctx, "sync.nodb") if nodb != "true" { t.Fatalf("sync.nodb not set correctly: got %q, want %q", nodb, "true") } // Build routing config mode, _ := projectStore.GetConfig(env.ctx, "routing.mode") contributorPath, _ := projectStore.GetConfig(env.ctx, "routing.contributor") routingConfig := &routing.RoutingConfig{ Mode: mode, ContributorRepo: contributorPath, MaintainerRepo: ".", } verifyIssueRouting( t, env.ctx, routingConfig, routing.Contributor, planningStore, projectStore, env.planningDir, "no-db mode contributor routing", ) } // TestContributorRoutingDaemon verifies routing works when daemon mode is active. // Note: In practice, daemon mode is bypassed for routed issues (T013), but the // routing decision should still be correct. func TestContributorRoutingDaemon(t *testing.T) { env := setupContributorRoutingEnv(t) defer env.cleanup() projectStore := env.initProjectStore("direct") defer projectStore.Close() planningStore := env.initPlanningStore() defer planningStore.Close() // Build routing config mode, _ := projectStore.GetConfig(env.ctx, "routing.mode") contributorPath, _ := projectStore.GetConfig(env.ctx, "routing.contributor") routingConfig := &routing.RoutingConfig{ Mode: mode, ContributorRepo: contributorPath, MaintainerRepo: ".", } // Verify routing decision is correct (daemon mode bypasses RPC for routed issues) // The key behavior is: when routing to a different repo, daemon is bypassed (T013) targetRepo := routing.DetermineTargetRepo(routingConfig, routing.Contributor, ".") if targetRepo != env.planningDir { t.Errorf("daemon mode routing decision: got %q, want %q", targetRepo, env.planningDir) } // Verify we can create issue directly in planning store (simulating daemon bypass) issue := &types.Issue{ Title: "Test daemon mode routing bypass", IssueType: types.TypeTask, Status: types.StatusOpen, Priority: 2, } if err := planningStore.CreateIssue(env.ctx, issue, "test"); err != nil { t.Fatalf("failed to create issue in planning store: %v", err) } // Verify isolation projectIssue, _ := projectStore.GetIssue(env.ctx, issue.ID) if projectIssue != nil { t.Error("issue should NOT exist in project store (daemon bypass isolation failure)") } } // TestContributorRoutingLocalOnly verifies routing works when local-only mode is enabled func TestContributorRoutingLocalOnly(t *testing.T) { env := setupContributorRoutingEnv(t) defer env.cleanup() projectStore := env.initProjectStore("local-only") defer projectStore.Close() planningStore := env.initPlanningStore() defer planningStore.Close() // Verify local-only is set localOnly, _ := projectStore.GetConfig(env.ctx, "sync.local-only") if localOnly != "true" { t.Fatalf("sync.local-only not set correctly: got %q, want %q", localOnly, "true") } // Build routing config mode, _ := projectStore.GetConfig(env.ctx, "routing.mode") contributorPath, _ := projectStore.GetConfig(env.ctx, "routing.contributor") routingConfig := &routing.RoutingConfig{ Mode: mode, ContributorRepo: contributorPath, MaintainerRepo: ".", } verifyIssueRouting( t, env.ctx, routingConfig, routing.Contributor, planningStore, projectStore, env.planningDir, "local-only mode contributor routing", ) } // TestMaintainerRoutingUnaffected verifies maintainers' issues stay in the project repo func TestMaintainerRoutingUnaffected(t *testing.T) { env := setupContributorRoutingEnv(t) defer env.cleanup() projectStore := env.initProjectStore("direct") defer projectStore.Close() planningStore := env.initPlanningStore() defer planningStore.Close() // Build routing config mode, _ := projectStore.GetConfig(env.ctx, "routing.mode") contributorPath, _ := projectStore.GetConfig(env.ctx, "routing.contributor") routingConfig := &routing.RoutingConfig{ Mode: mode, ContributorRepo: contributorPath, MaintainerRepo: ".", // Maintainer stays local } // Verify maintainer routes to local repo (.) targetRepo := routing.DetermineTargetRepo(routingConfig, routing.Maintainer, env.projectDir) if targetRepo != "." { t.Errorf("maintainer routing: got %q, want %q", targetRepo, ".") } // Create issue in project store (maintainer's issue stays local) issue := &types.Issue{ Title: "Test maintainer issue stays local", IssueType: types.TypeTask, Status: types.StatusOpen, Priority: 2, } if err := projectStore.CreateIssue(env.ctx, issue, "test"); err != nil { t.Fatalf("failed to create issue in project store: %v", err) } // Verify issue exists in project store retrieved, err := projectStore.GetIssue(env.ctx, issue.ID) if err != nil || retrieved == nil { t.Fatalf("maintainer issue not found in project store: %v", err) } // Verify issue does NOT exist in planning store planningIssue, _ := planningStore.GetIssue(env.ctx, issue.ID) if planningIssue != nil { t.Error("maintainer issue should NOT exist in planning store (isolation failure)") } } // TestExplicitRepoOverride verifies --repo flag takes precedence over auto-routing func TestExplicitRepoOverride(t *testing.T) { env := setupContributorRoutingEnv(t) defer env.cleanup() projectStore := env.initProjectStore("direct") defer projectStore.Close() planningStore := env.initPlanningStore() defer planningStore.Close() // Create a third "override" directory overrideDir := filepath.Join(env.tmpDir, "override") overrideBeadsDir := filepath.Join(overrideDir, ".beads") if err := os.MkdirAll(overrideBeadsDir, 0755); err != nil { t.Fatalf("failed to create override .beads dir: %v", err) } overrideDBPath := filepath.Join(overrideBeadsDir, "beads.db") overrideStore, err := sqlite.New(env.ctx, overrideDBPath) if err != nil { t.Fatalf("failed to create override store: %v", err) } defer overrideStore.Close() if err := overrideStore.SetConfig(env.ctx, "issue_prefix", "over-"); err != nil { t.Fatalf("failed to set issue_prefix in override store: %v", err) } // Build routing config WITH explicit override mode, _ := projectStore.GetConfig(env.ctx, "routing.mode") contributorPath, _ := projectStore.GetConfig(env.ctx, "routing.contributor") routingConfig := &routing.RoutingConfig{ Mode: mode, ContributorRepo: contributorPath, MaintainerRepo: ".", ExplicitOverride: overrideDir, // --repo /path/to/override } // Verify explicit override takes precedence over auto-routing targetRepo := routing.DetermineTargetRepo(routingConfig, routing.Contributor, env.projectDir) if targetRepo != overrideDir { t.Errorf("explicit repo override: got %q, want %q", targetRepo, overrideDir) } // Verify maintainer also respects explicit override targetRepo = routing.DetermineTargetRepo(routingConfig, routing.Maintainer, env.projectDir) if targetRepo != overrideDir { t.Errorf("explicit repo override for maintainer: got %q, want %q", targetRepo, overrideDir) } // Create issue in override store issue := &types.Issue{ Title: "Test explicit override", IssueType: types.TypeTask, Status: types.StatusOpen, Priority: 2, } if err := overrideStore.CreateIssue(env.ctx, issue, "test"); err != nil { t.Fatalf("failed to create issue in override store: %v", err) } // Verify issue exists ONLY in override store retrieved, _ := overrideStore.GetIssue(env.ctx, issue.ID) if retrieved == nil { t.Error("issue not found in override store") } projectIssue, _ := projectStore.GetIssue(env.ctx, issue.ID) if projectIssue != nil { t.Error("issue should NOT exist in project store") } planningIssue, _ := planningStore.GetIssue(env.ctx, issue.ID) if planningIssue != nil { t.Error("issue should NOT exist in planning store") } } // TestBEADS_DIRPrecedence verifies BEADS_DIR env var takes precedence over routing config func TestBEADS_DIRPrecedence(t *testing.T) { env := setupContributorRoutingEnv(t) defer env.cleanup() // Create an external beads directory (simulating BEADS_DIR target) externalDir := filepath.Join(env.tmpDir, "external") externalBeadsDir := filepath.Join(externalDir, ".beads") if err := os.MkdirAll(externalBeadsDir, 0755); err != nil { t.Fatalf("failed to create external .beads dir: %v", err) } externalDBPath := filepath.Join(externalBeadsDir, "beads.db") externalStore, err := sqlite.New(env.ctx, externalDBPath) if err != nil { t.Fatalf("failed to create external store: %v", err) } defer externalStore.Close() if err := externalStore.SetConfig(env.ctx, "issue_prefix", "ext-"); err != nil { t.Fatalf("failed to set issue_prefix in external store: %v", err) } // Set BEADS_DIR to external directory t.Setenv("BEADS_DIR", externalBeadsDir) // The key insight: BEADS_DIR is checked in FindBeadsDir() BEFORE routing config is read. // This test verifies the precedence order documented in CONTRIBUTOR_NAMESPACE_ISOLATION.md: // 1. BEADS_DIR environment variable (highest precedence) // 2. --repo flag (explicit override) // 3. Auto-routing based on user role // 4. Current directory's .beads/ (default) // When BEADS_DIR is set, all operations should use that directory, // regardless of routing config. // Create issue in external store issue := &types.Issue{ Title: "Test BEADS_DIR precedence", IssueType: types.TypeTask, Status: types.StatusOpen, Priority: 2, } if err := externalStore.CreateIssue(env.ctx, issue, "test"); err != nil { t.Fatalf("failed to create issue in external store: %v", err) } // Verify issue exists in external store retrieved, err := externalStore.GetIssue(env.ctx, issue.ID) if err != nil || retrieved == nil { t.Fatalf("issue not found in external store: %v", err) } // Initialize project store (should be ignored when BEADS_DIR is set) projectStore := env.initProjectStore("direct") defer projectStore.Close() // Verify issue does NOT exist in project store projectIssue, _ := projectStore.GetIssue(env.ctx, issue.ID) if projectIssue != nil { t.Error("issue should NOT exist in project store when BEADS_DIR is set") } } // TestExplicitRoleOverride verifies git config beads.role takes precedence over URL detection func TestExplicitRoleOverride(t *testing.T) { // Test that beads.role=maintainer in git config forces maintainer role // even when the remote URL would indicate contributor tests := []struct { name string configRole string expectedRole routing.UserRole description string }{ { name: "explicit maintainer", configRole: "maintainer", expectedRole: routing.Maintainer, description: "git config beads.role=maintainer should force maintainer", }, { name: "explicit contributor", configRole: "contributor", expectedRole: routing.Contributor, description: "git config beads.role=contributor should force contributor", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Use the routing package's gitCommandRunner stub mechanism // This is tested more thoroughly in routing_test.go, but we verify // the integration here // Build a routing config assuming the role override routingConfig := &routing.RoutingConfig{ Mode: "auto", ContributorRepo: "/path/to/planning", MaintainerRepo: ".", } // Verify routing behavior based on role var expectedRepo string if tt.expectedRole == routing.Maintainer { expectedRepo = "." } else { expectedRepo = "/path/to/planning" } targetRepo := routing.DetermineTargetRepo(routingConfig, tt.expectedRole, ".") if targetRepo != expectedRepo { t.Errorf("%s: got target repo %q, want %q", tt.description, targetRepo, expectedRepo) } }) } } // TestRoutingWithAllSyncModes is a table-driven test covering all sync mode combinations func TestRoutingWithAllSyncModes(t *testing.T) { syncModes := []struct { name string mode string configKey string configVal string extraCheck func(t *testing.T, store *sqlite.SQLiteStorage, ctx context.Context) }{ { name: "direct", mode: "direct", configKey: "", configVal: "", }, { name: "sync-branch", mode: "sync-branch", configKey: "sync.branch", configVal: "beads-sync", extraCheck: func(t *testing.T, store *sqlite.SQLiteStorage, ctx context.Context) { val, _ := store.GetConfig(ctx, "sync.branch") if val != "beads-sync" { t.Errorf("sync.branch = %q, want %q", val, "beads-sync") } }, }, { name: "no-db", mode: "no-db", configKey: "sync.nodb", configVal: "true", }, { name: "local-only", mode: "local-only", configKey: "sync.local-only", configVal: "true", }, } for _, sm := range syncModes { t.Run(sm.name, func(t *testing.T) { env := setupContributorRoutingEnv(t) defer env.cleanup() projectStore := env.initProjectStore(sm.mode) defer projectStore.Close() planningStore := env.initPlanningStore() defer planningStore.Close() // Run extra check if provided if sm.extraCheck != nil { sm.extraCheck(t, projectStore, env.ctx) } // Build routing config mode, _ := projectStore.GetConfig(env.ctx, "routing.mode") contributorPath, _ := projectStore.GetConfig(env.ctx, "routing.contributor") routingConfig := &routing.RoutingConfig{ Mode: mode, ContributorRepo: contributorPath, MaintainerRepo: ".", } // Verify contributor routing targetRepo := routing.DetermineTargetRepo(routingConfig, routing.Contributor, ".") if targetRepo != env.planningDir { t.Errorf("sync mode %s: contributor target = %q, want %q", sm.name, targetRepo, env.planningDir) } // Verify maintainer routing targetRepo = routing.DetermineTargetRepo(routingConfig, routing.Maintainer, ".") if targetRepo != "." { t.Errorf("sync mode %s: maintainer target = %q, want %q", sm.name, targetRepo, ".") } }) } }