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