Files
Levyraati26_go/src/news_test.go
T
Esa Kataja deaadd2f5c Add announcements, and record when members last logged in
A single place to say "downloads work again" without messaging everyone.
The body is markdown, stored as typed and rendered on the way out, so a post
survives editing without a lossy round trip through HTML. goldmark drops raw
HTML rather than rendering it, which matters because the body reaches the
page through template.HTML with Go's own escaping switched off.

The front page carries the three newest under the queue, newest expanded,
and links to /news only when there is a fourth. News is decoration there: if
the query fails the queue still renders. Drafts exist so a post can be
written before it is sent, and hiding is the same toggle as publishing.

Ages read as "5 minuuttia sitten" for a week and then become a date, since
past that the exact age stops being the interesting part.

last_login_at is unrelated to the feed — it answers "does anyone actually
use this", and stays null until a real login, which is how an unused invite
shows up in the members table.

Ago and HTML take value receivers on purpose: templates reach them through
dict, which boxes the item in an interface, and a pointer method on a
non-addressable value is invisible there. That failure renders as a 500 and
is invisible to go vet, so TestFrontPageRendersNews renders the real page.
2026-09-05 16:00:01 +03:00

202 lines
6.3 KiB
Go

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 <script>alert(1)</script> 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, "<script") || strings.Contains(got, "</script") {
t.Fatalf("raw script tag survived rendering: %s", got)
}
if !strings.Contains(got, "<strong>lihavointi</strong>") {
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, "[email protected]")
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, "[email protected]")
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, "[email protected]")
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", "<strong>lihavoituna</strong>", "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": {"[email protected]"}, "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 = '[email protected]'`).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": {"[email protected]"}, "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 = '[email protected]'`).Scan(&last); err != nil {
t.Fatal(err)
}
if last == nil {
t.Fatal("login did not record last_login_at")
}
}