Files
bookhoard/internal/services/media_scanner_manifest_test.go
T
John O'Keefe 6750e9e0dc feat(scanner): manifest-gated sidecar change detection + playlist sync on change path
Part 2 of the faceted plan, Phase 4 (steps 3-4). Size-only detection
was blind to metadata-sidecar edits; a size-stable book is changed by
EDITING its metadata.opf/metadata.json/cover — previously invisible
until a force rescan.

- Sidecar manifest fingerprints (media_scanner_manifest.go): the
  manifest stats exactly the names the extractors consume (metadata.opf,
  metadata.json, findSidecarCover patterns incl. <basename>.<ext>), so
  manifest and extractors can never disagree. Shape v1:
  {v, entries:[{n, s, m (unix nanos — second granularity would hide
  edits landing in the backfill scan's own second), h (sha256 — covers
  included: the repoint ladder needs a baseline hash for any stat-
  drifted sidecar; a hashless cover turns harmless mtime drift into a
  re-extraction, the exact flood this design kills)]}.
- Evaluation ladder in cost order: stat pairs identical → skip with
  ZERO bytes read; stat drift → hash that sidecar (equal = host repoint
  reconciliation, stat pair refreshed, no re-extraction; differing =
  changed); NULL stored manifest → backfill only, NO re-extraction
  (deploy-day rule: the first scan after release must not look like an
  mtime flood).
- Change path: re-read full row, extractMetadata once and pass it
  through updateMediaItemWithMetadata (preExtracted — metadata_overrides
  stay protected), run syncPlaylistCollectionsAfterCreate (Correction 2:
  a reading-order entry added to an EXISTING book now creates its
  collection; add-if-missing is idempotent), store a fresh full
  manifest. Creates seed their manifest at insert time.
- Multifile folder/zip books intentionally keep Phase 3 behavior: their
  change path does not consume sidecar extractors, so gating them would
  flip books 'changed' without picking anything up (plan correction —
  the plan's §3 multifile note assumed otherwise).
- UpsertMediaScanState + GetMediaScanState queries.

Tests: media_scanner_manifest_test.go (ladder unit tests — fast path,
repoint, same-size drift, appear/vanish, unhashable legacy entries);
TestScanManifestLifecycle (seeding, unchanged-skip stability, repoint
reconciliation with stat-pair refresh assertion, same-size edit
consumed, playlist sync on the change path + idempotency, no-leak
guard). Container runner.
2026-10-19 20:17:57 -04:00

137 lines
4.8 KiB
Go

package services
// Unit tests for the sidecar manifest ladder (faceted plan Part 2 §3):
// stat-pair fast compare, hash-based repoint reconciliation, and the
// change verdicts. Real files only where the hash compare needs them.
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestDecodeSidecarManifest(t *testing.T) {
if decodeSidecarManifest(nil) != nil {
t.Error("NULL manifest decodes to nil (backfill territory)")
}
if decodeSidecarManifest([]byte("{not json")) != nil {
t.Error("corrupt manifest decodes to nil (self-healing backfill)")
}
m := decodeSidecarManifest([]byte(`{"v":1,"entries":[{"n":"metadata.opf","s":10,"m":100,"h":"abc"}]}`))
if m == nil || len(m.Entries) != 1 || m.Entries[0].Sha != "abc" {
t.Errorf("valid manifest round-trip failed: %+v", m)
}
}
func TestReconcileSidecarManifest(t *testing.T) {
dir := t.TempDir()
write := func(name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
// The book: one hashed sidecar + one size-only cover.
write("metadata.opf", `<opf>original content</opf>`)
originalSha, err := sha256File(filepath.Join(dir, "metadata.opf"))
if err != nil {
t.Fatal(err)
}
write("cover.jpg", "coverbytes")
// stored is seeded exactly the way production backfill does: a real
// stat pass + hash fill. Hand-built fake mtimes would drift on the
// first compare (real files' mtimes never equal a constant).
stored := hashSidecarManifest("book.epub", dir, statSidecarManifest(filepath.Join(dir, "book.epub"), dir))
stat := func() *sidecarManifest {
m := statSidecarManifest(filepath.Join(dir, "book.epub"), dir)
if m == nil || len(m.Entries) < 2 {
t.Fatalf("stat manifest should find both sidecars, got %+v", m)
}
return m
}
// Stat pairs identical → unchanged, zero reads, nothing to store.
fresh, changed := reconcileSidecarManifest(dir, stored, stat())
if changed || fresh != nil {
t.Errorf("identical stats must be unchanged, got changed=%v fresh=%v", changed, fresh)
}
// Repoint: mtime drifts, content identical → reconciled fresh
// manifest with the refreshed stat pair and the KEPT hash, no change.
if err := os.Chtimes(filepath.Join(dir, "metadata.opf"), time.Now(), time.Now().Add(time.Minute)); err != nil {
t.Fatal(err)
}
fresh, changed = reconcileSidecarManifest(dir, stored, stat())
if changed {
t.Error("mtime drift with identical content is repoint reconciliation, not a change")
}
if fresh == nil || len(fresh.Entries) != 2 {
t.Fatalf("repoint must produce a refreshed manifest, got %+v", fresh)
}
if fresh.Entries[0].Sha != originalSha {
t.Error("repointed entry keeps its stored hash")
}
if fresh.Entries[0].MTime == stored.Entries[0].MTime {
t.Error("repointed entry refreshes its stat pair")
}
// Same-size edit: drift + hash differs → changed.
write("metadata.opf", `<opf>mutated XYY content</opf>`) // +1 byte, and hash differs
_, changed = reconcileSidecarManifest(dir, stored, stat())
if !changed {
t.Error("content drift must be a change")
}
// Same-LENGTH edit (the size-only detection blind spot) → changed.
write("metadata.opf", `<opf>original Xontent</opf>`)
_, changed = reconcileSidecarManifest(dir, stored, stat())
if !changed {
t.Error("same-size content drift must be a change (hash compare)")
}
// New sidecar appears → changed.
write("metadata.json", `{"title":"X"}`)
_, changed = reconcileSidecarManifest(dir, stored, stat())
if !changed {
t.Error("an appearing sidecar must be a change")
}
// Sidecar vanishes AND everything else returns to the stored state
// (opf restored; its mtime drifts but the hash proves equality) →
// the manifest reconciles, not a change.
if err := os.Remove(filepath.Join(dir, "metadata.json")); err != nil {
t.Fatal(err)
}
write("metadata.opf", `<opf>original content</opf>`)
fresh, changed = reconcileSidecarManifest(dir, stored, stat())
if changed {
t.Errorf("returning to the stored state is not a change: %+v", fresh)
}
if fresh == nil || len(fresh.Entries) != 2 {
t.Errorf("reconciled manifest covers both sidecars: %+v", fresh)
}
}
// An unhashable drift (a stored entry with no baseline hash — legacy
// or partially-written manifest) is a change: the manifest cannot prove
// content equality for it.
func TestReconcileUnhashableDrift(t *testing.T) {
dir := t.TempDir()
stored := &sidecarManifest{Version: 1, Entries: []manifestSidecar{
{Name: "cover.jpg", Size: 10, MTime: 1000},
}}
computed := &sidecarManifest{Version: 1, Entries: []manifestSidecar{
{Name: "cover.jpg", Size: 11, MTime: 1001},
}}
fresh, changed := reconcileSidecarManifest(dir, stored, computed)
if !changed {
t.Error("stat drift without a stored baseline hash must be a change")
}
if fresh != nil {
t.Error("a change never returns a fresh manifest (caller builds one)")
}
}