Files
tiennm99bot/internal/modules/loldle/api_test.go
T
tiennm99 629b513c91 feat(games): move /wordle and /loldle to the web games and add /loldledaily
/wordle and /loldle now send the wordle and loldle BotFather games in an
unlimited mode: each player gets their own word or champion, saved after
every guess, with a New game button. Cards the bot did not record as
unlimited, including the daily cards, play the daily puzzle. The helper
commands keep working on the player's round.

/loldledaily mirrors /wordledaily: one champion per day at 07:00 ICT,
subscribe and unsubscribe, the morning recap and the live group results.

The daily and unlimited machinery is shared in util/guessgame, the old
in-chat wordle module is removed, and a one-time startup migration carries
each player's stats and unfinished round over without changing old data.
2026-10-10 09:29:48 +07:00

451 lines
17 KiB
Go

package loldle
import (
"context"
"encoding/json"
"fmt"
"math"
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strings"
"testing"
"time"
"github.com/tiennm99/tiennm99bot/internal/modules/loldle/web"
"github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame"
)
func urlUnescape(t *testing.T, s string) string {
t.Helper()
u, err := url.QueryUnescape(s)
if err != nil {
t.Fatal(err)
}
return u
}
func TestRules_MarksArrowsAndRefusals(t *testing.T) {
r := rules{champions: loadChampions()}
for guess, want := range map[string]string{"Akali": "cwwwccu", "wukong": "wcwccwc", "kaisa": "cwccwwd", "Ahri": "ccccccc"} {
g, err := r.Judge(guess, "Ahri", nil)
if err != nil || g.Marks != want {
t.Errorf("%s vs Ahri = %+v, %v; want %s", guess, g, err, want)
}
}
if g, _ := r.Judge("jinx", "Kai'Sa", nil); g.Word != "Jinx" || g.Marks != "cpccwcu" {
t.Errorf("Jinx vs Kai'Sa = %+v", g)
}
for input, want := range map[string]error{"zzz": errUnknownChampion, "ka": errAmbiguous, "": errUnknownChampion} {
if _, err := r.Judge(input, "Ahri", nil); err != want {
t.Errorf("Judge(%q) = %v, want %v", input, err, want)
}
}
if _, err := r.Judge("akali", "Ahri", []guessgame.Guess{{Word: "Akali"}}); err != errDuplicate {
t.Errorf("duplicate = %v", err)
}
if _, err := r.Judge("akali", "Gone", nil); err != errTargetGone {
t.Errorf("gone = %v", err)
}
if got := guessgame.EmojiRow(r, "cpwudcw"); got != "🟩🟨🟥🟥🟥🟩🟥" {
t.Errorf("emoji = %q", got)
}
}
func TestChampions_EveryRowHasAnIconID(t *testing.T) {
seen := map[string]bool{}
idRe := regexp.MustCompile(`^[A-Za-z]+$`)
for _, c := range loadChampions() {
if !idRe.MatchString(c.ID) || seen[c.ID] || c.Title == "" {
t.Errorf("%s: id %q title %q", c.ChampionName, c.ID, c.Title)
}
seen[c.ID] = true
}
}
// Data Dragon tile file names are case-sensitive and do not always match the
// champion key: Fiddlesticks' tile is FiddleSticks_0.jpg.
func TestChampions_IconIDsNameTheTileFile(t *testing.T) {
ids := map[string]string{}
for _, c := range loadChampions() {
ids[c.ChampionName] = c.ID
}
for name, want := range map[string]string{"Fiddlesticks": "FiddleSticks", "Wukong": "MonkeyKing", "Ahri": "Ahri"} {
if ids[name] != want {
t.Errorf("%s icon id = %q, want %q", name, ids[name], want)
}
}
}
func TestAPI_ChampionListCarriesNamesAndIDsOnly(t *testing.T) {
h := newHarness(t)
rec := httptest.NewRecorder()
h.handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, routePrefix+"champions.json", nil))
if rec.Code != http.StatusOK || !strings.HasPrefix(rec.Header().Get("Content-Type"), "application/json") {
t.Fatalf("champions.json: %d %v", rec.Code, rec.Header())
}
var list []map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil || len(list) != len(loadChampions()) {
t.Fatalf("list = %d, %v", len(list), err)
}
for _, c := range list {
if len(c) != 2 || c["name"] == "" || c["id"] == "" {
t.Fatalf("entry %v", c)
}
}
if list[0]["name"] != "Aatrox" || !strings.Contains(rec.Body.String(), `{"name":"Wukong","id":"MonkeyKing"}`) {
t.Fatalf("list starts %v", list[0])
}
}
func TestAPI_HeadersAllowDataDragonImagesOnly(t *testing.T) {
h := newHarness(t)
for path, ctype := range map[string]string{"": "text/html", "app.js": "text/javascript", "app.css": "text/css"} {
rec := httptest.NewRecorder()
h.handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, routePrefix+path, nil))
csp := rec.Header().Get("Content-Security-Policy")
if rec.Code != http.StatusOK || !strings.HasPrefix(rec.Header().Get("Content-Type"), ctype) ||
!strings.Contains(csp, "img-src 'self' data: https://ddragon.leagueoflegends.com;") || !strings.Contains(csp, "connect-src 'self'") {
t.Errorf("GET %q: %d %v", path, rec.Code, rec.Header())
}
}
if r := h.state("garbage"); r.code != http.StatusUnauthorized || r.err != "bad_token" {
t.Fatalf("bad token = %d %s", r.code, r.body)
}
}
var (
inlineScriptRe = regexp.MustCompile(`(?is)<script\b([^>]*)>(.*?)</script>`)
urlRe = regexp.MustCompile(`https?://[^'"\s)]+`)
inlineHandlerRe = regexp.MustCompile(`\son[a-z]+=`)
)
// The page runs under the strict CSP: no inline script, style or handler,
// and it reaches no host but its own and Data Dragon's icons.
func TestWebAssets_FitTheCSP(t *testing.T) {
index, _ := web.FS.ReadFile("index.html")
for _, m := range inlineScriptRe.FindAllStringSubmatch(string(index), -1) {
if strings.TrimSpace(m[2]) != "" || !strings.Contains(m[1], "src=") {
t.Errorf("inline script breaks the CSP: %q", m[0])
}
}
if strings.Contains(string(index), " style=") || strings.Contains(string(index), "<style") || inlineHandlerRe.Match(index) {
t.Error("inline style or handler breaks the CSP")
}
js, _ := web.FS.ReadFile("app.js")
for _, u := range urlRe.FindAllString(string(js), -1) {
if !strings.HasPrefix(u, ddragonOrigin+"/") {
t.Errorf("app.js reaches %s", u)
}
}
// No champion attributes ship to the page.
for _, leak := range []string{"Vastayan", "Manaless", "Shurima", "release_date"} {
if strings.Contains(string(js), leak) {
t.Errorf("app.js carries %q", leak)
}
}
}
// The board's column labels are small text, so they need WCAG AA's 4.5:1
// against the header background.
func TestWebAssets_ColumnLabelsMeetAAContrast(t *testing.T) {
css, err := web.FS.ReadFile("app.css")
if err != nil {
t.Fatal(err)
}
tokens := map[string]string{}
for _, m := range regexp.MustCompile(`(--[a-z-]+):\s*(#[0-9a-fA-F]{6});`).FindAllStringSubmatch(string(css), -1) {
tokens[m[1]] = m[2]
}
rule := regexp.MustCompile(`(?s)\.head \.cell \{(.*?)\}`).FindStringSubmatch(string(css))
if rule == nil {
t.Fatal("no .head .cell rule")
}
prop := func(name string) string {
m := regexp.MustCompile(`(?m)^\s*` + name + `:\s*var\((--[a-z-]+)\);`).FindStringSubmatch(rule[1])
if m == nil || tokens[m[1]] == "" {
t.Fatalf(".head .cell %s is not a colour token", name)
}
return tokens[m[1]]
}
fg, bg := prop("color"), prop("background")
if r := contrast(fg, bg); r < 4.5 {
t.Errorf("column labels %s on %s = %.2f:1, want at least 4.5:1", fg, bg, r)
}
}
// contrast is the WCAG 2 contrast ratio of two #rrggbb colours.
func contrast(a, b string) float64 {
lum := func(hex string) float64 {
var ch [3]float64
for i := range ch {
var v int
_, _ = fmt.Sscanf(hex[1+2*i:3+2*i], "%02x", &v)
c := float64(v) / 255
if c <= 0.03928 {
ch[i] = c / 12.92
} else {
ch[i] = math.Pow((c+0.055)/1.055, 2.4)
}
}
return 0.2126*ch[0] + 0.7152*ch[1] + 0.0722*ch[2]
}
la, lb := lum(a), lum(b)
return (max(la, lb) + 0.05) / (min(la, lb) + 0.05)
}
func TestAPI_UnlimitedRoundNeverLeaksTheAnswer(t *testing.T) {
h := newHarness(t)
tok := h.token(1, groupChat, 5, "Alice", true)
r := h.state(tok)
if r.code != http.StatusOK || r.v.Mode != "unlimited" || r.v.Seq != 1 || r.v.Max != 8 || len(r.v.Columns) != 7 || r.v.Answer != nil || r.v.Stats != nil {
t.Fatalf("fresh = %s", r.body)
}
for _, name := range []string{"Akali", "Wukong", "Kai'Sa"} {
r = h.roundGuess(tok, 1, name)
if r.code != http.StatusOK || strings.Contains(r.body, "Ahri") || strings.Contains(r.body, `"answer"`) {
t.Fatalf("guess %s leaked or failed: %d %s", name, r.code, r.body)
}
}
row := r.v.Guesses[2]
if row.Name != "Kai'Sa" || row.ID != "Kaisa" || row.Marks != "cwccwwd" || len(row.Cells) != 7 {
t.Fatalf("row = %+v", row)
}
// Cells show the guess's own values only.
if c := row.Cells[4]; c.Key != "regions" || c.Value != "Shurima, Void" || c.Result != "wrong" {
t.Fatalf("regions cell = %+v", c)
}
if c := row.Cells[6]; c.Value != "2018" || c.Dir != "down" || c.Result != "wrong" {
t.Fatalf("year cell = %+v", c)
}
for name, code := range map[string]string{"zzz": "unknown", "ka": "ambiguous", "akali": "duplicate"} {
if r := h.roundGuess(tok, 1, name); r.code != http.StatusUnprocessableEntity || r.err != code {
t.Errorf("%s = %d %s", name, r.code, r.body)
}
}
r = h.roundGuess(tok, 1, "ahri")
if r.v.Status != "won" || r.v.Answer == nil || r.v.Answer.Name != "Ahri" || r.v.Answer.ID != "Ahri" || r.v.Answer.Title == "" ||
r.v.Stats == nil || r.v.Stats.Played != 1 || len(r.v.Stats.Dist) != MaxGuessesCap || r.v.Stats.Dist[3] != 1 || r.v.Share != "" {
t.Fatalf("win = %s", r.body)
}
if r = h.post("new", map[string]any{"token": tok, "seq": 1}); r.v.Seq != 2 || len(r.v.Guesses) != 0 || r.v.Abandoned != nil {
t.Fatalf("new = %s", r.body)
}
// Giving up a round with guesses shows its champion before the next one.
h.roundGuess(tok, 2, "Akali") // round 2 is Jinx
if r = h.post("new", map[string]any{"token": tok, "seq": 2}); r.v.Seq != 3 || r.v.Answer != nil ||
r.v.Abandoned == nil || r.v.Abandoned.Name != "Jinx" || r.v.Abandoned.ID != "Jinx" || r.v.Abandoned.Title == "" {
t.Fatalf("abandon = %s", r.body)
}
if r = h.roundGuess(tok, 1, "akali"); r.code != http.StatusConflict || r.err != "new_round" {
t.Fatalf("stale = %d %s", r.code, r.body)
}
if n := len(h.rep.snapshot()); n != 0 {
t.Fatalf("unlimited win reported %d times", n)
}
if r = h.post("new", map[string]any{"token": h.token(1, groupChat, 5, "Alice", false), "seq": 1}); r.code != http.StatusForbidden || r.err != "bad_mode" {
t.Fatalf("new on a daily card = %d %s", r.code, r.body)
}
}
func TestDaily_SameChampionForEveryoneAndHiddenUntilFinished(t *testing.T) {
h := newHarness(t)
h.pinDaily(1, "Ahri")
alice := h.token(1, groupChat, 7, "Alice", false)
r := h.state(alice)
if r.v.Mode != "daily" || r.v.Num != 1 || r.v.Date != "2026-10-09" || r.v.Max != 8 || r.v.NextAt != guessgame.Epoch.Add(24*time.Hour).Unix() {
t.Fatalf("daily = %s", r.body)
}
r = h.dailyGuess(alice, 1, "Akali")
if strings.Contains(r.body, "Ahri") || r.v.Guesses[0].Marks != "cwwwccu" {
t.Fatalf("guess leaked or wrong: %s", r.body)
}
r = h.dailyGuess(alice, 1, "Ahri")
if r.v.Status != "won" || r.v.Answer.Name != "Ahri" || r.v.Stats.Played != 1 || len(r.v.Stats.Dist) != 8 || r.v.Stats.Dist[1] != 1 ||
r.v.Share != "LoLdle Daily #1 2/8\n\n🟩🟥🟥🟥🟩🟩🟥\n🟩🟩🟩🟩🟩🟩🟩" {
t.Fatalf("win = %s", r.body)
}
if calls := h.rep.snapshot(); len(calls) != 1 || calls[0].score != 7 || calls[0].addr.ChatID != groupChat {
t.Fatalf("score = %+v, want 9-2", calls)
}
if r := h.dailyGuess(alice, 1, "Jinx"); r.code != http.StatusUnprocessableEntity || r.err != "finished" {
t.Fatalf("after the win = %d %s", r.code, r.body)
}
// Bob gets the same champion.
bob := h.token(2, 2, 3, "Bob", false)
if r := h.dailyGuess(bob, 1, "Ahri"); r.v.Status != "won" {
t.Fatalf("bob = %s", r.body)
}
// At 07:00 ICT the next champion starts; a stale board is refused.
h.clock.advance(12 * time.Hour)
bob = h.token(2, 2, 3, "Bob", false)
if r := h.dailyGuess(bob, 1, "Ahri"); r.code != http.StatusConflict || r.err != "new_puzzle" {
t.Fatalf("stale = %d %s", r.code, r.body)
}
if r := h.state(bob); r.v.Num != 2 || len(r.v.Guesses) != 0 || r.v.Date != "2026-10-10" {
t.Fatalf("day 2 = %s", r.body)
}
// Daily stats live under their own keys, apart from the legacy stats.
if keys, _ := h.svc.Stats.List(context.Background(), "dstats:"); len(keys) != 2 {
t.Fatalf("daily stats keys = %v", keys)
}
}
// A champion pinned as a day's answer and later removed from the data keeps
// the day playable: guesses score wrong in every column, naming the answer
// wins, and the board finishes instead of failing every guess.
func TestDaily_PinnedAnswerMissingFromDataStaysPlayable(t *testing.T) {
h := newHarness(t)
h.pinDaily(1, "Retired Champ")
alice := h.token(1, groupChat, 7, "Alice", false)
r := h.dailyGuess(alice, 1, "Akali")
if r.code != http.StatusOK || len(r.v.Guesses) != 1 || r.v.Guesses[0].Marks != "wwwwwww" || r.v.Answer != nil {
t.Fatalf("guess = %d %s", r.code, r.body)
}
for name, code := range map[string]string{"zzz": "unknown", "ka": "ambiguous", "akali": "duplicate"} {
if r := h.dailyGuess(alice, 1, name); r.code != http.StatusUnprocessableEntity || r.err != code {
t.Errorf("%s = %d %s", name, r.code, r.body)
}
}
r = h.dailyGuess(alice, 1, "retired champ")
if r.code != http.StatusOK || r.v.Status != "won" || r.v.Answer == nil || r.v.Answer.Name != "Retired Champ" {
t.Fatalf("win = %d %s", r.code, r.body)
}
bob := h.token(2, 2, 3, "Bob", false)
for i, name := range []string{"Ahri", "Akali", "Jinx", "Wukong", "Kai'Sa", "Lux", "Garen", "Teemo"} {
r = h.dailyGuess(bob, 1, name)
if r.code != http.StatusOK {
t.Fatalf("bob guess %d = %d %s", i, r.code, r.body)
}
}
if r.v.Status != "lost" || r.v.Answer == nil || r.v.Answer.Name != "Retired Champ" {
t.Fatalf("loss = %s", r.body)
}
}
func TestDaily_AnswersAreKeyedChampionsWithoutRepeats(t *testing.T) {
h := newHarness(t)
n := len(loadChampions())
seen := map[string]bool{}
for num := 1; num <= n; num++ {
a := h.svc.AnswerFor(num)
if seen[a] || findChampionByExactName(h.svc.cfg.champions, a) == nil {
t.Fatalf("puzzle %d answer %q repeats or is unknown", num, a)
}
seen[a] = true
}
other := testConfig(h.clock, h.coll, h.rep, nil, h.sched)
other.rootKey = []byte("another-root-key-another-root-key")
same := 0
for num := 1; num <= 10; num++ {
if newService(other).AnswerFor(num) == h.svc.AnswerFor(num) {
same++
}
}
if same == 10 {
t.Fatal("the answer order ignores the key")
}
// The first resolution pins the day: a later key or data change keeps it.
a, err := h.svc.ResolvePuzzle(context.Background(), 1)
if err != nil {
t.Fatal(err)
}
if got, _ := newService(other).ResolvePuzzle(context.Background(), 1); got != a {
t.Fatalf("pinned %q, then %q", a, got)
}
}
// tokenFor-style helper with a thread; daily tests need topics.
func (h *harness) tokenTopic(user, chat int64, msg, thread int, name string) string {
h.t.Helper()
tok, err := h.svc.signToken(guessgame.Claims{UserID: user, Name: name, ChatID: chat, MessageID: msg, ThreadID: thread,
Expiry: h.clock.now().Add(guessgame.TokenTTL).Unix()})
if err != nil {
h.t.Fatal(err)
}
return tok
}
func TestDaily_LiveResultsAreSpoilerFree(t *testing.T) {
h := newHarness(t)
h.pinDaily(1, "Ahri")
alice := h.tokenTopic(1, groupChat, 7, 0, "Alice")
h.dailyGuess(alice, 1, "Akali")
h.dailyGuess(h.tokenTopic(2, groupChat, 7, 0, "Bob"), 1, "Kai'Sa")
h.sched.run()
sends := sentMethod(h.rb, "sendMessage")
if len(sends) != 1 {
t.Fatalf("sends = %+v", h.rb.Sent())
}
text := sends[0].Text()
if text != "LoLdle Daily #1 · live results\n\nAlice playing 1/8\n🟩🟥🟥🟥🟩🟩🟥\nBob playing 1/8\n🟩🟥🟩🟩🟥🟥🟥" {
t.Fatalf("live = %q", text)
}
for _, c := range loadChampions() {
if strings.Contains(text, c.ChampionName) {
t.Fatalf("live results name %s", c.ChampionName)
}
}
h.dailyGuess(alice, 1, "Ahri")
h.sched.run()
edits := sentMethod(h.rb, "editMessageText")
if len(edits) == 0 || !strings.Contains(edits[len(edits)-1].Form["text"], "Alice 2/8\n🟩🟥🟥🟥🟩🟩🟥\n🟩🟩🟩🟩🟩🟩🟩") {
t.Fatalf("edits = %+v", edits)
}
}
func TestDaily_RecapAnswerFirstStreakCountsWinsOnly(t *testing.T) {
h := newHarness(t)
ctx := context.Background()
rb := install(t, h.svc.module(), 0)
if got := send(t, rb, groupChat, 1, "/loldledaily_subscribe"); !strings.HasPrefix(got, "✅ Subscribed this chat to LoLdle Daily") {
t.Fatalf("subscribe = %q", got)
}
// Day 1: Alice wins in 2, Dan in 2, Bob loses; day 2: Alice wins.
h.pinDaily(1, "Ahri")
h.pinDaily(2, "Jinx")
h.pinDaily(3, "Wukong")
h.dailyGuess(h.tokenTopic(1, groupChat, 7, 0, "Alice"), 1, "Akali")
h.dailyGuess(h.tokenTopic(1, groupChat, 7, 0, "Alice"), 1, "Ahri")
h.dailyGuess(h.tokenTopic(3, groupChat, 7, 0, "Dan"), 1, "Jinx")
h.dailyGuess(h.tokenTopic(3, groupChat, 7, 0, "Dan"), 1, "Ahri")
bob := h.tokenTopic(2, groupChat, 7, 0, "Bob")
for _, c := range []string{"Akali", "Wukong", "Jinx", "Kai'Sa", "Aatrox", "Annie", "Ashe", "Braum"} {
h.dailyGuess(bob, 1, c)
}
// Day 1's recap groups the finishers by guesses, the best crowned.
got, err := h.svc.RenderRecap(ctx, 1, groupChat, 0)
if err != nil || got != "LoLdle Daily #1 — yesterday's results\nChampion: Ahri\n👑 2/8: Alice, Dan\nX/8: Bob" {
t.Fatalf("day 1 recap = %q, %v", got, err)
}
h.clock.advance(24 * time.Hour)
h.dailyGuess(h.tokenTopic(1, groupChat, 8, 0, "Alice"), 2, "Jinx")
h.clock.advance(24 * time.Hour)
h.sched.run()
h.rb.Reset()
if err := h.svc.RunDailyPush(ctx, h.rb.Bot); err != nil {
t.Fatal(err)
}
sent := h.rb.Sent()
if len(sent) != 2 || sent[1].Method != "sendGame" || sent[1].Form["game_short_name"] != "loldle" {
t.Fatalf("push = %+v", sent)
}
if got := sent[0].Text(); got != "LoLdle Daily #2 — yesterday's results\nChampion: Jinx\n🔥 Your group is on a 2 day streak!\n👑 1/8: Alice" {
t.Fatalf("recap = %q", got)
}
// Day 3 only a loss: the streak resets.
h.dailyGuess(h.tokenTopic(1, groupChat, 9, 0, "Alice"), 3, "Akali")
for _, c := range []string{"Ahri", "Jinx", "Kai'Sa", "Aatrox", "Annie", "Ashe", "Braum"} {
h.dailyGuess(h.tokenTopic(1, groupChat, 9, 0, "Alice"), 3, c)
}
h.clock.advance(24 * time.Hour)
h.rb.Reset()
if err := h.svc.RunDailyPush(ctx, h.rb.Bot); err != nil {
t.Fatal(err)
}
if got := h.rb.Sent()[0].Text(); got != "LoLdle Daily #3 — yesterday's results\nChampion: Wukong\nNobody solved it. Group streak reset.\nX/8: Alice" {
t.Fatalf("loss recap = %q", got)
}
}