35c6ff5a71
Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
559 lines
17 KiB
Go
559 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 TestValidateAllowsEmptySecretsOnlyWhenDisabled(t *testing.T) {
|
|
cfg := Config{Disabled: true}
|
|
if err := Validate(cfg); err != nil {
|
|
t.Fatalf("Validate disabled config: %v", err)
|
|
}
|
|
if _, err := New(cfg); err == nil {
|
|
t.Fatal("New built a live sink from a disabled config")
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
if !errors.Is(err, delivery.ErrPermanent) {
|
|
t.Fatalf("revoked bot credential must be permanent, 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) {
|
|
// Telegram is the second away alternative for reminders. If ntfy is
|
|
// unavailable and the dispatcher falls through, 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)
|
|
}
|
|
}
|