Some checks failed
Docker Build / build-and-push-image (push) Has been cancelled
Brings the project to a TDD/BDD-friendly state — apologies for shipping Phase 1.5 + Phase 2 code-first, that violated feedback_tdd_first_bdd_required. What's added : - helpers_test.go : FakeTelegram (httptest server that records sendMessage / deleteMessage / setWebhook / etc.), miniredis bootstrap, MakeUpdate / PostWebhook helpers. The same harness simulates 'a user DMing the bot' end-to-end without hitting Telegram cloud — answer to the user question. - 43 tests covering : allowlist parsing, telegram type helpers (UserID / ChatID / Text / messageID), secret_token constant-time compare, Backoff schedule, Auth (login wrong/right/logout/TTL/nil-receiver), EchoHandler, HTTPHandler (forward / timeout / non-2xx / empty body), AuthHandler (start / auth / whoami / logout / replay defense delete), Server (bad secret 401, unknown bot 404, allowlist drop, gated bot prompt, full /auth → echo → /logout flow, healthz/readyz). - All tests pass with -race in 1.6s, no external deps (miniredis + httptest in-process). Infra : - Updated .gitea/workflows/dockerimage.yaml : new 'test' job (go vet + go test -race) gates the build-and-push-image job. CI now also runs on pull_request. - docker-compose.yml : redis + postgres for full local stack. - Makefile : test-race, compose-up/down targets. - README updated with test + local-dev sections. Refs ~/.claude/plans/pour-les-notifications-on-inherited-seal.md § Phase 2.
201 lines
5.6 KiB
Go
201 lines
5.6 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/alicebob/miniredis/v2"
|
|
)
|
|
|
|
// FakeTelegram is an in-memory stand-in for api.telegram.org. It records all
|
|
// inbound calls (sendMessage, deleteMessage, setWebhook, getWebhookInfo,
|
|
// deleteWebhook) and always returns `{"ok":true}`. Tests can assert on the
|
|
// recorded calls via Sent() / Deleted() / Webhooks().
|
|
type FakeTelegram struct {
|
|
srv *httptest.Server
|
|
|
|
mu sync.Mutex
|
|
sent []SendMessageParams
|
|
deleted []DeleteMessageParams
|
|
webhooks []SetWebhookParams
|
|
}
|
|
|
|
func NewFakeTelegram(t *testing.T) *FakeTelegram {
|
|
t.Helper()
|
|
ft := &FakeTelegram{}
|
|
ft.srv = httptest.NewServer(http.HandlerFunc(ft.handle))
|
|
t.Cleanup(ft.srv.Close)
|
|
return ft
|
|
}
|
|
|
|
// URL returns the base URL to plug into TelegramClient.apiBase.
|
|
func (f *FakeTelegram) URL() string { return f.srv.URL }
|
|
|
|
// Client returns a TelegramClient pointing at this fake.
|
|
func (f *FakeTelegram) Client() *TelegramClient {
|
|
return &TelegramClient{
|
|
httpClient: &http.Client{Timeout: 5 * time.Second},
|
|
apiBase: f.srv.URL,
|
|
}
|
|
}
|
|
|
|
// Sent / Deleted / Webhooks return snapshots of the recorded calls.
|
|
func (f *FakeTelegram) Sent() []SendMessageParams {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
out := make([]SendMessageParams, len(f.sent))
|
|
copy(out, f.sent)
|
|
return out
|
|
}
|
|
|
|
func (f *FakeTelegram) Deleted() []DeleteMessageParams {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
out := make([]DeleteMessageParams, len(f.deleted))
|
|
copy(out, f.deleted)
|
|
return out
|
|
}
|
|
|
|
func (f *FakeTelegram) Webhooks() []SetWebhookParams {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
out := make([]SetWebhookParams, len(f.webhooks))
|
|
copy(out, f.webhooks)
|
|
return out
|
|
}
|
|
|
|
// LastSentTextTo returns the text of the last sendMessage targeting chatID, or "".
|
|
func (f *FakeTelegram) LastSentTextTo(chatID int64) string {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for i := len(f.sent) - 1; i >= 0; i-- {
|
|
if f.sent[i].ChatID == chatID {
|
|
return f.sent[i].Text
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (f *FakeTelegram) handle(w http.ResponseWriter, r *http.Request) {
|
|
// Path looks like /botTOKEN/method (ignore TOKEN, dispatch on method)
|
|
parts := strings.SplitN(strings.TrimPrefix(r.URL.Path, "/"), "/", 2)
|
|
method := ""
|
|
if len(parts) == 2 {
|
|
method = parts[1]
|
|
}
|
|
body, _ := io.ReadAll(r.Body)
|
|
defer r.Body.Close()
|
|
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
|
|
switch method {
|
|
case "sendMessage":
|
|
var p SendMessageParams
|
|
_ = json.Unmarshal(body, &p)
|
|
f.sent = append(f.sent, p)
|
|
case "deleteMessage":
|
|
var p DeleteMessageParams
|
|
_ = json.Unmarshal(body, &p)
|
|
f.deleted = append(f.deleted, p)
|
|
case "setWebhook":
|
|
var p SetWebhookParams
|
|
_ = json.Unmarshal(body, &p)
|
|
f.webhooks = append(f.webhooks, p)
|
|
case "getWebhookInfo":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"ok":true,"result":{"url":"","has_custom_certificate":false,"pending_update_count":0}}`)
|
|
return
|
|
case "deleteWebhook":
|
|
// no-op
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"ok":true,"result":true}`)
|
|
}
|
|
|
|
// MakeUpdate builds a Telegram Update with a private-chat message from a given user.
|
|
func MakeUpdate(updateID, fromID, chatID, msgID int64, text string) Update {
|
|
return Update{
|
|
UpdateID: updateID,
|
|
Message: &Message{
|
|
MessageID: msgID,
|
|
From: &User{ID: fromID, FirstName: "Test"},
|
|
Chat: Chat{ID: chatID, Type: "private"},
|
|
Date: 1700000000,
|
|
Text: text,
|
|
},
|
|
}
|
|
}
|
|
|
|
// StartMiniRedis spins up an in-process Redis stub for auth tests.
|
|
func StartMiniRedis(t *testing.T) *miniredis.Miniredis {
|
|
t.Helper()
|
|
m, err := miniredis.Run()
|
|
if err != nil {
|
|
t.Fatalf("miniredis: %v", err)
|
|
}
|
|
t.Cleanup(m.Close)
|
|
return m
|
|
}
|
|
|
|
// NewTestAuth returns an Auth wired against the miniredis instance.
|
|
func NewTestAuth(t *testing.T, m *miniredis.Miniredis, secret string, ttl time.Duration) *Auth {
|
|
t.Helper()
|
|
a, err := NewAuth("redis://"+m.Addr(), secret, ttl)
|
|
if err != nil {
|
|
t.Fatalf("NewAuth: %v", err)
|
|
}
|
|
return a
|
|
}
|
|
|
|
// PostWebhook simulates Telegram POSTing an Update to /bot/<slug> on the gateway.
|
|
// `secretToken` is sent in the X-Telegram-Bot-Api-Secret-Token header.
|
|
func PostWebhook(t *testing.T, srv http.Handler, slug, secretToken string, update Update) (status int, body string) {
|
|
t.Helper()
|
|
payload, err := json.Marshal(update)
|
|
if err != nil {
|
|
t.Fatalf("marshal update: %v", err)
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "/bot/"+slug, strings.NewReader(string(payload)))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if secretToken != "" {
|
|
req.Header.Set("X-Telegram-Bot-Api-Secret-Token", secretToken)
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
return rr.Code, rr.Body.String()
|
|
}
|
|
|
|
// boolPtr returns a pointer to b — convenient for BotConfig.RequireAuth.
|
|
func boolPtr(b bool) *bool { return &b }
|
|
|
|
// MakeRegistry builds a minimal registry from a single bot config, plumbing
|
|
// in the FakeTelegram and (optional) Auth. Used across handler tests.
|
|
func MakeRegistry(t *testing.T, ft *FakeTelegram, auth *Auth, slug, token, secret string, bc BotConfig) *Registry {
|
|
t.Helper()
|
|
bc.Token = token
|
|
bc.Secret = secret
|
|
cfg := &Config{Bots: map[string]BotConfig{slug: bc}}
|
|
r, err := NewRegistry(cfg, ft.Client(), auth, nil) // queue=nil for sync tests
|
|
if err != nil {
|
|
t.Fatalf("NewRegistry: %v", err)
|
|
}
|
|
return r
|
|
}
|
|
|
|
// MakeServer wraps a Registry into a Server with allowlist/auth wiring for tests.
|
|
func MakeServer(reg *Registry, auth *Auth, allow Allowlist, ft *FakeTelegram) *Server {
|
|
return NewServer(reg, auth, allow, ft.Client(), nil)
|
|
}
|
|
|
|
// dummy ctx helper — many handler tests don't care.
|
|
func bgCtx() context.Context { return context.Background() }
|