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(), "Nothing 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, both buckets. 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", }} ignored := []UnlinkedBookData{{ ProgressID: "ub2", DeviceName: "KOReader Phone", DeviceType: "koreader", FilePath: "/sdcard/ignored.epub", SHA256: "def456", TitleFromDevice: "Some Dismissed Book", LastSyncTime: "2026-10-18 12:00", }} if err := UnlinkedBooks(user, books, ignored).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", // Dismiss/delete actions and the ignored recovery bucket. "Some Dismissed Book", `hx-post="/unlinked-books/ub1/ignore"`, `hx-delete="/unlinked-books/ub1"`, `hx-post="/unlinked-books/ub2/unignore"`, "Ignored (1)", } { if !strings.Contains(out, want) { t.Errorf("unlinked page missing %q", want) } } } // The library switcher's D13 podcast exclusion must hold at the // presentation layer — and the templ `continue` statement leak (codegen // emits it as literal TEXT, browser sweep 2026-10-30) must never return. func TestLibrarySwitcherExcludesPodcasts(t *testing.T) { var b strings.Builder libs := []LibraryData{ {ID: "l1", Name: "Ebooks", TypeName: "books", MediaCount: 5}, {ID: "l2", Name: "Podcasts", TypeName: "podcasts", MediaCount: 235}, } if err := LibrarySwitcher(libs, "l1", "", nil).Render(context.Background(), &b); err != nil { t.Fatalf("LibrarySwitcher: %v", err) } out := b.String() if strings.Contains(out, "Podcasts (235)") { t.Error("switcher leaked the podcasts library into the book switcher") } if strings.Contains(out, "continue") { t.Error("switcher rendered the literal text `continue` (templ has no continue statement)") } if !strings.Contains(out, "Ebooks (5)") { t.Error("switcher missing the books library") } } // The Appearance panel's theme buttons interpolate their click handlers // from Go loop vars. The CSP-build migration (eb57b720) shipped // `@click="selectTheme(opt.Name)"` as LITERAL text (templ gotcha #1: // quoted attribute values are never interpolated), so Alpine threw // "Undefined variable: opt" on every theme click and themes were // unchangeable until the user report, 2026-10-31. The wood-paneling // sibling always had the correct expression form, which is why the // panel half-worked. Pin both halves: handlers must carry real theme // names, and the raw Go identifier must never reach the HTML. func TestThemePickerHandlersAreInterpolated(t *testing.T) { var b strings.Builder user := User{ID: "u1", Username: "testuser", Role: "admin", Theme: "tokyo-night", ShowPodcastsNav: true} if err := Header(user, "/collections").Render(context.Background(), &b); err != nil { t.Fatalf("Header: %v", err) } out := b.String() // templ escapes `'` as ' inside attribute values; the HTML parser // decodes entities before Alpine evaluates, so normalize for matching. decoded := strings.ReplaceAll(out, "'", "'") if strings.Contains(out, "selectTheme(opt.Name)") { t.Error("theme buttons leaked the raw Go loop var into the click handler (themes unchangeable)") } if strings.Contains(out, "changeWoodPaneling(w.Name)") { t.Error("wood buttons leaked the raw Go loop var into the click handler") } // At least the SSR'd current theme and one wood option must appear // as real string-literal arguments. if !strings.Contains(decoded, "selectTheme('tokyo-night')") { t.Error("theme buttons missing the interpolated current-theme handler") } if !strings.Contains(decoded, "changeWoodPaneling('none')") { t.Error("wood buttons missing the interpolated handler") } }