235 lines
10 KiB
Go
235 lines
10 KiB
Go
package postgres
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"testing"
|
||
"time"
|
||
|
||
appauth "telesrv/internal/app/auth"
|
||
"telesrv/internal/domain"
|
||
"telesrv/internal/store/memory"
|
||
)
|
||
|
||
// TestBotStoreRoundTripPostgres 验证迁移 0090 的 bot 模型:BotFather 种子行、
|
||
// CreateBotAccount 双行事务、空 phone 不撞唯一索引、token 轮换、对话状态。
|
||
func TestBotStoreRoundTripPostgres(t *testing.T) {
|
||
pool := testPool(t)
|
||
ctx := context.Background()
|
||
|
||
users := NewUserStore(pool)
|
||
bots := NewBotStore(pool)
|
||
|
||
// BotFather 种子:users 行 + bots 行 + 不可登录(token 为空)。
|
||
bf, found, err := users.ByID(ctx, domain.BotFatherUserID)
|
||
if err != nil || !found {
|
||
t.Fatalf("BotFather user not seeded: found=%v err=%v", found, err)
|
||
}
|
||
if !bf.Bot || bf.BotInfoVersion < 1 || bf.Username != "BotFather" || !bf.Verified {
|
||
t.Fatalf("BotFather user = %+v, want verified bot with bot_info_version>=1", bf)
|
||
}
|
||
bfProfile, found, err := bots.GetBot(ctx, domain.BotFatherUserID)
|
||
if err != nil || !found {
|
||
t.Fatalf("BotFather bots row not seeded: found=%v err=%v", found, err)
|
||
}
|
||
if bfProfile.TokenSecret != "" || len(bfProfile.Commands) == 0 {
|
||
t.Fatalf("BotFather profile = %+v, want empty token with seeded commands", bfProfile)
|
||
}
|
||
// 空 phone 查询不得命中任何行。
|
||
if _, found, err := users.ByPhone(ctx, ""); err != nil || found {
|
||
t.Fatalf("ByPhone('') found=%v err=%v, want not found", found, err)
|
||
}
|
||
|
||
// owner 用户 + 两个空 phone 的 bot(验证部分唯一索引)。
|
||
suffix := time.Now().UnixNano() % 1_000_000_000
|
||
ownerPhone := fmt.Sprintf("1666%d", suffix)
|
||
owner, err := users.Create(ctx, domain.User{AccessHash: 7, Phone: ownerPhone, FirstName: "BotOwner"})
|
||
if err != nil {
|
||
t.Fatalf("create owner: %v", err)
|
||
}
|
||
t.Cleanup(func() {
|
||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID)
|
||
})
|
||
makeBot := func(i int) (domain.User, domain.BotProfile) {
|
||
u, p, err := bots.CreateBotAccount(ctx, domain.User{
|
||
AccessHash: int64(100 + i),
|
||
FirstName: fmt.Sprintf("IT Bot %d", i),
|
||
Username: fmt.Sprintf("it%d_%d_bot", i, suffix),
|
||
}, domain.BotProfile{OwnerUserID: owner.ID, TokenSecret: fmt.Sprintf("secret-%d-%d", i, suffix)})
|
||
if err != nil {
|
||
t.Fatalf("create bot %d: %v", i, err)
|
||
}
|
||
t.Cleanup(func() {
|
||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", u.ID) // bots 行随 FK CASCADE
|
||
})
|
||
return u, p
|
||
}
|
||
bot1, profile1 := makeBot(1)
|
||
bot2, _ := makeBot(2)
|
||
if bot1.Phone != "" || bot2.Phone != "" {
|
||
t.Fatalf("bot phones = %q/%q, want empty", bot1.Phone, bot2.Phone)
|
||
}
|
||
if !bot1.Bot || bot1.BotInfoVersion != 1 {
|
||
t.Fatalf("bot1 = %+v, want is_bot with bot_info_version=1", bot1)
|
||
}
|
||
|
||
// username 冲突映射 ErrUsernameOccupied,且不留 users 孤儿行。
|
||
if _, _, err := bots.CreateBotAccount(ctx, domain.User{
|
||
AccessHash: 999,
|
||
FirstName: "Dup",
|
||
Username: bot1.Username,
|
||
}, domain.BotProfile{OwnerUserID: owner.ID, TokenSecret: "x"}); err != domain.ErrUsernameOccupied {
|
||
t.Fatalf("duplicate username err = %v, want ErrUsernameOccupied", err)
|
||
}
|
||
|
||
if n, err := bots.CountBotsByOwner(ctx, owner.ID); err != nil || n != 2 {
|
||
t.Fatalf("CountBotsByOwner = %d err=%v, want 2", n, err)
|
||
}
|
||
list, err := bots.ListBotsByOwner(ctx, owner.ID)
|
||
if err != nil || len(list) != 2 {
|
||
t.Fatalf("ListBotsByOwner = %d err=%v, want 2", len(list), err)
|
||
}
|
||
|
||
// token 轮换。
|
||
if err := bots.UpdateBotTokenSecret(ctx, bot1.ID, "rotated-secret"); err != nil {
|
||
t.Fatalf("rotate token: %v", err)
|
||
}
|
||
rotated, _, err := bots.GetBot(ctx, bot1.ID)
|
||
if err != nil || rotated.TokenSecret != "rotated-secret" {
|
||
t.Fatalf("rotated profile = %+v err=%v", rotated, err)
|
||
}
|
||
if err := bots.UpdateBotTokenSecret(ctx, 424242, "x"); err != domain.ErrBotNotFound {
|
||
t.Fatalf("rotate missing bot err = %v, want ErrBotNotFound", err)
|
||
}
|
||
|
||
// 对话状态 upsert/get/delete。
|
||
state := domain.BotChatState{
|
||
BotUserID: domain.BotFatherUserID,
|
||
UserID: owner.ID,
|
||
Command: "newbot",
|
||
Step: "username",
|
||
Draft: map[string]string{"name": "IT Bot"},
|
||
}
|
||
t.Cleanup(func() {
|
||
_, _ = pool.Exec(ctx, "DELETE FROM bot_chat_states WHERE user_id = $1", owner.ID)
|
||
})
|
||
if err := bots.UpsertBotChatState(ctx, state); err != nil {
|
||
t.Fatalf("upsert state: %v", err)
|
||
}
|
||
got, found, err := bots.GetBotChatState(ctx, domain.BotFatherUserID, owner.ID)
|
||
if err != nil || !found {
|
||
t.Fatalf("get state: found=%v err=%v", found, err)
|
||
}
|
||
if got.Command != "newbot" || got.Step != "username" || got.Draft["name"] != "IT Bot" {
|
||
t.Fatalf("state = %+v, want round-trip", got)
|
||
}
|
||
if err := bots.DeleteBotChatState(ctx, domain.BotFatherUserID, owner.ID); err != nil {
|
||
t.Fatalf("delete state: %v", err)
|
||
}
|
||
if _, found, _ := bots.GetBotChatState(ctx, domain.BotFatherUserID, owner.ID); found {
|
||
t.Fatal("state still present after delete")
|
||
}
|
||
|
||
// P2 元数据写入 + bot_info_version bump(postgres 单事务)。
|
||
uBefore, _, _ := users.ByID(ctx, bot1.ID)
|
||
v1, err := bots.UpdateBotCommands(ctx, bot1.ID, []domain.BotCommand{{Command: "start", Description: "begin"}})
|
||
if err != nil || v1 <= uBefore.BotInfoVersion {
|
||
t.Fatalf("UpdateBotCommands version = %d err=%v, want > %d", v1, err, uBefore.BotInfoVersion)
|
||
}
|
||
gotBot, _, _ := bots.GetBot(ctx, bot1.ID)
|
||
if len(gotBot.Commands) != 1 || gotBot.Commands[0].Command != "start" {
|
||
t.Fatalf("commands = %+v, want [start]", gotBot.Commands)
|
||
}
|
||
v2, err := bots.UpdateBotInfo(ctx, bot1.ID, domain.BotInfoUpdate{
|
||
SetName: true, Name: "PG Bot", SetAbout: true, About: "pg about", SetDescription: true, Description: "pg desc",
|
||
})
|
||
if err != nil || v2 <= v1 {
|
||
t.Fatalf("UpdateBotInfo version = %d err=%v, want > %d", v2, err, v1)
|
||
}
|
||
uAfter, _, _ := users.ByID(ctx, bot1.ID)
|
||
if uAfter.FirstName != "PG Bot" || uAfter.About != "pg about" {
|
||
t.Fatalf("bot user after setInfo = first_name=%q about=%q", uAfter.FirstName, uAfter.About)
|
||
}
|
||
if descBot, _, _ := bots.GetBot(ctx, bot1.ID); descBot.Description != "pg desc" {
|
||
t.Fatalf("description = %q, want 'pg desc'", descBot.Description)
|
||
}
|
||
v3, err := bots.UpdateBotMenuButton(ctx, bot1.ID, domain.BotMenuButton{Type: domain.BotMenuButtonWebView, Text: "Open", URL: "https://pg.example/app"})
|
||
if err != nil || v3 <= v2 {
|
||
t.Fatalf("UpdateBotMenuButton version = %d err=%v, want > %d", v3, err, v2)
|
||
}
|
||
if mbBot, _, _ := bots.GetBot(ctx, bot1.ID); mbBot.MenuButton.Type != domain.BotMenuButtonWebView || mbBot.MenuButton.URL != "https://pg.example/app" {
|
||
t.Fatalf("menu button = %+v", mbBot.MenuButton)
|
||
}
|
||
v4, err := bots.SetBotInlinePlaceholder(ctx, bot1.ID, "Search PG")
|
||
if err != nil || v4 <= v3 {
|
||
t.Fatalf("SetBotInlinePlaceholder version = %d err=%v, want > %d", v4, err, v3)
|
||
}
|
||
if inlineBot, _, _ := bots.GetBot(ctx, bot1.ID); inlineBot.InlinePlaceholder != "Search PG" {
|
||
t.Fatalf("inline placeholder = %q, want Search PG", inlineBot.InlinePlaceholder)
|
||
}
|
||
v5, err := bots.SetBotInlineGeo(ctx, bot1.ID, true)
|
||
if err != nil || v5 <= v4 {
|
||
t.Fatalf("SetBotInlineGeo version = %d err=%v, want > %d", v5, err, v4)
|
||
}
|
||
if inlineBot, _, _ := bots.GetBot(ctx, bot1.ID); !inlineBot.InlineGeo {
|
||
t.Fatalf("inline geo = false, want true")
|
||
}
|
||
if _, err := bots.SetBotNochats(ctx, bot1.ID, true); err != nil {
|
||
t.Fatalf("SetBotNochats: %v", err)
|
||
}
|
||
if _, err := bots.SetBotChatHistory(ctx, bot1.ID, true); err != nil {
|
||
t.Fatalf("SetBotChatHistory: %v", err)
|
||
}
|
||
if flagBot, _, _ := bots.GetBot(ctx, bot1.ID); !flagBot.Nochats || !flagBot.ChatHistory {
|
||
t.Fatalf("flags = nochats=%v chat_history=%v, want both true", flagBot.Nochats, flagBot.ChatHistory)
|
||
}
|
||
if can, err := bots.CanBotSendMessage(ctx, bot1.ID, owner.ID); err != nil || can {
|
||
t.Fatalf("CanBotSendMessage before allow = %v,%v, want false,nil", can, err)
|
||
}
|
||
if created, err := bots.AllowBotSendMessage(ctx, bot1.ID, owner.ID, true); err != nil || !created {
|
||
t.Fatalf("AllowBotSendMessage first = %v,%v, want true,nil", created, err)
|
||
}
|
||
if can, err := bots.CanBotSendMessage(ctx, bot1.ID, owner.ID); err != nil || !can {
|
||
t.Fatalf("CanBotSendMessage after allow = %v,%v, want true,nil", can, err)
|
||
}
|
||
if created, err := bots.AllowBotSendMessage(ctx, bot1.ID, owner.ID, true); err != nil || created {
|
||
t.Fatalf("AllowBotSendMessage repeat = %v,%v, want false,nil", created, err)
|
||
}
|
||
// 不存在的 bot → ErrBotNotFound。
|
||
if _, err := bots.UpdateBotCommands(ctx, 424243, nil); err != domain.ErrBotNotFound {
|
||
t.Fatalf("update missing bot err = %v, want ErrBotNotFound", err)
|
||
}
|
||
|
||
// SignInBot:PG 全链路 token 校验 + authorizations 绑定。
|
||
authSvc := appauth.NewService(users, NewAuthorizationStore(pool), memory.NewCodeStore(), nil, nil, "12345",
|
||
appauth.WithBotLogin(bots))
|
||
var authKeyID [8]byte
|
||
copy(authKeyID[:], fmt.Sprintf("%08d", suffix%100000000))
|
||
if _, err := pool.Exec(ctx, "INSERT INTO auth_keys (auth_key_id, body, server_salt, expires_at) VALUES ($1, $2, 0, 0) ON CONFLICT DO NOTHING",
|
||
authKeyIDToInt64(authKeyID), make([]byte, 256)); err != nil {
|
||
t.Fatalf("seed auth key: %v", err)
|
||
}
|
||
t.Cleanup(func() {
|
||
_, _ = pool.Exec(ctx, "DELETE FROM auth_keys WHERE auth_key_id = $1", authKeyIDToInt64(authKeyID))
|
||
})
|
||
token := domain.FormatBotToken(profile1.BotUserID, "rotated-secret")
|
||
u, err := authSvc.SignInBot(ctx, domain.Authorization{AuthKeyID: authKeyID}, token)
|
||
if err != nil {
|
||
t.Fatalf("SignInBot: %v", err)
|
||
}
|
||
if u.ID != bot1.ID || !u.Bot {
|
||
t.Fatalf("SignInBot user = %+v, want bot %d", u, bot1.ID)
|
||
}
|
||
if uid, ok, err := authSvc.UserID(ctx, authKeyID); err != nil || !ok || uid != bot1.ID {
|
||
t.Fatalf("UserID after SignInBot = %d,%v,%v, want %d", uid, ok, err, bot1.ID)
|
||
}
|
||
// 旧 token(轮换前 secret)必须失效。
|
||
oldToken := domain.FormatBotToken(profile1.BotUserID, profile1.TokenSecret)
|
||
if _, err := authSvc.SignInBot(ctx, domain.Authorization{AuthKeyID: authKeyID}, oldToken); err != domain.ErrBotTokenInvalid {
|
||
t.Fatalf("old token err = %v, want ErrBotTokenInvalid", err)
|
||
}
|
||
// BotFather 空 token 永不可登录。
|
||
if _, err := authSvc.SignInBot(ctx, domain.Authorization{AuthKeyID: authKeyID}, domain.FormatBotToken(domain.BotFatherUserID, "")); err != domain.ErrBotTokenInvalid {
|
||
t.Fatalf("BotFather token err = %v, want ErrBotTokenInvalid", err)
|
||
}
|
||
}
|