Files
Pete/internal/web/adventure_test.go
T
prosolis 91d25e9da1 adventure: stop dropping dispatches Pete has no words for
An event_type with no template was a 400 at ingest. That reads like caution
and behaves like deletion: gogobee retries a 400 to its cap and then parks the
row forever, so rejecting a type Pete hadn't learned to phrase didn't defer the
event, it destroyed it.

companion_hire went that way. It has been emitted from `!expedition hire` since
the combat-engine work landed and has never once reached the site — the game
logged a successful emit every time, and the queue row simply never sent. The
mitigation on the books was "always deploy Pete first", which is a thing a
person has to remember rather than a property of the system.

So invert it. An unknown type now warns, gets counted, and publishes on a
neutral fallback. 400 is kept for facts that are actually invalid: no guid, or
a name that failed the fact-guard. gogobee can ship a new event type any day of
the week now; the worst case is a thin card until Pete learns the words.

It is thinner than it sounds in practice. gogobee authors dispatch prose from
the fact's fields with no per-type switch, so an unrecognised type still
arrives with a real headline and lede and is allowed to use them. The fallback
only shows through when the model is off or the prose-guard refused the output.

Untemplated types never post live to Matrix, whatever tier they claim. A thin
card among cards is cheap and reversible; pinging everyone in the room with a
dispatch Pete couldn't phrase is neither. The daily digest still carries it,
one line among many, which is the right volume for something we don't
understand yet.

And give companion_hire its template. Pete is the one being hired, so it is
first-person like his duels — third-person Pete filling in as a cleric reads as
somebody else reporting on him.

The admin status page grows a "dispatches with no template" panel, so the next
one of these is a to-do list Pete can see rather than an archaeology dig.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
2026-07-24 14:46:02 -07:00

540 lines
21 KiB
Go

