35c6ff5a71
Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
146 lines
5.0 KiB
Go
146 lines
5.0 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/ipc"
|
|
)
|
|
|
|
// eventsCore serves a canned intake journal. Embedding
|
|
// ipc.UnimplementedCoreAPI means any other call fails loudly.
|
|
type eventsCore struct {
|
|
ipc.UnimplementedCoreAPI
|
|
events []ipc.IntakeEvent
|
|
err error
|
|
gotN int
|
|
}
|
|
|
|
func (c *eventsCore) RecentEvents(_ context.Context, n int) ([]ipc.IntakeEvent, error) {
|
|
c.gotN = n
|
|
return c.events, c.err
|
|
}
|
|
|
|
func getEvents(t *testing.T, core ipc.CoreAPI) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
handleEvents(w, httptest.NewRequest(http.MethodGet, "/events", nil), core)
|
|
return w
|
|
}
|
|
|
|
func TestEventsPageRendersTheJournal(t *testing.T) {
|
|
core := &eventsCore{events: []ipc.IntakeEvent{
|
|
{Source: "rss:tech", Kind: "note", Title: "Вышло ядро 6.19", Priority: "low",
|
|
OccurredAt: time.Date(2026, 8, 1, 7, 15, 0, 0, time.UTC)},
|
|
{Source: "ambient:notif", Kind: "fact", Title: "calendar_event_20260801_планёрка",
|
|
Body: "10:00-11:00 планёрка", Priority: "low",
|
|
OccurredAt: time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)},
|
|
}}
|
|
w := getEvents(t, core)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", w.Code)
|
|
}
|
|
body := w.Body.String()
|
|
occurred := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC).Local().Format("02.01 15:04:05")
|
|
for _, want := range []string{"rss:tech", "Вышло ядро 6.19", "ambient:notif", "10:00-11:00 планёрка", occurred} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("page does not mention %q", want)
|
|
}
|
|
}
|
|
if core.gotN != eventsPageLimit {
|
|
t.Errorf("asked core for %d events, want %d", core.gotN, eventsPageLimit)
|
|
}
|
|
}
|
|
|
|
func TestEventsPageSaysNothingArrived(t *testing.T) {
|
|
w := getEvents(t, &eventsCore{})
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", w.Code)
|
|
}
|
|
if !strings.Contains(w.Body.String(), "nothing has arrived yet") {
|
|
t.Error("empty journal did not render the empty-state line")
|
|
}
|
|
}
|
|
|
|
func TestEventsPageReportsAReadFailure(t *testing.T) {
|
|
// An unreachable journal must say so rather than render an empty table,
|
|
// which would imply nothing arrived.
|
|
w := getEvents(t, &eventsCore{err: errors.New("core is down")})
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 with the error rendered", w.Code)
|
|
}
|
|
body := w.Body.String()
|
|
if !strings.Contains(body, "intake journal unavailable") ||
|
|
!strings.Contains(body, string(problemCoreReadFailed)) ||
|
|
!strings.Contains(body, "request ") {
|
|
t.Errorf("page did not report a traceable, sanitized read failure: %s", body)
|
|
}
|
|
if strings.Contains(body, "core is down") {
|
|
t.Errorf("page disclosed the internal read error: %s", body)
|
|
}
|
|
if strings.Contains(body, "nothing has arrived yet") {
|
|
t.Error("a failed read rendered as an empty journal")
|
|
}
|
|
}
|
|
|
|
func TestEventsPageWithoutCore(t *testing.T) {
|
|
w := getEvents(t, nil)
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Errorf("status = %d, want 503", w.Code)
|
|
}
|
|
}
|
|
|
|
// awayFromLocal returns a zone three hours off whatever this machine runs in,
|
|
// so a test can tell "rendered in his clock" apart from "rendered in whatever
|
|
// zone the value arrived in" without depending on TZ.
|
|
func awayFromLocal() *time.Location {
|
|
_, off := time.Now().Zone()
|
|
return time.FixedZone("away", off+3*60*60)
|
|
}
|
|
|
|
func TestEventsPageRendersBothTimesInLocalZone(t *testing.T) {
|
|
// OccurredAt carries the source's zone — the store hands back UTC and
|
|
// internal/rss parses a pubDate to UTC — while NoticedAt is the bus's local
|
|
// instant. Rendered raw, the two columns of one row were in two zones and a
|
|
// feed item read hours older than it was.
|
|
away := awayFromLocal()
|
|
occurred := time.Date(2026, 8, 1, 7, 15, 0, 0, time.UTC).In(away)
|
|
noticed := occurred.Add(2 * time.Minute)
|
|
core := &eventsCore{events: []ipc.IntakeEvent{{
|
|
Source: "rss:tech", Kind: "note", Title: "Вышло ядро 6.19", Priority: "low",
|
|
OccurredAt: occurred, NoticedAt: noticed,
|
|
}}}
|
|
body := getEvents(t, core).Body.String()
|
|
const layout = "02.01 15:04:05"
|
|
for _, ts := range []time.Time{occurred, noticed} {
|
|
if !strings.Contains(body, ts.Local().Format(layout)) {
|
|
t.Errorf("page does not render %s in his clock (%s)", ts, ts.Local().Format(layout))
|
|
}
|
|
if strings.Contains(body, ts.In(away).Format(layout)) {
|
|
t.Errorf("page rendered %s in the source's zone", ts)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEventsPageEscapesIntakeText(t *testing.T) {
|
|
// Titles come from outside — a feed headline, a notification. They are shown
|
|
// on a page and must never be able to inject markup into it.
|
|
core := &eventsCore{events: []ipc.IntakeEvent{{
|
|
Source: "rss:x", Kind: "note", Priority: "low",
|
|
Title: `<script>alert(1)</script>`,
|
|
OccurredAt: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC),
|
|
}}}
|
|
body := getEvents(t, core).Body.String()
|
|
if strings.Contains(body, "<script>alert(1)</script>") {
|
|
t.Error("intake title was not escaped")
|
|
}
|
|
if !strings.Contains(body, "<script>") {
|
|
t.Error("intake title is missing from the page entirely")
|
|
}
|
|
}
|