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

View File

@@ -3,7 +3,7 @@ APP := telegram-gateway
IMAGE := gitea.arcodange.lab/arcodange/$(APP) IMAGE := gitea.arcodange.lab/arcodange/$(APP)
TAG ?= dev TAG ?= dev
.PHONY: build test vet tidy run docker push setwebhook deletewebhook .PHONY: build test test-race vet tidy run docker push setwebhook deletewebhook compose-up compose-down
build: build:
go build -o bin/gateway . go build -o bin/gateway .
@@ -11,12 +11,22 @@ build:
test: test:
go test ./... go test ./...
test-race:
go test -race -count=1 -timeout 120s ./...
vet: vet:
go vet ./... go vet ./...
tidy: tidy:
go mod tidy go mod tidy
# Local dev stack — Redis (auth) + Postgres (Phase 2b queue) on localhost.
compose-up:
docker compose up -d --wait
compose-down:
docker compose down -v
run: build run: build
CONFIG_PATH=./bots.example.yaml ./bin/gateway serve CONFIG_PATH=./bots.example.yaml ./bin/gateway serve

View File

@@ -27,21 +27,40 @@ Telegram → Cloudflare Tunnel (tg.arcodange.fr) → Service telegram-gateway:80
## Local dev ## Local dev
Pour le dev local complet (Redis pour l'auth + Postgres pour la queue) :
```bash ```bash
# 1. Provide a config + env make compose-up # docker compose up -d --wait : redis + postgres
export REDIS_URL=redis://localhost:6379/0
export DATABASE_URL=postgres://gateway:gateway@localhost:5432/gateway?sslmode=disable
export AUTH_SECRET=$(openssl rand -hex 16)
export ALLOWED_USERS=<your-tg-user-id>
export BOT_FACTORY_TOKEN='8737289837:…' # from @BotFather export BOT_FACTORY_TOKEN='8737289837:…' # from @BotFather
export BOT_FACTORY_SECRET=$(openssl rand -hex 32) export BOT_FACTORY_SECRET=$(openssl rand -hex 32)
# 2. Run
make run # uses bots.example.yaml make run # uses bots.example.yaml
```
# 3. Smoke a webhook Smoke d'un webhook (sans Telegram cloud) :
```bash
curl -X POST -H "X-Telegram-Bot-Api-Secret-Token: $BOT_FACTORY_SECRET" \ curl -X POST -H "X-Telegram-Bot-Api-Secret-Token: $BOT_FACTORY_SECRET" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"update_id":1,"message":{"chat":{"id":<your-chat-id>},"text":"hi"}}' \ -d '{"update_id":1,"message":{"message_id":1,"from":{"id":<tg-id>},"chat":{"id":<tg-id>},"text":"hi"}}' \
http://localhost:8080/bot/factory http://localhost:8080/bot/factory
``` ```
## Tests
```bash
make test # unit + integration (in-process miniredis + httptest mock Telegram)
make test-race # avec race detector
make vet
```
43 tests couvrent : allowlist parsing, secret comparison, auth flow (login/logout/whoami/replay-defense), echo handler (plain/slash/empty), http handler (forward/timeout/non-2xx/empty), webhook dispatch (bad secret 401, unknown bot 404, allowlist drop, gated bot prompt, full /auth → echo flow).
Pour des smokes locaux contre une vraie API Telegram, voir la section "Local dev" ci-dessus.
## Set / delete webhook ## Set / delete webhook
```bash ```bash

68
allowlist_test.go Normal file
View File

