Phase 2c — testing infrastructure (43 tests, CI gating, docker-compose)
Some checks failed
Docker Build / build-and-push-image (push) Has been cancelled
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.
This commit is contained in:
200
helpers_test.go
Normal file
200
helpers_test.go
Normal file
@@ -0,0 +1,200 @@
|
||||
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() }
|
||||
Reference in New Issue
Block a user