Release / build-and-push (push) Successful in 3m20s
The extractor/force-rescan tests assert manifest stamps against the default version (1), but the setting lives in the SHARED dev DB — any prior run (or a manual admin-UI bump) can leave it at 2, failing every stamp assertion through no fault of the code. Each ev-sensitive test now pins the setting to 1 at start and restores 1 on cleanup; proven against a deliberately bumped setting (tests pass, row restored).
188 lines
7.2 KiB
Go
188 lines
7.2 KiB
Go
package main
|
|
|
|
// Phase 13 (faceted plan): force rescan = original-scan semantics, and
|
|
// the manually_edited filter.
|
|
//
|
|
// Force rescan contract: re-review every field on every book from all
|
|
// sources (sidecars → folder structure → tags → fallbacks) on multi-
|
|
// file books too — the pre-Phase-13 skip is gone — with
|
|
// metadata_overrides as the only protection. A force pass also stamps
|
|
// the fresh manifest with the current parser version (success-only)
|
|
// and closes the book's open ledger issues.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/services"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// pinExtractorVersionOne pins the SHARED dev DB's extractor version to
|
|
// the default 1 for the test's duration. Prior interrupted runs can
|
|
// leave it bumped (an early test failure aborts before its restore
|
|
// cleanup registers), and every stamp assertion in these tests assumes
|
|
// the default. The cleanup restores 1 — the repo's steady state.
|
|
func pinExtractorVersionOne(t *testing.T, setup *TestServerSetup) {
|
|
t.Helper()
|
|
setExtractorVersion(t, setup, 1)
|
|
t.Cleanup(func() { setExtractorVersion(t, setup, 1) })
|
|
}
|
|
|
|
// forceScanTestFolder runs a FORCE scan over one folder.
|
|
func forceScanTestFolder(t *testing.T, setup *TestServerSetup, adminID pgtype.UUID, folder string) {
|
|
t.Helper()
|
|
scanner := services.NewMediaScanner(setup.DB)
|
|
defer func() { _ = scanner.Close() }()
|
|
scanner.SetAdminID(adminID)
|
|
require.NoError(t, scanner.SetFolders([]string{folder}, false))
|
|
scanner.SetForce(true)
|
|
require.NoError(t, scanner.ScanFolders(context.Background()))
|
|
}
|
|
|
|
func TestForceRescanMultifileSidecarApply(t *testing.T) {
|
|
setup := setupTestServer(t)
|
|
t.Cleanup(func() { _ = setup.Close() })
|
|
|
|
ctx := context.Background()
|
|
admin, _ := moveTestAdminID(t, setup)
|
|
pinExtractorVersionOne(t, setup)
|
|
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Force Rescan %d", time.Now().UnixNano()))
|
|
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
|
|
|
writeTestTrack(t, filepath.Join(folder, "Force Author", "Force Title", "01 First.mp3"))
|
|
writeTestTrack(t, filepath.Join(folder, "Force Author", "Force Title", "02 Second.mp3"))
|
|
|
|
scanTestFolder(t, setup, admin, folder)
|
|
rel := filepath.ToSlash("Force Author/Force Title")
|
|
book, found := findAudioItemByPath(t, setup, libID, rel)
|
|
require.True(t, found, "folder book must import")
|
|
require.Equal(t, "Force Title", book.Title)
|
|
|
|
// Corrupt, then FORCE: the pre-Phase-13 skip is gone — the full
|
|
// chain re-derives the title from the folder basename.
|
|
corruptItemColumn(t, setup, book.ID, "title", "ZZZ")
|
|
forceScanTestFolder(t, setup, admin, folder)
|
|
book, found = findAudioItemByPath(t, setup, libID, rel)
|
|
require.True(t, found)
|
|
require.Equal(t, "Force Title", book.Title, "force rescan must re-apply folder-book metadata")
|
|
require.Equal(t, 1, storedManifestEv(t, setup, book.ID), "force rescan stamps the current parser version")
|
|
|
|
// metadata_overrides is the ONLY protection: a recorded override
|
|
// survives the force pass untouched.
|
|
_, err := setup.DBPool.Exec(ctx,
|
|
`UPDATE media_items SET metadata_overrides = ARRAY['title'], title = 'Manual Title' WHERE id = $1`,
|
|
book.ID)
|
|
require.NoError(t, err)
|
|
forceScanTestFolder(t, setup, admin, folder)
|
|
book, _ = findAudioItemByPath(t, setup, libID, rel)
|
|
require.Equal(t, "Manual Title", book.Title, "metadata_overrides protect manual edits under force rescan")
|
|
}
|
|
|
|
func TestForceRescanSingleFileSidecarReapply(t *testing.T) {
|
|
setup := setupTestServer(t)
|
|
t.Cleanup(func() { _ = setup.Close() })
|
|
|
|
admin, _ := moveTestAdminID(t, setup)
|
|
pinExtractorVersionOne(t, setup)
|
|
libID, folder := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Force Single %d", time.Now().UnixNano()), "ebooks")
|
|
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
|
|
|
bookDir := filepath.Join(folder, "Force Probe Author", "Force 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>Force Probe Book</dc:title>
|
|
<dc:creator>Force 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, "Force Probe Book.epub"), []byte("dummy epub content"), 0o644))
|
|
|
|
scanTestFolder(t, setup, admin, folder)
|
|
rel := filepath.ToSlash("Force Probe Author/Force Probe Book/Force Probe Book.epub")
|
|
book, found := findAudioItemByPath(t, setup, libID, rel)
|
|
require.True(t, found)
|
|
|
|
// Single-file force rescan already ran the full chain — pin it, and
|
|
// pin the NEW part: the manifest comes out stamped.
|
|
corruptItemColumn(t, setup, book.ID, "title", "ZZZ")
|
|
forceScanTestFolder(t, setup, admin, folder)
|
|
book, found = findAudioItemByPath(t, setup, libID, rel)
|
|
require.True(t, found)
|
|
require.Equal(t, "Force Probe Book", book.Title, "force rescan re-derives sidecar metadata (single-file)")
|
|
require.Equal(t, 1, storedManifestEv(t, setup, book.ID), "force rescan stamps the manifest")
|
|
}
|
|
|
|
func TestManuallyEditedFilter(t *testing.T) {
|
|
setup := setupTestServer(t)
|
|
t.Cleanup(func() { _ = setup.Close() })
|
|
|
|
ctx := context.Background()
|
|
libID, _ := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Manual Edits %d", time.Now().UnixNano()), "ebooks")
|
|
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
|
|
|
createBook := func(path string) pgtype.UUID {
|
|
created, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
|
LibraryID: libID,
|
|
Title: path,
|
|
Authors: []string{},
|
|
FilePath: path,
|
|
FileSize: pgtype.Int8{Int64: 64, Valid: true},
|
|
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
|
})
|
|
require.NoError(t, err)
|
|
return created.ID
|
|
}
|
|
editedID := createBook("edited-book.epub")
|
|
cleanID := createBook("clean-book.epub")
|
|
_, err := setup.DBPool.Exec(ctx,
|
|
`UPDATE media_items SET metadata_overrides = ARRAY['title'] WHERE id = $1`,
|
|
editedID)
|
|
require.NoError(t, err)
|
|
|
|
qfIDs := func(op string) []string {
|
|
status, rows := qfSearch(t, setup, setup.Token, map[string][]string{
|
|
"qf": {qfAll(qfRule("manually_edited", op))},
|
|
"library_id": {libID.String()},
|
|
})
|
|
require.Equal(t, http.StatusOK, status)
|
|
ids := make([]string, 0, len(rows))
|
|
for _, r := range rows {
|
|
ids = append(ids, r["id"].(string))
|
|
}
|
|
return ids
|
|
}
|
|
|
|
trueIDs := qfIDs("is_true")
|
|
require.Len(t, trueIDs, 1)
|
|
assert.Equal(t, editedID.String(), trueIDs[0], "is_true matches only manually-edited books")
|
|
|
|
falseIDs := qfIDs("is_false")
|
|
require.Len(t, falseIDs, 1)
|
|
assert.Equal(t, cleanID.String(), falseIDs[0], "is_false matches only untouched books")
|
|
|
|
// The deep-linkable JSON shape round-trips through the qf parser
|
|
// (arity-none rule: no values on the wire).
|
|
var parsed struct {
|
|
Mode string `json:"mode"`
|
|
Children []struct {
|
|
Field string `json:"field"`
|
|
} `json:"children"`
|
|
}
|
|
require.NoError(t, json.Unmarshal([]byte(qfAll(qfRule("manually_edited", "is_true"))), &parsed))
|
|
require.Len(t, parsed.Children, 1)
|
|
assert.Equal(t, "manually_edited", parsed.Children[0].Field)
|
|
}
|