@@ -0,0 +1,68 @@
package main
import "testing"
func TestAllowlist_Empty_OpenToAll(t *testing.T) {
a := NewAllowlist("")
if !a.Open() {
t.Fatal("expected open=true on empty raw")
}
if !a.IsAllowed(0) || !a.IsAllowed(123456789) {
t.Fatal("open allowlist should allow any user")
}
}
func TestAllowlist_Whitespace_OpenToAll(t *testing.T) {
a := NewAllowlist(" \t\n ")
if !a.Open() {
t.Fatal("whitespace-only should be treated as empty")
}
}
func TestAllowlist_OneID(t *testing.T) {
a := NewAllowlist("7497777082")
if a.Open() {
t.Fatal("non-empty should not be open")
}
if !a.IsAllowed(7497777082) {
t.Fatal("listed user must be allowed")
}
if a.IsAllowed(999) {
t.Fatal("stranger must be rejected")
}
}
func TestAllowlist_MultiCSVWithSpaces(t *testing.T) {
a := NewAllowlist(" 12345 , 67890 , 42 ")
if a.Size() != 3 {
t.Fatalf("expected 3, got %d", a.Size())
}
for _, id := range []int64{12345, 67890, 42} {
if !a.IsAllowed(id) {
t.Fatalf("id=%d should be allowed", id)
}
}
if a.IsAllowed(999) {
t.Fatal("999 must be rejected")
}
}
func TestAllowlist_InvalidEntriesIgnored(t *testing.T) {
a := NewAllowlist("123,abc,,456,not-a-number")
if a.Size() != 2 {
t.Fatalf("expected 2 valid ids, got %d", a.Size())
}
if !a.IsAllowed(123) || !a.IsAllowed(456) {
t.Fatal("valid ids should pass")
}
}
func TestAllowlist_AllInvalid_ClosedNotOpen(t *testing.T) {
a := NewAllowlist("abc,xyz")
if a.Open() {
t.Fatal("CSV with no valid ids should be closed (fail-safe), not open")
}
if a.IsAllowed(123) {
t.Fatal("must reject everyone when no valid ids")
}
}

82
auth_test.go Normal file
View File

@@ -0,0 +1,82 @@
package main
import (
"context"
"testing"
"time"
)
func TestAuth_LoginWrongCode(t *testing.T) {
m := StartMiniRedis(t)
a := NewTestAuth(t, m, "rightcode", time.Hour)
ok, err := a.Login(context.Background(), 42, "wrongcode")
if err != nil {
t.Fatalf("login err: %v", err)
}
if ok {
t.Fatal("wrong code should fail")
}
authed, err := a.IsAuthed(context.Background(), 42)
if err != nil || authed {
t.Fatalf("user must NOT be authed after wrong code (authed=%v err=%v)", authed, err)
}
}
func TestAuth_LoginRightCode_SetsTTL(t *testing.T) {
m := StartMiniRedis(t)
a := NewTestAuth(t, m, "rightcode", 30*time.Second)
ok, err := a.Login(context.Background(), 7, "rightcode")
if err != nil || !ok {
t.Fatalf("login: ok=%v err=%v", ok, err)
}
authed, err := a.IsAuthed(context.Background(), 7)
if err != nil || !authed {
t.Fatalf("user must be authed after correct code (authed=%v err=%v)", authed, err)
}
rem, err := a.Remaining(context.Background(), 7)
if err != nil {
t.Fatalf("Remaining err: %v", err)
}
if rem <= 0 || rem > 30*time.Second {
t.Fatalf("Remaining = %s, want (0, 30s]", rem)
}
}
func TestAuth_Logout(t *testing.T) {
m := StartMiniRedis(t)
a := NewTestAuth(t, m, "code", time.Hour)
if _, err := a.Login(context.Background(), 1, "code"); err != nil {
t.Fatal(err)
}
if err := a.Logout(context.Background(), 1); err != nil {
t.Fatalf("logout: %v", err)
}
authed, _ := a.IsAuthed(context.Background(), 1)
if authed {
t.Fatal("must NOT be authed after logout")
}
}
func TestAuth_NewAuth_RequiresSecret(t *testing.T) {
m := StartMiniRedis(t)
if _, err := NewAuth("redis://"+m.Addr(), "", time.Hour); err == nil {
t.Fatal("expected error when AUTH_SECRET is empty")
}
}
func TestAuth_NilReceiver_Safe(t *testing.T) {
var a *Auth
authed, err := a.IsAuthed(context.Background(), 1)
if err != nil || authed {
t.Fatalf("nil Auth.IsAuthed must return (false,nil), got (%v,%v)", authed, err)
}
if err := a.Logout(context.Background(), 1); err != nil {
t.Fatalf("nil Auth.Logout must be no-op, got: %v", err)
}
}

49
docker-compose.yml Normal file
View File

