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
+17
View File
@@ -2,6 +2,23 @@
This changelog is maintained from git history plus the published Gitea release series. Future releases should update this file and publish matching release notes on the release page. This changelog is maintained from git history plus the published Gitea release series. Future releases should update this file and publish matching release notes on the release page.
## v0.10.0
Ports and adapters refactor.
- Extract shared manifest types into `internal/manifest/types` leaf package.
- Extract SQLite adapter into `internal/manifest/sqlite`: Store, Adapter, LogWriter.
- Extract JSONL adapter into `internal/manifest/jsonl`: Store, Adapter.
- `modernc.org/sqlite` import isolated to `internal/manifest/sqlite/adapter.go`.
- Registry pattern: `manifest.Default` provides adapter-backed `ParseFormat`/`Open`/`OpenLogWriter`.
- Adapter-agnostic `ConvertManifest` in `types/` for JSONL↔SQLite conversion.
- `MemoryAdapter` for in-memory manifest testing.
- CLI uses `manifest.Default` registry directly; zero concrete adapter references.
- SQLite `LogWriter` type assertion moved from central code into `SQLiteAdapter`.
- `Manifest` interface now includes `Entries()`; `EntryReader` removed.
- No behavior changes, no new features.
- 100% statement coverage across all 6 packages.
## v0.9.4 ## v0.9.4
Doctor release. Doctor release.
+1 -1
View File
@@ -1,6 +1,6 @@
BINARY := ./bin/photoscli BINARY := ./bin/photoscli
MODULE := gitea.k3s.k0.nu/tools/photocli MODULE := gitea.k3s.k0.nu/tools/photocli
VERSION := 0.9.4 VERSION := 0.10.0
RELEASE_ZIP := ./bin/photoscli-$(VERSION)-macos-arm64.zip RELEASE_ZIP := ./bin/photoscli-$(VERSION)-macos-arm64.zip
RELEASE_NOTES := RELEASE_NOTES.md RELEASE_NOTES := RELEASE_NOTES.md
BRIDGE_DIR := bridge BRIDGE_DIR := bridge
+14 -7
View File
@@ -1,18 +1,25 @@
# v0.9.4 # v0.10.0
This release adds `doctor` diagnostics for Photos access and backups. Ports and adapters refactor. No user-visible changes.
## Highlights ## Highlights
- Add `doctor` to check Photos access. - Extract manifest types, registry, and conversion logic into `internal/manifest/types`.
- Add `doctor --out <dir>` to report backup directory, manifest entries, and failure count. - Extract SQLite adapter into `internal/manifest/sqlite` with its own store, adapter, and log writer.
- Add `doctor --json` for scriptable diagnostics. - Extract JSONL adapter into `internal/manifest/jsonl` with its own store and adapter.
- `doctor` is read-only and does not modify backup data. - Isolate `modernc.org/sqlite` import to the SQLite adapter package only.
- Replace central `Open`/`ParseFormat`/`OpenLogWriter` with adapter-backed registry.
- Adapter-agnostic manifest conversion via `ConvertManifest`.
- SQLite log writer selection moved from central code into `SQLiteAdapter`.
- CLI uses `manifest.Default` registry; no direct references to concrete JSONL or SQLite types.
- `MemoryAdapter` available for in-memory manifest tests.
- 100% statement coverage across all 6 packages.
- No behavior changes.
## Assets ## Assets
- `photoscli`: Apple Silicon macOS binary (`darwin/arm64`). - `photoscli`: Apple Silicon macOS binary (`darwin/arm64`).
- `photoscli-0.9.4-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG. - `photoscli-0.10.0-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG.
- `USERGUIDE.md`: standalone user guide. - `USERGUIDE.md`: standalone user guide.
Intel Macs are not currently a supported release target. Intel Macs are not currently a supported release target.
+17 -19
View File
@@ -34,6 +34,7 @@ var (
renameFunc = os.Rename renameFunc = os.Rename
openFileFunc = os.OpenFile openFileFunc = os.OpenFile
removeFunc = os.Remove removeFunc = os.Remove
reg = manifest.Default
) )
type exportOptions struct { type exportOptions struct {
@@ -500,7 +501,7 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
return exitErr return exitErr
} }
mf, mfErr := manifest.ParseFormat(manifestFmt) mf, mfErr := reg.ParseFormat(manifestFmt)
if mfErr != nil { if mfErr != nil {
fmt.Fprintf(stderr, "error: %v\n", mfErr) fmt.Fprintf(stderr, "error: %v\n", mfErr)
return exitErr return exitErr
@@ -603,7 +604,7 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
} }
var exported, failed int var exported, failed int
if opts.metadataOnly { if opts.metadataOnly {
m, _ := manifest.Open(outDir, mf) m, _ := reg.Open(outDir, mf)
defer m.Close() defer m.Close()
entries := manifestEntries(m) entries := manifestEntries(m)
fmt.Fprintf(stderr, "writing metadata for %d assets to %s...\n", total, outDir) fmt.Fprintf(stderr, "writing metadata for %d assets to %s...\n", total, outDir)
@@ -673,7 +674,7 @@ func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge)
return exitErr return exitErr
} }
mf, mfErr := manifest.ParseFormat(manifestFmt) mf, mfErr := reg.ParseFormat(manifestFmt)
if mfErr != nil { if mfErr != nil {
fmt.Fprintf(stderr, "error: %v\n", mfErr) fmt.Fprintf(stderr, "error: %v\n", mfErr)
return exitErr return exitErr
@@ -747,7 +748,7 @@ func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge)
} }
var totalAssets, failed int var totalAssets, failed int
if opts.metadataOnly { if opts.metadataOnly {
m, _ := manifest.Open(outDir, mf) m, _ := reg.Open(outDir, mf)
entries := manifestEntries(m) entries := manifestEntries(m)
m.Close() m.Close()
pending, skipped := collectPendingAssets(nodes, outDir, bridge, skipVideos, originals, nil, nil, sortNewest, excludeAlbums, sinceTime, opts) pending, skipped := collectPendingAssets(nodes, outDir, bridge, skipVideos, originals, nil, nil, sortNewest, excludeAlbums, sinceTime, opts)
@@ -1299,10 +1300,7 @@ func writeMetadataOnlySidecar(pa pendingAsset, entry manifest.Entry, originals b
} }
func manifestEntries(m manifest.Manifest) map[string]manifest.Entry { func manifestEntries(m manifest.Manifest) map[string]manifest.Entry {
if r, ok := m.(manifest.EntryReader); ok { return m.Entries()
return r.Entries()
}
return nil
} }
func metadataOnlyAssets(assets []photos.Asset, outDir string, originals bool, dirPrefix string, entries map[string]manifest.Entry, opts exportOptions, bridge photos.Bridge) (int, int) { func metadataOnlyAssets(assets []photos.Asset, outDir string, originals bool, dirPrefix string, entries map[string]manifest.Entry, opts exportOptions, bridge photos.Bridge) (int, int) {
@@ -1428,7 +1426,7 @@ func backupTree(nodes []photos.CollectionNode, outDir string, targetSize, qualit
var m manifest.Manifest var m manifest.Manifest
if !noManifest { if !noManifest {
var err error var err error
m, err = manifest.Open(outDir, mf) m, err = reg.Open(outDir, mf)
if err != nil { if err != nil {
fmt.Fprintf(stderr, " warning: could not open manifest: %v\n", err) fmt.Fprintf(stderr, " warning: could not open manifest: %v\n", err)
} else { } else {
@@ -1441,7 +1439,7 @@ func backupTree(nodes []photos.CollectionNode, outDir string, targetSize, qualit
var lw manifest.LogWriter = manifest.NoopLogWriter var lw manifest.LogWriter = manifest.NoopLogWriter
if enableLog { if enableLog {
var err error var err error
lw, err = manifest.OpenLogWriter(m, outDir) lw, err = reg.OpenLogWriter(m, outDir, mf)
if err != nil { if err != nil {
fmt.Fprintf(stderr, " warning: could not open log writer: %v\n", err) fmt.Fprintf(stderr, " warning: could not open log writer: %v\n", err)
lw = manifest.NoopLogWriter lw = manifest.NoopLogWriter
@@ -1717,7 +1715,7 @@ func exportAssets(assets []photos.Asset, outDir string, targetSize, quality, con
var m manifest.Manifest var m manifest.Manifest
if !noManifest { if !noManifest {
var err error var err error
m, err = manifest.Open(outDir, mf) m, err = reg.Open(outDir, mf)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "warning: could not open manifest: %v\n", err) fmt.Fprintf(stderr, "warning: could not open manifest: %v\n", err)
} else { } else {
@@ -1730,7 +1728,7 @@ func exportAssets(assets []photos.Asset, outDir string, targetSize, quality, con
var lw manifest.LogWriter = manifest.NoopLogWriter var lw manifest.LogWriter = manifest.NoopLogWriter
if enableLog { if enableLog {
var err error var err error
lw, err = manifest.OpenLogWriter(m, outDir) lw, err = reg.OpenLogWriter(m, outDir, mf)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "warning: could not open log writer: %v\n", err) fmt.Fprintf(stderr, "warning: could not open log writer: %v\n", err)
lw = manifest.NoopLogWriter lw = manifest.NoopLogWriter
@@ -2230,7 +2228,7 @@ func cmdReport(args []string, stdout, stderr io.Writer) int {
fmt.Fprintln(stderr, "error: --out is required") fmt.Fprintln(stderr, "error: --out is required")
return exitErr return exitErr
} }
mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl")) mf, err := reg.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
if err != nil { if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err) fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr return exitErr
@@ -2259,7 +2257,7 @@ func cmdDiff(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int
fmt.Fprintln(stderr, "error: --album-id and --out are required") fmt.Fprintln(stderr, "error: --album-id and --out are required")
return exitErr return exitErr
} }
mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl")) mf, err := reg.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
if err != nil { if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err) fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr return exitErr
@@ -2304,7 +2302,7 @@ func cmdVerify(args []string, stdout, stderr io.Writer) int {
fmt.Fprintln(stderr, "error: --out is required") fmt.Fprintln(stderr, "error: --out is required")
return exitErr return exitErr
} }
mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl")) mf, err := reg.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
if err != nil { if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err) fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr return exitErr
@@ -2417,7 +2415,7 @@ func cmdDoctor(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
problems++ problems++
} else { } else {
result["backup_dir"] = "ok" result["backup_dir"] = "ok"
mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl")) mf, err := reg.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
if err != nil { if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err) fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr return exitErr
@@ -2466,7 +2464,7 @@ func cmdCleanup(args []string, stdout, stderr io.Writer) int {
fmt.Fprintln(stderr, "error: --out is required") fmt.Fprintln(stderr, "error: --out is required")
return exitErr return exitErr
} }
mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl")) mf, err := reg.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
if err != nil { if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err) fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr return exitErr
@@ -2544,7 +2542,7 @@ func cmdManifest(args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "error: --checksum must be none or sha256, got %q\n", checksumMode) fmt.Fprintf(stderr, "error: --checksum must be none or sha256, got %q\n", checksumMode)
return exitErr return exitErr
} }
mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl")) mf, err := reg.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
if err != nil { if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err) fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr return exitErr
@@ -2755,7 +2753,7 @@ func cmdStatus(args []string, stdout, stderr io.Writer) int {
return exitErr return exitErr
} }
manifestFmt := flagValWithDefault(args, "--manifest", "jsonl") manifestFmt := flagValWithDefault(args, "--manifest", "jsonl")
mf, err := manifest.ParseFormat(manifestFmt) mf, err := reg.ParseFormat(manifestFmt)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err) fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr return exitErr
+2
View File
@@ -56,6 +56,7 @@ func (noEntryManifest) AddEntry(manifest.Entry) {}
func (noEntryManifest) Save() error { return nil } func (noEntryManifest) Save() error { return nil }
func (noEntryManifest) Close() {} func (noEntryManifest) Close() {}
func (noEntryManifest) OpenAppend() error { return nil } func (noEntryManifest) OpenAppend() error { return nil }
func (noEntryManifest) Entries() map[string]manifest.Entry { return nil }
func (m *mockBridge) RequestAccess() error { return m.accessErr } func (m *mockBridge) RequestAccess() error { return m.accessErr }
func (m *mockBridge) ListAlbums() ([]photos.Album, error) { return m.albums, m.albumsErr } func (m *mockBridge) ListAlbums() ([]photos.Album, error) { return m.albums, m.albumsErr }
@@ -5459,3 +5460,4 @@ func (m *mockManifest) AddEntry(e manifest.Entry) { m.last = e }
func (m *mockManifest) Save() error { return nil } func (m *mockManifest) Save() error { return nil }
func (m *mockManifest) Close() {} func (m *mockManifest) Close() {}
func (m *mockManifest) OpenAppend() error { return nil } func (m *mockManifest) OpenAppend() error { return nil }
func (m *mockManifest) Entries() map[string]manifest.Entry { return nil }
-56
View File
@@ -1,56 +0,0 @@
package manifest
import "testing"
func TestNewEntryPath(t *testing.T) {
e := newEntry("id1", "file.jpg", 123, "local")
if e.ID != "id1" || e.Filename != "file.jpg" || e.Path != "file.jpg" || e.Size != 123 || e.Cloud != "local" || e.Exported == 0 {
t.Fatalf("unexpected entry: %+v", e)
}
e = NewEntry("id2", "file2.jpg", "Album/file2.jpg", 456, "cloud")
if e.Path != "Album/file2.jpg" || e.Filename != "file2.jpg" || e.Size != 456 || e.Cloud != "cloud" {
t.Fatalf("unexpected entry with path: %+v", e)
}
e = NewEntryWithChecksum("id3", "file3.jpg", "Album/file3.jpg", 789, "local", "sha256:abc")
if e.Checksum != "sha256:abc" || e.Path != "Album/file3.jpg" {
t.Fatalf("unexpected checksum entry: %+v", e)
}
}
func TestAddEntryDefaultsPath(t *testing.T) {
dir := t.TempDir()
jm := LoadJSONL(dir)
if err := jm.OpenAppend(); err != nil {
t.Fatal(err)
}
jm.AddEntry(Entry{ID: "x1", Filename: "file.jpg", Size: 3, Cloud: "local", Checksum: "sha256:abc"})
jm.Close()
loaded := LoadJSONL(dir).Entries()["x1"]
if got := loaded.Path; got != "file.jpg" {
t.Fatalf("jsonl path = %q", got)
}
if loaded.Checksum != "sha256:abc" {
t.Fatalf("jsonl checksum = %q", loaded.Checksum)
}
sdir := t.TempDir()
sm, err := LoadSQLite(sdir)
if err != nil {
t.Fatal(err)
}
if err := sm.OpenAppend(); err != nil {
t.Fatal(err)
}
sm.AddEntry(Entry{ID: "x1", Filename: "file.jpg", Size: 3, Cloud: "local", Checksum: "sha256:def"})
if _, err := sm.db.Exec(`UPDATE downloads SET path = '' WHERE id = 'x1'`); err != nil {
t.Fatal(err)
}
sloaded := sm.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)
}
sm.Close()
}
+17
View File
@@ -0,0 +1,17 @@
package jsonl
import (
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
)
type Adapter struct{}
func (Adapter) Format() types.Format { return types.FormatJSONL }
func (Adapter) Aliases() []string { return []string{"json"} }
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), nil }
func (Adapter) OpenLogWriter(_ types.Manifest, dir string) (types.LogWriter, error) {
return types.NewFileLogWriter(types.LogPath(dir))
}
+209
View File
@@ -0,0 +1,209 @@
package jsonl
import (
"os"
"testing"
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
)
func TestAdapter(t *testing.T) {
a := Adapter{}
if a.Format() != types.FormatJSONL {
t.Fatal("expected JSONL format")
}
if len(a.Aliases()) != 1 || a.Aliases()[0] != "json" {
t.Fatal("expected json alias")
}
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 TestStoreLoadEmpty(t *testing.T) {
m := Load(t.TempDir())
if m == nil {
t.Fatal("expected non-nil store")
}
}
func TestStoreLoadNonexistent(t *testing.T) {
m := Load("/nonexistent/path")
if m == nil {
t.Fatal("expected non-nil store")
}
}
func TestStoreAddAndHas(t *testing.T) {
m := Load(t.TempDir())
if m.Has("x") {
t.Fatal("expected Has to return false")
}
m.Add("x", "photo.jpg", 42, "s3")
if !m.Has("x") {
t.Fatal("expected Has to return true")
}
}
func TestStoreSaveAndReload(t *testing.T) {
dir := t.TempDir()
m := Load(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("id1", "file1.jpg", 10, "aws")
m.Add("id2", "file2.jpg", 20, "gcs")
if err := m.Save(); err != nil {
t.Fatal(err)
}
m.Close()
m2 := Load(dir)
if !m2.Has("id1") {
t.Fatal("expected id1 after reload")
}
if !m2.Has("id2") {
t.Fatal("expected id2 after reload")
}
}
func TestStoreOpenAppendCreatesDirs(t *testing.T) {
dir := t.TempDir()
subdir := dir + "/a/b"
m := Load(subdir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Close()
}
func TestStoreCloseIdempotent(t *testing.T) {
m := Load(t.TempDir())
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Close()
m.Close()
}
func TestStoreOpenAppendIdempotent(t *testing.T) {
m := Load(t.TempDir())
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Close()
}
func TestStoreEntries(t *testing.T) {
m := Load(t.TempDir())
m.Add("e1", "f1.jpg", 1, "c1")
m.Add("e2", "f2.jpg", 2, "c2")
entries := m.Entries()
if len(entries) != 2 {
t.Fatalf("expected 2 entries, got %d", len(entries))
}
}
func TestStoreManifestFormat(t *testing.T) {
m := Load(t.TempDir())
if m.ManifestFormat() != types.FormatJSONL {
t.Fatal("expected JSONL format")
}
}
func TestStoreOpenAppendMkdirError(t *testing.T) {
m := Load("/proc/cannot-create-dir-here")
if err := m.OpenAppend(); err == nil {
t.Fatal("expected error")
}
}
func TestStoreSaveWithNoFile(t *testing.T) {
m := Load(t.TempDir())
if err := m.Save(); err != nil {
t.Fatal(err)
}
}
func TestStoreLoadFromExistingFile(t *testing.T) {
dir := t.TempDir()
m := Load(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("abc", "a.jpg", 100, "gcs")
m.Close()
m2 := Load(dir)
if !m2.Has("abc") {
t.Fatal("expected abc after reload")
}
}
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 TestLoadWithPathFallback(t *testing.T) {
dir := t.TempDir()
path := Path(dir)
osWriteFile(path, []byte(`{"id":"abc","filename":"a.jpg","size":100,"cloud":"gcs","exported":1234}`+"\n"), 0644)
m := Load(dir)
if !m.Has("abc") {
t.Fatal("expected abc")
}
if e := m.Entries()["abc"]; e.Path != "a.jpg" {
t.Fatalf("expected path fallback to filename, got %q", e.Path)
}
}
func TestOpenAppendAlreadyOpen(t *testing.T) {
m := Load(t.TempDir())
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Close()
}
func TestOpenAppendOpenFileError(t *testing.T) {
dir := t.TempDir()
os.MkdirAll(Path(dir), 0755)
m := Load(dir)
if err := m.OpenAppend(); err == nil {
t.Fatal("expected error when path is a directory")
}
}
func TestOpenAppendMkdirError(t *testing.T) {
m := Load("/proc/cannot-create-dir-here")
if err := m.OpenAppend(); err == nil {
t.Fatal("expected error")
}
}
var osWriteFile = os.WriteFile
@@ -1,4 +1,4 @@
package manifest package jsonl
import ( import (
"encoding/json" "encoding/json"
@@ -6,32 +6,34 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
) )
type jsonlManifest struct { type Store struct {
mu sync.Mutex mu sync.Mutex
entries map[string]Entry entries map[string]types.Entry
path string path string
file *os.File file *os.File
syncFunc func() error SyncFunc func() error
} }
var jsonlSaveHook func() error var saveHook func() error
func SetJSONLSaveHook(fn func() error) func() error { func SetSaveHook(fn func() error) func() error {
old := jsonlSaveHook old := saveHook
jsonlSaveHook = fn saveHook = fn
return old return old
} }
func JSONLPath(dir string) string { func Path(dir string) string {
return filepath.Join(dir, "downloads.jsonl") return filepath.Join(dir, "downloads.jsonl")
} }
func LoadJSONL(dir string) *jsonlManifest { func Load(dir string) *Store {
m := &jsonlManifest{ m := &Store{
entries: make(map[string]Entry), entries: make(map[string]types.Entry),
path: JSONLPath(dir), path: Path(dir),
} }
data, err := os.ReadFile(m.path) data, err := os.ReadFile(m.path)
if err != nil { if err != nil {
@@ -56,7 +58,7 @@ func LoadJSONL(dir string) *jsonlManifest {
if raw.Path == "" { if raw.Path == "" {
raw.Path = raw.Filename raw.Path = raw.Filename
} }
m.entries[raw.ID] = Entry{ m.entries[raw.ID] = types.Entry{
ID: raw.ID, ID: raw.ID,
Filename: raw.Filename, Filename: raw.Filename,
Path: raw.Path, Path: raw.Path,
@@ -70,18 +72,22 @@ func LoadJSONL(dir string) *jsonlManifest {
return m return m
} }
func (m *jsonlManifest) Has(id string) bool { func (m *Store) Has(id string) bool {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
_, ok := m.entries[id] _, ok := m.entries[id]
return ok return ok
} }
func (m *jsonlManifest) Add(id string, filename string, size int64, cloud string) { func (m *Store) ManifestFormat() types.Format {
m.AddEntry(newEntry(id, filename, size, cloud)) return types.FormatJSONL
} }
func (m *jsonlManifest) AddEntry(entry Entry) { 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) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
if entry.Path == "" { if entry.Path == "" {
@@ -103,14 +109,14 @@ func (m *jsonlManifest) AddEntry(entry Entry) {
} }
} }
func (m *jsonlManifest) Save() error { func (m *Store) Save() error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
if m.syncFunc != nil { if m.SyncFunc != nil {
return m.syncFunc() return m.SyncFunc()
} }
if jsonlSaveHook != nil { if saveHook != nil {
return jsonlSaveHook() return saveHook()
} }
if m.file != nil { if m.file != nil {
return m.file.Sync() return m.file.Sync()
@@ -118,7 +124,7 @@ func (m *jsonlManifest) Save() error {
return nil return nil
} }
func (m *jsonlManifest) Close() { func (m *Store) Close() {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
if m.file != nil { if m.file != nil {
@@ -127,7 +133,7 @@ func (m *jsonlManifest) Close() {
} }
} }
func (m *jsonlManifest) OpenAppend() error { func (m *Store) OpenAppend() error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
if m.file != nil { if m.file != nil {
@@ -144,12 +150,20 @@ func (m *jsonlManifest) OpenAppend() error {
return nil return nil
} }
func (m *jsonlManifest) Entries() map[string]Entry { func (m *Store) Entries() map[string]types.Entry {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
out := make(map[string]Entry, len(m.entries)) out := make(map[string]types.Entry, len(m.entries))
for k, v := range m.entries { for k, v := range m.entries {
out[k] = v out[k] = v
} }
return out return out
} }
func FileExists(path string) bool {
info, err := os.Stat(path)
if err != nil {
return false
}
return !info.IsDir()
}
+79
View File
@@ -0,0 +1,79 @@
package jsonl
import (
"fmt"
"testing"
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
)
func TestSetSaveHook(t *testing.T) {
old := SetSaveHook(func() error { return fmt.Errorf("hook error") })
if old != nil {
t.Error("expected nil old hook")
}
restore := SetSaveHook(old)
if restore == nil {
t.Error("expected non-nil restore function")
}
}
func TestSaveHookError(t *testing.T) {
dir := t.TempDir()
m := Load(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("x1", "photo.jpg", 1024, "local")
old := SetSaveHook(func() error { return fmt.Errorf("hook error") })
defer SetSaveHook(old)
if err := m.Save(); err == nil {
t.Error("expected hook error from Save")
}
m.Close()
}
func TestSyncFuncError(t *testing.T) {
dir := t.TempDir()
m := Load(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("x1", "photo.jpg", 1024, "local")
m.SyncFunc = func() error { return fmt.Errorf("sync func error") }
if err := m.Save(); err == nil {
t.Error("expected syncFunc error from Save")
}
m.Close()
}
func TestSaveError(t *testing.T) {
dir := t.TempDir()
m := Load(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("x1", "photo.jpg", 1024, "local")
m.file.Close()
if err := m.Save(); err == nil {
t.Error("expected Sync error on closed file")
}
m.Close()
}
func TestAddEntryDefaultsPath(t *testing.T) {
dir := t.TempDir()
m := Load(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.AddEntry(types.Entry{ID: "x1", Filename: "file.jpg", Size: 3, Cloud: "local", Checksum: "sha256:abc"})
m.Close()
loaded := Load(dir).Entries()["x1"]
if got := loaded.Path; got != "file.jpg" {
t.Fatalf("jsonl path = %q", got)
}
if loaded.Checksum != "sha256:abc" {
t.Fatalf("jsonl checksum = %q", loaded.Checksum)
}
}
-30
View File
@@ -1,30 +0,0 @@
package manifest
type LogEntry struct {
Timestamp int64 `json:"ts"`
Level string `json:"level"`
Event string `json:"event"`
AssetID string `json:"asset_id,omitempty"`
Album string `json:"album,omitempty"`
Filename string `json:"filename,omitempty"`
Size int64 `json:"size,omitempty"`
Cloud string `json:"cloud,omitempty"`
DurationMs int64 `json:"duration_ms,omitempty"`
Message string `json:"message,omitempty"`
}
type LogWriter interface {
Log(entry LogEntry)
Close()
}
type noopLogWriter struct{}
func (noopLogWriter) Log(LogEntry) { _ = struct{}{} }
func (noopLogWriter) Close() { _ = struct{}{} }
var NoopLogWriter LogWriter = noopLogWriter{}
func LogPath(dir string) string {
return dir + "/export.log"
}
-211
View File
@@ -1,211 +0,0 @@
package manifest
import (
"database/sql"
"os"
"path/filepath"
"testing"
_ "modernc.org/sqlite"
)
func TestNoopLogWriter(t *testing.T) {
lw := NoopLogWriter
lw.Log(LogEntry{Event: "test"})
lw.Close()
noopLogWriter{}.Log(LogEntry{Event: "test"})
noopLogWriter{}.Close()
}
func TestNewSQLiteLogWriter(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 := NewSQLiteLogWriter(db)
if err != nil {
t.Fatal(err)
}
lw.Log(LogEntry{
Timestamp: 1700000000,
Level: "info",
Event: "export_done",
AssetID: "asset-1",
Album: "Favorites",
Filename: "photo.jpg",
Size: 1024,
Cloud: "local",
DurationMs: 500,
Message: "",
})
lw.Log(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 TestSQLiteLogWriterNilDB(t *testing.T) {
w := &sqliteLogWriter{db: nil}
w.Log(LogEntry{Event: "test"})
w.Close()
}
func TestNewSQLiteLogWriterError(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 := NewSQLiteLogWriter(db); err == nil {
t.Error("expected error for closed db")
}
}
func TestSQLiteLogWriterCloseConcrete(t *testing.T) {
(&sqliteLogWriter{}).Close()
}
func TestNewFileLogWriter(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "export.log")
lw, err := NewFileLogWriter(path)
if err != nil {
t.Fatal(err)
}
lw.Log(LogEntry{
Timestamp: 1700000000,
Level: "info",
Event: "export_done",
Filename: "photo.jpg",
Size: 2048,
})
lw.Close()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if len(data) == 0 {
t.Error("expected log data")
}
if data[len(data)-1] != '\n' {
t.Error("expected trailing newline")
}
}
func TestFileLogWriterClosed(t *testing.T) {
w := &fileLogWriter{f: nil}
w.Log(LogEntry{Event: "test"})
w.Close()
}
func TestNewFileLogWriterError(t *testing.T) {
_, err := NewFileLogWriter("/nonexistent/dir/export.log")
if err == nil {
t.Error("expected error for bad path")
}
}
func TestFileLogWriterDoubleClose(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "export.log")
lw, err := NewFileLogWriter(path)
if err != nil {
t.Fatal(err)
}
lw.Close()
lw.Close()
}
func TestLogPath(t *testing.T) {
p := LogPath("/tmp/out")
if p != "/tmp/out/export.log" {
t.Errorf("expected /tmp/out/export.log, got %s", p)
}
}
func TestOpenLogWriterSQLite(t *testing.T) {
dir := t.TempDir()
m, err := LoadSQLite(dir)
if err != nil {
t.Fatal(err)
}
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
defer m.Close()
lw, err := OpenLogWriter(m, dir)
if err != nil {
t.Fatal(err)
}
lw.Log(LogEntry{Event: "test", Level: "info"})
lw.Close()
}
func TestOpenLogWriterJSONL(t *testing.T) {
dir := t.TempDir()
m := LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
defer m.Close()
lw, err := OpenLogWriter(m, dir)
if err != nil {
t.Fatal(err)
}
lw.Log(LogEntry{Event: "test", Level: "info"})
lw.Close()
if _, err := os.Stat(LogPath(dir)); os.IsNotExist(err) {
t.Error("expected export.log to exist")
}
}
func TestOpenLogWriterNilManifest(t *testing.T) {
dir := t.TempDir()
lw, err := OpenLogWriter(nil, dir)
if err != nil {
t.Fatal(err)
}
lw.Log(LogEntry{Event: "test", Level: "info"})
lw.Close()
if _, err := os.Stat(LogPath(dir)); os.IsNotExist(err) {
t.Error("expected export.log to exist for nil manifest")
}
}
+29 -43
View File
@@ -1,51 +1,37 @@
package manifest package manifest
import "time" import "gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
type Entry struct { type Entry = types.Entry
ID string
Filename string
Path string
Size int64
Cloud string
Checksum string
Exported int64
}
type Manifest interface { type Manifest = types.Manifest
Has(id string) bool
Add(id string, filename string, size int64, cloud string)
AddEntry(entry Entry)
Save() error
Close()
OpenAppend() error
}
type EntryReader interface { type EntryReader = types.EntryReader
Entries() map[string]Entry
}
func newEntry(id, filename string, size int64, cloud string) Entry { type Format = types.Format
return Entry{
ID: id,
Filename: filename,
Path: filename,
Size: size,
Cloud: cloud,
Exported: time.Now().Unix(),
}
}
func NewEntry(id, filename, path string, size int64, cloud string) Entry { const (
e := newEntry(id, filename, size, cloud) FormatJSONL = types.FormatJSONL
if path != "" { FormatSQLite = types.FormatSQLite
e.Path = path )
}
return e
}
func NewEntryWithChecksum(id, filename, path string, size int64, cloud, checksum string) Entry { type LogEntry = types.LogEntry
e := NewEntry(id, filename, path, size, cloud)
e.Checksum = checksum type LogWriter = types.LogWriter
return e
} var NoopLogWriter = types.NoopLogWriter
type Adapter = types.Adapter
type Registry = types.Registry
type FormatReporter = types.FormatReporter
var (
NewRegistry = types.NewRegistry
NewEntry = types.NewEntry
NewEntryWithChecksum = types.NewEntryWithChecksum
LogPath = types.LogPath
ConvertManifest = types.ConvertManifest
NewFileLogWriter = types.NewFileLogWriter
)
+52
View File
@@ -0,0 +1,52 @@
package manifest
import "gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
type memoryStore struct {
entries map[string]types.Entry
}
func newMemoryStore() *memoryStore {
return &memoryStore{entries: make(map[string]types.Entry)}
}
func (m *memoryStore) Has(id string) bool {
_, ok := m.entries[id]
return ok
}
func (m *memoryStore) Add(id string, filename string, size int64, cloud string) {
m.AddEntry(types.NewEntry(id, filename, filename, size, cloud))
}
func (m *memoryStore) AddEntry(entry types.Entry) {
if entry.Path == "" {
entry.Path = entry.Filename
}
m.entries[entry.ID] = entry
}
func (m *memoryStore) Save() error { return nil }
func (m *memoryStore) Close() { _ = m }
func (m *memoryStore) OpenAppend() error { return nil }
func (m *memoryStore) Entries() map[string]types.Entry {
out := make(map[string]types.Entry, len(m.entries))
for k, v := range m.entries {
out[k] = v
}
return out
}
type MemoryAdapter struct{}
func (MemoryAdapter) Format() types.Format { return types.FormatJSONL }
func (MemoryAdapter) Aliases() []string { return nil }
func (MemoryAdapter) Path(string) string { return "" }
func (MemoryAdapter) Exists(string) bool { return false }
func (MemoryAdapter) Open(string) (types.Manifest, error) { return newMemoryStore(), nil }
func (MemoryAdapter) OpenLogWriter(types.Manifest, string) (types.LogWriter, error) {
return types.NoopLogWriter, nil
}
+17 -78
View File
@@ -1,84 +1,21 @@
package manifest package manifest
import ( import (
"fmt"
"os" "os"
"strings"
)
type Format string "gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
const (
FormatJSONL Format = "jsonl"
FormatSQLite Format = "sqlite"
) )
func Open(dir string, format Format) (Manifest, error) { func Open(dir string, format Format) (Manifest, error) {
jsonlPath := JSONLPath(dir) return Default.Open(dir, format)
sqlitePath := SQLitePath(dir)
jsonlExists := FileExists(jsonlPath)
sqliteExists := FileExists(sqlitePath)
switch {
case format == FormatJSONL && jsonlExists:
return LoadJSONL(dir), nil
case format == FormatSQLite && sqliteExists:
return LoadSQLite(dir)
case format == FormatJSONL && sqliteExists:
return ConvertFromSQLite(dir)
case format == FormatSQLite && jsonlExists:
return ConvertFromJSONL(dir)
default:
if format == FormatJSONL {
return LoadJSONL(dir), nil
}
return LoadSQLite(dir)
}
} }
func ConvertFromJSONL(dir string) (Manifest, error) { func ConvertFromJSONL(dir string) (Manifest, error) {
src := LoadJSONL(dir) return types.ConvertManifest(dir, JSONLAdapter, SQLiteAdapter)
if err := src.OpenAppend(); err != nil {
return nil, fmt.Errorf("open jsonl for read: %w", err)
}
defer src.Close()
dst, _ := LoadSQLite(dir)
if err := dst.OpenAppend(); err != nil {
return nil, fmt.Errorf("open sqlite for write: %w", err)
}
for id, e := range src.Entries() {
e.ID = id
dst.AddEntry(e)
}
os.Remove(JSONLPath(dir))
return dst, nil
} }
func ConvertFromSQLite(dir string) (Manifest, error) { func ConvertFromSQLite(dir string) (Manifest, error) {
src, _ := LoadSQLite(dir) return types.ConvertManifest(dir, SQLiteAdapter, JSONLAdapter)
if err := src.OpenAppend(); err != nil {
return nil, fmt.Errorf("open sqlite for read: %w", err)
}
defer src.Close()
dst := LoadJSONL(dir)
if err := dst.OpenAppend(); err != nil {
return nil, fmt.Errorf("open jsonl for write: %w", err)
}
for id, e := range src.Entries() {
e.ID = id
dst.AddEntry(e)
}
if err := dst.Save(); err != nil {
return nil, fmt.Errorf("save jsonl: %w", err)
}
os.Remove(SQLitePath(dir))
return dst, nil
} }
func FileExists(path string) bool { func FileExists(path string) bool {
@@ -90,19 +27,21 @@ func FileExists(path string) bool {
} }
func ParseFormat(s string) (Format, error) { func ParseFormat(s string) (Format, error) {
switch strings.ToLower(s) { return Default.ParseFormat(s)
case "jsonl", "json":
return FormatJSONL, nil
case "sqlite", "db", "sqlite3":
return FormatSQLite, nil
default:
return "", fmt.Errorf("unknown manifest format %q (use jsonl or sqlite)", s)
}
} }
func OpenLogWriter(m Manifest, dir string) (LogWriter, error) { func OpenLogWriter(m Manifest, dir string) (LogWriter, error) {
if sm, ok := m.(*sqliteManifest); ok && sm.DB() != nil { format := FormatJSONL
return NewSQLiteLogWriter(sm.DB()) if reporter, ok := m.(types.FormatReporter); ok {
format = reporter.ManifestFormat()
} }
return NewFileLogWriter(LogPath(dir)) return Default.OpenLogWriter(m, dir, format)
}
func osRemove(path string) error {
return os.Remove(path)
}
func init() {
types.SetRemoveFunc(osRemove)
} }
+34
View File
@@ -0,0 +1,34 @@
package manifest
import (
jsonladapter "gitea.k3s.k0.nu/tools/photocli/internal/manifest/jsonl"
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/sqlite"
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
)
var (
JSONLAdapter = jsonladapter.Adapter{}
SQLiteAdapter = sqlite.Adapter{}
)
var Default = types.NewRegistry(JSONLAdapter, SQLiteAdapter)
func LoadJSONL(dir string) *jsonladapter.Store {
return jsonladapter.Load(dir)
}
func JSONLPath(dir string) string {
return jsonladapter.Path(dir)
}
func SetJSONLSaveHook(fn func() error) func() error {
return jsonladapter.SetSaveHook(fn)
}
func LoadSQLite(dir string) (Manifest, error) {
return sqlite.Load(dir)
}
func SQLitePath(dir string) string {
return sqlite.Path(dir)
}
+279
View File
@@ -0,0 +1,279 @@
package manifest
import (
"fmt"
"testing"
jsonladapter "gitea.k3s.k0.nu/tools/photocli/internal/manifest/jsonl"
sqliteadapter "gitea.k3s.k0.nu/tools/photocli/internal/manifest/sqlite"
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
)
func TestRegistryParseFormatAliases(t *testing.T) {
r := Default
cases := map[string]Format{
"jsonl": FormatJSONL,
"json": FormatJSONL,
"sqlite": FormatSQLite,
"db": FormatSQLite,
"sqlite3": FormatSQLite,
}
for input, want := range cases {
got, err := r.ParseFormat(input)
if err != nil {
t.Fatalf("ParseFormat(%q): %v", input, err)
}
if got != want {
t.Fatalf("ParseFormat(%q) = %q, want %q", input, got, want)
}
}
}
func TestRegistryParseFormatUnknown(t *testing.T) {
if _, err := Default.ParseFormat("bad"); err == nil {
t.Fatal("expected unknown format error")
}
}
func TestRegistryOpenConvertsExistingManifest(t *testing.T) {
dir := t.TempDir()
m := LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("x1", "photo.jpg", 12, "local")
if err := m.Save(); err != nil {
t.Fatal(err)
}
m.Close()
converted, err := Default.Open(dir, FormatSQLite)
if err != nil {
t.Fatal(err)
}
defer converted.Close()
if !converted.Has("x1") {
t.Fatal("expected converted sqlite manifest to contain entry")
}
if FileExists(JSONLPath(dir)) {
t.Fatal("expected source jsonl manifest to be removed")
}
if !FileExists(SQLitePath(dir)) {
t.Fatal("expected sqlite manifest to exist")
}
}
func TestMemoryAdapterStore(t *testing.T) {
s := newMemoryStore()
if s.Has("x") {
t.Fatal("expected empty")
}
s.Add("x", "photo.jpg", 42, "local")
if !s.Has("x") {
t.Fatal("expected has after add")
}
s.AddEntry(Entry{ID: "y", Filename: "file.jpg"})
if e := s.Entries()["y"]; e.Path != "file.jpg" {
t.Fatal("expected default path")
}
if err := s.Save(); err != nil {
t.Fatal(err)
}
s.Close()
if err := s.OpenAppend(); err != nil {
t.Fatal(err)
}
adapter := MemoryAdapter{}
if adapter.Format() != FormatJSONL {
t.Fatal("expected JSONL format")
}
if len(adapter.Aliases()) != 0 {
t.Fatal("expected no aliases")
}
if adapter.Path("") != "" {
t.Fatal("expected empty path")
}
if adapter.Exists("") {
t.Fatal("expected not to exist")
}
m, err := adapter.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
m.Close()
w, err := adapter.OpenLogWriter(nil, t.TempDir())
if err != nil {
t.Fatal(err)
}
w.Close()
}
func TestRegistryOpenLogWriterUsesAdapter(t *testing.T) {
dir := t.TempDir()
m, err := Default.Open(dir, FormatJSONL)
if err != nil {
t.Fatal(err)
}
defer m.Close()
w, err := Default.OpenLogWriter(m, dir, FormatJSONL)
if err != nil {
t.Fatal(err)
}
w.Close()
if !FileExists(LogPath(dir)) {
t.Fatal("expected jsonl adapter to create file log")
}
}
func TestRegistryOpenCreatesRequestedAdapter(t *testing.T) {
dir := t.TempDir()
m, err := Default.Open(dir, FormatJSONL)
if err != nil {
t.Fatal(err)
}
defer m.Close()
if _, ok := m.(*jsonladapter.Store); !ok {
t.Fatalf("expected jsonl store, got %T", m)
}
}
func TestRegistryUnknownAdapterErrors(t *testing.T) {
r := NewRegistry(JSONLAdapter)
if _, err := r.Open(t.TempDir(), FormatSQLite); err == nil {
t.Fatal("expected open error for unregistered format")
}
if _, err := r.OpenLogWriter(LoadJSONL(t.TempDir()), t.TempDir(), FormatSQLite); err == nil {
t.Fatal("expected log writer error for unregistered format")
}
}
func TestSQLiteAdapterOpenLogWriterUsesSQLite(t *testing.T) {
dir := t.TempDir()
m, err := LoadSQLite(dir)
if err != nil {
t.Fatal(err)
}
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
defer m.Close()
w, err := SQLiteAdapter.OpenLogWriter(m, dir)
if err != nil {
t.Fatal(err)
}
defer w.Close()
if _, ok := w.(*sqliteadapter.LogWriter); !ok {
t.Fatalf("expected sqlite log writer, got %T", w)
}
}
func TestOpenLogWriterUsesManifestFormat(t *testing.T) {
dir := t.TempDir()
m, err := LoadSQLite(dir)
if err != nil {
t.Fatal(err)
}
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
defer m.Close()
w, err := OpenLogWriter(m, dir)
if err != nil {
t.Fatal(err)
}
defer w.Close()
if _, ok := w.(*sqliteadapter.LogWriter); !ok {
t.Fatalf("expected sqlite log writer, got %T", w)
}
}
func TestSQLiteAdapterOpenLogWriterFallsBackToFile(t *testing.T) {
dir := t.TempDir()
w, err := SQLiteAdapter.OpenLogWriter(LoadJSONL(dir), dir)
if err != nil {
t.Fatal(err)
}
w.Close()
if !FileExists(LogPath(dir)) {
t.Fatal("expected sqlite adapter fallback to create file log")
}
}
func TestSetJSONLSaveHookWrapper(t *testing.T) {
old := SetJSONLSaveHook(func() error { return nil })
SetJSONLSaveHook(old)
}
func TestConvertManifestErrorBranches(t *testing.T) {
dir := t.TempDir()
if _, err := ConvertManifest(dir, failingAdapter{format: FormatJSONL, openErr: fmt.Errorf("boom")}, JSONLAdapter); err == nil {
t.Fatal("expected source open error")
}
if _, err := ConvertManifest(dir, staticAdapter{format: FormatJSONL, store: badOpenManifest{}}, JSONLAdapter); err == nil {
t.Fatal("expected source OpenAppend error")
}
if _, err := ConvertManifest(dir, staticAdapter{format: FormatJSONL, store: noReadManifest{}}, JSONLAdapter); err == nil {
t.Fatal("expected entry reader error")
}
if _, err := ConvertManifest(dir, staticAdapter{format: FormatJSONL, store: readableManifest{}}, failingAdapter{format: FormatSQLite, openErr: fmt.Errorf("boom")}); err == nil {
t.Fatal("expected destination open error")
}
if _, err := ConvertManifest(dir, staticAdapter{format: FormatJSONL, store: readableManifest{}}, staticAdapter{format: FormatSQLite, store: badOpenManifest{}}); err == nil {
t.Fatal("expected destination OpenAppend error")
}
oldRemove := types.RemoveFunc()
types.SetRemoveFunc(func(string) error { return fmt.Errorf("remove failed") })
defer func() { types.SetRemoveFunc(oldRemove) }()
if _, err := ConvertManifest(dir, staticAdapter{format: FormatJSONL, store: readableManifest{}}, staticAdapter{format: FormatSQLite, store: readableManifest{}}); err == nil {
t.Fatal("expected remove error")
}
}
type staticAdapter struct {
format Format
store Manifest
}
func (a staticAdapter) Format() Format { return a.format }
func (a staticAdapter) Aliases() []string { return nil }
func (a staticAdapter) Path(string) string { return "manifest.file" }
func (a staticAdapter) Exists(string) bool { return true }
func (a staticAdapter) Open(string) (Manifest, error) { return a.store, nil }
func (a staticAdapter) OpenLogWriter(Manifest, string) (LogWriter, error) { return NoopLogWriter, nil }
type failingAdapter struct {
format Format
openErr error
}
func (a failingAdapter) Format() Format { return a.format }
func (a failingAdapter) Aliases() []string { return nil }
func (a failingAdapter) Path(string) string { return "manifest.file" }
func (a failingAdapter) Exists(string) bool { return true }
func (a failingAdapter) Open(string) (Manifest, error) { return nil, a.openErr }
func (a failingAdapter) OpenLogWriter(Manifest, string) (LogWriter, error) { return nil, a.openErr }
type readableManifest struct{}
func (readableManifest) Has(string) bool { return false }
func (readableManifest) Add(string, string, int64, string) {}
func (readableManifest) AddEntry(Entry) {}
func (readableManifest) Save() error { return nil }
func (readableManifest) Close() {}
func (readableManifest) OpenAppend() error { return nil }
func (readableManifest) Entries() map[string]Entry { return map[string]Entry{"x": {Filename: "x.jpg"}} }
type noReadManifest struct{}
func (noReadManifest) Has(string) bool { return false }
func (noReadManifest) Add(string, string, int64, string) {}
func (noReadManifest) AddEntry(Entry) {}
func (noReadManifest) Save() error { return nil }
func (noReadManifest) Close() {}
func (noReadManifest) OpenAppend() error { return nil }
func (noReadManifest) Entries() map[string]Entry { return nil }
type badOpenManifest struct{ readableManifest }
func (badOpenManifest) OpenAppend() error { return fmt.Errorf("open failed") }
+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()
}
@@ -1,14 +1,16 @@
package manifest package sqlite
import ( import (
"database/sql" "database/sql"
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
) )
type sqliteLogWriter struct { type LogWriter struct {
db *sql.DB db *sql.DB
} }
func NewSQLiteLogWriter(db *sql.DB) (LogWriter, error) { func NewLogWriter(db *sql.DB) (types.LogWriter, error) {
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS logs ( _, err := db.Exec(`CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL, ts INTEGER NOT NULL,
@@ -27,10 +29,10 @@ func NewSQLiteLogWriter(db *sql.DB) (LogWriter, error) {
} }
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs(ts)`) _, _ = 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)`) _, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_logs_event ON logs(event)`)
return &sqliteLogWriter{db: db}, nil return &LogWriter{db: db}, nil
} }
func (w *sqliteLogWriter) Log(e LogEntry) { func (w *LogWriter) Log(e types.LogEntry) {
if w.db == nil { if w.db == nil {
return return
} }
@@ -38,4 +40,4 @@ func (w *sqliteLogWriter) Log(e LogEntry) {
e.Timestamp, e.Level, e.Event, e.AssetID, e.Album, e.Filename, e.Size, e.Cloud, e.DurationMs, e.Message) e.Timestamp, e.Level, e.Event, e.AssetID, e.Album, e.Filename, e.Size, e.Cloud, e.DurationMs, e.Message)
} }
func (w *sqliteLogWriter) Close() { _ = w } func (w *LogWriter) Close() { _ = w }
@@ -1,4 +1,4 @@
package manifest package sqlite
import ( import (
"database/sql" "database/sql"
@@ -6,40 +6,46 @@ import (
"os" "os"
"path/filepath" "path/filepath"
_ "modernc.org/sqlite" "gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
) )
type sqliteManifest struct { 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 path string
db *sql.DB db *sql.DB
open sqlOpenerFunc open OpenerFunc
execFunc func(query string, args ...any) (sql.Result, error) execFunc func(query string, args ...any) (sql.Result, error)
} }
type sqlOpenerFunc func(driverName, dataSourceName string) (*sql.DB, error) func Path(dir string) string {
var sqliteOpenFunc sqlOpenerFunc
func defaultSQLOpener() sqlOpenerFunc {
if sqliteOpenFunc != nil {
return sqliteOpenFunc
}
return sql.Open
}
func SQLitePath(dir string) string {
return filepath.Join(dir, "downloads.db") return filepath.Join(dir, "downloads.db")
} }
func LoadSQLite(dir string) (*sqliteManifest, error) { func Load(dir string) (*Store, error) {
m := &sqliteManifest{ m := &Store{
path: SQLitePath(dir), path: Path(dir),
open: defaultSQLOpener(), open: opener(),
} }
return m, nil return m, nil
} }
func (m *sqliteManifest) OpenAppend() error { func opener() OpenerFunc {
if openerOverride != nil {
return openerOverride
}
return sql.Open
}
func (m *Store) OpenAppend() error {
if m.db != nil { if m.db != nil {
return nil return nil
} }
@@ -82,7 +88,13 @@ func (m *sqliteManifest) OpenAppend() error {
return nil return nil
} }
func (m *sqliteManifest) Has(id string) bool { 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 { if m.db == nil {
return false return false
} }
@@ -94,11 +106,15 @@ func (m *sqliteManifest) Has(id string) bool {
return count > 0 return count > 0
} }
func (m *sqliteManifest) Add(id string, filename string, size int64, cloud string) { func (m *Store) ManifestFormat() types.Format {
m.AddEntry(newEntry(id, filename, size, cloud)) return types.FormatSQLite
} }
func (m *sqliteManifest) AddEntry(entry Entry) { 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 { if m.db == nil {
return return
} }
@@ -109,33 +125,27 @@ func (m *sqliteManifest) AddEntry(entry Entry) {
entry.ID, entry.Filename, entry.Path, entry.Size, entry.Cloud, entry.Checksum, entry.Exported) entry.ID, entry.Filename, entry.Path, entry.Size, entry.Cloud, entry.Checksum, entry.Exported)
} }
func (m *sqliteManifest) Save() error { func (m *Store) Save() error { return nil }
return nil
}
func (m *sqliteManifest) Close() { func (m *Store) Close() {
if m.db != nil { if m.db != nil {
m.db.Close() m.db.Close()
m.db = nil m.db = nil
} }
} }
func (m *sqliteManifest) DB() *sql.DB { func (m *Store) Entries() map[string]types.Entry {
return m.db
}
func (m *sqliteManifest) Entries() map[string]Entry {
if m.db == nil { if m.db == nil {
return nil return nil
} }
out := make(map[string]Entry) out := make(map[string]types.Entry)
rows, err := m.db.Query(`SELECT id, filename, path, size, cloud, checksum, exported FROM downloads`) rows, err := m.db.Query(`SELECT id, filename, path, size, cloud, checksum, exported FROM downloads`)
if err != nil { if err != nil {
return out return out
} }
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
var e Entry 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 err := rows.Scan(&e.ID, &e.Filename, &e.Path, &e.Size, &e.Cloud, &e.Checksum, &e.Exported); err == nil {
if e.Path == "" { if e.Path == "" {
e.Path = e.Filename e.Path = e.Filename
@@ -145,3 +155,11 @@ func (m *sqliteManifest) Entries() map[string]Entry {
} }
return out 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()
}
-402
View File
@@ -1,402 +0,0 @@
package manifest
import (
"database/sql"
"fmt"
"os"
"strings"
"testing"
_ "modernc.org/sqlite"
)
func TestSetJSONLSaveHook(t *testing.T) {
old := SetJSONLSaveHook(func() error { return fmt.Errorf("hook error") })
if old != nil {
t.Error("expected nil old hook")
}
restore := SetJSONLSaveHook(old)
if restore == nil {
t.Error("expected non-nil restore function")
}
}
func TestJSONLSaveHookError(t *testing.T) {
dir := t.TempDir()
m := LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("x1", "photo.jpg", 1024, "local")
old := SetJSONLSaveHook(func() error { return fmt.Errorf("hook error") })
defer SetJSONLSaveHook(old)
if err := m.Save(); err == nil {
t.Error("expected hook error from Save")
}
m.Close()
}
func TestJSONLSyncFuncError(t *testing.T) {
dir := t.TempDir()
m := LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("x1", "photo.jpg", 1024, "local")
m.syncFunc = func() error { return fmt.Errorf("sync func error") }
if err := m.Save(); err == nil {
t.Error("expected syncFunc error from Save")
}
m.Close()
}
func TestSQLiteOpenAppendSQLError(t *testing.T) {
dir := t.TempDir()
m, err := LoadSQLite(dir)
if err != nil {
t.Fatal(err)
}
m.open = 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 TestSQLiteOpenAppendCreateTableError(t *testing.T) {
dir := t.TempDir()
m, err := LoadSQLite(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 CREATE TABLE")
}
}
func TestSQLiteHasAfterClose(t *testing.T) {
dir := t.TempDir()
m, err := LoadSQLite(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 TestSQLiteEntriesAfterClose(t *testing.T) {
dir := t.TempDir()
m, err := LoadSQLite(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 TestSQLiteOpenAppendMkdirAllError(t *testing.T) {
m, err := LoadSQLite("/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 TestSQLiteOpenAppendNilOpener(t *testing.T) {
dir := t.TempDir()
m, err := LoadSQLite(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 TestSQLiteOpenAppendCreateIndexError(t *testing.T) {
dir := t.TempDir()
m, err := LoadSQLite(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 TestSQLiteHasQueryError(t *testing.T) {
dir := t.TempDir()
m, err := LoadSQLite(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()
result := m.Has("x1")
if result {
t.Error("Has should return false with broken DB connection")
}
}
func TestSQLiteEntriesQueryError(t *testing.T) {
dir := t.TempDir()
m, err := LoadSQLite(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 TestSQLiteHasQueryErrorWithOpenDB(t *testing.T) {
dir := t.TempDir()
m, err := LoadSQLite(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 TestSQLiteEntriesQueryErrorWithOpenDB(t *testing.T) {
dir := t.TempDir()
m, err := LoadSQLite(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 TestSQLiteCreateIndexError(t *testing.T) {
dir := t.TempDir()
m, err := LoadSQLite(dir)
if err != nil {
t.Fatal(err)
}
realOpen := m.open
callCount := 0
m.open = func(driverName, dataSourceName string) (*sql.DB, error) {
db, err := realOpen(driverName, dataSourceName)
if err != nil {
return nil, err
}
m.execFunc = func(query string, args ...any) (sql.Result, error) {
if strings.Contains(query, "CREATE INDEX") {
return nil, fmt.Errorf("injected CREATE INDEX error")
}
callCount++
return db.Exec(query, args...)
}
return db, nil
}
if err := m.OpenAppend(); err == nil {
t.Error("expected error from CREATE INDEX")
m.Close()
}
}
func TestConvertFromJSONLOpenAppendSQLError(t *testing.T) {
dir := t.TempDir()
m := LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("x1", "photo.jpg", 1024, "local")
m.Close()
oldOpen := sqliteOpenFunc
sqliteOpenFunc = func(driverName, dataSourceName string) (*sql.DB, error) {
return nil, fmt.Errorf("simulated sqlite open error")
}
defer func() { sqliteOpenFunc = oldOpen }()
_, err := ConvertFromJSONL(dir)
if err == nil {
t.Error("expected error from dst.OpenAppend during ConvertFromJSONL")
}
}
func TestConvertFromJSONLDstOpenAppendError(t *testing.T) {
dir := t.TempDir()
m := LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("x1", "photo.jpg", 1024, "local")
m.Close()
realOpen := defaultSQLOpener()
oldOpen := sqliteOpenFunc
sqliteOpenFunc = func(driverName, dataSourceName string) (*sql.DB, error) {
db, err := realOpen(driverName, dataSourceName)
if err != nil {
return nil, err
}
db.Close()
return db, nil
}
defer func() { sqliteOpenFunc = oldOpen }()
_, err := ConvertFromJSONL(dir)
if err == nil {
t.Error("expected error from dst.OpenAppend during ConvertFromJSONL")
}
}
func TestConvertFromSQLiteSrcOpenAppendError(t *testing.T) {
dir := t.TempDir()
src, err := LoadSQLite(dir)
if err != nil {
t.Fatal(err)
}
if err := src.OpenAppend(); err != nil {
t.Fatal(err)
}
src.Add("x1", "photo.jpg", 1024, "local")
src.Close()
realOpen := defaultSQLOpener()
callCount := 0
oldOpen := sqliteOpenFunc
sqliteOpenFunc = func(driverName, dataSourceName string) (*sql.DB, error) {
callCount++
db, err := realOpen(driverName, dataSourceName)
if err != nil {
return nil, err
}
if callCount > 0 {
db.Close()
}
return db, nil
}
defer func() { sqliteOpenFunc = oldOpen }()
_, err = ConvertFromSQLite(dir)
if err == nil {
t.Error("expected error from src.OpenAppend during ConvertFromSQLite")
}
}
func TestConvertFromSQLiteDstOpenAppendError(t *testing.T) {
dir := t.TempDir()
src, err := LoadSQLite(dir)
if err != nil {
t.Fatal(err)
}
if err := src.OpenAppend(); err != nil {
t.Fatal(err)
}
src.Add("x1", "photo.jpg", 1024, "local")
src.Close()
jsonlPath := JSONLPath(dir)
f, err := os.Create(jsonlPath)
if err != nil {
t.Fatal(err)
}
f.Close()
os.Chmod(jsonlPath, 0444)
defer os.Chmod(jsonlPath, 0644)
_, err = ConvertFromSQLite(dir)
if err == nil {
t.Error("expected error from dst.OpenAppend during ConvertFromSQLite")
}
}
func TestJSONLSaveError(t *testing.T) {
dir := t.TempDir()
m := LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.Add("x1", "photo.jpg", 1024, "local")
m.file.Close()
if err := m.Save(); err == nil {
t.Error("expected Sync error on closed file")
}
m.Close()
}
func TestConvertFromSQLiteSaveError(t *testing.T) {
dir := t.TempDir()
src, err := LoadSQLite(dir)
if err != nil {
t.Fatal(err)
}
if err := src.OpenAppend(); err != nil {
t.Fatal(err)
}
src.Add("x1", "photo.jpg", 1024, "local")
src.Close()
oldHook := jsonlSaveHook
jsonlSaveHook = func() error { return fmt.Errorf("simulated sync error") }
defer func() { jsonlSaveHook = oldHook }()
_, err = ConvertFromSQLite(dir)
if err == nil {
t.Error("expected error from dst.Save during ConvertFromSQLite")
}
if err != nil && !strings.Contains(err.Error(), "save jsonl") {
t.Errorf("expected save jsonl error, got: %v", err)
}
}
@@ -1,4 +1,4 @@
package manifest package types
import ( import (
"encoding/json" "encoding/json"
+117
View File
@@ -0,0 +1,117 @@
package types
import (
"fmt"
"strings"
)
type Adapter interface {
Format() Format
Aliases() []string
Path(dir string) string
Exists(dir string) bool
Open(dir string) (Manifest, error)
OpenLogWriter(m Manifest, dir string) (LogWriter, error)
}
type Registry struct {
adapters []Adapter
}
func NewRegistry(adapters ...Adapter) Registry {
return Registry{adapters: adapters}
}
func (r Registry) ParseFormat(s string) (Format, error) {
want := strings.ToLower(s)
for _, adapter := range r.adapters {
if want == string(adapter.Format()) {
return adapter.Format(), nil
}
for _, alias := range adapter.Aliases() {
if want == strings.ToLower(alias) {
return adapter.Format(), nil
}
}
}
return "", fmt.Errorf("unknown manifest format %q (use jsonl or sqlite)", s)
}
func (r Registry) Open(dir string, format Format) (Manifest, error) {
dst, err := r.adapter(format)
if err != nil {
return nil, err
}
if dst.Exists(dir) {
return dst.Open(dir)
}
for _, src := range r.adapters {
if src.Format() != dst.Format() && src.Exists(dir) {
return ConvertManifest(dir, src, dst)
}
}
return dst.Open(dir)
}
func (r Registry) OpenLogWriter(m Manifest, dir string, format Format) (LogWriter, error) {
adapter, err := r.adapter(format)
if err != nil {
return nil, err
}
return adapter.OpenLogWriter(m, dir)
}
func (r Registry) adapter(format Format) (Adapter, error) {
for _, adapter := range r.adapters {
if adapter.Format() == format {
return adapter, nil
}
}
return nil, fmt.Errorf("unknown manifest format %q (use jsonl or sqlite)", format)
}
func ConvertManifest(dir string, src Adapter, dst Adapter) (Manifest, error) {
source, err := src.Open(dir)
if err != nil {
return nil, fmt.Errorf("open %s for read: %w", src.Format(), err)
}
if err := source.OpenAppend(); err != nil {
source.Close()
return nil, fmt.Errorf("open %s for read: %w", src.Format(), err)
}
defer source.Close()
target, err := dst.Open(dir)
if err != nil {
return nil, fmt.Errorf("open %s for write: %w", dst.Format(), err)
}
if err := target.OpenAppend(); err != nil {
target.Close()
return nil, fmt.Errorf("open %s for write: %w", dst.Format(), err)
}
for id, entry := range source.Entries() {
entry.ID = id
target.AddEntry(entry)
}
if err := target.Save(); err != nil {
target.Close()
return nil, fmt.Errorf("save %s: %w", dst.Format(), err)
}
if err := removeFile(src.Path(dir)); err != nil {
target.Close()
return nil, err
}
return target, nil
}
var removeFile func(string) error
func SetRemoveFunc(fn func(string) error) {
removeFile = fn
}
func RemoveFunc() func(string) error {
return removeFile
}
+266
View File
@@ -0,0 +1,266 @@
package types
import (
"fmt"
"testing"
)
func TestNewRegistry(t *testing.T) {
r := NewRegistry()
if _, err := r.ParseFormat("jsonl"); err == nil {
t.Fatal("expected error from empty registry")
}
}
func TestRegistryParseFormatAliases(t *testing.T) {
r := NewRegistry(testAdapter{format: FormatJSONL, aliases: []string{"json"}}, testAdapter{format: FormatSQLite, aliases: []string{"db", "sqlite3"}})
cases := map[string]Format{
"jsonl": FormatJSONL,
"json": FormatJSONL,
"sqlite": FormatSQLite,
"db": FormatSQLite,
"sqlite3": FormatSQLite,
}
for input, want := range cases {
got, err := r.ParseFormat(input)
if err != nil {
t.Fatalf("ParseFormat(%q): %v", input, err)
}
if got != want {
t.Fatalf("ParseFormat(%q) = %q, want %q", input, got, want)
}
}
}
func TestRegistryParseFormatUnknown(t *testing.T) {
r := NewRegistry()
if _, err := r.ParseFormat("bad"); err == nil {
t.Fatal("expected unknown format error")
}
}
func TestRegistryOpenDefaultsToOpen(t *testing.T) {
r := NewRegistry(testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}, exists: false})
m, err := r.Open(t.TempDir(), FormatJSONL)
if err != nil {
t.Fatal(err)
}
m.Close()
}
func TestRegistryOpenUnknownFormat(t *testing.T) {
r := NewRegistry()
if _, err := r.Open(t.TempDir(), FormatSQLite); err == nil {
t.Fatal("expected error")
}
}
func TestRegistryOpenNoConversionFallback(t *testing.T) {
r := NewRegistry(
testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}, exists: false},
testAdapter{format: FormatSQLite, store: &testStore{entries: map[string]Entry{}}, exists: false},
)
m, err := r.Open(t.TempDir(), FormatJSONL)
if err != nil {
t.Fatal(err)
}
m.Close()
}
func TestRegistryOpenSameFormatAdapterInLoop(t *testing.T) {
r := NewRegistry(
testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}, exists: false},
testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}, exists: false},
)
m, err := r.Open(t.TempDir(), FormatJSONL)
if err != nil {
t.Fatal(err)
}
m.Close()
}
func TestRegistryOpenOtherFormatNotExists(t *testing.T) {
r := NewRegistry(
testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}, exists: false},
testAdapter{format: FormatSQLite, store: &testStore{entries: map[string]Entry{}}, exists: false},
)
m, err := r.Open(t.TempDir(), FormatJSONL)
if err != nil {
t.Fatal(err)
}
m.Close()
}
func TestRegistryOpenExistingManifest(t *testing.T) {
r := NewRegistry(
testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}, exists: true},
)
m, err := r.Open(t.TempDir(), FormatJSONL)
if err != nil {
t.Fatal(err)
}
m.Close()
}
func TestRegistryOpenConverts(t *testing.T) {
dir := t.TempDir()
src := &testStore{entries: map[string]Entry{"x1": {ID: "x1", Filename: "photo.jpg", Path: "photo.jpg"}}}
dst := &testStore{entries: map[string]Entry{}}
r := NewRegistry(
testAdapter{format: FormatJSONL, store: src, exists: true},
testAdapter{format: FormatSQLite, store: dst, exists: false},
)
SetRemoveFunc(func(string) error { return nil })
m, err := r.Open(dir, FormatSQLite)
if err != nil {
t.Fatal(err)
}
m.Close()
if _, ok := dst.entries["x1"]; !ok {
t.Fatal("expected converted entry")
}
}
func TestRegistryOpenLogWriter(t *testing.T) {
r := NewRegistry(testAdapter{format: FormatJSONL, logWriter: NoopLogWriter})
w, err := r.OpenLogWriter(nil, t.TempDir(), FormatJSONL)
if err != nil {
t.Fatal(err)
}
w.Close()
}
func TestRegistryOpenLogWriterUnknown(t *testing.T) {
r := NewRegistry()
if _, err := r.OpenLogWriter(nil, t.TempDir(), FormatSQLite); err == nil {
t.Fatal("expected error")
}
}
func TestConvertManifest(t *testing.T) {
dir := t.TempDir()
src := &testStore{entries: map[string]Entry{"x1": {ID: "x1", Filename: "photo.jpg", Path: "photo.jpg"}}}
dst := &testStore{entries: map[string]Entry{}}
SetRemoveFunc(func(string) error { return nil })
m, err := ConvertManifest(dir, testAdapter{format: FormatJSONL, store: src, exists: true}, testAdapter{format: FormatSQLite, store: dst, exists: false})
if err != nil {
t.Fatal(err)
}
m.Close()
if _, ok := dst.entries["x1"]; !ok {
t.Fatal("expected converted entry")
}
}
func TestConvertManifestSourceOpenError(t *testing.T) {
if _, err := ConvertManifest(t.TempDir(), testAdapter{format: FormatJSONL, openErr: fmt.Errorf("boom")}, testAdapter{format: FormatSQLite, store: &testStore{}}); err == nil {
t.Fatal("expected error")
}
}
func TestConvertManifestSourceOpenAppendError(t *testing.T) {
if _, err := ConvertManifest(t.TempDir(), testAdapter{format: FormatJSONL, store: &testStore{openAppendErr: fmt.Errorf("boom")}}, testAdapter{format: FormatSQLite, store: &testStore{}}); err == nil {
t.Fatal("expected error")
}
}
func TestConvertManifestDstOpenError(t *testing.T) {
if _, err := ConvertManifest(t.TempDir(), testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}}, testAdapter{format: FormatSQLite, openErr: fmt.Errorf("boom")}); err == nil {
t.Fatal("expected error")
}
}
func TestConvertManifestDstOpenAppendError(t *testing.T) {
if _, err := ConvertManifest(t.TempDir(), testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}}, testAdapter{format: FormatSQLite, store: &testStore{openAppendErr: fmt.Errorf("boom")}}); err == nil {
t.Fatal("expected error")
}
}
func TestConvertManifestDstSaveError(t *testing.T) {
if _, err := ConvertManifest(t.TempDir(), testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}}, testAdapter{format: FormatSQLite, store: &testStore{saveErr: fmt.Errorf("boom")}}); err == nil {
t.Fatal("expected error")
}
}
func TestConvertManifestRemoveError(t *testing.T) {
SetRemoveFunc(func(string) error { return fmt.Errorf("remove failed") })
defer SetRemoveFunc(nil)
if _, err := ConvertManifest(t.TempDir(), testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}}, testAdapter{format: FormatSQLite, store: &testStore{}}); err == nil {
t.Fatal("expected error")
}
}
func TestSetRemoveFunc(t *testing.T) {
SetRemoveFunc(func(string) error { return nil })
if RemoveFunc() == nil {
t.Fatal("expected non-nil remove func")
}
SetRemoveFunc(nil)
if RemoveFunc() != nil {
t.Fatal("expected nil remove func")
}
}
func TestNoopLogWriterMethods(t *testing.T) {
NoopLogWriter.Log(LogEntry{})
NoopLogWriter.Close()
var n noopLogWriter
n.Log(LogEntry{})
n.Close()
}
type testAdapter struct {
format Format
aliases []string
store Manifest
exists bool
openErr error
logWriter LogWriter
}
func (a testAdapter) Format() Format { return a.format }
func (a testAdapter) Aliases() []string { return a.aliases }
func (a testAdapter) Path(string) string { return "test" }
func (a testAdapter) Exists(string) bool { return a.exists }
func (a testAdapter) Open(string) (Manifest, error) {
if a.openErr != nil {
return nil, a.openErr
}
return a.store, nil
}
func (a testAdapter) OpenLogWriter(Manifest, string) (LogWriter, error) {
if a.logWriter != nil {
return a.logWriter, nil
}
return NoopLogWriter, nil
}
type testStore struct {
entries map[string]Entry
openAppendErr error
saveErr error
}
func (s *testStore) Has(id string) bool {
_, ok := s.entries[id]
return ok
}
func (s *testStore) Add(id string, filename string, size int64, cloud string) {
s.entries[id] = NewEntry(id, filename, filename, size, cloud)
}
func (s *testStore) AddEntry(e Entry) {
if e.Path == "" {
e.Path = e.Filename
}
s.entries[e.ID] = e
}
func (s *testStore) Save() error { return s.saveErr }
func (s *testStore) Close() {}
func (s *testStore) OpenAppend() error { return s.openAppendErr }
func (s *testStore) Entries() map[string]Entry {
out := make(map[string]Entry, len(s.entries))
for k, v := range s.entries {
out[k] = v
}
return out
}
+86
View File
@@ -0,0 +1,86 @@
package types
import "time"
type Entry struct {
ID string
Filename string
Path string
Size int64
Cloud string
Checksum string
Exported int64
}
type Manifest interface {
Has(id string) bool
Add(id string, filename string, size int64, cloud string)
AddEntry(entry Entry)
Save() error
Close()
OpenAppend() error
Entries() map[string]Entry
}
type EntryReader = Manifest
type Format string
const (
FormatJSONL Format = "jsonl"
FormatSQLite Format = "sqlite"
)
type LogEntry struct {
Timestamp int64 `json:"ts"`
Level string `json:"level"`
Event string `json:"event"`
AssetID string `json:"asset_id,omitempty"`
Album string `json:"album,omitempty"`
Filename string `json:"filename,omitempty"`
Size int64 `json:"size,omitempty"`
Cloud string `json:"cloud,omitempty"`
DurationMs int64 `json:"duration_ms,omitempty"`
Message string `json:"message,omitempty"`
}
type LogWriter interface {
Log(entry LogEntry)
Close()
}
type noopLogWriter struct{}
func (noopLogWriter) Log(LogEntry) { _ = struct{}{} }
func (noopLogWriter) Close() { _ = struct{}{} }
var NoopLogWriter LogWriter = noopLogWriter{}
type FormatReporter interface {
ManifestFormat() Format
}
func LogPath(dir string) string {
return dir + "/export.log"
}
func NewEntry(id, filename, path string, size int64, cloud string) Entry {
e := Entry{
ID: id,
Filename: filename,
Path: filename,
Size: size,
Cloud: cloud,
Exported: time.Now().Unix(),
}
if path != "" {
e.Path = path
}
return e
}
func NewEntryWithChecksum(id, filename, path string, size int64, cloud, checksum string) Entry {
e := NewEntry(id, filename, path, size, cloud)
e.Checksum = checksum
return e
}
+77
View File
@@ -0,0 +1,77 @@
package types
import (
"os"
"testing"
)
func TestNewEntryPath(t *testing.T) {
e := NewEntry("id1", "file.jpg", "", 123, "local")
if e.ID != "id1" || e.Filename != "file.jpg" || e.Path != "file.jpg" || e.Size != 123 || e.Cloud != "local" || e.Exported == 0 {
t.Fatalf("unexpected entry: %+v", e)
}
e = NewEntry("id2", "file2.jpg", "Album/file2.jpg", 456, "cloud")
if e.Path != "Album/file2.jpg" || e.Filename != "file2.jpg" || e.Size != 456 || e.Cloud != "cloud" {
t.Fatalf("unexpected entry with path: %+v", e)
}
e = NewEntryWithChecksum("id3", "file3.jpg", "Album/file3.jpg", 789, "local", "sha256:abc")
if e.Checksum != "sha256:abc" || e.Path != "Album/file3.jpg" {
t.Fatalf("unexpected checksum entry: %+v", e)
}
}
func TestNoopLogWriter(t *testing.T) {
NoopLogWriter.Log(LogEntry{Event: "test"})
NoopLogWriter.Close()
}
func TestNewFileLogWriter(t *testing.T) {
dir := t.TempDir()
path := LogPath(dir)
w, err := NewFileLogWriter(path)
if err != nil {
t.Fatal(err)
}
w.Log(LogEntry{Timestamp: 1700000000, Level: "info", Event: "export_done", Filename: "photo.jpg", Size: 2048})
w.Close()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if len(data) == 0 {
t.Error("expected log data")
}
if data[len(data)-1] != '\n' {
t.Error("expected trailing newline")
}
}
func TestFileLogWriterClosed(t *testing.T) {
w, err := NewFileLogWriter(LogPath(t.TempDir()))
if err != nil {
t.Fatal(err)
}
w.Close()
w.Log(LogEntry{Event: "after-close"})
}
func TestNewFileLogWriterError(t *testing.T) {
if _, err := NewFileLogWriter("/nonexistent/dir/export.log"); err == nil {
t.Error("expected error for bad path")
}
}
func TestFileLogWriterDoubleClose(t *testing.T) {
w, err := NewFileLogWriter(LogPath(t.TempDir()))
if err != nil {
t.Fatal(err)
}
w.Close()
w.Close()
}
func TestLogPath(t *testing.T) {
if got := LogPath("/tmp/out"); got != "/tmp/out/export.log" {
t.Fatalf("LogPath = %q", got)
}
}