From 9918c9918f8135614cc8e714921a4f60eed0f5e2 Mon Sep 17 00:00:00 2001 From: juancwu Date: Wed, 6 May 2026 00:16:39 +0000 Subject: [PATCH] feat: initial sqlite store --- go.mod | 14 +- go.sum | 21 +++ service/service.go | 7 +- store/errors.go | 5 +- store/models.go | 2 +- store/queries.go | 33 ++++ store/sqlite/migrations.go | 38 +++++ store/sqlite/store.go | 94 ++++++++++++ store/sqlite/store_test.go | 71 +++++++++ store/storetest/storetest.go | 281 +++++++++++++++++++++++++++++++++++ 10 files changed, 560 insertions(+), 6 deletions(-) create mode 100644 store/queries.go create mode 100644 store/sqlite/migrations.go create mode 100644 store/sqlite/store.go create mode 100644 store/sqlite/store_test.go create mode 100644 store/storetest/storetest.go diff --git a/go.mod b/go.mod index 815523d..170042f 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,16 @@ module git.juancwu.dev/juancwu/pase go 1.26.2 -require github.com/oklog/ulid/v2 v2.1.1 // indirect +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/oklog/ulid/v2 v2.1.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.42.0 // indirect + modernc.org/libc v1.72.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.50.0 // indirect +) diff --git a/go.sum b/go.sum index 4b2c1c3..0d68445 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,24 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c= +modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM= +modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= diff --git a/service/service.go b/service/service.go index c048482..0e688d0 100644 --- a/service/service.go +++ b/service/service.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "crypto/sha256" + "database/sql" "encoding/base64" "errors" "fmt" @@ -74,8 +75,8 @@ func (s *Service) RegisterUser(ctx context.Context, email string) (*store.User, timeoutCtx, cancel := context.WithTimeout(ctx, s.operationTimeout) defer cancel() - nullString := store.NullString{Valid: false} - nullTime := store.NullTime{Valid: false} + nullString := store.NullString{NullString: sql.NullString{Valid: false}} + nullTime := store.NullTime{NullTime: sql.NullTime{Valid: false}} user := store.User{ ID: s.idGenerator.NewID(), Email: email, @@ -86,7 +87,7 @@ func (s *Service) RegisterUser(ctx context.Context, email string) (*store.User, ProfileImageURL: nullString, Status: store.UserStatusActive, - StatusReason: "New registration", + StatusReason: nullString, StatusChangedAt: nullTime, StatusExpiresAt: nullTime, diff --git a/store/errors.go b/store/errors.go index 4a6cec4..d6cf1ac 100644 --- a/store/errors.go +++ b/store/errors.go @@ -3,5 +3,8 @@ package store import "errors" var ( - ErrUserEmailConflict = errors.New("pase.store: user email conflict") + ErrUserEmailConflict = errors.New("pase: user email conflict") + ErrEmailAlreadyExists = errors.New("pase: email already exists") + ErrUsernameAlreadyExists = errors.New("pase: username already exists") + ErrUserNotFound = errors.New("pase: user not found") ) diff --git a/store/models.go b/store/models.go index b697043..98864d2 100644 --- a/store/models.go +++ b/store/models.go @@ -26,7 +26,7 @@ type User struct { ProfileImageURL NullString Status UserStatus - StatusReason string + StatusReason NullString StatusChangedAt NullTime StatusExpiresAt NullTime diff --git a/store/queries.go b/store/queries.go new file mode 100644 index 0000000..492e4cd --- /dev/null +++ b/store/queries.go @@ -0,0 +1,33 @@ +package store + +type Queries struct { + CreateUser string + GetUserByID string +} + +var CanonicalQueries Queries = Queries{ + CreateUser: ` + INSERT INTO pase_users ( + id, email, email_verified_at, + username, username_normalized, display_name, profile_image_url, + status, status_reason, status_changed_at, status_expires_at, + failed_login_count, last_failed_login_at, + created_at, updated_at + ) VALUES (?,?,?, ?,?,?,?, ?,?,?,?, ?,?, ?,?);`, + GetUserByID: ` + SELECT + id, email, email_verified_at, + username, username_normalized, display_name, profile_image_url, + status, status_reason, status_changed_at, status_expires_at, + failed_login_count, last_failed_login_at, + created_at, updated_at + FROM pase_users + WHERE id = ?;`, +} + +func (q Queries) Rebind(d Dialect) Queries { + return Queries{ + CreateUser: d.Rebind(q.CreateUser), + GetUserByID: d.Rebind(q.GetUserByID), + } +} diff --git a/store/sqlite/migrations.go b/store/sqlite/migrations.go new file mode 100644 index 0000000..f634e0b --- /dev/null +++ b/store/sqlite/migrations.go @@ -0,0 +1,38 @@ +package sqlite + +import ( + "context" + "database/sql" + "embed" + "fmt" + + "git.juancwu.dev/juancwu/pase/store" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +// Migrate applies all pending migrations to db using the SQLite dialect. +// It is safe to call repeatedly; already-applied migrations are skipped. +// +// Run from a single instance at startup. There is no advisory locking; if you +// need it, gate the call behind your own coordination primitive. +func Migrate(ctx context.Context, db *sql.DB) error { + migrations, err := store.LoadMigrations(migrationsFS, "migrations") + if err != nil { + return fmt.Errorf("pase/sqlite: load migrations: %w", err) + } + + m := &store.Migrator{ + DB: db, + Dialect: store.SQLiteDialect{}, + Migrations: migrations, + // SQLite's TEXT timestamp convention. INTEGER PK is implicit ROWID alias. + CreateTableSQL: `CREATE TABLE IF NOT EXISTS pase_schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL + )`, + } + return m.Migrate(ctx) +} diff --git a/store/sqlite/store.go b/store/sqlite/store.go new file mode 100644 index 0000000..0a586da --- /dev/null +++ b/store/sqlite/store.go @@ -0,0 +1,94 @@ +// Package sqlite provides a SQLite-backed implementation of store.Store. +// Tested with "modernc.org/sqlite" other sqlite drivers may yield different results. +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "git.juancwu.dev/juancwu/pase/store" +) + +// Store is a SQLite-backed implementation of store.Store. +// +// All queries are written with ? placeholders and rebound through the dialect +// at construction time. Every method is intended to be a single round-trip; +// methods that need atomicity (e.g. ConsumeToken) use a single statement or +// an explicit transaction — never read-modify-write through Go. +type Store struct { + db *sql.DB + dialect store.Dialect + + // Pre-rebound query strings. One field per query keeps the SQL text out + // of method bodies and makes it easy to audit by reading the struct. + q store.Queries +} + +// NewStore wraps an existing *sql.DB. It does not own the connection pool; +// the caller is responsible for opening, configuring, and closing it. +// +// Foreign key enforcement is required; configure it on the connection (e.g. +// via DSN `?_pragma=foreign_keys(1)` for modernc.org/sqlite, or +// `?_fk=1` for mattn/go-sqlite3) before passing the *sql.DB in. +func NewStore(db *sql.DB) *Store { + d := store.SQLiteDialect{} + s := &Store{db: db, dialect: d} + + s.q = store.CanonicalQueries.Rebind(d) + + return s +} + +// CreateUser inserts a user row. Timestamps will be overwritten if they are set. +func (s *Store) CreateUser(ctx context.Context, u *store.User) error { + now := time.Now() + u.CreatedAt = now + u.UpdatedAt = now + + _, err := s.db.ExecContext(ctx, s.q.CreateUser, + u.ID, u.Email, u.EmailVerifiedAt, + u.Username, u.UsernameNormalized, + u.DisplayName, u.ProfileImageURL, + u.Status, u.StatusReason, + u.StatusChangedAt, u.StatusExpiresAt, + u.FailedLoginCount, u.LastFailedLoginAt, + u.CreatedAt, u.UpdatedAt, + ) + if err != nil { + errStr := err.Error() + if strings.Contains(errStr, "pase_users.email") { + return fmt.Errorf("pase/sqlite: create user: %w", store.ErrEmailAlreadyExists) + } + if strings.Contains(errStr, "pase_users.username_normalized") { + return fmt.Errorf("pase/sqlite: create user: %w", store.ErrUsernameAlreadyExists) + } + return fmt.Errorf("pase/sqlite: create user: %w", err) + } + return nil +} + +// GetUserByID returns the user with the given id. +func (s *Store) GetUserByID(ctx context.Context, id string) (*store.User, error) { + row := s.db.QueryRowContext(ctx, s.q.GetUserByID, id) + + var u store.User + err := row.Scan( + &u.ID, &u.Email, &u.EmailVerifiedAt, + &u.Username, &u.UsernameNormalized, &u.DisplayName, &u.ProfileImageURL, + &u.Status, &u.StatusReason, + &u.StatusChangedAt, &u.StatusExpiresAt, + &u.FailedLoginCount, &u.LastFailedLoginAt, + &u.CreatedAt, &u.UpdatedAt, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("pase/sqlite: get user by id: %w", store.ErrUserNotFound) + } + return nil, fmt.Errorf("pase/sqlite: get user by id: %w", err) + } + return &u, nil +} diff --git a/store/sqlite/store_test.go b/store/sqlite/store_test.go new file mode 100644 index 0000000..6b41c55 --- /dev/null +++ b/store/sqlite/store_test.go @@ -0,0 +1,71 @@ +package sqlite_test + +import ( + "context" + "database/sql" + "testing" + + "git.juancwu.dev/juancwu/pase/store/sqlite" + "git.juancwu.dev/juancwu/pase/store/storetest" + + // modernc.org/sqlite registers the "sqlite" driver. Pure Go, no CGO. + // Imported only by tests; the sqlite package itself stays driver-agnostic. + _ "modernc.org/sqlite" +) + +// newSQLiteStore opens a fresh in-memory SQLite, applies migrations, and +// returns a Store ready to be exercised by the storetest suite. Each call +// produces an isolated database — no cross-test pollution. +// +// DSN notes: +// - file::memory: form is required so the driver parses query params. +// Plain ":memory:?..." would silently ignore them. +// - _pragma=foreign_keys(1) enables ON DELETE CASCADE on this connection. +// - _time_format=sqlite makes the driver write time.Time as +// "2006-01-02 15:04:05.999999999-07:00", which Go's stdlib can read back +// via sql.NullTime.Scan. +func newSQLiteStore(t *testing.T) storetest.SuiteStore { + t.Helper() + + db, err := sql.Open("sqlite", "file::memory:?_pragma=foreign_keys(1)&_time_format=sqlite") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + // In-memory databases are per-connection by default. Pin the pool to a + // single connection so subsequent calls hit the same database. + db.SetMaxOpenConns(1) + + if err := sqlite.Migrate(context.Background(), db); err != nil { + t.Fatalf("migrate: %v", err) + } + + return sqlite.NewStore(db) +} + +// TestSQLiteStore runs the full Store contract suite against the SQLite +// implementation. Add new dialect-specific tests below; they should be +// rare — anything testable as a contract belongs in storetest. +func TestSQLiteStore(t *testing.T) { + storetest.RunSuite(t, newSQLiteStore) +} + +// TestMigrate_idempotent stays here because migrations are dialect-specific +// (per-package embed.FS) and not part of the Store interface contract. +func TestMigrate_idempotent(t *testing.T) { + db, err := sql.Open("sqlite", "file::memory:?_pragma=foreign_keys(1)&_time_format=sqlite") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + db.SetMaxOpenConns(1) + + ctx := context.Background() + if err := sqlite.Migrate(ctx, db); err != nil { + t.Fatalf("first Migrate: %v", err) + } + if err := sqlite.Migrate(ctx, db); err != nil { + t.Fatalf("second Migrate should succeed, got: %v", err) + } +} diff --git a/store/storetest/storetest.go b/store/storetest/storetest.go new file mode 100644 index 0000000..5ac92df --- /dev/null +++ b/store/storetest/storetest.go @@ -0,0 +1,281 @@ +// Package storetest is the shared contract suite for store.Store +// implementations. Every dialect (sqlite, postgres, future in-memory) wires +// a Factory to RunSuite; passing the suite is the definition of "implements +// the Store contract correctly." +// +// This package is *not* a _test package — it exports helpers so dialect +// test files can call into it. Drivers are imported only by the dialect +// test files; storetest itself stays driver-agnostic. +package storetest + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "git.juancwu.dev/juancwu/pase/store" +) + +// SuiteStore is the slice of store.Store that the contract suite currently +// exercises. It exists so dialect implementations don't have to satisfy the +// full Store interface before passing what's already written — the suite +// grows method-by-method, and SuiteStore grows with it. +// +// When all Store methods have at least one contract test, SuiteStore should +// equal store.Store and Factory can be retyped to return that directly. +type SuiteStore interface { + CreateUser(ctx context.Context, u *store.User) error + GetUserByID(ctx context.Context, id string) (*store.User, error) +} + +// Factory returns a fresh, isolated Store. Each call to the factory must +// produce a Store with no shared state from previous calls — RunSuite calls +// the factory once per subtest. Cleanup is registered via t.Cleanup inside +// the factory. +type Factory func(t *testing.T) SuiteStore + +// RunSuite executes the full Store contract against the implementation +// produced by newStore. Add new test cases here as the Store interface +// grows; every method on the interface should have at least one case. +// +// Test names are stable subtest paths so consumers can target one with +// `go test -run TestSQLiteStore/CreateUser_duplicateEmail`. +func RunSuite(t *testing.T, newStore Factory) { + t.Helper() + + cases := []struct { + name string + fn func(t *testing.T, s SuiteStore) + }{ + {"CreateUser_GetUserByID_roundTrip", testCreateUserGetUserByIDRoundTrip}, + {"GetUserByID_notFound", testGetUserByIDNotFound}, + {"CreateUser_duplicateEmail", testCreateUserDuplicateEmail}, + {"CreateUser_duplicateUsernameNormalized", testCreateUserDuplicateUsernameNormalized}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tc.fn(t, newStore(t)) + }) + } +} + +// --------------------------------------------------------------------------- +// Test cases. Each takes a fresh Store and exercises one aspect of the +// contract. Keep cases self-contained: do not depend on order or on state +// left by a previous case. +// --------------------------------------------------------------------------- + +func testCreateUserGetUserByIDRoundTrip(t *testing.T, s SuiteStore) { + ctx := context.Background() + + want := FixedUser() + if err := s.CreateUser(ctx, want); err != nil { + t.Fatalf("CreateUser: %v", err) + } + + got, err := s.GetUserByID(ctx, want.ID) + if err != nil { + t.Fatalf("GetUserByID: %v", err) + } + + AssertUserEqual(t, got, want) +} + +func testGetUserByIDNotFound(t *testing.T, s SuiteStore) { + got, err := s.GetUserByID(context.Background(), "does-not-exist") + if err == nil { + t.Fatalf("expected error, got user %+v", got) + } + if !errors.Is(err, store.ErrUserNotFound) { + t.Errorf("expected ErrUserNotFound, got: %v", err) + } +} + +func testCreateUserDuplicateEmail(t *testing.T, s SuiteStore) { + ctx := context.Background() + + first := FixedUser() + if err := s.CreateUser(ctx, first); err != nil { + t.Fatalf("first CreateUser: %v", err) + } + + second := FixedUser() + second.ID = "01YYYYYYYYYYYYYYYYYYYYYYYY" + second.UsernameNormalized = NullString("bob") + // Email collides with `first`. Expect ErrEmailAlreadyExists. + + err := s.CreateUser(ctx, second) + if err == nil { + t.Fatal("expected duplicate-email error, got nil") + } + if !errors.Is(err, store.ErrEmailAlreadyExists) { + t.Errorf("expected ErrEmailAlreadyExists, got: %v", err) + } +} + +func testCreateUserDuplicateUsernameNormalized(t *testing.T, s SuiteStore) { + ctx := context.Background() + + first := FixedUser() + if err := s.CreateUser(ctx, first); err != nil { + t.Fatalf("first CreateUser: %v", err) + } + + second := FixedUser() + second.ID = "01ZZZZZZZZZZZZZZZZZZZZZZZZ" + second.Email = "bob@example.com" + // UsernameNormalized still "alice" — should collide. + + err := s.CreateUser(ctx, second) + if err == nil { + t.Fatal("expected duplicate-username error, got nil") + } + if !errors.Is(err, store.ErrUsernameAlreadyExists) { + t.Errorf("expected ErrUsernameAlreadyExists, got: %v", err) + } +} + +// --------------------------------------------------------------------------- +// Fixtures and helpers. Exported so dialect-specific tests can reuse them +// for one-off cases that don't fit into the shared suite. +// --------------------------------------------------------------------------- + +// FixedUser returns a fully-populated User with deterministic values. +// Tests that mutate it should call FixedUser() per call to avoid sharing. +func FixedUser() *store.User { + now := time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC) + verified := now.Add(-time.Hour) + changed := now.Add(-2 * time.Hour) + + return &store.User{ + ID: "01HXXXXXXXXXXXXXXXXXXXXXXX", + Email: "alice@example.com", + EmailVerifiedAt: NullTime(verified), + Username: NullString("Alice"), + UsernameNormalized: NullString("alice"), + DisplayName: NullString("Alice A."), + ProfileImageURL: store.NullString{}, + Status: store.UserStatusActive, + StatusReason: store.NullString{}, + StatusChangedAt: NullTime(changed), + StatusExpiresAt: store.NullTime{}, + FailedLoginCount: 0, + LastFailedLoginAt: store.NullTime{}, + CreatedAt: now, + UpdatedAt: now, + } +} + +// NullString constructs a valid store.NullString. Equivalent to +// `store.NullString{NullString: sql.NullString{String: s, Valid: true}}` +// but readable in fixture tables. +func NullString(s string) store.NullString { + return store.NullString{NullString: sql.NullString{String: s, Valid: true}} +} + +// NullTime constructs a valid store.NullTime. See NullString. +func NullTime(t time.Time) store.NullTime { + return store.NullTime{NullTime: sql.NullTime{Time: t, Valid: true}} +} + +// AssertUserEqual compares all fields of two users, reporting per-field +// errors with t.Errorf. Times use .Equal (handles location/monotonic); +// nullable types use the dedicated equality helpers. +// +// CreatedAt/UpdatedAt are not compared field-equal because the Store mints +// them on insert. They are checked for "populated and recent" instead. +func AssertUserEqual(t *testing.T, got, want *store.User) { + t.Helper() + + if got == nil { + t.Fatal("got nil user") + } + + if got.ID != want.ID { + t.Errorf("ID: got %q, want %q", got.ID, want.ID) + } + if got.Email != want.Email { + t.Errorf("Email: got %q, want %q", got.Email, want.Email) + } + if got.Status != want.Status { + t.Errorf("Status: got %q, want %q", got.Status, want.Status) + } + if got.FailedLoginCount != want.FailedLoginCount { + t.Errorf("FailedLoginCount: got %d, want %d", got.FailedLoginCount, want.FailedLoginCount) + } + + if !NullStringEqual(got.StatusReason, want.StatusReason) { + t.Errorf("StatusReason: got %+v, want %+v", got.StatusReason, want.StatusReason) + } + if !NullStringEqual(got.Username, want.Username) { + t.Errorf("Username: got %+v, want %+v", got.Username, want.Username) + } + if !NullStringEqual(got.UsernameNormalized, want.UsernameNormalized) { + t.Errorf("UsernameNormalized: got %+v, want %+v", got.UsernameNormalized, want.UsernameNormalized) + } + if !NullStringEqual(got.DisplayName, want.DisplayName) { + t.Errorf("DisplayName: got %+v, want %+v", got.DisplayName, want.DisplayName) + } + if !NullStringEqual(got.ProfileImageURL, want.ProfileImageURL) { + t.Errorf("ProfileImageURL: got %+v, want %+v", got.ProfileImageURL, want.ProfileImageURL) + } + + if !NullTimeEqual(got.EmailVerifiedAt, want.EmailVerifiedAt) { + t.Errorf("EmailVerifiedAt: got %+v, want %+v", got.EmailVerifiedAt, want.EmailVerifiedAt) + } + if !NullTimeEqual(got.StatusChangedAt, want.StatusChangedAt) { + t.Errorf("StatusChangedAt: got %+v, want %+v", got.StatusChangedAt, want.StatusChangedAt) + } + if !NullTimeEqual(got.StatusExpiresAt, want.StatusExpiresAt) { + t.Errorf("StatusExpiresAt: got %+v, want %+v", got.StatusExpiresAt, want.StatusExpiresAt) + } + if !NullTimeEqual(got.LastFailedLoginAt, want.LastFailedLoginAt) { + t.Errorf("LastFailedLoginAt: got %+v, want %+v", got.LastFailedLoginAt, want.LastFailedLoginAt) + } + + // CreatedAt/UpdatedAt: store-minted, so don't compare to `want`. Just + // check they were populated and are recent. Loose bound (1 minute) + // avoids flakiness on slow CI. + now := time.Now() + if got.CreatedAt.IsZero() { + t.Error("CreatedAt: not populated") + } + if got.UpdatedAt.IsZero() { + t.Error("UpdatedAt: not populated") + } + if delta := now.Sub(got.CreatedAt); delta < 0 || delta > time.Minute { + t.Errorf("CreatedAt: not recent (delta=%s)", delta) + } + if !got.CreatedAt.Equal(got.UpdatedAt) { + t.Errorf("CreatedAt and UpdatedAt should match on insert: %v vs %v", + got.CreatedAt, got.UpdatedAt) + } +} + +// NullStringEqual reports whether two store.NullString values are equal, +// treating two invalid (NULL) values as equal regardless of String content. +func NullStringEqual(a, b store.NullString) bool { + if a.Valid != b.Valid { + return false + } + if !a.Valid { + return true + } + return a.String == b.String +} + +// NullTimeEqual reports whether two store.NullTime values are equal. +// Time comparison uses time.Time.Equal so location and monotonic clock +// differences don't cause spurious mismatches. +func NullTimeEqual(a, b store.NullTime) bool { + if a.Valid != b.Valid { + return false + } + if !a.Valid { + return true + } + return a.Time.Equal(b.Time) +}