e3c2e80a0a
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Failing after 24s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
The direct rail runs on НПД, where the provider neither files with the tax service nor issues a receipt — so nobody was doing it. This registers each rouble purchase, annuls its receipt on a refund, and hands the buyer the receipt by email. Two properties of the (unofficial) lknpd API shape the design. Registering an income takes no idempotency key, so an error does not mean nothing happened: the service name is frozen before the call and carries a marker from the tail of the order id, and after a failure the taxpayer's income list is searched for that exact name. Found means filed; not found halts the queue for a human, because declaring an income twice is as wrong as not declaring it. And faults are classified rather than logged: a token is renewed silently, a throttle backs off, an outage retries, but three unfixable rejections take the rail out of service — a changed format must not become thousands of requests overnight. The console button and the worker share one RunBatch. Automatic mode is armed from the console, not from configuration, so the operator can watch a run go through by hand first. A daily watchdog runs whether or not it is armed, since the case it exists for is the export being off. An idle queue issues no call at all — not even an authentication. No payment path changed: the purchase letter rides the existing payment-event outbox on its own cursor, the receipt and annulment letters ride the export row. Decisions D53-D60.
153 lines
8.4 KiB
Go
153 lines
8.4 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", At: "2026-07-28 12:00",
|
|
Amount: "149.00 RUB", Name: `Внутриигровая валюта: "Фишка", 100 шт. (ID: 0198c1f2)`}},
|
|
Cancels: []MyNalogCancelRow{{LedgerID: "l2", AccountID: "a2", ReceiptUUID: "20abcdef01", Failed: true}},
|
|
Attention: []MyNalogRow{{LedgerID: "l3", AccountID: "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)
|
|
}
|
|
}
|