diff --git a/internal/delivery/telegramsink/intake_test.go b/internal/delivery/telegramsink/intake_test.go new file mode 100644 index 0000000..780336d --- /dev/null +++ b/internal/delivery/telegramsink/intake_test.go @@ -0,0 +1,201 @@ +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") + } +}