Files
scrabble-game/backend/internal/adminconsole/render_test.go
T
Ilia Denisov 69bddaeb9a
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
fix(admin): shorten the receipt letter, show the account id
The receipt letter repeated what the receipt itself states. It now carries the
amount, the moment of payment with its offset, and the link — the receipt is
the document, and it opens without authentication, so there is nothing for the
two to disagree about. Subject reworded to name what it is.

The ledger and tax-export tables labelled the account link "card", meaning the
user card. On pages about payments that reads as a bank card, which is exactly
how it was misread. Both now show the first eight characters of the account id,
so the column carries data — two rows from the same buyer are visible at a
glance — while the link still holds the whole id.
2026-07-28 16:44:44 +02:00

153 lines
8.5 KiB
Go

package adminconsole
import (
"bytes"
"html/template"
"io/fs"
"strings"
"testing"
)
// TestRendererRendersEveryPage parses the embedded templates and renders each
// page with a representative view, asserting the page executes, carries the
// shared layout chrome and shows a distinctive value.
func TestRendererRendersEveryPage(t *testing.T) {
r, err := NewRenderer()
if err != nil {
t.Fatalf("new renderer: %v", err)
}
cases := []struct {
page string
data any
want string
}{
{"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "scrabble_en", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"},
{"users", UsersView{Items: []UserRow{{ID: "a1", DisplayName: "Kaya", FlaggedHighRate: true}}, Pager: NewPager(1, 50, 1)}, "high-rate"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", VKID: "494075"}, "vk.com/id494075"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", FlaggedHighRateAt: "2026-06-10 12:00"}, "Clear high-rate flag"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", Roles: []string{"feedback_banned"}, KnownRoles: []string{"feedback_banned"}}, "feedback_banned"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya",
Friends: []RelationRow{{AccountID: "b2", DisplayName: "Ann", Date: "2026-06-10 12:00"}},
Blocks: []RelationRow{{AccountID: "c3", DisplayName: "Bob", Date: "2026-06-11 09:00"}},
BlockedBy: []RelationRow{{AccountID: "d4", DisplayName: "Cay", Date: "2026-06-12 08:00"}},
}, `/_gm/users/c3`},
{"throttled", ThrottledView{
Episodes: []ThrottleEpisodeRow{{Class: "user", Key: "a1", UserID: "a1", Rejected: 1234, FirstSeen: "2026-06-10 12:00", LastSeen: "2026-06-10 12:05"}},
Flagged: []FlaggedAccountRow{{ID: "a1", DisplayName: "Kaya", FlaggedAt: "2026-06-10 12:05"}},
FlagThreshold: 1000, FlagWindow: "10m0s",
}, "Recent episodes"},
{"games", GamesView{Items: []GameRow{{ID: "g1", Variant: "scrabble_en", Status: "active"}}, Status: "active", Pager: NewPager(1, 50, 1)}, "g1"},
{"games", GamesView{Items: []GameRow{{ID: "g-open", Variant: "scrabble_en", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "?status=open"},
{"game_detail", GameDetailView{ID: "g1", Variant: "scrabble_en", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"},
{"complaints", ComplaintsView{Items: []ComplaintRow{{ID: "c1", Word: "qi", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "qi"},
{"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1", Unread: true}}, UnreadOnly: true, Pager: NewPager(1, 50, 1)}, "unread only"},
{"chatmessage", ChatMessageDetailView{ID: "m1", GameID: "g1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Kind: "message", Body: "good luck", Unread: true, Seats: []ChatSeatStatusRow{{Seat: 0, AccountID: "a1", DisplayName: "Kaya", Role: "sender"}, {Seat: 1, AccountID: "b2", DisplayName: "Opp", Role: "unread"}}}, "Read by seat"},
{"feedback", FeedbackView{Items: []FeedbackRow{{ID: "f1", AccountID: "a1", SenderName: "Kaya", Source: "telegram", Channel: "web", HasAttachment: true, Replied: true}}, Status: "unread", Pager: NewPager(1, 50, 1)}, "replied"},
{"feedback_detail", FeedbackDetailView{ID: "f1", AccountID: "a1", SenderName: "Kaya", Channel: "telegram", InterfaceLanguage: "en", Body: "please fix the board", HasAttachment: true, AttachmentName: "shot.png", IsImage: true, Banned: true}, "Interface language"},
{"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "scrabble_en"}, "Resolve"},
{"dictionary", DictionaryView{ActiveVersion: "v1.0.0", Variants: []VariantVersions{{Variant: "scrabble_en", Versions: []string{"v1.0.0"}}}, Changes: []DictChangeRow{{Variant: "scrabble_en", Word: "qi", Action: "add"}}}, "Update dictionaries"},
{"dictionary_preview", DictionaryPreviewView{Version: "v1.1.0", Token: "0123456789abcdef0123456789abcdef", ActiveVersion: "v1.0.0", Variants: []VariantDiffRow{{Variant: "scrabble_en", AddedCount: 2, RemovedCount: 1, AddedSample: []string{"qi", "za"}, RemovedSample: []string{"xqz"}, RemovedTruncated: true}}}, "v1.1.0"},
{"broadcast", BroadcastView{ConnectorEnabled: true}, "Post to the game channel"},
{"mynalog", MyNalogView{TimeZone: "Europe/Moscow"}, "Not signed in"},
{"mynalog", MyNalogView{
SignedIn: true, INN: "770000000000", AutoEnabled: true, TimeZone: "Europe/Moscow",
LastOK: "2026-07-28 12:00",
Backlog: MyNalogBacklogRow{Count: 2, Amount: "300.00 RUB", Oldest: "2026-06-30", Overdue: true},
Owed: []MyNalogRow{{LedgerID: "l1", AccountID: "a1", AccountShort: "a1", At: "2026-07-28 12:00",
Amount: "149.00 RUB", Name: `Внутриигровая валюта: "Фишка", 100 шт. (ID: 0198c1f2)`}},
Cancels: []MyNalogCancelRow{{LedgerID: "l2", AccountID: "a2", AccountShort: "a2", ReceiptUUID: "20abcdef01", Failed: true}},
Attention: []MyNalogRow{{LedgerID: "l3", AccountID: "a3", AccountShort: "a3", At: "2026-07-27 09:00",
Amount: "99.00 RUB", Name: "x (ID: 0198c1f3)", Attempts: 2, LastError: "timeout"}},
}, "Needs attention"},
{"mynalog", MyNalogView{SignedIn: true, TimeZone: "Europe/Moscow", Paused: true,
PauseReason: "the tax service rejected three receipts in a row"}, "Resume"},
{"message", MessageView{Heading: "Done", Body: "ok", Back: "/_gm/"}, "Done"},
}
for _, tc := range cases {
t.Run(tc.page, func(t *testing.T) {
var buf bytes.Buffer
if err := r.Render(&buf, tc.page, PageData{Title: tc.page, Data: tc.data}); err != nil {
t.Fatalf("render %s: %v", tc.page, err)
}
out := buf.String()
if !strings.Contains(out, tc.want) {
t.Errorf("render %s: missing %q in output", tc.page, tc.want)
}
if !strings.Contains(out, "Scrabble · admin") {
t.Errorf("render %s: missing layout chrome", tc.page)
}
})
}
}
// TestPagerLinksPreserveFilterQuery guards the paginated lists whose links carry a
// pre-encoded filter query (url.Values.Encode) past the page number. The query fragment
// must reach the link verbatim: the contextual escaper would otherwise re-encode its
// structural "=" and "&" (turning "kind=robots" into the broken "kind%3drobots"), dropping
// the active filter on every page step. FilterQuery is typed template.URL to prevent that.
func TestPagerLinksPreserveFilterQuery(t *testing.T) {
r := MustNewRenderer()
t.Run("users", func(t *testing.T) {
var buf bytes.Buffer
view := UsersView{Robots: true, FilterQuery: template.URL("kind=robots"), Pager: NewPager(2, 50, 200)}
if err := r.Render(&buf, "users", PageData{Title: "Users", Data: view}); err != nil {
t.Fatalf("render users: %v", err)
}
out := buf.String()
for _, want := range []string{
`href="/_gm/users?kind=robots&page=1"`,
`href="/_gm/users?kind=robots&page=3"`,
} {
if !strings.Contains(out, want) {
t.Errorf("users pager: missing %q in:\n%s", want, out)
}
}
if strings.Contains(out, "kind%3drobots") {
t.Error("users pager: filter query was double-encoded (kind%3drobots)")
}
})
t.Run("messages", func(t *testing.T) {
var buf bytes.Buffer
view := MessagesView{FilterQuery: template.URL("game=abc&user=def"), Pager: NewPager(2, 50, 200)}
if err := r.Render(&buf, "messages", PageData{Title: "Messages", Data: view}); err != nil {
t.Fatalf("render messages: %v", err)
}
out := buf.String()
for _, want := range []string{
`href="/_gm/messages.csv?game=abc&user=def"`,
`href="/_gm/messages?game=abc&user=def&page=1"`,
`href="/_gm/messages?game=abc&user=def&page=3"`,
} {
if !strings.Contains(out, want) {
t.Errorf("messages pager: missing %q in:\n%s", want, out)
}
}
if strings.Contains(out, "%3d") || strings.Contains(out, "%26") {
t.Error("messages pager: filter query was double-encoded (%3d / %26)")
}
})
}
// TestRendererUnknownPage reports an error for a page that does not exist.
func TestRendererUnknownPage(t *testing.T) {
r := MustNewRenderer()
if err := r.Render(&bytes.Buffer{}, "nope", PageData{}); err == nil {
t.Fatal("expected an error rendering an unknown page")
}
}
// TestAssets confirms the stylesheet is embedded and reachable under the assets
// root.
func TestAssets(t *testing.T) {
fsys, err := Assets()
if err != nil {
t.Fatalf("assets: %v", err)
}
if _, err := fs.Stat(fsys, "console.css"); err != nil {
t.Errorf("console.css not embedded: %v", err)
}
}