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
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.
153 lines
8.5 KiB
Go
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)
|
|
}
|
|
}
|