Files
bookhoard/cmd/server/tests/extractor_version_test.go
John O'Keefe 3ed852515b test(helpers): setSystemSettingRestored — leave shared settings as found
The testuser-experience rule: the suite must leave shared settings
exactly as it found them. setSystemSettingRestored snapshots the prior
system_settings row, writes through the sanctioned API path (the only
one that reloads the settings registry — raw SQL leaves the cache
stale), and registers a cleanup restoring what WAS there, never a
hardcoded value.

setExtractorVersion now rides it, so the ev-pin tests no longer
restore scan_extractor_version to a hardcoded 1: a deliberate dev bump
of the extractor version survives a suite run instead of being
silently cancelled (the live dev server obeys that setting on its next
poll and would stop re-extracting parser fixes). The now-redundant
explicit t.Cleanup restores are removed from
TestExtractorVersionReextract and pinExtractorVersionOne.
2026-10-21 20:24:44 -04:00

182 lines
8.8 KiB
Go

package main
// Extractor-version (ev) mechanism, faceted plan Phase 10: parser fixes
// (7e4c97f6, genre model v2) must reach rows ingested under an older
// parser without a force rescan. The manifest's ev stamp is the
// per-item provenance; scan_extractor_version is the current parser's
// claim; a mismatch re-extracts on a later scan, bounded per scan.
//
// State machine pinned here, driven through REAL scans over a temp
// library (the corruption probe makes re-extraction observable: the
// only writer that restores the sidecar values is a re-extraction):
//
// create → stamped ev=1 (current parser, truthful)
// equal → no re-extract (corruption persists)
// wiped → backfill stores ev UNSET, never stamps, never re-extracts
// bump → re-extract ONCE (corruption healed, ev=2)
// equal → stable (second corruption persists)
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/require"
)
// storedManifestEv reads the "ev" stamp from an item's stored manifest
// (0 = absent — the backfill's v1-or-unknown marker).
func storedManifestEv(t *testing.T, setup *TestServerSetup, itemID pgtype.UUID) int {
t.Helper()
row, err := setup.DB.GetMediaScanState(context.Background(), itemID)
require.NoError(t, err, "scan-state row must exist — every book needs an ev home")
var m map[string]any
require.NoError(t, json.Unmarshal(row.Manifest, &m))
ev, _ := m["ev"].(float64)
return int(ev)
}
// corruptItemColumn scribbles a scan-owned column on a TEST-CREATED item
// so a later re-extraction is observable (the sidecar rewrites it back).
// The row is test data cascade-deleted with the library — never real rows.
func corruptItemColumn(t *testing.T, setup *TestServerSetup, itemID pgtype.UUID, column, value string) {
t.Helper()
tag, err := setup.DBPool.Exec(context.Background(),
fmt.Sprintf("UPDATE media_items SET %s = $1 WHERE id = $2", column), value, itemID)
require.NoError(t, err)
require.Equal(t, int64(1), tag.RowsAffected(), "corruption must hit exactly the test item")
}
func setExtractorVersion(t *testing.T, setup *TestServerSetup, v int) {
t.Helper()
// Restore-to-prior (not a hardcoded value): the shared dev DB's
// setting must survive the suite exactly as the test found it — if a
// parser-fix bump was in flight, a hardcoded 1 would silently cancel
// it and the live server would stop re-extracting.
setSystemSettingRestored(t, setup, "scan_extractor_version", strconv.Itoa(v))
}
func TestExtractorVersionReextract(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
ctx := context.Background()
admin, _ := moveTestAdminID(t, setup)
pinExtractorVersionOne(t, setup)
libID, folder := createTypedTestLibrary(t, setup, fmt.Sprintf("Test EV Library %d", time.Now().UnixNano()), "ebooks")
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
// metadata.opf sidecar: the re-extraction's provenance source.
bookDir := filepath.Join(folder, "EV Probe Author", "EV Probe Book")
require.NoError(t, os.MkdirAll(bookDir, 0o755))
opf := `<?xml version='1.0' encoding='utf-8'?>
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>EV Probe Book</dc:title>
<dc:creator>EV Probe Author</dc:creator>
</metadata>
</package>`
require.NoError(t, os.WriteFile(filepath.Join(bookDir, "metadata.opf"), []byte(opf), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(bookDir, "EV Probe Book.epub"), []byte("dummy epub content"), 0o644))
scanTestFolder(t, setup, admin, folder)
item, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Probe Author/EV Probe Book/EV Probe Book.epub"))
require.True(t, found, "sidecar book must import")
// Create stamps the current parser version (truthful).
require.Equal(t, 1, storedManifestEv(t, setup, item.ID), "create must stamp the default ev=1")
// Equality → skip: corruption persists (nothing re-extracts at ev=1).
// NOTE: `author` is trigger-owned (sync_author_publisher_display
// rewrites it from the authors array on every UPDATE) — corrupt a
// trigger-free column the re-extraction rewrites: title.
corruptItemColumn(t, setup, item.ID, "title", "ZZZ corrupted")
scanTestFolder(t, setup, admin, folder)
item, found = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Probe Author/EV Probe Book/EV Probe Book.epub"))
require.True(t, found)
require.Equal(t, "ZZZ corrupted", item.Title, "equal ev must NOT re-extract (corruption persists)")
require.Equal(t, 1, storedManifestEv(t, setup, item.ID))
// Wipe the scan state (test-owned row) to simulate a pre-release
// book: backfill re-fingerprints WITHOUT re-extraction and WITHOUT
// stamping — the row never claims a version it can't prove.
_, err := setup.DBPool.Exec(ctx, "DELETE FROM media_scan_state WHERE media_item_id = $1", item.ID)
require.NoError(t, err)
scanTestFolder(t, setup, admin, folder)
require.Equal(t, 0, storedManifestEv(t, setup, item.ID), "backfill must store ev UNSET (v1-or-unknown)")
item, _ = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Probe Author/EV Probe Book/EV Probe Book.epub"))
require.Equal(t, "ZZZ corrupted", item.Title, "backfill must NOT re-extract")
// Bump via the SETTINGS API; the absent ev rides v1 → mismatch →
// re-extract once: the corruption heals and the fresh manifest
// carries ev=2.
setExtractorVersion(t, setup, 2)
// Prior-value restore rides inside setExtractorVersion — never a
// hardcoded 1, so a deliberate dev bump survives the suite.
scanTestFolder(t, setup, admin, folder)
item, found = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Probe Author/EV Probe Book/EV Probe Book.epub"))
require.True(t, found)
require.Equal(t, "EV Probe Book", item.Title, "ev mismatch must re-extract (sidecar value restored)")
require.Equal(t, 2, storedManifestEv(t, setup, item.ID), "post-extraction store stamps the new ev")
// Then stable: the second corruption persists at ev=2 == setting 2.
corruptItemColumn(t, setup, item.ID, "title", "ZZZ corrupted again")
scanTestFolder(t, setup, admin, folder)
item, _ = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Probe Author/EV Probe Book/EV Probe Book.epub"))
require.Equal(t, "ZZZ corrupted again", item.Title, "equal ev must NOT re-extract again (stable)")
require.Equal(t, 2, storedManifestEv(t, setup, item.ID))
}
func TestExtractorVersionMultifile(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
admin, _ := moveTestAdminID(t, setup)
pinExtractorVersionOne(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test EV Audio %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
// A canonical folder book, sidecar-less: the Phase 6 seed gives it
// an entries-less manifest — now also its ev home.
writeTestTrack(t, filepath.Join(folder, "EV Audio Author", "EV Audio Title", "01 First.mp3"))
writeTestTrack(t, filepath.Join(folder, "EV Audio Author", "EV Audio Title", "02 Second.mp3"))
scanTestFolder(t, setup, admin, folder)
book, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Audio Author/EV Audio Title"))
require.True(t, found, "folder book must import")
require.Equal(t, "EV Audio Title", book.Title)
require.Equal(t, 1, storedManifestEv(t, setup, book.ID), "multifile create must stamp the default ev=1")
// Equality → skip: the multifile ladder's unchanged gate stays shut.
corruptItemColumn(t, setup, book.ID, "title", "ZZZ corrupted")
scanTestFolder(t, setup, admin, folder)
book, _ = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Audio Author/EV Audio Title"))
require.Equal(t, "ZZZ corrupted", book.Title, "equal ev must NOT re-apply (corruption persists)")
require.Equal(t, 1, storedManifestEv(t, setup, book.ID))
// Bump → the ev gate fires through multifileSidecarChanged → the
// editorial apply re-derives the title from the folder basename.
setExtractorVersion(t, setup, 2)
// Prior-value restore rides inside setExtractorVersion — never a
// hardcoded 1, so a deliberate dev bump survives the suite.
scanTestFolder(t, setup, admin, folder)
book, found = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Audio Author/EV Audio Title"))
require.True(t, found)
require.Equal(t, "EV Audio Title", book.Title, "ev mismatch must re-extract the folder book")
require.Equal(t, 2, storedManifestEv(t, setup, book.ID), "change-path store stamps the new ev")
// Then stable.
corruptItemColumn(t, setup, book.ID, "title", "ZZZ corrupted again")
scanTestFolder(t, setup, admin, folder)
book, _ = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Audio Author/EV Audio Title"))
require.Equal(t, "ZZZ corrupted again", book.Title, "equal ev must NOT re-apply again (stable)")
require.Equal(t, 2, storedManifestEv(t, setup, book.ID))
}