From ea867afe8e3d18c91e62c64b69c2e5a62d8cc157 Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Tue, 28 Apr 2026 22:13:28 +0200 Subject: [PATCH] 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) --- go.mod | 17 ++++- go.sum | 49 +++++++++++- internal/storage/db.go | 50 ++++++++++++ internal/storage/users.go | 133 ++++++++++++++++++++++++++++++++ internal/storage/users_test.go | 134 +++++++++++++++++++++++++++++++++ 5 files changed, 379 insertions(+), 4 deletions(-) create mode 100644 internal/storage/db.go create mode 100644 internal/storage/users.go create mode 100644 internal/storage/users_test.go diff --git a/go.mod b/go.mod index 0dfa610..29168b9 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index b1edd03..3a10256 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/storage/db.go b/internal/storage/db.go new file mode 100644 index 0000000..f28843f --- /dev/null +++ b/internal/storage/db.go @@ -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)`, +} diff --git a/internal/storage/users.go b/internal/storage/users.go new file mode 100644 index 0000000..815aa55 --- /dev/null +++ b/internal/storage/users.go @@ -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") +} diff --git a/internal/storage/users_test.go b/internal/storage/users_test.go new file mode 100644 index 0000000..96cc78d --- /dev/null +++ b/internal/storage/users_test.go @@ -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) + } +}