Files
mesh-lab/asks/faketelegram_test.go
T
jochen 7289edb276
mesh/merge-gate error: the check could not run: the base branch proofs/asks-answered-on-the-phone cannot be read, and the check it holds judges the change:…
mesh/repo-check error: the check could not run: the base branch proofs/asks-answered-on-the-phone cannot be read, and the check it holds judges the change:…
mesh/delivery rejected: the gate failed or could not run, or the repository's own check failed
asks: the proof sees what the phone shows at a tap, and the question's outcome as its title (hq issue 372)
The operator approved on Telegram and saw nothing: the tap was acknowledged
with no words and the question kept its title. The proof now checks the toast
says the approval was recorded, no earlier than the warrant; that the edited
question opens with how it ended; and that the same answer tapped again is
said as already recorded, not refused.
2026-10-10 14:12:45 +02:00

319 lines
9.0 KiB
Go

package asks
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
// fakeTelegram is the Bot API as the channel uses it, and nothing more: the lab's phone. It holds the updates
// the lab makes happen (a message typed, a button tapped) for the channel's long poll, and keeps every message
// the bot sends or edits, by chat, so the lab reads what the operator would see. It never reaches Telegram.
type fakeTelegram struct {
t *testing.T
server *httptest.Server
token string
mu sync.Mutex
updates []map[string]any
next int64
arrived chan struct{}
sent []tgMessage
msgID int64
taps int // answerCallbackQuery calls
toasts []toast // what each acknowledgement showed on the phone (issue 372)
deleted int
}
// toast is a tap's acknowledgement: the short line the phone shows over the chat, and when.
type toast struct {
Text string
At time.Time
}
// tgMessage is one message the bot sent or edited: its chat, its id, its words and its buttons (label →
// callback data).
type tgMessage struct {
Chat int64
ID int64
Text string
Buttons [][2]string
Edited bool
At time.Time
}
func newFakeTelegram(t *testing.T, token string) *fakeTelegram {
f := &fakeTelegram{t: t, token: token, next: 1, arrived: make(chan struct{}, 1)}
f.server = httptest.NewServer(http.HandlerFunc(f.serve))
t.Cleanup(f.server.Close)
return f
}
func (f *fakeTelegram) URL() string { return f.server.URL }
func (f *fakeTelegram) serve(w http.ResponseWriter, r *http.Request) {
prefix := "/bot" + f.token + "/"
if !strings.HasPrefix(r.URL.Path, prefix) {
// A wrong token is what the real API answers 401 to.
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error_code": 401, "description": "Unauthorized"})
return
}
method := strings.TrimPrefix(r.URL.Path, prefix)
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
ok := func(result any) { _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": result}) }
switch method {
case "getMe":
ok(map[string]any{"id": 1000, "is_bot": true, "first_name": "Lab", "username": "lab_mesh_bot"})
case "getUpdates":
offset := int64(num(body["offset"]))
wait := time.Duration(num(body["timeout"])) * time.Second
if wait > 2*time.Second {
wait = 2 * time.Second // the lab's phone answers quickly; the channel polls again at once
}
deadline := time.Now().Add(wait)
for {
f.mu.Lock()
var out []map[string]any
for _, u := range f.updates {
if int64(num(u["update_id"])) >= offset {
out = append(out, u)
}
}
f.mu.Unlock()
if len(out) > 0 {
f.t.Logf("phone: getUpdates from %d hands %d update(s)", offset, len(out))
}
if len(out) > 0 || time.Now().After(deadline) {
ok(orEmpty(out))
return
}
select {
case <-f.arrived:
case <-time.After(time.Until(deadline)):
}
}
case "sendMessage", "editMessageText":
chat := int64(num(body["chat_id"]))
text, _ := body["text"].(string)
m := tgMessage{Chat: chat, Text: text, Buttons: buttonsOf(body["reply_markup"]), At: time.Now()}
f.mu.Lock()
if method == "sendMessage" {
f.msgID++
m.ID = f.msgID
} else {
m.ID, m.Edited = int64(num(body["message_id"])), true
}
f.sent = append(f.sent, m)
f.mu.Unlock()
f.t.Logf("phone: %s to %d (#%d): %q %v", method, chat, m.ID, firstLine(text), m.Buttons)
ok(map[string]any{"message_id": m.ID, "chat": map[string]any{"id": chat, "type": "private"}, "text": text})
case "answerCallbackQuery":
text, _ := body["text"].(string)
f.mu.Lock()
f.taps++
f.toasts = append(f.toasts, toast{Text: text, At: time.Now()})
f.mu.Unlock()
f.t.Logf("phone: a tap acknowledged with %q", text)
ok(true)
case "deleteMessage":
f.mu.Lock()
f.deleted++
f.mu.Unlock()
ok(true)
default:
w.WriteHeader(http.StatusNotFound)
_ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error_code": 404, "description": "Not Found: " + method})
}
}
// push makes one thing happen on the phone, for the channel's next poll.
func (f *fakeTelegram) push(u map[string]any) {
f.mu.Lock()
u["update_id"] = f.next
f.next++
f.updates = append(f.updates, u)
f.mu.Unlock()
select {
case f.arrived <- struct{}{}:
default:
}
}
// typed is a message an account writes in a chat with the bot; private when the chat is the account's own.
func (f *fakeTelegram) typed(from int64, chat int64, chatType, text string) {
f.push(map[string]any{"message": map[string]any{"message_id": 9000 + f.nextID(), "text": text,
"from": map[string]any{"id": from, "is_bot": false, "first_name": fmt.Sprintf("Account %d", from)},
"chat": map[string]any{"id": chat, "type": chatType}}})
}
// tapped is a button tapped on a message the bot sent, carrying the button's data and the message's words as
// the service gives them: the words the message shows now (its last send or edit), as on the phone.
func (f *fakeTelegram) tapped(from int64, chat int64, chatType string, onMessage int64, data string) {
f.tappedOn(from, chat, chatType, onMessage, data, f.textOf(onMessage))
}
// tappedOn is a tap on a message showing the words given: what a message changed under the operator's thumb,
// or forwarded and edited elsewhere, carries.
func (f *fakeTelegram) tappedOn(from int64, chat int64, chatType string, onMessage int64, data, text string) {
f.push(map[string]any{"callback_query": map[string]any{"id": fmt.Sprintf("cb-%d", f.nextID()), "data": data,
"from": map[string]any{"id": from, "is_bot": false, "first_name": fmt.Sprintf("Account %d", from)},
"message": map[string]any{"message_id": onMessage, "text": text,
"chat": map[string]any{"id": chat, "type": chatType}}}})
}
// textOf is what a message the bot sent shows now.
func (f *fakeTelegram) textOf(id int64) string {
f.mu.Lock()
defer f.mu.Unlock()
text := ""
for _, m := range f.sent {
if m.ID == id {
text = m.Text
}
}
return text
}
func (f *fakeTelegram) nextID() int64 {
f.mu.Lock()
defer f.mu.Unlock()
f.msgID++
return f.msgID
}
// waitFor waits for a message to a chat that satisfies match, sent after since.
func (f *fakeTelegram) waitFor(what string, chat int64, since time.Time, within time.Duration, match func(tgMessage) bool) tgMessage {
f.t.Helper()
deadline := time.Now().Add(within)
for time.Now().Before(deadline) {
f.mu.Lock()
for _, m := range f.sent {
if m.Chat == chat && !m.At.Before(since) && match(m) {
f.mu.Unlock()
return m
}
}
f.mu.Unlock()
time.Sleep(100 * time.Millisecond)
}
f.t.Fatalf("the phone never showed %s (chat %d) within %s; it showed:\n%s", what, chat, within, f.transcript())
return tgMessage{}
}
// since is every message to a chat sent at or after a time.
func (f *fakeTelegram) since(chat int64, at time.Time) []tgMessage {
f.mu.Lock()
defer f.mu.Unlock()
var out []tgMessage
for _, m := range f.sent {
if m.Chat == chat && !m.At.Before(at) {
out = append(out, m)
}
}
return out
}
func (f *fakeTelegram) transcript() string {
f.mu.Lock()
defer f.mu.Unlock()
var b strings.Builder
for _, m := range f.sent {
fmt.Fprintf(&b, " %s to %d #%d edited=%v %q %v\n", m.At.Format("15:04:05.000"), m.Chat, m.ID, m.Edited,
firstLine(m.Text), m.Buttons)
}
return b.String()
}
// button is the data of the button labelled so on a message.
func button(m tgMessage, label string) string {
for _, b := range m.Buttons {
if b[0] == label {
return b[1]
}
}
return ""
}
func labels(m tgMessage) []string {
var out []string
for _, b := range m.Buttons {
out = append(out, b[0])
}
return out
}
func buttonsOf(markup any) [][2]string {
var out [][2]string
m, _ := markup.(map[string]any)
rows, _ := m["inline_keyboard"].([]any)
for _, row := range rows {
cells, _ := row.([]any)
for _, c := range cells {
cell, _ := c.(map[string]any)
text, _ := cell["text"].(string)
data, _ := cell["callback_data"].(string)
out = append(out, [2]string{text, data})
}
}
return out
}
func num(v any) float64 {
switch n := v.(type) {
case float64:
return n
case int64:
return float64(n)
case int:
return float64(n)
}
return 0
}
func orEmpty(in []map[string]any) []map[string]any {
if in == nil {
return []map[string]any{}
}
return in
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i] + " …"
}
return s
}
// waitForToast waits for a tap's acknowledgement since a time that shows what match wants, and fails saying
// what the phone showed instead.
func (f *fakeTelegram) waitForToast(what string, since time.Time, within time.Duration, match func(string) bool) toast {
f.t.Helper()
deadline := time.Now().Add(within)
for {
f.mu.Lock()
var seen []string
for _, t := range f.toasts {
if t.At.Before(since) {
continue
}
if match(t.Text) {
f.mu.Unlock()
return t
}
seen = append(seen, fmt.Sprintf("%q", t.Text))
}
f.mu.Unlock()
if time.Now().After(deadline) {
f.t.Fatalf("the phone never showed %s; it showed: %s", what, strings.Join(seen, ", "))
}
time.Sleep(100 * time.Millisecond)
}
}