Files
beads/internal/routing/routing_test.go
Bo 6b5bebfa12 fix(routing): handle symlinked .beads directories correctly (#1112)
When .beads is a symlink (e.g., ~/gt/.beads -> ~/gt/olympus/.beads),
findTownRoutes() incorrectly used filepath.Dir() on the resolved
symlink path to determine the town root.

This caused route resolution to fail because the town root would be
~/gt/olympus instead of ~/gt, making routes point to non-existent
directories.

The fix adds findTownRootFromCWD() which walks up from the current
working directory instead of the beads directory path. This finds
the correct town root regardless of symlink resolution.

Changes:
- Add findTownRootFromCWD() function
- Update findTownRoutes() to use CWD-based town root detection
- Add fallback to filepath.Dir() for non-Gas Town repos
- Add debug logging (BD_DEBUG_ROUTING=1)
- Add comprehensive test case

Test: go test ./internal/routing/...
2026-01-15 19:20:42 -08:00

394 lines
11 KiB
Go

package routing
import (
"errors"
"os"
"path/filepath"
"reflect"
"testing"
)
func TestDetermineTargetRepo(t *testing.T) {
tests := []struct {
name string
config *RoutingConfig
userRole UserRole
repoPath string
want string
}{
{
name: "explicit override takes precedence",
config: &RoutingConfig{
Mode: "auto",
DefaultRepo: "~/planning",
MaintainerRepo: ".",
ContributorRepo: "~/contributor-planning",
ExplicitOverride: "/tmp/custom",
},
userRole: Maintainer,
repoPath: ".",
want: "/tmp/custom",
},
{
name: "auto mode - maintainer uses maintainer repo",
config: &RoutingConfig{
Mode: "auto",
MaintainerRepo: ".",
ContributorRepo: "~/contributor-planning",
},
userRole: Maintainer,
repoPath: ".",
want: ".",
},
{
name: "auto mode - contributor uses contributor repo",
config: &RoutingConfig{
Mode: "auto",
MaintainerRepo: ".",
ContributorRepo: "~/contributor-planning",
},
userRole: Contributor,
repoPath: ".",
want: "~/contributor-planning",
},
{
name: "explicit mode uses default",
config: &RoutingConfig{
Mode: "explicit",
DefaultRepo: "~/planning",
},
userRole: Maintainer,
repoPath: ".",
want: "~/planning",
},
{
name: "no config defaults to current directory",
config: &RoutingConfig{
Mode: "auto",
},
userRole: Maintainer,
repoPath: ".",
want: ".",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := DetermineTargetRepo(tt.config, tt.userRole, tt.repoPath)
if got != tt.want {
t.Errorf("DetermineTargetRepo() = %v, want %v", got, tt.want)
}
})
}
}
func TestDetectUserRole_Fallback(t *testing.T) {
// Test fallback behavior when git is not available
role, err := DetectUserRole("/nonexistent/path/that/does/not/exist")
if err != nil {
t.Fatalf("DetectUserRole() error = %v, want nil", err)
}
if role != Contributor {
t.Errorf("DetectUserRole() = %v, want %v (fallback)", role, Contributor)
}
}
func TestExtractPrefix(t *testing.T) {
tests := []struct {
id string
want string
}{
{"gt-abc123", "gt-"},
{"bd-xyz", "bd-"},
{"hq-1234", "hq-"},
{"abc123", ""}, // No hyphen
{"", ""}, // Empty string
{"-abc", "-"}, // Starts with hyphen
}
for _, tt := range tests {
t.Run(tt.id, func(t *testing.T) {
got := ExtractPrefix(tt.id)
if got != tt.want {
t.Errorf("ExtractPrefix(%q) = %q, want %q", tt.id, got, tt.want)
}
})
}
}
func TestExtractProjectFromPath(t *testing.T) {
tests := []struct {
path string
want string
}{
{"beads/mayor/rig", "beads"},
{"gastown/crew/max", "gastown"},
{"simple", "simple"},
{"", ""},
{"/absolute/path", ""}, // Starts with /, first component is empty
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
got := ExtractProjectFromPath(tt.path)
if got != tt.want {
t.Errorf("ExtractProjectFromPath(%q) = %q, want %q", tt.path, got, tt.want)
}
})
}
}
func TestResolveToExternalRef(t *testing.T) {
// This test is limited since it requires a routes.jsonl file
// Just test that it returns empty string for nonexistent directory
got := ResolveToExternalRef("bd-abc", "/nonexistent/path")
if got != "" {
t.Errorf("ResolveToExternalRef() = %q, want empty string for nonexistent path", got)
}
}
type gitCall struct {
repo string
args []string
}
type gitResponse struct {
expect gitCall
output string
err error
}
type gitStub struct {
t *testing.T
responses []gitResponse
idx int
}
func (s *gitStub) run(repo string, args ...string) ([]byte, error) {
if s.idx >= len(s.responses) {
s.t.Fatalf("unexpected git call %v in repo %s", args, repo)
}
resp := s.responses[s.idx]
s.idx++
if resp.expect.repo != repo {
s.t.Fatalf("repo mismatch: got %q want %q", repo, resp.expect.repo)
}
if !reflect.DeepEqual(resp.expect.args, args) {
s.t.Fatalf("args mismatch: got %v want %v", args, resp.expect.args)
}
return []byte(resp.output), resp.err
}
func (s *gitStub) verify() {
if s.idx != len(s.responses) {
s.t.Fatalf("expected %d git calls, got %d", len(s.responses), s.idx)
}
}
func TestDetectUserRole_ConfigOverrideMaintainer(t *testing.T) {
orig := gitCommandRunner
stub := &gitStub{t: t, responses: []gitResponse{
{expect: gitCall{"", []string{"config", "--get", "beads.role"}}, output: "maintainer\n"},
}}
gitCommandRunner = stub.run
t.Cleanup(func() {
gitCommandRunner = orig
stub.verify()
})
role, err := DetectUserRole("")
if err != nil {
t.Fatalf("DetectUserRole error = %v", err)
}
if role != Maintainer {
t.Fatalf("expected %s, got %s", Maintainer, role)
}
}
func TestDetectUserRole_ConfigOverrideContributor(t *testing.T) {
orig := gitCommandRunner
stub := &gitStub{t: t, responses: []gitResponse{
{expect: gitCall{"/repo", []string{"config", "--get", "beads.role"}}, output: "contributor\n"},
}}
gitCommandRunner = stub.run
t.Cleanup(func() {
gitCommandRunner = orig
stub.verify()
})
role, err := DetectUserRole("/repo")
if err != nil {
t.Fatalf("DetectUserRole error = %v", err)
}
if role != Contributor {
t.Fatalf("expected %s, got %s", Contributor, role)
}
}
func TestDetectUserRole_PushURLMaintainer(t *testing.T) {
orig := gitCommandRunner
stub := &gitStub{t: t, responses: []gitResponse{
{expect: gitCall{"/repo", []string{"config", "--get", "beads.role"}}, output: "unknown"},
{expect: gitCall{"/repo", []string{"remote", "get-url", "--push", "origin"}}, output: "git@github.com:owner/repo.git"},
}}
gitCommandRunner = stub.run
t.Cleanup(func() {
gitCommandRunner = orig
stub.verify()
})
role, err := DetectUserRole("/repo")
if err != nil {
t.Fatalf("DetectUserRole error = %v", err)
}
if role != Maintainer {
t.Fatalf("expected %s, got %s", Maintainer, role)
}
}
func TestDetectUserRole_HTTPSCredentialsMaintainer(t *testing.T) {
orig := gitCommandRunner
stub := &gitStub{t: t, responses: []gitResponse{
{expect: gitCall{"/repo", []string{"config", "--get", "beads.role"}}, output: ""},
{expect: gitCall{"/repo", []string{"remote", "get-url", "--push", "origin"}}, output: "https://token@github.com/owner/repo.git"},
}}
gitCommandRunner = stub.run
t.Cleanup(func() {
gitCommandRunner = orig
stub.verify()
})
role, err := DetectUserRole("/repo")
if err != nil {
t.Fatalf("DetectUserRole error = %v", err)
}
if role != Maintainer {
t.Fatalf("expected %s, got %s", Maintainer, role)
}
}
func TestDetectUserRole_DefaultContributor(t *testing.T) {
orig := gitCommandRunner
stub := &gitStub{t: t, responses: []gitResponse{
{expect: gitCall{"", []string{"config", "--get", "beads.role"}}, err: errors.New("missing")},
{expect: gitCall{"", []string{"remote", "get-url", "--push", "origin"}}, err: errors.New("no push")},
{expect: gitCall{"", []string{"remote", "get-url", "origin"}}, output: "https://github.com/owner/repo.git"},
}}
gitCommandRunner = stub.run
t.Cleanup(func() {
gitCommandRunner = orig
stub.verify()
})
role, err := DetectUserRole("")
if err != nil {
t.Fatalf("DetectUserRole error = %v", err)
}
if role != Contributor {
t.Fatalf("expected %s, got %s", Contributor, role)
}
}
// TestFindTownRoutes_SymlinkedBeadsDir verifies that findTownRoutes correctly
// handles symlinked .beads directories by using findTownRootFromCWD() instead of
// walking up from the beadsDir path.
//
// Scenario: ~/gt/.beads is a symlink to ~/gt/olympus/.beads
// Before fix: walking up from ~/gt/olympus/.beads finds ~/gt/olympus (WRONG)
// After fix: findTownRootFromCWD() walks up from CWD to find mayor/town.json at ~/gt
func TestFindTownRoutes_SymlinkedBeadsDir(t *testing.T) {
// Create temporary directory structure simulating Gas Town:
// tmpDir/
// mayor/
// town.json <- town root marker
// olympus/ <- actual beads storage
// .beads/
// routes.jsonl
// .beads -> olympus/.beads <- symlink
// daedalus/
// mayor/
// rig/
// .beads/ <- target rig
tmpDir, err := os.MkdirTemp("", "routing-symlink-test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// Resolve symlinks in tmpDir (macOS /var -> /private/var)
tmpDir, err = filepath.EvalSymlinks(tmpDir)
if err != nil {
t.Fatal(err)
}
// Create mayor/town.json to mark town root
mayorDir := filepath.Join(tmpDir, "mayor")
if err := os.MkdirAll(mayorDir, 0750); err != nil {
t.Fatal(err)
}
townJSON := filepath.Join(mayorDir, "town.json")
if err := os.WriteFile(townJSON, []byte(`{"name": "test-town"}`), 0600); err != nil {
t.Fatal(err)
}
// Create olympus/.beads with routes.jsonl
olympusBeadsDir := filepath.Join(tmpDir, "olympus", ".beads")
if err := os.MkdirAll(olympusBeadsDir, 0750); err != nil {
t.Fatal(err)
}
routesContent := `{"prefix": "gt-", "path": "daedalus/mayor/rig"}
`
routesPath := filepath.Join(olympusBeadsDir, "routes.jsonl")
if err := os.WriteFile(routesPath, []byte(routesContent), 0600); err != nil {
t.Fatal(err)
}
// Create daedalus/mayor/rig/.beads as target rig
daedalusBeadsDir := filepath.Join(tmpDir, "daedalus", "mayor", "rig", ".beads")
if err := os.MkdirAll(daedalusBeadsDir, 0750); err != nil {
t.Fatal(err)
}
// Create metadata.json so the rig is recognized as valid
if err := os.WriteFile(filepath.Join(daedalusBeadsDir, "metadata.json"), []byte(`{}`), 0600); err != nil {
t.Fatal(err)
}
// Create symlink: tmpDir/.beads -> olympus/.beads
symlinkPath := filepath.Join(tmpDir, ".beads")
if err := os.Symlink(olympusBeadsDir, symlinkPath); err != nil {
t.Skip("Cannot create symlinks on this system (may require admin on Windows)")
}
// Change to the town root directory - this simulates the user running bd from ~/gt
// The fix uses findTownRootFromCWD() which needs CWD to be inside the town
t.Chdir(tmpDir)
// Simulate what happens when FindBeadsDir() returns the resolved symlink path
// (this is what CanonicalizePath does)
resolvedBeadsDir := olympusBeadsDir // This is what would be passed to findTownRoutes
// Call findTownRoutes with the resolved symlink path
routes, townRoot := findTownRoutes(resolvedBeadsDir)
// Verify we got the routes
if len(routes) == 0 {
t.Fatal("findTownRoutes returned no routes")
}
// Verify the town root is correct (should be tmpDir, NOT tmpDir/olympus)
if townRoot != tmpDir {
t.Errorf("findTownRoutes returned wrong townRoot:\n got: %s\n want: %s", townRoot, tmpDir)
}
// Verify route resolution works - the route should resolve to the correct path
expectedRigPath := filepath.Join(tmpDir, "daedalus", "mayor", "rig", ".beads")
for _, route := range routes {
if route.Prefix == "gt-" {
actualPath := filepath.Join(townRoot, route.Path, ".beads")
if actualPath != expectedRigPath {
t.Errorf("Route resolution failed:\n got: %s\n want: %s", actualPath, expectedRigPath)
}
}
}
}