Files
silo-server/internal/discord/client_test.go
QuickandClaude Fable 5 5f05374a1d feat(notifications): Discord bot DM channel with account linking
Adds Discord direct messages as a notification channel. Users link their
Discord account via OAuth2 (identify scope only, one-time server-side
state rows); a bot delivers their inbox notifications as DMs.

- Extract the email channel's watermark sweep into a generic
  account-channel engine; email and Discord are now thin adapters, so
  the SKIP LOCKED claim / watermark-after-send durability logic exists
  once.
- New internal/discord REST client (token exchange, identity, open DM,
  send message) — no Gateway connection, no new dependencies.
- Opt-in master switch (notifications.discord_enabled, default off)
  gates delivery, linking, capability, and the admin settings reveal.
- Admin UI: credentials (secret + bot token encrypted at rest), dev
  portal setup checklist, bot invite link buttons, and a test button
  that bypasses the settings read cache and is disabled while
  credential edits are unsaved.
- DM failures from missing shared guild (Discord 50007) surface as link
  health in user settings and self-heal via capped backoff.
- New combined mode (per_episode_and_digest) for email and Discord:
  instant sends all day plus a daily digest recapping the whole window
  since the previous digest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:51:03 -04:00

137 lines
4.2 KiB
Go

package discord
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"golang.org/x/time/rate"
)
func testClient(t *testing.T, handler http.Handler) *Client {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
return &Client{
httpClient: server.Client(),
apiBase: server.URL,
limiter: rate.NewLimiter(rate.Inf, 1),
}
}
func TestExchangeCodeAndGetUser(t *testing.T) {
client := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/oauth2/token":
user, pass, ok := r.BasicAuth()
if !ok || user != "client-id" || pass != "client-secret" {
t.Errorf("missing or wrong basic auth: %s/%s", user, pass)
}
if err := r.ParseForm(); err != nil {
t.Fatal(err)
}
if r.PostForm.Get("grant_type") != "authorization_code" || r.PostForm.Get("code") != "the-code" {
t.Errorf("unexpected form: %v", r.PostForm)
}
_ = json.NewEncoder(w).Encode(map[string]string{"access_token": "user-token"})
case "/users/@me":
if got := r.Header.Get("Authorization"); got != "Bearer user-token" {
t.Errorf("unexpected authorization %q", got)
}
_ = json.NewEncoder(w).Encode(User{ID: "42", Username: "quick"})
default:
t.Errorf("unexpected path %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
token, err := client.ExchangeCode(context.Background(), "client-id", "client-secret", "the-code", "https://silo.example/cb")
if err != nil {
t.Fatalf("exchange: %v", err)
}
user, err := client.GetUser(context.Background(), token)
if err != nil {
t.Fatalf("get user: %v", err)
}
if user.ID != "42" || user.Username != "quick" {
t.Fatalf("unexpected user %+v", user)
}
}
func TestOpenDMChannelAndSendDM(t *testing.T) {
client := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/users/@me/channels":
if got := r.Header.Get("Authorization"); got != "Bot bot-token" {
t.Errorf("unexpected authorization %q", got)
}
var body map[string]string
_ = json.NewDecoder(r.Body).Decode(&body)
if body["recipient_id"] != "42" {
t.Errorf("unexpected recipient %q", body["recipient_id"])
}
_ = json.NewEncoder(w).Encode(map[string]string{"id": "dm-123"})
case "/channels/dm-123/messages":
w.WriteHeader(http.StatusOK)
default:
t.Errorf("unexpected path %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
channelID, err := client.OpenDMChannel(context.Background(), "bot-token", "42")
if err != nil {
t.Fatalf("open dm: %v", err)
}
if channelID != "dm-123" {
t.Fatalf("unexpected channel id %q", channelID)
}
if err := client.SendDM(context.Background(), "bot-token", channelID, []byte(`{"content":"hi"}`)); err != nil {
t.Fatalf("send dm: %v", err)
}
}
func TestErrorMapping(t *testing.T) {
cases := []struct {
name string
status int
body string
want error
}{
{"dm blocked", http.StatusForbidden, `{"code":50007,"message":"Cannot send messages to this user"}`, ErrDMBlocked},
{"bad token", http.StatusUnauthorized, `{"message":"401: Unauthorized"}`, ErrUnauthorized},
{"rate limited", http.StatusTooManyRequests, `{"message":"You are being rate limited."}`, ErrRateLimited},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
client := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(tc.status)
_, _ = w.Write([]byte(tc.body))
}))
err := client.SendDM(context.Background(), "bot-token", "dm-123", []byte(`{}`))
if !errors.Is(err, tc.want) {
t.Fatalf("got %v, want %v", err, tc.want)
}
})
}
}
func TestLimiterHonorsContextCancel(t *testing.T) {
client := testClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// Exhausted limiter with a long refill forces Wait to block on the context.
client.limiter = rate.NewLimiter(rate.Every(time.Hour), 1)
client.limiter.Allow()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
if err := client.SendDM(ctx, "bot-token", "dm-123", []byte(`{}`)); err == nil {
t.Fatal("expected context error from limiter wait")
}
}