Search results now show where you are in a book: a completed check at the 0.999 threshold (same cut as the filter status arms), a partial progress bar along the cover while mid-read, nothing when you never opened it. Rendered in BookCard (any grid whose adapter loads viewer progress) and in the header-search dropdown rows. The progress rides a LEFT JOIN reading_progress on the SAME user_id param SearchMediaItemsUnified already binds for library visibility — one requester param, no new wire inputs, row-neutral join (the table is UNIQUE per media_item+user). SearchMediaItemsUnifiedRow and BookInfo gain the keys additively (viewer_percentage null when unknown, library_name empty on feeds that don't load it); contract_pins.md carries the changelog entry and the pin tests widen in lockstep. The header dropdown itself migrates off the innerHTML string renderer in search.ts (a rendering-ownership violation: a templ twin existed via the HX-Request branch). The input now hx-gets layout=dropdown and swaps the new templ SearchResults partial — titles/authors highlighted server-side (escape-first, safe by construction), rows with the same progress overlays — while a slim Alpine component owns only client-local state (min-length gating, open/close, keyboard selection, htmx lifecycle). web/src/rendering-allowlist.txt drops the search.ts baseline to zero accordingly. Integration test pins the per-user join (fresh user sees 1.0/0.42/null on the fixture; the shared admin sees null everywhere — read-only) and the dropdown partial's overlays/count/row count.
130 lines
4.9 KiB
Go
130 lines
4.9 KiB
Go
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
|
|
}
|