repos
/ orchard main

orchard

mirror

Every 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

8.5 KB · 284 lines · Go Raw History
  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}