364 lines
9.6 KiB
Go
364 lines
9.6 KiB
Go
package templates
|
|
|
|
import (
|
|
"bytes"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"mal/integrations/jikan"
|
|
"mal/internal/db"
|
|
"mal/internal/domain"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/PuerkitoBio/goquery"
|
|
)
|
|
|
|
func TestProvideRendererParsesTemplates(t *testing.T) {
|
|
r, err := ProvideRenderer()
|
|
if err != nil {
|
|
t.Fatalf("parse templates: %v", err)
|
|
}
|
|
if len(r.templates) == 0 {
|
|
t.Fatal("expected at least one parsed template")
|
|
}
|
|
}
|
|
|
|
func TestInstanceReturnsHTMLRender(t *testing.T) {
|
|
r, err := ProvideRenderer()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
render := r.Instance("index.gohtml", map[string]any{"key": "val"})
|
|
hr, ok := render.(HTMLRender)
|
|
if !ok {
|
|
t.Fatalf("expected HTMLRender, got %T", render)
|
|
}
|
|
if hr.Name != "index.gohtml" {
|
|
t.Errorf("expected index.gohtml, got %s", hr.Name)
|
|
}
|
|
}
|
|
|
|
func TestRenderValidTemplate(t *testing.T) {
|
|
r, err := ProvideRenderer()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
render := r.Instance("index.gohtml", map[string]any{
|
|
"User": false,
|
|
})
|
|
w := httptest.NewRecorder()
|
|
if err := render.Render(w); err != nil {
|
|
t.Fatalf("Render error: %v", err)
|
|
}
|
|
if !strings.Contains(w.Body.String(), "<!DOCTYPE html>") {
|
|
t.Error("expected HTML doctype in output")
|
|
}
|
|
}
|
|
|
|
func TestRenderUsesPurposeSizedBrandAssets(t *testing.T) {
|
|
r, err := ProvideRenderer()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
render := r.Instance("index.gohtml", map[string]any{
|
|
"User": true,
|
|
"CurrentPath": "/",
|
|
})
|
|
w := httptest.NewRecorder()
|
|
if err := render.Render(w); err != nil {
|
|
t.Fatalf("Render error: %v", err)
|
|
}
|
|
|
|
doc, err := goquery.NewDocumentFromReader(strings.NewReader(w.Body.String()))
|
|
if err != nil {
|
|
t.Fatalf("parse rendered html: %v", err)
|
|
}
|
|
|
|
assets := map[string]string{
|
|
`link[rel="manifest"]`: "/static/assets/manifest.json",
|
|
`link[rel="icon"][sizes="32x32"]`: "/static/assets/favicon-32.png",
|
|
`link[rel="icon"][sizes="16x16"]`: "/static/assets/favicon-16.png",
|
|
`link[rel="apple-touch-icon"][sizes="180x180"]`: "/static/assets/apple-touch-icon-180.png",
|
|
}
|
|
for selector, want := range assets {
|
|
got, ok := doc.Find(selector).Attr("href")
|
|
if !ok {
|
|
t.Fatalf("missing %s", selector)
|
|
}
|
|
path, _, _ := strings.Cut(got, "?")
|
|
if path != want {
|
|
t.Fatalf("%s href = %q, want %q", selector, path, want)
|
|
}
|
|
}
|
|
|
|
logo := doc.Find(`a[title="Home"] img`)
|
|
if logo.Length() != 1 {
|
|
t.Fatalf("navigation logo count = %d, want 1", logo.Length())
|
|
}
|
|
src, _ := logo.Attr("src")
|
|
src, _, _ = strings.Cut(src, "?")
|
|
if src != "/static/assets/logo-128.png" {
|
|
t.Fatalf("navigation logo src = %q", src)
|
|
}
|
|
if width, _ := logo.Attr("width"); width != "166" {
|
|
t.Fatalf("navigation logo width = %q, want 166", width)
|
|
}
|
|
if height, _ := logo.Attr("height"); height != "128" {
|
|
t.Fatalf("navigation logo height = %q, want 128", height)
|
|
}
|
|
}
|
|
|
|
func TestRenderInvalidTemplate(t *testing.T) {
|
|
r, err := ProvideRenderer()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
render := r.Instance("nonexistent.gohtml", nil)
|
|
w := httptest.NewRecorder()
|
|
if err := render.Render(w); err == nil {
|
|
t.Fatal("expected error for nonexistent template")
|
|
}
|
|
}
|
|
|
|
func TestRenderWithFragment(t *testing.T) {
|
|
r, err := ProvideRenderer()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
render := r.Instance("index.gohtml", map[string]any{
|
|
"_fragment": "content",
|
|
"User": false,
|
|
})
|
|
w := httptest.NewRecorder()
|
|
if err := render.Render(w); err != nil {
|
|
t.Fatalf("Render error: %v", err)
|
|
}
|
|
if !strings.Contains(w.Body.String(), "Airing & Popular") {
|
|
t.Error("expected content block in fragment render")
|
|
}
|
|
}
|
|
|
|
func TestRenderWithNonStringFragment(t *testing.T) {
|
|
r, err := ProvideRenderer()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
render := r.Instance("index.gohtml", map[string]any{
|
|
"_fragment": 42,
|
|
})
|
|
w := httptest.NewRecorder()
|
|
if err := render.Render(w); err != nil {
|
|
t.Fatalf("Render error: %v", err)
|
|
}
|
|
// non-string fragment should fall through to default template rendering
|
|
if !strings.Contains(w.Body.String(), "<!DOCTYPE html>") {
|
|
t.Error("expected HTML output for non-string fragment fallthrough")
|
|
}
|
|
}
|
|
|
|
func TestTopPicksTemplateDoesNotRenderRecommendationRationale(t *testing.T) {
|
|
r, err := ProvideRenderer()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = r.ExecuteFragment(&buf, "top_picks.gohtml", "content", map[string]any{
|
|
"Animes": []domain.Anime{
|
|
{
|
|
Anime: jikan.Anime{
|
|
MalID: 1,
|
|
Title: "Haikyuu!!",
|
|
},
|
|
RecommendationRationale: []string{"Sports", "Production I.G"},
|
|
},
|
|
},
|
|
"WatchlistMap": map[int64]bool{},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ExecuteFragment error: %v", err)
|
|
}
|
|
|
|
body := buf.String()
|
|
for _, want := range []string{"Why this was picked", "Sports", "Production I.G"} {
|
|
if strings.Contains(body, want) {
|
|
t.Fatalf("top picks template should not render %q:\n%s", want, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTopPicksTemplateStylesBackToHomeAsButton(t *testing.T) {
|
|
r, err := ProvideRenderer()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = r.ExecuteFragment(&buf, "top_picks.gohtml", "content", map[string]any{
|
|
"Animes": []domain.Anime{},
|
|
"WatchlistMap": map[int64]bool{},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ExecuteFragment error: %v", err)
|
|
}
|
|
|
|
body := buf.String()
|
|
want := `href="/" class="inline-flex h-10 items-center justify-center bg-background-button px-4 text-sm font-normal text-foreground transition-colors hover:bg-background-button-hover focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent"`
|
|
if !strings.Contains(body, want) {
|
|
t.Fatalf("top picks back link should use button styling:\n%s", body)
|
|
}
|
|
}
|
|
|
|
func TestAnimeEpisodeCountTemplateDoesNotRenderAvailabilityStatus(t *testing.T) {
|
|
r, err := ProvideRenderer()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = r.ExecuteFragment(&buf, "anime.gohtml", "anime_episode_count", map[string]any{
|
|
"Items": map[string]any{
|
|
"Count": 3,
|
|
"Label": "Available episodes",
|
|
"Status": "Retrying soon",
|
|
"StatusTone": "warning",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ExecuteFragment error: %v", err)
|
|
}
|
|
body := buf.String()
|
|
if strings.Contains(body, "Retrying soon") || strings.Contains(body, "data-episode-availability-status") {
|
|
t.Fatalf("anime episode count should not expose availability status:\n%s", body)
|
|
}
|
|
}
|
|
|
|
func TestWatchTemplateEscapesJSONDataAttributes(t *testing.T) {
|
|
r, err := ProvideRenderer()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
label := `English ' data-injected='yes' <b>&"`
|
|
var buf bytes.Buffer
|
|
err = r.ExecuteFragment(&buf, "watch.gohtml", "content", domain.WatchPageData{
|
|
Anime: domain.Anime{Anime: jikan.Anime{MalID: 123, Title: "Example Anime"}},
|
|
CurrentEpID: "1",
|
|
Episodes: []domain.CanonicalEpisode{
|
|
{Number: 1, Title: "Episode 1", HasSub: true},
|
|
},
|
|
WatchData: domain.WatchData{
|
|
MalID: 123,
|
|
Title: "Example Anime",
|
|
CurrentEpisode: "1",
|
|
ModeSources: map[string]domain.ModeSource{
|
|
"sub": {
|
|
Token: "stream-token",
|
|
Subtitles: []domain.SubtitleItem{
|
|
{Lang: label, Token: "subtitle-token"},
|
|
},
|
|
},
|
|
},
|
|
AvailableModes: []string{"sub"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ExecuteFragment error: %v", err)
|
|
}
|
|
|
|
doc, err := goquery.NewDocumentFromReader(strings.NewReader(buf.String()))
|
|
if err != nil {
|
|
t.Fatalf("parse rendered html: %v", err)
|
|
}
|
|
player := doc.Find("[data-video-player]")
|
|
if got := player.Length(); got != 1 {
|
|
t.Fatalf("data-video-player count = %d, want 1", got)
|
|
}
|
|
if injected, ok := player.Attr("data-injected"); ok {
|
|
t.Fatalf("json data created injected attribute %q in html:\n%s", injected, buf.String())
|
|
}
|
|
if got := player.Find("b").Length(); got != 0 {
|
|
t.Fatalf("json data created %d nested b tags in html:\n%s", got, buf.String())
|
|
}
|
|
|
|
raw, ok := player.Attr("data-mode-sources")
|
|
if !ok {
|
|
t.Fatalf("missing data-mode-sources in html:\n%s", buf.String())
|
|
}
|
|
var sources map[string]domain.ModeSource
|
|
if err := json.Unmarshal([]byte(raw), &sources); err != nil {
|
|
t.Fatalf("data-mode-sources is not recoverable json: %v\nraw: %s", err, raw)
|
|
}
|
|
got := sources["sub"].Subtitles[0].Lang
|
|
if got != label {
|
|
t.Fatalf("subtitle label mismatch\nwant: %q\ngot: %q", label, got)
|
|
}
|
|
}
|
|
|
|
func TestExecuteFragmentValid(t *testing.T) {
|
|
r, err := ProvideRenderer()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = r.ExecuteFragment(&buf, "index.gohtml", "content", map[string]any{
|
|
"User": false,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ExecuteFragment error: %v", err)
|
|
}
|
|
if !strings.Contains(buf.String(), "Airing & Popular") {
|
|
t.Error("expected content in fragment output")
|
|
}
|
|
}
|
|
|
|
func TestExecuteFragmentInvalidTemplate(t *testing.T) {
|
|
r, err := ProvideRenderer()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = r.ExecuteFragment(&buf, "missing.gohtml", "content", nil)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing template")
|
|
}
|
|
}
|
|
|
|
func TestContinueWatchingTemplateIncludesAnimeDetailsLink(t *testing.T) {
|
|
r, err := ProvideRenderer()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err = r.ExecuteFragment(&buf, "index.gohtml", "continue_watching", []db.GetContinueWatchingEntriesRow{
|
|
{
|
|
AnimeID: 321,
|
|
TitleOriginal: "Original Title",
|
|
TitleEnglish: sql.NullString{String: "English Title", Valid: true},
|
|
ImageUrl: "https://example.com/poster.webp",
|
|
CurrentEpisode: sql.NullInt64{Int64: 7, Valid: true},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ExecuteFragment error: %v", err)
|
|
}
|
|
|
|
body := buf.String()
|
|
if !strings.Contains(body, `href="/anime/321"`) {
|
|
t.Fatalf("continue watching card should include anime details link:\n%s", body)
|
|
}
|
|
if !strings.Contains(body, `href="/anime/321/watch?ep=7"`) {
|
|
t.Fatalf("continue watching card should keep watch link:\n%s", body)
|
|
}
|
|
}
|