@@ -0,0 +1,49 @@
# Local-dev stack for telegram-gateway. Brings up Redis (Phase 1.5 auth) and
# Postgres (Phase 2b queue) so you can run the gateway locally with the same
# env-var contract as in cluster.
#
# Usage :
#
# docker compose up -d
# export REDIS_URL=redis://localhost:6379/0
# export DATABASE_URL=postgres://gateway:gateway@localhost:5432/gateway?sslmode=disable
# export AUTH_SECRET=$(openssl rand -hex 16)
# export ALLOWED_USERS=<your-tg-user-id>
# export BOT_FACTORY_TOKEN=<botfather-token>
# export BOT_FACTORY_SECRET=$(openssl rand -hex 32)
# make run
#
# Tests don't need this — they use miniredis in-process and (eventually)
# testcontainers-go for Postgres.
services:
redis:
image: redis:8-alpine
container_name: tg-gateway-redis
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
postgres:
image: postgres:16-alpine
container_name: tg-gateway-postgres
environment:
POSTGRES_USER: gateway
POSTGRES_PASSWORD: gateway
POSTGRES_DB: gateway
ports:
- "5432:5432"
volumes:
- tg-gateway-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U gateway -d gateway"]
interval: 5s
timeout: 3s
retries: 5
volumes:
tg-gateway-pgdata:

4
go.mod
View File

