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:
120
handler_http_test.go
Normal file
120
handler_http_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHTTPHandler_ForwardAndReply(t *testing.T) {
|
||||
ft := NewFakeTelegram(t)
|
||||
|
||||
var receivedBody []byte
|
||||
var receivedSlug string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedBody, _ = io.ReadAll(r.Body)
|
||||
receivedSlug = r.Header.Get("X-Bot-Slug")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprint(w, `{"text":"pong"}`)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
h, err := NewHTTPHandler(ft.Client(), HTTPConfig{URL: upstream.URL, Timeout: 2 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("NewHTTPHandler: %v", err)
|
||||
}
|
||||
bot := Bot{Slug: "ping", Token: "t1", Secret: "s"}
|
||||
|
||||
upd := MakeUpdate(1, 100, 42, 1, "ping")
|
||||
if err := h.Handle(bgCtx(), upd, bot); err != nil {
|
||||
t.Fatalf("handle: %v", err)
|
||||
}
|
||||
|
||||
// Upstream got the Update verbatim
|
||||
var got Update
|
||||
if err := json.Unmarshal(receivedBody, &got); err != nil {
|
||||
t.Fatalf("upstream body decode: %v", err)
|
||||
}
|
||||
if got.UpdateID != 1 || got.Message.Text != "ping" {
|
||||
t.Fatalf("upstream body wrong: %+v", got)
|
||||
}
|
||||
if receivedSlug != "ping" {
|
||||
t.Fatalf("X-Bot-Slug = %q, want ping", receivedSlug)
|
||||
}
|
||||
|
||||
// Telegram sendMessage was called with the upstream's text
|
||||
sent := ft.Sent()
|
||||
if len(sent) != 1 || sent[0].Text != "pong" || sent[0].ChatID != 42 {
|
||||
t.Fatalf("sendMessage wrong: %+v", sent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPHandler_EmptyBody_NoSend(t *testing.T) {
|
||||
ft := NewFakeTelegram(t)
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
h, _ := NewHTTPHandler(ft.Client(), HTTPConfig{URL: upstream.URL, Timeout: time.Second})
|
||||
bot := Bot{Slug: "x", Token: "t", Secret: "s"}
|
||||
|
||||
if err := h.Handle(bgCtx(), MakeUpdate(1, 1, 1, 1, "hi"), bot); err != nil {
|
||||
t.Fatalf("handle: %v", err)
|
||||
}
|
||||
if len(ft.Sent()) != 0 {
|
||||
t.Fatalf("empty upstream body should not trigger sendMessage")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPHandler_Non2xx_ReturnsError(t *testing.T) {
|
||||
ft := NewFakeTelegram(t)
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = fmt.Fprint(w, "boom")
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
h, _ := NewHTTPHandler(ft.Client(), HTTPConfig{URL: upstream.URL, Timeout: time.Second})
|
||||
bot := Bot{Slug: "x", Token: "t", Secret: "s"}
|
||||
|
||||
err := h.Handle(bgCtx(), MakeUpdate(1, 1, 1, 1, "hi"), bot)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on non-2xx upstream")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPHandler_Timeout(t *testing.T) {
|
||||
ft := NewFakeTelegram(t)
|
||||
var calls int32
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprint(w, `{"text":"too late"}`)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
h, _ := NewHTTPHandler(ft.Client(), HTTPConfig{URL: upstream.URL, Timeout: 50 * time.Millisecond})
|
||||
bot := Bot{Slug: "x", Token: "t", Secret: "s"}
|
||||
|
||||
err := h.Handle(bgCtx(), MakeUpdate(1, 1, 1, 1, "hi"), bot)
|
||||
if err == nil {
|
||||
t.Fatal("expected timeout error")
|
||||
}
|
||||
if atomic.LoadInt32(&calls) == 0 {
|
||||
t.Fatal("upstream should have been called once")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPHandler_RequiresURL(t *testing.T) {
|
||||
ft := NewFakeTelegram(t)
|
||||
if _, err := NewHTTPHandler(ft.Client(), HTTPConfig{URL: ""}); err == nil {
|
||||
t.Fatal("empty URL should fail config validation")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user