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:
12
Makefile
12
Makefile
@@ -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
|
||||||
|
|
||||||
|
|||||||
29
README.md
29
README.md
@@ -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
68
allowlist_test.go
Normal 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
82
auth_test.go
Normal 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
49
docker-compose.yml
Normal 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
4
go.mod
@@ -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
23
go.sum
@@ -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
117
handler_auth_test.go
Normal 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
59
handler_echo_test.go
Normal 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
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
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() }
|
||||||
19
http_helpers_test.go
Normal file
19
http_helpers_test.go
Normal 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
34
middleware_test.go
Normal 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
26
queue_test.go
Normal 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
200
server_test.go
Normal 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
67
telegram_types_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user