package telegramsink import ( "context" "encoding/json" "errors" "strings" "testing" ) // The turn he types in the chat is the turn the web would run, and the reply // carries the one gesture beside it. func TestIntakeRunsTheTurnAndOffersTheCorrection(t *testing.T) { b := newFakeBot(t) rec := &recorder{reply: "поняла", traceID: 91} p := newTestPoller(t, b, rec) p.handle(context.Background(), msg(ownerChat, " поужинал ")) if got := rec.took(); len(got) != 1 || got[0] != "поужинал" { t.Fatalf("turns %q, want the trimmed utterance once", got) } // The dialogue is keyed per chat, so a clarify asked here is not answered on // the web. if rec.conversation != "telegram:"+ownerChat { t.Errorf("conversation %q does not name the chat", rec.conversation) } sends := b.called("sendMessage") if len(sends) != 1 { t.Fatalf("%d sends, want 1", len(sends)) } if sends[0].body["text"] != "поняла" { t.Errorf("sent %v, want the reply", sends[0].body["text"]) } if sends[0].body["protect_content"] != true { t.Error("his own data went out forwardable") } kb, _ := json.Marshal(sends[0].body["reply_markup"]) if !strings.Contains(string(kb), "w:91") { t.Errorf("keyboard %s does not point at the turn's trace", kb) } } // A turn nothing persisted has no row to correct, and a button that would name // one reports a failure he cannot act on. func TestIntakeSkipsTheGestureWithNoTrace(t *testing.T) { b := newFakeBot(t) p := newTestPoller(t, b, &recorder{reply: "поняла", traceID: 0}) p.handle(context.Background(), msg(ownerChat, "привет")) sends := b.called("sendMessage") if len(sends) != 1 { t.Fatalf("%d sends, want 1", len(sends)) } if _, ok := sends[0].body["reply_markup"]; ok { t.Error("offered a correction on a turn with no trace") } } // One chat, and a stranger is not answered at all: a reply confirms the bot // exists and whose it is. func TestIntakeIgnoresAnyOtherChat(t *testing.T) { b := newFakeBot(t) rec := &recorder{reply: "поняла", traceID: 5} p := newTestPoller(t, b, rec) p.handle(context.Background(), msg("9999", "включи свет")) p.handle(context.Background(), update{UpdateID: 8, CallbackQuery: &callbackQuery{ ID: "cb", Data: "t:5:note", Message: message{Chat: chat{ID: json.Number("9999")}}, }}) if got := rec.took(); len(got) != 0 { t.Errorf("ran %q for a chat that is not the owner's", got) } if len(rec.corrections) != 0 { t.Errorf("wrote %v from a chat that is not the owner's", rec.corrections) } if len(b.calls) != 0 { t.Errorf("answered a stranger: %v", b.calls) } } // Tapping "не то" opens the seven and writes nothing yet. The target is worth // much more than the negative, so it must not be spent before he can name it. func TestIntakeFirstTapOnlyOpensTheTargets(t *testing.T) { b := newFakeBot(t) rec := &recorder{} p := newTestPoller(t, b, rec) p.handle(context.Background(), update{UpdateID: 9, CallbackQuery: &callbackQuery{ ID: "cb", Data: "w:77", Message: message{MessageID: 11, Chat: chat{ID: json.Number(ownerChat)}}, }}) if len(rec.corrections) != 0 { t.Fatalf("wrote %v before he named a target", rec.corrections) } if len(b.called("answerCallbackQuery")) != 1 { t.Error("left the clock spinning on the button") } edits := b.called("editMessageReplyMarkup") if len(edits) != 1 { t.Fatalf("%d edits, want the target row", len(edits)) } kb, _ := json.Marshal(edits[0].body["reply_markup"]) for _, want := range CorrectionTargets { if !strings.Contains(string(kb), `"`+want+`"`) { t.Errorf("target row %s is missing %s", kb, want) } } // And the way out, because he opened the row without knowing he had to name // anything. if !strings.Contains(string(kb), `"t:77:"`) { t.Errorf("target row %s prices out the untargeted negative", kb) } } func TestIntakeWritesTheCorrection(t *testing.T) { for _, tc := range []struct { name, data, want string }{ {"with a target", "t:77:note", "note"}, {"untargeted", "t:77:", ""}, } { t.Run(tc.name, func(t *testing.T) { b := newFakeBot(t) rec := &recorder{} p := newTestPoller(t, b, rec) p.handle(context.Background(), update{UpdateID: 9, CallbackQuery: &callbackQuery{ ID: "cb", Data: tc.data, Message: message{MessageID: 11, Chat: chat{ID: json.Number(ownerChat)}}, }}) if len(rec.corrections) != 1 || rec.corrections[0] != (correction{77, tc.want}) { t.Fatalf("corrections %v, want trace 77 → %q", rec.corrections, tc.want) } // The buttons come off once the gesture is given. edits := b.called("editMessageReplyMarkup") if len(edits) != 1 { t.Fatalf("%d edits, want the keyboard cleared", len(edits)) } kb, _ := json.Marshal(edits[0].body["reply_markup"]) if strings.Contains(string(kb), "t:77") { t.Errorf("keyboard %s still invites a second correction", kb) } }) } } // A write that failed says so on the button. Silence would read as recorded. func TestIntakeSaysWhenTheLabelDidNotLand(t *testing.T) { b := newFakeBot(t) rec := &recorder{correctErr: errors.New("no such routing trace")} p := newTestPoller(t, b, rec) p.handle(context.Background(), update{UpdateID: 9, CallbackQuery: &callbackQuery{ ID: "cb", Data: "t:77:fact", Message: message{MessageID: 11, Chat: chat{ID: json.Number(ownerChat)}}, }}) answers := b.called("answerCallbackQuery") if len(answers) != 1 || answers[0].body["text"] == "" { t.Fatalf("answers %v, want a toast saying it did not land", answers) } if len(b.called("editMessageReplyMarkup")) != 0 { t.Error("cleared the buttons after a failed write, so he cannot try again") } } // A question asked while the daemon was down has been answered by him or has // stopped mattering, and a reminder set from it would land at the wrong time. func TestIntakeDiscardsTheBacklog(t *testing.T) { b := newFakeBot(t, []update{msg(ownerChat, "напомни в 7 позвонить маме")}) rec := &recorder{reply: "поняла", traceID: 3} p := newTestPoller(t, b, rec) p.discardBacklog(context.Background()) if got := rec.took(); len(got) != 0 { t.Errorf("answered %q from the overnight queue", got) } // And the offset moved past it, so the next poll does not see it again. if p.offset != 8 { t.Errorf("offset %d, want the skipped update acknowledged", p.offset) } } // The poller does not start without somewhere to send the turn. func TestNewPollerNeedsATurn(t *testing.T) { sink, err := New(Config{BotToken: "t", ChatID: ownerChat}) if err != nil { t.Fatal(err) } if _, err := NewPoller(sink, nil, nil); err == nil { t.Error("built a poller that reads the chat and answers nothing") } if _, err := NewPoller(nil, func(context.Context, string, string) (string, int64, error) { return "", 0, nil }, nil); err == nil { t.Error("built a poller with no sink to answer through") } } // A chat id the intake half cannot match is refused before anything reads the // chat. Config validation calls the same check, so this is the boot error. func TestValidateIntakeChatID(t *testing.T) { for _, ok := range []string{"123", "-1001234567890", " 42 "} { if err := ValidateIntakeChatID(ok); err != nil { t.Errorf("ValidateIntakeChatID(%q): %v", ok, err) } } for _, bad := range []string{"", "@maven", "-", "12a", "1 2"} { if err := ValidateIntakeChatID(bad); err == nil { t.Errorf("ValidateIntakeChatID(%q) accepted; want error", bad) } } }