package app import ( "encoding/json" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "cep.is/telegrambot/internal/telegram" ) const fakeToken = "424242:not-a-real-token-generated-for-tests" const fakeUsername = "cepisbot" type cannedFailure struct { status int code int description string retryAfter int } // fakeTelegram is a real HTTP server speaking the Bot API, including the // confirmation semantics of getUpdates: an update stays pending until a later // call arrives with a higher offset. Without that, an offset bug in the poller // would go unnoticed. type fakeTelegram struct { server *httptest.Server username string mu sync.Mutex pending []telegram.Update offsets []int64 sent []telegram.SendMessageRequest inline []telegram.AnswerInlineQueryRequest sendFailures []cannedFailure getMeCalls int } func newFakeTelegram(t *testing.T) *fakeTelegram { t.Helper() f := &fakeTelegram{username: fakeUsername} f.server = httptest.NewServer(f) t.Cleanup(f.server.Close) return f } func (f *fakeTelegram) URL() string { return f.server.URL } func (f *fakeTelegram) ServeHTTP(w http.ResponseWriter, r *http.Request) { prefix, method, ok := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/") if !ok || prefix != "bot"+fakeToken { writeEnvelope(w, http.StatusUnauthorized, envelope{ OK: false, ErrorCode: 401, Description: "Unauthorized", }) return } switch method { case "getMe": f.mu.Lock() f.getMeCalls++ f.mu.Unlock() writeResult(w, telegram.User{ID: 42, IsBot: true, FirstName: "cep.is", Username: f.username}) case "getUpdates": f.getUpdates(w, r) case "sendMessage": f.sendMessage(w, r) case "answerInlineQuery": var req telegram.AnswerInlineQueryRequest decode(r, &req) f.mu.Lock() f.inline = append(f.inline, req) f.mu.Unlock() writeResult(w, true) default: writeEnvelope(w, http.StatusNotFound, envelope{ OK: false, ErrorCode: 404, Description: "Not Found: method not found", }) } } func (f *fakeTelegram) getUpdates(w http.ResponseWriter, r *http.Request) { var req telegram.GetUpdatesRequest decode(r, &req) f.mu.Lock() f.offsets = append(f.offsets, req.Offset) if req.Offset > 0 { kept := f.pending[:0] for _, update := range f.pending { if update.UpdateID >= req.Offset { kept = append(kept, update) } } f.pending = kept } f.mu.Unlock() deadline := time.Now().Add(time.Duration(req.Timeout) * time.Second) for { f.mu.Lock() out := append([]telegram.Update(nil), f.pending...) f.mu.Unlock() if len(out) > 0 || time.Now().After(deadline) { if req.Limit > 0 && len(out) > req.Limit { out = out[:req.Limit] } writeResult(w, out) return } select { case <-r.Context().Done(): writeResult(w, []telegram.Update{}) return case <-time.After(5 * time.Millisecond): } } } func (f *fakeTelegram) sendMessage(w http.ResponseWriter, r *http.Request) { var req telegram.SendMessageRequest decode(r, &req) f.mu.Lock() if len(f.sendFailures) > 0 { failure := f.sendFailures[0] f.sendFailures = f.sendFailures[1:] f.mu.Unlock() body := envelope{OK: false, ErrorCode: failure.code, Description: failure.description} if failure.retryAfter > 0 { body.Parameters = &telegram.ResponseParameters{RetryAfter: failure.retryAfter} } writeEnvelope(w, failure.status, body) return } f.sent = append(f.sent, req) f.mu.Unlock() writeResult(w, map[string]any{"message_id": 1}) } func (f *fakeTelegram) push(updates ...telegram.Update) { f.mu.Lock() defer f.mu.Unlock() f.pending = append(f.pending, updates...) } func (f *fakeTelegram) failNextSends(failures ...cannedFailure) { f.mu.Lock() defer f.mu.Unlock() f.sendFailures = append(f.sendFailures, failures...) } func (f *fakeTelegram) sentMessages() []telegram.SendMessageRequest { f.mu.Lock() defer f.mu.Unlock() return append([]telegram.SendMessageRequest(nil), f.sent...) } func (f *fakeTelegram) inlineAnswers() []telegram.AnswerInlineQueryRequest { f.mu.Lock() defer f.mu.Unlock() return append([]telegram.AnswerInlineQueryRequest(nil), f.inline...) } func (f *fakeTelegram) requestedOffsets() []int64 { f.mu.Lock() defer f.mu.Unlock() return append([]int64(nil), f.offsets...) } func (f *fakeTelegram) pendingCount() int { f.mu.Lock() defer f.mu.Unlock() return len(f.pending) } // fakeCEPIS is a real HTTP server speaking the cep.is native contract, with // enough of it to exercise every branch the bot has: an address with a street // coordinate, one that is a whole municipality, a 404 that names the // municipality the CEP would fall in, a 404 that does not, a 400 and a 500. type fakeCEPIS struct { server *httptest.Server mu sync.Mutex // bodies are the 200s, missing are the 404s that carry a faixa, and // statuses forces a status for a CEP regardless of the other two. bodies map[string]string missing map[string]string statuses map[string]int requests int } func newFakeCEPIS(t *testing.T) *fakeCEPIS { t.Helper() f := &fakeCEPIS{ bodies: map[string]string{ "01001000": `{"cep":"01001000","logradouro":"Praça da Sé","complemento":"lado ímpar",` + `"unidade":"","bairro":"Sé","localidade":"São Paulo","uf":"SP","estado":"São Paulo",` + `"regiao":"Sudeste","ibge":"3550308","gia":"1004","ddd":"11","siafi":"7107",` + `"geo":{"lat":-23.567202,"lon":-46.649659,"raio_m":128,"precisao":"logradouro"}}`, "76200000": `{"cep":"76200000","logradouro":"","complemento":"","unidade":"",` + `"bairro":"","localidade":"Iporá","uf":"GO","estado":"Goiás","regiao":"Centro-Oeste",` + `"ibge":"5210000","gia":"","ddd":"64","siafi":"9439",` + `"geo":{"lat":-16.441,"lon":-51.118,"raio_m":18400,"precisao":"localidade"}}`, "29902000": `{"cep":"29902000","logradouro":"Rua Sem Coordenada","complemento":"",` + `"unidade":"","bairro":"Centro","localidade":"Linhares","uf":"ES","estado":"Espírito Santo",` + `"regiao":"Sudeste","ibge":"3203205","gia":"","ddd":"27","siafi":"5665"}`, }, // 25845000 is the CEP the "-000" guess produces for Areal/RJ, which // is coded street by street and never received that number: the // municipality exists, the CEP does not. missing: map[string]string{ "25845000": `{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE",` + `"faixa":{"localidade":"Areal","uf":"RJ","estado":"Rio de Janeiro","regiao":"Sudeste",` + `"ibge":"3300225","cep_ini":"25845001","cep_fim":"25849899","ceps":118}}`, }, statuses: map[string]int{ "50000000": http.StatusInternalServerError, }, } f.server = httptest.NewServer(http.HandlerFunc(f.handle)) t.Cleanup(f.server.Close) return f } func (f *fakeCEPIS) URL() string { return f.server.URL } func (f *fakeCEPIS) handle(w http.ResponseWriter, r *http.Request) { f.mu.Lock() f.requests++ cep := strings.TrimPrefix(r.URL.Path, "/") status, forced := f.statuses[cep] body, known := f.bodies[cep] absent, attributed := f.missing[cep] f.mu.Unlock() w.Header().Set("Content-Type", "application/json; charset=utf-8") switch { case forced: w.WriteHeader(status) w.Write([]byte(`{"erro":"interno","mensagem":"falha interna"}`)) case known: w.WriteHeader(http.StatusOK) w.Write([]byte(body)) case attributed: w.WriteHeader(http.StatusNotFound) w.Write([]byte(absent)) case len(cep) != 8: w.WriteHeader(http.StatusBadRequest) w.Write([]byte(`{"erro":"cep_invalido","mensagem":"CEP deve ter 8 dígitos"}`)) default: w.WriteHeader(http.StatusNotFound) w.Write([]byte(`{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"}`)) } } func (f *fakeCEPIS) requestCount() int { f.mu.Lock() defer f.mu.Unlock() return f.requests } type envelope struct { OK bool `json:"ok"` Result any `json:"result,omitempty"` ErrorCode int `json:"error_code,omitempty"` Description string `json:"description,omitempty"` Parameters *telegram.ResponseParameters `json:"parameters,omitempty"` } func writeResult(w http.ResponseWriter, result any) { writeEnvelope(w, http.StatusOK, envelope{OK: true, Result: result}) } func writeEnvelope(w http.ResponseWriter, status int, body envelope) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(body) } func decode(r *http.Request, out any) { json.NewDecoder(r.Body).Decode(out) } func waitFor(t *testing.T, what string, timeout time.Duration, cond func() bool) { t.Helper() deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { if cond() { return } time.Sleep(5 * time.Millisecond) } t.Fatalf("timed out waiting for %s", what) }