Phase 2c — testing infrastructure (43 tests, CI gating, docker-compose)
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:
2026-05-09 15:18:29 +02:00
parent 4f246ccc1d
commit d63f195b3d
16 changed files with 1100 additions and 9 deletions

200
helpers_test.go Normal file
View 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() }