Files
libretechandClaude Opus 4.7 ea867afe8e 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>
2026-04-28 22:13:28 +02:00

135 lines
3.4 KiB
Go

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)
}
}