package main import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TestSearchViewerProgress pins the search-progress batch (2026-10-27): // the unified search row carries the REQUESTING user's reading progress // (viewer_percentage) via a LEFT JOIN on the same user_id param the // visibility join binds, the JSON endpoint serializes it, and the // layout=dropdown HX branch renders templ rows with the completed check // / partial bar. Fixture: three books in a test library; the FRESH // regular user finished one (1.0), is mid-read on another (0.42) and // never opened the third (NULL). The shared admin's search response is // read but never written — their progress stays untouched. func TestSearchViewerProgress(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test in short mode") } setup := setupTestServer(t) ctx := context.Background() libID := createTestLibraryWithFolder(t, setup.Server, setup.Token, "Test Search Progress Library", false) viewerID := getRegularUserID(t, setup.DB) books := []struct { title string percentage string // "" = no progress row at all }{ {title: "Progress Probe Alpha", percentage: "1.0"}, {title: "Progress Probe Beta", percentage: "0.42"}, {title: "Progress Probe Gamma", percentage: ""}, } itemIDs := map[string]string{} t.Cleanup(func() { _, _ = setup.DBPool.Exec(ctx, "DELETE FROM libraries WHERE id = $1", libID) }) for _, b := range books { var itemID string err := setup.DBPool.QueryRow(ctx, `INSERT INTO media_items (library_id, title, file_path, created_at, imported_at, updated_at) VALUES ($1, $2, $3, '2026-02-01 00:00:00+00', '2026-02-01 00:00:00+00', '2026-02-01 00:00:00+00') RETURNING id`, libID, b.title, fmt.Sprintf("/tmp/test-search-progress/%s.epub", b.title), ).Scan(&itemID) require.NoError(t, err) itemIDs[b.title] = itemID if b.percentage != "" { _, err = setup.DBPool.Exec(ctx, `INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at) VALUES ($1, $2, $3::float, '2026-02-02 00:00:00+00')`, itemID, viewerID, b.percentage, ) require.NoError(t, err) } } t.Run("JSON rows carry the viewer's own percentage", func(t *testing.T) { rows := searchJSON(t, setup, setup.RegularToken, "Progress Probe", libID) require.Len(t, rows, 3) byTitle := map[string]map[string]interface{}{} for _, row := range rows { byTitle[row["title"].(string)] = row } assert.Equal(t, float64(1), byTitle["Progress Probe Alpha"]["viewer_percentage"]) assert.InDelta(t, 0.42, byTitle["Progress Probe Beta"]["viewer_percentage"], 0.0001) require.Nil(t, byTitle["Progress Probe Gamma"]["viewer_percentage"], "never-opened book must be null") // Per-user join: the shared admin (no progress on any fixture // book) sees null across the board — the join binds the // REQUESTER, not a global. adminRows := searchJSON(t, setup, setup.Token, "Progress Probe", libID) require.Len(t, adminRows, 3) for _, row := range adminRows { assert.Nil(t, row["viewer_percentage"], "admin must see null for %v", row["title"]) } }) t.Run("dropdown partial renders progress overlays", func(t *testing.T) { req, err := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?layout=dropdown&q=Progress+Probe&library_id="+libID, nil) require.NoError(t, err) req.Header.Set("Cookie", "token="+setup.RegularToken) req.Header.Set("HX-Request", "true") resp, err := (&http.Client{}).Do(req) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode) bodyBytes, err := io.ReadAll(resp.Body) require.NoError(t, err) body := string(bodyBytes) assert.Contains(t, body, "3 results for", "count label") // Completed book: the check overlay + title (completed ≥ 0.999). assert.Contains(t, body, "Completed", "completed overlay") // Partial book: the bar at 42%. assert.Contains(t, body, "width: 42.0%", "partial progress bar") // Server-rendered rows, one per hit. assert.Equal(t, 3, strings.Count(body, `class="search-result-item`), "one row per result") }) } // searchJSON runs a search as the given token and decodes the row array // (library-scoped: the unified search is fuzzy, so an unscoped q would // sweep in live dev rows). func searchJSON(t *testing.T, setup *TestServerSetup, token, query, libraryID string) []map[string]interface{} { t.Helper() req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/media-items/search?q=%s&library_id=%s", setup.Server.URL, url.QueryEscape(query), libraryID), nil) require.NoError(t, err) req.Header.Set("Authorization", "Bearer "+token) resp, err := (&http.Client{}).Do(req) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode) var rows []map[string]interface{} require.NoError(t, json.NewDecoder(resp.Body).Decode(&rows)) return rows }