Artifact 40ec644653a1ffd51773f742d1f9e75a7fa78ac06ba4502c598ea32313b496d6:
- File
hub/users_test.go
— part of check-in
[40c9b9aff3]
at
2026-05-19 16:25:25
on branch main
— feat(hub): Hub.EnsureUser — idempotent user-table registration (closes #183) (#184)
Add Hub.EnsureUser(login, caps string) error as an idempotent counterpart to Hub.AddUser. Consumers (sesh, others) need "register if absent" semantics; AddUser errors on duplicate, forcing each consumer to write check-then-act + race-recheck wrappers. The user-table layer is the right place to own that idempotency.
Implementation choice (a) — GetUser → CreateUser, serialized by a Repo- level mutex. Picked over (b) "catch CreateUser's already-exists error" because libfossil's CreateUser surfaces a wrapped SQLite UNIQUE-constraint error with no sentinel or typed shape — string-matching would be brittle. A defensive GetUser retry after CreateUser failure absorbs the rare cross- process race where the in-process mutex doesn't apply.
Contract: existing caps are PRESERVED on duplicate (documented). Callers wanting replace-on-conflict use SetUserCaps explicitly.
Tests cover first-time registration, duplicate no-op, caps-change-on- duplicate (preservation), 5-goroutine concurrent race (exactly one row lands), and empty-login rejection. Full hub package + leaf + bridge + CLI suites pass; go vet clean; gofmt clean.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> (user: [email protected], size: 3382) [annotate] [blame] [check-ins using]
package hub import ( "sync" "testing" ) // TestHub_EnsureUser covers the contract documented on Hub.EnsureUser: // first-time registration, idempotent re-call, caps-change-preserves- // existing, concurrent racing callers, and the empty-login failure mode. // // Test gap: the "underlying repo unreachable / fossil-lock contention" // failure mode isn't covered here — exercising it cleanly requires either // closing the hub mid-call (data race on the libfossil handle) or // fault-injection at the libfossil layer that isn't currently exposed. // Acceptable: the cap-empty / login-empty path proves error wiring, and // the underlying CreateUser/GetUser error paths are already covered by // libfossil's own user_test.go. func TestHub_EnsureUser(t *testing.T) { t.Run("first-time registration creates user with caps", func(t *testing.T) { h := newTestHub(t) if err := h.EnsureUser("alice", "i"); err != nil { t.Fatalf("EnsureUser(alice, i): %v", err) } if !h.HasUser("alice") { t.Fatal("HasUser(alice) = false after EnsureUser") } got, err := h.GetUser("alice") if err != nil { t.Fatalf("GetUser(alice): %v", err) } if got.Login != "alice" || got.Caps != "i" { t.Errorf("GetUser = %+v, want {Login:alice Caps:i}", got) } }) t.Run("duplicate call with same caps is a no-op", func(t *testing.T) { h := newTestHub(t) if err := h.EnsureUser("bob", "i"); err != nil { t.Fatalf("EnsureUser bob first: %v", err) } if err := h.EnsureUser("bob", "i"); err != nil { t.Fatalf("EnsureUser bob second: %v", err) } if !h.HasUser("bob") { t.Fatal("HasUser(bob) = false after two EnsureUser calls") } got, err := h.GetUser("bob") if err != nil { t.Fatalf("GetUser(bob): %v", err) } if got.Caps != "i" { t.Errorf("GetUser caps = %q, want %q", got.Caps, "i") } }) t.Run("caps change on duplicate preserves original caps", func(t *testing.T) { h := newTestHub(t) if err := h.EnsureUser("carol", "i"); err != nil { t.Fatalf("EnsureUser carol i: %v", err) } // Second call with different caps must NOT mutate the row. if err := h.EnsureUser("carol", "a"); err != nil { t.Fatalf("EnsureUser carol a (duplicate): %v", err) } got, err := h.GetUser("carol") if err != nil { t.Fatalf("GetUser(carol): %v", err) } if got.Caps != "i" { t.Errorf("GetUser caps = %q, want %q (caps must be preserved on duplicate)", got.Caps, "i") } }) t.Run("concurrent callers all return nil and land exactly one user", func(t *testing.T) { h := newTestHub(t) const n = 5 var wg sync.WaitGroup errs := make([]error, n) start := make(chan struct{}) for i := 0; i < n; i++ { wg.Add(1) go func(i int) { defer wg.Done() <-start errs[i] = h.EnsureUser("dana", "i") }(i) } close(start) wg.Wait() for i, err := range errs { if err != nil { t.Errorf("goroutine %d: EnsureUser: %v", i, err) } } users, err := h.ListUsers() if err != nil { t.Fatalf("ListUsers: %v", err) } var danaCount int for _, u := range users { if u.Login == "dana" { danaCount++ } } if danaCount != 1 { t.Errorf("ListUsers count(dana) = %d, want 1", danaCount) } }) t.Run("empty login is rejected", func(t *testing.T) { h := newTestHub(t) if err := h.EnsureUser("", "i"); err == nil { t.Fatal("EnsureUser with empty login should error") } }) }