package client import ( "context" "errors" "net/http" "os" "path/filepath" "testing" "time" "lodge/internal/gitrepo" "lodge/internal/protocol" ) func issueCode(t *testing.T, url string) string { t.Helper() var code protocol.CodeResponse admin := &Client{URL: url, Token: adminToken} if err := admin.call(context.Background(), http.MethodPost, "/api/codes", nil, &code); err != nil { t.Fatal(err) } return code.Code } func (m *machine) remote(url, code string) { m.t.Helper() ctx := context.Background() if code != "" { if err := m.config.Connect(ctx, url, code); err != nil { m.t.Fatal(err) } } if err := m.config.Bind(repoKey(m.t, m), url); err != nil { m.t.Fatal(err) } m.sync() } func revisionsOn(t *testing.T, url, key string) []protocol.Revision { t.Helper() revisions, err := (&Client{URL: url, Token: adminToken}).Revisions(context.Background(), key, "") if err != nil { t.Fatal(err) } return revisions } func TestBoundRepositoryNeverReachesTheDefaultServer(t *testing.T) { personal, event, origin := newServer(t), newServer(t), newOrigin(t) mac := newMachine(t, origin, personal, adminToken) key := repoKey(t, mac) ctx := context.Background() if err := mac.config.Hook(ctx, mac.root); err != nil { t.Fatal(err) } stale, err := mac.config.reload() if err != nil { t.Fatal(err) } mac.remote(event+"/", issueCode(t, event)) write(t, mac.root, "docs/agents/plan.md", "event plan") if _, err := stale.Sync(ctx, mac.root, true); err != nil { t.Fatal(err) } worktree := filepath.Join(t.TempDir(), "feature") git(t, mac.root, "worktree", "add", "-b", "feature", worktree) worktree, _ = filepath.EvalSymlinks(worktree) if err := mac.config.Hook(ctx, worktree); err != nil { t.Fatal(err) } if data, _ := os.ReadFile(filepath.Join(worktree, "docs/agents/plan.md")); string(data) != "event plan" { t.Fatalf("new worktree of a bound repository got %q", data) } other := newMachine(t, newOrigin(t), personal, adminToken) other.config = mac.config write(t, other.root, "docs/agents/private.md", "personal") other.sync() if revisions := revisionsOn(t, personal, key); len(revisions) != 0 { t.Fatalf("the default server received %d revisions of a bound repository", len(revisions)) } if revisions := revisionsOn(t, event, key); len(revisions) != 1 { t.Fatalf("the event server has %d revisions, want 1", len(revisions)) } if revisions := revisionsOn(t, event, repoKey(t, other)); len(revisions) != 0 { t.Fatal("an unbound repository reached the event server") } if revisions := revisionsOn(t, personal, repoKey(t, other)); len(revisions) != 1 { t.Fatal("an unbound repository did not reach the default server") } if c, err := mac.config.Server(key); err != nil || c.URL != event { t.Fatalf("server of the bound repository = %v (%v), want %s", c, err, event) } } func TestMachineWithoutDefaultServer(t *testing.T) { event, origin := newServer(t), newOrigin(t) mac := newMachine(t, origin, event, adminToken) write(t, mac.root, "docs/agents/plan.md", "plan") mac.sync() fresh := newMachine(t, origin, "", "") if _, err := fresh.config.Sync(context.Background(), fresh.root, true); !errors.Is(err, ErrNotLoggedIn) { t.Fatalf("sync without any server: %v", err) } if roots := fresh.config.Worktrees(); len(roots) != 0 { t.Fatalf("a repository without a server was registered: %v", roots) } if err := fresh.config.Connect(context.Background(), event, issueCode(t, event)); err != nil { t.Fatal(err) } fresh.remote(event, "") fresh.expect("docs/agents/plan.md", "plan") if fresh.config.URL != "" { t.Fatalf("lodge remote set the default server to %q", fresh.config.URL) } } func TestMovingBetweenServersKeepsLocalFiles(t *testing.T) { personal, event, origin := newServer(t), newServer(t), newOrigin(t) mac := newMachine(t, origin, personal, adminToken) dell := newMachine(t, origin, "", "") key := repoKey(t, mac) write(t, mac.root, "docs/agents/mine.md", "mine") mac.sync() dell.remote(event, issueCode(t, event)) write(t, dell.root, "docs/agents/theirs.md", "theirs") dell.sync() mac.remote(event, issueCode(t, event)) mac.expect("docs/agents/mine.md", "mine") mac.expect("docs/agents/theirs.md", "theirs") dell.sync() dell.expect("docs/agents/mine.md", "mine") if err := mac.config.Bind(key, ""); err != nil { t.Fatal(err) } mac.sync() mac.expect("docs/agents/mine.md", "mine") mac.expect("docs/agents/theirs.md", "theirs") state, err := mac.client.State(context.Background(), key, "") if err != nil || len(state.Spaces[protocol.SharedSpace]) != 2 { t.Fatalf("back on the default server: %v (%v)", state.Spaces, err) } } func TestStateFromBeforeServersIsAdopted(t *testing.T) { mac, dell := setup(t) write(t, mac.root, "docs/agents/plan.md", "plan") mac.sync() dell.sync() repo := openRepo(t, mac) if err := os.Rename(mac.client.statePath(repo), worktreePath(mac.config.DataDir, repo)+".json"); err != nil { t.Fatal(err) } if err := os.Remove(filepath.Join(dell.root, "docs/agents/plan.md")); err != nil { t.Fatal(err) } dell.sync() mac.sync() mac.expect("docs/agents/plan.md", "") } func TestDaemonFollowsServersAddedLater(t *testing.T) { event, origin := newServer(t), newOrigin(t) mac := newMachine(t, origin, event, adminToken) fresh := newMachine(t, origin, "", "") daemonConfig, err := fresh.config.reload() if err != nil { t.Fatal(err) } ctx, cancel := context.WithCancel(context.Background()) done := make(chan error) go func() { done <- daemonConfig.Daemon(ctx) }() t.Cleanup(func() { cancel() if err := <-done; err != nil { t.Error(err) } }) fresh.remote(event, issueCode(t, event)) write(t, mac.root, "docs/agents/first.md", "first") mac.sync() fresh.waitFor("docs/agents/first.md", "first") write(t, mac.root, "docs/agents/second.md", "second") mac.sync() fresh.waitFor("docs/agents/second.md", "second") } func (m *machine) waitFor(path, content string) { m.t.Helper() deadline := time.Now().Add(15 * time.Second) for { if data, _ := os.ReadFile(filepath.Join(m.root, path)); string(data) == content { return } if time.Now().After(deadline) { m.t.Fatalf("the daemon did not bring %s", path) } time.Sleep(50 * time.Millisecond) } } func openRepo(t *testing.T, m *machine) *gitrepo.Repo { t.Helper() repo, err := gitrepo.Open(m.root) if err != nil { t.Fatal(err) } return repo }