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.
183 lines
7.6 KiB
Go
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")
|
|
}
|