Files
bookhoard/cmd/server/tests/scanner_audio_guard_test.go
T
John O'Keefe f62b46fb15 test(integration): route regression tests for the audio_metadata guards
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.
2026-10-21 20:24:09 -04:00

183 lines
7.6 KiB
Go

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")
}