feat: initial sqlite store
This commit is contained in:
parent
52814db43d
commit
9918c9918f
10 changed files with 560 additions and 6 deletions
14
go.mod
14
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
|
||||
)
|
||||
|
|
|
|||
21
go.sum
21
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=
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ type User struct {
|
|||
ProfileImageURL NullString
|
||||
|
||||
Status UserStatus
|
||||
StatusReason string
|
||||
StatusReason NullString
|
||||
StatusChangedAt NullTime
|
||||
StatusExpiresAt NullTime
|
||||
|
||||
|
|
|
|||
33
store/queries.go
Normal file
33
store/queries.go
Normal file
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
38
store/sqlite/migrations.go
Normal file
38
store/sqlite/migrations.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
94
store/sqlite/store.go
Normal file
94
store/sqlite/store.go
Normal file
|
|
@ -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
|
||||
}
|
||||
71
store/sqlite/store_test.go
Normal file
71
store/sqlite/store_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
281
store/storetest/storetest.go
Normal file
281
store/storetest/storetest.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue