Files
sqlite/sqlite_test.go
Rene Nochebuena 237cba9bad feat(sqlite): initial stable release v0.9.0
Pure-Go CGO-free SQLite client with launcher lifecycle, write-mutex serialisation, health check, unit-of-work via context injection, and structured error mapping.

What's included:
- Executor / Tx / Client / Component interfaces using database/sql native types
- Tx.Commit() / Tx.Rollback() without ctx, matching the honest database/sql contract
- New(logger, cfg) constructor; database opened in OnInit
- Config struct with env-tag support; default Pragmas: WAL + 5s busy timeout + FK enforcement
- PRAGMA foreign_keys = ON enforced explicitly in OnInit
- writeMu sync.Mutex acquired by UnitOfWork.Do to serialise writes and prevent SQLITE_BUSY
- UnitOfWork via context injection; GetExecutor(ctx) returns active Tx or *sql.DB
- HandleError mapping SQLite extended error codes to xerrors codes (unique/primary-key → AlreadyExists, foreign-key → InvalidInput, ErrNoRows → NotFound)
- health.Checkable at LevelCritical; pure-Go modernc.org/sqlite driver (CGO_ENABLED=0 compatible)

Tested-via: todo-api POC integration
Reviewed-against: docs/adr/
2026-03-19 13:25:31 +00:00

271 lines
7.2 KiB
Go

