From f62b46fb15be6a651c3e2e50dee8c2bede62f03f Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 21 Oct 2026 20:24:09 -0400 Subject: [PATCH] test(integration): route regression tests for the audio_metadata guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three tests in scanner_audio_guard_test.go pin the routes the wipes traveled; each drives a real scan/rescan/edit over ffmpeg-rendered fixtures and asserts the payload survives with duration and manifest intact: - TestRescanMediaItemPreservesAudioMetadata: folder book through the focused payload writer. - TestRescanMediaItemPreservesAudioMetadataSingleFileM4B: the incident class verbatim — single-file m4b (no layout key) routes through the generic updater; fixture rendered with ffmpeg AAC/ipod (writeTestTrackM4B). - TestMetadataEditorSavePreservesAudioMetadata: the metadata editor's full-row save through PUT /api/media-items/:id. Verified to bite: with the handler fix stashed, the editor test fails with "metadata edit must never NULL audio_metadata"; with it, green. --- cmd/server/tests/audiobook_multifile_test.go | 18 ++ cmd/server/tests/scanner_audio_guard_test.go | 182 +++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 cmd/server/tests/scanner_audio_guard_test.go diff --git a/cmd/server/tests/audiobook_multifile_test.go b/cmd/server/tests/audiobook_multifile_test.go index 3d08bb30..2fa924b8 100644 --- a/cmd/server/tests/audiobook_multifile_test.go +++ b/cmd/server/tests/audiobook_multifile_test.go @@ -47,6 +47,24 @@ func writeTestTrack(t *testing.T, path string) { require.NoError(t, os.WriteFile(path, []byte("ID3 dummy audio"), 0o644)) } +// writeTestTrackM4B is writeTestTrack for the m4b (AAC/ipod muxer) +// container — the audiobook format whose rescan route is under guard. +// ffmpeg needs -f ipod for the .m4b extension; falls back to dummy bytes. +func writeTestTrackM4B(t *testing.T, path string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + if ffmpeg, err := exec.LookPath("ffmpeg"); err == nil { + cmd := exec.Command(ffmpeg, "-y", "-v", "error", "-f", "lavfi", + "-i", "sine=frequency=440:duration=0.3", "-codec:a", "aac", "-b:a", "32k", "-f", "ipod", path) + if out, err := cmd.CombinedOutput(); err == nil { + return + } else { + t.Logf("ffmpeg m4b render failed (%s), falling back to dummy bytes: %s", err, out) + } + } + require.NoError(t, os.WriteFile(path, []byte("dummy m4b audio"), 0o644)) +} + // audioTestLibrary builds an audiobooks-type library with a fresh temp // folder, returning its UUID and the folder path. func audioTestLibrary(t *testing.T, setup *TestServerSetup, name string) (pgtype.UUID, string) { diff --git a/cmd/server/tests/scanner_audio_guard_test.go b/cmd/server/tests/scanner_audio_guard_test.go new file mode 100644 index 00000000..37a8356b --- /dev/null +++ b/cmd/server/tests/scanner_audio_guard_test.go @@ -0,0 +1,182 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "path/filepath" + "testing" + "time" + + "bookhoard/internal/services" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/require" +) + +// TestRescanMediaItemPreservesAudioMetadata pins the 2026-10-21 incident: +// a per-item rescan of a multi-file audiobook routes book-level metadata +// (sidecars/tags — AudioMeta nil) through the generic row update, whose +// unguarded write NULLed audio_metadata. A NULL payload drops every +// track/chapter and the duration, so the book displays as 1 second long +// and the audio reader loses its track manifest. Re-extraction must be +// write-after-success: no fresh audio payload from the audio chain means +// the existing column survives byte-for-byte. +func TestRescanMediaItemPreservesAudioMetadata(t *testing.T) { + setup := setupTestServer(t) + t.Cleanup(func() { _ = setup.Close() }) + + ctx := context.Background() + admin, _ := moveTestAdminID(t, setup) + libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Guard %d", time.Now().UnixNano())) + t.Cleanup(func() { deleteLibraries(t, setup, libID) }) + + writeTestTrack(t, filepath.Join(folder, "Guard Author", "Guard Book", "01 One.mp3")) + writeTestTrack(t, filepath.Join(folder, "Guard Author", "Guard Book", "02 Two.mp3")) + + scanTestFolder(t, setup, admin, folder) + rel := filepath.ToSlash("Guard Author/Guard Book") + book, found := findAudioItemByPath(t, setup, libID, rel) + require.True(t, found, "folder book must import") + + audioBytes := func(t *testing.T) []byte { + t.Helper() + var raw []byte + require.NoError(t, setup.DBPool.QueryRow(ctx, + `SELECT audio_metadata FROM media_items WHERE id = $1`, book.ID).Scan(&raw)) + return raw + } + + before := audioBytes(t) + require.NotNil(t, before, "initial scan must populate audio_metadata") + var payload services.AudioMetadataPayload + require.NoError(t, json.Unmarshal(before, &payload), "payload must be valid JSON") + require.Greater(t, payload.DurationSeconds, 0.0, "initial scan must carry a duration") + require.Len(t, payload.Tracks, 2, "initial scan must carry the track manifest") + + scanner := services.NewMediaScanner(setup.DB) + defer func() { _ = scanner.Close() }() + scanner.SetAdminID(admin) + require.NoError(t, scanner.RescanMediaItem(ctx, book.ID, false), "per-item rescan must succeed") + + after := audioBytes(t) + require.NotNil(t, after, "rescan must never NULL audio_metadata (2026-10-21 incident)") + var afterPayload services.AudioMetadataPayload + require.NoError(t, json.Unmarshal(after, &afterPayload), "surviving payload must stay valid JSON") + require.Greater(t, afterPayload.DurationSeconds, 0.0, "duration must survive the rescan") + require.Len(t, afterPayload.Tracks, 2, "track manifest must survive the rescan") +} + +// TestMetadataEditorSavePreservesAudioMetadata pins the editor-side half +// of the incident: PUT /api/media-items/:id (the metadata editor) writes +// the FULL row, and audio_metadata has no editor field — an unguarded +// save NULLed every audiobook's payload on a plain metadata edit. +func TestMetadataEditorSavePreservesAudioMetadata(t *testing.T) { + setup := setupTestServer(t) + t.Cleanup(func() { _ = setup.Close() }) + + ctx := context.Background() + admin, _ := moveTestAdminID(t, setup) + libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Editor Guard %d", time.Now().UnixNano())) + t.Cleanup(func() { deleteLibraries(t, setup, libID) }) + + writeTestTrack(t, filepath.Join(folder, "Editor Author", "Editor Book", "01 One.mp3")) + writeTestTrack(t, filepath.Join(folder, "Editor Author", "Editor Book", "02 Two.mp3")) + + scanTestFolder(t, setup, admin, folder) + rel := filepath.ToSlash("Editor Author/Editor Book") + book, found := findAudioItemByPath(t, setup, libID, rel) + require.True(t, found, "folder book must import") + + audioBytes := func(t *testing.T) []byte { + t.Helper() + var raw []byte + require.NoError(t, setup.DBPool.QueryRow(ctx, + `SELECT audio_metadata FROM media_items WHERE id = $1`, book.ID).Scan(&raw)) + return raw + } + before := audioBytes(t) + require.NotNil(t, before, "initial scan must populate audio_metadata") + + // A plain metadata edit through the editor endpoint (admin token — + // the editor is admin-only), no audio fields involved. + editorSave(t, setup, book.ID, map[string]interface{}{ + "title": "Editor Book Renamed", + "cover_action": "keep", + }) + + after := audioBytes(t) + require.NotNil(t, after, "metadata edit must never NULL audio_metadata") + var afterPayload services.AudioMetadataPayload + require.NoError(t, json.Unmarshal(after, &afterPayload)) + require.Greater(t, afterPayload.DurationSeconds, 0.0, "duration must survive the edit") + require.Len(t, afterPayload.Tracks, 2, "track manifest must survive the edit") + + renamed, found := findAudioItemByPath(t, setup, libID, rel) + require.True(t, found) + require.Equal(t, "Editor Book Renamed", renamed.Title, "the edit itself must still apply") +} + +// TestRescanMediaItemPreservesAudioMetadataSingleFileM4B is the incident +// class verbatim: the failing books were SINGLE-FILE m4b audiobooks +// (audio_metadata without a layout key), which RescanMediaItem routes +// through the generic per-file updater — the same writer whose unguarded +// pass NULLed their payloads. The whole flow must keep the m4b's +// duration and chapters intact. +func TestRescanMediaItemPreservesAudioMetadataSingleFileM4B(t *testing.T) { + setup := setupTestServer(t) + t.Cleanup(func() { _ = setup.Close() }) + + ctx := context.Background() + admin, _ := moveTestAdminID(t, setup) + libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test M4B Guard %d", time.Now().UnixNano())) + t.Cleanup(func() { deleteLibraries(t, setup, libID) }) + + writeTestTrackM4B(t, filepath.Join(folder, "M4B Guard Author", "M4B Guard Book.m4b")) + + scanTestFolder(t, setup, admin, folder) + rel := filepath.ToSlash("M4B Guard Author/M4B Guard Book.m4b") + book, found := findAudioItemByPath(t, setup, libID, rel) + require.True(t, found, "single-file m4b must import") + + audioBytes := func(t *testing.T) []byte { + t.Helper() + var raw []byte + require.NoError(t, setup.DBPool.QueryRow(ctx, + `SELECT audio_metadata FROM media_items WHERE id = $1`, book.ID).Scan(&raw)) + return raw + } + before := audioBytes(t) + require.NotNil(t, before, "initial scan must populate audio_metadata") + var beforePayload services.AudioMetadataPayload + require.NoError(t, json.Unmarshal(before, &beforePayload)) + require.Greater(t, beforePayload.DurationSeconds, 0.0, "m4b probe must carry a real duration") + + scanner := services.NewMediaScanner(setup.DB) + defer func() { _ = scanner.Close() }() + scanner.SetAdminID(admin) + require.NoError(t, scanner.RescanMediaItem(ctx, book.ID, false)) + + after := audioBytes(t) + require.NotNil(t, after, "m4b rescan must never NULL audio_metadata") + var afterPayload services.AudioMetadataPayload + require.NoError(t, json.Unmarshal(after, &afterPayload)) + require.Greater(t, afterPayload.DurationSeconds, 0.0, "m4b duration must survive the rescan") +} + +// editorSave drives PUT /api/media-items/:id as the admin (the metadata +// editor's endpoint; admin-only per the handler). +func editorSave(t *testing.T, setup *TestServerSetup, itemID pgtype.UUID, fields map[string]interface{}) { + t.Helper() + body, _ := json.Marshal(fields) + req, _ := http.NewRequest("PUT", setup.Server.URL+"/api/media-items/"+uuid.UUID(itemID.Bytes).String(), bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+setup.Token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + require.Equal(t, http.StatusOK, resp.StatusCode, "metadata save must succeed") +}