| 1 | package calendartest |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "net/http/httptest" |
| 6 | "regexp" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | calendar "amadan.net/oficina/calendar/internal/calendar" |
| 11 | ) |
| 12 | |
| 13 | // hashedStylesheet is the fingerprinted URL shape a page links: |
| 14 | // /static/tokens.<16 hex>.css. |
| 15 | var hashedStylesheet = regexp.MustCompile(`/static/tokens\.[0-9a-f]{16}\.css`) |
| 16 | |
| 17 | func TestIndexRendersForASignedInPerson(t *testing.T) { |
| 18 | app, session := signedIn(t, "someone@example.com") |
| 19 | rec := follow(t, app.Handler(), session, "/") |
| 20 | if rec.Code != http.StatusOK { |
| 21 | t.Fatalf("GET /: status %d, want 200", rec.Code) |
| 22 | } |
| 23 | // The day page opens with its own sentence, which is the one thing |
| 24 | // every day has whether or not anything is on it. |
| 25 | if !strings.Contains(rec.Body.String(), "cal-sentence") { |
| 26 | t.Errorf("GET /: the day page is missing its summary sentence:\n%s", rec.Body.String()) |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | func TestIndexLinksFingerprintedStylesheet(t *testing.T) { |
| 31 | // The sign-in page, not the index: this pins the asset pipeline, |
| 32 | // which every page shares, and a signed-out page is the one every |
| 33 | // visitor reaches. |
| 34 | h := newApp(t).Handler() |
| 35 | href := hashedStylesheet.FindString(get(t, h, "/signin").Body.String()) |
| 36 | if href == "" { |
| 37 | t.Fatal("sign-in page links no fingerprinted stylesheet") |
| 38 | } |
| 39 | rec := get(t, h, href) |
| 40 | if rec.Code != http.StatusOK { |
| 41 | t.Fatalf("GET %s: status %d, want 200", href, rec.Code) |
| 42 | } |
| 43 | if got := rec.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" { |
| 44 | t.Errorf("GET %s: Cache-Control = %q, want immutable year", href, got) |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | // The bare name keeps working, just never long-cached — so a changed |
| 49 | // file always shows on an ordinary reload. |
| 50 | func TestBareAssetNameStaysFresh(t *testing.T) { |
| 51 | rec := get(t, newApp(t).Handler(), "/static/tokens.css") |
| 52 | if rec.Code != http.StatusOK { |
| 53 | t.Fatalf("GET /static/tokens.css: status %d, want 200", rec.Code) |
| 54 | } |
| 55 | if got := rec.Header().Get("Cache-Control"); got != "no-cache" { |
| 56 | t.Errorf("Cache-Control = %q, want no-cache", got) |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | // ErrorPage is what main.go points Options.ErrorPage at; called |
| 61 | // directly, it pins that a panic's page is the framework's error-page |
| 62 | // partial rather than a stray net/http default. |
| 63 | func TestErrorPageRendersFrameworkPartial(t *testing.T) { |
| 64 | rec := httptest.NewRecorder() |
| 65 | calendar.ErrorPage(rec, httptest.NewRequest(http.MethodGet, "/", nil), http.StatusInternalServerError, "abc123") |
| 66 | if rec.Code != http.StatusInternalServerError { |
| 67 | t.Fatalf("ErrorPage: status %d, want %d", rec.Code, http.StatusInternalServerError) |
| 68 | } |
| 69 | body := rec.Body.String() |
| 70 | if !strings.Contains(body, "If it keeps happening, quote the reference below.") { |
| 71 | t.Errorf("ErrorPage body missing the 500 body (spec §3.2):\n%s", body) |
| 72 | } |
| 73 | if !strings.Contains(body, "abc123") { |
| 74 | t.Errorf("ErrorPage body missing the ref:\n%s", body) |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | // The error pages say what the shared-chrome spec's §3.2 table says (estilo |
| 79 | // docs/superpowers/specs/2026-09-23-shared-chrome.md): the tab title, the |
| 80 | // h1, and a way back that names the app. A 500 or 503 on a GET offers the |
| 81 | // same URL again; anything else goes to Calendar. |
| 82 | func TestErrorPagesUseTheSuiteCopy(t *testing.T) { |
| 83 | for _, c := range []struct { |
| 84 | status int |
| 85 | method string |
| 86 | title, h1, cta string |
| 87 | ctaHref string |
| 88 | }{ |
| 89 | {http.StatusNotFound, http.MethodGet, "Page not found · Calendar", "We can’t find that page", "Go to Calendar", "/"}, |
| 90 | {http.StatusForbidden, http.MethodGet, "No access · Calendar", "You don’t have access to this page", "Go to Calendar", "/"}, |
| 91 | {http.StatusInternalServerError, http.MethodGet, "Something went wrong · Calendar", "Something went wrong", "Try again", "/week/2026-09-24?x=1"}, |
| 92 | {http.StatusInternalServerError, http.MethodPost, "Something went wrong · Calendar", "Something went wrong", "Go to Calendar", "/"}, |
| 93 | {http.StatusServiceUnavailable, http.MethodGet, "Calendar is unavailable · Calendar", "Calendar is unavailable right now", "Try again", "/week/2026-09-24?x=1"}, |
| 94 | } { |
| 95 | rec := httptest.NewRecorder() |
| 96 | calendar.ErrorPage(rec, httptest.NewRequest(c.method, "/week/2026-09-24?x=1", nil), c.status, "") |
| 97 | body := rec.Body.String() |
| 98 | if rec.Code != c.status { |
| 99 | t.Errorf("%d %s: status %d", c.status, c.method, rec.Code) |
| 100 | } |
| 101 | if !strings.Contains(body, "<title>"+c.title+"</title>") { |
| 102 | t.Errorf("%d %s: no <title>%s</title>", c.status, c.method, c.title) |
| 103 | } |
| 104 | if !regexp.MustCompile(`<h1 rst-error-title>\s*` + regexp.QuoteMeta(c.h1) + `\s*</h1>`).MatchString(body) { |
| 105 | t.Errorf("%d %s: no h1 %q", c.status, c.method, c.h1) |
| 106 | } |
| 107 | cta := regexp.MustCompile(`<a rst-btn="primary" href="([^"]*)">([^<]*)</a>`).FindStringSubmatch(body) |
| 108 | if cta == nil || cta[2] != c.cta || strings.ReplaceAll(cta[1], "&", "&") != c.ctaHref { |
| 109 | t.Errorf("%d %s: primary way back = %q, want %q → %s", c.status, c.method, cta, c.cta, c.ctaHref) |
| 110 | } |
| 111 | } |
| 112 | } |
| 113 | |