package main import ( "encoding/xml" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" ) func newTestServer(t *testing.T, files map[string]string) (*httptest.Server, string) { t.Helper() dir := t.TempDir() for name, src := range files { writeFile(t, dir, name, src) } a, err := newApp(dir, "https://posts.example.com/") if err != nil { t.Fatal(err) } srv := httptest.NewServer(a.routes()) t.Cleanup(srv.Close) return srv, dir } func writeFile(t *testing.T, dir, name, src string) { t.Helper() if err := os.WriteFile(filepath.Join(dir, name), []byte(src), 0o644); err != nil { t.Fatal(err) } } func get(t *testing.T, srv *httptest.Server, path string) (int, http.Header, string) { t.Helper() resp, err := http.Get(srv.URL + path) if err != nil { t.Fatal(err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { t.Fatal(err) } return resp.StatusCode, resp.Header, string(body) } var sample = map[string]string{ "2026-01-01-oldest.md": "# Oldest\n", "2026-02-01-middle.md": "---\ntitle: Middle\nsummary: The middle one.\n---\nMiddle body\n", "2026-03-01-newest.md": "# Newest\n", "2026-04-01-draft.md": "---\ndraft: true\n---\n# Secret draft\n", "Bad Name.md": "# Broken\n", "notes.txt": "not a post", } func TestIndex(t *testing.T) { srv, _ := newTestServer(t, sample) status, h, body := get(t, srv, "/") if status != 200 { t.Fatalf("status = %d", status) } if h.Get("Content-Security-Policy") == "" || h.Get("X-Content-Type-Options") != "nosniff" { t.Errorf("missing security headers: %v", h) } n, m, o := strings.Index(body, "Newest"), strings.Index(body, "Middle"), strings.Index(body, "Oldest") if n < 0 || m < 0 || o < 0 || !(n < m && m < o) { t.Errorf("posts missing or out of order:\n%s", body) } if !strings.Contains(body, "The middle one.") { t.Error("summary not shown") } for _, hidden := range []string{"Secret draft", "Broken"} { if strings.Contains(body, hidden) { t.Errorf("%q should not be listed", hidden) } } } func TestEmptyIndex(t *testing.T) { srv, _ := newTestServer(t, nil) if status, _, body := get(t, srv, "/"); status != 200 || !strings.Contains(body, "No posts yet") { t.Errorf("status = %d, body:\n%s", status, body) } } func TestPostPage(t *testing.T) { srv, _ := newTestServer(t, sample) status, _, body := get(t, srv, "/posts/middle") if status != 200 { t.Fatalf("status = %d", status) } for _, want := range []string{ "Middle ยท posts.example.com", "

Middle body

", ``, ``, } { if !strings.Contains(body, want) { t.Errorf("missing %q in:\n%s", want, body) } } _, _, newest := get(t, srv, "/posts/newest") if strings.Contains(newest, `class="newer"`) { t.Error("newest post should have no newer link") } _, _, oldest := get(t, srv, "/posts/oldest") if strings.Contains(oldest, `class="older"`) { t.Error("oldest post should have no older link") } } func TestNotFound(t *testing.T) { srv, _ := newTestServer(t, sample) for _, path := range []string{ "/posts/nope", "/posts/draft", "/posts/2026-01-01-oldest", "/posts/..%2f..%2fetc%2fpasswd", "/posts/", "/static/", "/static/missing.css", "/whatever", } { status, _, body := get(t, srv, path) if status != 404 { t.Errorf("%s: status = %d, want 404", path, status) } if path != "/static/missing.css" && !strings.Contains(body, "

404

") { t.Errorf("%s: expected styled 404 page", path) } } } func TestStatic(t *testing.T) { srv, _ := newTestServer(t, nil) status, h, body := get(t, srv, "/static/style.css") if status != 200 || !strings.HasPrefix(h.Get("Content-Type"), "text/css") || !strings.Contains(body, ":root") { t.Errorf("status = %d, type = %q", status, h.Get("Content-Type")) } } func TestFeed(t *testing.T) { srv, _ := newTestServer(t, sample) status, h, body := get(t, srv, "/feed.xml") if status != 200 || !strings.HasPrefix(h.Get("Content-Type"), "application/rss+xml") { t.Fatalf("status = %d, type = %q", status, h.Get("Content-Type")) } var feed rss if err := xml.Unmarshal([]byte(body), &feed); err != nil { t.Fatalf("invalid XML: %v\n%s", err, body) } items := feed.Channel.Items if len(items) != 3 { t.Fatalf("got %d items, want 3", len(items)) } if items[0].Link != "https://posts.example.com/posts/newest" { t.Errorf("first link = %q", items[0].Link) } if items[1].Description != "The middle one." { t.Errorf("description = %q", items[1].Description) } } func TestReloadsWhenContentChanges(t *testing.T) { srv, dir := newTestServer(t, map[string]string{"2026-01-01-one.md": "# One\n"}) if _, _, body := get(t, srv, "/"); strings.Contains(body, "Two") { t.Fatal("unexpected post") } writeFile(t, dir, "2026-01-02-two.md", "# Two\n") if status, _, _ := get(t, srv, "/posts/two"); status != 200 { t.Errorf("new post not picked up: status = %d", status) } if err := os.Remove(filepath.Join(dir, "2026-01-01-one.md")); err != nil { t.Fatal(err) } if status, _, _ := get(t, srv, "/posts/one"); status != 404 { t.Errorf("removed post still served: status = %d", status) } }