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) } } // TestStatSidecarManifestSentinelNames pins the Phase 6 name derivation // for folder books: the manifest must stat exactly the names the // extractor consumes through the sentinel (metadata sidecars + the // standard cover set) — never the directory's own basename (the // extractor's basename-pattern covers resolve against the sentinel's // basename, which generates no candidates). func TestStatSidecarManifestSentinelNames(t *testing.T) { dir := t.TempDir() write := func(name string) { t.Helper() if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil { t.Fatal(err) } } write("metadata.json") write("cover.jpg") write(filepath.Base(dir) + ".jpg") // extractor never reads this sentinel := filepath.Join(dir, ".audiobook-root") m := statSidecarManifest(sentinel, dir) if m == nil { t.Fatal("manifest must stat, not nil") } seen := map[string]bool{} for _, e := range m.Entries { seen[e.Name] = true } if !seen["metadata.json"] || !seen["cover.jpg"] { t.Errorf("extractor-consumed sidecars missing from manifest: %v", seen) } if seen[filepath.Base(dir)+".jpg"] { t.Errorf("directory-basename cover must not be tracked (extractor ignores it): %v", seen) } for name := range seen { if name == ".audiobook-root.jpg" { t.Errorf("sentinel-basename candidates must not appear (they cannot exist): %v", seen) } } } 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", `original content`) 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", `mutated XYY content`) // +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", `original Xontent`) _, 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", `original content`) 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)") } }