package main import ( "context" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" ) func TestAgo(t *testing.T) { now := time.Date(2026, 9, 5, 12, 0, 0, 0, time.Local) for _, tc := range []struct { name string at time.Time want string }{ {"seconds", now.Add(-30 * time.Second), "juuri nyt"}, {"one minute", now.Add(-time.Minute), "minuutti sitten"}, {"minutes", now.Add(-5 * time.Minute), "5 minuuttia sitten"}, {"one hour", now.Add(-time.Hour), "tunti sitten"}, {"hours", now.Add(-5 * time.Hour), "5 tuntia sitten"}, {"yesterday", now.Add(-25 * time.Hour), "eilen"}, {"days", now.Add(-5 * 24 * time.Hour), "5 päivää sitten"}, // Past a week the exact age stops mattering and the date takes over. {"a week", now.Add(-7 * 24 * time.Hour), "29.8.2026"}, {"months", now.Add(-60 * 24 * time.Hour), "7.7.2026"}, } { t.Run(tc.name, func(t *testing.T) { if got := ago(tc.at, now); got != tc.want { t.Fatalf("ago = %q, want %q", got, tc.want) } }) } } // A draft is the author's alone. It must not reach a member through either surface. func TestDraftsAreInvisibleToMembers(t *testing.T) { a := testApp(t) ctx := context.Background() for _, n := range []struct { title string draft bool }{ {"Julkaistu tiedote", false}, {"Salainen luonnos", true}, } { if _, err := a.db.ExecContext(ctx, `insert into news (title, body, is_draft) values ($1, 'teksti', $2)`, n.title, n.draft); err != nil { t.Fatal(err) } } items, err := a.publishedNews(ctx, 10) if err != nil { t.Fatal(err) } if len(items) != 1 || items[0].Title != "Julkaistu tiedote" { t.Fatalf("published news = %+v, want only the published one", items) } // The admin listing is the one place a draft shows up. all, err := a.adminNews(ctx) if err != nil { t.Fatal(err) } if len(all) != 2 { t.Fatalf("admin news = %d items, want 2", len(all)) } } // The body reaches the page through template.HTML, which turns off Go's escaping. goldmark has to // be the thing that neutralises a script tag, so assert it actually does. func TestMarkdownEscapesRawHTML(t *testing.T) { n := newsItem{Body: "Hei ja **lihavointi** ja [linkki](https://example.com)."} got := string(n.HTML()) // goldmark drops raw HTML rather than escaping it, so the tag disappears entirely — stricter // than escaping, and either outcome is safe. What matters is that no tag survives. if strings.Contains(got, "lihavointi") { t.Fatalf("markdown emphasis did not render: %s", got) } if !strings.Contains(got, `href="https://example.com"`) { t.Fatalf("markdown link did not render: %s", got) } } // Posting news is admin-only, and the checkbox decides whether members ever see it. func TestCreateNewsRequiresAdminAndHonoursDraft(t *testing.T) { a := testApp(t) ctx := context.Background() mux := a.withMember(a.memberMux()) plain := a.seedMember(t, "jasen@example.com") memberTok, _, err := a.startSession(ctx, plain, false) if err != nil { t.Fatal(err) } form := url.Values{"title": {"Otsikko"}, "body": {"Teksti"}} if w := postAs(t, mux, "/admin/news", form, memberTok); w.Code != http.StatusNotFound { t.Fatalf("member posting news: status = %d, want 404", w.Code) } _, adminTok := a.seedAdminMember(t, "yllapito@example.com") draftForm := url.Values{"title": {"Luonnos"}, "body": {"Teksti"}, "is_draft": {"1"}} if w := postAs(t, mux, "/admin/news", draftForm, adminTok); w.Code != http.StatusSeeOther { t.Fatalf("admin posting draft: status = %d, want 303", w.Code) } if w := postAs(t, mux, "/admin/news", form, adminTok); w.Code != http.StatusSeeOther { t.Fatalf("admin posting news: status = %d, want 303", w.Code) } items, err := a.publishedNews(ctx, 10) if err != nil { t.Fatal(err) } if len(items) != 1 || items[0].Title != "Otsikko" { t.Fatalf("published = %+v, want only the non-draft", items) } } // The templates reach Ago and HTML through dict, which boxes the item in an interface — a pointer // receiver there is invisible and only shows up as a 500 in a browser. go vet cannot see it, so // render the real page and insist the markdown came out the far side. func TestFrontPageRendersNews(t *testing.T) { a := testApp(t) ctx := context.Background() mux := a.withMember(a.memberMux()) id := a.seedMember(t, "esa@example.com") tok, _, err := a.startSession(ctx, id, false) if err != nil { t.Fatal(err) } if _, err := a.db.ExecContext(ctx, `insert into news (title, body) values ('Tiedote', 'Teksti **lihavoituna**.')`); err != nil { t.Fatal(err) } r := httptest.NewRequest("GET", "/", nil) r.AddCookie(&http.Cookie{Name: sessionCookie, Value: tok}) w := httptest.NewRecorder() mux.ServeHTTP(w, r) if w.Code != http.StatusOK { t.Fatalf("front page: status = %d, want 200", w.Code) } body := w.Body.String() for _, want := range []string{"Tiedote", "lihavoituna", "juuri nyt"} { if !strings.Contains(body, want) { t.Fatalf("front page is missing %q", want) } } } // Logging in is what records last_login_at; nothing else writes it. func TestLoginRecordsLastLogin(t *testing.T) { a := testApp(t) ctx := context.Background() mux := a.withMember(a.memberMux()) if _, err := a.db.ExecContext(ctx, `insert into invites (code) values ('kutsu9')`); err != nil { t.Fatal(err) } reg := url.Values{ "code": {"kutsu9"}, "name": {"Esa"}, "email": {"esa@example.com"}, "password": {"salasana1"}, } if w := post(t, mux, "/register", reg); w.Code != http.StatusSeeOther { t.Fatalf("register: status = %d, want 303", w.Code) } var last *time.Time if err := a.db.QueryRowContext(ctx, `select last_login_at from users where email = 'esa@example.com'`).Scan(&last); err != nil { t.Fatal(err) } if last != nil { t.Fatalf("registration set last_login_at to %v, want null until a real login", last) } if w := post(t, mux, "/login", url.Values{ "email": {"esa@example.com"}, "password": {"salasana1"}, }); w.Code != http.StatusSeeOther { t.Fatalf("login: status = %d, want 303", w.Code) } if err := a.db.QueryRowContext(ctx, `select last_login_at from users where email = 'esa@example.com'`).Scan(&last); err != nil { t.Fatal(err) } if last == nil { t.Fatal("login did not record last_login_at") } }