mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
compose.yml now holds each default as ${VAR:-default}, and the code keeps
no fallback values. The bot stops at startup on a missing or invalid
PORT or LOG_LEVEL, /addsticker refuses without STICKER_PACK_NAME, and the
renderer refuses to start until every RENDERER_* setting is a valid
value, listing each problem. Settings whose empty value means none or
all (MODULES, OWNER_ID, ADMIN_IDS, the API tokens) use ${VAR:-}.
The renderer's npm start and dev load .env when present, so a local run
works from a copy of .env.example.
BREAKING CHANGE: running outside compose now requires LOG_LEVEL and PORT
for the bot, STICKER_PACK_NAME for /addsticker, and every RENDERER_*
variable for the renderer.
396 lines
15 KiB
Go
396 lines
15 KiB
Go
package sticker_test
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/go-telegram/bot/models"
|
|
|
|
"github.com/tiennm99/miti99bot/internal/modules"
|
|
"github.com/tiennm99/miti99bot/internal/modules/sticker"
|
|
"github.com/tiennm99/miti99bot/internal/storage"
|
|
"github.com/tiennm99/miti99bot/internal/testutil"
|
|
)
|
|
|
|
// installSticker builds a registry holding only the sticker module. /addsticker
|
|
// is public, so no auth is needed for it to dispatch.
|
|
func installSticker(t *testing.T) *testutil.RecordingBot {
|
|
t.Helper()
|
|
rb := testutil.NewRecordingBot(t)
|
|
reg, err := modules.Build([]string{"sticker"},
|
|
map[string]modules.Factory{"sticker": sticker.New},
|
|
storage.NewMemoryProvider(), modules.BuildOptions{})
|
|
if err != nil {
|
|
t.Fatalf("Build: %v", err)
|
|
}
|
|
modules.Install(rb.Bot, reg, modules.Auth{})
|
|
return rb
|
|
}
|
|
|
|
// installAddSticker builds the sticker module with the shared pack configured and
|
|
// getMe stubbed.
|
|
//
|
|
// getMe must be stubbed explicitly: the bot runs with WithSkipGetMe(), and
|
|
// RecordingBot's default reply for an unknown method is a bare `true`, which
|
|
// cannot decode into a User. The username matters — it is half of the
|
|
// "_by_<bot_username>" suffix that proves the pack is this bot's.
|
|
func installAddSticker(t *testing.T, packName, botUsername string) *testutil.RecordingBot {
|
|
t.Helper()
|
|
t.Setenv("OWNER_ID", "999")
|
|
t.Setenv("STICKER_PACK_NAME", packName)
|
|
rb := installSticker(t)
|
|
rb.StubMethod("getMe", `{"id":1,"is_bot":true,"username":"`+botUsername+`"}`)
|
|
return rb
|
|
}
|
|
|
|
// stickerReply builds "/addsticker <args>" replying to a static sticker.
|
|
func stickerReply(userID int64, args, fileID, emoji string) *models.Update {
|
|
text := "/addsticker"
|
|
if args != "" {
|
|
text += " " + args
|
|
}
|
|
upd := testutil.NewPrivateMessage(userID, text)
|
|
upd.Message.ReplyToMessage = &models.Message{
|
|
Sticker: &models.Sticker{FileID: fileID, Type: "regular", Emoji: emoji},
|
|
}
|
|
return upd
|
|
}
|
|
|
|
// callTo returns the first recorded call to the named API method.
|
|
func callTo(rb *testutil.RecordingBot, method string) (testutil.SentCall, bool) {
|
|
for _, c := range rb.Sent() {
|
|
if c.Method == method {
|
|
return c, true
|
|
}
|
|
}
|
|
return testutil.SentCall{}, false
|
|
}
|
|
|
|
// The whole point of the shared-pack model: the sticker is added on behalf of
|
|
// the *pack owner*, not the caller, so a non-owner writing to the pack is the
|
|
// normal case rather than a refusal.
|
|
func TestAddSticker_NonOwnerWritesToConfiguredPack(t *testing.T) {
|
|
rb := installAddSticker(t, "shared_by_testbot", "testbot")
|
|
|
|
rb.Bot.ProcessUpdate(context.Background(), stickerReply(7, "", "src-file-id", ""))
|
|
|
|
call, ok := callTo(rb, "addStickerToSet")
|
|
if !ok {
|
|
t.Fatalf("no addStickerToSet call; got %+v", rb.Sent())
|
|
}
|
|
if got := call.Form["user_id"]; got != "999" {
|
|
t.Errorf("user_id = %q, want the pack owner %q", got, "999")
|
|
}
|
|
if got := call.Form["name"]; got != "shared_by_testbot" {
|
|
t.Errorf("name = %q, want %q", got, "shared_by_testbot")
|
|
}
|
|
if got := call.Form["sticker"]; !strings.Contains(got, "src-file-id") {
|
|
t.Errorf("sticker = %q, want it to carry the replied file_id", got)
|
|
}
|
|
rb.AssertSentText(t, "https://t.me/addstickers/shared_by_testbot")
|
|
if _, ok := callTo(rb, "createNewStickerSet"); ok {
|
|
t.Error("createNewStickerSet called for a pack that already exists")
|
|
}
|
|
}
|
|
|
|
// There is no fallback pack: an unset STICKER_PACK_NAME must not write
|
|
// anywhere.
|
|
func TestAddSticker_RequiresPackName(t *testing.T) {
|
|
rb := installAddSticker(t, "", "miti99bot")
|
|
|
|
rb.Bot.ProcessUpdate(context.Background(), stickerReply(999, "", "src", ""))
|
|
|
|
if _, ok := callTo(rb, "addStickerToSet"); ok {
|
|
t.Fatal("addStickerToSet called without a configured pack name")
|
|
}
|
|
if _, ok := callTo(rb, "createNewStickerSet"); ok {
|
|
t.Fatal("createNewStickerSet called without a configured pack name")
|
|
}
|
|
}
|
|
|
|
// Precedence: explicit emoji args beat the replied sticker's own emoji.
|
|
func TestAddSticker_EmojiPrecedence(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
args string
|
|
replyEmoji string
|
|
want string
|
|
}{
|
|
{name: "explicit args win", args: "😂🔥", replyEmoji: "🎉", want: "😂"},
|
|
{name: "inherits replied emoji", args: "", replyEmoji: "🎉", want: "🎉"},
|
|
{name: "falls back to default", args: "", replyEmoji: "", want: "⭐"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
rb := installAddSticker(t, "shared_by_testbot", "testbot")
|
|
|
|
rb.Bot.ProcessUpdate(context.Background(), stickerReply(999, tc.args, "src", tc.replyEmoji))
|
|
|
|
call, ok := callTo(rb, "addStickerToSet")
|
|
if !ok {
|
|
t.Fatalf("no addStickerToSet call; got %+v", rb.Sent())
|
|
}
|
|
if got := call.Form["sticker"]; !strings.Contains(got, tc.want) {
|
|
t.Errorf("sticker = %q, want emoji_list to carry %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A missing set is created rather than reported, seeded with the sticker that
|
|
// triggered it, under the same owner every later add is attributed to.
|
|
func TestAddSticker_CreatesPackWhenMissing(t *testing.T) {
|
|
rb := installAddSticker(t, "shared_by_testbot", "testbot")
|
|
rb.FailMethodCode("addStickerToSet", 400, "Bad Request: STICKERSET_INVALID")
|
|
|
|
rb.Bot.ProcessUpdate(context.Background(), stickerReply(7, "🔥", "src-file-id", ""))
|
|
|
|
call, ok := callTo(rb, "createNewStickerSet")
|
|
if !ok {
|
|
t.Fatalf("no createNewStickerSet call; got %+v", rb.Sent())
|
|
}
|
|
if got := call.Form["user_id"]; got != "999" {
|
|
t.Errorf("user_id = %q, want the pack owner %q", got, "999")
|
|
}
|
|
if got := call.Form["name"]; got != "shared_by_testbot" {
|
|
t.Errorf("name = %q, want %q", got, "shared_by_testbot")
|
|
}
|
|
// The title is the slug half of the name — everything before "_by_<bot>".
|
|
if got := call.Form["title"]; got != "shared" {
|
|
t.Errorf("title = %q, want %q", got, "shared")
|
|
}
|
|
if got := call.Form["stickers"]; !strings.Contains(got, "src-file-id") || !strings.Contains(got, "🔥") {
|
|
t.Errorf("stickers = %q, want the triggering sticker and its emoji", got)
|
|
}
|
|
rb.AssertSentText(t, "Created the shared pack")
|
|
}
|
|
|
|
// A set the add cannot see and the create cannot claim is one this bot cannot
|
|
// write to: created for a different owner, or by another bot.
|
|
func TestAddSticker_NameTakenBySetItCannotManage(t *testing.T) {
|
|
rb := installAddSticker(t, "shared_by_testbot", "testbot")
|
|
rb.FailMethodCode("addStickerToSet", 400, "Bad Request: STICKERSET_INVALID")
|
|
rb.FailMethodCode("createNewStickerSet", 400, "Bad Request: PACK_SHORT_NAME_OCCUPIED")
|
|
|
|
rb.Bot.ProcessUpdate(context.Background(), stickerReply(999, "", "src", ""))
|
|
|
|
rb.AssertSentText(t, "already exists and this bot cannot manage it")
|
|
}
|
|
|
|
// The "_by_<bot_username>" suffix is Telegram's proof of authorship, so a
|
|
// mismatch is provable offline — before any download, upload, or API call.
|
|
func TestAddSticker_RefusesPackNotCreatedByThisBot(t *testing.T) {
|
|
for _, packName := range []string{"shared_by_otherbot", "no_suffix_at_all", "_by_testbot"} {
|
|
t.Run(packName, func(t *testing.T) {
|
|
rb := installAddSticker(t, packName, "testbot")
|
|
|
|
rb.Bot.ProcessUpdate(context.Background(), stickerReply(999, "", "src", ""))
|
|
|
|
rb.AssertSentText(t, "not one this bot can manage")
|
|
if _, ok := callTo(rb, "addStickerToSet"); ok {
|
|
t.Error("addStickerToSet called for a pack this bot cannot manage")
|
|
}
|
|
if _, ok := callTo(rb, "createNewStickerSet"); ok {
|
|
t.Error("createNewStickerSet called for a name this bot cannot use")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAddSticker_NoReply_ShowsUsage(t *testing.T) {
|
|
rb := installAddSticker(t, "shared_by_testbot", "testbot")
|
|
|
|
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/addsticker"))
|
|
|
|
rb.AssertSentText(t, "Reply to a sticker, image, video or GIF")
|
|
if _, ok := callTo(rb, "addStickerToSet"); ok {
|
|
t.Error("addStickerToSet called with nothing to add")
|
|
}
|
|
}
|
|
|
|
// A misconfigured owner is not the caller's fault and must not leak the bot's
|
|
// own env into a reply.
|
|
func TestAddSticker_OwnerIDUnset_RepliesGenerically(t *testing.T) {
|
|
rb := installAddSticker(t, "shared_by_testbot", "testbot")
|
|
t.Setenv("OWNER_ID", "") // overrides the helper's value for this test
|
|
|
|
rb.Bot.ProcessUpdate(context.Background(), stickerReply(999, "", "src", ""))
|
|
|
|
got := rb.LastSent().Text()
|
|
if !strings.Contains(got, "Something went wrong") {
|
|
t.Errorf("reply = %q, want the generic failure", got)
|
|
}
|
|
if strings.Contains(got, "OWNER_ID") {
|
|
t.Errorf("reply leaked the env var name: %q", got)
|
|
}
|
|
if _, ok := callTo(rb, "addStickerToSet"); ok {
|
|
t.Error("addStickerToSet called without a pack owner")
|
|
}
|
|
}
|
|
|
|
// A sticker already in a set satisfies its format's rules, so it is copied by
|
|
// file_id with no conversion — the format just has to be reported correctly.
|
|
func TestAddSticker_CopiesEachStickerFormat(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
sticker *models.Sticker
|
|
want string
|
|
}{
|
|
{name: "static", sticker: &models.Sticker{FileID: "src", Type: "regular"}, want: "static"},
|
|
{name: "animated", sticker: &models.Sticker{FileID: "src", IsAnimated: true}, want: "animated"},
|
|
{name: "video", sticker: &models.Sticker{FileID: "src", IsVideo: true}, want: "video"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
rb := installAddSticker(t, "shared_by_testbot", "testbot")
|
|
upd := testutil.NewPrivateMessage(999, "/addsticker")
|
|
upd.Message.ReplyToMessage = &models.Message{Sticker: tc.sticker}
|
|
|
|
rb.Bot.ProcessUpdate(context.Background(), upd)
|
|
|
|
call, ok := callTo(rb, "addStickerToSet")
|
|
if !ok {
|
|
t.Fatalf("no addStickerToSet call; got %+v", rb.Sent())
|
|
}
|
|
if got := call.Form["sticker"]; !strings.Contains(got, `"format":"`+tc.want+`"`) {
|
|
t.Errorf("sticker = %q, want format %q", got, tc.want)
|
|
}
|
|
// No upload: a file_id copy never touches uploadStickerFile.
|
|
if _, ok := callTo(rb, "uploadStickerFile"); ok {
|
|
t.Error("uploadStickerFile called for a file_id copy")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Format is per-sticker but sticker_type is fixed at creation, and the shared
|
|
// pack is regular — so these are refused whatever format they are in.
|
|
func TestAddSticker_RejectsMaskAndCustomEmojiStickers(t *testing.T) {
|
|
for _, stickerType := range []string{"mask", "custom_emoji"} {
|
|
t.Run(stickerType, func(t *testing.T) {
|
|
rb := installAddSticker(t, "shared_by_testbot", "testbot")
|
|
upd := testutil.NewPrivateMessage(999, "/addsticker")
|
|
upd.Message.ReplyToMessage = &models.Message{
|
|
Sticker: &models.Sticker{FileID: "src", Type: stickerType, IsVideo: true},
|
|
}
|
|
|
|
rb.Bot.ProcessUpdate(context.Background(), upd)
|
|
|
|
rb.AssertSentText(t, "mask or custom-emoji")
|
|
if _, ok := callTo(rb, "addStickerToSet"); ok {
|
|
t.Error("addStickerToSet called with a mask or custom-emoji source")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Moving sources take the transcode path, which begins with getFile. Asserting
|
|
// the routing rather than the finished sticker keeps the test independent of
|
|
// whether the host has ffmpeg — the conversion itself is covered by
|
|
// TestToStickerWEBM_* against real ffmpeg.
|
|
func TestAddSticker_RoutesMovingSourcesToTheVideoPath(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
replied *models.Message
|
|
}{
|
|
{name: "animation (telegram GIF)", replied: &models.Message{Animation: &models.Animation{FileID: "src", MimeType: "video/mp4"}}},
|
|
{name: "video", replied: &models.Message{Video: &models.Video{FileID: "src", MimeType: "video/mp4"}}},
|
|
{name: "video note", replied: &models.Message{VideoNote: &models.VideoNote{FileID: "src"}}},
|
|
{name: "gif document", replied: &models.Message{Document: &models.Document{FileID: "src", MimeType: "image/gif"}}},
|
|
{name: "mp4 document", replied: &models.Message{Document: &models.Document{FileID: "src", MimeType: "video/mp4"}}},
|
|
{name: "webm document", replied: &models.Message{Document: &models.Document{FileID: "src", MimeType: "video/webm"}}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
rb := installAddSticker(t, "shared_by_testbot", "testbot")
|
|
upd := testutil.NewPrivateMessage(999, "/addsticker")
|
|
upd.Message.ReplyToMessage = tc.replied
|
|
|
|
rb.Bot.ProcessUpdate(context.Background(), upd)
|
|
|
|
if _, ok := callTo(rb, "getFile"); !ok {
|
|
t.Errorf("getFile not called; the source did not reach the video path: %+v", rb.Sent())
|
|
}
|
|
// The stubbed download cannot yield a real clip, so the add must
|
|
// never happen — what matters is that it failed at the conversion
|
|
// rather than being refused as an unsupported reply.
|
|
if _, ok := callTo(rb, "addStickerToSet"); ok {
|
|
t.Error("addStickerToSet called with an unconvertible source")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// An oversized clip is refused from the message metadata alone, so nothing is
|
|
// downloaded and ffmpeg is never reached.
|
|
func TestAddSticker_RefusesOversizedVideo(t *testing.T) {
|
|
rb := installAddSticker(t, "shared_by_testbot", "testbot")
|
|
upd := testutil.NewPrivateMessage(999, "/addsticker")
|
|
upd.Message.ReplyToMessage = &models.Message{
|
|
Video: &models.Video{FileID: "src", MimeType: "video/mp4", FileSize: 11 << 20},
|
|
}
|
|
|
|
rb.Bot.ProcessUpdate(context.Background(), upd)
|
|
|
|
rb.AssertSentText(t, "too large")
|
|
for _, method := range []string{"getFile", "uploadStickerFile", "addStickerToSet"} {
|
|
if _, ok := callTo(rb, method); ok {
|
|
t.Errorf("%s called for an oversized source", method)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A document that is neither a supported image nor convertible video still
|
|
// gets the image advice, not a video error.
|
|
func TestAddSticker_RefusesUnsupportedDocument(t *testing.T) {
|
|
rb := installAddSticker(t, "shared_by_testbot", "testbot")
|
|
upd := testutil.NewPrivateMessage(999, "/addsticker")
|
|
upd.Message.ReplyToMessage = &models.Message{
|
|
Document: &models.Document{FileID: "src", MimeType: "application/pdf"},
|
|
}
|
|
|
|
rb.Bot.ProcessUpdate(context.Background(), upd)
|
|
|
|
rb.AssertSentText(t, "not a supported image")
|
|
if _, ok := callTo(rb, "getFile"); ok {
|
|
t.Error("getFile called for an unsupported document")
|
|
}
|
|
}
|
|
|
|
func TestAddSticker_NonEmojiArgRefused(t *testing.T) {
|
|
rb := installAddSticker(t, "shared_by_testbot", "testbot")
|
|
|
|
rb.Bot.ProcessUpdate(context.Background(), stickerReply(999, "hello", "src", ""))
|
|
|
|
rb.AssertSentText(t, "is not an emoji")
|
|
if _, ok := callTo(rb, "addStickerToSet"); ok {
|
|
t.Error("addStickerToSet called with a non-emoji argument")
|
|
}
|
|
}
|
|
|
|
// Telegram's MTProto codes arrive as "Bad Request: <CODE>" and must be
|
|
// translated, never echoed.
|
|
func TestAddSticker_APIRefusalsTranslated(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
description string
|
|
want string
|
|
}{
|
|
{name: "pack full", description: "Bad Request: STICKERS_TOO_MUCH", want: "full (120 stickers)"},
|
|
{name: "bad emoji", description: "Bad Request: invalid sticker emojis", want: "rejected those emoji"},
|
|
{name: "bad name", description: "Bad Request: PACK_SHORT_NAME_INVALID", want: "rejected the shared pack's name"},
|
|
{name: "unmapped", description: "Bad Request: SOMETHING_NEW", want: "Something went wrong"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
rb := installAddSticker(t, "shared_by_testbot", "testbot")
|
|
rb.FailMethodCode("addStickerToSet", 400, tc.description)
|
|
|
|
rb.Bot.ProcessUpdate(context.Background(), stickerReply(999, "", "src", ""))
|
|
|
|
rb.AssertSentText(t, tc.want)
|
|
})
|
|
}
|
|
}
|