@@ -3,6 +3,7 @@ module github.com/arcodange/telegram-gateway
go 1.24 go 1.24
require ( require (
github.com/alicebob/miniredis/v2 v2.37.0
github.com/lib/pq v1.12.3 github.com/lib/pq v1.12.3
github.com/redis/go-redis/v9 v9.19.0 github.com/redis/go-redis/v9 v9.19.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
@@ -10,5 +11,8 @@ require (
require ( require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/stretchr/testify v1.8.2 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
go.uber.org/atomic v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
) )

23
go.sum
View File

@@ -1,28 +1,45 @@
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k= github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k=
github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

117
handler_auth_test.go Normal file
View File

@@ -0,0 +1,117 @@
package main
import (
"context"
"strings"
"testing"
"time"
)
const testAuthCode = "topsecret"
func setupAuthHandler(t *testing.T) (*FakeTelegram, *AuthHandler, Bot) {
t.Helper()
ft := NewFakeTelegram(t)
m := StartMiniRedis(t)
a := NewTestAuth(t, m, testAuthCode, time.Hour)
h := &AuthHandler{tg: ft.Client(), auth: a}
bot := Bot{Slug: "factory", Token: "tok", Secret: "sec"}
return ft, h, bot
}
func TestAuthHandler_StartShowsHelp(t *testing.T) {
ft, h, bot := setupAuthHandler(t)
_ = h.Handle(bgCtx(), MakeUpdate(1, 100, 100, 1, "/start"), bot)
sent := ft.Sent()
if len(sent) != 1 || !strings.Contains(sent[0].Text, "/auth <code>") {
t.Fatalf("/start should welcome + show help, got %+v", sent)
}
}
func TestAuthHandler_AuthMissingCode(t *testing.T) {
ft, h, bot := setupAuthHandler(t)
_ = h.Handle(bgCtx(), MakeUpdate(1, 100, 100, 1, "/auth"), bot)
sent := ft.Sent()
if len(sent) != 1 || !strings.Contains(sent[0].Text, "Usage") {
t.Fatalf("bare /auth should print Usage, got %+v", sent)
}
}
func TestAuthHandler_AuthWrongCode_NoSession(t *testing.T) {
ft, h, bot := setupAuthHandler(t)
_ = h.Handle(bgCtx(), MakeUpdate(1, 100, 100, 1, "/auth wrong"), bot)
sent := ft.Sent()
if len(sent) != 1 || !strings.Contains(sent[0].Text, "Mauvais code") {
t.Fatalf("wrong code should reply error, got %+v", sent)
}
authed, _ := h.auth.IsAuthed(context.Background(), 100)
if authed {
t.Fatal("session must NOT be created on wrong code")
}
}
func TestAuthHandler_AuthRightCode_SessionAndDelete(t *testing.T) {
ft, h, bot := setupAuthHandler(t)
upd := MakeUpdate(1, 100, 100, 42, "/auth "+testAuthCode)
_ = h.Handle(bgCtx(), upd, bot)
sent := ft.Sent()
if len(sent) != 1 || !strings.Contains(sent[0].Text, "Authentifié") {
t.Fatalf("right code should welcome, got %+v", sent)
}
deleted := ft.Deleted()
if len(deleted) != 1 || deleted[0].MessageID != 42 || deleted[0].ChatID != 100 {
t.Fatalf("expected deleteMessage on the /auth message (id=42 chat=100), got %+v", deleted)
}
authed, _ := h.auth.IsAuthed(context.Background(), 100)
if !authed {
t.Fatal("session must exist after right code")
}
}
func TestAuthHandler_Whoami_NotAuthed(t *testing.T) {
ft, h, bot := setupAuthHandler(t)
_ = h.Handle(bgCtx(), MakeUpdate(1, 200, 200, 1, "/whoami"), bot)
sent := ft.Sent()
if len(sent) != 1 || !strings.Contains(sent[0].Text, "non authentifié") {
t.Fatalf("whoami without session should say so, got %+v", sent)
}
}
func TestAuthHandler_Whoami_Authed_ShowsTTL(t *testing.T) {
ft, h, bot := setupAuthHandler(t)
_ = h.Handle(bgCtx(), MakeUpdate(1, 300, 300, 1, "/auth "+testAuthCode), bot)
_ = h.Handle(bgCtx(), MakeUpdate(2, 300, 300, 2, "/whoami"), bot)
sent := ft.Sent()
last := sent[len(sent)-1].Text
if !strings.Contains(last, "authentifié") || !strings.Contains(last, "user=300") {
t.Fatalf("whoami auth answer wrong: %q", last)
}
}
func TestAuthHandler_Logout(t *testing.T) {
ft, h, bot := setupAuthHandler(t)
_ = h.Handle(bgCtx(), MakeUpdate(1, 400, 400, 1, "/auth "+testAuthCode), bot)
authed, _ := h.auth.IsAuthed(context.Background(), 400)
if !authed {
t.Fatal("setup: should be authed")
}
_ = h.Handle(bgCtx(), MakeUpdate(2, 400, 400, 2, "/logout"), bot)
authed, _ = h.auth.IsAuthed(context.Background(), 400)
if authed {
t.Fatal("logout should drop the session")
}
last := ft.Sent()[len(ft.Sent())-1].Text
if !strings.Contains(last, "Déconnecté") {
t.Fatalf("logout reply wrong: %q", last)
}
}
func TestAuthHandler_Default_PrintsHelp(t *testing.T) {
ft, h, bot := setupAuthHandler(t)
_ = h.Handle(bgCtx(), MakeUpdate(1, 500, 500, 1, "salut !"), bot)
last := ft.Sent()[0].Text
if !strings.Contains(last, "Commandes disponibles") {
t.Fatalf("default reply should print help, got %q", last)
}
}

59
handler_echo_test.go Normal file
View File

@@ -0,0 +1,59 @@
package main
import (
"strings"
"testing"
)
func TestEchoHandler_PlainText(t *testing.T) {
ft := NewFakeTelegram(t)
h := &EchoHandler{tg: ft.Client()}
bot := Bot{Slug: "echo", Token: "t1", Secret: "s"}
if err := h.Handle(bgCtx(), MakeUpdate(1, 100, 42, 1, "salut"), bot); err != nil {
t.Fatalf("handle: %v", err)
}
sent := ft.Sent()
if len(sent) != 1 || sent[0].ChatID != 42 || sent[0].Text != "salut" {
t.Fatalf("expected echo of 'salut' to chat 42, got %+v", sent)
}
}
func TestEchoHandler_SlashEcho_StripsCommand(t *testing.T) {
ft := NewFakeTelegram(t)
h := &EchoHandler{tg: ft.Client()}
bot := Bot{Slug: "echo", Token: "t1", Secret: "s"}
if err := h.Handle(bgCtx(), MakeUpdate(2, 100, 42, 1, "/echo coucou monde"), bot); err != nil {
t.Fatalf("handle: %v", err)
}
sent := ft.Sent()
if len(sent) != 1 || sent[0].Text != "coucou monde" {
t.Fatalf("expected reply 'coucou monde', got %q", sent[0].Text)
}
}
func TestEchoHandler_EmptyTextNoSend(t *testing.T) {
ft := NewFakeTelegram(t)
h := &EchoHandler{tg: ft.Client()}
bot := Bot{Slug: "echo", Token: "t1", Secret: "s"}
if err := h.Handle(bgCtx(), MakeUpdate(3, 100, 42, 1, " "), bot); err != nil {
t.Fatalf("handle: %v", err)
}
if len(ft.Sent()) != 0 {
t.Fatalf("empty text should send nothing, got %d", len(ft.Sent()))
}
}
func TestEchoHandler_SlashEchoBare_DefaultMessage(t *testing.T) {
ft := NewFakeTelegram(t)
h := &EchoHandler{tg: ft.Client()}
bot := Bot{Slug: "echo", Token: "t1", Secret: "s"}
_ = h.Handle(bgCtx(), MakeUpdate(4, 100, 42, 1, "/echo"), bot)
sent := ft.Sent()
if len(sent) != 1 || !strings.Contains(sent[0].Text, "echo bot online") {
t.Fatalf("expected default greeting, got %+v", sent)
}
}

120
handler_http_test.go Normal file
View 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")
}
}

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() }

