Go · 6160 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package web
4
5 import (
6 "io"
7 "io/fs"
8 "net/http/httptest"
9 "path"
10 "strings"
11 "testing"
12
13 "github.com/tenseleyFlow/shithub/internal/web/render"
14 )
15
16 // TestProductionTemplatesParse runs the embedded production templates
17 // through render.New so any undefined-template ref or unparseable file
18 // fails CI rather than the binary's first request after deploy.
19 //
20 // The check exists in addition to the targeted unit tests in
21 // internal/web/render/render_test.go: those cover the validator's
22 // behaviour on synthetic FS trees; this one covers the actual files
23 // under internal/web/templates/.
24 func TestProductionTemplatesParse(t *testing.T) {
25 t.Parallel()
26 _, err := render.New(TemplatesFS(), render.Options{})
27 if err != nil {
28 t.Fatalf("production templates failed to parse: %v", err)
29 }
30 }
31
32 // TestProductionTemplatesPartialsTolerateEmptyData renders every page
33 // in the production tree with an empty map[string]any and fails the
34 // test only if an error originates in a shared partial (filename
35 // starting with `_`, e.g. _nav.html / _layout.html).
36 //
37 // Why this matters: html/template parses fine even when a partial
38 // references a field nothing populates. The error only fires at
39 // execute time. A live site went 500 in May 2026 when _nav.html
40 // started referencing .GlobalSearchQuery and the homepage's
41 // helloData struct didn't carry it.
42 //
43 // We pass an empty map (not a typed struct) because:
44 // 1. map[string]any tolerates missing keys (`with .X` evaluates nil).
45 // 2. So any error that *does* fire from inside _nav.html or
46 // _layout.html is necessarily a real defect: a partial referencing
47 // a field with semantics that can't be nil (e.g. ranging over a
48 // non-map type, or calling a method on a nil interface).
49 //
50 // Page-internal errors (from non-partial templates) are expected with
51 // empty data and are filtered out — those are exercised by the
52 // handler-level tests that pass realistic fixtures. For the typed-
53 // struct regression specifically, see the hello handler test in
54 // internal/web/handlers/.
55 func TestProductionTemplatesPartialsTolerateEmptyData(t *testing.T) {
56 t.Parallel()
57 tmplFS := TemplatesFS()
58 r, err := render.New(tmplFS, render.Options{})
59 if err != nil {
60 t.Fatalf("render.New: %v", err)
61 }
62 pages, err := listPages(tmplFS)
63 if err != nil {
64 t.Fatalf("listPages: %v", err)
65 }
66 if len(pages) == 0 {
67 t.Fatal("no pages discovered under templates/ — fixture broken")
68 }
69 req := httptest.NewRequest("GET", "/", nil)
70 for _, name := range pages {
71 t.Run(name, func(t *testing.T) {
72 t.Parallel()
73 rw := httptest.NewRecorder()
74 err := r.RenderPage(rw, req, name, map[string]any{})
75 if err != nil && errorOriginatesInPartial(err) {
76 t.Errorf("page %q: shared partial errored on empty data: %v", name, err)
77 }
78 _, _ = io.Copy(io.Discard, rw.Body)
79 })
80 }
81 }
82
83 func TestOrgPagesRenderSingleSharedOrgNav(t *testing.T) {
84 t.Parallel()
85 r, err := render.New(TemplatesFS(), render.Options{})
86 if err != nil {
87 t.Fatalf("render.New: %v", err)
88 }
89 data := map[string]any{
90 "Title": "Organization",
91 "Org": map[string]any{"Slug": "gardesk", "DisplayName": "gardesk"},
92 "AvatarURL": "/avatars/gardesk",
93 "ActiveOrgNav": "repositories",
94 "RepoCount": 5,
95 "FilteredCount": 5,
96 "PageCount": 1,
97 "MemberCount": 1,
98 "IsOwner": true,
99 "Form": map[string]any{
100 "DisplayName": "gardesk",
101 "Description": "",
102 "Website": "",
103 "Location": "",
104 "BillingEmail": "",
105 "AllowMemberRepoCreate": true,
106 },
107 }
108 req := httptest.NewRequest("GET", "/", nil)
109 for _, page := range []string{"orgs/repositories", "orgs/settings_profile"} {
110 t.Run(page, func(t *testing.T) {
111 t.Parallel()
112 rw := httptest.NewRecorder()
113 if err := r.RenderPage(rw, req, page, data); err != nil {
114 t.Fatalf("RenderPage: %v", err)
115 }
116 if got := strings.Count(rw.Body.String(), `<nav class="shithub-org-nav"`); got != 1 {
117 t.Fatalf("org nav count = %d, want 1", got)
118 }
119 })
120 }
121 }
122
123 func TestExploreFeedFragmentAppendsRowsAndReplacesPagination(t *testing.T) {
124 t.Parallel()
125 r, err := render.New(TemplatesFS(), render.Options{})
126 if err != nil {
127 t.Fatalf("render.New: %v", err)
128 }
129 rw := httptest.NewRecorder()
130 if err := r.RenderFragment(rw, "explore/feed_page", map[string]any{
131 "FeedHasNext": true,
132 "FeedNextURL": "/explore?before=2026-05-12T03%3A00%3A00Z~42",
133 }); err != nil {
134 t.Fatalf("RenderFragment: %v", err)
135 }
136 body := rw.Body.String()
137 for _, want := range []string{
138 `id="shithub-feed-fragment-rows"`,
139 `hx-target="#shithub-feed-list"`,
140 `hx-swap="beforeend"`,
141 `hx-select="#shithub-feed-fragment-rows > *"`,
142 `hx-select-oob="#shithub-feed-pagination:outerHTML"`,
143 `Loading...`,
144 } {
145 if !strings.Contains(body, want) {
146 t.Fatalf("fragment missing %q in:\n%s", want, body)
147 }
148 }
149 }
150
151 // errorOriginatesInPartial returns true when an html/template execute
152 // error blames a file whose basename starts with `_`. Errors from such
153 // files are bugs in the partial because we render with an empty map
154 // (which should never cause field-existence failures).
155 func errorOriginatesInPartial(err error) bool {
156 // Format: `template: file.html:LINE:COL: executing ...`
157 s := err.Error()
158 const prefix = "template: "
159 for {
160 i := strings.Index(s, prefix)
161 if i < 0 {
162 return false
163 }
164 s = s[i+len(prefix):]
165 end := strings.IndexByte(s, ':')
166 if end < 0 {
167 return false
168 }
169 filename := s[:end]
170 if strings.HasPrefix(path.Base(filename), "_") {
171 return true
172 }
173 s = s[end:]
174 }
175 }
176
177 // listPages walks tmplFS and returns the lookup names render.New uses
178 // (path without trailing .html, partials excluded).
179 func listPages(tmplFS fs.FS) ([]string, error) {
180 var pages []string
181 err := fs.WalkDir(tmplFS, ".", func(p string, d fs.DirEntry, walkErr error) error {
182 if walkErr != nil {
183 return walkErr
184 }
185 if d.IsDir() || !strings.HasSuffix(p, ".html") {
186 return nil
187 }
188 if strings.HasPrefix(path.Base(p), "_") {
189 return nil
190 }
191 pages = append(pages, strings.TrimSuffix(p, ".html"))
192 return nil
193 })
194 return pages, err
195 }
196