Add user model and SQLite storage
internal/storage/ provides: - Open(path) to create or open the SQLite database with WAL journal, busy timeout, and foreign keys enabled - Embedded migrations that create the users table on first run - UserStore with Create, GetByID, GetByEmail, UpdateLastLogin, Delete - Email normalisation (trim+lowercase) and uniqueness enforcement with ErrEmailTaken - ErrNotFound on lookups and deletes - UUIDv4 IDs auto-generated when caller leaves ID empty Uses modernc.org/sqlite (pure-Go) so the binary stays CGO-free and matches Dockerfile.dev's CGO_ENABLED=0. Tests cover all CRUD operations, email uniqueness (case-insensitive), WAL mode verification, and ErrNotFound paths. Closes #8. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) *UserStore {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db, err := Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
return NewUserStore(db)
|
||||
}
|
||||
|
||||
func TestCreateAndGet(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
u, err := s.Create(ctx, User{Email: "Alice@example.COM", StoragePath: "data/alice"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if u.ID == "" {
|
||||
t.Fatal("expected generated ID")
|
||||
}
|
||||
if u.Email != "alice@example.com" {
|
||||
t.Errorf("email not normalised: %q", u.Email)
|
||||
}
|
||||
|
||||
got, err := s.GetByID(ctx, u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id: %v", err)
|
||||
}
|
||||
if got.Email != u.Email || got.StoragePath != u.StoragePath {
|
||||
t.Errorf("mismatch: %+v vs %+v", got, u)
|
||||
}
|
||||
|
||||
got2, err := s.GetByEmail(ctx, " ALICE@example.com ")
|
||||
if err != nil {
|
||||
t.Fatalf("get by email: %v", err)
|
||||
}
|
||||
if got2.ID != u.ID {
|
||||
t.Errorf("email lookup id mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailUniqueness(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
_, err := s.Create(ctx, User{Email: "dup@example.com", StoragePath: "data/dup"})
|
||||
if err != nil {
|
||||
t.Fatalf("first create: %v", err)
|
||||
}
|
||||
_, err = s.Create(ctx, User{Email: "DUP@example.com", StoragePath: "data/dup2"})
|
||||
if !errors.Is(err, ErrEmailTaken) {
|
||||
t.Fatalf("expected ErrEmailTaken, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateLastLogin(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
u, err := s.Create(ctx, User{Email: "login@example.com", StoragePath: "data/login"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u.LastLoginAt != nil {
|
||||
t.Errorf("expected nil last_login on create")
|
||||
}
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
if err := s.UpdateLastLogin(ctx, u.ID, now); err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
got, _ := s.GetByID(ctx, u.ID)
|
||||
if got.LastLoginAt == nil || !got.LastLoginAt.Equal(now) {
|
||||
t.Errorf("last_login mismatch: %v vs %v", got.LastLoginAt, now)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateLastLoginMissing(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
err := s.UpdateLastLogin(context.Background(), "nope", time.Now())
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
u, _ := s.Create(ctx, User{Email: "del@example.com", StoragePath: "data/del"})
|
||||
if err := s.Delete(ctx, u.ID); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
_, err := s.GetByID(ctx, u.ID)
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("expected ErrNotFound after delete, got %v", err)
|
||||
}
|
||||
if err := s.Delete(ctx, u.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("expected ErrNotFound on second delete, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMissing(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
if _, err := s.GetByID(context.Background(), "missing"); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("got %v", err)
|
||||
}
|
||||
if _, err := s.GetByEmail(context.Background(), "nobody@example.com"); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWALMode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db, err := Open(filepath.Join(dir, "wal.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
var mode string
|
||||
if err := db.QueryRow("PRAGMA journal_mode").Scan(&mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mode != "wal" {
|
||||
t.Errorf("expected WAL mode, got %q", mode)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user