package postgres import ( "bytes" "context" "testing" "time" "telesrv/internal/domain" ) func TestBotAPICallbackQueryQueueRoundTrip(t *testing.T) { pool := testPool(t) ctx := context.Background() suffix := randomSuffix(t) users := NewUserStore(pool) bot, err := users.Create(ctx, domain.User{ AccessHash: 921, Phone: "+1921" + suffix + "01", FirstName: "CallbackQueueBot", }) if err != nil { t.Fatalf("create bot user: %v", err) } clicker, err := users.Create(ctx, domain.User{ AccessHash: 922, Phone: "+1922" + suffix + "02", FirstName: "CallbackClicker", }) if err != nil { t.Fatalf("create callback user: %v", err) } if _, err := pool.Exec(ctx, ` INSERT INTO bots (bot_user_id, owner_user_id, token_secret) VALUES ($1, $1, 'callback-queue-secret')`, bot.ID); err != nil { t.Fatalf("seed bot: %v", err) } t.Cleanup(func() { _, _ = pool.Exec(ctx, "DELETE FROM bot_api_updates WHERE bot_user_id = $1", bot.ID) _, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID) _, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID) }) callback := &domain.BotCallbackQuery{ ID: 880011, BotUserID: bot.ID, UserID: clicker.ID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: clicker.ID}, MessageID: 17, ChatInstance: 990022, Data: []byte{0, 1, 0xff, 'x'}, } req := domain.EnqueueBotAPIUpdateRequest{ BotUserID: bot.ID, Kind: domain.BotAPIUpdateCallbackQuery, Peer: callback.Peer, MessageID: callback.MessageID, Date: int(time.Now().Unix()), Callback: callback, } store := NewBotAPIUpdateStore(pool) first, created, err := store.EnqueueBotAPIUpdate(ctx, req) if err != nil || !created { t.Fatalf("enqueue callback: row=%+v created=%v err=%v", first, created, err) } again, created, err := store.EnqueueBotAPIUpdate(ctx, req) if err != nil || created || again.ID != first.ID { t.Fatalf("dedupe callback: row=%+v created=%v err=%v", again, created, err) } items, err := store.ListBotAPIUpdates(ctx, bot.ID, first.ID, 100) if err != nil || len(items) != 1 { t.Fatalf("list callback = %+v, %v", items, err) } got := items[0].Callback if got == nil || got.ID != callback.ID || got.BotUserID != bot.ID || got.UserID != clicker.ID || got.Peer != callback.Peer || got.MessageID != callback.MessageID || got.ChatInstance != callback.ChatInstance || !bytes.Equal(got.Data, callback.Data) { t.Fatalf("callback round trip = %+v, want %+v", got, callback) } } func TestBotAPIInlineCallbackAndWebhookStateRoundTrip(t *testing.T) { pool := testPool(t) ctx := context.Background() suffix := randomSuffix(t) users := NewUserStore(pool) bot, err := users.Create(ctx, domain.User{AccessHash: 931, Phone: "+1931" + suffix + "01", FirstName: "WebhookBot"}) if err != nil { t.Fatal(err) } clicker, err := users.Create(ctx, domain.User{AccessHash: 932, Phone: "+1932" + suffix + "02", FirstName: "InlineClicker"}) if err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `INSERT INTO bots (bot_user_id, owner_user_id, token_secret) VALUES ($1, $1, 'webhook-secret')`, bot.ID); err != nil { t.Fatal(err) } t.Cleanup(func() { _, _ = pool.Exec(ctx, "DELETE FROM bot_api_webhooks WHERE bot_user_id = $1", bot.ID) _, _ = pool.Exec(ctx, "DELETE FROM bot_api_updates WHERE bot_user_id = $1", bot.ID) _, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID) _, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID) }) s := NewBotAPIUpdateStore(pool) inline := &domain.BotInlineMessageID{DCID: 2, OwnerID: clicker.ID, ID: 17, AccessHash: 445566} callback := &domain.BotCallbackQuery{ ID: 9911, BotUserID: bot.ID, UserID: clicker.ID, ChatInstance: 8811, Data: []byte{0, 1, 0xff}, InlineMessage: inline, } row, created, err := s.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{ BotUserID: bot.ID, Kind: domain.BotAPIUpdateCallbackQuery, Date: int(time.Now().Unix()), Callback: callback, }) if err != nil || !created { t.Fatalf("enqueue inline callback row=%#v created=%v err=%v", row, created, err) } items, err := s.ListBotAPIUpdates(ctx, bot.ID, row.ID, 100) if err != nil || len(items) != 1 || items[0].Peer != (domain.Peer{}) || items[0].MessageID != 0 || items[0].Callback == nil || items[0].Callback.InlineMessage == nil || *items[0].Callback.InlineMessage != *inline || !bytes.Equal(items[0].Callback.Data, callback.Data) { t.Fatalf("inline callback items=%#v err=%v", items, err) } config := domain.BotAPIWebhook{ BotUserID: bot.ID, URL: "https://example.test/hook", SecretToken: "safe_secret", MaxConnections: 8, AllowedUpdates: []domain.BotAPIUpdateKind{domain.BotAPIUpdateCallbackQuery}, AllowedUpdatesSet: true, } if err := s.SetBotAPIWebhook(ctx, config, false); err != nil { t.Fatal(err) } stored, found, err := s.BotAPIWebhook(ctx, bot.ID) if err != nil || !found || stored.URL != config.URL || stored.SecretToken != config.SecretToken || stored.MaxConnections != 8 || len(stored.AllowedUpdates) != 1 { t.Fatalf("webhook=%#v found=%v err=%v", stored, found, err) } config.URL = "https://example.test/reconfigured" config.AllowedUpdates = nil config.AllowedUpdatesSet = false if err := s.SetBotAPIWebhook(ctx, config, false); err != nil { t.Fatal(err) } stored, found, err = s.BotAPIWebhook(ctx, bot.ID) if err != nil || !found || stored.URL != config.URL || len(stored.AllowedUpdates) != 1 || stored.AllowedUpdates[0] != domain.BotAPIUpdateCallbackQuery { t.Fatalf("preserved webhook=%#v found=%v err=%v", stored, found, err) } if acquired, err := s.AcquireBotAPIWebhookLease(ctx, bot.ID, "one", time.Minute); err != nil || !acquired { t.Fatalf("first lease=%v err=%v", acquired, err) } if acquired, err := s.AcquireBotAPIWebhookLease(ctx, bot.ID, "two", time.Minute); err != nil || acquired { t.Fatalf("second lease=%v err=%v", acquired, err) } if err := s.ReleaseBotAPIWebhookLease(ctx, bot.ID, "stale"); err != nil { t.Fatal(err) } if acquired, _ := s.AcquireBotAPIWebhookLease(ctx, bot.ID, "two", time.Minute); acquired { t.Fatal("stale webhook release removed active lease") } next := time.Now().Add(time.Hour) if err := s.RecordBotAPIWebhookSuccess(ctx, bot.ID, "one", next); err != nil { t.Fatal(err) } if due, err := s.ListDueBotAPIWebhooks(ctx, 10); err != nil || len(due) != 0 { t.Fatalf("idle due=%#v err=%v", due, err) } // A newly inserted allowed callback wakes the idle webhook in the same SQL statement. callback2 := *callback callback2.ID++ callback2.InlineMessage = &domain.BotInlineMessageID{DCID: 2, OwnerID: clicker.ID, ID: 18, AccessHash: 556677} if _, created, err := s.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{ BotUserID: bot.ID, Kind: domain.BotAPIUpdateCallbackQuery, Date: int(time.Now().Unix()), Callback: &callback2, }); err != nil || !created { t.Fatalf("enqueue wake created=%v err=%v", created, err) } if due, err := s.ListDueBotAPIWebhooks(ctx, 10); err != nil || len(due) != 1 || due[0].BotUserID != bot.ID { t.Fatalf("woken due=%#v err=%v", due, err) } if err := s.DeleteBotAPIWebhook(ctx, bot.ID, true); err != nil { t.Fatal(err) } if _, found, err := s.BotAPIWebhook(ctx, bot.ID); err != nil || found { t.Fatalf("webhook after delete found=%v err=%v", found, err) } if pending, err := s.PendingBotAPIUpdateCount(ctx, bot.ID); err != nil || pending != 0 { t.Fatalf("pending after delete/drop=%d err=%v", pending, err) } } func TestBotAPIPollLeaseCrossStoreInstance(t *testing.T) { pool := testPool(t) ctx := context.Background() suffix := randomSuffix(t) users := NewUserStore(pool) bot, err := users.Create(ctx, domain.User{AccessHash: 933, Phone: "+1933" + suffix + "01", FirstName: "PollLeaseBot"}) if err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `INSERT INTO bots (bot_user_id, owner_user_id, token_secret) VALUES ($1, $1, 'poll-lease-secret')`, bot.ID); err != nil { t.Fatal(err) } t.Cleanup(func() { _, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID) _, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID) }) a, b := NewBotAPIUpdateStore(pool), NewBotAPIUpdateStore(pool) if acquired, err := a.AcquireBotAPIPollLease(ctx, bot.ID, "one", time.Minute); err != nil || !acquired { t.Fatalf("first acquire=%v err=%v", acquired, err) } if acquired, err := b.AcquireBotAPIPollLease(ctx, bot.ID, "two", time.Minute); err != nil || acquired { t.Fatalf("cross-instance acquire=%v err=%v", acquired, err) } if err := b.ReleaseBotAPIPollLease(ctx, bot.ID, "stale"); err != nil { t.Fatal(err) } if acquired, _ := b.AcquireBotAPIPollLease(ctx, bot.ID, "two", time.Minute); acquired { t.Fatal("stale release removed active poll lease") } if err := a.ReleaseBotAPIPollLease(ctx, bot.ID, "one"); err != nil { t.Fatal(err) } if acquired, err := b.AcquireBotAPIPollLease(ctx, bot.ID, "two", time.Minute); err != nil || !acquired { t.Fatalf("successor acquire=%v err=%v", acquired, err) } } func TestBotAPIPollingStateClampFilterTailAndDrop(t *testing.T) { pool := testPool(t) ctx := context.Background() users := NewUserStore(pool) suffix := randomSuffix(t) bot, err := users.Create(ctx, domain.User{ AccessHash: 923, Phone: "+1923" + suffix + "01", FirstName: "PollingStateBot", }) if err != nil { t.Fatalf("create bot user: %v", err) } if _, err := pool.Exec(ctx, `INSERT INTO bots (bot_user_id, owner_user_id, token_secret) VALUES ($1, $1, 'poll-state-secret')`, bot.ID); err != nil { t.Fatalf("seed bot: %v", err) } t.Cleanup(func() { _, _ = pool.Exec(ctx, "DELETE FROM bot_api_updates WHERE bot_user_id = $1", bot.ID) _, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID) _, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID) }) s := NewBotAPIUpdateStore(pool) enqueue := func(kind domain.BotAPIUpdateKind, messageID int) (domain.BotAPIUpdate, bool) { t.Helper() row, created, err := s.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{ BotUserID: bot.ID, Kind: kind, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bot.ID + 1}, MessageID: messageID, SourcePts: messageID, Date: int(time.Now().Unix()), }) if err != nil { t.Fatalf("enqueue %s/%d: %v", kind, messageID, err) } return row, created } for id := 1; id <= 3; id++ { if _, created := enqueue(domain.BotAPIUpdateMessage, id); !created { t.Fatalf("initial message %d was not created", id) } } if err := s.SetBotAPIAllowedUpdates(ctx, bot.ID, []domain.BotAPIUpdateKind{domain.BotAPIUpdateEditedMessage}); err != nil { t.Fatalf("set allowed updates: %v", err) } if row, created := enqueue(domain.BotAPIUpdateMessage, 4); created || row.ID != 0 { t.Fatalf("filtered row=%+v created=%v", row, created) } lastBeforeBaseline, created := enqueue(domain.BotAPIUpdateEditedMessage, 5) if !created { t.Fatal("allowed edit was filtered") } if err := s.ConfirmBotAPIUpdates(ctx, bot.ID, 1<<60); err != nil { t.Fatalf("initialize external cursor: %v", err) } confirmed, found, err := s.ConfirmedBotAPIUpdateID(ctx, bot.ID) if err != nil || !found || confirmed != lastBeforeBaseline.ID { t.Fatalf("baseline confirmed=%d found=%v err=%v want=%d", confirmed, found, err, lastBeforeBaseline.ID) } pendingRow, created := enqueue(domain.BotAPIUpdateEditedMessage, 6) if !created { t.Fatal("post-baseline edit was filtered") } if err := s.ConfirmBotAPIUpdates(ctx, bot.ID, 1<<60); err != nil { t.Fatalf("repeat external cursor: %v", err) } confirmed, _, _ = s.ConfirmedBotAPIUpdateID(ctx, bot.ID) if confirmed != lastBeforeBaseline.ID { t.Fatalf("repeat external cursor advanced to %d, want %d", confirmed, lastBeforeBaseline.ID) } tail, err := s.ListTailBotAPIUpdates(ctx, bot.ID, 1, 100) if err != nil || len(tail) != 1 || tail[0].ID != pendingRow.ID { t.Fatalf("tail=%+v err=%v want=%d", tail, err, pendingRow.ID) } if count, err := s.PendingBotAPIUpdateCount(ctx, bot.ID); err != nil || count != 1 { t.Fatalf("pending count=%d err=%v", count, err) } if err := s.DropPendingBotAPIUpdates(ctx, bot.ID); err != nil { t.Fatalf("drop pending: %v", err) } if count, err := s.PendingBotAPIUpdateCount(ctx, bot.ID); err != nil || count != 0 { t.Fatalf("pending after drop=%d err=%v", count, err) } } // TestBotAPIUpdateRetention 锁定 H1 场景矩阵: // - 已确认 + 超宽限 → 删;已确认 + 宽限内 → 留; // - 未确认 + created_at 超保留期 → 删(含无 state 行的 MTProto-only bot); // - 未确认 + created_at 在保留期内 → 留; // - 删除后 getUpdates 读路径(fromID > confirmed)不受影响。 func TestBotAPIUpdateRetention(t *testing.T) { pool := testPool(t) ctx := context.Background() suffix := randomSuffix(t) users := NewUserStore(pool) newBot := func(phoneTail, name string) int64 { t.Helper() u, err := users.Create(ctx, domain.User{ AccessHash: 920, Phone: "+1920" + suffix + phoneTail, FirstName: name, }) if err != nil { t.Fatalf("create bot user %s: %v", name, err) } if _, err := pool.Exec(ctx, ` INSERT INTO bots (bot_user_id, owner_user_id, token_secret) VALUES ($1, $1, 'retention-test-secret') ON CONFLICT (bot_user_id) DO NOTHING`, u.ID); err != nil { t.Fatalf("seed bot %s: %v", name, err) } return u.ID } confirmedBot := newBot("01", "RetentionConfirmedBot") mtprotoOnlyBot := newBot("02", "RetentionMTOnlyBot") t.Cleanup(func() { _, _ = pool.Exec(ctx, "DELETE FROM bot_api_updates WHERE bot_user_id IN ($1, $2)", confirmedBot, mtprotoOnlyBot) _, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id IN ($1, $2)", confirmedBot, mtprotoOnlyBot) _, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id IN ($1, $2)", confirmedBot, mtprotoOnlyBot) }) s := NewBotAPIUpdateStore(pool) now := time.Now().Unix() enqueue := func(botID int64, messageID int, date int64) domain.BotAPIUpdate { t.Helper() row, created, err := s.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{ BotUserID: botID, Kind: domain.BotAPIUpdateMessage, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1}, MessageID: messageID, SourcePts: messageID, Date: int(date), }) if err != nil || !created { t.Fatalf("enqueue bot=%d msg=%d: created=%v err=%v", botID, messageID, created, err) } return row } confirmedOld := enqueue(confirmedBot, 1, now) // 已确认 + created_at 回拨超宽限 → 删 confirmedFresh := enqueue(confirmedBot, 2, now) // 已确认 + 宽限内 → 留 unconfirmedFresh := enqueue(confirmedBot, 3, now) expiredNoState := enqueue(mtprotoOnlyBot, 4, now) // 无 state 行 + created_at 超保留期 → 删 freshNoState := enqueue(mtprotoOnlyBot, 5, now) if err := s.ConfirmBotAPIUpdates(ctx, confirmedBot, confirmedFresh.ID); err != nil { t.Fatalf("confirm: %v", err) } if _, err := pool.Exec(ctx, "UPDATE bot_api_updates SET created_at = now() - interval '1 hour' WHERE id = $1", confirmedOld.ID); err != nil { t.Fatalf("backdate confirmed row: %v", err) } if _, err := pool.Exec(ctx, "UPDATE bot_api_updates SET created_at = now() - interval '48 hours' WHERE id = $1", expiredNoState.ID); err != nil { t.Fatalf("backdate expired row: %v", err) } deleted, err := s.DeleteDeliveredOrExpired(ctx, 15*time.Minute, 24*time.Hour, 1000) if err != nil { t.Fatalf("DeleteDeliveredOrExpired: %v", err) } // 共享测试库可能有其它历史行同被回收,只要求至少删掉本测试的 2 行; // 精确归属由下方 remaining 断言保证。 if deleted < 2 { t.Fatalf("deleted = %d, want >= 2 (confirmed+grace expired, created_at expired)", deleted) } remaining := map[int64]bool{} rows, err := pool.Query(ctx, "SELECT id FROM bot_api_updates WHERE bot_user_id IN ($1, $2)", confirmedBot, mtprotoOnlyBot) if err != nil { t.Fatalf("list remaining: %v", err) } for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { t.Fatalf("scan remaining: %v", err) } remaining[id] = true } rows.Close() if remaining[confirmedOld.ID] { t.Fatal("confirmed row past grace was not deleted") } if remaining[expiredNoState.ID] { t.Fatal("expired row of state-less bot was not deleted") } if !remaining[confirmedFresh.ID] || !remaining[unconfirmedFresh.ID] || !remaining[freshNoState.ID] { t.Fatalf("fresh rows were deleted, remaining=%v", remaining) } // 读路径回归:确认水位之后的未确认行仍可被 getUpdates 读到。 items, err := s.ListBotAPIUpdates(ctx, confirmedBot, confirmedFresh.ID+1, 100) if err != nil { t.Fatalf("list after retention: %v", err) } if len(items) != 1 || items[0].ID != unconfirmedFresh.ID { t.Fatalf("post-retention list = %+v, want only unconfirmed fresh row %d", items, unconfirmedFresh.ID) } } func TestBotAPIEphemeralEnvelopeRoundTrip(t *testing.T) { pool := testPool(t) ctx := context.Background() suffix := randomSuffix(t) users := NewUserStore(pool) bot, err := users.Create(ctx, domain.User{AccessHash: 941, Phone: "+1941" + suffix + "01", FirstName: "EphemeralQueueBot"}) if err != nil { t.Fatal(err) } human, err := users.Create(ctx, domain.User{AccessHash: 942, Phone: "+1942" + suffix + "02", FirstName: "EphemeralHuman"}) if err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `INSERT INTO bots (bot_user_id, owner_user_id, token_secret) VALUES ($1, $1, 'ephemeral-secret')`, bot.ID); err != nil { t.Fatal(err) } t.Cleanup(func() { _, _ = pool.Exec(ctx, "DELETE FROM bot_api_updates WHERE bot_user_id = $1", bot.ID) _, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID) _, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID) }) now := time.Now() peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 3901} message := domain.EphemeralMessage{ ID: 81, Peer: peer, SenderUserID: human.ID, ReceiverUserID: bot.ID, Date: int(now.Unix()), RandomID: 11, Content: domain.EphemeralContent{Message: "/private"}, Version: 1, CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention), } store := NewBotAPIUpdateStore(pool) request := domain.EnqueueBotAPIUpdateRequest{ BotUserID: bot.ID, Kind: domain.BotAPIUpdateMessage, Peer: peer, MessageID: message.ID, Date: message.Date, Ephemeral: domain.NewBotAPIEphemeralPayload(message), } first, created, err := store.EnqueueBotAPIUpdate(ctx, request) if err != nil || !created || first.Ephemeral == nil { t.Fatalf("first=%+v created=%v err=%v", first, created, err) } var leakedPrivateRoutingState bool if err := pool.QueryRow(ctx, ` SELECT (ephemeral_payload -> 'Message') ?| ARRAY[ 'RandomID', 'OriginDevice', 'PayloadHash', 'CreatedAt', 'Deleted' ] FROM bot_api_updates WHERE id = $1`, first.ID).Scan(&leakedPrivateRoutingState); err != nil { t.Fatal(err) } if leakedPrivateRoutingState { t.Fatal("durable Bot API envelope contains private ephemeral routing fields") } if replay, created, err := store.EnqueueBotAPIUpdate(ctx, request); err != nil || created || replay.ID != first.ID { t.Fatalf("replay=%+v created=%v err=%v", replay, created, err) } message.Version, message.EditDate, message.Content.Message = 2, message.Date+1, "edited" request.Kind = domain.BotAPIUpdateEditedMessage request.Ephemeral = domain.NewBotAPIEphemeralPayload(message) second, created, err := store.EnqueueBotAPIUpdate(ctx, request) if err != nil || !created || second.ID <= first.ID { t.Fatalf("second=%+v created=%v err=%v", second, created, err) } rows, err := store.ListBotAPIUpdates(ctx, bot.ID, first.ID, 100) if err != nil || len(rows) != 2 || rows[0].SourcePts != 0 || rows[0].Ephemeral == nil || rows[0].Ephemeral.Message.Content.Message != "/private" || rows[1].Ephemeral.Message.Content.Message != "edited" { t.Fatalf("rows=%+v err=%v", rows, err) } }