package sqlite
import (
"context"
"database/sql"
"errors"
"sync"
"testing"
"code.nochebuena.dev/go/health"
"code.nochebuena.dev/go/logz"
"code.nochebuena.dev/go/xerrors"
)
func newLogger() logz.Logger { return logz.New(logz.Options{}) }
func newMemDB(t *testing.T) Component {
t.Helper()
c := New(newLogger(), Config{Path: ":memory:", MaxOpenConns: 1, MaxIdleConns: 1,
Pragmas: "?_fk=true"})
if err := c.OnInit(); err != nil {
t.Fatalf("OnInit: %v", err)
}
return c
}
// --- New / name / priority ---
func TestNew(t *testing.T) {
if New(newLogger(), Config{Path: ":memory:"}) == nil {
t.Fatal("New returned nil")
}
}
func TestComponent_Name(t *testing.T) {
c := New(newLogger(), Config{Path: ":memory:"}).(health.Checkable)
if c.Name() != "sqlite" {
t.Errorf("want sqlite, got %s", c.Name())
}
}
func TestComponent_Priority(t *testing.T) {
c := New(newLogger(), Config{Path: ":memory:"}).(health.Checkable)
if c.Priority() != health.LevelCritical {
t.Error("Priority() != LevelCritical")
}
}
func TestComponent_OnStop_NilDB(t *testing.T) {
c := &sqliteComponent{logger: newLogger()}
if err := c.OnStop(); err != nil {
t.Errorf("OnStop with nil db: %v", err)
}
}
func TestComponent_FullLifecycle(t *testing.T) {
c := New(newLogger(), Config{Path: ":memory:", Pragmas: ""})
if err := c.OnInit(); err != nil {
t.Fatalf("OnInit: %v", err)
}
if err := c.OnStart(); err != nil {
t.Fatalf("OnStart: %v", err)
}
if err := c.OnStop(); err != nil {
t.Fatalf("OnStop: %v", err)
}
}
// --- Exec / Query / QueryRow ---
func TestComponent_Exec(t *testing.T) {
c := newMemDB(t)
exec := c.GetExecutor(context.Background())
_, err := exec.ExecContext(context.Background(),
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
if err != nil {
t.Fatalf("create table: %v", err)
}
res, err := exec.ExecContext(context.Background(), "INSERT INTO t VALUES (1, 'hello')")
if err != nil {
t.Fatalf("insert: %v", err)
}
n, _ := res.RowsAffected()
if n != 1 {
t.Errorf("want 1 row affected, got %d", n)
}
}
func TestComponent_Query(t *testing.T) {
c := newMemDB(t)
exec := c.GetExecutor(context.Background())
_, _ = exec.ExecContext(context.Background(), "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
_, _ = exec.ExecContext(context.Background(), "INSERT INTO t VALUES (1, 'hello')")
rows, err := exec.QueryContext(context.Background(), "SELECT name FROM t")
if err != nil {
t.Fatalf("query: %v", err)
}
defer rows.Close()
if !rows.Next() {
t.Fatal("expected a row")
}
var name string
if err := rows.Scan(&name); err != nil {
t.Fatalf("scan: %v", err)
}
if name != "hello" {
t.Errorf("want hello, got %s", name)
}
}
func TestComponent_QueryRow(t *testing.T) {
c := newMemDB(t)
exec := c.GetExecutor(context.Background())
_, _ = exec.ExecContext(context.Background(), "CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT)")
_, _ = exec.ExecContext(context.Background(), "INSERT INTO t VALUES (1, 'world')")
var val string
if err := exec.QueryRowContext(context.Background(), "SELECT val FROM t WHERE id=1").Scan(&val); err != nil {
t.Fatalf("scan: %v", err)
}
if val != "world" {
t.Errorf("want world, got %s", val)
}
}
// --- HandleError ---
func TestHandleError_Nil(t *testing.T) {
if err := HandleError(nil); err != nil {
t.Errorf("want nil, got %v", err)
}
}
func TestHandleError_NoRows(t *testing.T) {
assertCode(t, HandleError(sql.ErrNoRows), xerrors.ErrNotFound)
}
func TestHandleError_Generic(t *testing.T) {
assertCode(t, HandleError(errors.New("oops")), xerrors.ErrInternal)
}
func TestHandleError_UniqueConstraint(t *testing.T) {
c := newMemDB(t)
exec := c.GetExecutor(context.Background())
_, _ = exec.ExecContext(context.Background(), "CREATE TABLE t (id INTEGER PRIMARY KEY)")
_, _ = exec.ExecContext(context.Background(), "INSERT INTO t VALUES (1)")
_, err := exec.ExecContext(context.Background(), "INSERT INTO t VALUES (1)")
if err == nil {
t.Fatal("expected unique constraint error")
}
mapped := HandleError(err)
assertCode(t, mapped, xerrors.ErrAlreadyExists)
}
func TestHandleError_ForeignKey(t *testing.T) {
c := newMemDB(t)
exec := c.GetExecutor(context.Background())
_, _ = exec.ExecContext(context.Background(),
"CREATE TABLE parent (id INTEGER PRIMARY KEY)")
_, _ = exec.ExecContext(context.Background(),
"CREATE TABLE child (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent(id))")
_, err := exec.ExecContext(context.Background(),
"INSERT INTO child VALUES (1, 999)")
if err == nil {
t.Fatal("expected foreign key error")
}
mapped := HandleError(err)
assertCode(t, mapped, xerrors.ErrInvalidInput)
}
// --- UnitOfWork ---
func TestUnitOfWork_Commit(t *testing.T) {
c := newMemDB(t)
exec := c.GetExecutor(context.Background())
_, _ = exec.ExecContext(context.Background(), "CREATE TABLE t (id INTEGER PRIMARY KEY)")
uow := NewUnitOfWork(newLogger(), c)
err := uow.Do(context.Background(), func(ctx context.Context) error {
e := c.GetExecutor(ctx)
_, err := e.ExecContext(ctx, "INSERT INTO t VALUES (42)")
return err
})
if err != nil {
t.Fatalf("Do: %v", err)
}
// verify committed
var id int
_ = exec.QueryRowContext(context.Background(), "SELECT id FROM t WHERE id=42").Scan(&id)
if id != 42 {
t.Error("row not committed")
}
}
func TestUnitOfWork_Rollback(t *testing.T) {
c := newMemDB(t)
exec := c.GetExecutor(context.Background())
_, _ = exec.ExecContext(context.Background(), "CREATE TABLE t (id INTEGER PRIMARY KEY)")
uow := NewUnitOfWork(newLogger(), c)
_ = uow.Do(context.Background(), func(ctx context.Context) error {
e := c.GetExecutor(ctx)
_, _ = e.ExecContext(ctx, "INSERT INTO t VALUES (99)")
return errors.New("abort")
})
var count int
_ = exec.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM t").Scan(&count)
if count != 0 {
t.Error("row should have been rolled back")
}
}
func TestUnitOfWork_InjectsExecutor(t *testing.T) {
c := newMemDB(t)
uow := NewUnitOfWork(newLogger(), c)
var got Executor
_ = uow.Do(context.Background(), func(ctx context.Context) error {
got = c.GetExecutor(ctx)
return nil
})
if got == nil {
t.Error("GetExecutor should return a Tx inside Do")
}
// Outside Do, should return the pool
pool := c.GetExecutor(context.Background())
if got == pool {
t.Error("inside Do should be a Tx, not the pool")
}
}
func TestUnitOfWork_WriteMutex(t *testing.T) {
c := newMemDB(t)
exec := c.GetExecutor(context.Background())
_, _ = exec.ExecContext(context.Background(), "CREATE TABLE t (id INTEGER PRIMARY KEY)")
uow := NewUnitOfWork(newLogger(), c)
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
i := i
go func() {
defer wg.Done()
_ = uow.Do(context.Background(), func(ctx context.Context) error {
e := c.GetExecutor(ctx)
_, err := e.ExecContext(ctx, "INSERT INTO t VALUES (?)", i)
return err
})
}()
}
wg.Wait()
var count int
_ = exec.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM t").Scan(&count)
if count != 5 {
t.Errorf("expected 5 rows, got %d", count)
}
}
// --- helpers ---
func assertCode(t *testing.T, err error, want xerrors.Code) {
t.Helper()
var xe *xerrors.Err
if !errors.As(err, &xe) {
t.Fatalf("expected *xerrors.Err, got %T: %v", err, err)
}
if xe.Code() != want {
t.Errorf("want %s, got %s", want, xe.Code())
}
}