pase/store/storetest/storetest.go
2026-05-06 01:09:47 +00:00

357 lines
12 KiB
Go

// 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)
GetUserByEmail(ctx context.Context, email string) (*store.User, error)
GetUserByUsername(ctx context.Context, username 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},
{"CreateUser_GetUserByEmail_roundTrip", testCreateUserGetUserByEmailRoundTrip},
{"GetUserByEmail_notFound", testGetUserByEmailNotFound},
{"CreateUser_GetUserByUsername_roundTrip", testCreateUserGetUserByUsernameRoundTrip},
{"GetUserByUsername_notFound", testGetUserByUsernameNotFound},
{"GetUserByUsername_notNormalized_notFound", testGetUserByUsernameNotNormalized},
}
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)
}
}
func testCreateUserGetUserByEmailRoundTrip(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.GetUserByEmail(ctx, want.Email)
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
AssertUserEqual(t, got, want)
}
func testGetUserByEmailNotFound(t *testing.T, s SuiteStore) {
got, err := s.GetUserByEmail(context.Background(), "does-not-exist@mail.com")
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 testCreateUserGetUserByUsernameRoundTrip(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.GetUserByUsername(ctx, want.UsernameNormalized.String)
if err != nil {
t.Fatalf("GetUserByUsername (normalized username): %v", err)
}
AssertUserEqual(t, got, want)
}
func testGetUserByUsernameNotFound(t *testing.T, s SuiteStore) {
got, err := s.GetUserByUsername(context.Background(), "does-not-exists")
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 testGetUserByUsernameNotNormalized(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.GetUserByUsername(ctx, want.Username.String)
if err == nil {
t.Fatalf("expected error, got user %+v", got)
}
if !errors.Is(err, store.ErrUserNotFound) {
t.Errorf("expected ErrUserNotFound, 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)
}