diff --git a/cmd/openai-status-bot/main.go b/cmd/openai-status-bot/main.go index fb862eb..f7a7978 100644 --- a/cmd/openai-status-bot/main.go +++ b/cmd/openai-status-bot/main.go @@ -2,7 +2,9 @@ package main import ( "context" + "encoding/json" "errors" + "fmt" "log/slog" "net/http" "os" @@ -13,6 +15,7 @@ import ( "time" tgbot "github.com/go-telegram/bot" + tgmodels "github.com/go-telegram/bot/models" "github.com/tiennm99/openai-status-bot/internal/bot" "github.com/tiennm99/openai-status-bot/internal/config" "github.com/tiennm99/openai-status-bot/internal/health" @@ -24,6 +27,11 @@ import ( "go.mongodb.org/mongo-driver/v2/mongo/options" ) +type telegramWebhookManager interface { + DeleteWebhook(context.Context, *tgbot.DeleteWebhookParams) (bool, error) + GetWebhookInfo(context.Context) (*tgmodels.WebhookInfo, error) +} + func main() { cfg, err := config.LoadFromEnv() if err != nil { @@ -103,7 +111,7 @@ func main() { os.Exit(1) } requestCtx, cancelRequest := contextWithOptionalTimeout(ctx, cfg.HTTPTimeout) - _, err = telegramBot.DeleteWebhook(requestCtx, &tgbot.DeleteWebhookParams{DropPendingUpdates: false}) + err = clearTelegramWebhook(requestCtx, telegramBot) cancelRequest() if err != nil { logger.Error("delete telegram webhook", "error", redactTelegramRuntimeError(cfg.TelegramBotToken, err)) @@ -163,3 +171,33 @@ func frameworkInitialOffset(nextOffset int64) (int64, bool) { } return nextOffset - 1, true } + +func clearTelegramWebhook(ctx context.Context, telegramBot telegramWebhookManager) error { + _, err := telegramBot.DeleteWebhook(ctx, &tgbot.DeleteWebhookParams{DropPendingUpdates: false}) + if err == nil { + return nil + } + if !isDeleteWebhookEmptyResponseError(err) { + return err + } + + info, infoErr := telegramBot.GetWebhookInfo(ctx) + if infoErr != nil { + return fmt.Errorf("deleteWebhook returned an empty response and getWebhookInfo failed: %w", errors.Join(err, infoErr)) + } + if info == nil { + return fmt.Errorf("deleteWebhook returned an empty response and getWebhookInfo returned no webhook info: %w", err) + } + if info.URL != "" { + return fmt.Errorf("deleteWebhook returned an empty response and webhook is still configured: %w", err) + } + return nil +} + +func isDeleteWebhookEmptyResponseError(err error) bool { + var syntaxErr *json.SyntaxError + if !errors.As(err, &syntaxErr) { + return false + } + return strings.Contains(err.Error(), "error decode response body for method deleteWebhook, ,") +} diff --git a/cmd/openai-status-bot/main_test.go b/cmd/openai-status-bot/main_test.go index 52a903d..74c7627 100644 --- a/cmd/openai-status-bot/main_test.go +++ b/cmd/openai-status-bot/main_test.go @@ -1,6 +1,16 @@ package main -import "testing" +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + + tgbot "github.com/go-telegram/bot" + tgmodels "github.com/go-telegram/bot/models" +) func TestFrameworkInitialOffsetSeedsLastSeenUpdate(t *testing.T) { tests := []struct { @@ -23,3 +33,95 @@ func TestFrameworkInitialOffsetSeedsLastSeenUpdate(t *testing.T) { }) } } + +func TestClearTelegramWebhookReturnsNilOnSuccessfulDelete(t *testing.T) { + manager := &fakeWebhookManager{} + + if err := clearTelegramWebhook(context.Background(), manager); err != nil { + t.Fatalf("clearTelegramWebhook returned error: %v", err) + } + if manager.deleteCalls != 1 || manager.infoCalls != 0 { + t.Fatalf("calls = delete %d info %d, want delete 1 info 0", manager.deleteCalls, manager.infoCalls) + } +} + +func TestClearTelegramWebhookAcceptsEmptyDeleteResponseWhenWebhookGone(t *testing.T) { + manager := &fakeWebhookManager{ + deleteErr: emptyDeleteWebhookResponseError(t), + webhookInfo: &tgmodels.WebhookInfo{}, + } + + if err := clearTelegramWebhook(context.Background(), manager); err != nil { + t.Fatalf("clearTelegramWebhook returned error: %v", err) + } + if manager.deleteCalls != 1 || manager.infoCalls != 1 { + t.Fatalf("calls = delete %d info %d, want delete 1 info 1", manager.deleteCalls, manager.infoCalls) + } +} + +func TestClearTelegramWebhookFailsWhenWebhookStillConfiguredAfterEmptyDeleteResponse(t *testing.T) { + manager := &fakeWebhookManager{ + deleteErr: emptyDeleteWebhookResponseError(t), + webhookInfo: &tgmodels.WebhookInfo{URL: "https://example.com/webhook"}, + } + + err := clearTelegramWebhook(context.Background(), manager) + if err == nil || !strings.Contains(err.Error(), "webhook is still configured") { + t.Fatalf("error = %v, want webhook still configured", err) + } +} + +func TestClearTelegramWebhookFailsWhenWebhookInfoCheckFails(t *testing.T) { + infoErr := errors.New("getWebhookInfo unavailable") + manager := &fakeWebhookManager{ + deleteErr: emptyDeleteWebhookResponseError(t), + webhookInfoErr: infoErr, + } + + err := clearTelegramWebhook(context.Background(), manager) + if !errors.Is(err, infoErr) { + t.Fatalf("error = %v, want wrapping info error", err) + } +} + +func TestClearTelegramWebhookReturnsNonEmptyDeleteError(t *testing.T) { + deleteErr := errors.New("telegram unauthorized") + manager := &fakeWebhookManager{deleteErr: deleteErr} + + err := clearTelegramWebhook(context.Background(), manager) + if !errors.Is(err, deleteErr) { + t.Fatalf("error = %v, want delete error", err) + } + if manager.infoCalls != 0 { + t.Fatalf("infoCalls = %d, want 0", manager.infoCalls) + } +} + +type fakeWebhookManager struct { + deleteErr error + webhookInfo *tgmodels.WebhookInfo + webhookInfoErr error + deleteCalls int + infoCalls int +} + +func (f *fakeWebhookManager) DeleteWebhook(context.Context, *tgbot.DeleteWebhookParams) (bool, error) { + f.deleteCalls++ + return f.deleteErr == nil, f.deleteErr +} + +func (f *fakeWebhookManager) GetWebhookInfo(context.Context) (*tgmodels.WebhookInfo, error) { + f.infoCalls++ + return f.webhookInfo, f.webhookInfoErr +} + +func emptyDeleteWebhookResponseError(t *testing.T) error { + t.Helper() + + var response struct{} + err := json.Unmarshal([]byte{}, &response) + if err == nil { + t.Fatal("expected json decode error") + } + return fmt.Errorf("error decode response body for method deleteWebhook, , %w", err) +}