Files
bookhoard/templates/partials_render_test.go
T
John O'Keefe d74db03cf3
Release / build-and-push (push) Successful in 3m7s
feat(sync): unlinked-books queue — ingest, page route, dashboard banner
The identification queue for device files the sync cannot match is now
live end-to-end. Previously the whole feature was dark: unmatched device
files were silently dropped (losing their reading progress), the queue
table sat empty by construction, and the page template had no route.

- Ingest: the KOReader sync loop, on an unresolvable file, upserts
  unlinked_books (one row per device + content id; re-syncs bump
  last_seen_at and refresh device-reported metadata) instead of
  dropping the file. Schema gains the (device_id, content_id) unique
  index the upsert needs.
- Page: GET /unlinked-books routes the existing page template, listing
  the caller's own unresolved rows (ListUnresolvedUnlinkedBooksForUser
  — devices.user_id scoping; no cross-user leakage).
- Dashboard banner: a warning card above the shelves, rendered only
  when the caller's queue is non-empty (hidden-unless-needed), linking
  to the page.
- Resolve flow unchanged and already gated: MatchingHandler.LinkBook
  verifies device ownership and target-item visibility (403s) and
  creates the device file alias, so the next sync matches by path.
  SyncHandler.LinkBook and Handler.LinkBook are unrouted dead handlers
  — triage candidates, not part of the live flow.
2026-10-18 16:45:48 -04:00

94 lines
3.1 KiB
Go

package templates
import (
"context"
"strings"
"testing"
"bookhoard/internal/handlers"
)
// The rendering-ownership partials must render standalone (they are
// fetched by htmx.ajax at runtime) and must never leak templ literal
// expressions into their markup.
func TestRenderingPartialsRender(t *testing.T) {
var b strings.Builder
col := CollectionData{ID: "c1", Name: "Test Shelf", IsSmart: false}
books := []handlers.BookInfo{{MediaItemID: "m1", Title: "A Book", Author: "An Author"}}
if err := CollectionBooksGrid(col, books).Render(context.Background(), &b); err != nil {
t.Fatalf("CollectionBooksGrid: %v", err)
}
out := b.String()
for _, want := range []string{"A Book", `data-media-id="m1"`, "Remove from Collection"} {
if !strings.Contains(out, want) {
t.Errorf("books grid missing %q", want)
}
}
// Empty collection renders the empty state, not a literal loop.
b.Reset()
if err := CollectionBooksGrid(col, nil).Render(context.Background(), &b); err != nil {
t.Fatalf("CollectionBooksGrid empty: %v", err)
}
if !strings.Contains(b.String(), "No books in this collection yet.") {
t.Error("books grid empty state missing")
}
b.Reset()
user := User{ID: "u1", Timezone: "UTC"}
conflicts := []handlers.ConflictDetailResponse{{ID: "cf1", MediaItemTitle: "War and Peace", ConflictType: "progress"}}
if err := ConflictsRefreshFragment(user, conflicts, 3, 1).Render(context.Background(), &b); err != nil {
t.Fatalf("ConflictsRefreshFragment: %v", err)
}
out = b.String()
for _, want := range []string{`id="conflicts-list"`, `id="stats-bar"`, "hx-swap-oob", "Resolve"} {
if !strings.Contains(out, want) {
t.Errorf("conflicts fragment missing %q", want)
}
}
}
// The unlinked-books queue banner renders only when the queue is
// non-empty (hidden-unless-needed).
func TestDashboardUnlinkedBanner(t *testing.T) {
render := func(count int) string {
var b strings.Builder
err := Dashboard(User{ID: "u1"}, nil, nil, nil, "", nil, 0, nil, "", "", count).Render(context.Background(), &b)
if err != nil {
t.Fatalf("render: %v", err)
}
return b.String()
}
if out := render(0); strings.Contains(out, "/unlinked-books") {
t.Error("banner rendered with empty queue")
}
out := render(2)
if !strings.Contains(out, "/unlinked-books") || !strings.Contains(out, "could not be identified") {
t.Error("banner missing with non-empty queue")
}
}
// The unlinked-books page renders queue rows standalone.
func TestUnlinkedBooksPageRender(t *testing.T) {
var b strings.Builder
user := User{ID: "u1", Timezone: "UTC"}
books := []UnlinkedBookData{{
ProgressID: "ub1",
DeviceName: "Kobo Clara",
DeviceType: "kobo",
FilePath: "/mnt/onboard/book.epub",
SHA256: "abc123",
TitleFromDevice: "Some Sideloaded Book",
LastSyncTime: "2026-10-18 12:00",
}}
if err := UnlinkedBooks(user, books).Render(context.Background(), &b); err != nil {
t.Fatalf("render: %v", err)
}
out := b.String()
for _, want := range []string{"Some Sideloaded Book", "abc123", `data-progress-id="ub1"`, "searchMatches"} {
if !strings.Contains(out, want) {
t.Errorf("unlinked page missing %q", want)
}
}
}