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.
182 lines
8.8 KiB
Go
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))
|
|
}
|