Files
pad/internal/store/users_test.go
T
xarmian 7cda0d7896 feat: rebrand to Perpetual Software + new tagline (IDEA-832) (#273)
Migrates from xarmian/pad to PerpetualSoftware/pad across the entire
repo and updates the product subtitle to "Collaborate with your AI
agents".

Go module rename
- go.mod: github.com/xarmian/pad → github.com/PerpetualSoftware/pad
- All Go imports updated across cmd/pad, internal/{cli,server,store,
  models,collections,items,events,metrics,webhooks} (~130 files)
- Test fixtures with the literal repo slug ("xarmian/pad" in JSON
  shapes, SSH/HTTPS git URL strings, workspace_context fixtures)
  also updated, including the secondary repo entry
  (xarmian/pad-web → PerpetualSoftware/pad-web — pad-web was also
  moved to the org per branch context)

Docs / config
- README badges, install instructions, brew tap, Docker image, source
  build path, sponsor link (sponsor link kept as personal @xarmian)
- Subtitle: "Project management for developers and AI agents." →
  "Collaborate with your AI agents." (README, manifests, web layout
  meta, .goreleaser homebrew description)
- CONTRIBUTING.md, SECURITY.md, skills/INSTALL.md
- .goreleaser.yaml: homebrew_casks owner, GHCR image, release github
  owner, cosign cert-identity regex, comments
- .github/workflows/release.yml: tap/release comments
- deploy/k8s/deployment.yaml: container image
- docs/deployment.md: clone URL
- web/static/{site.webmanifest,manifest.json}: description
- web/src/routes/+layout.svelte: meta description + og:description

Brew tap path is PerpetualSoftware/tap/pad (CamelCase, matches
GitHub user case). GHCR image is ghcr.io/perpetualsoftware/pad
(lowercased per GHCR's URL normalization). CODEOWNERS @xarmian and
FUNDING.yml github: xarmian intentionally retained — those are the
personal maintainer / sponsor account, separate from the org repo.

Verification: go build ./..., go test ./... (all pkgs pass), web
build, and make install all clean (TASK-844, TASK-845).
2026-04-28 12:26:39 -04:00

271 lines
7.5 KiB
Go

package store
import (
"testing"
"time"
"github.com/PerpetualSoftware/pad/internal/models"
)
func createTestUser(t *testing.T, s *Store, email, name, password string) *models.User {
t.Helper()
u, err := s.CreateUser(models.UserCreate{
Email: email,
Name: name,
Password: password,
})
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
return u
}
func TestUserCRUD(t *testing.T) {
s := testStore(t)
// Create
u, err := s.CreateUser(models.UserCreate{
Email: "Dave@Example.com",
Name: "Dave",
Password: "password123",
})
if err != nil {
t.Fatalf("CreateUser error: %v", err)
}
if u.Name != "Dave" {
t.Errorf("expected name 'Dave', got %q", u.Name)
}
if u.Email != "dave@example.com" {
t.Errorf("expected lowercased email, got %q", u.Email)
}
if u.Role != "member" {
t.Errorf("expected default role 'member', got %q", u.Role)
}
if u.PasswordHash == "" {
t.Error("password hash should not be empty")
}
if u.PasswordHash == "password123" {
t.Error("password should be hashed, not stored plaintext")
}
// Get by ID
got, err := s.GetUser(u.ID)
if err != nil {
t.Fatalf("GetUser error: %v", err)
}
if got == nil || got.ID != u.ID {
t.Error("GetUser returned wrong user")
}
// Get by email (case-insensitive)
got, err = s.GetUserByEmail("DAVE@example.com")
if err != nil {
t.Fatalf("GetUserByEmail error: %v", err)
}
if got == nil || got.ID != u.ID {
t.Error("GetUserByEmail returned wrong user")
}
// Get nonexistent
got, err = s.GetUser("nonexistent-id")
if err != nil {
t.Fatalf("GetUser nonexistent error: %v", err)
}
if got != nil {
t.Error("expected nil for nonexistent user")
}
// Update name
newName := "David"
updated, err := s.UpdateUser(u.ID, models.UserUpdate{Name: &newName})
if err != nil {
t.Fatalf("UpdateUser error: %v", err)
}
if updated.Name != "David" {
t.Errorf("expected updated name 'David', got %q", updated.Name)
}
// Update password
newPass := "newpassword456"
_, err = s.UpdateUser(u.ID, models.UserUpdate{Password: &newPass})
if err != nil {
t.Fatalf("UpdateUser password error: %v", err)
}
// Old password should no longer work
result, _ := s.ValidatePassword("dave@example.com", "password123")
if result != nil {
t.Error("old password should not validate after change")
}
// New password should work
result, _ = s.ValidatePassword("dave@example.com", "newpassword456")
if result == nil {
t.Error("new password should validate after change")
}
}
func TestUserCreateAdmin(t *testing.T) {
s := testStore(t)
u, err := s.CreateUser(models.UserCreate{
Email: "admin@test.com",
Name: "Admin",
Password: "admin123",
Role: "admin",
})
if err != nil {
t.Fatalf("CreateUser error: %v", err)
}
if u.Role != "admin" {
t.Errorf("expected role 'admin', got %q", u.Role)
}
}
func TestUserDuplicateEmail(t *testing.T) {
s := testStore(t)
_, err := s.CreateUser(models.UserCreate{
Email: "dave@test.com", Name: "Dave", Password: "pass123",
})
if err != nil {
t.Fatalf("first CreateUser error: %v", err)
}
_, err = s.CreateUser(models.UserCreate{
Email: "dave@test.com", Name: "Other Dave", Password: "pass456",
})
if err == nil {
t.Error("expected error for duplicate email")
}
}
func TestValidatePassword(t *testing.T) {
s := testStore(t)
createTestUser(t, s, "test@test.com", "Test", "correctpassword")
// Correct password
u, err := s.ValidatePassword("test@test.com", "correctpassword")
if err != nil {
t.Fatalf("ValidatePassword error: %v", err)
}
if u == nil {
t.Error("expected user for correct password")
}
// Wrong password
u, err = s.ValidatePassword("test@test.com", "wrongpassword")
if err != nil {
t.Fatalf("ValidatePassword wrong password error: %v", err)
}
if u != nil {
t.Error("expected nil for wrong password")
}
// Nonexistent email
u, err = s.ValidatePassword("nobody@test.com", "anything")
if err != nil {
t.Fatalf("ValidatePassword nonexistent error: %v", err)
}
if u != nil {
t.Error("expected nil for nonexistent email")
}
}
func TestListUsers(t *testing.T) {
s := testStore(t)
createTestUser(t, s, "a@test.com", "Alice", "pass1")
createTestUser(t, s, "b@test.com", "Bob", "pass2")
users, err := s.ListUsers()
if err != nil {
t.Fatalf("ListUsers error: %v", err)
}
if len(users) != 2 {
t.Errorf("expected 2 users, got %d", len(users))
}
}
func TestUserCount(t *testing.T) {
s := testStore(t)
count, _ := s.UserCount()
if count != 0 {
t.Errorf("expected 0 users, got %d", count)
}
createTestUser(t, s, "a@test.com", "Alice", "pass1")
createTestUser(t, s, "b@test.com", "Bob", "pass2")
count, _ = s.UserCount()
if count != 2 {
t.Errorf("expected 2 users, got %d", count)
}
}
func TestCountBillingAggregates(t *testing.T) {
s := testStore(t)
now := time.Now().UTC()
cutoff := now.Add(-30 * 24 * time.Hour)
// Empty store: zero counts, no plans, no signups.
agg, err := s.CountBillingAggregates(cutoff)
if err != nil {
t.Fatalf("CountBillingAggregates on empty store: %v", err)
}
if len(agg.CustomersByPlan) != 0 {
t.Errorf("empty store: want no plan entries, got %v", agg.CustomersByPlan)
}
if agg.NewProSignups != 0 {
t.Errorf("empty store: want NewProSignups=0, got %d", agg.NewProSignups)
}
// Mix: explicit-empty plan (older legacy rows) + explicit "free"
// (current default) + pro recent + pro old + self-hosted. Both the
// '' and 'free' rows must roll up into the same "free" bucket — Codex
// round 2 caught this regression where GROUP BY plan (raw column)
// produced two scanned rows both labelled "free" that overwrote each
// other in the result map.
insertWithPlanAndDate(t, s, "legacy_blank@test.com", "", now.Add(-100*24*time.Hour))
insertWithPlanAndDate(t, s, "free_explicit@test.com", "free", now.Add(-2*24*time.Hour))
insertWithPlanAndDate(t, s, "free_explicit2@test.com", "free", now.Add(-1*24*time.Hour))
insertWithPlanAndDate(t, s, "pro_recent@test.com", "pro", now.Add(-5*24*time.Hour))
insertWithPlanAndDate(t, s, "pro_old@test.com", "pro", now.Add(-90*24*time.Hour))
insertWithPlanAndDate(t, s, "self@test.com", "self-hosted", now.Add(-60*24*time.Hour))
agg, err = s.CountBillingAggregates(cutoff)
if err != nil {
t.Fatalf("CountBillingAggregates: %v", err)
}
// 1 legacy blank + 2 explicit "free" must roll up to 3.
if got, want := agg.CustomersByPlan["free"], 3; got != want {
t.Errorf("free (rolls up '' + 'free'): want %d, got %d (full map: %v)", want, got, agg.CustomersByPlan)
}
if got, want := agg.CustomersByPlan["pro"], 2; got != want {
t.Errorf("pro: want %d, got %d", want, got)
}
if got, want := agg.CustomersByPlan["self-hosted"], 1; got != want {
t.Errorf("self-hosted: want %d, got %d", want, got)
}
if got, want := agg.NewProSignups, 1; got != want {
t.Errorf("new pro signups in last 30d: want %d (recent only), got %d", want, got)
}
}
// insertWithPlanAndDate is a test-only helper that inserts a user with a
// specific plan + created_at timestamp. CreateUser doesn't expose either
// directly. Times are RFC3339-formatted strings (matching store.now) so
// CountBillingAggregates can do a string lex-compare against the cutoff.
func insertWithPlanAndDate(t *testing.T, s *Store, email, plan string, createdAt time.Time) {
t.Helper()
id := newID()
ts := createdAt.UTC().Format(time.RFC3339)
_, err := s.db.Exec(s.q(`
INSERT INTO users (id, email, username, name, password_hash, role, plan, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`),
id, email, "u_"+id, "Test "+id, "x", "member", plan, ts, ts)
if err != nil {
t.Fatalf("insert user with plan=%s: %v", plan, err)
}
}