19
http_helpers_test.go Normal file
View File

@@ -0,0 +1,19 @@
package main
import (
"net/http"
"net/http/httptest"
"strings"
)
func httpRequest(method, path, body string) *http.Request {
req := httptest.NewRequest(method, path, strings.NewReader(body))
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
return req
}
func httpRecorder() *httptest.ResponseRecorder {
return httptest.NewRecorder()
}

34
middleware_test.go Normal file
View File

@@ -0,0 +1,34 @@
package main
import "testing"
func TestVerifyTelegramSecret(t *testing.T) {
cases := []struct {
name string
provided, expect string
want bool
}{
{"empty expected refuses anything", "anything", "", false},
{"empty provided refuses", "", "secret", false},
{"match", "topsecret", "topsecret", true},
{"mismatch same length", "topSecret", "topsecret", false},
{"mismatch different length", "topsecret", "topsecretextra", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := verifyTelegramSecret(c.provided, c.expect)
if got != c.want {
t.Errorf("verify(%q,%q) = %v, want %v", c.provided, c.expect, got, c.want)
}
})
}
}
func TestTruncate(t *testing.T) {
if got := truncate("short", 100); got != "short" {
t.Errorf("short string changed: %q", got)
}
if got := truncate("123456789", 5); got != "12345…" {
t.Errorf("long string trunc wrong: %q", got)
}
}

26
queue_test.go Normal file
View File

@@ -0,0 +1,26 @@
package main
import (
"testing"
"time"
)
func TestBackoff_Schedule(t *testing.T) {
cases := []struct {
attempt int
want time.Duration
}{
{1, 30 * time.Second},
{2, 2 * time.Minute},
{3, 10 * time.Minute},
{4, 1 * time.Hour},
{5, 1 * time.Hour},
{99, 1 * time.Hour},
}
for _, c := range cases {
got := Backoff(c.attempt)
if got != c.want {
t.Errorf("Backoff(%d) = %s, want %s", c.attempt, got, c.want)
}
}
}

200
server_test.go Normal file
View File

