feat: initial sqlite store
This commit is contained in:
parent
52814db43d
commit
9918c9918f
10 changed files with 560 additions and 6 deletions
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