package main import ( "context" "fmt" "regexp" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TestSeriesPagination pins the SSR series-page pagination contract // (2026-10-19 paginator fix). The /series page renders its grid and // paginator entirely server-side (templates/series.templ); page state // lives in the ?page= query param. The fixture mirrors a prod bulk // import: 30 series whose items share ONE identical created_at (a single // transaction's NOW()), which historically left the ORDER BY tied — the // tiebreaker (library_id, series) makes pages deterministic. func TestSeriesPagination(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test in short mode") } setup := setupTestServer(t) libID := createTestLibraryWithFolder(t, setup.Server, setup.Token, "Test Pagination Library", false) ctx := context.Background() const totalSeries = 30 for i := 1; i <= totalSeries; i++ { _, err := setup.DBPool.Exec(ctx, `INSERT INTO media_items (library_id, title, file_path, series, created_at, imported_at, updated_at) VALUES ($1, $2, $3, $4, '2026-01-01 00:00:00+00', '2026-01-01 00:00:00+00', '2026-01-01 00:00:00+00')`, libID, fmt.Sprintf("Pagination Probe Book %02d", i), fmt.Sprintf("/tmp/test-pagination/book-%02d.epub", i), fmt.Sprintf("Pagination Probe Series %02d", i), ) require.NoError(t, err) } t.Cleanup(func() { _, _ = setup.DBPool.Exec(ctx, "DELETE FROM libraries WHERE id = $1", libID) }) // templ separates dynamic text segments with HTML comments; strip them // so plain regexes work on names and the page chip. stripComments := func(html string) string { return regexp.MustCompile(``).ReplaceAllString(html, "") } seriesNames := func(html string) map[string]bool { names := map[string]bool{} for _, m := range regexp.MustCompile(`]*>([^<]+)`).FindAllStringSubmatch(stripComments(html), -1) { names[strings.TrimSpace(m[1])] = true } return names } pageChip := func(html string) string { m := regexp.MustCompile(`Page\s+(\d+)\s+of\s+(\d+)`).FindStringSubmatch(stripComments(html)) if m == nil { return "" } return fmt.Sprintf("Page %s of %s", m[1], m[2]) } perPage := 24 // internal/router/frontend.go perSeriesPage t.Run("page1 defaults and next link", func(t *testing.T) { code, body := ssrGet(t, setup.Server.URL+"/series?library_id="+libID, setup.Token) require.Equal(t, 200, code) assert.Equal(t, fmt.Sprintf("Page 1 of 2"), pageChip(body)) names := seriesNames(body) assert.Len(t, names, perPage, "page 1 renders exactly 24 cards") assert.Contains(t, names, "Pagination Probe Series 01", "deterministic order: page 1 starts at the first series") assert.Contains(t, names, fmt.Sprintf("Pagination Probe Series %02d", perPage), "page 1 ends at the 24th series") assert.NotContains(t, names, fmt.Sprintf("Pagination Probe Series %02d", perPage+1), "page 1 must not leak page-2 rows") assert.NotContains(t, body, "Previous", "no Previous link on page 1") // templ HTML-escapes & as & inside attribute values. assert.Contains(t, body, fmt.Sprintf(`href="/series?library_id=%s&page=2"`, libID), "Next link carries library_id and page") }) t.Run("page2 is disjoint and terminal", func(t *testing.T) { code, body := ssrGet(t, setup.Server.URL+"/series?library_id="+libID+"&page=2", setup.Token) require.Equal(t, 200, code) assert.Equal(t, "Page 2 of 2", pageChip(body)) names := seriesNames(body) assert.Len(t, names, totalSeries-perPage, "page 2 renders the remainder") code1, body1 := ssrGet(t, setup.Server.URL+"/series?library_id="+libID+"&page=1", setup.Token) require.Equal(t, 200, code1) page1 := seriesNames(body1) for name := range names { assert.NotContains(t, page1, name, "pages must be disjoint even with fully tied created_at") } assert.NotContains(t, body, "Next", "no Next link on the last page") assert.Contains(t, body, fmt.Sprintf(`href="/series?library_id=%s&page=1"`, libID), "Previous link carries library_id and page") }) t.Run("missing page param defaults to page 1", func(t *testing.T) { code, body := ssrGet(t, setup.Server.URL+"/series?library_id="+libID, setup.Token) require.Equal(t, 200, code) assert.Equal(t, "Page 1 of 2", pageChip(body)) }) t.Run("invalid page param falls back to page 1", func(t *testing.T) { for _, bad := range []string{"0", "-3", "two"} { code, body := ssrGet(t, setup.Server.URL+"/series?library_id="+libID+"&page="+bad, setup.Token) require.Equal(t, 200, code, "page=%s", bad) assert.Equal(t, "Page 1 of 2", pageChip(body), "page=%s must clamp to page 1", bad) } }) }