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:
2026-04-28 22:13:28 +02:00
co-authored by Claude Opus 4.7
parent 3b3eb1b201
commit ea867afe8e
5 changed files with 379 additions and 4 deletions
+14 -3
View File
@@ -1,18 +1,29 @@
module git.librete.ch/public/librenotes
go 1.20
go 1.25.0
require (
github.com/google/uuid v1.6.0
github.com/junegunn/fzf v0.58.0
modernc.org/sqlite v1.50.0
)
require (
github.com/charlievieth/fastwalk v1.0.9 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gdamore/encoding v1.0.1 // indirect
github.com/gdamore/tcell/v2 v2.8.1 // indirect
github.com/junegunn/fzf v0.58.0 // indirect
github.com/junegunn/go-shellwords v0.0.0-20240813092932-a62c48c52e97 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
modernc.org/libc v1.72.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+48 -1
View File
@@ -1,10 +1,18 @@
github.com/charlievieth/fastwalk v1.0.9 h1:Odb92AfoReO3oFBfDGT5J+nwgzQPF/gWAw6E6/lkor0=
github.com/charlievieth/fastwalk v1.0.9/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
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/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU=
github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/junegunn/fzf v0.58.0 h1:sT6lO4OTkHpEHpr8E1iZz6bvxZ6URHjTYl8/yhS8s1U=
github.com/junegunn/fzf v0.58.0/go.mod h1:IsDYaa3WFbMYYi8yp92fQFTqN10hs3nH4OMBiz/kJXo=
github.com/junegunn/go-shellwords v0.0.0-20240813092932-a62c48c52e97 h1:rqzLixVo1c/GQW6px9j1xQmlvQIn+lf/V6M1UQ7IFzw=
@@ -15,6 +23,10 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
@@ -30,6 +42,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -45,6 +59,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -56,8 +72,9 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -84,4 +101,34 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
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/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+50
View File
@@ -0,0 +1,50 @@
// Package storage provides the SQLite-backed persistence layer for
// multi-tenant user state. It owns the database connection, migrations,
// and CRUD repositories.
package storage
import (
"database/sql"
"fmt"
_ "modernc.org/sqlite"
)
// Open opens (or creates) the SQLite database at path, configures WAL
// mode and pragmas suitable for concurrent reads, and applies migrations.
func Open(path string) (*sql.DB, error) {
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)", path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
if err := db.Ping(); err != nil {
_ = db.Close()
return nil, fmt.Errorf("ping sqlite: %w", err)
}
if err := migrate(db); err != nil {
_ = db.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
return db, nil
}
func migrate(db *sql.DB) error {
for _, stmt := range migrations {
if _, err := db.Exec(stmt); err != nil {
return fmt.Errorf("exec migration: %w\nstmt: %s", err, stmt)
}
}
return nil
}
var migrations = []string{
`CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL,
last_login_at INTEGER,
storage_path TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`,
}
+133
View File
@@ -0,0 +1,133 @@
package storage
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
// User represents a tenant of the librenotes service.
type User struct {
ID string
Email string
CreatedAt time.Time
LastLoginAt *time.Time
StoragePath string
}
// ErrNotFound indicates that a user lookup did not match any row.
var ErrNotFound = errors.New("user not found")
// ErrEmailTaken indicates that the given email already maps to a user.
var ErrEmailTaken = errors.New("email already registered")
// UserStore is the persistence interface for users. Implementations are
// expected to be safe for concurrent use.
type UserStore struct {
db *sql.DB
}
// NewUserStore wraps a database handle.
func NewUserStore(db *sql.DB) *UserStore { return &UserStore{db: db} }
// Create inserts a new user. The ID is generated as a UUIDv4 if empty.
// Email is normalised (trimmed + lowercased) before insertion. The
// StoragePath is preserved as given so the caller can decide on the
// filesystem layout.
func (s *UserStore) Create(ctx context.Context, u User) (User, error) {
if u.ID == "" {
u.ID = uuid.NewString()
}
u.Email = normaliseEmail(u.Email)
if u.Email == "" {
return User{}, fmt.Errorf("email required")
}
if u.StoragePath == "" {
return User{}, fmt.Errorf("storage_path required")
}
if u.CreatedAt.IsZero() {
u.CreatedAt = time.Now().UTC()
}
const q = `INSERT INTO users (id, email, created_at, last_login_at, storage_path)
VALUES (?, ?, ?, ?, ?)`
var lastLogin sql.NullInt64
if u.LastLoginAt != nil {
lastLogin = sql.NullInt64{Int64: u.LastLoginAt.Unix(), Valid: true}
}
_, err := s.db.ExecContext(ctx, q, u.ID, u.Email, u.CreatedAt.Unix(), lastLogin, u.StoragePath)
if err != nil {
if isUniqueErr(err) {
return User{}, ErrEmailTaken
}
return User{}, fmt.Errorf("insert user: %w", err)
}
return u, nil
}
// GetByID fetches a user by primary key.
func (s *UserStore) GetByID(ctx context.Context, id string) (User, error) {
return s.scanOne(ctx, `SELECT id, email, created_at, last_login_at, storage_path FROM users WHERE id = ?`, id)
}
// GetByEmail fetches a user by their (normalised) email.
func (s *UserStore) GetByEmail(ctx context.Context, email string) (User, error) {
return s.scanOne(ctx, `SELECT id, email, created_at, last_login_at, storage_path FROM users WHERE email = ?`, normaliseEmail(email))
}
// UpdateLastLogin records a successful login at the given instant.
func (s *UserStore) UpdateLastLogin(ctx context.Context, id string, at time.Time) error {
res, err := s.db.ExecContext(ctx, `UPDATE users SET last_login_at = ? WHERE id = ?`, at.Unix(), id)
if err != nil {
return fmt.Errorf("update last_login: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
// Delete removes a user row. Returns ErrNotFound if no row matched.
func (s *UserStore) Delete(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete user: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
func (s *UserStore) scanOne(ctx context.Context, q string, args ...any) (User, error) {
var u User
var created int64
var lastLogin sql.NullInt64
err := s.db.QueryRowContext(ctx, q, args...).Scan(&u.ID, &u.Email, &created, &lastLogin, &u.StoragePath)
if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrNotFound
}
if err != nil {
return User{}, fmt.Errorf("scan user: %w", err)
}
u.CreatedAt = time.Unix(created, 0).UTC()
if lastLogin.Valid {
t := time.Unix(lastLogin.Int64, 0).UTC()
u.LastLoginAt = &t
}
return u, nil
}
func normaliseEmail(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
func isUniqueErr(err error) bool {
// modernc.org/sqlite returns errors whose Error() text contains
// "UNIQUE constraint failed". This is stable across versions.
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
}
+134
View File
@@ -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)
}
}