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.
This commit is contained in:
1 parent
35d8ba32e5
commit
f62b46fb15
2 files changed
+200
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
Reference in new issue
Block a user