owpengram-server/internal/store/postgres/botapi_update_integration_test.go
A bf965f610c feat: sync bot keyboards and callbacks
Sync telesrv b96f2dd (feat(bot): complete keyboards callbacks and durable delivery).

Skipped private docs and preserved public README files per sync rules; normalized the appearance seed log label for public naming.
2026-07-19 20:38:48 +08:00

408 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}