@@ -0,0 +1,200 @@
package main
import (
"context"
"net/http"
"strings"
"testing"
"time"
)
// e2eHarness wires a full Server (Registry + Auth + Allowlist + FakeTelegram)
// for integration tests covering the webhook → handler dispatch path.
type e2eHarness struct {
ft *FakeTelegram
auth *Auth
server *Server
}
func newE2EHarness(t *testing.T, allowedUsers string) *e2eHarness {
t.Helper()
ft := NewFakeTelegram(t)
m := StartMiniRedis(t)
auth := NewTestAuth(t, m, testAuthCode, time.Hour)
bots := map[string]BotConfig{
"factory": {
Handler: "auth",
Token: "factory-tok",
Secret: "factory-secret",
},
"echobot": {
Handler: "echo",
Token: "echo-tok",
Secret: "echo-secret",
},
"publicbot": {
Handler: "echo",
RequireAuth: boolPtr(false),
Token: "pub-tok",
Secret: "pub-secret",
},
}
cfg := &Config{Bots: bots}
reg, err := NewRegistry(cfg, ft.Client(), auth, nil)
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
allow := NewAllowlist(allowedUsers)
srv := NewServer(reg, auth, allow, ft.Client(), nil)
return &e2eHarness{ft: ft, auth: auth, server: srv}
}
func (h *e2eHarness) routes() http.Handler { return h.server.Routes() }
// authenticate does the /auth <code> dance for the given user.
func (h *e2eHarness) authenticate(t *testing.T, userID int64) {
t.Helper()
ok, err := h.auth.Login(context.Background(), userID, testAuthCode)
if err != nil || !ok {
t.Fatalf("authenticate user %d: ok=%v err=%v", userID, ok, err)
}
}
func TestServer_BadSecretToken_401(t *testing.T) {
h := newE2EHarness(t, "")
upd := MakeUpdate(1, 7497777082, 7497777082, 1, "hi")
status, _ := PostWebhook(t, h.routes(), "echobot", "WRONG", upd)
if status != 401 {
t.Fatalf("status = %d, want 401", status)
}
}
func TestServer_UnknownBot_404(t *testing.T) {
h := newE2EHarness(t, "")
upd := MakeUpdate(1, 7497777082, 7497777082, 1, "hi")
status, _ := PostWebhook(t, h.routes(), "ghost", "anything", upd)
if status != 404 {
t.Fatalf("status = %d, want 404", status)
}
}
func TestServer_AllowlistDrop_SilentNoReply(t *testing.T) {
h := newE2EHarness(t, "7497777082") // only this user is allowed
upd := MakeUpdate(1, 999, 999, 1, "hi") // stranger
status, _ := PostWebhook(t, h.routes(), "echobot", "echo-secret", upd)
if status != 200 {
t.Fatalf("status = %d, want 200", status)
}
if len(h.ft.Sent()) != 0 {
t.Fatalf("stranger should be silent-dropped, got %d sends", len(h.ft.Sent()))
}
}
func TestServer_GatedBot_NotAuthed_PromptsAuth(t *testing.T) {
h := newE2EHarness(t, "")
upd := MakeUpdate(1, 100, 100, 1, "hi")
status, _ := PostWebhook(t, h.routes(), "echobot", "echo-secret", upd)
if status != 200 {
t.Fatalf("status = %d, want 200", status)
}
last := h.ft.LastSentTextTo(100)
if !strings.Contains(last, "🔒") || !strings.Contains(last, "@arcodange_factory_bot") {
t.Fatalf("should prompt /auth, got %q", last)
}
}
func TestServer_GatedBot_Authed_HandlerRuns(t *testing.T) {
h := newE2EHarness(t, "")
h.authenticate(t, 100)
upd := MakeUpdate(1, 100, 100, 1, "ping")
status, _ := PostWebhook(t, h.routes(), "echobot", "echo-secret", upd)
if status != 200 {
t.Fatalf("status = %d, want 200", status)
}
if got := h.ft.LastSentTextTo(100); got != "ping" {
t.Fatalf("authed user should get echo, got %q", got)
}
}
func TestServer_PublicBot_NoAuthNeeded(t *testing.T) {
h := newE2EHarness(t, "")
upd := MakeUpdate(1, 100, 100, 1, "hi public")
status, _ := PostWebhook(t, h.routes(), "publicbot", "pub-secret", upd)
if status != 200 {
t.Fatalf("status = %d, want 200", status)
}
if got := h.ft.LastSentTextTo(100); got != "hi public" {
t.Fatalf("public bot must echo without auth, got %q", got)
}
}
func TestServer_FactoryAuth_FullFlow(t *testing.T) {
h := newE2EHarness(t, "")
// Step 1 : /auth wrong → "Mauvais code"
wrong := MakeUpdate(1, 100, 100, 11, "/auth wrong")
_, _ = PostWebhook(t, h.routes(), "factory", "factory-secret", wrong)
if got := h.ft.LastSentTextTo(100); !strings.Contains(got, "Mauvais") {
t.Fatalf("step 1 wrong code reply : %q", got)
}
authed, _ := h.auth.IsAuthed(context.Background(), 100)
if authed {
t.Fatal("session should not exist after wrong code")
}
// Step 2 : /auth right → "Authentifié" + deleteMessage
right := MakeUpdate(2, 100, 100, 22, "/auth "+testAuthCode)
_, _ = PostWebhook(t, h.routes(), "factory", "factory-secret", right)
if got := h.ft.LastSentTextTo(100); !strings.Contains(got, "Authentifié") {
t.Fatalf("step 2 right code reply : %q", got)
}
deleted := h.ft.Deleted()
if len(deleted) != 1 || deleted[0].MessageID != 22 {
t.Fatalf("right code should deleteMessage 22, got %+v", deleted)
}
authed, _ = h.auth.IsAuthed(context.Background(), 100)
if !authed {
t.Fatal("session must exist after right code")
}
// Step 3 : the gated bot now responds normally
ping := MakeUpdate(3, 100, 100, 33, "ping after auth")
_, _ = PostWebhook(t, h.routes(), "echobot", "echo-secret", ping)
if got := h.ft.LastSentTextTo(100); got != "ping after auth" {
t.Fatalf("step 3 echo reply : %q", got)
}
// Step 4 : /logout closes the session
logout := MakeUpdate(4, 100, 100, 44, "/logout")
_, _ = PostWebhook(t, h.routes(), "factory", "factory-secret", logout)
authed, _ = h.auth.IsAuthed(context.Background(), 100)
if authed {
t.Fatal("logout should drop the session")
}
// Step 5 : the gated bot is gated again
again := MakeUpdate(5, 100, 100, 55, "after logout")
_, _ = PostWebhook(t, h.routes(), "echobot", "echo-secret", again)
if got := h.ft.LastSentTextTo(100); !strings.Contains(got, "🔒") {
t.Fatalf("step 5 should be gated again, got %q", got)
}
}
func TestServer_HealthAndReady(t *testing.T) {
h := newE2EHarness(t, "")
for _, path := range []string{"/healthz", "/readyz"} {
req := httpRequest("GET", path, "")
rr := httpRecorder()
h.routes().ServeHTTP(rr, req)
if rr.Code != 200 || rr.Body.String() != "OK" {
t.Errorf("%s = %d %q", path, rr.Code, rr.Body.String())
}
}
}

