8d20efcfbb
The sink parsed the response, and when the body did not unmarshal it fell through to the status check and returned nil on any 2xx. This box reaches telegram through a relay, and a relay that is up but cannot reach api.telegram.org answers 200 with a page of its own. That read as delivered: the dispatcher wrote a 'sent' outbox row and MarkSent restarted the repeat clock, so a sev4 alarm nobody received went quiet for a full interval. Only ok=true is a send now. The response cap moves from 4096 to 64KiB, because a truncated body no longer parses and would read as a failure, and error lines carry a 200-byte snippet instead of the relay's whole page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
546 lines
17 KiB
Go
546 lines
17 KiB
Go
package telegramsink
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/delivery"
|
|
"github.com/kami/maven/internal/loop"
|
|
)
|
|
|
|
// recordingServer — captures the last request so tests assert the wire shape.
|
|
type recordingServer struct {
|
|
mu chan struct{}
|
|
method string
|
|
path string
|
|
body string
|
|
contentType string
|
|
auth string
|
|
status int
|
|
respond string
|
|
}
|
|
|
|
func newRecordingServer(t *testing.T, status int, respond string) *recordingServer {
|
|
t.Helper()
|
|
rs := &recordingServer{status: status, respond: respond, mu: make(chan struct{}, 1)}
|
|
rs.mu <- struct{}{}
|
|
return rs
|
|
}
|
|
|
|
func (rs *recordingServer) handler() http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
b, _ := io.ReadAll(r.Body)
|
|
<-rs.mu
|
|
rs.method = r.Method
|
|
rs.path = r.URL.Path
|
|
rs.body = string(b)
|
|
rs.contentType = r.Header.Get("Content-Type")
|
|
rs.auth = r.Header.Get("Authorization")
|
|
rs.mu <- struct{}{}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(rs.status)
|
|
if rs.respond != "" {
|
|
_, _ = w.Write([]byte(rs.respond))
|
|
} else {
|
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
|
}
|
|
})
|
|
}
|
|
|
|
func (rs *recordingServer) snapshot() (method, path, body, contentType, auth string) {
|
|
<-rs.mu
|
|
method, path, body, contentType, auth = rs.method, rs.path, rs.body, rs.contentType, rs.auth
|
|
rs.mu <- struct{}{}
|
|
return
|
|
}
|
|
|
|
func nudgeSendable(sev loop.Severity, summary string) delivery.Sendable {
|
|
return delivery.Sendable{
|
|
Channel: delivery.ChannelTelegram,
|
|
Kind: delivery.KindNudge,
|
|
Severity: sev,
|
|
RuleName: "service_down",
|
|
Body: "the backup service on homesrv is down - check journalctl",
|
|
Summary: summary,
|
|
Ts: time.Now(),
|
|
}
|
|
}
|
|
|
|
func reminderSendable(summary string) delivery.Sendable {
|
|
return delivery.Sendable{
|
|
Channel: delivery.ChannelTelegram,
|
|
Kind: delivery.KindReminder,
|
|
ReminderID: 42,
|
|
Body: "full reminder body with detail",
|
|
Summary: summary,
|
|
Ts: time.Now(),
|
|
}
|
|
}
|
|
|
|
// sinkCfg — convenience for tests: BaseURL + minimal required fields.
|
|
func sinkCfg(baseURL string) Config {
|
|
return Config{BaseURL: baseURL, BotToken: "123:abc", ChatID: "42"}
|
|
}
|
|
|
|
// ----------------------------- config ---------------------------------------
|
|
|
|
func TestNewRejectsEmptyBotToken(t *testing.T) {
|
|
_, err := New(Config{ChatID: "42"})
|
|
if err == nil {
|
|
t.Fatal("want error for empty BotToken")
|
|
}
|
|
}
|
|
|
|
func TestNewRejectsEmptyChatID(t *testing.T) {
|
|
_, err := New(Config{BotToken: "123:abc"})
|
|
if err == nil {
|
|
t.Fatal("want error for empty ChatID")
|
|
}
|
|
}
|
|
|
|
func TestNewDefaultTimeout(t *testing.T) {
|
|
s, err := New(Config{BotToken: "123:abc", ChatID: "42"})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
if s.hc.Timeout != DefaultTimeout {
|
|
t.Fatalf("default timeout: want %v, got %v", DefaultTimeout, s.hc.Timeout)
|
|
}
|
|
}
|
|
|
|
func TestNewRejectsBadBaseURL(t *testing.T) {
|
|
_, err := New(Config{BotToken: "x", ChatID: "1", BaseURL: "://bad"})
|
|
if err == nil {
|
|
t.Fatal("want error for bad BaseURL")
|
|
}
|
|
}
|
|
|
|
func TestNewRejectsBadProxy(t *testing.T) {
|
|
_, err := New(Config{BotToken: "x", ChatID: "1", Proxy: "://bad"})
|
|
if err == nil {
|
|
t.Fatal("want error for bad Proxy")
|
|
}
|
|
}
|
|
|
|
// ----------------------------- send shape -----------------------------------
|
|
|
|
func TestSendPostsToSendMessagePath(t *testing.T) {
|
|
rs := newRecordingServer(t, 200, "")
|
|
srv := httptest.NewServer(rs.handler())
|
|
defer srv.Close()
|
|
|
|
sink, err := New(sinkCfg(srv.URL))
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
if err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "homesrv down")); err != nil {
|
|
t.Fatalf("Send: %v", err)
|
|
}
|
|
method, path, _, _, _ := rs.snapshot()
|
|
if method != http.MethodPost {
|
|
t.Fatalf("method: want POST, got %s", method)
|
|
}
|
|
if path != "/bot123:abc/sendMessage" {
|
|
t.Fatalf("path: want /bot123:abc/sendMessage, got %s", path)
|
|
}
|
|
}
|
|
|
|
func TestSendBodyIsSummaryNotFullBody(t *testing.T) {
|
|
// minimal-body rule: away channels get Summary, never Body. the sink must
|
|
// post Summary so a phraser bug (Body leaking detail) can't exfil.
|
|
rs := newRecordingServer(t, 200, "")
|
|
srv := httptest.NewServer(rs.handler())
|
|
defer srv.Close()
|
|
|
|
sink, _ := New(sinkCfg(srv.URL))
|
|
if err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "homesrv down")); err != nil {
|
|
t.Fatalf("Send: %v", err)
|
|
}
|
|
_, _, body, _, _ := rs.snapshot()
|
|
var req sendMessageReq
|
|
if err := json.Unmarshal([]byte(body), &req); err != nil {
|
|
t.Fatalf("unmarshal body: %v (raw=%q)", err, body)
|
|
}
|
|
if req.Text != "homesrv down" {
|
|
t.Fatalf("text: want summary 'homesrv down', got %q", req.Text)
|
|
}
|
|
}
|
|
|
|
func TestSendNeverSendsTheBodyWhenSummaryEmpty(t *testing.T) {
|
|
// #368: this used to fall back to the full body. telegram leaves the box,
|
|
// so an empty summary gets a fixed generic line plus the rule name.
|
|
rs := newRecordingServer(t, 200, "")
|
|
srv := httptest.NewServer(rs.handler())
|
|
defer srv.Close()
|
|
|
|
s := nudgeSendable(loop.Sev4, "")
|
|
sink, _ := New(sinkCfg(srv.URL))
|
|
if err := sink.Send(context.Background(), s); err != nil {
|
|
t.Fatalf("Send: %v", err)
|
|
}
|
|
_, _, body, _, _ := rs.snapshot()
|
|
var req sendMessageReq
|
|
_ = json.Unmarshal([]byte(body), &req)
|
|
want := delivery.GenericAwayMessage + ": service_down"
|
|
if req.Text != want {
|
|
t.Fatalf("text: want %q, got %q", want, req.Text)
|
|
}
|
|
}
|
|
|
|
func TestSendNeverSendsAnEmptyMessage(t *testing.T) {
|
|
// with nothing at all to say we still send the generic line.
|
|
rs := newRecordingServer(t, 200, "")
|
|
srv := httptest.NewServer(rs.handler())
|
|
defer srv.Close()
|
|
|
|
sink, _ := New(sinkCfg(srv.URL))
|
|
s := nudgeSendable(loop.Sev4, "")
|
|
s.Body = ""
|
|
s.RuleName = ""
|
|
if err := sink.Send(context.Background(), s); err != nil {
|
|
t.Fatalf("Send: %v", err)
|
|
}
|
|
_, _, body, _, _ := rs.snapshot()
|
|
var req sendMessageReq
|
|
_ = json.Unmarshal([]byte(body), &req)
|
|
if req.Text != delivery.GenericAwayMessage {
|
|
t.Fatalf("text: want %q, got %q", delivery.GenericAwayMessage, req.Text)
|
|
}
|
|
}
|
|
|
|
func TestSendSetsChatIDAndProtectContent(t *testing.T) {
|
|
rs := newRecordingServer(t, 200, "")
|
|
srv := httptest.NewServer(rs.handler())
|
|
defer srv.Close()
|
|
|
|
cfg := sinkCfg(srv.URL)
|
|
cfg.ChatID = "@maven_alerts"
|
|
sink, _ := New(cfg)
|
|
if err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")); err != nil {
|
|
t.Fatalf("Send: %v", err)
|
|
}
|
|
_, _, body, _, _ := rs.snapshot()
|
|
var req sendMessageReq
|
|
if err := json.Unmarshal([]byte(body), &req); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if req.ChatID != "@maven_alerts" {
|
|
t.Fatalf("chat_id: want @maven_alerts, got %q", req.ChatID)
|
|
}
|
|
if !req.ProtectContent {
|
|
t.Fatalf("protect_content: want true, got false")
|
|
}
|
|
if req.DisableNotification {
|
|
t.Fatalf("disable_notification: want false (alarms ring), got true")
|
|
}
|
|
}
|
|
|
|
func TestSendContentTypeIsJSON(t *testing.T) {
|
|
rs := newRecordingServer(t, 200, "")
|
|
srv := httptest.NewServer(rs.handler())
|
|
defer srv.Close()
|
|
|
|
sink, _ := New(sinkCfg(srv.URL))
|
|
_ = sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down"))
|
|
_, _, _, ct, _ := rs.snapshot()
|
|
if !strings.Contains(ct, "application/json") {
|
|
t.Fatalf("content-type: want application/json, got %q", ct)
|
|
}
|
|
}
|
|
|
|
func TestSendNoBasicAuthHeader(t *testing.T) {
|
|
// telegram uses the bot token in the URL path, NOT a Basic auth header.
|
|
// a Basic header would be a token-leak surface: proxies log headers, URLs
|
|
// less so.
|
|
rs := newRecordingServer(t, 200, "")
|
|
srv := httptest.NewServer(rs.handler())
|
|
defer srv.Close()
|
|
|
|
sink, _ := New(sinkCfg(srv.URL))
|
|
_ = sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down"))
|
|
_, _, _, _, auth := rs.snapshot()
|
|
if auth != "" {
|
|
t.Fatalf("want no Authorization header (token in URL), got %q", auth)
|
|
}
|
|
}
|
|
|
|
// ----------------------------- error handling -------------------------------
|
|
|
|
func TestSendReturnsErrorOnTelegramError(t *testing.T) {
|
|
// telegram returns non-2xx with JSON {"ok":false,"error_code":401,...} on
|
|
// auth failure. the sink must surface error_code + description.
|
|
rs := newRecordingServer(t, http.StatusUnauthorized, `{"ok":false,"error_code":401,"description":"Unauthorized"}`)
|
|
srv := httptest.NewServer(rs.handler())
|
|
defer srv.Close()
|
|
|
|
sink, _ := New(sinkCfg(srv.URL))
|
|
err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down"))
|
|
if err == nil {
|
|
t.Fatal("want error on telegram 401")
|
|
}
|
|
if !strings.Contains(err.Error(), "401") {
|
|
t.Fatalf("error should mention error_code 401, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// A relay that is up but cannot reach api.telegram.org answers 200 with a page
|
|
// of its own. That is not a delivered message, and calling it one silences a
|
|
// sev4 alarm for a full repeat interval.
|
|
func TestSendRefusesA200ThatIsNotTheBotAPIEnvelope(t *testing.T) {
|
|
rs := newRecordingServer(t, http.StatusOK, `<html><body>proxy: upstream unreachable</body></html>`)
|
|
srv := httptest.NewServer(rs.handler())
|
|
defer srv.Close()
|
|
|
|
sink, _ := New(sinkCfg(srv.URL))
|
|
err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down"))
|
|
if err == nil {
|
|
t.Fatal("want error on a 200 that is not the bot API envelope")
|
|
}
|
|
if !strings.Contains(err.Error(), "not a confirmed send") {
|
|
t.Fatalf("error should say the send is unconfirmed, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// The success path must stay a success: ok=true on 200 is a send.
|
|
func TestSendAcceptsOkTrue(t *testing.T) {
|
|
rs := newRecordingServer(t, http.StatusOK, `{"ok":true,"result":{"message_id":7}}`)
|
|
srv := httptest.NewServer(rs.handler())
|
|
defer srv.Close()
|
|
|
|
sink, _ := New(sinkCfg(srv.URL))
|
|
if err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")); err != nil {
|
|
t.Fatalf("want success on ok=true, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// A body long enough to have been truncated by the old 4096-byte cap still
|
|
// parses, so a real send is not reported as a failure.
|
|
func TestSendAcceptsAnOversizedButValidEnvelope(t *testing.T) {
|
|
rs := newRecordingServer(t, http.StatusOK,
|
|
`{"ok":true,"result":{"message_id":7,"text":"`+strings.Repeat("x", 8000)+`"}}`)
|
|
srv := httptest.NewServer(rs.handler())
|
|
defer srv.Close()
|
|
|
|
sink, _ := New(sinkCfg(srv.URL))
|
|
if err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")); err != nil {
|
|
t.Fatalf("want success on a large ok=true envelope, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// The error line carries a snippet, not the relay's whole page.
|
|
func TestSendErrorDoesNotCarryTheWholeBody(t *testing.T) {
|
|
rs := newRecordingServer(t, http.StatusBadGateway, strings.Repeat("z", 5000))
|
|
srv := httptest.NewServer(rs.handler())
|
|
defer srv.Close()
|
|
|
|
sink, _ := New(sinkCfg(srv.URL))
|
|
err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down"))
|
|
if err == nil {
|
|
t.Fatal("want error on 502")
|
|
}
|
|
if len(err.Error()) > 500 {
|
|
t.Fatalf("error line should be trimmed, got %d bytes", len(err.Error()))
|
|
}
|
|
}
|
|
|
|
func TestSendReturnsErrorOnNon2xx(t *testing.T) {
|
|
rs := newRecordingServer(t, http.StatusBadGateway, "bad gateway")
|
|
srv := httptest.NewServer(rs.handler())
|
|
defer srv.Close()
|
|
|
|
sink, _ := New(sinkCfg(srv.URL))
|
|
err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down"))
|
|
if err == nil {
|
|
t.Fatal("want error on 502")
|
|
}
|
|
}
|
|
|
|
func TestSendContextCancelReturnsError(t *testing.T) {
|
|
rs := newRecordingServer(t, 200, "")
|
|
srv := httptest.NewServer(rs.handler())
|
|
defer srv.Close()
|
|
|
|
sink, _ := New(sinkCfg(srv.URL))
|
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
|
|
defer cancel()
|
|
err := sink.Send(ctx, nudgeSendable(loop.Sev4, "down"))
|
|
if err == nil {
|
|
t.Fatal("want error on canceled context")
|
|
}
|
|
}
|
|
|
|
func TestSendConnectionRefusedReturnsError(t *testing.T) {
|
|
cfg := sinkCfg("http://127.0.0.1:1")
|
|
cfg.Timeout = time.Second
|
|
sink, _ := New(cfg)
|
|
err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down"))
|
|
if err == nil {
|
|
t.Fatal("want error on connection refused")
|
|
}
|
|
}
|
|
|
|
// --------------------------- token redaction --------------------------------
|
|
|
|
// realToken — shaped like a real BotFather token, unlike sinkCfg's "123:abc".
|
|
// The redaction tests need something long and distinctive enough that finding
|
|
// it in an error message is unambiguous.
|
|
const realToken = "7556767480:AAFh0vLU9sg8l7DwXU9y-VZQquSJKW3lsVQ"
|
|
|
|
// A transport error renders the whole request URL, and telegram accepts the
|
|
// token nowhere but the URL path. On 2026-08-01 that put the live bot token in
|
|
// `docker compose logs mavend` once a minute while egress was down.
|
|
func TestSendTransportErrorRedactsToken(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
run func(*Sink) error
|
|
}{
|
|
{"connection refused", func(s *Sink) error {
|
|
return s.Send(context.Background(), nudgeSendable(loop.Sev4, "down"))
|
|
}},
|
|
{"context cancel", func(s *Sink) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
|
|
defer cancel()
|
|
return s.Send(ctx, nudgeSendable(loop.Sev4, "down"))
|
|
}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cfg := sinkCfg("http://127.0.0.1:1")
|
|
cfg.BotToken = realToken
|
|
cfg.Timeout = time.Second
|
|
sink, _ := New(cfg)
|
|
|
|
err := tc.run(sink)
|
|
if err == nil {
|
|
t.Fatal("want a transport error")
|
|
}
|
|
if strings.Contains(err.Error(), realToken) {
|
|
t.Fatalf("token leaked into error: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), tokenPlaceholder) {
|
|
t.Fatalf("want %q in the redacted error, got: %v", tokenPlaceholder, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The structural branch keeps the error's type so errors.As still matches.
|
|
func TestRedactPreservesURLErrorType(t *testing.T) {
|
|
cfg := sinkCfg("http://127.0.0.1:1")
|
|
cfg.BotToken = realToken
|
|
cfg.Timeout = time.Second
|
|
sink, _ := New(cfg)
|
|
|
|
err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down"))
|
|
var ue *url.Error
|
|
if !errors.As(err, &ue) {
|
|
t.Fatalf("want *url.Error to survive redaction, got %T: %v", err, err)
|
|
}
|
|
if strings.Contains(ue.URL, realToken) {
|
|
t.Fatalf("token left in url.Error.URL: %s", ue.URL)
|
|
}
|
|
}
|
|
|
|
// Nothing to redact must not disturb the error.
|
|
func TestRedactLeavesCleanErrorsAlone(t *testing.T) {
|
|
sink, _ := New(sinkCfg("http://127.0.0.1:1"))
|
|
in := errors.New("dial tcp: no route to host")
|
|
if got := sink.redact(in); got != in {
|
|
t.Fatalf("want the same error back, got %v", got)
|
|
}
|
|
if sink.redact(nil) != nil {
|
|
t.Fatal("want nil for nil")
|
|
}
|
|
}
|
|
|
|
// ----------------------------- proxy seam -----------------------------------
|
|
|
|
func TestProxyWiredIntoTransport(t *testing.T) {
|
|
// region restriction: homesrv can't reach api.telegram.org directly.
|
|
// the proxy URL configured must land on the http.Transport.Proxy so the
|
|
// stdlib dials the relay first. this is the only thing the sink needs to
|
|
// do for the region cut — Transport.Proxy handles HTTP/HTTPS/SOCKS5.
|
|
sink, err := New(Config{
|
|
BotToken: "123:abc",
|
|
ChatID: "42",
|
|
Proxy: "socks5://127.0.0.1:1080",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
tr, ok := sink.hc.Transport.(*http.Transport)
|
|
if !ok {
|
|
t.Fatalf("transport: want *http.Transport, got %T", sink.hc.Transport)
|
|
}
|
|
if tr.Proxy == nil {
|
|
t.Fatal("transport.Proxy not set")
|
|
}
|
|
pu, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "api.telegram.org"}})
|
|
if err != nil {
|
|
t.Fatalf("proxy fn: %v", err)
|
|
}
|
|
if pu == nil || pu.Host != "127.0.0.1:1080" || pu.Scheme != "socks5" {
|
|
t.Fatalf("proxy url: want socks5://127.0.0.1:1080, got %v", pu)
|
|
}
|
|
}
|
|
|
|
func TestSendRoutesThroughProxyWhenConfigured(t *testing.T) {
|
|
// end-to-end: a fake proxy (httptest) records that the transport routes
|
|
// through it. the fake telegram API is rigged to t.Fatalf if reached
|
|
// directly — proving the proxy actually carried the request, not just that
|
|
// Transport.Proxy is set (covered by the previous test).
|
|
proxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(200)
|
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
|
}))
|
|
defer proxySrv.Close()
|
|
|
|
telegramSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatalf("direct telegram API reached — proxy was bypassed")
|
|
}))
|
|
defer telegramSrv.Close()
|
|
|
|
cfg := sinkCfg(telegramSrv.URL)
|
|
cfg.Proxy = proxySrv.URL
|
|
sink, err := New(cfg)
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
if err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")); err != nil {
|
|
t.Fatalf("Send: %v", err)
|
|
}
|
|
// reaching here means the proxy carried the request; the direct telegram
|
|
// endpoint's handler never fired.
|
|
}
|
|
|
|
// ----------------------------- reminder same shape --------------------------
|
|
|
|
func TestReminderSendUsesSamePath(t *testing.T) {
|
|
// reminders away route to ntfy, not telegram — but if the daemon ever
|
|
// routes a reminder via telegram (per-reminder override), the sink must
|
|
// accept KindReminder undamaged. exercises the kind-agnostic contract.
|
|
rs := newRecordingServer(t, 200, "")
|
|
srv := httptest.NewServer(rs.handler())
|
|
defer srv.Close()
|
|
|
|
sink, _ := New(sinkCfg(srv.URL))
|
|
if err := sink.Send(context.Background(), reminderSendable("wake up")); err != nil {
|
|
t.Fatalf("Send: %v", err)
|
|
}
|
|
_, _, body, _, _ := rs.snapshot()
|
|
var req sendMessageReq
|
|
_ = json.Unmarshal([]byte(body), &req)
|
|
if req.Text != "wake up" {
|
|
t.Fatalf("reminder text: want 'wake up', got %q", req.Text)
|
|
}
|
|
}
|