test(series): pin the SSR pagination contract under tied timestamps
New integration test mirroring a prod bulk import: a dedicated test library with 30 series whose items share ONE identical created_at (single-transaction NOW()), the exact data shape that made prod pagination look broken. Pins, via the real SSR /series route: - page 1 renders exactly 24 deterministic series (01-24) with chip "Page 1 of 2", no Previous, and a Next href carrying library_id and page (templ escapes & as & inside attributes) - page 2 renders the remaining 6, disjoint from page 1, chip "Page 2 of 2", no Next, Previous href correct - missing page param defaults to page 1; page=0/-3/two clamp to page 1 Guards both the SQL tiebreaker and the /series handler contract; would fail on any return of the stomp or an unstable sort.
This commit is contained in:
1 parent
38efbff7e1
commit
220d97cfe0
1 file changed
+115
@@ -0,0 +1,115 @@
|
|||||||
|
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(`<h3[^>]*>([^<]+)</h3>`).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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in new issue
Block a user