|
|
@@ -0,0 +1,183 @@
|
|
|
+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{
|
|
|
+ "<title>Middle · posts.example.com</title>",
|
|
|
+ "<p>Middle body</p>",
|
|
|
+ `<a class="newer" href="/posts/newest">`,
|
|
|
+ `<a class="older" href="/posts/oldest">`,
|
|
|
+ } {
|
|
|
+ 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, "<h1>404</h1>") {
|
|
|
+ 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)
|
|
|
+ }
|
|
|
+}
|