67
telegram_types_test.go Normal file
View File

@@ -0,0 +1,67 @@
package main
import "testing"
func TestUpdate_ChatID(t *testing.T) {
u := MakeUpdate(1, 100, 42, 7, "hi")
id, ok := u.ChatID()
if !ok || id != 42 {
t.Fatalf("Message chat = %d/%v", id, ok)
}
editU := Update{EditedMessage: &Message{Chat: Chat{ID: 99}}}
id, ok = editU.ChatID()
if !ok || id != 99 {
t.Fatalf("EditedMessage chat = %d/%v", id, ok)
}
cb := Update{CallbackQuery: &CallbackQuery{Message: &Message{Chat: Chat{ID: 77}}}}
id, ok = cb.ChatID()
if !ok || id != 77 {
t.Fatalf("CallbackQuery chat = %d/%v", id, ok)
}
empty := Update{}
if _, ok := empty.ChatID(); ok {
t.Fatal("empty Update must have no chat")
}
}
func TestUpdate_UserID(t *testing.T) {
u := MakeUpdate(1, 7497777082, 42, 7, "hi")
id, ok := u.UserID()
if !ok || id != 7497777082 {
t.Fatalf("Message from = %d/%v", id, ok)
}
cb := Update{CallbackQuery: &CallbackQuery{From: &User{ID: 555}}}
id, ok = cb.UserID()
if !ok || id != 555 {
t.Fatalf("CallbackQuery from = %d/%v", id, ok)
}
noFrom := Update{Message: &Message{Chat: Chat{ID: 1}}}
if _, ok := noFrom.UserID(); ok {
t.Fatal("Message without From should have no UserID")
}
}
func TestUpdate_Text(t *testing.T) {
if got := MakeUpdate(1, 1, 1, 1, "hi").Text(); got != "hi" {
t.Errorf("Message text: %q", got)
}
cb := Update{CallbackQuery: &CallbackQuery{Data: "btn:42"}}
if got := cb.Text(); got != "btn:42" {
t.Errorf("CallbackQuery data: %q", got)
}
}
func TestMessageID(t *testing.T) {
u := MakeUpdate(1, 1, 1, 88, "x")
if got := messageID(u); got != 88 {
t.Errorf("messageID = %d", got)
}
if got := messageID(Update{}); got != 0 {
t.Errorf("empty Update messageID = %d (want 0)", got)
}
}