package main import ( "bytes" "context" "encoding/json" "fmt" "net/http" "os" "path/filepath" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/require" ) // A2c end-to-end: an ABS sidecar whose series array mixes a real series and // an ordering construct must (a) land the real series on the item, (b) // auto-create a curated playlist collection for the ordering construct with // provenance + position, (c) survive a rescan without duplicates, (d) // support owner-only reorder, and (e) filter finished books for Up Next / // hide_finished with a completion marker when everything is read. func TestPlaylistCollectionsEndToEnd(t *testing.T) { setup := setupTestServer(t) t.Cleanup(func() { _ = setup.Close() }) adminID, _ := moveTestAdminID(t, setup) libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Playlist %d", time.Now().UnixNano())) t.Cleanup(func() { deleteLibraries(t, setup, libID) }) // The user's exact Witcher shape: real series + reading-order construct. itemDir := filepath.Join(folder, "Tag Author", "The Tagged Album") fixtureAudio(t, "tagged.mp3", filepath.Join(itemDir, "tagged.mp3")) sidecar := `{ "title": "The Tagged Album", "authors": ["Tag Author"], "series": ["The Tagged Saga #2", "The Tagged Saga (Reading Order) #7"] }` require.NoError(t, os.WriteFile(filepath.Join(itemDir, "metadata.json"), []byte(sidecar), 0644)) scanTestFolder(t, setup, adminID, folder) // (a) The item carries the REAL series only; the reading-order entry // never touches the series columns. var series string var seriesNum float64 var foundPath string err := setup.DBPool.QueryRow(context.Background(), `SELECT series, series_number, file_path FROM media_items WHERE title = 'The Tagged Album'`, ).Scan(&series, &seriesNum, &foundPath) if err != nil { var paths []string rows, qerr := setup.DBPool.Query(context.Background(), `SELECT file_path FROM media_items WHERE library_id = $1`, libID) if qerr == nil { for rows.Next() { var p string _ = rows.Scan(&p) paths = append(paths, p) } rows.Close() } t.Fatalf("item not found by title (paths in library: %v): %v", paths, err) } require.Equal(t, "The Tagged Saga", series) require.Equal(t, 2.0, seriesNum) require.NotEmpty(t, foundPath) // (b) The playlist collection exists with provenance, dashboard pin, // and the item at reading position 7. adminUUIDStr := adminID.String() var collID uuid.UUID var collName, collDesc string var showOnDashboard bool var viewSettingsRaw []byte require.NoError(t, setup.DBPool.QueryRow(context.Background(), `SELECT id, name, description, show_on_dashboard, view_settings FROM collections WHERE user_id = $1 AND name = 'The Tagged Saga (Reading Order)'`, adminID).Scan(&collID, &collName, &collDesc, &showOnDashboard, &viewSettingsRaw)) require.True(t, showOnDashboard, "playlists are dashboard-eligible by design") require.Contains(t, string(viewSettingsRaw), "imported_from_series") require.Contains(t, string(viewSettingsRaw), "show_up_next") require.Contains(t, collDesc, "safe to edit or delete") _ = adminUUIDStr var position *int32 var itemCount int require.NoError(t, setup.DBPool.QueryRow(context.Background(), `SELECT ci.position, COUNT(*) OVER () FROM collection_items ci WHERE ci.collection_id = $1`, collID).Scan(&position, &itemCount)) require.Equal(t, 1, itemCount) require.NotNil(t, position) require.Equal(t, int32(7), *position) _ = adminUUIDStr // (c) Rescan: no duplicate collection, no duplicate membership. scanTestFolder(t, setup, adminID, folder) var collectionCount, membershipCount int require.NoError(t, setup.DBPool.QueryRow(context.Background(), `SELECT COUNT(*) FROM collections WHERE user_id = $1 AND name = 'The Tagged Saga (Reading Order)'`, adminID).Scan(&collectionCount)) require.Equal(t, 1, collectionCount) require.NoError(t, setup.DBPool.QueryRow(context.Background(), `SELECT COUNT(*) FROM collection_items WHERE collection_id = $1`, collID).Scan(&membershipCount)) require.Equal(t, 1, membershipCount) // (d) Owner-only reorder: positions become 1..n; a non-owner gets 403. var itemID uuid.UUID require.NoError(t, setup.DBPool.QueryRow(context.Background(), `SELECT id FROM media_items WHERE title = 'The Tagged Album'`).Scan(&itemID)) reorderURL := fmt.Sprintf("%s/api/collections/%s/items/order", setup.Server.URL, collID.String()) body, _ := json.Marshal(map[string][]string{"item_ids": {itemID.String()}}) req, _ := http.NewRequest("PUT", reorderURL, bytes.NewBuffer(body)) req.Header.Set("Authorization", "Bearer "+setup.Token) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) _ = resp.Body.Close() regularToken := setup.RegularToken req2, _ := http.NewRequest("PUT", reorderURL, bytes.NewBuffer(body)) req2.Header.Set("Authorization", "Bearer "+regularToken) req2.Header.Set("Content-Type", "application/json") resp2, err := http.DefaultClient.Do(req2) require.NoError(t, err) require.Equal(t, http.StatusForbidden, resp2.StatusCode) _ = resp2.Body.Close() // (e) hide_finished + Up Next + completion marker. // Flip the collection's flag on, mark the single item finished for the // admin, and the dashboard section becomes Complete with no items. _, err = setup.DBPool.Exec(context.Background(), `UPDATE collections SET view_settings = view_settings || '{"hide_finished": true}'::jsonb WHERE id = $1`, collID) require.NoError(t, err) _, err = setup.DBPool.Exec(context.Background(), `INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at) VALUES ($1, $2, 1.0, NOW()) ON CONFLICT (media_item_id, user_id) DO UPDATE SET percentage = 1.0`, itemID, adminID) require.NoError(t, err) sectionsURL := fmt.Sprintf("%s/api/dashboard/sections?library_id=%s", setup.Server.URL, libID.String()) req3, _ := http.NewRequest("GET", sectionsURL, nil) req3.Header.Set("Authorization", "Bearer "+setup.Token) resp3, err := http.DefaultClient.Do(req3) require.NoError(t, err) defer func() { _ = resp3.Body.Close() }() require.Equal(t, http.StatusOK, resp3.StatusCode) var payload struct { Sections []struct { CollectionID string `json:"collection_id"` Complete bool `json:"complete"` IsPlaylist bool `json:"is_playlist"` Items []map[string]any `json:"items"` } `json:"sections"` } require.NoError(t, json.NewDecoder(resp3.Body).Decode(&payload)) var found bool for _, s := range payload.Sections { if s.CollectionID == collID.String() { found = true if !s.Complete { b, _ := json.Marshal(s) t.Fatalf("section found but not complete: %s", b) } require.True(t, s.IsPlaylist) require.Empty(t, s.Items, "finished items must not show under hide_finished") } } if !found { b, _ := json.Marshal(payload.Sections) t.Fatalf("playlist section missing; sections = %s", b) } require.True(t, found, "the playlist section must appear on the owner's dashboard") // Up Next on the collection detail: the finished queue is empty. detailURL := fmt.Sprintf("%s/api/collections/%s", setup.Server.URL, collID.String()) req4, _ := http.NewRequest("GET", detailURL, nil) req4.Header.Set("Authorization", "Bearer "+setup.Token) resp4, err := http.DefaultClient.Do(req4) require.NoError(t, err) defer func() { _ = resp4.Body.Close() }() var detail struct { UpNext []map[string]any `json:"up_next"` } require.NoError(t, json.NewDecoder(resp4.Body).Decode(&detail)) require.Empty(t, detail.UpNext, "nothing is up next once everything is finished") }