orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1package main
2
3import (
4 "io/fs"
5 "strings"
6 "testing"
7
8 "github.com/google/uuid"
9
10 "analytics.bythewood.me/web"
11)
12
13// The "/" case is the one that is not obvious: "//" opens a Typst line comment,
14// so a page URL swallows the rest of the line rather than failing.
15func TestTypstMD(t *testing.T) {
16 tests := []struct{ in, want string }{
17 {"/blog/post", `\/blog\/post`},
18 {"https://example.com//x", `https:\/\/example.com\/\/x`},
19 {"a*b_c`d", `a\*b\_c\` + "`" + `d`},
20 {"#set page", `\#set page`},
21 {"[link]", `\[link\]`},
22 {"a@b", `a\@b`},
23 {"plain text", "plain text"},
24 {"", ""},
25 }
26 for _, tt := range tests {
27 if got := typstMD(tt.in); got != tt.want {
28 t.Errorf("typstMD(%q) = %q, want %q", tt.in, got, tt.want)
29 }
30 }
31}
32
33func TestTypstStr(t *testing.T) {
34 tests := []struct{ in, want string }{
35 {`say "hi"`, `say \"hi\"`},
36 {`back\slash`, `back\\slash`},
37 {"line\nbreak", `line\nbreak`},
38 // Markup metacharacters are harmless in a string literal; escaping
39 // them here would put visible backslashes in the PDF.
40 {"a/b#c", "a/b#c"},
41 }
42 for _, tt := range tests {
43 if got := typstStr(tt.in); got != tt.want {
44 t.Errorf("typstStr(%q) = %q, want %q", tt.in, got, tt.want)
45 }
46 }
47}
48
49// A non-ASCII byte cannot go into a header value, so "Café" has to survive the
50// trip into Content-Disposition.
51func TestASCIIFilename(t *testing.T) {
52 tests := []struct{ in, want string }{
53 {"Café", "Caf_"},
54 {"my site.com", "my site.com"},
55 // Leading dots are stripped, so this is not a hidden file.
56 {"../../etc/passwd", "_.._etc_passwd"},
57 {`quote"inject`, "quote_inject"},
58 {"...", "report"},
59 {"", "report"},
60 {" ", "report"},
61 }
62 for _, tt := range tests {
63 got := asciiFilename(tt.in)
64 if got != tt.want {
65 t.Errorf("asciiFilename(%q) = %q, want %q", tt.in, got, tt.want)
66 }
67 for _, r := range got {
68 if r > 127 {
69 t.Errorf("asciiFilename(%q) = %q, which is not ASCII", tt.in, got)
70 }
71 }
72 }
73}
74
75// Getting this wrong does not error, it splits one referrer into several rows.
76func TestNormalizeReferrer(t *testing.T) {
77 tests := []struct{ in, want string }{
78 {"https://www.google.com/search?q=x", "google.com"},
79 {"http://news.ycombinator.com/", "news.ycombinator.com"},
80 {"https://GitHub.com/overshard", "github.com"},
81 {"example.com/path", "example.com"},
82 {"", ""},
83 }
84 for _, tt := range tests {
85 if got := normalizeReferrer(tt.in); got != tt.want {
86 t.Errorf("normalizeReferrer(%q) = %q, want %q", tt.in, got, tt.want)
87 }
88 }
89}
90
91// A zero previous must not become an infinite or 100% increase.
92func TestPctChange(t *testing.T) {
93 tests := []struct {
94 cur, prev float64
95 want int64
96 }{
97 {100, 100, 0},
98 {120, 100, 20},
99 {80, 100, -20},
100 {5, 0, 0},
101 {0, 0, 0},
102 {0, 100, -100},
103 }
104 for _, tt := range tests {
105 if got := pctChange(tt.cur, tt.prev); got != tt.want {
106 t.Errorf("pctChange(%v, %v) = %d, want %d", tt.cur, tt.prev, got, tt.want)
107 }
108 }
109}
110
111// The single-point case divides by len-1, which is zero.
112func TestChartPolyline(t *testing.T) {
113 if got := chartPolyline(nil); got != "" {
114 t.Errorf("empty graph = %q, want empty", got)
115 }
116
117 one := chartPolyline([]GraphPoint{{Label: "Jan 1", Count: 5}})
118 if !strings.HasPrefix(one, "300.0,") {
119 t.Errorf("single point = %q, want it centred at x=300", one)
120 }
121
122 many := chartPolyline([]GraphPoint{{Count: 0}, {Count: 10}, {Count: 5}})
123 parts := strings.Fields(many)
124 if len(parts) != 3 {
125 t.Fatalf("three points produced %d coordinates: %q", len(parts), many)
126 }
127 // The peak pins to the top of the usable band, the zero to the bottom.
128 if !strings.HasPrefix(parts[0], "0.0,96.0") {
129 t.Errorf("zero count = %q, want it on the baseline at y=96", parts[0])
130 }
131 if !strings.HasPrefix(parts[1], "300.0,4.0") {
132 t.Errorf("peak = %q, want it at the top at y=4", parts[1])
133 }
134 if !strings.HasPrefix(parts[2], "600.0,") {
135 t.Errorf("last point = %q, want it at x=600", parts[2])
136 }
137}
138
139// A bot counted as a human inflates every metric, and the number still looks
140// plausible.
141func TestBotClassification(t *testing.T) {
142 bots := []string{
143 "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
144 "facebookexternalhit/1.1",
145 "Mozilla/5.0 (X11; Linux x86_64) HeadlessChrome/120.0.0.0",
146 "UptimeRobot/2.0",
147 }
148 for _, ua := range bots {
149 if !looksLikeBot(ua) {
150 t.Errorf("%q was not classified as a bot", ua)
151 }
152 }
153
154 humans := []string{
155 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
156 "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 Version/17.4 Mobile/15E148 Safari/604.1",
157 }
158 for _, ua := range humans {
159 if looksLikeBot(ua) {
160 t.Errorf("%q was misclassified as a bot", ua)
161 }
162 }
163}
164
165// Tablet is tested before mobile because an iPad's User-Agent contains neither
166// "mobile" nor "iphone", while an Android tablet's contains "mobile".
167func TestClassifyDevice(t *testing.T) {
168 tests := []struct{ ua, family, want string }{
169 {"Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X)", "iPad", "Tablet"},
170 {"Mozilla/5.0 (Linux; Android 14; SM-X200) Mobile Safari", "Tablet", "Tablet"},
171 {"Mozilla/5.0 (iPhone; CPU iPhone OS 17_4)", "iPhone", "Mobile"},
172 {"Mozilla/5.0 (Linux; Android 14; Pixel 8) Mobile Safari", "", "Mobile"},
173 {"Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/131", "", "Desktop"},
174 }
175 for _, tt := range tests {
176 if got := classifyDevice(tt.ua, tt.family); got != tt.want {
177 t.Errorf("classifyDevice(%q, %q) = %q, want %q", tt.ua, tt.family, got, tt.want)
178 }
179 }
180}
181
182// Truncating on a byte boundary would store invalid UTF-8.
183func TestClampRunes(t *testing.T) {
184 if got := clampRunes("hello", 10); got != "hello" {
185 t.Errorf("short string was altered: %q", got)
186 }
187 got := clampRunes(strings.Repeat("é", 100), 10)
188 if len([]rune(got)) != 10 {
189 t.Errorf("clamped to %d runes, want 10", len([]rune(got)))
190 }
191 if !isValidUTF8(got) {
192 t.Errorf("clamping produced invalid UTF-8: %q", got)
193 }
194}
195
196func isValidUTF8(s string) bool {
197 for _, r := range s {
198 if r == '�' {
199 return false
200 }
201 }
202 return true
203}
204
205// encodeExtra stores rather than renders, so a "<" that went in comes back out.
206func TestEncodeExtra(t *testing.T) {
207 if got := encodeExtra(nil); got != "{}" {
208 t.Errorf("empty extra = %q, want {}", got)
209 }
210 got := encodeExtra(map[string]any{"note": "a<b & c>d"})
211 if !strings.Contains(got, "a<b & c>d") {
212 t.Errorf("encodeExtra escaped HTML in stored data: %q", got)
213 }
214}
215
216func TestParseDateToMS(t *testing.T) {
217 start, ok := parseDateToMS("2026-05-09", false)
218 if !ok {
219 t.Fatal("a valid date failed to parse")
220 }
221 end, ok := parseDateToMS("2026-05-09", true)
222 if !ok {
223 t.Fatal("a valid end date failed to parse")
224 }
225 // One second short of a full day: 23:59:59.
226 if d := end - start; d != 86399*1000 {
227 t.Errorf("end minus start = %dms, want %dms", d, 86399*1000)
228 }
229 for _, bad := range []string{"", "not-a-date", "2026-13-45", "05/09/2026"} {
230 if _, ok := parseDateToMS(bad, false); ok {
231 t.Errorf("%q parsed as a date", bad)
232 }
233 }
234}
235
236// %v would flip to scientific notation at the top of the range.
237func TestTrimFloat(t *testing.T) {
238 tests := []struct {
239 in float64
240 want string
241 }{
242 {0, "0"},
243 {60.94, "60.94"},
244 {100, "100"},
245 {1200000, "1200000"},
246 }
247 for _, tt := range tests {
248 if got := trimFloat(tt.in); got != tt.want {
249 t.Errorf("trimFloat(%v) = %q, want %q", tt.in, got, tt.want)
250 }
251 }
252}
253
254// A wrong id here is silent: the dashboard just shows no traffic for itself.
255func TestSelfTrackingID(t *testing.T) {
256 const want = "580ebab0-3d14-4a20-89da-d57fc3d7d9e8"
257 if analyticsID != want {
258 t.Errorf("analyticsID = %q, want %q", analyticsID, want)
259 }
260 if _, err := uuid.Parse(analyticsID); err != nil {
261 t.Errorf("analyticsID is not a uuid, so collect would reject it: %v", err)
262 }
263 // Staging renders no snippet rather than posting an id it has no row for.
264 if got := collectorID(); Staging && got != "" {
265 t.Errorf("collectorID() = %q on staging, want empty", got)
266 } else if !Staging && got != analyticsID {
267 t.Errorf("collectorID() = %q, want %q", got, analyticsID)
268 }
269}
270
271// Every template the server asks for has to exist. NewRenderer resolves the
272// list at boot rather than at build, so a page left listed after its file was
273// deleted compiles, ships, and then crash-loops the container on startup, which
274// is how it was found on repos.
275func TestEveryListedTemplateParses(t *testing.T) {
276 templates, err := fs.Sub(templateFS, "templates")
277 if err != nil {
278 t.Fatal(err)
279 }
280 if _, err := web.NewRenderer(templates, templateFuncs, layoutTemplates, pageTemplates); err != nil {
281 t.Fatalf("the template set does not parse: %v", err)
282 }
283}