package web import ( "encoding/json" "net/http" "net/http/httptest" "testing" "time" "pete/internal/storage" ) // The "while you were away" panel. Two things are worth pinning: it never shows // somebody else's adventurer, and its window survives a page refresh — the // failure that would make the whole panel useless without breaking anything a // unit test would normally notice. // awayReq builds a request for /adventure as a signed-in user, or anonymously // when sub is empty. func awayReq(t *testing.T, s *Server, sub, username string) *http.Request { t.Helper() r := httptest.NewRequest("GET", "/adventure", nil) if sub != "" { payload, _ := json.Marshal(SessionUser{ Sub: sub, Username: username, Exp: time.Now().Add(time.Hour).Unix(), }) r.AddCookie(&http.Cookie{Name: sessionCookie, Value: s.auth.sign(payload)}) } return r } // seedAwayOwner puts one adventurer on the board owned by localpart, the way the // two real pushes do. func seedAwayOwner(t *testing.T, localpart, character string) { t.Helper() now := time.Now().Unix() if err := storage.ReplaceRoster([]storage.RosterEntry{{ Token: "tok-" + localpart, Name: character, Level: 14, Status: "idle", }}, now); err != nil { t.Fatal(err) } if err := storage.ReplacePlayerDetail([]storage.PlayerDetail{{ Localpart: localpart, Token: "tok-" + localpart, }}, now); err != nil { t.Fatal(err) } } func seedAwayEvent(t *testing.T, guid, kind, subject string, at int64) { t.Helper() if err := storage.InsertAdventureEvent(&storage.AdvEvent{ GUID: guid, EventType: kind, Subject: subject, Zone: "holymachina", OccurredAt: at, }); err != nil { t.Fatal(err) } } // TestAwayPanelIsSilentOnAFirstVisit. A brand-new row means "never seen before", // and treating that as "away since the epoch" would greet somebody's first // sign-in with every death their character ever suffered. func TestAwayPanelIsSilentOnAFirstVisit(t *testing.T) { s, _ := newAdvServer(t, "tok") s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")} seedAwayOwner(t, "josie", "Josie") seedAwayEvent(t, "death:a:1", "death", "Josie", time.Now().Add(-time.Hour).Unix()) if v := s.awayPanel(awayReq(t, s, "sub-1", "josie")); v.Has { t.Errorf("first visit rendered a panel of %d lines; it must be silent", len(v.Lines)) } // And the clock was still stamped, so the next visit has a window to read from. from, first, err := storage.AdvVisitWindow("sub-1", time.Now().Unix()) if err != nil { t.Fatal(err) } if first || from == 0 { t.Errorf("visit clock not stamped on the first pass (from=%d first=%v)", from, first) } } // TestAwayPanelSurvivesARefresh is the reason adventure_visit has two columns. // The naive one-column version shows the news, moves the stamp to now, and then // renders an empty box over the same events the moment the reader reloads — // which is exactly what somebody does after clicking into a dispatch and back. func TestAwayPanelSurvivesARefresh(t *testing.T) { s, _ := newAdvServer(t, "tok") s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")} seedAwayOwner(t, "josie", "Josie") // A visit two hours ago established the clock. Stamped directly rather than // through awayPanel, because the panel reads the wall clock and this test is // about what happens between two visits rather than inside one. if _, first, err := storage.AdvVisitWindow("sub-1", time.Now().Add(-2*time.Hour).Unix()); err != nil || !first { t.Fatalf("seed visit: first=%v err=%v", first, err) } // Then something happened to Josie. seedAwayEvent(t, "death:a:1", "death", "Josie", time.Now().Add(-time.Minute).Unix()) first := s.awayPanel(awayReq(t, s, "sub-1", "josie")) if !first.Has || len(first.Lines) != 1 { t.Fatalf("panel = %+v, want one line about the death", first) } if first.Name != "Josie" { t.Errorf("panel names %q, want Josie", first.Name) } // The refresh. Same panel, not an empty one. again := s.awayPanel(awayReq(t, s, "sub-1", "josie")) if !again.Has || len(again.Lines) != len(first.Lines) { t.Errorf("refresh emptied the panel: %+v", again) } } // TestAwayPanelNeverShowsAnotherPlayersNews. The panel is keyed on a fact's // character name, resolved through the owner join — the same join the alert // sender uses, and the same failure-closed rule. A signed-in visitor who owns // nothing must see nothing, never the realm's news relabelled as their own. func TestAwayPanelNeverShowsAnotherPlayersNews(t *testing.T) { s, _ := newAdvServer(t, "tok") s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")} seedAwayOwner(t, "josie", "Josie") seedAwayEvent(t, "death:a:1", "death", "Josie", time.Now().Add(-time.Minute).Unix()) // Anonymous: no panel, and no visit row to create either. if v := s.awayPanel(awayReq(t, s, "", "")); v.Has { t.Error("an anonymous visitor got a personal panel") } // Signed in, but owns no adventurer on the board. if v := s.awayPanel(awayReq(t, s, "sub-stranger", "stranger")); v.Has { t.Errorf("a visitor with no adventurer got %+v", v) } // Second pass, now that their visit row exists — the branch that would fall // through to a broadcast if the ownership join were ever treated as optional. if v := s.awayPanel(awayReq(t, s, "sub-stranger", "stranger")); v.Has { t.Errorf("a visitor with no adventurer got %+v on their second visit", v) } } // TestAwayPanelCapsAndCounts: six lines is a glance, and the overflow has to be // counted rather than silently dropped. func TestAwayPanelCapsAndCounts(t *testing.T) { s, _ := newAdvServer(t, "tok") s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")} seedAwayOwner(t, "josie", "Josie") if _, first, err := storage.AdvVisitWindow("sub-1", time.Now().Add(-4*time.Hour).Unix()); err != nil || !first { t.Fatalf("seed visit: first=%v err=%v", first, err) } base := time.Now().Add(-time.Hour).Unix() for i := 0; i < awayCap+3; i++ { seedAwayEvent(t, "boss_kill:"+string(rune('a'+i))+":1", "boss_kill", "Josie", base+int64(i)) } v := s.awayPanel(awayReq(t, s, "sub-1", "josie")) if len(v.Lines) != awayCap { t.Errorf("panel drew %d lines, want the cap of %d", len(v.Lines), awayCap) } if !v.HasMore { t.Error("overflow was not flagged; the extra events would read as if they never happened") } if v.Token != "tok-josie" { t.Errorf("panel links to %q, want the reader's own adventurer page", v.Token) } }