feat: publish standalone worker
Separate worker packaging and service lifecycle from the control plane.
Этот коммит содержится в:
57
internal/notify/email.go
Обычный файл
57
internal/notify/email.go
Обычный файл
@@ -0,0 +1,57 @@
|
||||
// Package notify delivers notifications using credentials persisted in the
|
||||
// notification_credentials table. It is the credential-backed counterpart to
|
||||
// the legacy secrets.yml driven senders in internal/sender and internal/tg.
|
||||
package notify
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
|
||||
"gopkg.in/gomail.v2"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
// Email sends an email using the given SMTP credential. bodyText is sent as
|
||||
// text/plain and, when non-empty, bodyHTML is added as a text/html alternative
|
||||
// so the recipient's MUA picks the best representation.
|
||||
func Email(cred *models.NotificationCredential, to, subject, bodyText, bodyHTML string) error {
|
||||
if cred == nil {
|
||||
return fmt.Errorf("credential is nil")
|
||||
}
|
||||
if cred.Kind != models.CredentialKindSMTP {
|
||||
return fmt.Errorf("credential %d is not smtp (kind=%s)", cred.ID, cred.Kind)
|
||||
}
|
||||
if cred.Server == nil || cred.Port == nil || cred.Login == nil || cred.FromAddr == nil {
|
||||
return fmt.Errorf("credential %d missing required smtp fields", cred.ID)
|
||||
}
|
||||
|
||||
password, err := cred.GetSecret()
|
||||
if err != nil {
|
||||
return fmt.Errorf("decrypt smtp password: %w", err)
|
||||
}
|
||||
|
||||
fromName := "RSMon"
|
||||
if cred.FromName != nil && *cred.FromName != "" {
|
||||
fromName = *cred.FromName
|
||||
}
|
||||
|
||||
m := gomail.NewMessage()
|
||||
m.SetHeader("From", m.FormatAddress(*cred.FromAddr, fromName))
|
||||
m.SetHeader("To", to)
|
||||
m.SetHeader("Subject", subject)
|
||||
m.AddAlternative("text/plain", bodyText)
|
||||
if bodyHTML != "" {
|
||||
m.AddAlternative("text/html", bodyHTML)
|
||||
}
|
||||
|
||||
d := gomail.NewDialer(*cred.Server, *cred.Port, *cred.Login, password)
|
||||
if cred.InsecureSkipVerify {
|
||||
d.TLSConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
}
|
||||
|
||||
if err := d.DialAndSend(m); err != nil {
|
||||
return fmt.Errorf("send smtp via credential %d: %w", cred.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
202
internal/notify/email_network_test.go
Обычный файл
202
internal/notify/email_network_test.go
Обычный файл
@@ -0,0 +1,202 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
const (
|
||||
mailhogSMTPHost = "localhost"
|
||||
mailhogSMTPPort = 31025
|
||||
mailhogAPIURL = "http://localhost:38025"
|
||||
networkDialTimeout = 2 * time.Second
|
||||
mailhogPollInterval = 100 * time.Millisecond
|
||||
mailhogPollDeadline = 3 * time.Second
|
||||
)
|
||||
|
||||
// mailhogMessage mirrors the subset of MailHog's /api/v2/messages payload we
|
||||
// care about in tests: the parsed headers and the rendered body.
|
||||
type mailhogMessage struct {
|
||||
Content struct {
|
||||
Headers map[string][]string `json:"Headers"`
|
||||
Body string `json:"Body"`
|
||||
} `json:"Content"`
|
||||
}
|
||||
|
||||
// skipIfMailHogDown skips the test if MailHog's SMTP port is unreachable.
|
||||
// Tests stay green on dev machines without docker; on CI with docker they
|
||||
// run for real against the running MailHog container.
|
||||
func skipIfMailHogDown(t *testing.T) {
|
||||
t.Helper()
|
||||
addr := net.JoinHostPort(mailhogSMTPHost, fmt.Sprintf("%d", mailhogSMTPPort))
|
||||
conn, err := net.DialTimeout("tcp", addr, networkDialTimeout)
|
||||
if err != nil {
|
||||
t.Skipf("mailhog not reachable at %s: %v", addr, err)
|
||||
}
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
// mailhogMessages fetches the most recent messages from MailHog's HTTP API.
|
||||
func mailhogMessages(t *testing.T) []mailhogMessage {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, mailhogAPIURL+"/api/v2/messages?limit=50", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("build mailhog request: %v", err)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("query mailhog: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var out struct {
|
||||
Total int `json:"total"`
|
||||
Items []mailhogMessage `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode mailhog response: %v", err)
|
||||
}
|
||||
return out.Items
|
||||
}
|
||||
|
||||
// mailhogDeleteAll clears the MailHog in-memory mailbox so each test starts
|
||||
// from a known state.
|
||||
func mailhogDeleteAll(t *testing.T) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodDelete, mailhogAPIURL+"/api/v1/messages", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("build mailhog delete: %v", err)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("delete mailhog messages: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("mailhog delete returned %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmail_Network_SendAndVerify(t *testing.T) {
|
||||
skipIfMailHogDown(t)
|
||||
mailhogDeleteAll(t)
|
||||
|
||||
port := mailhogSMTPPort
|
||||
enabled := true
|
||||
cred := &models.NotificationCredential{
|
||||
Kind: models.CredentialKindSMTP,
|
||||
Name: "mailhog-test",
|
||||
Server: strPtr(mailhogSMTPHost),
|
||||
Port: &port,
|
||||
Login: strPtr(""),
|
||||
FromName: strPtr("RSMon Tests"),
|
||||
FromAddr: strPtr("tests@rsmon.test"),
|
||||
Enabled: &enabled,
|
||||
// MailHog accepts any password; "plain:" prefix keeps GetSecret
|
||||
// working without the credential encryption key configured.
|
||||
SecretEnc: "plain:",
|
||||
}
|
||||
|
||||
subject := "rsmon-test-subject-" + time.Now().Format("150405.000")
|
||||
body := "rsmon-test-body hello mailhog"
|
||||
to := "to@rsmon.test"
|
||||
|
||||
if err := Email(cred, to, subject, body, ""); err != nil {
|
||||
t.Fatalf("Email returned error: %v", err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(mailhogPollDeadline)
|
||||
var found *mailhogMessage
|
||||
for time.Now().Before(deadline) {
|
||||
msgs := mailhogMessages(t)
|
||||
for i := range msgs {
|
||||
hsubj := msgs[i].Content.Headers["Subject"]
|
||||
if len(hsubj) > 0 && strings.Contains(hsubj[0], subject) {
|
||||
found = &msgs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if found != nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(mailhogPollInterval)
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("message with subject %q not found in MailHog after %s", subject, mailhogPollDeadline)
|
||||
}
|
||||
|
||||
if !strings.Contains(found.Content.Body, body) {
|
||||
t.Errorf("body mismatch: want contains %q, got %q", body, found.Content.Body)
|
||||
}
|
||||
if hfrom := found.Content.Headers["From"]; len(hfrom) == 0 || !strings.Contains(hfrom[0], "tests@rsmon.test") {
|
||||
t.Errorf("from mismatch: want contains tests@rsmon.test, got %v", hfrom)
|
||||
}
|
||||
if hto := found.Content.Headers["To"]; len(hto) == 0 || !strings.Contains(hto[0], to) {
|
||||
t.Errorf("to mismatch: want contains %s, got %v", to, hto)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmail_Network_HTMLAlternative(t *testing.T) {
|
||||
skipIfMailHogDown(t)
|
||||
mailhogDeleteAll(t)
|
||||
|
||||
port := mailhogSMTPPort
|
||||
enabled := true
|
||||
cred := &models.NotificationCredential{
|
||||
Kind: models.CredentialKindSMTP,
|
||||
Name: "mailhog-html",
|
||||
Server: strPtr(mailhogSMTPHost),
|
||||
Port: &port,
|
||||
Login: strPtr(""),
|
||||
FromAddr: strPtr("tests@rsmon.test"),
|
||||
Enabled: &enabled,
|
||||
SecretEnc: "plain:",
|
||||
}
|
||||
|
||||
subject := "rsmon-html-" + time.Now().Format("150405.000")
|
||||
bodyText := "plain text body"
|
||||
bodyHTML := "<p>html <b>body</b></p>"
|
||||
|
||||
if err := Email(cred, "to@rsmon.test", subject, bodyText, bodyHTML); err != nil {
|
||||
t.Fatalf("Email returned error: %v", err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(mailhogPollDeadline)
|
||||
var found *mailhogMessage
|
||||
for time.Now().Before(deadline) {
|
||||
msgs := mailhogMessages(t)
|
||||
for i := range msgs {
|
||||
hsubj := msgs[i].Content.Headers["Subject"]
|
||||
if len(hsubj) > 0 && strings.Contains(hsubj[0], subject) {
|
||||
found = &msgs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if found != nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(mailhogPollInterval)
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("html message with subject %q not found in MailHog after %s", subject, mailhogPollDeadline)
|
||||
}
|
||||
|
||||
body := found.Content.Body
|
||||
if !strings.Contains(body, bodyText) {
|
||||
t.Errorf("text part missing: want contains %q in %q", bodyText, body)
|
||||
}
|
||||
if !strings.Contains(body, bodyHTML) {
|
||||
t.Errorf("html part missing: want contains %q in %q", bodyHTML, body)
|
||||
}
|
||||
}
|
||||
54
internal/notify/email_test.go
Обычный файл
54
internal/notify/email_test.go
Обычный файл
@@ -0,0 +1,54 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
func TestEmail_NilCred(t *testing.T) {
|
||||
if err := Email(nil, "to@x.com", "s", "b", ""); err == nil {
|
||||
t.Fatal("expected error for nil cred")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmail_WrongKind(t *testing.T) {
|
||||
cred := &models.NotificationCredential{Kind: models.CredentialKindTelegram}
|
||||
if err := Email(cred, "to@x.com", "s", "b", ""); err == nil {
|
||||
t.Fatal("expected error for wrong kind")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmail_MissingRequiredFields(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cred *models.NotificationCredential
|
||||
}{
|
||||
{"nil server", &models.NotificationCredential{Kind: models.CredentialKindSMTP}},
|
||||
{"nil port", &models.NotificationCredential{
|
||||
Kind: models.CredentialKindSMTP,
|
||||
Server: strPtr("smtp.x.com"),
|
||||
}},
|
||||
{"nil login", &models.NotificationCredential{
|
||||
Kind: models.CredentialKindSMTP,
|
||||
Server: strPtr("smtp.x.com"),
|
||||
Port: intPtr(587),
|
||||
}},
|
||||
{"nil fromaddr", &models.NotificationCredential{
|
||||
Kind: models.CredentialKindSMTP,
|
||||
Server: strPtr("smtp.x.com"),
|
||||
Port: intPtr(587),
|
||||
Login: strPtr("u"),
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := Email(tc.cred, "to@x.com", "s", "b", ""); err == nil {
|
||||
t.Fatalf("expected error for %s", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
func intPtr(i int) *int { return &i }
|
||||
66
internal/notify/telegram.go
Обычный файл
66
internal/notify/telegram.go
Обычный файл
@@ -0,0 +1,66 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
// Telegram sends a text message to the given chat ID using the given Telegram
|
||||
// bot credential. chatID is the numeric Telegram chat id assigned by Telegram
|
||||
// to a private chat, group or channel.
|
||||
func Telegram(cred *models.NotificationCredential, chatID int64, text string) error {
|
||||
if cred == nil {
|
||||
return fmt.Errorf("credential is nil")
|
||||
}
|
||||
if cred.Kind != models.CredentialKindTelegram {
|
||||
return fmt.Errorf("credential %d is not telegram (kind=%s)", cred.ID, cred.Kind)
|
||||
}
|
||||
|
||||
token, err := cred.GetSecret()
|
||||
if err != nil {
|
||||
return fmt.Errorf("decrypt telegram token: %w", err)
|
||||
}
|
||||
if token == "" {
|
||||
return fmt.Errorf("credential %d has empty token", cred.ID)
|
||||
}
|
||||
|
||||
apiURL := ""
|
||||
if cred.APIURL != nil && *cred.APIURL != "" {
|
||||
apiURL = *cred.APIURL
|
||||
}
|
||||
|
||||
bot, err := tgbotapi.NewBotAPIWithAPIEndpoint(token, telegramEndpoint(apiURL))
|
||||
if err != nil {
|
||||
return fmt.Errorf("init telegram bot: %w", err)
|
||||
}
|
||||
|
||||
msg := tgbotapi.NewMessage(chatID, text)
|
||||
if _, err := bot.Send(msg); err != nil {
|
||||
return fmt.Errorf("send telegram via credential %d: %w", cred.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// telegramEndpoint returns the Bot API endpoint pattern that tgbotapi expects
|
||||
// (".../bot%s/%s"). If rawURL is empty, returns the default Telegram endpoint.
|
||||
//
|
||||
// rawURL may be a base URL with optional basic-auth credentials in the
|
||||
// userinfo (https://user:pass@host/) — net/http applies the userinfo as the
|
||||
// Authorization header automatically, so reverse proxies with basic auth
|
||||
// work transparently.
|
||||
//
|
||||
// We deliberately avoid url.Parse here: it percent-encodes the literal "%"
|
||||
// in "/bot%s/%s" to "/bot%25s/%25s", which makes tgbotapi's fmt.Sprintf
|
||||
// produce a malformed URL (verified against the deploy.rscz.ru proxy).
|
||||
// Stripping the path and appending the bot-method pattern as a string is
|
||||
// both simpler and safe.
|
||||
func telegramEndpoint(rawURL string) string {
|
||||
if rawURL == "" {
|
||||
return tgbotapi.APIEndpoint
|
||||
}
|
||||
return strings.TrimRight(rawURL, "/") + "/bot%s/%s"
|
||||
}
|
||||
110
internal/notify/telegram_network_test.go
Обычный файл
110
internal/notify/telegram_network_test.go
Обычный файл
@@ -0,0 +1,110 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
func TestTelegram_Network_MockBotAPI(t *testing.T) {
|
||||
var (
|
||||
gotPath atomic.Value
|
||||
gotMethod atomic.Value
|
||||
gotContentTy atomic.Value
|
||||
gotForm atomic.Pointer[url.Values]
|
||||
)
|
||||
gotPath.Store("")
|
||||
gotMethod.Store("")
|
||||
gotContentTy.Store("")
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath.Store(r.URL.Path)
|
||||
gotMethod.Store(r.Method)
|
||||
gotContentTy.Store(r.Header.Get("Content-Type"))
|
||||
if err := r.ParseForm(); err == nil {
|
||||
form := r.PostForm
|
||||
gotForm.Store(&form)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"Test","username":"test_bot"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
enabled := true
|
||||
cred := &models.NotificationCredential{
|
||||
Kind: models.CredentialKindTelegram,
|
||||
Name: "test-bot",
|
||||
BotName: strPtr("test_bot"),
|
||||
APIURL: strPtr(srv.URL),
|
||||
Enabled: &enabled,
|
||||
SecretEnc: "plain:FAKE_TOKEN_FOR_TEST",
|
||||
}
|
||||
|
||||
if err := Telegram(cred, 123456789, "hello from test"); err != nil {
|
||||
t.Fatalf("Telegram returned error: %v", err)
|
||||
}
|
||||
|
||||
path := gotPath.Load().(string)
|
||||
method := gotMethod.Load().(string)
|
||||
ct := gotContentTy.Load().(string)
|
||||
if method != http.MethodPost {
|
||||
t.Errorf("method = %s; want POST", method)
|
||||
}
|
||||
if !strings.HasPrefix(path, "/botFAKE_TOKEN_FOR_TEST/") {
|
||||
t.Errorf("unexpected path: %s", path)
|
||||
}
|
||||
if !strings.HasSuffix(path, "/sendMessage") {
|
||||
t.Errorf("expected path to end with /sendMessage, got: %s", path)
|
||||
}
|
||||
if !strings.Contains(ct, "application/x-www-form-urlencoded") {
|
||||
t.Errorf("content-type = %q; want application/x-www-form-urlencoded", ct)
|
||||
}
|
||||
|
||||
formPtr := gotForm.Load()
|
||||
if formPtr == nil {
|
||||
t.Fatal("form body was not captured")
|
||||
}
|
||||
form := *formPtr
|
||||
if got := form.Get("chat_id"); got != "123456789" {
|
||||
t.Errorf("chat_id = %q; want 123456789", got)
|
||||
}
|
||||
if got := form.Get("text"); got != "hello from test" {
|
||||
t.Errorf("text = %q; want %q", got, "hello from test")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegram_Network_BasicAuthProxy(t *testing.T) {
|
||||
var gotAuth atomic.Value
|
||||
gotAuth.Store("")
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth.Store(r.Header.Get("Authorization"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{"id":1,"username":"t"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
authedURL := strings.Replace(srv.URL, "http://", "http://gleb:rokBelHoho@", 1)
|
||||
|
||||
enabled := true
|
||||
cred := &models.NotificationCredential{
|
||||
Kind: models.CredentialKindTelegram,
|
||||
Name: "proxy-bot",
|
||||
APIURL: strPtr(authedURL),
|
||||
Enabled: &enabled,
|
||||
SecretEnc: "plain:TOKEN",
|
||||
}
|
||||
|
||||
if err := Telegram(cred, 1, "x"); err != nil {
|
||||
t.Fatalf("Telegram returned error: %v", err)
|
||||
}
|
||||
got := gotAuth.Load().(string)
|
||||
if !strings.HasPrefix(got, "Basic ") {
|
||||
t.Errorf("expected Basic auth header, got %q", got)
|
||||
}
|
||||
}
|
||||
53
internal/notify/telegram_test.go
Обычный файл
53
internal/notify/telegram_test.go
Обычный файл
@@ -0,0 +1,53 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
func TestTelegram_NilCred(t *testing.T) {
|
||||
if err := Telegram(nil, 123, "x"); err == nil {
|
||||
t.Fatal("expected error for nil cred")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegram_WrongKind(t *testing.T) {
|
||||
cred := &models.NotificationCredential{Kind: models.CredentialKindSMTP}
|
||||
if err := Telegram(cred, 123, "x"); err == nil {
|
||||
t.Fatal("expected error for wrong kind")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegram_EmptyToken(t *testing.T) {
|
||||
enabled := true
|
||||
cred := &models.NotificationCredential{
|
||||
Kind: models.CredentialKindTelegram,
|
||||
Enabled: &enabled,
|
||||
}
|
||||
if err := Telegram(cred, 123, "x"); err == nil {
|
||||
t.Fatal("expected error for empty token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramEndpoint_Default(t *testing.T) {
|
||||
if got := telegramEndpoint(""); got != tgbotapi.APIEndpoint {
|
||||
t.Fatalf("expected default endpoint %q, got %q", tgbotapi.APIEndpoint, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramEndpoint_CustomURL(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"https://api.telegram.org", "https://api.telegram.org/bot%s/%s"},
|
||||
{"https://api.telegram.org/", "https://api.telegram.org/bot%s/%s"},
|
||||
{"https://user:pass@deploy.rscz.ru/", "https://user:pass@deploy.rscz.ru/bot%s/%s"},
|
||||
{"https://deploy.rscz.ru", "https://deploy.rscz.ru/bot%s/%s"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := telegramEndpoint(tc.in); got != tc.want {
|
||||
t.Errorf("telegramEndpoint(%q) = %q; want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Ссылка в новой задаче
Block a user