Files
mesh-catalog/modules/telegram/cmd/telegram/channel_test.go
T
jochen f8515219b1
mesh/merge-gate pass: builds desk-channel, messenger, telegram → novox; no bus step; every machine composes with the change as it did without (4 of 4 compo…
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery-group group fix/373-one-message-per-condition ready: every member ready, and composed together they pass
mesh/delivery superseded: a newer head of the same pull request
Review: a blip swallows no escalation or waiting reminder; replace safely (issue 373)
- A comeback before the clearing was said rings when it is urgent now, and
  says the reminder or escalation that was waiting when it cleared.
- Comebacks never said are counted apart, so "again, Nth time" counts only
  what the operator was told; an urgent comeback after a said clearing rings
  again in place of its message.
- telegram sends the new message before deleting the earlier one, so a
  failed send leaves the operator a message.
- desk-channel keeps a machine's notification on record when its replace
  fails, and says a message shown nowhere as failed.
- A store that cannot be read sends a new message instead of holding it.
- Tests for each, and for a reminder in a burst and a clearing the channel
  could not take.
2026-10-10 14:22:53 +02:00

643 lines
26 KiB
Go

package main
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"time"
"unicode/utf8"
"git.novox.be/novox/mesh-sdk/go/asks"
)
const goodToken = "123456789:AAEhBP0av28P4XFQnIuR-o-7Xnz1kkUzW3g"
// fakeAPI is the Bot API, answering each method as the test says and keeping what it was asked.
type fakeAPI struct {
mu sync.Mutex
calls []call
answer map[string]func(body map[string]any) (int, string)
next int64
}
type call struct {
Method string
Body map[string]any
}
func (f *fakeAPI) of(method string) []call {
f.mu.Lock()
defer f.mu.Unlock()
var out []call
for _, c := range f.calls {
if c.Method == method {
out = append(out, c)
}
}
return out
}
func newAPI(t *testing.T) *fakeAPI {
t.Helper()
f := &fakeAPI{answer: map[string]func(map[string]any) (int, string){}}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(r.URL.Path, "/")
method := parts[len(parts)-1]
if parts[1] != "bot"+goodToken {
w.WriteHeader(401)
_, _ = w.Write([]byte(`{"ok":false,"error_code":401,"description":"Unauthorized"}`))
return
}
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
f.mu.Lock()
f.calls = append(f.calls, call{method, body})
answer := f.answer[method]
f.next++
id := f.next
f.mu.Unlock()
if answer != nil {
code, out := answer(body)
w.WriteHeader(code)
_, _ = w.Write([]byte(out))
return
}
switch method {
case "sendMessage":
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":` + jsonInt(id) + `,"chat":{"id":42,"type":"private"}}}`))
case "getMe":
_, _ = w.Write([]byte(`{"ok":true,"result":{"id":1,"is_bot":true,"username":"mesh_bot"}}`))
default:
_, _ = w.Write([]byte(`{"ok":true,"result":true}`))
}
}))
t.Cleanup(srv.Close)
was := API
API = srv.URL
t.Cleanup(func() { API = was })
return f
}
func jsonInt(n int64) string { b, _ := json.Marshal(n); return string(b) }
type memStore struct {
offset int64
placed map[string]Placed
shown map[[2]int64]string
}
func (m *memStore) Shown(chat, id int64) (string, error) { return m.shown[[2]int64{chat, id}], nil }
func (m *memStore) Show(chat, id int64, digest string) error {
if m.shown == nil {
m.shown = map[[2]int64]string{}
}
m.shown[[2]int64{chat, id}] = digest
return nil
}
func (m *memStore) Offset() (int64, error) { return m.offset, nil }
func (m *memStore) SetOffset(n int64) error { m.offset = n; return nil }
func (m *memStore) Place(h string, p Placed) error {
if m.placed == nil {
m.placed = map[string]Placed{}
}
m.placed[h] = p
return nil
}
func (m *memStore) Placed(h string) (*Placed, error) {
p, ok := m.placed[h]
if !ok {
return nil, nil
}
return &p, nil
}
type said struct {
mu sync.Mutex
by map[string][]json.RawMessage
proved []json.RawMessage
later []func()
}
func (s *said) of(subject string) []json.RawMessage {
s.mu.Lock()
defer s.mu.Unlock()
return s.by[subject]
}
func newChannel(t *testing.T, token string) (*Channel, *fakeAPI, *said, *memStore) {
t.Helper()
f := newAPI(t)
file := filepath.Join(t.TempDir(), "telegram-token")
if token != "" {
if err := os.WriteFile(file, []byte(token+"\n"), 0o600); err != nil {
t.Fatal(err)
}
}
s := &said{by: map[string][]json.RawMessage{}}
st := &memStore{}
now := time.Date(2026, 10, 8, 14, 0, 0, 0, time.UTC)
c := &Channel{Bot: &Bot{TokenFile: file}, Store: st, Now: func() time.Time { return now }, Logf: t.Logf,
Sleep: func(time.Duration) {},
Publish: func(subject string, body any, id string) error {
raw, _ := json.Marshal(body)
s.mu.Lock()
s.by[subject] = append(s.by[subject], raw)
s.mu.Unlock()
return nil
},
Prove: func(subject string, body any) (json.RawMessage, error) {
raw, _ := json.Marshal(body)
s.mu.Lock()
s.proved = append(s.proved, raw)
s.mu.Unlock()
if strings.Contains(string(raw), `"code":"123456"`) {
return json.RawMessage(`{"accepted":true,"words":"Linked."}`), nil
}
return json.RawMessage(`{"accepted":false,"words":"That code is not right."}`), nil
}}
c.After = func(d time.Duration, fn func()) {
s.mu.Lock()
s.later = append(s.later, fn)
s.mu.Unlock()
}
c.init()
return c, f, s, st
}
// waited runs what was set to happen later: the router said nothing in time.
func (s *said) waited() {
s.mu.Lock()
later := s.later
s.later = nil
s.mu.Unlock()
for _, fn := range later {
fn()
}
}
func work(verb string, m asks.Message) (string, json.RawMessage) {
raw, _ := json.Marshal(m)
return asks.ChannelSubject(verb, Kind), raw
}
func TestAQuestionIsShownWithItsTicketsAndRingsAndAWarningIsSilent(t *testing.T) {
c, f, _, st := newChannel(t, goodToken)
subject, body := work(asks.Show, asks.Message{Handle: "m1", Title: "Delivery of hq held", Body: "Needs you: release it.",
To: "42", Buttons: []asks.Button{{Label: "Release", Ticket: "0123456789abcdef0123456789abcdef"}}})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
sent := f.of("sendMessage")
if len(sent) != 1 {
t.Fatalf("sent %d", len(sent))
}
b := sent[0].Body
kb, _ := json.Marshal(b["reply_markup"])
if b["chat_id"] != float64(42) || b["text"] != "Delivery of hq held\nNeeds you: release it." || b["disable_notification"] != false ||
string(kb) != `{"inline_keyboard":[[{"callback_data":"0123456789abcdef0123456789abcdef","text":"Release"}]]}` {
t.Errorf("sent %v %s", b, kb)
}
if _, deprecated := b["disable_web_page_preview"]; deprecated {
t.Error("the deprecated preview flag is still sent (D7)")
}
if st.placed["m1"].ID == 0 {
t.Error("where it is was not kept")
}
subject, body = work(asks.Send, asks.Message{Handle: "m2", Title: "Resolved: hq works again", To: "42"})
_ = c.Take(subject, body)
if f.of("sendMessage")[1].Body["disable_notification"] != true {
t.Error("a clearing rang (D4)")
}
subject, body = work(asks.Send, asks.Message{Handle: "m3", Title: "Urgent: ace silent", Urgent: true, To: "42"})
_ = c.Take(subject, body)
if f.of("sendMessage")[2].Body["disable_notification"] != false {
t.Error("an urgent message was silent (D4)")
}
}
func TestALongMessageIsCutAndTheCutSaid(t *testing.T) {
c, f, _, _ := newChannel(t, goodToken)
subject, body := work(asks.Send, asks.Message{Handle: "m1", Title: "t", Body: strings.Repeat("x", 5000), To: "42"})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
got := f.of("sendMessage")[0].Body["text"].(string)
if n := len([]rune(got)); n != MaxLength || !strings.HasSuffix(got, cutNote) {
t.Errorf("%d characters, ends %q (D1)", n, got[len(got)-40:])
}
}
func TestAnEditToTheSameTextIsASuccessAndAGoneMessageIsSentAgain(t *testing.T) {
c, f, s, st := newChannel(t, goodToken)
st.Place("m1", Placed{Chat: 42, ID: 7})
f.answer["editMessageText"] = func(map[string]any) (int, string) {
return 400, `{"ok":false,"error_code":400,"description":"Bad Request: message is not modified"}`
}
subject, body := work(asks.Edit, asks.Message{Handle: "m1", Title: "same", To: "42"})
if err := c.Take(subject, body); err != nil || len(f.of("sendMessage")) != 0 || len(s.of(asks.IntakeSubject(asks.FailedWhat, Kind))) != 0 {
t.Fatalf("an identical edit was not a success (D6): %v", err)
}
f.answer["editMessageText"] = func(map[string]any) (int, string) {
return 400, `{"ok":false,"error_code":400,"description":"Bad Request: message to edit not found"}`
}
_ = c.Take(subject, body)
if len(f.of("sendMessage")) != 1 {
t.Error("an edit of a message gone was not sent again")
}
if kb, _ := json.Marshal(f.of("editMessageText")[0].Body["reply_markup"]); string(kb) != `{"inline_keyboard":[]}` {
t.Errorf("an edit without buttons left them: %s", kb)
}
}
func TestARefusalIsPermanentARateLimitIsWaitedOutAndNeitherCarriesTheToken(t *testing.T) {
c, f, s, _ := newChannel(t, goodToken)
tries := 0
var slept []time.Duration
c.Sleep = func(d time.Duration) { slept = append(slept, d) }
f.answer["sendMessage"] = func(map[string]any) (int, string) {
tries++
if tries == 1 {
return 429, `{"ok":false,"error_code":429,"description":"Too Many Requests","parameters":{"retry_after":7}}`
}
return 403, `{"ok":false,"error_code":403,"description":"Forbidden: bot was blocked by the user ` + goodToken + `"}`
}
subject, body := work(asks.Send, asks.Message{Handle: "m1", Title: "x", To: "42"})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
if len(slept) != 1 || slept[0] != 7*time.Second || tries != 2 {
t.Errorf("the rate limit was not waited out as told (D5): slept %v, tries %d", slept, tries)
}
failed := s.of(asks.IntakeSubject(asks.FailedWhat, Kind))
if len(failed) != 1 || !strings.Contains(string(failed[0]), `"permanent":true`) || strings.Contains(string(failed[0]), goodToken) {
t.Fatalf("the refusal: %s (D6)", failed)
}
// No linked account and nothing to reply to: said as failed for good, never retried.
subject, body = work(asks.Send, asks.Message{Handle: "m2", Title: "x"})
_ = c.Take(subject, body)
if f := s.of(asks.IntakeSubject(asks.FailedWhat, Kind)); len(f) != 2 || !strings.Contains(string(f[1]), "no account is linked") {
t.Errorf("%s", f)
}
}
func TestATapIsAcknowledgedAtOnceAndSaidWithItsAuthenticatedSender(t *testing.T) {
c, f, s, _ := newChannel(t, goodToken)
shown := showQuestion(t, c, f)
c.Handle(Update{UpdateID: 1, Callback: &Callback{ID: "cb1", Data: "tkt", From: User{ID: 42, FirstName: "Jo"},
Message: &Msg{MessageID: shown, Chat: Chat{ID: 42, Type: "private"}, Text: "Release it?\nThe delivery is held."}}})
got := s.of(asks.IntakeSubject(asks.Chosen, Kind))
var ch asks.Choice
if len(got) != 1 || json.Unmarshal(got[0], &ch) != nil || ch.Ticket != "tkt" ||
!reflect.DeepEqual(ch.Sender, asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}) {
t.Fatalf("said %s", got)
}
// In a group, or from another bot, nothing is said: it is nobody's own chat with the bot.
c.Handle(Update{UpdateID: 2, Callback: &Callback{ID: "cb2", Data: "tkt", From: User{ID: 42},
Message: &Msg{Chat: Chat{ID: -100, Type: "group"}}}})
c.Handle(Update{UpdateID: 3, Callback: &Callback{ID: "cb3", Data: "tkt", From: User{ID: 9, IsBot: true},
Message: &Msg{Chat: Chat{ID: 9, Type: "private"}}}})
if len(s.of(asks.IntakeSubject(asks.Chosen, Kind))) != 1 {
t.Error("a tap outside the account's own chat was said")
}
}
func TestStartAsksALinkAndACodeIsAProofDeletedFromTheChat(t *testing.T) {
c, f, s, _ := newChannel(t, goodToken)
jo := &User{ID: 42, FirstName: "Jo", Username: "jo"}
c.Handle(Update{UpdateID: 10, Message: &Msg{MessageID: 5, From: jo, Chat: Chat{ID: 42, Type: "private"}, Text: "/start"}})
var l asks.LinkAsked
got := s.of(asks.IntakeSubject(asks.Link, Kind))
if len(got) != 1 || json.Unmarshal(got[0], &l) != nil || l.Sender.Identity != "42" || l.Sender.Display != "Jo (@jo)" {
t.Fatalf("link %s", got)
}
// The router's answer to the link goes to the chat it came from, though nobody is linked yet.
subject, body := work(asks.Send, asks.Message{Handle: "r1", Title: "Send the code shown on your desk", Reply: l.ID})
if err := c.Take(subject, body); err != nil || len(f.of("sendMessage")) != 1 || f.of("sendMessage")[0].Body["chat_id"] != float64(42) {
t.Fatalf("the reply did not reach the chat: %v %v", err, f.of("sendMessage"))
}
c.Handle(Update{UpdateID: 11, Message: &Msg{MessageID: 6, From: jo, Chat: Chat{ID: 42, Type: "private"}, Text: "123 456"}})
if len(s.proved) != 1 || !strings.Contains(string(s.proved[0]), `"code":"123456"`) {
t.Fatalf("the code was not asked as a proof: %s", s.proved)
}
for subject, events := range s.by {
for _, e := range events {
if strings.Contains(string(e), "123456") {
t.Errorf("the code was said on %s", subject)
}
}
}
if d := f.of("deleteMessage"); len(d) != 1 || d[0].Body["message_id"] != float64(6) {
t.Error("the code was not deleted from the chat")
}
if last := f.of("sendMessage"); last[len(last)-1].Body["text"] != "Linked." {
t.Errorf("the router's answer was not said: %v", last[len(last)-1].Body)
}
c.Handle(Update{UpdateID: 12, Message: &Msg{MessageID: 7, From: jo, Chat: Chat{ID: 42, Type: "private"}, Text: "release it"}})
if last := f.of("sendMessage"); last[len(last)-1].Body["text"] != FixedLine {
t.Error("other words were not answered with the fixed line")
}
if c.Store.(*memStore).offset != 0 {
t.Error("the offset moved without a poll")
}
}
func TestStandingSaysWhatIsMissingAndNeverTheToken(t *testing.T) {
for token, want := range map[string]string{
"": "no bot token",
"a-random-value-x": "not a bot token",
"987654321:" + strings.Repeat("Z", 35): "does not work",
} {
c, _, _, _ := newChannel(t, token)
s := c.Standing()
if s.Ready || !strings.Contains(s.Why, want) || (token != "" && strings.Contains(s.Why, token)) {
t.Errorf("%q: %+v", token, s)
}
}
c, _, said, _ := newChannel(t, goodToken)
if s := c.Standing(); !s.Ready || !s.EditsSilently {
t.Errorf("%+v", s)
}
c.SayStanding()
c.SayStanding()
if n := len(said.of(asks.IntakeSubject(asks.StandingWhat, Kind))); n != 1 {
t.Errorf("standing said %d times without a change", n)
}
}
func TestPollTakesTheUpdatesAndKeepsTheOffsetAndSaysAConflict(t *testing.T) {
c, f, s, st := newChannel(t, goodToken)
f.answer["getUpdates"] = func(map[string]any) (int, string) {
return 200, `{"ok":true,"result":[{"update_id":41,"message":{"message_id":1,"from":{"id":42},"chat":{"id":42,"type":"private"},"text":"/start"}}]}`
}
if err := c.Poll(time.Second); err != nil || st.offset != 42 || len(s.of(asks.IntakeSubject(asks.Link, Kind))) != 1 {
t.Fatalf("%v offset %d", err, st.offset)
}
f.answer["getUpdates"] = func(map[string]any) (int, string) {
return 409, `{"ok":false,"error_code":409,"description":"Conflict: terminated by other getUpdates request"}`
}
if err := c.Poll(time.Second); err == nil || !strings.Contains(err.Error(), "another program takes this bot's updates") {
t.Errorf("%v", err)
}
if s := c.Standing(); s.Ready {
t.Error("ready while the updates do not arrive")
}
}
// showQuestion shows one question with a button to the linked account, and answers its message's id.
func showQuestion(t *testing.T, c *Channel, f *fakeAPI) int64 {
t.Helper()
subject, body := work(asks.Show, asks.Message{Handle: "q1", Title: "Release it?", Body: "The delivery is held.",
To: "42", Buttons: []asks.Button{{Label: "Release", Ticket: "tkt"}}})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
sent := f.of("sendMessage")
if len(sent) == 0 {
t.Fatal("the question was not sent")
}
f.mu.Lock()
id := f.next
f.mu.Unlock()
return id
}
// A tap is said only on a message showing exactly what this channel put there (the review of 2026-10-09): a
// message whose words were changed by another holder of the token — "Release" under other words — or one
// this channel never showed, is refused on the phone and never reaches the router.
func TestATapOnAMessageShowingOtherWordsIsRefused(t *testing.T) {
c, f, s, st := newChannel(t, goodToken)
shown := showQuestion(t, c, f)
tap := func(id string, msg int64, words string) {
c.Handle(Update{UpdateID: 1, Callback: &Callback{ID: id, Data: "tkt", From: User{ID: 42, FirstName: "Jo"},
Message: &Msg{MessageID: msg, Chat: Chat{ID: 42, Type: "private"}, Text: words}}})
}
tap("cb-other", shown, "Silence it for a week?\nNothing changes.")
tap("cb-unknown", shown+100, "Release it?\nThe delivery is held.")
if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 0 {
t.Fatalf("a tap on other words was said: %s", got)
}
answers := f.of("answerCallbackQuery")
if len(answers) != 2 || !strings.Contains(answers[0].Body["text"].(string), "not") {
t.Errorf("the phone was not told: %v", answers)
}
// The same words, as Telegram gives them back (its line endings, its outer spaces), are taken.
tap("cb-same", shown, " Release it?\r\nThe delivery is held. ")
if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 {
t.Fatalf("the tap on the words shown was not said: %s", got)
}
// An edit moves what was shown: the old words are then refused.
subject, body := work(asks.Edit, asks.Message{Handle: "q1", Title: "Released", Body: "by you", To: "42"})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
tap("cb-old", shown, "Release it?\nThe delivery is held.")
if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 {
t.Errorf("a tap on words since replaced was said")
}
if d, _ := st.Shown(42, shown); d == "" {
t.Error("what an edit showed was not kept")
}
}
// tapOn taps the question showQuestion put there, as the operator.
func tapOn(c *Channel, shown int64, id string) {
c.Handle(Update{UpdateID: 1, Callback: &Callback{ID: id, Data: "tkt", From: User{ID: 42, FirstName: "Jo"},
Message: &Msg{MessageID: shown, Chat: Chat{ID: 42, Type: "private"}, Text: "Release it?\nThe delivery is held."}}})
}
// The operator taps Approve and sees at once what the router recorded (issue 372): the tap is acknowledged
// with the router's word on it, which comes as a reply to the tap — never before it, so the phone never says
// "recorded" for what the router did not record — and a word of one line goes nowhere else.
func TestATapIsAcknowledgedWithTheRoutersWordOnIt(t *testing.T) {
c, f, s, _ := newChannel(t, goodToken)
shown := showQuestion(t, c, f)
tapOn(c, shown, "cb1")
if got := f.of("answerCallbackQuery"); len(got) != 0 {
t.Fatalf("the tap was acknowledged before the router said anything: %v", got)
}
sentBefore := len(f.of("sendMessage"))
subject, body := work(asks.Send, asks.Message{Handle: "r1", Title: "You chose Release: recorded, and mesh-delivery was told.",
Silent: true, Reply: "tg-tap-cb1", To: "42"})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
got := f.of("answerCallbackQuery")
if len(got) != 1 || got[0].Body["callback_query_id"] != "cb1" ||
got[0].Body["text"] != "You chose Release: recorded, and mesh-delivery was told." {
t.Fatalf("the tap was acknowledged with %v", got)
}
if len(f.of("sendMessage")) != sentBefore {
t.Error("a word of one line was also sent to the chat")
}
// The wait running out after the word changes nothing: a tap is acknowledged once.
s.waited()
if len(f.of("answerCallbackQuery")) != 1 {
t.Error("the tap was acknowledged twice")
}
// The same word again, once the tap is acknowledged, is said in the chat.
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
if len(f.of("sendMessage")) != sentBefore+1 {
t.Error("a word for a tap already acknowledged was lost")
}
}
// A refused tap says why on the phone, and the refusal stays in the chat (issue 372).
func TestARefusedTapSaysWhyOnThePhoneAndInTheChat(t *testing.T) {
c, f, _, _ := newChannel(t, goodToken)
shown := showQuestion(t, c, f)
tapOn(c, shown, "cb1")
sentBefore := len(f.of("sendMessage"))
subject, body := work(asks.Send, asks.Message{Handle: "r1", Title: "That answer was not taken",
Body: "The question was already answered or has ended. Nothing was done.", Silent: true, Reply: "tg-tap-cb1", To: "42"})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
got := f.of("answerCallbackQuery")
if len(got) != 1 || got[0].Body["text"] != "That answer was not taken: The question was already answered or has ended. Nothing was done." {
t.Fatalf("the tap was acknowledged with %v", got)
}
if len(f.of("sendMessage")) != sentBefore+1 {
t.Error("the refusal was not kept in the chat")
}
}
// Without the router's word in time, the tap is acknowledged with words that claim nothing, and the word when
// it comes is said in the chat. A tap Telegram no longer takes an acknowledgement for is the same.
func TestATapTheRouterIsSlowOnClaimsNothingAndItsWordComesInTheChat(t *testing.T) {
c, f, s, _ := newChannel(t, goodToken)
shown := showQuestion(t, c, f)
tapOn(c, shown, "cb1")
s.waited()
got := f.of("answerCallbackQuery")
if len(got) != 1 || got[0].Body["text"] != TapPending || strings.Contains(TapPending, "record") {
t.Fatalf("a slow router's tap was acknowledged with %v", got)
}
sentBefore := len(f.of("sendMessage"))
subject, body := work(asks.Send, asks.Message{Handle: "r1", Title: "You chose Release: recorded.", Reply: "tg-tap-cb1", To: "42"})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
if sent := f.of("sendMessage"); len(sent) != sentBefore+1 || sent[len(sent)-1].Body["text"] != "You chose Release: recorded." {
t.Errorf("the late word was not said in the chat: %v", sent)
}
// Telegram refuses an acknowledgement that came too late: the word goes to the chat instead.
f.answer["answerCallbackQuery"] = func(map[string]any) (int, string) {
return 400, `{"ok":false,"error_code":400,"description":"Bad Request: query is too old and response timeout expired or query ID is invalid"}`
}
tapOn(c, shown, "cb2")
subject, body = work(asks.Send, asks.Message{Handle: "r2", Title: "You chose Release: recorded.", Reply: "tg-tap-cb2", To: "42"})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
if len(f.of("sendMessage")) != sentBefore+2 {
t.Error("a word Telegram would not take as the acknowledgement was lost")
}
}
// A tap the router cannot be told of is acknowledged at once with that, claiming nothing done; a secret
// (a link's code) is never shown as an acknowledgement.
func TestATapNotSaidToTheRouterIsSaidSoAndASecretIsNeverAToast(t *testing.T) {
c, f, _, _ := newChannel(t, goodToken)
shown := showQuestion(t, c, f)
c.Publish = func(string, any, string) error { return errors.New("the bus is away") }
tapOn(c, shown, "cb1")
got := f.of("answerCallbackQuery")
if len(got) != 1 || !strings.Contains(got[0].Body["text"].(string), "Nothing was done") {
t.Fatalf("acknowledged with %v", got)
}
c.Publish = func(string, any, string) error { return nil }
tapOn(c, shown, "cb2")
subject, body := work(asks.Send, asks.Message{Handle: "r1", Title: "Your code", Body: "123456", Secret: true,
Reply: "tg-tap-cb2", To: "42"})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
for _, a := range f.of("answerCallbackQuery") {
if strings.Contains(fmt.Sprint(a.Body["text"]), "123456") {
t.Fatal("a secret was shown as a tap's acknowledgement")
}
}
}
// The toast is the title and the first line of the body, cut at Telegram's bound.
func TestAToastIsCutAtTelegramsBound(t *testing.T) {
long := toast(asks.Message{Title: strings.Repeat("a", 300)})
if utf8.RuneCountInString(long) != ToastLength || !strings.HasSuffix(long, "…") {
t.Errorf("a long toast is %d runes", utf8.RuneCountInString(long))
}
if got := toast(asks.Message{Title: "Details: x", Body: "first\nsecond"}); got != "Details: x: first" {
t.Errorf("toast %q", got)
}
}
// Issue 373: a reminder or an escalation is sent under the handle of the message it follows, and replaces it:
// the earlier one leaves the chat and the new one rings as its kind rings. One the service will not delete
// loses its buttons. A new handle is a new message, and nothing is deleted.
func TestAMessageSaidAgainUnderItsHandleReplacesTheEarlierOne(t *testing.T) {
c, f, _, st := newChannel(t, goodToken)
subject, body := work(asks.Show, asks.Message{Handle: "m1", Title: "Urgent: ace silent", Urgent: true, To: "42",
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "0123456789abcdef0123456789abcdef"}}})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
first := st.placed["m1"].ID
subject, body = work(asks.Send, asks.Message{Handle: "m2", Title: "another problem", To: "42"})
_ = c.Take(subject, body)
if len(f.of("deleteMessage")) != 0 {
t.Fatal("a new handle deleted a message")
}
subject, body = work(asks.Show, asks.Message{Handle: "m1", Title: "Still open: ace silent", Urgent: true, To: "42",
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "fedcba9876543210fedcba9876543210"}}})
if err := c.Take(subject, body); err != nil {
t.Fatal(err)
}
del := f.of("deleteMessage")
if len(del) != 1 || del[0].Body["message_id"] != float64(first) {
t.Fatalf("the earlier message was not deleted: %+v", del)
}
sent := f.of("sendMessage")
if len(sent) != 3 || sent[2].Body["disable_notification"] != false || st.placed["m1"].ID == first {
t.Fatalf("the reminder was not sent ringing in its place: %d sends, placed %d", len(sent), st.placed["m1"].ID)
}
// The service refuses the delete: the earlier message loses its buttons instead.
f.answer["deleteMessage"] = func(map[string]any) (int, string) {
return 400, `{"ok":false,"error_code":400,"description":"Bad Request: message can't be deleted"}`
}
second := st.placed["m1"].ID
subject, body = work(asks.Send, asks.Message{Handle: "m1", Title: "Now urgent: ace silent", Urgent: true, To: "42"})
_ = c.Take(subject, body)
ed := f.of("editMessageText")
if len(ed) != 1 || ed[0].Body["message_id"] != float64(second) || !strings.Contains(ed[0].Body["text"].(string), "Replaced") {
t.Fatalf("an undeletable earlier message kept its buttons: %+v", ed)
}
if kb, _ := json.Marshal(ed[0].Body["reply_markup"]); string(kb) != `{"inline_keyboard":[]}` {
t.Errorf("its buttons stayed: %s", kb)
}
}
// The review of PR 176: the new message is sent before the earlier one is deleted, so a send that fails leaves
// the operator the earlier message rather than none.
func TestAReplacementThatCannotBeSentLeavesTheEarlierMessage(t *testing.T) {
c, f, _, st := newChannel(t, goodToken)
st.Place("m1", Placed{Chat: 42, ID: 7})
f.answer["sendMessage"] = func(map[string]any) (int, string) {
return 400, `{"ok":false,"error_code":400,"description":"Bad Request: chat not found"}`
}
subject, body := work(asks.Send, asks.Message{Handle: "m1", Title: "Still open: ace silent", Urgent: true, To: "42"})
_ = c.Take(subject, body)
if len(f.of("deleteMessage")) != 0 || st.placed["m1"].ID != 7 {
t.Fatalf("the earlier message was taken away though its replacement was not sent: %+v", f.of("deleteMessage"))
}
}