package web
import (
"bytes"
"encoding/json"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"pete/internal/config"
"pete/internal/storage"
)
// newAdvServer builds a web server with the adventure seam enabled and a
// capturing priority poster, backed by a fresh temp DB.
func newAdvServer(t *testing.T, token string) (*Server, *[]AdvPost) {
t.Helper()
storage.Close()
if err := storage.Init(filepath.Join(t.TempDir(), "adv.db")); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { storage.Close() })
var posted []AdvPost
adv := config.AdventureConfig{Enabled: true, IngestToken: token, Channel: "adventure"}
// Mirror the production poster's side effect: queue.PostNow records a
// post_log row, which is exactly what marks a beat "posted" and thus
// excludes it from the bulletin digest.
poster := func(p AdvPost) {
posted = append(posted, p)
storage.InsertPostLog(p.GUID, "adventure", p.GUID, "", false)
}
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0", BaseURL: "https://news.example"},
nil, true, adv, poster)
if err != nil {
t.Fatal(err)
}
return s, &posted
}
func postFact(t *testing.T, s *Server, token string, f AdvFact) *httptest.ResponseRecorder {
t.Helper()
body, _ := json.Marshal(f)
req := httptest.NewRequest("POST", "/api/ingest/adventure", bytes.NewReader(body))
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rw := httptest.NewRecorder()
s.handleAdventureIngest(rw, req)
return rw
}
// TestAdventureIngestEndToEnd covers the seam: a priority death fact is
// bearer-accepted, templated, stored as an adventure story, and posted live.
func TestAdventureIngestEndToEnd(t *testing.T) {
const token = "s3cret-token"
s, posted := newAdvServer(t, token)
f := AdvFact{
GUID: "death:abc:1000", EventType: "death", Tier: "priority",
Actors: []string{"Brannigan"}, Subject: "Brannigan",
Zone: "the Underforge", Level: 14, OccurredAt: 1000,
}
if rw := postFact(t, s, token, f); rw.Code != 200 {
t.Fatalf("ingest status = %d body=%s", rw.Code, rw.Body.String())
}
got, err := storage.GetStoryByGUID("death:abc:1000")
if err != nil || got == nil {
t.Fatalf("story not stored: %v", err)
}
if got.Channel != "adventure" || got.Source != advSource {
t.Errorf("channel/source = %q/%q", got.Channel, got.Source)
}
if got.Headline != "We lost Brannigan in the Underforge." {
t.Errorf("headline = %q", got.Headline)
}
if got.ArticleURL != "https://news.example/adventure/death:abc:1000" {
t.Errorf("article_url = %q", got.ArticleURL)
}
if len(*posted) != 1 || (*posted)[0].GUID != f.GUID {
t.Fatalf("priority post not delivered: %+v", *posted)
}
// Idempotent re-delivery: no error, no second post.
if rw := postFact(t, s, token, f); rw.Code != 200 {
t.Fatalf("dup ingest status = %d", rw.Code)
}
if len(*posted) != 1 {
t.Errorf("duplicate fact re-posted: %d posts", len(*posted))
}
}
// TestAdventurePermalink renders the per-story page the article_url points at:
// an ingested dispatch must be fetchable at /adventure/{guid} with its headline
// and body, and an unknown guid must 404.
func TestAdventurePermalink(t *testing.T) {
const token = "t"
s, _ := newAdvServer(t, token)
f := AdvFact{
GUID: "death:abc:1000", EventType: "death", Tier: "priority",
Actors: []string{"Brannigan"}, Subject: "Brannigan",
Zone: "the Underforge", Level: 14, OccurredAt: 1000,
}
if rw := postFact(t, s, token, f); rw.Code != 200 {
t.Fatalf("ingest status = %d", rw.Code)
}
req := httptest.NewRequest("GET", "/adventure/death:abc:1000", nil)
req.SetPathValue("guid", "death:abc:1000")
rw := httptest.NewRecorder()
s.handleAdventureStory(rw, req)
if rw.Code != 200 {
t.Fatalf("permalink status = %d body=%s", rw.Code, rw.Body.String())
}
body := rw.Body.String()
if !bytes.Contains([]byte(body), []byte("We lost Brannigan in the Underforge.")) {
t.Errorf("permalink missing headline; body=%s", body)
}
if !bytes.Contains([]byte(body), []byte("In memoriam")) {
t.Errorf("permalink missing event label; body=%s", body)
}
// Unknown guid 404s.
req2 := httptest.NewRequest("GET", "/adventure/nope:1", nil)
req2.SetPathValue("guid", "nope:1")
rw2 := httptest.NewRecorder()
s.handleAdventureStory(rw2, req2)
if rw2.Code != 404 {
t.Errorf("unknown guid status = %d, want 404", rw2.Code)
}
}
// TestDurUntilNextHour targets the next UTC occurrence of the digest hour and
// rolls to tomorrow when it's already that hour (so a restart can't double-fire).
func TestDurUntilNextHour(t *testing.T) {
// 14:30 UTC, targeting 17:00 → 2h30m today.
now := time.Date(2026, 7, 11, 14, 30, 0, 0, time.UTC)
if got := durUntilNextHour(now, 17); got != 2*time.Hour+30*time.Minute {
t.Errorf("before hour: got %v", got)
}
// 17:00 exactly → tomorrow's 17:00 (24h), not zero.
now = time.Date(2026, 7, 11, 17, 0, 0, 0, time.UTC)
if got := durUntilNextHour(now, 17); got != 24*time.Hour {
t.Errorf("at hour: got %v", got)
}
// 20:00, targeting 17:00 → tomorrow, 21h.
now = time.Date(2026, 7, 11, 20, 0, 0, 0, time.UTC)
if got := durUntilNextHour(now, 17); got != 21*time.Hour {
t.Errorf("after hour: got %v", got)
}
}
// TestAdventureDigest covers the batched path: bulletins (no live post) are
// collected into one roundup, priority beats are excluded (they already posted),
// digested bulletins don't recur, and an empty window stays silent.
func TestAdventureDigest(t *testing.T) {
const token = "t"
s, posted := newAdvServer(t, token)
now := time.Now()
// Two bulletins + one priority (which posts live and must be excluded).
postFact(t, s, token, AdvFact{GUID: "arrival:a:1", EventType: "arrival", Tier: "bulletin",
Actors: []string{"Zapp"}, Subject: "Zapp", ClassRace: "Elf Ranger", OccurredAt: now.Unix()})
postFact(t, s, token, AdvFact{GUID: "rival:b:2", EventType: "rival_result", Tier: "bulletin",
Actors: []string{"Kif", "Zapp"}, Subject: "Kif", Opponent: "Zapp", Outcome: "won", OccurredAt: now.Unix()})
postFact(t, s, token, AdvFact{GUID: "death:c:3", EventType: "death", Tier: "priority",
Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 9, OccurredAt: now.Unix()})
if len(*posted) != 1 {
t.Fatalf("setup: priority posts = %d, want 1", len(*posted))
}
s.postDailyDigest(now.UTC())
if len(*posted) != 2 {
t.Fatalf("digest not posted: total posts = %d, want 2", len(*posted))
}
dg := (*posted)[1]
if !strings.Contains(dg.Headline, "2 dispatches") {
t.Errorf("digest headline = %q", dg.Headline)
}
if !strings.HasPrefix(dg.GUID, "adv-digest:") {
t.Errorf("digest guid = %q", dg.GUID)
}
// Re-running finds nothing new (bulletins marked digested).
s.postDailyDigest(now.UTC())
if len(*posted) != 2 {
t.Errorf("digest re-posted: total = %d, want 2", len(*posted))
}
}
// TestAdventureArtAndMeta covers the visual-identity slice: the emblem endpoint
// returns a themed SVG, ingested cards carry its local path, and the permalink
// page is noindex with an og:image.
// TestRenderZoneTaxonomy: a realm-first (zone_first) reads as first-ever; a
// repeat (zone_clear) reads as a personal clear, not a mis-labeled "first".
func TestRenderZoneTaxonomy(t *testing.T) {
first := AdvFact{EventType: "zone_first", Tier: "priority", Subject: "Brannigan",
Zone: "Dragon's Lair", Region: "the Underforge", Level: 14}
hl, _, ok := renderAdventure(first)
if !ok || !strings.Contains(hl, "very first time") {
t.Errorf("zone_first headline = %q (ok=%v)", hl, ok)
}
repeat := AdvFact{EventType: "zone_clear", Tier: "bulletin", Subject: "Brannigan",
Zone: "Dragon's Lair", Region: "the Underforge", Level: 14}
hl2, _, ok := renderAdventure(repeat)
if !ok || !strings.Contains(hl2, "Brannigan clears") || strings.Contains(hl2, "first") {
t.Errorf("zone_clear headline = %q (ok=%v)", hl2, ok)
}
// The permalink label distinguishes the two, so a repeat's page isn't stamped
// "First clear".
if lbl, _ := advEventMeta("zone_clear"); lbl == "First clear" {
t.Errorf("zone_clear meta label = %q, want distinct from first clear", lbl)
}
}
// TestRenderTreasure: a story-grade find names the item and its zone; a
// priority find is billed as a realm-first, and the rarity from outcome rides
// into the lede.
func TestRenderTreasure(t *testing.T) {
hoard := AdvFact{EventType: "treasure_found", Tier: "priority", Subject: "Josie",
Zone: "The Ossuary", Stakes: "Crown of the Drowned King", Outcome: "legendary", Level: 7}
hl, lede, ok := renderAdventure(hoard)
if !ok || !strings.Contains(hl, "First ever") || !strings.Contains(hl, "Crown of the Drowned King") {
t.Errorf("hoard headline = %q (ok=%v)", hl, ok)
}
if !strings.Contains(lede, "legendary") {
t.Errorf("hoard lede dropped the rarity: %q", lede)
}
find := AdvFact{EventType: "treasure_found", Tier: "bulletin", Subject: "Josie",
Zone: "The Sump", Stakes: "Ring of Nine Sorrows"}
hl2, _, ok := renderAdventure(find)
if !ok || !strings.Contains(hl2, "Josie") || !strings.Contains(hl2, "Ring of Nine Sorrows") ||
!strings.Contains(hl2, "The Sump") || strings.Contains(hl2, "First ever") {
t.Errorf("plain find headline = %q (ok=%v)", hl2, ok)
}
if lbl, emoji := advEventMeta("treasure_found"); lbl != "Treasure" || emoji == "" {
t.Errorf("treasure meta = %q/%q", lbl, emoji)
}
}
func TestAdventureArtAndMeta(t *testing.T) {
const token = "t"
s, _ := newAdvServer(t, token)
// Emblem endpoint returns SVG with the event's emoji.
areq := httptest.NewRequest("GET", "/adventure/art/death.svg", nil)
areq.SetPathValue("type", "death.svg")
arw := httptest.NewRecorder()
s.handleAdventureArt(arw, areq)
if arw.Code != 200 {
t.Fatalf("art status = %d", arw.Code)
}
if ct := arw.Header().Get("Content-Type"); !strings.HasPrefix(ct, "image/svg+xml") {
t.Errorf("art content-type = %q", ct)
}
if b := arw.Body.String(); !strings.Contains(b, "🪦") || !strings.Contains(b, "<svg") {
t.Errorf("art body missing emblem: %s", b)
}
// Ingest sets the card image to the local emblem path.
f := AdvFact{GUID: "death:abc:1000", EventType: "death", Tier: "priority",
Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 4, OccurredAt: 1000}
if rw := postFact(t, s, token, f); rw.Code != 200 {
t.Fatalf("ingest status = %d", rw.Code)
}
got, _ := storage.GetStoryByGUID("death:abc:1000")
if got == nil || got.ImageURL != "/adventure/art/death.svg" {
t.Errorf("story image = %q", got.ImageURL)
}
// Permalink page is noindex with an og:image.
preq := httptest.NewRequest("GET", "/adventure/death:abc:1000", nil)
preq.SetPathValue("guid", "death:abc:1000")
prw := httptest.NewRecorder()
s.handleAdventureStory(prw, preq)
body := prw.Body.String()
if !strings.Contains(body, `name="robots" content="noindex"`) {
t.Error("permalink not noindex")
}
if !strings.Contains(body, `property="og:image" content="https://news.example/adventure/art/death.svg"`) {
t.Errorf("permalink missing og:image; body=%s", body)
}
}
// TestAdventureIngestBearer rejects missing/wrong tokens.
func TestAdventureIngestBearer(t *testing.T) {
s, _ := newAdvServer(t, "right")
f := AdvFact{GUID: "arrival:x:1", EventType: "arrival", Tier: "bulletin", OccurredAt: 1}
if rw := postFact(t, s, "", f); rw.Code != 401 {
t.Errorf("no token: status = %d, want 401", rw.Code)
}
if rw := postFact(t, s, "wrong", f); rw.Code != 401 {
t.Errorf("wrong token: status = %d, want 401", rw.Code)
}
}
// TestAdventureFactGuard rejects a subject not present in the actors allow-list,
// so a name that slipped the source can't reach a public page.
func TestAdventureFactGuard(t *testing.T) {
const token = "t"
s, posted := newAdvServer(t, token)
f := AdvFact{
GUID: "death:evil:1", EventType: "death", Tier: "priority",
Actors: []string{"Brannigan"}, Subject: "Kif", // Kif not in actors
Zone: "the Underforge", Level: 3, OccurredAt: 1,
}
if rw := postFact(t, s, token, f); rw.Code != 400 {
t.Errorf("fact-guard: status = %d, want 400", rw.Code)
}
if storage.IsGUIDSeen("death:evil:1") {
t.Error("guarded fact was stored")
}
if len(*posted) != 0 {
t.Error("guarded fact was posted")
}
}
// TestAdventureDisabled 404s when the seam is off.
func TestAdventureDisabled(t *testing.T) {
storage.Close()
if err := storage.Init(filepath.Join(t.TempDir(), "off.db")); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { storage.Close() })
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
if err != nil {
t.Fatal(err)
}
f := AdvFact{GUID: "x", EventType: "arrival", OccurredAt: 1}
if rw := postFact(t, s, "anything", f); rw.Code != 404 {
t.Errorf("disabled: status = %d, want 404", rw.Code)
}
}
// TestRenderMischief: gogobee's four mischief event types must all render. An
// untemplated type no longer 400s — it publishes on the neutral fallback (see
// TestUnknownEventTypePublishes) — so what is at stake here is voice, not data
// loss: these four carry the anonymity mechanic, and the generic fallback would
// strip out the part that makes it work.
//
// It also pins the anonymity contract, which is the feature's whole social
// engine: an unsigned contract must not name the buyer, and a survival must.
func TestRenderMischief(t *testing.T) {
anon := AdvFact{EventType: "mischief_contract", Tier: "priority",
Subject: "Josie", Boss: "Elite", Stakes: "€350", Level: 14}
hl, lede, ok := renderAdventure(anon)
if !ok {
t.Fatal("mischief_contract did not render — ingest would 400")
}
if strings.Contains(hl+lede, "Brannigan") {
t.Error("anonymous contract leaked a buyer name")
}
if !strings.Contains(hl, "€350") {
t.Errorf("contract headline lost the stakes: %q", hl)
}
signed := anon
signed.Opponent = "Brannigan"
hl, _, ok = renderAdventure(signed)
if !ok || !strings.Contains(hl, "Brannigan") {
t.Errorf("signed contract should name the buyer: %q (ok=%v)", hl, ok)
}
// The unseal: a survival names the buyer whether or not they signed.
survived := AdvFact{EventType: "mischief_survived", Tier: "priority",
Subject: "Josie", Opponent: "Brannigan", Boss: "Bone Colossus", Stakes: "€228"}
hl, _, ok = renderAdventure(survived)
if !ok || !strings.Contains(hl, "Brannigan") {
t.Errorf("survival must unseal the buyer: %q (ok=%v)", hl, ok)
}
// A downed target with an anonymous buyer stays anonymous — being maimed
// doesn't buy you the name.
downed := AdvFact{EventType: "mischief_downed", Tier: "priority",
Subject: "Josie", Boss: "Bone Colossus", Level: 14}
hl, lede, ok = renderAdventure(downed)
if !ok {
t.Fatal("mischief_downed did not render")
}
if strings.Contains(hl+lede, "Brannigan") {
t.Error("anonymous buyer named on a downed contract")
}
if _, _, ok := renderAdventure(AdvFact{EventType: "mischief_fizzled", Subject: "Josie", Stakes: "€315"}); !ok {
t.Error("mischief_fizzled did not render")
}
for _, et := range []string{"mischief_contract", "mischief_survived", "mischief_downed", "mischief_fizzled"} {
if lbl, _ := advEventMeta(et); lbl == "Dispatch" {
t.Errorf("%s has no permalink label", et)
}
}
}
// TestRenderCompanionHire pins the template whose absence was a live bug.
//
// gogobee has emitted companion_hire from `!expedition hire` since the combat-
// engine work landed (expedition_companion_cmd.go). Pete had no case for it, so
// every one of those dispatches 400'd, retried to peteclient's cap, and parked
// forever. Nothing surfaced the loss: the game logged a successful emit, the
// queue row just never sent.
//
// The unknown-type inversion (TestUnknownEventTypePublishes) means a repeat of
// this costs a thin card rather than a deleted event — but the template is still
// the point, and this test is what says so.
func TestRenderCompanionHire(t *testing.T) {
f := AdvFact{EventType: "companion_hire", Tier: "bulletin",
Subject: "Josie", ClassRace: "Cleric", Zone: "holymachina", Level: 14}
hl, lede, ok := renderAdventure(f)
if !ok {
t.Fatal("companion_hire did not render — this is the bug, do not re-break it")
}
if !strings.Contains(hl, "cleric") {
t.Errorf("headline lost the seat Pete is filling: %q", hl)
}
if !strings.Contains(lede, "Josie") || !strings.Contains(lede, "holymachina") {
t.Errorf("lede lost the leader or the zone: %q", lede)
}
// He is talking about himself here, like his duels. Third-person Pete filling
// in as a cleric reads as someone else reporting on him.
if !strings.Contains(lede, "I'm") && !strings.Contains(lede, "I ") {
t.Errorf("companion_hire should be first-person Pete: %q", lede)
}
// "needed a cleric", never "needed cleric".
if !strings.Contains(lede, "a cleric") {
t.Errorf("seat needs its article in the lede: %q", lede)
}
if lbl, _ := advEventMeta("companion_hire"); lbl == "Dispatch" {
t.Error("companion_hire has no permalink label")
}
// A missing class must not produce "needed a ." — the fallback seat carries
// its own article.
bare := AdvFact{EventType: "companion_hire", Subject: "Josie"}
_, bareLede, ok := renderAdventure(bare)
if !ok {
t.Fatal("companion_hire with no class did not render")
}
if strings.Contains(bareLede, "a .") || strings.Contains(bareLede, "needed ") {
t.Errorf("empty class produced malformed prose: %q", bareLede)
}
}
// TestUnknownEventTypePublishes is the regression for the whole class of bug.
//
// An event type Pete has no template for must PUBLISH, not 400. A 400 is retried
// to peteclient's cap and then parked forever, so rejecting an unrecognised type
// does not defer the event — it deletes it, permanently, and that is how
// companion_hire went missing. The site can carry a thin card; it cannot recover
// a dispatch gogobee has given up on.
func TestUnknownEventTypePublishes(t *testing.T) {
const token = "s3cret-token"
s, posted := newAdvServer(t, token)
f := AdvFact{
GUID: "brand_new_thing:abc:5000", EventType: "brand_new_thing",
Tier: "priority", // claims priority, and still must not interrupt Matrix
Subject: "Josie", Actors: []string{"Josie"}, Zone: "holymachina",
OccurredAt: 5000,
}
if rw := postFact(t, s, token, f); rw.Code != 200 {
t.Fatalf("unknown event_type: status = %d, want 200 — a 400 parks the dispatch forever", rw.Code)
}
got, err := storage.GetStoryByGUID("brand_new_thing:abc:5000")
if err != nil || got == nil {
t.Fatal("unknown event_type was not stored; the event is lost")
}
if !strings.Contains(got.Headline+got.Lede, "Josie") {
t.Errorf("fallback dropped the subject: %q / %q", got.Headline, got.Lede)
}
// Untemplated types never post live, whatever tier they claim: a thin card on
// the site is cheap, a thin ping to everyone in the room is not. It still
// reaches Matrix via the daily digest.
if len(*posted) != 0 {
t.Errorf("untemplated priority fact posted live to Matrix: %+v", *posted)
}
// And the operator can see what Pete needs to learn.
if AdvUnknownTypeCounts()["brand_new_thing"] == 0 {
t.Error("unknown type was not counted for the status page")
}
}
// TestUnknownEventTypeUsesProse: the inversion is not a downgrade in practice.
// gogobee authors LLM prose from the fact's fields with no per-type switch
// (authorDispatch), so a type Pete has never heard of still arrives with a real
// headline and lede — and must be allowed to use them. The thin fallback is only
// for when the model is off or the prose-guard rejected the output.
func TestUnknownEventTypeUsesProse(t *testing.T) {
const token = "s3cret-token"
s, _ := newAdvServer(t, token)
f := AdvFact{
GUID: "another_new_thing:def:6000", EventType: "another_new_thing",
Tier: "bulletin", Subject: "Josie", Actors: []string{"Josie"},
OccurredAt: 6000,
Headline: "Josie has taken up beekeeping.",
Lede: "Not the news I expected today, but there she is, out behind the chapel with a smoker and a very calm expression.",
}
if rw := postFact(t, s, token, f); rw.Code != 200 {
t.Fatalf("status = %d, want 200", rw.Code)
}
got, err := storage.GetStoryByGUID("another_new_thing:def:6000")
if err != nil || got == nil {
t.Fatal("story not stored")
}
if got.Headline != f.Headline {
t.Errorf("LLM prose was discarded for an unknown type: got %q", got.Headline)
}
}
// TestUnknownEventTypeStillGuarded: publishing an untemplated type must not
// weaken the name guards. The fact-guard rejection is still a 400, because a
// fact naming someone it did not authorise is genuinely invalid — unlike a type
// Pete simply hasn't learned to phrase.
func TestUnknownEventTypeStillGuarded(t *testing.T) {
const token = "s3cret-token"
s, _ := newAdvServer(t, token)
f := AdvFact{
GUID: "unknowable:evil:1", EventType: "unknowable",
Subject: "Josie", Actors: []string{"Brannigan"}, OccurredAt: 1,
}
if rw := postFact(t, s, token, f); rw.Code != 400 {
t.Errorf("unguarded subject on an unknown type: status = %d, want 400", rw.Code)
}
if storage.IsGUIDSeen("unknowable:evil:1") {
t.Error("fact-guard rejection was stored anyway")
}
}