Use tgbotapi and improve http client mocking

This commit is contained in:
Jan-Lukas Else 2021-12-07 18:23:57 +01:00
parent 604345e7c3
commit c7e3605459
18 changed files with 119 additions and 132 deletions

2
app.go
View File

@ -48,7 +48,7 @@ type goBlog struct {
pDeleteHooks []postHookFunc
hourlyHooks []hourlyHookFunc
// HTTP Client
httpClient httpClient
httpClient *http.Client
// HTTP Routers
d http.Handler
// IndieAuth

View File

@ -12,10 +12,10 @@ import (
func Test_blogroll(t *testing.T) {
fc := &fakeHttpClient{}
fc := newFakeHttpClient()
app := &goBlog{
httpClient: fc,
httpClient: fc.Client,
cfg: &config{
Db: &configDb{
File: filepath.Join(t.TempDir(), "test.db"),

View File

@ -14,12 +14,11 @@ func Test_proxyTiles(t *testing.T) {
cfg: &config{},
}
hc := &fakeHttpClient{
handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("Hello, World!"))
}),
}
app.httpClient = hc
hc := newFakeHttpClient()
hc.setHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("Hello, World!"))
}))
app.httpClient = hc.Client
// Default tile source

1
go.mod
View File

@ -20,6 +20,7 @@ require (
github.com/emersion/go-smtp v0.15.0
github.com/go-chi/chi/v5 v5.0.7
github.com/go-fed/httpsig v1.1.0
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.4.0
github.com/google/uuid v1.3.0
github.com/gorilla/handlers v1.5.1
github.com/gorilla/securecookie v1.1.1

2
go.sum
View File

@ -169,6 +169,8 @@ github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.4.0 h1:Mr3JcvBjQEhCN9wld6OHKHuHxWaoXTaQfYKmj7QwP18=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.4.0/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=

View File

@ -5,24 +5,11 @@ import (
"time"
)
type httpClient interface {
Do(req *http.Request) (*http.Response, error)
}
type appHttpClient struct {
hc *http.Client
}
var _ httpClient = &appHttpClient{}
func (c *appHttpClient) Do(req *http.Request) (*http.Response, error) {
if c.hc == nil {
c.hc = &http.Client{
Timeout: 5 * time.Minute,
Transport: &http.Transport{
DisableKeepAlives: true,
},
}
func newHttpClient() *http.Client {
return &http.Client{
Timeout: 5 * time.Minute,
Transport: &http.Transport{
DisableKeepAlives: true,
},
}
return c.hc.Do(req)
}

View File

@ -1,28 +1,40 @@
package main
import (
"io"
"net/http"
"net/http/httptest"
)
type fakeHttpClient struct {
httpClient
*http.Client
req *http.Request
res *http.Response
handler http.Handler
}
var _ httpClient = &fakeHttpClient{}
func (c *fakeHttpClient) Do(req *http.Request) (*http.Response, error) {
if c.handler == nil {
return nil, nil
func newFakeHttpClient() *fakeHttpClient {
fc := &fakeHttpClient{}
fc.Client = &http.Client{
Transport: &handlerRoundTripper{
handler: http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
fc.req = r
if fc.handler != nil {
rec := httptest.NewRecorder()
fc.handler.ServeHTTP(rec, r)
fc.res = rec.Result()
// Copy the headers from the response recorder
for k, v := range rec.Header() {
rw.Header()[k] = v
}
// Copy result status code and body
rw.WriteHeader(fc.res.StatusCode)
io.Copy(rw, rec.Body)
}
}),
},
}
rec := httptest.NewRecorder()
c.handler.ServeHTTP(rec, req)
c.req = req
c.res = rec.Result()
return c.res, nil
return fc
}
func (c *fakeHttpClient) clean() {

View File

@ -21,7 +21,7 @@ func Test_indieAuthServer(t *testing.T) {
var err error
app := &goBlog{
httpClient: &fakeHttpClient{},
httpClient: newFakeHttpClient().Client,
cfg: &config{
Db: &configDb{
File: filepath.Join(t.TempDir(), "test.db"),

View File

@ -14,7 +14,7 @@ import (
func Test_checkIndieAuth(t *testing.T) {
app := &goBlog{
httpClient: &fakeHttpClient{},
httpClient: newFakeHttpClient().Client,
cfg: &config{
Db: &configDb{
File: filepath.Join(t.TempDir(), "test.db"),

View File

@ -53,7 +53,7 @@ func main() {
initGC()
app := &goBlog{
httpClient: &appHttpClient{},
httpClient: newHttpClient(),
}
// Initialize config

View File

@ -16,7 +16,7 @@ const defaultCompressionWidth = 2000
const defaultCompressionHeight = 3000
type mediaCompression interface {
compress(url string, save mediaStorageSaveFunc, hc httpClient) (location string, err error)
compress(url string, save mediaStorageSaveFunc, hc *http.Client) (location string, err error)
}
func (a *goBlog) compressMediaFile(url string) (location string, err error) {
@ -55,7 +55,7 @@ type shortpixel struct {
var _ mediaCompression = &shortpixel{}
func (sp *shortpixel) compress(url string, upload mediaStorageSaveFunc, hc httpClient) (location string, err error) {
func (sp *shortpixel) compress(url string, upload mediaStorageSaveFunc, hc *http.Client) (location string, err error) {
// Check url
fileExtension, allowed := urlHasExt(url, "jpg", "jpeg", "png")
if !allowed {
@ -111,7 +111,7 @@ type tinify struct {
var _ mediaCompression = &tinify{}
func (tf *tinify) compress(url string, upload mediaStorageSaveFunc, hc httpClient) (location string, err error) {
func (tf *tinify) compress(url string, upload mediaStorageSaveFunc, hc *http.Client) (location string, err error) {
// Check url
fileExtension, allowed := urlHasExt(url, "jpg", "jpeg", "png")
if !allowed {
@ -188,7 +188,7 @@ type cloudflare struct {
var _ mediaCompression = &cloudflare{}
func (cf *cloudflare) compress(url string, upload mediaStorageSaveFunc, hc httpClient) (location string, err error) {
func (cf *cloudflare) compress(url string, upload mediaStorageSaveFunc, hc *http.Client) (location string, err error) {
// Check url
_, allowed := urlHasExt(url, "jpg", "jpeg", "png")
if !allowed {

View File

@ -27,7 +27,7 @@ func Test_compress(t *testing.T) {
}
t.Run("Cloudflare", func(t *testing.T) {
fakeClient := &fakeHttpClient{}
fakeClient := newFakeHttpClient()
fakeClient.setHandler(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
assert.Equal(t, "https://www.cloudflare.com/cdn-cgi/image/f=jpeg,q=75,metadata=none,fit=scale-down,w=2000,h=3000/https://example.com/original.jpg", r.URL.String())
@ -36,14 +36,14 @@ func Test_compress(t *testing.T) {
}))
cf := &cloudflare{}
res, err := cf.compress("https://example.com/original.jpg", uf, fakeClient)
res, err := cf.compress("https://example.com/original.jpg", uf, fakeClient.Client)
assert.Nil(t, err)
assert.Equal(t, "https://example.com/"+fakeSha256+".jpeg", res)
})
t.Run("Shortpixel", func(t *testing.T) {
fakeClient := &fakeHttpClient{}
fakeClient := newFakeHttpClient()
fakeClient.setHandler(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
assert.Equal(t, "https://api.shortpixel.com/v2/reducer-sync.php", r.URL.String())
@ -63,7 +63,7 @@ func Test_compress(t *testing.T) {
}))
cf := &shortpixel{"testkey"}
res, err := cf.compress("https://example.com/original.jpg", uf, fakeClient)
res, err := cf.compress("https://example.com/original.jpg", uf, fakeClient.Client)
assert.Nil(t, err)
assert.Equal(t, "https://example.com/"+fakeSha256+".jpg", res)

View File

@ -31,7 +31,7 @@ func (a *goBlog) sendNotification(text string) {
log.Println("Failed to save notification:", err.Error())
}
if an := a.cfg.Notifications; an != nil {
err := a.send(an.Telegram, n.Text, "")
_, err := a.send(an.Telegram, n.Text, "")
if err != nil {
log.Println("Failed to send Telegram notification:", err.Error())
}

View File

@ -503,22 +503,6 @@ func (d *database) countPosts(config *postsRequestConfig) (count int, err error)
return
}
func (d *database) getPostPaths(status postStatus) ([]string, error) {
var postPaths []string
rows, err := d.query("select path from posts where status = @status", sql.Named("status", status))
if err != nil {
return nil, err
}
var path string
for rows.Next() {
_ = rows.Scan(&path)
if path != "" {
postPaths = append(postPaths, path)
}
}
return postPaths, nil
}
func (a *goBlog) getRandomPostPath(blog string) (path string, err error) {
sections, ok := funk.Keys(a.cfg.Blogs[blog].Sections).([]string)
if !ok {

View File

@ -64,17 +64,6 @@ func Test_postsDb(t *testing.T) {
is.Equal("Title", p.Title())
is.Equal([]string{"C", "A", "B"}, p.Parameters["tags"])
// Check number of post paths
pp, err := app.db.getPostPaths(statusDraft)
must.NoError(err)
if is.Len(pp, 1) {
is.Equal("/test/abc", pp[0])
}
pp, err = app.db.getPostPaths(statusPublished)
must.NoError(err)
is.Len(pp, 0)
// Check drafts
drafts, _ := app.getPosts(&postsRequestConfig{
blog: "en",

View File

@ -2,21 +2,17 @@ package main
import (
"bytes"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"strings"
)
const telegramBaseURL = "https://api.telegram.org/bot"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
func (a *goBlog) initTelegram() {
a.pPostHooks = append(a.pPostHooks, func(p *post) {
if tg := a.cfg.Blogs[p.Blog].Telegram; tg.enabled() && p.isPublishedSectionPost() {
if html := tg.generateHTML(p.RenderedTitle, a.fullPostURL(p), a.shortPostURL(p)); html != "" {
if err := a.send(tg, html, "HTML"); err != nil {
if _, err := a.send(tg, html, "HTML"); err != nil {
log.Printf("Failed to send post to Telegram: %v", err)
}
}
@ -35,47 +31,41 @@ func (tg *configTelegram) generateHTML(title, fullURL, shortURL string) string {
if !tg.enabled() {
return ""
}
replacer := strings.NewReplacer("<", "&lt;", ">", "&gt;", "&", "&amp;")
var message bytes.Buffer
if title != "" {
message.WriteString(replacer.Replace(title))
message.WriteString(tgbotapi.EscapeText(tgbotapi.ModeHTML, title))
message.WriteString("\n\n")
}
if tg.InstantViewHash != "" {
message.WriteString("<a href=\"https://t.me/iv?rhash=" + tg.InstantViewHash + "&url=" + url.QueryEscape(fullURL) + "\">")
message.WriteString(replacer.Replace(shortURL))
message.WriteString(tgbotapi.EscapeText(tgbotapi.ModeHTML, shortURL))
message.WriteString("</a>")
} else {
message.WriteString("<a href=\"" + shortURL + "\">")
message.WriteString(replacer.Replace(shortURL))
message.WriteString(tgbotapi.EscapeText(tgbotapi.ModeHTML, shortURL))
message.WriteString("</a>")
}
return message.String()
}
func (a *goBlog) send(tg *configTelegram, message, mode string) error {
func (a *goBlog) send(tg *configTelegram, message, mode string) (int, error) {
if !tg.enabled() {
return nil
return 0, nil
}
params := url.Values{}
params.Add("chat_id", tg.ChatID)
params.Add("text", message)
if mode != "" {
params.Add("parse_mode", mode)
}
tgURL, err := url.Parse(telegramBaseURL + tg.BotToken + "/sendMessage")
bot, err := tgbotapi.NewBotAPIWithClient(tg.BotToken, tgbotapi.APIEndpoint, a.httpClient)
if err != nil {
return errors.New("failed to create Telegram request")
return 0, err
}
tgURL.RawQuery = params.Encode()
req, _ := http.NewRequest(http.MethodPost, tgURL.String(), nil)
resp, err := a.httpClient.Do(req)
msg := tgbotapi.MessageConfig{
BaseChat: tgbotapi.BaseChat{
ChannelUsername: tg.ChatID,
},
Text: message,
ParseMode: mode,
}
res, err := bot.Send(msg)
if err != nil {
return err
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to send Telegram message, status code %d", resp.StatusCode)
}
return nil
return res.MessageID, nil
}

View File

@ -7,6 +7,7 @@ import (
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_configTelegram_enabled(t *testing.T) {
@ -72,7 +73,17 @@ func Test_configTelegram_generateHTML(t *testing.T) {
}
func Test_configTelegram_send(t *testing.T) {
fakeClient := &fakeHttpClient{}
fakeClient := newFakeHttpClient()
fakeClient.setHandler(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if r.URL.String() == "https://api.telegram.org/botbottoken/getMe" {
rw.WriteHeader(http.StatusOK)
rw.Write([]byte(`{"ok":true,"result":{"id":123456789,"is_bot":true,"first_name":"Test","username":"testbot"}}`))
return
}
rw.WriteHeader(http.StatusOK)
rw.Write([]byte(`{"ok":true,"result":{"message_id":123,"from":{"id":123456789,"is_bot":true,"first_name":"Test","username":"testbot"},"chat":{"id":123456789,"first_name":"Test","username":"testbot"},"date":1564181818,"text":"Message"}}`))
}))
tg := &configTelegram{
Enabled: true,
@ -81,17 +92,22 @@ func Test_configTelegram_send(t *testing.T) {
}
app := &goBlog{
httpClient: fakeClient,
httpClient: fakeClient.Client,
}
fakeClient.setFakeResponse(200, "")
msgId, err := app.send(tg, "Message", "HTML")
require.Nil(t, err)
err := app.send(tg, "Message", "HTML")
assert.Nil(t, err)
assert.Equal(t, 123, msgId)
assert.NotNil(t, fakeClient.req)
assert.Equal(t, http.MethodPost, fakeClient.req.Method)
assert.Equal(t, "https://api.telegram.org/botbottoken/sendMessage?chat_id=chatid&parse_mode=HTML&text=Message", fakeClient.req.URL.String())
assert.Equal(t, "https://api.telegram.org/botbottoken/sendMessage", fakeClient.req.URL.String())
req := fakeClient.req
assert.Equal(t, "chatid", req.FormValue("chat_id"))
assert.Equal(t, "HTML", req.FormValue("parse_mode"))
assert.Equal(t, "Message", req.FormValue("text"))
}
func Test_goBlog_initTelegram(t *testing.T) {
@ -108,9 +124,17 @@ func Test_goBlog_initTelegram(t *testing.T) {
func Test_telegram(t *testing.T) {
t.Run("Send post to Telegram", func(t *testing.T) {
fakeClient := &fakeHttpClient{}
fakeClient := newFakeHttpClient()
fakeClient.setFakeResponse(200, "")
fakeClient.setHandler(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if r.URL.String() == "https://api.telegram.org/botbottoken/getMe" {
rw.WriteHeader(http.StatusOK)
rw.Write([]byte(`{"ok":true,"result":{"id":123456789,"is_bot":true,"first_name":"Test","username":"testbot"}}`))
return
}
rw.WriteHeader(http.StatusOK)
rw.Write([]byte(`{"ok":true,"result":{"message_id":123,"from":{"id":123456789,"is_bot":true,"first_name":"Test","username":"testbot"},"chat":{"id":123456789,"first_name":"Test","username":"testbot"},"date":1564181818,"text":"Message"}}`))
}))
app := &goBlog{
pPostHooks: []postHookFunc{},
@ -131,7 +155,7 @@ func Test_telegram(t *testing.T) {
},
},
},
httpClient: fakeClient,
httpClient: fakeClient.Client,
}
_ = app.initDatabase(false)
@ -149,17 +173,16 @@ func Test_telegram(t *testing.T) {
app.pPostHooks[0](p)
assert.Equal(
t,
"https://api.telegram.org/botbottoken/sendMessage?chat_id=chatid&parse_mode=HTML&text=Title%0A%0A%3Ca+href%3D%22https%3A%2F%2Fexample.com%2Fs%2F1%22%3Ehttps%3A%2F%2Fexample.com%2Fs%2F1%3C%2Fa%3E",
fakeClient.req.URL.String(),
)
assert.Equal(t, "https://api.telegram.org/botbottoken/sendMessage", fakeClient.req.URL.String())
req := fakeClient.req
assert.Equal(t, "chatid", req.FormValue("chat_id"))
assert.Equal(t, "HTML", req.FormValue("parse_mode"))
assert.Equal(t, "Title\n\n<a href=\"https://example.com/s/1\">https://example.com/s/1</a>", req.FormValue("text"))
})
t.Run("Telegram disabled", func(t *testing.T) {
fakeClient := &fakeHttpClient{}
fakeClient.setFakeResponse(200, "")
fakeClient := newFakeHttpClient()
app := &goBlog{
pPostHooks: []postHookFunc{},
@ -174,7 +197,7 @@ func Test_telegram(t *testing.T) {
"en": {},
},
},
httpClient: fakeClient,
httpClient: fakeClient.Client,
}
_ = app.initDatabase(false)

View File

@ -16,11 +16,11 @@ func Test_verifyMention(t *testing.T) {
require.NoError(t, err)
testHtml := string(testHtmlBytes)
mockClient := &fakeHttpClient{}
mockClient := newFakeHttpClient()
mockClient.setFakeResponse(http.StatusOK, testHtml)
app := &goBlog{
httpClient: mockClient,
httpClient: mockClient.Client,
cfg: &config{
Db: &configDb{
File: filepath.Join(t.TempDir(), "test.db"),
@ -65,11 +65,11 @@ func Test_verifyMentionBidgy(t *testing.T) {
require.NoError(t, err)
testHtml := string(testHtmlBytes)
mockClient := &fakeHttpClient{}
mockClient := newFakeHttpClient()
mockClient.setFakeResponse(http.StatusOK, testHtml)
app := &goBlog{
httpClient: mockClient,
httpClient: mockClient.Client,
cfg: &config{
Db: &configDb{
File: filepath.Join(t.TempDir(), "test.db"),
@ -108,11 +108,11 @@ func Test_verifyMentionColin(t *testing.T) {
require.NoError(t, err)
testHtml := string(testHtmlBytes)
mockClient := &fakeHttpClient{}
mockClient := newFakeHttpClient()
mockClient.setFakeResponse(http.StatusOK, testHtml)
app := &goBlog{
httpClient: mockClient,
httpClient: mockClient.Client,
cfg: &config{
Db: &configDb{
File: filepath.Join(t.TempDir(), "test.db"),