package storage import "testing" // seedOwnedCharacter puts a player on the board and gives them an owner, which // is the two-row arrangement AdvCharacterForOwner has to walk: the public // (token -> name) board plus the owner-private (localpart -> token) detail row. func seedOwnedCharacter(t *testing.T, localpart, token, name string) { t.Helper() if err := ReplaceRoster([]RosterEntry{{ Token: token, Name: name, Level: 14, ClassRace: "Cleric", Status: "idle", }}, 1000); err != nil { t.Fatal(err) } if err := ReplacePlayerDetail([]PlayerDetail{{Localpart: localpart, Token: token}}, 1000); err != nil { t.Fatal(err) } } // TestAdvCharacterForOwnerNeedsBothHalves is the privacy contract behind every // owner-scoped alert. The sender asks "which character is this subscriber's" and // then compares that name against a dispatch's subject; if this function ever // answered generously, somebody's phone would buzz about another player's death. // // Each half of the join is removed in turn, because each one goes missing for a // real reason in production: the roster row disappears when a player opts out of // the news or leaves the board, and the self-detail row disappears when gogobee // stops pushing for them. Both must fail closed. func TestAdvCharacterForOwnerNeedsBothHalves(t *testing.T) { setupTestDB(t) seedOwnedCharacter(t, "josie", "tok-josie", "Josie") if name, ok := AdvCharacterForOwner("josie"); !ok || name != "Josie" { t.Fatalf("owner lookup = %q/%v, want Josie/true", name, ok) } if _, ok := AdvCharacterForOwner("quack"); ok { t.Error("a localpart with no self-detail row resolved to a character") } if _, ok := AdvCharacterForOwner(""); ok { t.Error("an empty localpart resolved to a character") } // Off the board — opted out of the news, or removed. The self-detail row is // still there and still points at tok-josie, so only the roster join stops // this. An opted-out player's facts are anonymised on the wire anyway, so // there would be no name left to match even if this leaked. if err := ReplaceRoster(nil, 2000); err != nil { t.Fatal(err) } if name, ok := AdvCharacterForOwner("josie"); ok { t.Errorf("off-the-board player still resolved to %q; alerts must close with the board", name) } // And the mirror case: on the board, but gogobee has stopped pushing the // owner-private half, so Pete has no basis to claim the name is theirs. seedOwnedCharacter(t, "josie", "tok-josie", "Josie") if err := ReplacePlayerDetail(nil, 3000); err != nil { t.Fatal(err) } if name, ok := AdvCharacterForOwner("josie"); ok { t.Errorf("resolved %q with no self-detail row; the ownership claim has no source", name) } } // TestAdvEventsSinceIsOrderedNewestFirst pins the ordering the sender depends on // twice over: it takes the first match as "the newest thing to tell you about", // and it advances every watermark to events[0]. Reverse this and the alert names // the oldest unseen dispatch while the watermark skips the rest. func TestAdvEventsSinceIsOrderedNewestFirst(t *testing.T) { setupTestDB(t) for _, e := range []AdvEvent{ {GUID: "g1", EventType: "death", Subject: "Josie", OccurredAt: 100}, {GUID: "g2", EventType: "zone_clear", Subject: "Josie", OccurredAt: 300}, {GUID: "g3", EventType: "retreat", Subject: "Josie", OccurredAt: 200}, } { if err := InsertAdventureEvent(&e); err != nil { t.Fatal(err) } } got, err := AdvEventsSince(150, 10) if err != nil { t.Fatal(err) } if len(got) != 2 { t.Fatalf("got %d events after ts=150, want 2 (the ts=100 one is behind the watermark)", len(got)) } if got[0].GUID != "g2" || got[1].GUID != "g3" { t.Fatalf("order = %s,%s; want g2,g3 (newest first)", got[0].GUID, got[1].GUID) } // limit <= 0 means "nothing", not "everything": the sender treats the result // as a bounded window and an unbounded read here would be a surprise. if got, _ := AdvEventsSince(0, 0); len(got) != 0 { t.Errorf("limit 0 returned %d events, want 0", len(got)) } } // TestAdvEventsSinceCarriesRunID pins that the run link survives the read. The // alert for an ended expedition points at the run report when there is one, and // that field is the only thing distinguishing it from the plain story permalink. func TestAdvEventsSinceCarriesRunID(t *testing.T) { setupTestDB(t) if err := InsertAdventureEvent(&AdvEvent{ GUID: "g1", EventType: "zone_clear", Subject: "Josie", RunID: "run-7", OccurredAt: 500, }); err != nil { t.Fatal(err) } got, err := AdvEventsSince(0, 10) if err != nil || len(got) != 1 { t.Fatalf("read back %d events (err %v), want 1", len(got), err) } if got[0].RunID != "run-7" { t.Errorf("run id = %q, want run-7", got[0].RunID) } } // TestAdvWatermarkIsIndependentOfTheDigest pins the schema note. The two senders // run on different clocks; if they shared a column, whichever ran last would // decide what the other had already seen, and one of the two channels would go // permanently quiet in a way nobody would think to look for. func TestAdvWatermarkIsIndependentOfTheDigest(t *testing.T) { setupTestDB(t) const ep = "https://push.example/ep" if err := AddPushSubscription("sub-1", "josie", ep, "p", "a"); err != nil { t.Fatal(err) } if err := TouchAdvPushSubscription(ep, 4242); err != nil { t.Fatal(err) } subs, _ := ListPushSubscriptions() if len(subs) != 1 { t.Fatalf("got %d subscriptions, want 1", len(subs)) } if subs[0].LastAdvNotifiedAt != 4242 { t.Errorf("adventure watermark = %d, want 4242", subs[0].LastAdvNotifiedAt) } if subs[0].LastNotifiedAt == 4242 { t.Error("touching the adventure watermark moved the digest watermark too") } if subs[0].Localpart != "josie" { t.Errorf("localpart = %q, want josie; owner-scoped alerts have nothing to join on without it", subs[0].Localpart) } // The reverse direction, so neither can quietly consume the other's backlog. if err := TouchPushSubscription(ep, 99); err != nil { t.Fatal(err) } subs, _ = ListPushSubscriptions() if subs[0].LastAdvNotifiedAt != 4242 { t.Errorf("digest touch moved the adventure watermark to %d", subs[0].LastAdvNotifiedAt) } }