v0.10.0: ports and adapters refactor
pipeline / test (push) Has been cancelled
pipeline / build (push) Has been cancelled

- Extract shared manifest types into internal/manifest/types leaf package.
- Extract SQLite adapter into internal/manifest/sqlite.
- Extract JSONL adapter into internal/manifest/jsonl.
- Isolate modernc.org/sqlite import to sqlite/adapter.go.
- Add adapter-backed registry with manifest.Default.
- Adapter-agnostic ConvertManifest in types/.
- MemoryAdapter for in-memory manifest testing.
- CLI uses manifest.Default registry directly.
- SQLite LogWriter type assertion moved into SQLiteAdapter.
- Manifest interface includes Entries(); EntryReader removed.
- No behavior changes. 100% coverage across all 6 packages.
This commit is contained in:
Ein Anderssono
2026-06-15 08:27:38 +02:00
parent 9cd048d9f3
commit c9ac014473
28 changed files with 2061 additions and 927 deletions
+21
View File
@@ -0,0 +1,21 @@
package sqlite
import (
_ "modernc.org/sqlite"
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
)
type Adapter struct{}
func (Adapter) Format() types.Format { return types.FormatSQLite }
func (Adapter) Aliases() []string { return []string{"db", "sqlite3"} }
func (Adapter) Path(dir string) string { return Path(dir) }
func (Adapter) Exists(dir string) bool { return FileExists(Path(dir)) }
func (Adapter) Open(dir string) (types.Manifest, error) { return Load(dir) }
func (Adapter) OpenLogWriter(m types.Manifest, dir string) (types.LogWriter, error) {
if sm, ok := m.(*Store); ok && sm.DB() != nil {
return NewLogWriter(sm.DB())
}
return types.NewFileLogWriter(types.LogPath(dir))
}
+290
View File
@@ -0,0 +1,290 @@
package sqlite
import (
"database/sql"
"fmt"
"path/filepath"
"testing"
_ "modernc.org/sqlite"
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
)
func TestAdapter(t *testing.T) {
a := Adapter{}
if a.Format() != types.FormatSQLite {
t.Fatal("expected SQLite format")
}
if len(a.Aliases()) != 2 {
t.Fatal("expected 2 aliases")
}
dir := t.TempDir()
if a.Path(dir) != Path(dir) {
t.Fatal("expected path match")
}
if a.Exists(dir) {
t.Fatal("expected not to exist in empty dir")
}
m, err := a.Open(dir)
if err != nil {
t.Fatal(err)
}
m.Close()
w, err := a.OpenLogWriter(nil, dir)
if err != nil {
t.Fatal(err)
}
w.Close()
}
func TestAdapterOpenLogWriterSQLite(t *testing.T) {
dir := t.TempDir()
m, err := Load(dir)
if err != nil {
t.Fatal(err)
}
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
defer m.Close()
w, err := Adapter{}.OpenLogWriter(m, dir)
if err != nil {
t.Fatal(err)
}
defer w.Close()
if _, ok := w.(*LogWriter); !ok {
t.Fatalf("expected sqlite log writer, got %T", w)
}
}
func TestStoreLoadEmpty(t *testing.T) {
m, err := Load(t.TempDir())
if err != nil {
t.Fatal(err)
}
if m == nil {
t.Fatal("expected non-nil store")
}
m.Close()
}
func TestStoreLoadNonexistent(t *testing.T) {
m, err := Load("/nonexistent/path")
if err != nil {
t.Fatal(err)
}
if m == nil {
t.Fatal("expected non-nil store")
}
m.Close()
}
func TestStoreAddHasSaveClose(t *testing.T) {
m, err := Load(t.TempDir())
if err != nil {
t.Fatal(err)
}
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("sid1", "sfile.jpg", 99, "azure")
if !m.Has("sid1") {
t.Fatal("expected Has to return true")
}
if m.Has("nope") {
t.Fatal("expected Has to return false")
}
if err := m.Save(); err != nil {
t.Fatal(err)
}
m.Close()
}
func TestStoreRoundTrip(t *testing.T) {
dir := t.TempDir()
m, err := Load(dir)
if err != nil {
t.Fatal(err)
}
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("rid1", "rfile1.jpg", 10, "aws")
m.Add("rid2", "rfile2.jpg", 20, "gcs")
if err := m.Save(); err != nil {
t.Fatal(err)
}
m.Close()
m2, err := Load(dir)
if err != nil {
t.Fatal(err)
}
if err := m2.OpenAppend(); err != nil {
t.Fatal(err)
}
defer m2.Close()
if !m2.Has("rid1") {
t.Fatal("expected rid1 after reload")
}
if !m2.Has("rid2") {
t.Fatal("expected rid2 after reload")
}
}
func TestStoreEntries(t *testing.T) {
m, err := Load(t.TempDir())
if err != nil {
t.Fatal(err)
}
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
defer m.Close()
m.Add("se1", "sf1.jpg", 1, "sc1")
m.Add("se2", "sf2.jpg", 2, "sc2")
entries := m.Entries()
if len(entries) != 2 {
t.Fatalf("expected 2 entries, got %d", len(entries))
}
}
func TestStoreCloseIdempotent(t *testing.T) {
m, err := Load(t.TempDir())
if err != nil {
t.Fatal(err)
}
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Close()
m.Close()
}
func TestStoreOpenAppendIdempotent(t *testing.T) {
m, err := Load(t.TempDir())
if err != nil {
t.Fatal(err)
}
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Close()
}
func TestStoreManifestFormat(t *testing.T) {
m, err := Load(t.TempDir())
if err != nil {
t.Fatal(err)
}
if m.ManifestFormat() != types.FormatSQLite {
t.Fatal("expected SQLite format")
}
}
func TestStoreSave(t *testing.T) {
m, err := Load(t.TempDir())
if err != nil {
t.Fatal(err)
}
if err := m.Save(); err != nil {
t.Fatal(err)
}
}
func TestStoreOpenAppendCreateDir(t *testing.T) {
dir := t.TempDir()
subdir := filepath.Join(dir, "subdir")
m, err := Load(subdir)
if err != nil {
t.Fatal(err)
}
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Close()
}
func TestStoreCloseWithoutOpen(t *testing.T) {
m, err := Load(t.TempDir())
if err != nil {
t.Fatal(err)
}
m.Close()
}
func TestStoreEntriesWithoutOpen(t *testing.T) {
m, err := Load(t.TempDir())
if err != nil {
t.Fatal(err)
}
entries := m.Entries()
if entries != nil {
t.Errorf("Entries without open should return nil, got %v", entries)
}
}
func TestStoreHasWithoutOpen(t *testing.T) {
m, err := Load(t.TempDir())
if err != nil {
t.Fatal(err)
}
if m.Has("x") {
t.Fatal("Has should return false without open")
}
}
func TestStoreAddEntryWithoutOpen(t *testing.T) {
m, err := Load(t.TempDir())
if err != nil {
t.Fatal(err)
}
m.AddEntry(types.Entry{ID: "x", Filename: "f.jpg"})
}
func TestSetOpener(t *testing.T) {
old := SetOpener(func(driverName, dataSourceName string) (*sql.DB, error) {
return nil, fmt.Errorf("test")
})
if old != nil {
t.Fatal("expected nil old opener")
}
restore := SetOpener(old)
if restore == nil {
t.Fatal("expected non-nil restore")
}
SetOpener(nil)
}
func TestFileExists(t *testing.T) {
dir := t.TempDir()
if FileExists(dir) {
t.Fatal("expected false for directory")
}
if FileExists("/nonexistent/file") {
t.Fatal("expected false for nonexistent")
}
}
func TestLogWriterClose(t *testing.T) {
w := &LogWriter{}
w.Close()
}
func TestOpenerWithOverride(t *testing.T) {
SetOpener(func(driverName, dataSourceName string) (*sql.DB, error) {
return nil, nil
})
if opener() == nil {
t.Fatal("expected override")
}
SetOpener(nil)
}
func TestLogWriterCloseDirect(t *testing.T) {
(&LogWriter{}).Close()
}
+43
View File
@@ -0,0 +1,43 @@
package sqlite
import (
"database/sql"
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
)
type LogWriter struct {
db *sql.DB
}
func NewLogWriter(db *sql.DB) (types.LogWriter, error) {
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
level TEXT NOT NULL,
event TEXT NOT NULL,
asset_id TEXT,
album TEXT,
filename TEXT,
size INTEGER,
cloud TEXT,
duration_ms INTEGER,
message TEXT
)`)
if err != nil {
return nil, err
}
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs(ts)`)
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_logs_event ON logs(event)`)
return &LogWriter{db: db}, nil
}
func (w *LogWriter) Log(e types.LogEntry) {
if w.db == nil {
return
}
w.db.Exec(`INSERT INTO logs (ts, level, event, asset_id, album, filename, size, cloud, duration_ms, message) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.Timestamp, e.Level, e.Event, e.AssetID, e.Album, e.Filename, e.Size, e.Cloud, e.DurationMs, e.Message)
}
func (w *LogWriter) Close() { _ = w }
+165
View File
@@ -0,0 +1,165 @@
package sqlite
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
)
type OpenerFunc func(driverName, dataSourceName string) (*sql.DB, error)
var openerOverride OpenerFunc
func SetOpener(fn OpenerFunc) (old OpenerFunc) {
old = openerOverride
openerOverride = fn
return old
}
type Store struct {
path string
db *sql.DB
open OpenerFunc
execFunc func(query string, args ...any) (sql.Result, error)
}
func Path(dir string) string {
return filepath.Join(dir, "downloads.db")
}
func Load(dir string) (*Store, error) {
m := &Store{
path: Path(dir),
open: opener(),
}
return m, nil
}
func opener() OpenerFunc {
if openerOverride != nil {
return openerOverride
}
return sql.Open
}
func (m *Store) OpenAppend() error {
if m.db != nil {
return nil
}
if err := os.MkdirAll(filepath.Dir(m.path), 0755); err != nil {
return err
}
opener := m.open
if opener == nil {
opener = sql.Open
}
db, err := opener("sqlite", m.path)
if err != nil {
return fmt.Errorf("open sqlite: %w", err)
}
execFn := m.execFunc
if execFn == nil {
execFn = db.Exec
}
_, err = execFn(`CREATE TABLE IF NOT EXISTS downloads (
id TEXT PRIMARY KEY,
filename TEXT NOT NULL DEFAULT '',
path TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
cloud TEXT NOT NULL DEFAULT '',
checksum TEXT NOT NULL DEFAULT '',
exported INTEGER NOT NULL DEFAULT 0
)`)
if err != nil {
db.Close()
return fmt.Errorf("create table: %w", err)
}
_, _ = execFn(`ALTER TABLE downloads ADD COLUMN path TEXT NOT NULL DEFAULT ''`)
_, _ = execFn(`ALTER TABLE downloads ADD COLUMN checksum TEXT NOT NULL DEFAULT ''`)
_, err = execFn(`CREATE INDEX IF NOT EXISTS idx_downloads_id ON downloads(id)`)
if err != nil {
db.Close()
return fmt.Errorf("create index: %w", err)
}
m.db = db
return nil
}
func (m *Store) DB() *sql.DB { return m.db }
func (m *Store) SetOpen(fn OpenerFunc) { m.open = fn }
func (m *Store) SetExecFunc(fn func(query string, args ...any) (sql.Result, error)) {
m.execFunc = fn
}
func (m *Store) Has(id string) bool {
if m.db == nil {
return false
}
var count int
err := m.db.QueryRow(`SELECT COUNT(*) FROM downloads WHERE id = ?`, id).Scan(&count)
if err != nil {
return false
}
return count > 0
}
func (m *Store) ManifestFormat() types.Format {
return types.FormatSQLite
}
func (m *Store) Add(id string, filename string, size int64, cloud string) {
m.AddEntry(types.NewEntry(id, filename, filename, size, cloud))
}
func (m *Store) AddEntry(entry types.Entry) {
if m.db == nil {
return
}
if entry.Path == "" {
entry.Path = entry.Filename
}
m.db.Exec(`INSERT OR REPLACE INTO downloads (id, filename, path, size, cloud, checksum, exported) VALUES (?, ?, ?, ?, ?, ?, ?)`,
entry.ID, entry.Filename, entry.Path, entry.Size, entry.Cloud, entry.Checksum, entry.Exported)
}
func (m *Store) Save() error { return nil }
func (m *Store) Close() {
if m.db != nil {
m.db.Close()
m.db = nil
}
}
func (m *Store) Entries() map[string]types.Entry {
if m.db == nil {
return nil
}
out := make(map[string]types.Entry)
rows, err := m.db.Query(`SELECT id, filename, path, size, cloud, checksum, exported FROM downloads`)
if err != nil {
return out
}
defer rows.Close()
for rows.Next() {
var e types.Entry
if err := rows.Scan(&e.ID, &e.Filename, &e.Path, &e.Size, &e.Cloud, &e.Checksum, &e.Exported); err == nil {
if e.Path == "" {
e.Path = e.Filename
}
out[e.ID] = e
}
}
return out
}
func FileExists(path string) bool {
info, err := os.Stat(path)
if err != nil {
return false
}
return !info.IsDir()
}
+323
View File
@@ -0,0 +1,323 @@
package sqlite
import (
"database/sql"
"fmt"
"path/filepath"
"strings"
"testing"
_ "modernc.org/sqlite"
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
)
func TestStoreOpenAppendSQLError(t *testing.T) {
dir := t.TempDir()
m, err := Load(dir)
if err != nil {
t.Fatal(err)
}
m.SetOpen(func(driverName, dataSourceName string) (*sql.DB, error) {
return nil, fmt.Errorf("simulated open error")
})
if err := m.OpenAppend(); err == nil {
t.Error("expected error from sql.Open failure")
}
}
func TestStoreOpenAppendCreateTableError(t *testing.T) {
dir := t.TempDir()
m, err := Load(dir)
if err != nil {
t.Fatal(err)
}
realOpen := m.open
m.SetOpen(func(driverName, dataSourceName string) (*sql.DB, error) {
db, err := realOpen(driverName, dataSourceName)
if err != nil {
return nil, err
}
db.Close()
return db, nil
})
if err := m.OpenAppend(); err == nil {
t.Error("expected error from closed DB CREATE TABLE")
}
}
func TestStoreHasAfterClose(t *testing.T) {
dir := t.TempDir()
m, err := Load(dir)
if err != nil {
t.Fatal(err)
}
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("x1", "photo.jpg", 1024, "local")
m.Close()
if m.Has("x1") {
t.Error("Has should return false after Close")
}
}
func TestStoreEntriesAfterClose(t *testing.T) {
dir := t.TempDir()
m, err := Load(dir)
if err != nil {
t.Fatal(err)
}
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("x1", "photo.jpg", 1024, "local")
m.Close()
entries := m.Entries()
if entries != nil {
t.Errorf("Entries should return nil after Close, got %d entries", len(entries))
}
}
func TestStoreOpenAppendMkdirAllError(t *testing.T) {
m, err := Load("/proc/cannot-write")
if err != nil {
t.Fatal(err)
}
if err := m.OpenAppend(); err == nil {
t.Error("expected error creating dir under /proc")
m.Close()
}
}
func TestStoreOpenAppendNilOpener(t *testing.T) {
dir := t.TempDir()
m, err := Load(dir)
if err != nil {
t.Fatal(err)
}
m.open = nil
if err := m.OpenAppend(); err != nil {
t.Errorf("expected nil opener to use sql.Open fallback, got err: %v", err)
}
m.Close()
}
func TestStoreOpenAppendCreateIndexError(t *testing.T) {
dir := t.TempDir()
m, err := Load(dir)
if err != nil {
t.Fatal(err)
}
realOpen := m.open
m.open = func(driverName, dataSourceName string) (*sql.DB, error) {
db, err := realOpen(driverName, dataSourceName)
if err != nil {
return nil, err
}
db.Close()
return db, nil
}
if err := m.OpenAppend(); err == nil {
t.Error("expected error from closed DB")
}
}
func TestStoreHasQueryError(t *testing.T) {
dir := t.TempDir()
m, err := Load(dir)
if err != nil {
t.Fatal(err)
}
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("x1", "photo.jpg", 1024, "local")
closedDB := m.DB()
closedDB.Close()
if m.Has("x1") {
t.Error("Has should return false with broken DB connection")
}
}
func TestStoreEntriesQueryError(t *testing.T) {
dir := t.TempDir()
m, err := Load(dir)
if err != nil {
t.Fatal(err)
}
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("x1", "photo.jpg", 1024, "local")
closedDB := m.DB()
closedDB.Close()
entries := m.Entries()
if entries == nil {
t.Error("Entries should return non-nil map with broken DB connection")
}
if len(entries) != 0 {
t.Errorf("Entries should be empty with broken DB connection, got %d", len(entries))
}
}
func TestStoreHasQueryErrorWithOpenDB(t *testing.T) {
dir := t.TempDir()
m, err := Load(dir)
if err != nil {
t.Fatal(err)
}
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("x1", "photo.jpg", 1024, "local")
closedDB := m.DB()
closedDB.Close()
if m.Has("x1") {
t.Error("Has should return false with closed DB")
}
}
func TestStoreEntriesQueryErrorWithOpenDB(t *testing.T) {
dir := t.TempDir()
m, err := Load(dir)
if err != nil {
t.Fatal(err)
}
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("x1", "photo.jpg", 1024, "local")
closedDB := m.DB()
closedDB.Close()
entries := m.Entries()
if entries != nil && len(entries) != 0 {
t.Errorf("Entries should be empty with closed DB, got %d", len(entries))
}
}
func TestStoreCreateIndexError(t *testing.T) {
dir := t.TempDir()
m, err := Load(dir)
if err != nil {
t.Fatal(err)
}
realOpen := m.open
m.open = func(driverName, dataSourceName string) (*sql.DB, error) {
db, err := realOpen(driverName, dataSourceName)
if err != nil {
return nil, err
}
m.SetExecFunc(func(query string, args ...any) (sql.Result, error) {
if strings.Contains(query, "CREATE INDEX") {
return nil, fmt.Errorf("injected CREATE INDEX error")
}
return db.Exec(query, args...)
})
return db, nil
}
if err := m.OpenAppend(); err == nil {
t.Error("expected error from CREATE INDEX")
m.Close()
}
}
func TestAddEntryDefaultsPath(t *testing.T) {
dir := t.TempDir()
m, err := Load(dir)
if err != nil {
t.Fatal(err)
}
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.AddEntry(types.Entry{ID: "x1", Filename: "file.jpg", Size: 3, Cloud: "local", Checksum: "sha256:def"})
if _, err := m.DB().Exec(`UPDATE downloads SET path = '' WHERE id = 'x1'`); err != nil {
t.Fatal(err)
}
sloaded := m.Entries()["x1"]
if got := sloaded.Path; got != "file.jpg" {
t.Fatalf("sqlite path = %q", got)
}
if sloaded.Checksum != "sha256:def" {
t.Fatalf("sqlite checksum = %q", sloaded.Checksum)
}
m.Close()
}
func TestNewLogWriter(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatal(err)
}
defer db.Close()
lw, err := NewLogWriter(db)
if err != nil {
t.Fatal(err)
}
lw.Log(types.LogEntry{
Timestamp: 1700000000,
Level: "info",
Event: "export_done",
AssetID: "asset-1",
Album: "Favorites",
Filename: "photo.jpg",
Size: 1024,
Cloud: "local",
DurationMs: 500,
})
lw.Log(types.LogEntry{
Timestamp: 1700000001,
Level: "error",
Event: "export_fail",
AssetID: "asset-2",
Filename: "bad.jpg",
Message: "timeout",
})
lw.Close()
var count int
err = db.QueryRow(`SELECT COUNT(*) FROM logs`).Scan(&count)
if err != nil {
t.Fatal(err)
}
if count != 2 {
t.Errorf("expected 2 log entries, got %d", count)
}
var event, level, assetID string
err = db.QueryRow(`SELECT event, level, asset_id FROM logs WHERE asset_id = 'asset-1'`).Scan(&event, &level, &assetID)
if err != nil {
t.Fatal(err)
}
if event != "export_done" || level != "info" || assetID != "asset-1" {
t.Errorf("unexpected row: event=%s level=%s asset_id=%s", event, level, assetID)
}
}
func TestLogWriterNilDB(t *testing.T) {
w := &LogWriter{db: nil}
w.Log(types.LogEntry{Event: "test"})
w.Close()
}
func TestNewLogWriterError(t *testing.T) {
db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
if _, err := NewLogWriter(db); err == nil {
t.Error("expected error for closed db")
}
}
func TestLogWriterCloseConcrete(t *testing.T) {
(&LogWriter{}).Close()
}