From d002f931ff282e6fdd9fb22130332e2d301f987b Mon Sep 17 00:00:00 2001 From: Valeriy Selitskiy <239034+iamwavecut@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:58:42 +0200 Subject: [PATCH 1/6] Fix spam decision boundary --- internal/handlers/moderation/spam_detector.go | 33 +++++- .../handlers/moderation/spam_detector_test.go | 105 ++++++++++++++++++ 2 files changed, 133 insertions(+), 5 deletions(-) diff --git a/internal/handlers/moderation/spam_detector.go b/internal/handlers/moderation/spam_detector.go index 8bc7074..2de64d0 100644 --- a/internal/handlers/moderation/spam_detector.go +++ b/internal/handlers/moderation/spam_detector.go @@ -48,7 +48,7 @@ var examples = []example{ {Message: "Хочешь зарабатывать на удалёнке но не знаешь как? Напиши мне и я тебе всё расскажу, от 18 лет. жду всех желающих в лс.", Response: 1}, {Message: "Нужны люди! Стабильнный доход, каждую неделю, на удалёнке, от 18 лет, пишите в лс.", Response: 1}, {Message: "Ищу людeй, заинтeрeсованных в хoрoшем доп.доходе на удаленке. Не полная занятость, от 21. По вопросам пишите в ЛС", Response: 1}, - {Message: "10000х Орууу в других играл и такого не разу не было, просто капец а такое возможно???? ", Response: 1}, + {Message: "10000х Орууу в других играл и такого не разу не было, просто капец а такое возможно???? ", Response: 0}, {Message: `🥇Первая игровая платформа в Telegram https://t.me/jetton?start=cdyrsJsbvYy @@ -74,6 +74,14 @@ t.me/slotsTON_BOT?start=cdyoNKvXn75`, Response: 1}, {Message: "???", Response: 0}, {Message: "...", Response: 0}, {Message: "Да", Response: 0}, + {Message: "уииии! долой гегемонию штатов!", Response: 0}, + {Message: "Долой монополии! Это уже ни в какие ворота.", Response: 0}, + {Message: "Down with corporate monopolies! Enough is enough.", Response: 0}, + {Message: "Долой гегемонию штатов! Есть удалённая работа с доходом 500 $ в день, пишите в ЛС", Response: 1}, + {Message: "ждём ускоренный квен3.8-27", Response: 0}, + {Message: "Надеюсь, следующая версия модели будет быстрее", Response: 0}, + {Message: "Waiting for a faster Qwen 3.8 27B release", Response: 0}, + {Message: "Ждём ускоренный Qwen 3.8 27B! А пока предлагаю удалённую работу с доходом 500 $ в день, пишите в ЛС", Response: 1}, {Message: "Ищу людей, возьму 2-3 человека 18+ Удаленная деятельность.От 250$ в день.Кому интересно: Пишите + в лс", Response: 1}, {Message: "Нужны люди, занятость на удалёнке", Response: 1}, {Message: "3дpaвcтвyйтe,Веду поиск пaртнёров для сoтруднuчества ,свoбoдный гpaфик ,пpuятный зapaбoтok eженeдельно. Ecли интepecуeт пoдpoбнaя инфopмaция пишuте.", Response: 1}, @@ -249,6 +257,19 @@ func newClassificationExample(message string, response int) classificationExampl } } +const spamDecisionBoundary = ` +Правило решения: +- Примеры ниже иллюстрируют границы правил и не являются голосованием: количество примеров класса 1 не повышает вероятность спама. +- Ставь 1 только если в сообщении есть хотя бы один из перечисленных признаков спама. +- Обычные короткие реплики, обсуждение технологий, моделей, версий, новостей, ожиданий и мнений сами по себе не являются признаками спама. +- Политические мнения, лозунги, критика стран, организаций или идеологий, шутки, грубость и эмоциональность сами по себе не являются признаками спама. +- Краткость, эмоциональность, названия моделей, номера версий и числа сами по себе не являются признаками спама. +- Умышленная замена букв похожими символами другого алфавита без самостоятельного признака спама не делает сообщение спамом. +- Эмодзи сами по себе не являются признаком спама. +- Если нет ни одного признака спама или уверенности недостаточно, ставь 0. +- Наличие обычной или политической фразы не отменяет другие признаки: если к ней добавлены реклама заработка, казино, реферальная ссылка, деанонимизация или призыв написать в личные сообщения, ставь 1. +` + const spamDetectionPrompt = `Ты ассистент для обнаружения спама, анализирующий сообщения на различных языках. Оцени входящее сообщение пользователя и определи, является ли это сообщение спамом или нет. Признаки спама: @@ -257,8 +278,7 @@ const spamDetectionPrompt = `Ты ассистент для обнаружени - Продвижение азартных игр/финансовых схем. - Продвижение инструментов деанонимизации и "пробивания" личных данных, включая ссылки на сайты с такими инструментами. - Внешние ссылки с явными реферальными кодами и GET параметрами вроде "?ref=", "/ref", "invite" и т.п. -- Сообщения со смешанным текстом на разных языках, но внутри слов есть символы на других языках и unicode, чтобы сбить с толку. -- Сообщения, соостоящие преимущественно из эмодзи. +- Умышленная маскировка рекламного или мошеннического сообщения заменой букв внутри обычных слов на похожие символы другого алфавита с целью обхода фильтра. Исключения: - Сообщения, связанные с домашними животными (часто о потерянных питомцах) @@ -266,6 +286,8 @@ const spamDetectionPrompt = `Ты ассистент для обнаружени - Ссылки на обычные вебсайты, не являющиеся реферальными ссылками. - Рекомендации по услугам, товарам, курсам и т.п. +` + spamDecisionBoundary + ` + Отвечай ТОЛЬКО следующими ответами: если сообщение скорее всего является спамом: 1 если сообщение скорее всего не является спамом: 0 @@ -281,8 +303,7 @@ const reportedSpamDetectionPrompt = `Ты ассистент для повтор - Продвижение азартных игр/финансовых схем. - Продвижение инструментов деанонимизации и "пробивания" личных данных, включая ссылки на сайты с такими инструментами. - Внешние ссылки с явными реферальными кодами и GET параметрами вроде "?ref=", "/ref", "invite" и т.п. -- Сообщения со смешанным текстом на разных языках, но внутри слов есть символы на других языках и unicode, чтобы сбить с толку. -- Сообщения, состоящие преимущественно из эмодзи. +- Умышленная маскировка рекламного или мошеннического сообщения заменой букв внутри обычных слов на похожие символы другого алфавита с целью обхода фильтра. Исключения: - Сообщения, связанные с домашними животными. @@ -290,6 +311,8 @@ const reportedSpamDetectionPrompt = `Ты ассистент для повтор - Ссылки на обычные вебсайты, не являющиеся реферальными ссылками. - Рекомендации по услугам, товарам, курсам и т.п. +` + spamDecisionBoundary + ` + Так как это повторная проверка по жалобе, будь внимателен к завуалированному рекламному/мошенническому тексту, но не подтверждай спам без признаков из политики. Отвечай ТОЛЬКО следующими ответами: diff --git a/internal/handlers/moderation/spam_detector_test.go b/internal/handlers/moderation/spam_detector_test.go index c200d89..40835c8 100644 --- a/internal/handlers/moderation/spam_detector_test.go +++ b/internal/handlers/moderation/spam_detector_test.go @@ -74,6 +74,111 @@ func TestSpamDetectorIncludesExtraExamplesInPrompt(t *testing.T) { } } +func TestSpamDetectorIncludesBenignConversationBoundaryExamples(t *testing.T) { + t.Parallel() + const candidate = "ждём ускоренный квен3.8-27" + + llmStub := &spamDetectorTestLLM{ + response: llm.ChatCompletionResponse{ + Choices: []llm.ChatCompletionChoice{ + {Message: llm.ChatCompletionMessage{Role: llm.RoleAssistant, Content: "0"}}, + }, + }, + } + detector := NewSpamDetector(llmStub, log.New().WithField("test", "spam_detector"), time.Minute) + result, err := detector.IsSpam(t.Context(), candidate, nil) + if err != nil { + t.Fatalf("IsSpam returned error: %v", err) + } + if result == nil || *result { + t.Fatalf("expected benign candidate to be non-spam, got %v", result) + } + + request := decodeClassificationRequest(t, llmStub.lastMessages[1].Content) + if request.Candidate.Message != candidate || request.Candidate.MessageBytes != len([]byte(candidate)) { + t.Fatalf("unexpected framed candidate: %#v", request.Candidate) + } + want := map[string]int{ + "10000х Орууу в других играл и такого не разу не было, просто капец а такое возможно???? ": 0, + "уииии! долой гегемонию штатов!": 0, + "Долой монополии! Это уже ни в какие ворота.": 0, + "Down with corporate monopolies! Enough is enough.": 0, + "Долой гегемонию штатов! Есть удалённая работа с доходом 500 $ в день, пишите в ЛС": 1, + "ждём ускоренный квен3.8-27": 0, + "Надеюсь, следующая версия модели будет быстрее": 0, + "Waiting for a faster Qwen 3.8 27B release": 0, + "Ждём ускоренный Qwen 3.8 27B! А пока предлагаю удалённую работу с доходом 500 $ в день, пишите в ЛС": 1, + } + for _, example := range request.Examples { + if classification, ok := want[example.Message]; ok { + if example.Classification != classification { + t.Fatalf("example %q classification = %d, want %d", example.Message, example.Classification, classification) + } + delete(want, example.Message) + } + } + if len(want) != 0 { + t.Fatalf("classification request is missing benign-conversation boundary examples: %#v", want) + } +} + +func TestSpamDetectorPromptsRequireExplicitSpamEvidence(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + check func(*spamDetector) (*bool, error) + }{ + { + name: "initial classification", + check: func(detector *spamDetector) (*bool, error) { + return detector.IsSpam(t.Context(), "candidate", nil) + }, + }, + { + name: "reported classification", + check: func(detector *spamDetector) (*bool, error) { + return detector.IsReportedSpam(t.Context(), "candidate", nil) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + llmStub := &spamDetectorTestLLM{ + response: llm.ChatCompletionResponse{ + Choices: []llm.ChatCompletionChoice{ + {Message: llm.ChatCompletionMessage{Role: llm.RoleAssistant, Content: "0"}}, + }, + }, + } + detector := NewSpamDetector(llmStub, log.New().WithField("test", "spam_detector"), time.Minute) + if _, err := tt.check(detector); err != nil { + t.Fatalf("classification returned error: %v", err) + } + + prompt := llmStub.lastMessages[0].Content + for _, required := range []string{ + "ставь 1 только", + "не являются голосованием", + "количество примеров класса 1", + "политические мнения", + "обсуждение технологий", + "названия моделей", + "умышленная замена букв", + "эмодзи сами по себе", + "сами по себе не являются признаками спама", + "если нет ни одного признака спама", + } { + if !strings.Contains(strings.ToLower(prompt), required) { + t.Fatalf("prompt does not enforce %q boundary: %q", required, prompt) + } + } + }) + } +} + func TestSpamDetectorFramesMaliciousAdminExamplesAsUntrustedData(t *testing.T) { t.Parallel() From 522993bfbde71188035b82319d8617524c240cd0 Mon Sep 17 00:00:00 2001 From: Valeriy Selitskiy <239034+iamwavecut@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:58:46 +0200 Subject: [PATCH 2/6] Update Go to 1.25.13 --- Dockerfile | 2 +- go.mod | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index e0ffa16..6838b51 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1.7 -FROM golang:1.25.12-alpine AS build +FROM golang:1.25.13-alpine AS build HEALTHCHECK NONE ARG VERSION=dev diff --git a/go.mod b/go.mod index 2b16ce9..b17ef6b 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/iamwavecut/ngbot -go 1.25.12 +go 1.25.13 require ( github.com/OvyFlash/telegram-bot-api v0.0.0-20260715235732-aca8bf3898bb From 924dd4d215ecb24d8613ddf78f60234e1b95760b Mon Sep 17 00:00:00 2001 From: Valeriy Selitskiy <239034+iamwavecut@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:12:48 +0200 Subject: [PATCH 3/6] Deduplicate test literals --- internal/config/config_test.go | 44 +++++++++++-------- internal/db/sqlite/client_challenges_test.go | 6 +-- internal/db/sqlite/client_spam_report_test.go | 8 ++-- .../db/sqlite/client_update_inbox_test.go | 12 ++--- internal/db/sqlite/test_constants_test.go | 8 ++++ internal/handlers/chat/banlist_guard_test.go | 6 +-- .../chat/reactor_message_pipeline_test.go | 8 ++-- internal/handlers/chat/test_bot_api_test.go | 1 + 8 files changed, 54 insertions(+), 39 deletions(-) create mode 100644 internal/db/sqlite/test_constants_test.go diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5bbc125..8b10b07 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -6,10 +6,16 @@ import ( "time" ) +const ( + testGatekeeperHandler = "gatekeeper" + testGeminiAPIKey = "gemini-key" + testOpenAIAPIKey = "openai-key" +) + func TestLoadUsesProviderSpecificCredential(t *testing.T) { t.Setenv("NG_TOKEN", "telegram-token") t.Setenv("NG_HANDLERS", "reactor") - t.Setenv("NG_LLM_API_TYPE", "gemini") + t.Setenv("NG_LLM_API_TYPE", LLMProviderGemini) t.Setenv("NG_LLM_GEMINI_API_KEY", "gemini-specific") t.Setenv("NG_LLM_OPENAI_API_KEY", "openai-unused") t.Setenv("NG_LLM_API_KEY", "legacy-unused") @@ -32,7 +38,7 @@ func TestLoadUsesProviderSpecificCredential(t *testing.T) { func TestLoadUsesBoundedTelegramInboxDefaults(t *testing.T) { t.Setenv("NG_TOKEN", "telegram-token") t.Setenv("NG_HANDLERS", "reactor") - t.Setenv("NG_LLM_API_TYPE", "gemini") + t.Setenv("NG_LLM_API_TYPE", LLMProviderGemini) t.Setenv("NG_LLM_GEMINI_API_KEY", "gemini-specific") t.Setenv("NG_DOT_PATH", t.TempDir()) @@ -55,7 +61,7 @@ func TestLoadDefaultsNativeWebAppToLoopback(t *testing.T) { t.Setenv("NG_TOKEN", "telegram-token") t.Setenv("NG_HANDLERS", "admin,gatekeeper") t.Setenv("NG_LLM_API_TYPE", LLMProviderGemini) - t.Setenv("NG_LLM_GEMINI_API_KEY", "gemini-key") + t.Setenv("NG_LLM_GEMINI_API_KEY", testGeminiAPIKey) t.Setenv("NG_DOT_PATH", t.TempDir()) t.Setenv("NG_TELEGRAM_POLL_TIMEOUT", "60s") t.Setenv("NG_TELEGRAM_REQUEST_TIMEOUT", "75s") @@ -103,7 +109,7 @@ func TestValidateConfigRejectsInvalidWebAppAdmissionLimits(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() cfg := validConfigForLLM() - cfg.EnabledHandlers = []string{"admin", "gatekeeper"} + cfg.EnabledHandlers = []string{"admin", testGatekeeperHandler} cfg.GatekeeperWebApp = tt.webApp err := validateConfig(&cfg) if err == nil || !strings.Contains(err.Error(), tt.want) { @@ -116,7 +122,7 @@ func TestValidateConfigRejectsInvalidWebAppAdmissionLimits(t *testing.T) { func TestValidateConfigRejectsTelegramTimeoutAtOrBeyondActionLease(t *testing.T) { t.Parallel() cfg := Config{ - EnabledHandlers: []string{"gatekeeper"}, + EnabledHandlers: []string{testGatekeeperHandler}, Telegram: Telegram{PollTimeout: time.Minute, RequestTimeout: 2 * time.Minute, RecoveryWindow: 10 * time.Minute}, SpamControl: SpamControl{MessageProbationDuration: time.Hour}, } @@ -263,7 +269,7 @@ func TestValidateConfig(t *testing.T) { EnabledHandlers: []string{"reactor"}, LLM: LLM{ APIKey: "legacy-key", - Type: "gemini", + Type: LLMProviderGemini, }, SpamControl: SpamControl{MessageProbationDuration: 3 * time.Hour}, Telegram: Telegram{ @@ -295,7 +301,7 @@ func TestValidateConfig(t *testing.T) { tt.cfg.GatekeeperWebApp.RequestsPerMinute = 120 if tt.cfg.LLM.Type == "" { tt.cfg.LLM.Type = LLMProviderGemini - tt.cfg.LLM.GeminiAPIKey = "gemini-key" + tt.cfg.LLM.GeminiAPIKey = testGeminiAPIKey } err := validateConfig(&tt.cfg) @@ -323,13 +329,13 @@ func TestValidateConfigNormalizesProviderConfiguration(t *testing.T) { if err := validateConfig(&cfg); err != nil { t.Fatalf("validateConfig returned error: %v", err) } - if cfg.LLM.Type != "gemini" { + if cfg.LLM.Type != LLMProviderGemini { t.Fatalf("provider = %q, want gemini", cfg.LLM.Type) } if cfg.LLM.Model != "gemini-2.5-flash-lite" { t.Fatalf("model = %q", cfg.LLM.Model) } - if cfg.LLM.GeminiAPIKey != "gemini-key" { + if cfg.LLM.GeminiAPIKey != testGeminiAPIKey { t.Fatalf("gemini key was not normalized") } if cfg.LLM.BaseURL != "https://api.openai.com/v1" { @@ -347,34 +353,34 @@ func TestValidateConfigRequiresOnlySelectedProviderCredential(t *testing.T) { }{ { name: "Gemini provider-specific credential", - llm: LLM{Type: "gemini", GeminiAPIKey: "gemini-key", RequestTimeout: 45 * time.Second}, + llm: LLM{Type: LLMProviderGemini, GeminiAPIKey: testGeminiAPIKey, RequestTimeout: 45 * time.Second}, }, { name: "OpenAI provider-specific credential", - llm: LLM{Type: "openai", OpenAIAPIKey: "openai-key", BaseURL: "https://api.openai.com/v1", RequestTimeout: 45 * time.Second}, + llm: LLM{Type: "openai", OpenAIAPIKey: testOpenAIAPIKey, BaseURL: "https://api.openai.com/v1", RequestTimeout: 45 * time.Second}, }, { name: "legacy Gemini credential fallback", - llm: LLM{Type: "gemini", APIKey: "legacy-key", RequestTimeout: 45 * time.Second}, + llm: LLM{Type: LLMProviderGemini, APIKey: "legacy-key", RequestTimeout: 45 * time.Second}, }, { name: "selected credential missing", - llm: LLM{Type: "gemini", OpenAIAPIKey: "wrong-provider-key", RequestTimeout: 45 * time.Second}, + llm: LLM{Type: LLMProviderGemini, OpenAIAPIKey: "wrong-provider-key", RequestTimeout: 45 * time.Second}, wantErr: true, }, { name: "unsupported provider is not inferred from available key", - llm: LLM{Type: "other", GeminiAPIKey: "gemini-key", OpenAIAPIKey: "openai-key", RequestTimeout: 45 * time.Second}, + llm: LLM{Type: "other", GeminiAPIKey: testGeminiAPIKey, OpenAIAPIKey: testOpenAIAPIKey, RequestTimeout: 45 * time.Second}, wantErr: true, }, { name: "OpenAI endpoint must use HTTPS", - llm: LLM{Type: "openai", OpenAIAPIKey: "openai-key", BaseURL: "http://api.openai.com/v1", RequestTimeout: 45 * time.Second}, + llm: LLM{Type: "openai", OpenAIAPIKey: testOpenAIAPIKey, BaseURL: "http://api.openai.com/v1", RequestTimeout: 45 * time.Second}, wantErr: true, }, { name: "model must be one identifier", - llm: LLM{Type: "gemini", GeminiAPIKey: "gemini-key", Model: "two models", RequestTimeout: 45 * time.Second}, + llm: LLM{Type: LLMProviderGemini, GeminiAPIKey: testGeminiAPIKey, Model: "two models", RequestTimeout: 45 * time.Second}, wantErr: true, }, } @@ -399,7 +405,7 @@ func TestValidateConfigRequiresLLMForMandatoryModeration(t *testing.T) { t.Parallel() cfg := validConfigForLLM() - cfg.EnabledHandlers = []string{"admin", "gatekeeper"} + cfg.EnabledHandlers = []string{"admin", testGatekeeperHandler} cfg.LLM = LLM{} if err := validateConfig(&cfg); err == nil { t.Fatal("mandatory moderation accepted an empty LLM configuration") @@ -407,7 +413,7 @@ func TestValidateConfigRequiresLLMForMandatoryModeration(t *testing.T) { cfg.LLM = LLM{ Type: LLMProviderOpenAI, - OpenAIAPIKey: "openai-key", + OpenAIAPIKey: testOpenAIAPIKey, BaseURL: "http://api.example.test/v1", RequestTimeout: 45 * time.Second, } @@ -421,7 +427,7 @@ func validConfigForLLM() Config { EnabledHandlers: []string{"reactor"}, LLM: LLM{ Type: LLMProviderGemini, - GeminiAPIKey: "gemini-key", + GeminiAPIKey: testGeminiAPIKey, RequestTimeout: 45 * time.Second, }, SpamControl: SpamControl{MessageProbationDuration: 3 * time.Hour}, diff --git a/internal/db/sqlite/client_challenges_test.go b/internal/db/sqlite/client_challenges_test.go index cb7c4cd..71a6eb6 100644 --- a/internal/db/sqlite/client_challenges_test.go +++ b/internal/db/sqlite/client_challenges_test.go @@ -29,7 +29,7 @@ func TestChallengeGenerationRejectsStaleOperations(t *testing.T) { UserID: 2, ChatID: 3, Status: db.ChallengeStatusPending, - SuccessUUID: "first", + SuccessUUID: testFirstValue, CreatedAt: now, ExpiresAt: now.Add(time.Minute), } @@ -623,7 +623,7 @@ func TestBanCheckBoundaryCannotBeOverwritten(t *testing.T) { } t.Cleanup(func() { _ = client.Close() }) now := time.Now() - first := &db.Challenge{CommChatID: 11, UserID: 12, ChatID: -13, Status: db.ChallengeStatusBanCheckPending, JoinRequestQueryID: "first", CreatedAt: now, ExpiresAt: now.Add(time.Minute)} + first := &db.Challenge{CommChatID: 11, UserID: 12, ChatID: -13, Status: db.ChallengeStatusBanCheckPending, JoinRequestQueryID: testFirstValue, CreatedAt: now, ExpiresAt: now.Add(time.Minute)} if _, err := client.CreateChallenge(t.Context(), first); err != nil { t.Fatal(err) } @@ -632,7 +632,7 @@ func TestBanCheckBoundaryCannotBeOverwritten(t *testing.T) { t.Fatalf("duplicate boundary overwrite error=%v", err) } stored, err := client.GetChallengeByChatUser(t.Context(), first.ChatID, first.UserID) - if err != nil || stored == nil || stored.JoinRequestQueryID != "first" { + if err != nil || stored == nil || stored.JoinRequestQueryID != testFirstValue { t.Fatalf("boundary overwritten: %#v err=%v", stored, err) } } diff --git a/internal/db/sqlite/client_spam_report_test.go b/internal/db/sqlite/client_spam_report_test.go index 90eb0a5..c92dedc 100644 --- a/internal/db/sqlite/client_spam_report_test.go +++ b/internal/db/sqlite/client_spam_report_test.go @@ -25,7 +25,7 @@ func TestSetSpamCasePreVoteRestrictedOnlyUpdatesPendingCase(t *testing.T) { ChatID: -100, UserID: 200, MessageID: 40, - MessageText: "candidate", + MessageText: testCandidateMessage, CreatedAt: now, ResolveAt: &resolveAt, Status: db.SpamCaseStatusPending, @@ -192,7 +192,7 @@ func TestSpamVoteAndTimeoutRaceHasOneResolutionAndOneStat(t *testing.T) { ChatID: -100, UserID: 200, MessageID: 40, - MessageText: "candidate", + MessageText: testCandidateMessage, CreatedAt: now, ResolveAt: &resolveAt, Status: db.SpamCaseStatusPending, @@ -296,7 +296,7 @@ func TestSpamResolutionAndReportQueueSurviveReopen(t *testing.T) { spamCase, err := client.CreateSpamCase(ctx, &db.SpamCase{ ChatID: -100, UserID: 200, - MessageText: "candidate", + MessageText: testCandidateMessage, CreatedAt: now, ResolveAt: &resolveAt, Status: db.SpamCaseStatusPending, @@ -539,7 +539,7 @@ func TestAddRestrictionRefreshesPersistedState(t *testing.T) { UserID: userID, RestrictedAt: now, ExpiresAt: now.Add(time.Hour), - Reason: "first", + Reason: testFirstValue, } if err := client.AddRestriction(ctx, first); err != nil { t.Fatalf("add first restriction: %v", err) diff --git a/internal/db/sqlite/client_update_inbox_test.go b/internal/db/sqlite/client_update_inbox_test.go index 51b8037..b5e968a 100644 --- a/internal/db/sqlite/client_update_inbox_test.go +++ b/internal/db/sqlite/client_update_inbox_test.go @@ -52,8 +52,8 @@ func TestTelegramUpdateInboxAdmissionBoundsPendingRowsAndBytes(t *testing.T) { }) for _, update := range []*db.TelegramUpdate{ - {UpdateID: 1, DispatchKey: "chat:1", Payload: []byte("1234"), ReceivedAt: time.Now()}, - {UpdateID: 2, DispatchKey: "chat:1", Payload: []byte("5678"), ReceivedAt: time.Now()}, + {UpdateID: 1, DispatchKey: testDispatchKeyChatOne, Payload: []byte("1234"), ReceivedAt: time.Now()}, + {UpdateID: 2, DispatchKey: testDispatchKeyChatOne, Payload: []byte("5678"), ReceivedAt: time.Now()}, } { inserted, enqueueErr := client.EnqueueTelegramUpdate(t.Context(), update) if enqueueErr != nil || !inserted { @@ -62,7 +62,7 @@ func TestTelegramUpdateInboxAdmissionBoundsPendingRowsAndBytes(t *testing.T) { } inserted, err := client.EnqueueTelegramUpdate(t.Context(), &db.TelegramUpdate{ - UpdateID: 3, DispatchKey: "chat:1", Payload: []byte("security"), SecurityRelevant: true, ReceivedAt: time.Now(), + UpdateID: 3, DispatchKey: testDispatchKeyChatOne, Payload: []byte("security"), SecurityRelevant: true, ReceivedAt: time.Now(), }) if inserted || !errors.Is(err, db.ErrTelegramUpdateInboxCapacity) { t.Fatalf("per-key overload = inserted=%t err=%v, want capacity backpressure", inserted, err) @@ -137,7 +137,7 @@ func TestTelegramUpdateInboxDeduplicatesAndPreservesOriginalPayload(t *testing.T receivedAt := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) inserted, err := client.EnqueueTelegramUpdate(t.Context(), &db.TelegramUpdate{ UpdateID: 100, - DispatchKey: "chat:-10", + DispatchKey: testDispatchKeyChatMinus10, Payload: []byte(`{"update_id":100,"message":{"text":"first"}}`), SecurityRelevant: true, ReceivedAt: receivedAt, @@ -162,7 +162,7 @@ func TestTelegramUpdateInboxDeduplicatesAndPreservesOriginalPayload(t *testing.T if len(updates) != 1 { t.Fatalf("runnable update count = %d, want 1", len(updates)) } - if updates[0].DispatchKey != "chat:-10" || !bytes.Contains(updates[0].Payload, []byte(`"first"`)) || !updates[0].SecurityRelevant { + if updates[0].DispatchKey != testDispatchKeyChatMinus10 || !bytes.Contains(updates[0].Payload, []byte(`"first"`)) || !updates[0].SecurityRelevant { t.Fatalf("duplicate changed original update: %#v", updates[0]) } } @@ -180,7 +180,7 @@ func TestTelegramUpdateInboxBlocksLaterChatUpdateThroughRetry(t *testing.T) { for _, updateID := range []int{10, 11} { inserted, enqueueErr := client.EnqueueTelegramUpdate(t.Context(), &db.TelegramUpdate{ UpdateID: updateID, - DispatchKey: "chat:-10", + DispatchKey: testDispatchKeyChatMinus10, Payload: []byte(`{"update_id":10}`), ReceivedAt: now, }) diff --git a/internal/db/sqlite/test_constants_test.go b/internal/db/sqlite/test_constants_test.go new file mode 100644 index 0000000..27c036f --- /dev/null +++ b/internal/db/sqlite/test_constants_test.go @@ -0,0 +1,8 @@ +package sqlite + +const ( + testCandidateMessage = "candidate" + testFirstValue = "first" + testDispatchKeyChatOne = "chat:1" + testDispatchKeyChatMinus10 = "chat:-10" +) diff --git a/internal/handlers/chat/banlist_guard_test.go b/internal/handlers/chat/banlist_guard_test.go index ef5d50e..fdaaae0 100644 --- a/internal/handlers/chat/banlist_guard_test.go +++ b/internal/handlers/chat/banlist_guard_test.go @@ -133,7 +133,7 @@ func TestBanlistGuardNoRightsStopsWithoutTelegramRetry(t *testing.T) { guard := NewBanlistGuard(botAPI, &testNotSpammerStore{}, banService) chat := &api.Chat{ID: -100, Type: testChatTypeSupergroup} user := &api.User{ID: 200} - message := &api.Message{MessageID: 42, Chat: *chat, From: user, Text: "spam"} + message := &api.Message{MessageID: 42, Chat: *chat, From: user, Text: testSpamMessageText} proceed, err := guard.Handle(context.Background(), &api.Update{Message: message}, chat, user) if err != nil { @@ -154,7 +154,7 @@ func TestBanlistGuardCapabilityUnknownReturnsRetryableFailure(t *testing.T) { guard := NewBanlistGuard(&api.BotAPI{}, &testNotSpammerStore{}, banService) chat := &api.Chat{ID: -100, Type: testChatTypeSupergroup} user := &api.User{ID: 200} - message := &api.Message{MessageID: 42, Chat: *chat, From: user, Text: "spam"} + message := &api.Message{MessageID: 42, Chat: *chat, From: user, Text: testSpamMessageText} proceed, err := guard.Handle(t.Context(), &api.Update{Message: message}, chat, user) if proceed { @@ -289,7 +289,7 @@ func TestBanlistGuardDoesNotRepeatAmbiguousBanAfterCrash(t *testing.T) { guard := NewBanlistGuard(botAPI, store, banService) chat := &api.Chat{ID: -100, Type: testChatTypeSupergroup} user := &api.User{ID: 200} - message := &api.Message{MessageID: 42, Chat: *chat, From: user, Text: "spam"} + message := &api.Message{MessageID: 42, Chat: *chat, From: user, Text: testSpamMessageText} update := &api.Update{UpdateID: 900, Message: message} if _, err := guard.Handle(t.Context(), update, chat, user); err == nil { diff --git a/internal/handlers/chat/reactor_message_pipeline_test.go b/internal/handlers/chat/reactor_message_pipeline_test.go index 5b11a61..86c9073 100644 --- a/internal/handlers/chat/reactor_message_pipeline_test.go +++ b/internal/handlers/chat/reactor_message_pipeline_test.go @@ -359,7 +359,7 @@ func TestDetectedSpamActionFailurePropagates(t *testing.T) { } chat := &api.Chat{ID: -100, Type: testChatTypeSupergroup} user := &api.User{ID: 200} - message := &api.Message{MessageID: 4, Chat: *chat, From: user, Text: "spam"} + message := &api.Message{MessageID: 4, Chat: *chat, From: user, Text: testSpamMessageText} err := reactor.handleMessage(t.Context(), message, chat, user, &db.Settings{LLMFirstMessageEnabled: true, CommunityVotingEnabled: true}) if !errors.Is(err, actionErr) { t.Fatalf("action error = %v, want %v", err, actionErr) @@ -606,7 +606,7 @@ func TestSenderChatIsAuthoritativeWithFromOnNewAndEditedMessages(t *testing.T) { settings := &db.Settings{LLMFirstMessageEnabled: true, CommunityVotingEnabled: true} for _, edited := range []bool{false, true} { - message := &api.Message{MessageID: 600 + detector.calls, Chat: *chat, From: from, SenderChat: senderChat, Text: "spam"} + message := &api.Message{MessageID: 600 + detector.calls, Chat: *chat, From: from, SenderChat: senderChat, Text: testSpamMessageText} var err error if edited { err = reactor.handleEditedMessage(t.Context(), message, chat, from, settings) @@ -976,7 +976,7 @@ func TestSpamVoteCallbackUsesSpamCaseChatSettings(t *testing.T) { spamCase, err := dbClient.CreateSpamCase(ctx, &db.SpamCase{ ChatID: -100, UserID: 200, - MessageText: "spam", + MessageText: testSpamMessageText, CreatedAt: time.Now(), ChannelUsername: "log_channel", ChannelPostID: 400, @@ -1099,7 +1099,7 @@ func TestSpamVoteHandlerChainConsumesBanlistPrecheck(t *testing.T) { t.Fatalf("set settings: %v", err) } spamCase, err := dbClient.CreateSpamCase(ctx, &db.SpamCase{ - ChatID: tt.targetChatID, UserID: 200, MessageID: 40, MessageText: "spam", CreatedAt: time.Now(), Status: db.SpamCaseStatusPending, + ChatID: tt.targetChatID, UserID: 200, MessageID: 40, MessageText: testSpamMessageText, CreatedAt: time.Now(), Status: db.SpamCaseStatusPending, }) if err != nil { t.Fatalf("create spam case: %v", err) diff --git a/internal/handlers/chat/test_bot_api_test.go b/internal/handlers/chat/test_bot_api_test.go index ce3a271..22ebfdd 100644 --- a/internal/handlers/chat/test_bot_api_test.go +++ b/internal/handlers/chat/test_bot_api_test.go @@ -50,6 +50,7 @@ const ( testWrongChoice = "wrong-choice" testWebAppFormToken = "token" testMessageText = "hello there" + testSpamMessageText = "spam" testCaptchaBook = "book" testCaptchaCar = "car" ) From 8a17cda0cf8d729c9950e23a482555c4b866af5c Mon Sep 17 00:00:00 2001 From: Valeriy Selitskiy <239034+iamwavecut@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:46:06 +0200 Subject: [PATCH 4/6] Deduplicate remaining test literals --- internal/handlers/chat/banlist_guard_test.go | 4 +- .../handlers/chat/gatekeeper_captcha_test.go | 2 +- .../chat/gatekeeper_durable_actions_test.go | 22 ++++---- .../chat/gatekeeper_join_flow_test.go | 36 ++++++------- .../handlers/chat/gatekeeper_webapp_test.go | 28 +++++----- .../chat/reactor_command_router_test.go | 6 +-- .../chat/reactor_message_pipeline_test.go | 48 ++++++++--------- .../chat/reactor_message_probation_test.go | 10 ++-- .../reactor_reaction_profile_check_test.go | 4 +- .../handlers/chat/telegram_method_test.go | 1 + internal/handlers/chat/test_bot_api_test.go | 10 ++++ .../moderation/ban_service_actions_test.go | 52 +++++++++---------- .../handlers/moderation/ban_service_test.go | 6 +-- .../spam_control_known_non_member_test.go | 24 ++++----- .../spam_control_notification_test.go | 8 +-- .../spam_control_test_constants_test.go | 29 +++++++---- 16 files changed, 156 insertions(+), 134 deletions(-) diff --git a/internal/handlers/chat/banlist_guard_test.go b/internal/handlers/chat/banlist_guard_test.go index fdaaae0..10ff82b 100644 --- a/internal/handlers/chat/banlist_guard_test.go +++ b/internal/handlers/chat/banlist_guard_test.go @@ -229,7 +229,7 @@ func TestBanlistGuardAllowsManuallyAllowlistedUser(t *testing.T) { banService := &testBanService{knownBanned: true} guard := NewBanlistGuard(botAPI, &testNotSpammerStore{isNotSpammer: true}, banService) chat := &api.Chat{ID: -100, Type: testChatTypeSupergroup} - user := &api.User{ID: 200, UserName: "allowlisted"} + user := &api.User{ID: 200, UserName: testAllowlistedUser} message := &api.Message{MessageID: 42, Chat: *chat, From: user, Text: "/settings"} proceed, err := guard.Handle(context.Background(), &api.Update{Message: message}, chat, user) @@ -257,7 +257,7 @@ func TestBanlistGuardAllowlistLookupFailureContinuesBan(t *testing.T) { store := &testNotSpammerStore{notSpammerErr: errors.New("database unavailable")} guard := NewBanlistGuard(botAPI, store, banService) chat := &api.Chat{ID: -100, Type: testChatTypeSupergroup} - user := &api.User{ID: 200, UserName: "candidate"} + user := &api.User{ID: 200, UserName: testCandidateValue} message := &api.Message{MessageID: 42, Chat: *chat, From: user, Text: "message"} proceed, err := guard.Handle(context.Background(), &api.Update{Message: message}, chat, user) diff --git a/internal/handlers/chat/gatekeeper_captcha_test.go b/internal/handlers/chat/gatekeeper_captcha_test.go index 70ff37d..83f9db8 100644 --- a/internal/handlers/chat/gatekeeper_captcha_test.go +++ b/internal/handlers/chat/gatekeeper_captcha_test.go @@ -217,7 +217,7 @@ func TestCreateCaptchaButtonsSupportsSmallVariantSet(t *testing.T) { gk := &Gatekeeper{ Variants: map[string]map[string]string{ "en": { - "🍎": "apple", + "🍎": captchaFallbackWord, "🐶": "dog", "🚗": "car", "🌟": "star", diff --git a/internal/handlers/chat/gatekeeper_durable_actions_test.go b/internal/handlers/chat/gatekeeper_durable_actions_test.go index 90ba9b4..411d91b 100644 --- a/internal/handlers/chat/gatekeeper_durable_actions_test.go +++ b/internal/handlers/chat/gatekeeper_durable_actions_test.go @@ -138,7 +138,7 @@ func TestPublicChallengePersistsRestrictionActionBeforeTelegram(t *testing.T) { store: client, config: &config.Config{}, banChecker: &testGatekeeperBanChecker{}, - Variants: map[string]map[string]string{"en": {"A": "apple", "B": "paper", "C": "vehicle"}}, + Variants: map[string]map[string]string{"en": {"A": captchaFallbackWord, "B": "paper", "C": "vehicle"}}, } chat := api.Chat{ID: settings.ID, Title: testGroupTitle, Type: testChatTypeSupergroup} user := api.User{ID: 2001, FirstName: testFirstNameUser} @@ -251,7 +251,7 @@ func TestTransientFallbackGetChatFailureRetriesWithoutDecline(t *testing.T) { ChatID: -1003, Status: db.ChallengeStatusWebAppFallbackPending, WebAppToken: "token", - JoinRequestQueryID: "query", + JoinRequestQueryID: testJoinRequestQuery, CreatedAt: now, ExpiresAt: now.Add(time.Minute), NextAttemptAt: sql.NullTime{Time: now, Valid: true}, @@ -396,7 +396,7 @@ func TestTerminalMemberApprovalErrorCompletesLeasedAction(t *testing.T) { t.Cleanup(func() { _ = client.Close() }) botAPI := newTestBotAPI(t, func(method string, _ *http.Request) any { switch method { - case "approveChatJoinRequest": + case testTelegramMethodApproveJoinRequest: return &testBotAPIError{code: http.StatusBadRequest, description: "USER_ALREADY_PARTICIPANT"} default: return true @@ -527,7 +527,7 @@ func TestLegacyQueuedJoinRequestTransitionsProtectedBoundaryToDMCaptcha(t *testi } }) settings := webAppSettings() - gatekeeper := &Gatekeeper{bot: botAPI, s: &gatekeeperTestService{testBotService: testBotService{botAPI: botAPI}, settings: settings}, store: client, config: &config.Config{}, banChecker: &testGatekeeperBanChecker{}, Variants: map[string]map[string]string{"en": {"A": "apple", "B": testCaptchaBook, "C": testCaptchaCar}}} + gatekeeper := &Gatekeeper{bot: botAPI, s: &gatekeeperTestService{testBotService: testBotService{botAPI: botAPI}, settings: settings}, store: client, config: &config.Config{}, banChecker: &testGatekeeperBanChecker{}, Variants: map[string]map[string]string{"en": {"A": captchaFallbackWord, "B": testCaptchaBook, "C": testCaptchaCar}}} request := &api.ChatJoinRequest{Chat: api.Chat{ID: -7001, Title: testGroupTitle}, From: api.User{ID: 7001, FirstName: "N", LanguageCode: "en"}, UserChatID: 7001, QueryID: "legacy-query"} if err := gatekeeper.handleChatJoinRequest(t.Context(), &api.Update{ChatJoinRequest: request}, settings); err != nil { t.Fatal(err) @@ -724,7 +724,7 @@ func TestLegacyJoinTransportLogsNeverExposeSecretURL(t *testing.T) { var output bytes.Buffer logger := log.New() logger.SetOutput(&output) - gatekeeper := &Gatekeeper{bot: botAPI, s: &gatekeeperTestService{testBotService: testBotService{botAPI: botAPI}, settings: settings}, store: store, config: &config.Config{}, banChecker: &testGatekeeperBanChecker{moderationUnavailable: true}, logger: log.NewEntry(logger), Variants: map[string]map[string]string{"en": {"A": "apple", "B": testCaptchaBook, "C": testCaptchaCar}}} + gatekeeper := &Gatekeeper{bot: botAPI, s: &gatekeeperTestService{testBotService: testBotService{botAPI: botAPI}, settings: settings}, store: store, config: &config.Config{}, banChecker: &testGatekeeperBanChecker{moderationUnavailable: true}, logger: log.NewEntry(logger), Variants: map[string]map[string]string{"en": {"A": captchaFallbackWord, "B": testCaptchaBook, "C": testCaptchaCar}}} _ = test.run(gatekeeper, settings) if strings.Contains(output.String(), "SECRET") || strings.Contains(output.String(), "api.telegram.org") || strings.Contains(output.String(), "query-secret") { t.Fatalf("transport secret leaked in log: %s", output.String()) @@ -836,7 +836,7 @@ func TestAcceptedChallengeMessageBindFailureMovesToReconciliation(t *testing.T) bot: botAPI, s: &gatekeeperTestService{testBotService: testBotService{botAPI: botAPI, language: "en"}, settings: settings}, store: store, config: &config.Config{}, banChecker: &testGatekeeperBanChecker{}, - Variants: map[string]map[string]string{"en": {"A": "apple", "B": "paper", "C": "vehicle"}}, + Variants: map[string]map[string]string{"en": {"A": captchaFallbackWord, "B": "paper", "C": "vehicle"}}, } chat := &api.Chat{ID: settings.ID, Title: testGroupTitle, Type: testChatTypeSupergroup} user := &api.User{ID: 3001, FirstName: testFirstNameUser} @@ -867,7 +867,7 @@ func TestWebAppCannotApproveWhileProviderBanCheckIsBlocked(t *testing.T) { s: &gatekeeperTestService{testBotService: testBotService{botAPI: botAPI}, settings: settings}, store: client, config: &config.Config{GatekeeperWebApp: config.GatekeeperWebApp{PublicURL: testWebAppURL}}, banChecker: checker, } - request := &api.ChatJoinRequest{Chat: api.Chat{ID: -2002}, From: api.User{ID: 3002}, UserChatID: 3002, QueryID: "query"} + request := &api.ChatJoinRequest{Chat: api.Chat{ID: -2002}, From: api.User{ID: 3002}, UserChatID: 3002, QueryID: testJoinRequestQuery} done := make(chan error, 1) go func() { done <- gatekeeper.handleChatJoinRequest(t.Context(), &api.Update{ChatJoinRequest: request}, settings) @@ -908,7 +908,7 @@ func TestPendingRequesterMissingDuringBanIsNotTreatedAsBanned(t *testing.T) { botAPI := newTestBotAPI(t, func(method string, _ *http.Request) any { switch method { case testTelegramMethodGetChatMember: - return map[string]any{"status": "left", "user": map[string]any{"id": 3003, "is_bot": false, "first_name": "N"}} + return map[string]any{logFieldStatus: testMemberStatusLeft, logFieldUser: map[string]any{"id": 3003, "is_bot": false, "first_name": "N"}} case testTelegramMethodBanChatMember: return &testBotAPIError{code: http.StatusBadRequest, description: "USER_NOT_PARTICIPANT"} case testTelegramMethodDeclineJoinRequest: @@ -968,7 +968,7 @@ func TestKnownBannedCleanupCannotDeleteBlockedLeasedActions(t *testing.T) { case testTelegramMethodGetChat: return map[string]any{"id": test.commID, testJSONType: telegramChatTypePrivate, testJSONFirstName: "N"} case testTelegramMethodGetChatMember: - return map[string]any{"status": "left", "user": map[string]any{"id": test.commID, "is_bot": false, "first_name": "N"}} + return map[string]any{logFieldStatus: testMemberStatusLeft, logFieldUser: map[string]any{"id": test.commID, "is_bot": false, "first_name": "N"}} case testTelegramMethodDeclineJoinRequest, testTelegramMethodDeleteMessage: return true default: @@ -978,11 +978,11 @@ func TestKnownBannedCleanupCannotDeleteBlockedLeasedActions(t *testing.T) { now := time.Now() challenge := &db.Challenge{CommChatID: test.commID, UserID: test.commID, ChatID: -test.commID, Status: test.status, CreatedAt: now, ExpiresAt: now.Add(time.Minute), NextAttemptAt: sql.NullTime{Time: now, Valid: true}} if test.status == db.ChallengeStatusApproveQueryPending { - challenge.JoinRequestQueryID = "query" + challenge.JoinRequestQueryID = testJoinRequestQuery } if test.status == db.ChallengeStatusWebAppFallbackPending { challenge.WebAppToken = "token" - challenge.JoinRequestQueryID = "query" + challenge.JoinRequestQueryID = testJoinRequestQuery } if _, err := client.CreateChallenge(t.Context(), challenge); err != nil { t.Fatal(err) diff --git a/internal/handlers/chat/gatekeeper_join_flow_test.go b/internal/handlers/chat/gatekeeper_join_flow_test.go index 1a2c8a2..1f38bab 100644 --- a/internal/handlers/chat/gatekeeper_join_flow_test.go +++ b/internal/handlers/chat/gatekeeper_join_flow_test.go @@ -634,7 +634,7 @@ func TestDisabledGatekeeperCleanChatMemberOnlyChecksBanlist(t *testing.T) { t.Parallel() user := api.User{ID: 200, FirstName: testFirstNameUser} - chat := api.Chat{ID: -100, Type: testChatTypeSupergroup, Title: "Group"} + chat := api.Chat{ID: -100, Type: testChatTypeSupergroup, Title: testGenericGroupTitle} banChecker := &testGatekeeperBanChecker{} gatekeeper := &Gatekeeper{ s: &gatekeeperTestService{testBotService: testBotService{language: "en"}, settings: &db.Settings{GatekeeperEnabled: false}}, @@ -659,7 +659,7 @@ func TestDisabledGatekeeperBannedChatMemberStillBans(t *testing.T) { t.Parallel() user := api.User{ID: 200, FirstName: testFirstNameUser} - chat := api.Chat{ID: -100, Type: testChatTypeSupergroup, Title: "Group"} + chat := api.Chat{ID: -100, Type: testChatTypeSupergroup, Title: testGenericGroupTitle} banChecker := &testGatekeeperBanChecker{banned: true} gatekeeper := &Gatekeeper{ s: &gatekeeperTestService{testBotService: testBotService{language: "en"}, settings: &db.Settings{GatekeeperEnabled: false}}, @@ -684,7 +684,7 @@ func TestChatMemberKnownBannedBanFailurePropagatesTypedOutcome(t *testing.T) { t.Parallel() user := api.User{ID: 200, FirstName: testFirstNameUser} - chat := api.Chat{ID: -100, Type: testChatTypeSupergroup, Title: "Group"} + chat := api.Chat{ID: -100, Type: testChatTypeSupergroup, Title: testGenericGroupTitle} for _, test := range []struct { name string banErr error @@ -726,7 +726,7 @@ func TestDisabledGatekeeperAllowlistedBannedChatMemberSkipsBan(t *testing.T) { t.Parallel() user := api.User{ID: 200, FirstName: testFirstNameUser} - chat := api.Chat{ID: -100, Type: testChatTypeSupergroup, Title: "Group"} + chat := api.Chat{ID: -100, Type: testChatTypeSupergroup, Title: testGenericGroupTitle} store := newGatekeeperFlowStore() store.isNotSpammer = true banChecker := &testGatekeeperBanChecker{banned: true} @@ -1242,7 +1242,7 @@ func TestJoinRequestCaptchaSuccessHandoffSkipsSecondCaptchaAndSendsGreetingOnce( testJSONType: telegramChatTypePrivate, testJSONFirstName: testFirstNameNeo, } - case "approveChatJoinRequest": + case testTelegramMethodApproveJoinRequest: handoffChallenge := store.onlyChallenge(t) if handoffChallenge.Status != db.ChallengeStatusApproveMemberPending { t.Fatalf("expected durable approval claim before Telegram effect, got %q", handoffChallenge.Status) @@ -1346,8 +1346,8 @@ func TestJoinRequestCaptchaSuccessHandoffSkipsSecondCaptchaAndSendsGreetingOnce( if len(store.challenges) != 0 { t.Fatalf("expected handoff challenge to be deleted after member join, got %d rows", len(store.challenges)) } - if len(recorder.byMethod("approveChatJoinRequest")) != 1 { - t.Fatalf("expected one join request approval, got %d", len(recorder.byMethod("approveChatJoinRequest"))) + if len(recorder.byMethod(testTelegramMethodApproveJoinRequest)) != 1 { + t.Fatalf("expected one join request approval, got %d", len(recorder.byMethod(testTelegramMethodApproveJoinRequest))) } if len(recorder.byMethod(testTelegramMethodDeleteMessage)) != 1 { t.Fatalf("expected one DM challenge cleanup, got %d", len(recorder.byMethod(testTelegramMethodDeleteMessage))) @@ -1391,7 +1391,7 @@ func TestJoinRequestCaptchaSuccessHandoffSkipsPublicCaptchaWithoutViaJoinRequest testJSONType: telegramChatTypePrivate, testJSONFirstName: testFirstNameNeo, } - case "approveChatJoinRequest": + case testTelegramMethodApproveJoinRequest: return true case testTelegramMethodSendMessage: return recorder.nextSendMessageResult() @@ -1464,7 +1464,7 @@ func TestManualJoinRequestApprovalSkipsPublicCaptchaAndSendsOnlyGreeting(t *test recorder.record(t, method, r) switch method { - case "getChat": + case testTelegramMethodGetChat: return map[string]any{"id": groupChat.ID, testJSONType: testChatTypeSupergroup, "permissions": map[string]any{"can_send_messages": true}} case testTelegramMethodSendMessage: return recorder.nextSendMessageResult() @@ -1542,7 +1542,7 @@ func TestDirectJoinCaptchaIncludesGreetingImmediatelyAndBackfillsJoinMessageID(t recorder.record(t, method, r) switch method { - case "getChat": + case testTelegramMethodGetChat: return map[string]any{"id": groupChat.ID, testJSONType: testChatTypeSupergroup, "permissions": map[string]any{"can_send_messages": true}} case testTelegramMethodSendMessage: return recorder.nextSendMessageResult() @@ -1933,8 +1933,8 @@ func TestProcessExpiredJoinRequestChallengesCleanupWithoutApproval(t *testing.T) if len(recorder.byMethod(testTelegramMethodDeleteMessage)) != 1 { t.Fatalf("expected one DM challenge cleanup, got %d", len(recorder.byMethod(testTelegramMethodDeleteMessage))) } - if len(recorder.byMethod("approveChatJoinRequest")) != 0 { - t.Fatalf("expected no join request approvals, got %d", len(recorder.byMethod("approveChatJoinRequest"))) + if len(recorder.byMethod(testTelegramMethodApproveJoinRequest)) != 0 { + t.Fatalf("expected no join request approvals, got %d", len(recorder.byMethod(testTelegramMethodApproveJoinRequest))) } if len(recorder.byMethod("declineChatJoinRequest")) != 0 { t.Fatalf("expected no join request declines, got %d", len(recorder.byMethod("declineChatJoinRequest"))) @@ -2172,8 +2172,8 @@ func TestProcessExpiredJoinRequestDMFallbackChallengeRejects(t *testing.T) { } case testTelegramMethodGetChatMember: return map[string]any{ - "status": testMemberStatusLeft, - "user": map[string]any{ + logFieldStatus: testMemberStatusLeft, + logFieldUser: map[string]any{ "id": 42, testJSONIsBot: false, testJSONFirstName: testFirstNameNeo, @@ -2587,8 +2587,8 @@ func TestFallbackClaimedWebAppChallengeRetriesWhenTargetChatUnavailable(t *testi } case testTelegramMethodGetChatMember: return map[string]any{ - "status": testMemberStatusLeft, - "user": map[string]any{"id": 42, testJSONIsBot: false, testJSONFirstName: testFirstNameNeo}, + logFieldStatus: testMemberStatusLeft, + logFieldUser: map[string]any{"id": 42, testJSONIsBot: false, testJSONFirstName: testFirstNameNeo}, } case testTelegramMethodJoinRequestQuery, testTelegramMethodBanChatMember: return true @@ -2664,8 +2664,8 @@ func TestDMFallbackForbiddenDeclinesWithoutDurableRetry(t *testing.T) { return &testBotAPIError{code: http.StatusForbidden, description: "Forbidden: bot can't initiate conversation with a user"} case testTelegramMethodGetChatMember: return map[string]any{ - "status": testMemberStatusLeft, - "user": map[string]any{"id": 42, testJSONIsBot: false, testJSONFirstName: testFirstNameNeo}, + logFieldStatus: testMemberStatusLeft, + logFieldUser: map[string]any{"id": 42, testJSONIsBot: false, testJSONFirstName: testFirstNameNeo}, } case testTelegramMethodJoinRequestQuery, testTelegramMethodBanChatMember: return true diff --git a/internal/handlers/chat/gatekeeper_webapp_test.go b/internal/handlers/chat/gatekeeper_webapp_test.go index 17dc8a5..1831820 100644 --- a/internal/handlers/chat/gatekeeper_webapp_test.go +++ b/internal/handlers/chat/gatekeeper_webapp_test.go @@ -518,7 +518,7 @@ func TestCaptchaRevalidationDoesNotCallProviderWithoutModerationRights(t *testin checker := &testGatekeeperBanChecker{moderationUnavailable: true, banned: true} gatekeeper := &Gatekeeper{store: newGatekeeperFlowStore(), banChecker: checker} challenge := newWebAppChallenge(time.Now().Add(time.Minute)) - banned, err := gatekeeper.revalidateChallengeIdentity(t.Context(), challenge, "candidate") + banned, err := gatekeeper.revalidateChallengeIdentity(t.Context(), challenge, testCandidateValue) if err != nil || banned { t.Fatalf("revalidate no-rights = banned %t err %v", banned, err) } @@ -533,7 +533,7 @@ func TestCaptchaRevalidationCachedBanIsTerminalWithoutModerationRights(t *testin challenge := newWebAppChallenge(time.Now().Add(time.Minute)) checker := &testGatekeeperBanChecker{moderationUnavailable: true, knownBanned: map[int64]bool{challenge.UserID: true}} gatekeeper := &Gatekeeper{store: newGatekeeperFlowStore(), banChecker: checker} - banned, err := gatekeeper.revalidateChallengeIdentity(t.Context(), challenge, "candidate") + banned, err := gatekeeper.revalidateChallengeIdentity(t.Context(), challenge, testCandidateValue) if err != nil || !banned { t.Fatalf("revalidate cached ban = banned %t err %v", banned, err) } @@ -666,7 +666,7 @@ func TestHandleJoinCaptchaAnswerReportsProcessingAfterLostApprovalResponse(t *te if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { t.Fatalf("decode response: %v", err) } - if response["done"] != true || response["state"] != "processing" { + if response["done"] != true || response["state"] != joinCaptchaStateProcessing { t.Fatalf("expected processing replay response, got %#v", response) } } @@ -1338,8 +1338,8 @@ func TestJoinCaptchaAnswerBlocksAfterTooManyWrongChoices(t *testing.T) { switch method { case testTelegramMethodGetChatMember: return map[string]any{ - "status": testMemberStatusLeft, - "user": map[string]any{"id": 42, testJSONIsBot: false, testJSONFirstName: testFirstNameNeo}, + logFieldStatus: testMemberStatusLeft, + logFieldUser: map[string]any{"id": 42, testJSONIsBot: false, testJSONFirstName: testFirstNameNeo}, } case testTelegramMethodJoinRequestQuery, testTelegramMethodBanChatMember: return true @@ -1404,8 +1404,8 @@ func TestJoinCaptchaAnswerReportsExpiredChallengeWithoutPunishment(t *testing.T) switch method { case testTelegramMethodGetChatMember: return map[string]any{ - "status": testMemberStatusLeft, - "user": map[string]any{"id": 42, testJSONIsBot: false, testJSONFirstName: testFirstNameNeo}, + logFieldStatus: testMemberStatusLeft, + logFieldUser: map[string]any{"id": 42, testJSONIsBot: false, testJSONFirstName: testFirstNameNeo}, } case testTelegramMethodJoinRequestQuery, testTelegramMethodBanChatMember: return true @@ -1448,7 +1448,7 @@ func TestJoinCaptchaAnswerReportsExpiredChallengeWithoutPunishment(t *testing.T) if body["ok"] != false || body["done"] != true { t.Fatalf("expected terminal expired response, got %#v", body) } - if body["state"] != "expired" { + if body["state"] != joinCaptchaStateExpired { t.Fatalf("expected expired state, got %#v", body) } if len(recorder.requests) != 0 { @@ -1654,11 +1654,11 @@ func TestJoinCaptchaStatusReportsDurableStatesWithoutRepeatingActions(t *testing wantState string wantOK bool }{ - {name: "pending", status: db.ChallengeStatusPending, expiresAt: time.Now().Add(time.Minute), wantStatus: http.StatusOK, wantState: "pending"}, - {name: "approval in progress", status: db.ChallengeStatusApproveQueryPending, expiresAt: time.Now().Add(time.Minute), wantStatus: http.StatusAccepted, wantState: "processing", wantOK: true}, + {name: joinCaptchaStatePending, status: db.ChallengeStatusPending, expiresAt: time.Now().Add(time.Minute), wantStatus: http.StatusOK, wantState: joinCaptchaStatePending}, + {name: "approval in progress", status: db.ChallengeStatusApproveQueryPending, expiresAt: time.Now().Add(time.Minute), wantStatus: http.StatusAccepted, wantState: joinCaptchaStateProcessing, wantOK: true}, {name: "passed", status: db.ChallengeStatusPassedWaitingMemberJoin, expiresAt: time.Now().Add(time.Minute), wantStatus: http.StatusOK, wantState: "passed", wantOK: true}, {name: "rejected", status: db.ChallengeStatusRejectPending, expiresAt: time.Now().Add(time.Minute), wantStatus: http.StatusForbidden, wantState: "rejected"}, - {name: "expired", status: db.ChallengeStatusPending, expiresAt: time.Now().Add(-time.Minute), wantStatus: http.StatusGone, wantState: "expired"}, + {name: joinCaptchaStateExpired, status: db.ChallengeStatusPending, expiresAt: time.Now().Add(-time.Minute), wantStatus: http.StatusGone, wantState: joinCaptchaStateExpired}, } for _, tt := range tests { @@ -1746,7 +1746,7 @@ func TestHandleJoinCaptchaAnswerPersistsApprovalRetryWhenApproveFails(t *testing if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { t.Fatalf("decode response: %v", err) } - if body["done"] != true || body["state"] != "processing" { + if body["done"] != true || body["state"] != joinCaptchaStateProcessing { t.Fatalf("expected durable processing response, got %#v", body) } if len(store.challenges) != 0 { @@ -1763,8 +1763,8 @@ func TestHandleJoinCaptchaAnswerDeclinesKnownBannedUser(t *testing.T) { switch method { case testTelegramMethodGetChatMember: return map[string]any{ - "status": testMemberStatusLeft, - "user": map[string]any{"id": 42, testJSONIsBot: false, testJSONFirstName: testFirstNameNeo}, + logFieldStatus: testMemberStatusLeft, + logFieldUser: map[string]any{"id": 42, testJSONIsBot: false, testJSONFirstName: testFirstNameNeo}, } case testTelegramMethodJoinRequestQuery, testTelegramMethodBanChatMember: return true diff --git a/internal/handlers/chat/reactor_command_router_test.go b/internal/handlers/chat/reactor_command_router_test.go index b27c0e4..43447d3 100644 --- a/internal/handlers/chat/reactor_command_router_test.go +++ b/internal/handlers/chat/reactor_command_router_test.go @@ -570,7 +570,7 @@ func TestVoteBanCommandAllowlistedTargetBypassesBanlistShortcut(t *testing.T) { }) chat := &api.Chat{ID: -100, Type: testChatTypeSupergroup} actor := &api.User{ID: 100, FirstName: testFirstNameActor} - target := &api.User{ID: 200, FirstName: testFirstNameTarget, UserName: "allowlisted"} + target := &api.User{ID: 200, FirstName: testFirstNameTarget, UserName: testAllowlistedUser} reply := &api.Message{MessageID: 40, Chat: *chat, From: target, Text: "reported text"} command := &api.Message{MessageID: 50, Chat: *chat, From: actor, Text: testVoteBanCommand, ReplyToMessage: reply} detector := &testSpamDetector{reportedResult: boolPtr(false)} @@ -626,7 +626,7 @@ func TestMessageMentionCurrentBotTriggersReportFlow(t *testing.T) { From: actor, Text: "@testbot", Entities: []api.MessageEntity{{ - Type: "mention", + Type: testEntityMention, Offset: 0, Length: len("@testbot"), }}, @@ -669,7 +669,7 @@ func TestMessageMentionsCurrentBot(t *testing.T) { msg := &api.Message{ Text: "hi @testbot", Entities: []api.MessageEntity{{ - Type: "mention", + Type: testEntityMention, Offset: 3, Length: len("@testbot"), }}, diff --git a/internal/handlers/chat/reactor_message_pipeline_test.go b/internal/handlers/chat/reactor_message_pipeline_test.go index 86c9073..93be786 100644 --- a/internal/handlers/chat/reactor_message_pipeline_test.go +++ b/internal/handlers/chat/reactor_message_pipeline_test.go @@ -134,7 +134,7 @@ func TestChallengeMarkerFailureDoesNotRememberAuthor(t *testing.T) { } chat := &api.Chat{ID: -100, Type: testChatTypeSupergroup} user := &api.User{ID: 200, FirstName: testFirstNameUser} - message := &api.Message{MessageID: 301, Chat: *chat, From: user, Text: "safe first message"} + message := &api.Message{MessageID: 301, Chat: *chat, From: user, Text: testSafeFirstMessage} err := reactor.handleMessage(t.Context(), message, chat, user, &db.Settings{LLMFirstMessageEnabled: true}) if err == nil || !strings.Contains(err.Error(), "record challenged message") { @@ -294,7 +294,7 @@ func TestMessageCapabilityLookupFailureReturnsRetryableFailure(t *testing.T) { } chat := &api.Chat{ID: -100, Type: testChatTypeSupergroup} user := &api.User{ID: 200} - err := reactor.handleMessage(t.Context(), &api.Message{MessageID: 1, Chat: *chat, From: user, Text: "candidate"}, chat, user, &db.Settings{LLMFirstMessageEnabled: true}) + err := reactor.handleMessage(t.Context(), &api.Message{MessageID: 1, Chat: *chat, From: user, Text: testCandidateValue}, chat, user, &db.Settings{LLMFirstMessageEnabled: true}) failure := botservice.ClassifyUpdateFailure(err) if failure.Source != botservice.UpdateFailureCapability || failure.Disposition != botservice.UpdateFailureRetryable { t.Fatalf("capability failure = %#v", failure) @@ -310,7 +310,7 @@ func TestSenderChatCapabilityLookupFailureReturnsRetryableFailure(t *testing.T) lastResults: make(map[messageResultKey]*MessageProcessingResult), } chat := &api.Chat{ID: -100, Type: testChatTypeSupergroup} - message := &api.Message{MessageID: 2, Chat: *chat, SenderChat: &api.Chat{ID: -200, Type: testChatTypeChannel}, Text: "candidate"} + message := &api.Message{MessageID: 2, Chat: *chat, SenderChat: &api.Chat{ID: -200, Type: testChatTypeChannel}, Text: testCandidateValue} err := reactor.handleSenderChatContent(t.Context(), message, chat, &MessageProcessingResult{}, reactor.getLogEntry()) failure := botservice.ClassifyUpdateFailure(err) if failure.Source != botservice.UpdateFailureCapability || failure.Disposition != botservice.UpdateFailureRetryable { @@ -328,7 +328,7 @@ func TestSenderChatMalformedClassificationReturnsRetryableFailure(t *testing.T) lastResults: make(map[messageResultKey]*MessageProcessingResult), } chat := &api.Chat{ID: -100, Type: testChatTypeSupergroup} - message := &api.Message{MessageID: 3, Chat: *chat, SenderChat: &api.Chat{ID: -200, Type: testChatTypeChannel}, Text: "candidate"} + message := &api.Message{MessageID: 3, Chat: *chat, SenderChat: &api.Chat{ID: -200, Type: testChatTypeChannel}, Text: testCandidateValue} err := reactor.handleSenderChatContent(t.Context(), message, chat, &MessageProcessingResult{}, reactor.getLogEntry()) failure := botservice.ClassifyUpdateFailure(err) if failure.Source != botservice.UpdateFailureLLM || failure.Disposition != botservice.UpdateFailureRetryable { @@ -374,7 +374,7 @@ func TestModerationRouterForwardsExhaustedLLMDegradation(t *testing.T) { router := NewModerationRouter(nil, reactor) chat := &api.Chat{ID: -100, Type: testChatTypeSupergroup} user := &api.User{ID: 200} - update := &api.Update{Message: &api.Message{MessageID: 5, Chat: *chat, From: user, Text: "candidate"}} + update := &api.Update{Message: &api.Message{MessageID: 5, Chat: *chat, From: user, Text: testCandidateValue}} failure := botservice.ClassifyUpdateFailure(botservice.NewRetryableUpdateFailure(botservice.UpdateFailureLLM, "provider", errors.New("unavailable"))) if err := router.HandleExhaustedUpdateFailure(t.Context(), update, chat, user, failure); err != nil { t.Fatalf("degrade through router: %v", err) @@ -401,7 +401,7 @@ func TestExhaustedLLMFailureQuarantinesOnlyWithKnownRights(t *testing.T) { reactor := &Reactor{banService: banService} chat := &api.Chat{ID: -100, Type: testChatTypeSupergroup} user := &api.User{ID: 200} - update := &api.Update{UpdateID: 1, Message: &api.Message{MessageID: 2, Chat: *chat, From: user, Text: "candidate"}} + update := &api.Update{UpdateID: 1, Message: &api.Message{MessageID: 2, Chat: *chat, From: user, Text: testCandidateValue}} failure := botservice.ClassifyUpdateFailure(botservice.NewRetryableUpdateFailure(botservice.UpdateFailureLLM, "provider_error", errors.New("unavailable"))) if err := reactor.HandleExhaustedUpdateFailure(t.Context(), update, chat, user, failure); err != nil { t.Fatalf("degrade exhausted LLM failure: %v", err) @@ -533,8 +533,8 @@ func TestUntrustedSenderChatSpamIsDeletedAndSenderChatBanned(t *testing.T) { botAPI := newTestBotAPI(t, func(method string, _ *http.Request) any { methods = append(methods, method) switch method { - case "getChat": - return map[string]any{"id": -100, testJSONType: testChatTypeSupergroup, "linked_chat_id": -999} + case testTelegramMethodGetChat: + return map[string]any{"id": -100, testJSONType: testChatTypeSupergroup, testJSONLinkedChatID: -999} case testTelegramMethodDeleteMessage, "banChatSenderChat": return true default: @@ -582,8 +582,8 @@ func TestSenderChatIsAuthoritativeWithFromOnNewAndEditedMessages(t *testing.T) { botAPI := newTestBotAPI(t, func(method string, _ *http.Request) any { methods = append(methods, method) switch method { - case "getChat": - return map[string]any{"id": -100, testJSONType: testChatTypeSupergroup, "linked_chat_id": -999} + case testTelegramMethodGetChat: + return map[string]any{"id": -100, testJSONType: testChatTypeSupergroup, testJSONLinkedChatID: -999} case testTelegramMethodDeleteMessage, testTelegramMethodBanChatSenderChat: return true default: @@ -601,7 +601,7 @@ func TestSenderChatIsAuthoritativeWithFromOnNewAndEditedMessages(t *testing.T) { lastResults: make(map[messageResultKey]*MessageProcessingResult), } chat := &api.Chat{ID: -100, Type: testChatTypeSupergroup} - from := &api.User{ID: 200, FirstName: "Forwarder"} + from := &api.User{ID: 200, FirstName: testFirstNameForwarder} senderChat := &api.Chat{ID: -200, Type: testChatTypeChannel, Title: "Untrusted"} settings := &db.Settings{LLMFirstMessageEnabled: true, CommunityVotingEnabled: true} @@ -641,7 +641,7 @@ func TestAnonymousAdminSenderChatWithFromRemainsTrustedOnNewAndEdit(t *testing.T banService: &testBanService{}, lastResults: make(map[messageResultKey]*MessageProcessingResult), } chat := &api.Chat{ID: -100, Type: testChatTypeSupergroup} - from := &api.User{ID: 200, FirstName: "Forwarder"} + from := &api.User{ID: 200, FirstName: testFirstNameForwarder} message := &api.Message{MessageID: 620, Chat: *chat, From: from, SenderChat: chat, Text: "admin post"} settings := &db.Settings{LLMFirstMessageEnabled: true} if err := reactor.handleMessage(t.Context(), message, chat, from, settings); err != nil { @@ -783,7 +783,7 @@ func TestFirstMessageDeletionEvasionKeepsSecondMessageUnderChallenge(t *testing. lastResults: make(map[messageResultKey]*MessageProcessingResult), } - first := &api.Message{MessageID: 300, Chat: *chat, From: user, Text: "safe first message"} + first := &api.Message{MessageID: 300, Chat: *chat, From: user, Text: testSafeFirstMessage} if err := reactor.handleMessage(t.Context(), first, chat, user, settings); err != nil { t.Fatalf("handle safe first message: %v", err) } @@ -843,7 +843,7 @@ func TestEditedChallengedMessageIsRechecked(t *testing.T) { Chat: *chat, From: user, Date: now.Unix(), - Text: "safe first message", + Text: testSafeFirstMessage, } proceed, err := reactor.Handle(t.Context(), &api.Update{Message: message}, chat, user) @@ -939,7 +939,7 @@ func TestSpamVoteCallbackUsesSpamCaseChatSettings(t *testing.T) { case "getChatMember": return map[string]any{ logFieldStatus: telegramMemberStatus, - logFieldUser: map[string]any{"id": 300, testJSONIsBot: false, testJSONFirstName: "Voter"}, + logFieldUser: map[string]any{"id": 300, testJSONIsBot: false, testJSONFirstName: testFirstNameVoter}, } case "answerCallbackQuery": callbackAnswers++ @@ -981,7 +981,7 @@ func TestSpamVoteCallbackUsesSpamCaseChatSettings(t *testing.T) { ChannelUsername: "log_channel", ChannelPostID: 400, NotificationMessageID: 0, - Status: "pending", + Status: db.SpamCaseStatusPending, }) if err != nil { t.Fatalf("create spam case: %v", err) @@ -997,7 +997,7 @@ func TestSpamVoteCallbackUsesSpamCaseChatSettings(t *testing.T) { reactor := NewReactor(service, botAPI, dbClient, dbClient, &testBanService{}, spamControl, nil, Config{}) logChat := &api.Chat{ID: 900, Type: testChatTypeChannel} - voter := &api.User{ID: 300, FirstName: "Voter"} + voter := &api.User{ID: 300, FirstName: testFirstNameVoter} update := &api.Update{ CallbackQuery: &api.CallbackQuery{ ID: "callback-id", @@ -1114,7 +1114,7 @@ func TestSpamVoteHandlerChainConsumesBanlistPrecheck(t *testing.T) { router := NewModerationRouter(NewBanlistGuard(botAPI, dbClient, banService), reactor, features) processor := botservice.NewUpdateProcessor(service, router, features) logChat := api.Chat{ID: tt.logChatID, Type: testChatTypeChannel} - voter := api.User{ID: 300, UserName: "voter_name", FirstName: "Voter"} + voter := api.User{ID: 300, UserName: "voter_name", FirstName: testFirstNameVoter} update := &api.Update{CallbackQuery: &api.CallbackQuery{ ID: "callback-id", From: &voter, Data: "spam_vote:" + strconv.FormatInt(spamCase.ID, 10) + ":1", Message: &api.Message{MessageID: 400, Chat: logChat}, @@ -1518,10 +1518,10 @@ func TestHandleMessageLinkedChannelSenderBypassesSpamPipeline(t *testing.T) { t.Fatalf("expected linked group lookup, got chat_id %q", got) } return map[string]any{ - "id": -100, - testJSONType: testChatTypeSupergroup, - testJSONTitle: "Discussion", - "linked_chat_id": -200, + "id": -100, + testJSONType: testChatTypeSupergroup, + testJSONTitle: "Discussion", + testJSONLinkedChatID: -200, } default: t.Fatalf("unexpected bot method: %s", method) @@ -1554,7 +1554,7 @@ func TestHandleMessageLinkedChannelSenderBypassesSpamPipeline(t *testing.T) { msg := &api.Message{ MessageID: 15, Chat: *chat, - From: &api.User{ID: 200, FirstName: "Forwarder"}, + From: &api.User{ID: 200, FirstName: testFirstNameForwarder}, SenderChat: &api.Chat{ ID: -200, Type: testChatTypeChannel, @@ -1610,7 +1610,7 @@ func TestHandleMessageSenderChatLookupFailureIsRetryable(t *testing.T) { banService: &testBanService{}, lastResults: make(map[messageResultKey]*MessageProcessingResult), } chat := &api.Chat{ID: -100, Type: testChatTypeSupergroup} - message := &api.Message{MessageID: 16, Chat: *chat, SenderChat: &api.Chat{ID: -200, Type: testChatTypeChannel}, Text: "candidate"} + message := &api.Message{MessageID: 16, Chat: *chat, SenderChat: &api.Chat{ID: -200, Type: testChatTypeChannel}, Text: testCandidateValue} _, err := reactor.Handle(t.Context(), &api.Update{Message: message}, chat, nil) failure := botservice.ClassifyUpdateFailure(err) if failure.Disposition != botservice.UpdateFailureRetryable || failure.Source != botservice.UpdateFailureTelegram { diff --git a/internal/handlers/chat/reactor_message_probation_test.go b/internal/handlers/chat/reactor_message_probation_test.go index b202731..0f62f92 100644 --- a/internal/handlers/chat/reactor_message_probation_test.go +++ b/internal/handlers/chat/reactor_message_probation_test.go @@ -64,7 +64,7 @@ func TestMessageProbationSafeExitRejectsDuplicateAndStaysPerChat(t *testing.T) { otherChat := &api.Chat{ID: -101, Type: testChatTypeSupergroup} user := &api.User{ID: 200, FirstName: testFirstNameUser} settings := &db.Settings{LLMFirstMessageEnabled: true, CommunityVotingEnabled: true} - first := &api.Message{MessageID: 1, Chat: *chat, From: user, Text: "safe first message"} + first := &api.Message{MessageID: 1, Chat: *chat, From: user, Text: testSafeFirstMessage} if err := reactor.handleMessage(t.Context(), first, chat, user, settings); err != nil { t.Fatalf("handle first message: %v", err) @@ -246,7 +246,7 @@ func TestMessageProbationChecksRichMessagePostsAndEdits(t *testing.T) { Chat: *chat, From: user, RichMessage: &api.RichMessage{Blocks: []api.RichBlock{ - api.RichBlockParagraph{Type: "paragraph", Text: "safe rich post"}, + api.RichBlockParagraph{Type: testRichBlockParagraph, Text: "safe rich post"}, }}, } if _, err := reactor.Handle(t.Context(), &api.Update{Message: post}, chat, user); err != nil { @@ -258,7 +258,7 @@ func TestMessageProbationChecksRichMessagePostsAndEdits(t *testing.T) { edit := *post edit.EditDate = now.Unix() edit.RichMessage = &api.RichMessage{Blocks: []api.RichBlock{ - api.RichBlockParagraph{Type: "paragraph", Text: "spam rich edit"}, + api.RichBlockParagraph{Type: testRichBlockParagraph, Text: "spam rich edit"}, }} if _, err := reactor.Handle(t.Context(), &api.Update{EditedMessage: &edit}, chat, user); err != nil { t.Fatalf("handle rich edit: %v", err) @@ -272,7 +272,7 @@ func TestMessageProbationChecksRichMessagePostsAndEdits(t *testing.T) { release := *post release.MessageID = 2 release.RichMessage = &api.RichMessage{Blocks: []api.RichBlock{ - api.RichBlockParagraph{Type: "paragraph", Text: "safe rich release"}, + api.RichBlockParagraph{Type: testRichBlockParagraph, Text: "safe rich release"}, }} if _, err := reactor.Handle(t.Context(), &api.Update{Message: &release}, chat, user); err != nil { t.Fatalf("handle rich release: %v", err) @@ -314,7 +314,7 @@ func TestCommandsAndMentionsStartProbationWithoutClassification(t *testing.T) { Chat: *chat, From: mentionUser, Text: "@ngbot", - Entities: []api.MessageEntity{{Type: "mention", Offset: 0, Length: 6}}, + Entities: []api.MessageEntity{{Type: testEntityMention, Offset: 0, Length: 6}}, } if _, err := reactor.Handle(t.Context(), &api.Update{Message: mention}, chat, mentionUser); err != nil { t.Fatalf("handle mention: %v", err) diff --git a/internal/handlers/chat/reactor_reaction_profile_check_test.go b/internal/handlers/chat/reactor_reaction_profile_check_test.go index 32e0a3b..36c7864 100644 --- a/internal/handlers/chat/reactor_reaction_profile_check_test.go +++ b/internal/handlers/chat/reactor_reaction_profile_check_test.go @@ -39,7 +39,7 @@ func TestReactionProfilePolicyFailureReturnsRetryableFailure(t *testing.T) { func TestReactionMalformedClassificationReturnsRetryableFailure(t *testing.T) { t.Parallel() - actorChat := &api.Chat{ID: -100999, Type: testChatTypeChannel, Title: "candidate"} + actorChat := &api.Chat{ID: -100999, Type: testChatTypeChannel, Title: testCandidateValue} botAPI := newTestBotAPI(t, func(method string, _ *http.Request) any { if method != testTelegramMethodGetChat { t.Fatalf("unexpected bot method: %s", method) @@ -447,7 +447,7 @@ func TestHandleMessageReactionAllowlistedUserBypassesBanlist(t *testing.T) { banService: banService, } chat := &api.Chat{ID: -100, Type: testChatTypeSupergroup} - user := &api.User{ID: 200, UserName: "allowlisted"} + user := &api.User{ID: 200, UserName: testAllowlistedUser} reaction := &api.MessageReactionUpdated{Chat: *chat, MessageID: 42, User: user} if err := reactor.moderateReactionUser(context.Background(), reaction, chat, user, reactor.getLogEntry()); err != nil { diff --git a/internal/handlers/chat/telegram_method_test.go b/internal/handlers/chat/telegram_method_test.go index de2d918..ee1a942 100644 --- a/internal/handlers/chat/telegram_method_test.go +++ b/internal/handlers/chat/telegram_method_test.go @@ -3,6 +3,7 @@ package handlers const ( testTelegramMethodBanChatMember = "banChatMember" testTelegramMethodBanChatSenderChat = "banChatSenderChat" + testTelegramMethodApproveJoinRequest = "approveChatJoinRequest" testTelegramMethodDeleteAllReactions = "deleteAllMessageReactions" testTelegramMethodDeleteMessage = "deleteMessage" testTelegramMethodDeclineJoinRequest = "declineChatJoinRequest" diff --git a/internal/handlers/chat/test_bot_api_test.go b/internal/handlers/chat/test_bot_api_test.go index 22ebfdd..aaa12dd 100644 --- a/internal/handlers/chat/test_bot_api_test.go +++ b/internal/handlers/chat/test_bot_api_test.go @@ -25,9 +25,11 @@ const ( testCorrectChoice = "correct-choice" testCaptchaOptionsJSON = `[{"id":"correct-choice","symbol":"A"},{"id":"wrong-choice","symbol":"B"}]` testJoinRequestDecline = "decline" + testJoinRequestQuery = "query" testWebAppFormChoice = "choice" testWebAppFormInitData = "init_data" testEntityBotCommand = "bot_command" + testEntityMention = "mention" testVoteBanCommand = "/voteban" testJSONIsBot = "is_bot" testJSONFirstName = "first_name" @@ -39,6 +41,7 @@ const ( testChatTypeSupergroup = "supergroup" testMemberStatusLeft = "left" testGroupUsername = "waveclub" + testGenericGroupTitle = "Group" testJoinQueryID = "join-query" testExpiredChallengeID = "uuid-expired" testToken = "tok" @@ -51,8 +54,15 @@ const ( testWebAppFormToken = "token" testMessageText = "hello there" testSpamMessageText = "spam" + testSafeFirstMessage = "safe first message" + testCandidateValue = "candidate" + testAllowlistedUser = "allowlisted" testCaptchaBook = "book" testCaptchaCar = "car" + testJSONLinkedChatID = "linked_chat_id" + testFirstNameForwarder = "Forwarder" + testFirstNameVoter = "Voter" + testRichBlockParagraph = "paragraph" ) type testBotAPIError struct { diff --git a/internal/handlers/moderation/ban_service_actions_test.go b/internal/handlers/moderation/ban_service_actions_test.go index 5435758..b6df0ed 100644 --- a/internal/handlers/moderation/ban_service_actions_test.go +++ b/internal/handlers/moderation/ban_service_actions_test.go @@ -66,17 +66,17 @@ func TestMuteUserPersistsCapturedPermissionsBeforeTelegram(t *testing.T) { var restrictions []api.ChatPermissions botAPI := newModerationTestBotAPI(t, func(method string, r *http.Request) any { switch method { - case "getChatMember": + case moderationTestTelegramMethodGetChatMember: return map[string]any{ - "user": map[string]any{"id": 200, "is_bot": false, "first_name": "User"}, - "status": "member", + moderationTestJSONUser: map[string]any{"id": 200, moderationTestJSONIsBot: false, moderationTestJSONFirstName: moderationTestFirstNameUser}, + moderationTestJSONStatus: moderationTestMemberStatusMember, } - case "getChat": + case moderationTestTelegramMethodGetChat: return map[string]any{ - "id": -100, "type": "supergroup", - "permissions": map[string]any{"can_send_messages": true, "can_send_photos": true}, + "id": -100, moderationTestJSONType: moderationTestSupergroup, + moderationTestJSONPermissions: map[string]any{moderationTestJSONPermissionCanSendMessages: true, "can_send_photos": true}, } - case "restrictChatMember": + case moderationTestTelegramMethodRestrictChatMember: if err := r.ParseForm(); err != nil { t.Fatalf("parse form: %v", err) } @@ -128,7 +128,7 @@ func TestUnmuteUserRestoresCapturedRestrictivePermissions(t *testing.T) { var permissions api.ChatPermissions botAPI := newModerationTestBotAPI(t, func(method string, r *http.Request) any { - if method != "restrictChatMember" { + if method != moderationTestTelegramMethodRestrictChatMember { t.Fatalf("unexpected bot method: %s", method) } if err := r.ParseForm(); err != nil { @@ -186,11 +186,11 @@ func TestMuteUserUsesVotingDeadlineAndSnapshotSurvivesExpiry(t *testing.T) { var untilDate int64 botAPI := newModerationTestBotAPI(t, func(method string, r *http.Request) any { switch method { - case "getChatMember": - return map[string]any{"user": map[string]any{"id": 200, "is_bot": false, "first_name": "User"}, "status": "member"} - case "getChat": - return map[string]any{"id": -100, "type": "supergroup", "permissions": map[string]any{"can_send_messages": true, "can_send_photos": false}} - case "restrictChatMember": + case moderationTestTelegramMethodGetChatMember: + return map[string]any{moderationTestJSONUser: map[string]any{"id": 200, moderationTestJSONIsBot: false, moderationTestJSONFirstName: moderationTestFirstNameUser}, moderationTestJSONStatus: moderationTestMemberStatusMember} + case moderationTestTelegramMethodGetChat: + return map[string]any{"id": -100, moderationTestJSONType: moderationTestSupergroup, moderationTestJSONPermissions: map[string]any{moderationTestJSONPermissionCanSendMessages: true, "can_send_photos": false}} + case moderationTestTelegramMethodRestrictChatMember: if err := r.ParseForm(); err != nil { t.Fatal(err) } @@ -249,18 +249,18 @@ func TestModerationAvailabilityCachesRightsAndExplicitFailureWins(t *testing.T) getChatMemberCalls := 0 botAPI := newModerationTestBotAPI(t, func(method string, _ *http.Request) any { - if method != "getChatMember" { + if method != moderationTestTelegramMethodGetChatMember { t.Fatalf("unexpected bot method: %s", method) } getChatMemberCalls++ return map[string]any{ - "user": map[string]any{ - "id": 1, - "is_bot": true, - "first_name": "Test", + moderationTestJSONUser: map[string]any{ + "id": 1, + moderationTestJSONIsBot: true, + moderationTestJSONFirstName: moderationTestFirstNameBot, }, - "status": "administrator", - "can_restrict_members": true, + moderationTestJSONStatus: "administrator", + "can_restrict_members": true, } }) @@ -290,16 +290,16 @@ func TestMutePrivilegeFailureImmediatelyDisablesModeration(t *testing.T) { botAPI := newModerationRetryTestBotAPI(t, func(method string, _ *http.Request) testAPIResponse { switch method { - case "getChatMember": + case moderationTestTelegramMethodGetChatMember: return testAPIResponse{OK: true, Result: map[string]any{ - "user": map[string]any{"id": 200, "is_bot": false, "first_name": "User"}, - "status": "member", + moderationTestJSONUser: map[string]any{"id": 200, moderationTestJSONIsBot: false, moderationTestJSONFirstName: moderationTestFirstNameUser}, + moderationTestJSONStatus: moderationTestMemberStatusMember, }} - case "getChat": + case moderationTestTelegramMethodGetChat: return testAPIResponse{OK: true, Result: map[string]any{ - "id": -100, "type": "supergroup", "permissions": map[string]any{"can_send_messages": true}, + "id": -100, moderationTestJSONType: moderationTestSupergroup, moderationTestJSONPermissions: map[string]any{moderationTestJSONPermissionCanSendMessages: true}, }} - case "restrictChatMember": + case moderationTestTelegramMethodRestrictChatMember: return testAPIResponse{OK: false, Description: "Bad Request: CHAT_ADMIN_REQUIRED"} default: t.Fatalf("unexpected bot method: %s", method) diff --git a/internal/handlers/moderation/ban_service_test.go b/internal/handlers/moderation/ban_service_test.go index bea4f74..cc22d46 100644 --- a/internal/handlers/moderation/ban_service_test.go +++ b/internal/handlers/moderation/ban_service_test.go @@ -196,7 +196,7 @@ func TestCheckBanPublishesOnlinePositiveBeforeBlockedPersistence(t *testing.T) { knownBanned: map[int64]struct{}{}, providers: []banlistProvider{ { - name: "positive", + name: moderationTestBanlistProviderPositive, check: func(context.Context, *http.Client, int64) (bool, error) { return true, nil }, @@ -225,7 +225,7 @@ func TestCheckBanPublishesOnlinePositiveBeforeBlockedPersistence(t *testing.T) { case <-time.After(time.Second): t.Fatal("online persistence worker did not receive observation") } - if store.hasSource(banlistSourceKey{provider: "positive", feedType: banlistFeedOnline}, 789) { + if store.hasSource(banlistSourceKey{provider: moderationTestBanlistProviderPositive, feedType: banlistFeedOnline}, 789) { t.Fatal("blocked persistence unexpectedly completed") } store.releasePersistence() @@ -234,7 +234,7 @@ func TestCheckBanPublishesOnlinePositiveBeforeBlockedPersistence(t *testing.T) { case <-time.After(time.Second): t.Fatal("online persistence did not complete after writer was released") } - if !store.hasSource(banlistSourceKey{provider: "positive", feedType: banlistFeedOnline}, 789) { + if !store.hasSource(banlistSourceKey{provider: moderationTestBanlistProviderPositive, feedType: banlistFeedOnline}, 789) { t.Fatal("online positive was not persisted by lifecycle worker") } } diff --git a/internal/handlers/moderation/spam_control_known_non_member_test.go b/internal/handlers/moderation/spam_control_known_non_member_test.go index 7fffed7..6f3a89b 100644 --- a/internal/handlers/moderation/spam_control_known_non_member_test.go +++ b/internal/handlers/moderation/spam_control_known_non_member_test.go @@ -302,13 +302,13 @@ func TestRecordVoteRejectsDepartedVoterEvenWhenMembershipCacheSaysMember(t *test t.Parallel() botAPI := newModerationTestBotAPI(t, func(method string, _ *http.Request) any { - if method != "getChatMember" { + if method != moderationTestTelegramMethodGetChatMember { t.Fatalf("unexpected method %q", method) } return map[string]any{ - "user": map[string]any{"id": 300, "is_bot": false, "first_name": "Voter"}, - "status": "left", - "is_member": false, + moderationTestJSONUser: map[string]any{"id": 300, moderationTestJSONIsBot: false, moderationTestJSONFirstName: "Voter"}, + moderationTestJSONStatus: "left", + "is_member": false, } }) spamCase := &db.SpamCase{ID: 1, ChatID: -100, UserID: 200, Status: db.SpamCaseStatusPending} @@ -330,13 +330,13 @@ func TestRecordVoteRejectsFreshlyBanlistedVoter(t *testing.T) { t.Parallel() botAPI := newModerationTestBotAPI(t, func(method string, _ *http.Request) any { - if method != "getChatMember" { + if method != moderationTestTelegramMethodGetChatMember { t.Fatalf("unexpected method %q", method) } return map[string]any{ - "user": map[string]any{"id": 300, "is_bot": false, "first_name": "Voter"}, - "status": "member", - "is_member": true, + moderationTestJSONUser: map[string]any{"id": 300, moderationTestJSONIsBot: false, moderationTestJSONFirstName: "Voter"}, + moderationTestJSONStatus: moderationTestMemberStatusMember, + "is_member": true, } }) spamCase := &db.SpamCase{ID: 1, ChatID: -100, UserID: 200, Status: db.SpamCaseStatusPending} @@ -508,7 +508,7 @@ func newModerationTestBotAPI(t *testing.T, handler func(method string, r *http.R result = map[string]any{ "id": 1, moderationTestJSONIsBot: true, - moderationTestJSONFirstName: "Test", + moderationTestJSONFirstName: moderationTestFirstNameBot, "username": "testbot", } default: @@ -981,10 +981,10 @@ func TestRecordVoteRejectsLogChannelOutsider(t *testing.T) { botAPI := newModerationTestBotAPI(t, func(method string, _ *http.Request) any { switch method { - case "getChatMember": + case moderationTestTelegramMethodGetChatMember: return map[string]any{ - "status": "left", - moderationTestJSONUser: map[string]any{"id": 300, moderationTestJSONIsBot: false, moderationTestJSONFirstName: "Outsider"}, + moderationTestJSONStatus: "left", + moderationTestJSONUser: map[string]any{"id": 300, moderationTestJSONIsBot: false, moderationTestJSONFirstName: "Outsider"}, } default: t.Fatalf("unexpected bot method: %s", method) diff --git a/internal/handlers/moderation/spam_control_notification_test.go b/internal/handlers/moderation/spam_control_notification_test.go index 7ab3cd0..5c8b71c 100644 --- a/internal/handlers/moderation/spam_control_notification_test.go +++ b/internal/handlers/moderation/spam_control_notification_test.go @@ -216,7 +216,7 @@ func TestVotingSurfaceFallbackPrecedesDestructiveModeration(t *testing.T) { MessageID: 40, Chat: api.Chat{ID: -100, Type: moderationTestSupergroup}, From: &api.User{ID: 200, FirstName: moderationTestTargetName}, - Text: "candidate", + Text: moderationTestCandidateText, } _, err := sc.ProcessSpamMessage(context.Background(), msg, &msg.Chat, "en") @@ -274,7 +274,7 @@ func TestVotingPermissionFailureClosesCaseWithoutDeletingOriginalMessage(t *test MessageID: 40, Chat: api.Chat{ID: -100, Type: moderationTestSupergroup}, From: &api.User{ID: 200, FirstName: moderationTestTargetName}, - Text: "candidate", + Text: moderationTestCandidateText, } result, err := sc.ProcessSpamMessage(context.Background(), msg, &msg.Chat, "en") @@ -421,7 +421,7 @@ func TestVotingSurfacePersistenceFailureCompensatesBeforeModeration(t *testing.T MessageID: 40, Chat: api.Chat{ID: -100, Type: moderationTestSupergroup}, From: &api.User{ID: 200, FirstName: moderationTestTargetName}, - Text: "candidate", + Text: moderationTestCandidateText, } if _, err := sc.ProcessSpamMessage(context.Background(), msg, &msg.Chat, "en"); err == nil { @@ -457,7 +457,7 @@ func newModerationRetryTestBotAPI(t *testing.T, handler func(method string, r *h response.Result = map[string]any{ "id": 1, moderationTestJSONIsBot: true, - moderationTestJSONFirstName: "Test", + moderationTestJSONFirstName: moderationTestFirstNameBot, "username": "testbot", } default: diff --git a/internal/handlers/moderation/spam_control_test_constants_test.go b/internal/handlers/moderation/spam_control_test_constants_test.go index 0787196..13902e7 100644 --- a/internal/handlers/moderation/spam_control_test_constants_test.go +++ b/internal/handlers/moderation/spam_control_test_constants_test.go @@ -1,13 +1,24 @@ package handlers const ( - moderationTestJSONIsBot = "is_bot" - moderationTestJSONFirstName = "first_name" - moderationTestJSONMessageID = "message_id" - moderationTestJSONDate = "date" - moderationTestJSONChat = "chat" - moderationTestJSONType = "type" - moderationTestJSONUser = "user" - moderationTestSupergroup = "supergroup" - moderationTestTargetName = "Target" + moderationTestJSONIsBot = "is_bot" + moderationTestJSONFirstName = "first_name" + moderationTestJSONMessageID = "message_id" + moderationTestJSONDate = "date" + moderationTestJSONChat = "chat" + moderationTestJSONType = "type" + moderationTestJSONUser = "user" + moderationTestJSONStatus = "status" + moderationTestJSONPermissions = "permissions" + moderationTestJSONPermissionCanSendMessages = "can_send_messages" + moderationTestSupergroup = "supergroup" + moderationTestTargetName = "Target" + moderationTestFirstNameUser = "User" + moderationTestFirstNameBot = "Test" + moderationTestMemberStatusMember = "member" + moderationTestCandidateText = "candidate" + moderationTestBanlistProviderPositive = "positive" + moderationTestTelegramMethodGetChat = "getChat" + moderationTestTelegramMethodGetChatMember = "getChatMember" + moderationTestTelegramMethodRestrictChatMember = "restrictChatMember" ) From 7fb303c9d98d16576ddc12ada177c1c44a60e845 Mon Sep 17 00:00:00 2001 From: Valeriy Selitskiy <239034+iamwavecut@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:55:19 +0200 Subject: [PATCH 5/6] Fix container identity CI check --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3363e5..32d31a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: - name: Verify container identity and persistent migrations run: | set -eu - test "$(docker image inspect ngbot:ci --format '{{ index .Config.Labels \"org.opencontainers.image.revision\" }}')" = "$GITHUB_SHA" + test "$(docker image inspect ngbot:ci --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}')" = "$GITHUB_SHA" test "$(docker run --rm --entrypoint ./ngbot ngbot:ci --version)" = "ngbot version=ci revision=$GITHUB_SHA build_date=1970-01-01T00:00:00Z" docker volume create ngbot-ci-data >/dev/null trap 'docker volume rm ngbot-ci-data >/dev/null' EXIT From f85ee343d3facc8676d3e17bf89c23e0d59948c7 Mon Sep 17 00:00:00 2001 From: Valeriy Selitskiy <239034+iamwavecut@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:06:50 +0200 Subject: [PATCH 6/6] Initialize CI data volume ownership --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32d31a9..726f80c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,7 @@ jobs: test "$(docker run --rm --entrypoint ./ngbot ngbot:ci --version)" = "ngbot version=ci revision=$GITHUB_SHA build_date=1970-01-01T00:00:00Z" docker volume create ngbot-ci-data >/dev/null trap 'docker volume rm ngbot-ci-data >/dev/null' EXIT + docker run --rm --mount source=ngbot-ci-data,target=/data alpine:3.22.1@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1 chown 65532:65532 /data for pass in 1 2; do docker run --rm --mount source=ngbot-ci-data,target=/data \ --env NG_TOKEN=validation-token \