(new): expand runtime APIs
(fix): harden concurrent lifecycle (tests): add regression coverage (doc): update v1.2 guidance
This commit is contained in:
+290
-2
@@ -14,6 +14,101 @@ import (
|
||||
"git.scuroneko.dev/scuroneko/sneklog/v2"
|
||||
)
|
||||
|
||||
func newMessageContextTestAPI(t *testing.T, transport roundTripFunc) *tgapi.API {
|
||||
t.Helper()
|
||||
api := tgapi.NewAPI(
|
||||
tgapi.NewAPIOpts("token").
|
||||
SetAPIURL("https://example.test").
|
||||
SetHTTPClient(&http.Client{Transport: transport}),
|
||||
)
|
||||
t.Cleanup(func() {
|
||||
if err := api.Close(); err != nil {
|
||||
t.Fatalf("Close returned error: %v", err)
|
||||
}
|
||||
})
|
||||
return api
|
||||
}
|
||||
|
||||
func readMessageContextRequest(t *testing.T, req *http.Request) map[string]any {
|
||||
t.Helper()
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read request body: %v", err)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(body, &decoded); err != nil {
|
||||
t.Fatalf("failed to decode request body: %v", err)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func messageContextResponse(result string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":` + result + `}`)),
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageContextPropagatesBusinessConnection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
wantMethod string
|
||||
result string
|
||||
invoke func(*MessageContext)
|
||||
}{
|
||||
{name: "send message", wantMethod: "sendMessage", result: `{"message_id":11,"date":1}`, invoke: func(ctx *MessageContext) { ctx.Answer("text") }},
|
||||
{name: "send photo", wantMethod: "sendPhoto", result: `{"message_id":11,"date":1}`, invoke: func(ctx *MessageContext) { ctx.AnswerPhoto("photo-id", "caption") }},
|
||||
{name: "edit caption", wantMethod: "editMessageCaption", result: `{"message_id":11,"date":1}`, invoke: func(ctx *MessageContext) { (&AnswerMessage{MessageID: 7, ctx: ctx}).EditCaption("caption") }},
|
||||
{name: "send action", wantMethod: "sendChatAction", result: `true`, invoke: func(ctx *MessageContext) { ctx.SendAction(tgapi.ChatActionTyping) }},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
api := newMessageContextTestAPI(t, roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if !strings.HasSuffix(req.URL.Path, "/"+tt.wantMethod) {
|
||||
t.Fatalf("request path = %q, want method %q", req.URL.Path, tt.wantMethod)
|
||||
}
|
||||
gotBody = readMessageContextRequest(t, req)
|
||||
return messageContextResponse(tt.result), nil
|
||||
}))
|
||||
ctx := &MessageContext{
|
||||
API: api,
|
||||
Msg: &tgapi.Message{
|
||||
BusinessConnectionID: "business-1",
|
||||
Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate},
|
||||
},
|
||||
Logger: sneklog.NewLogger(),
|
||||
}
|
||||
|
||||
tt.invoke(ctx)
|
||||
if got := gotBody["business_connection_id"]; got != "business-1" {
|
||||
t.Fatalf("business_connection_id = %v, want business-1", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageContextHelpersRejectMissingChat(t *testing.T) {
|
||||
requests := 0
|
||||
api := newMessageContextTestAPI(t, roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
requests++
|
||||
return nil, errors.New("unexpected request")
|
||||
}))
|
||||
ctx := &MessageContext{API: api, Msg: &tgapi.Message{}, CallbackMsgID: 7, Logger: sneklog.NewLogger()}
|
||||
|
||||
if answer := ctx.Answer("text"); answer != nil {
|
||||
t.Fatalf("Answer returned %#v for a message without a chat", answer)
|
||||
}
|
||||
if answer := ctx.EditCallback("text", nil); answer != nil {
|
||||
t.Fatalf("EditCallback returned %#v for a message without a chat", answer)
|
||||
}
|
||||
if requests != 0 {
|
||||
t.Fatalf("missing-chat helpers made %d requests", requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRichAnswerBuildsInputBlocks(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
@@ -33,8 +128,11 @@ func TestRichAnswerBuildsInputBlocks(t *testing.T) {
|
||||
api := tgapi.NewAPI(tgapi.NewAPIOpts("token").SetAPIURL("https://example.test").SetHTTPClient(client))
|
||||
defer func() { _ = api.Close() }()
|
||||
ctx := &MessageContext{
|
||||
API: api,
|
||||
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate}},
|
||||
API: api,
|
||||
Msg: &tgapi.Message{
|
||||
BusinessConnectionID: "business-1",
|
||||
Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate},
|
||||
},
|
||||
Logger: sneklog.NewLogger(),
|
||||
}
|
||||
|
||||
@@ -49,6 +147,12 @@ func TestRichAnswerBuildsInputBlocks(t *testing.T) {
|
||||
if _, exists := rich["skip_entity_detection"]; exists {
|
||||
t.Fatalf("rich_message unexpectedly disables entity detection: %#v", rich)
|
||||
}
|
||||
if got := gotBody["business_connection_id"]; got != "business-1" {
|
||||
t.Fatalf("business_connection_id = %v, want business-1", got)
|
||||
}
|
||||
if answer.Text != "<p><b>ready</b></p>" || answer.RichHTML != answer.Text {
|
||||
t.Fatalf("unexpected answer content: Text=%q RichHTML=%q", answer.Text, answer.RichHTML)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRichAnswerRejectsInvalidBlocksWithoutRequest(t *testing.T) {
|
||||
@@ -69,6 +173,190 @@ func TestRichAnswerRejectsInvalidBlocksWithoutRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnswerMessageEditRich(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
withKeyboard bool
|
||||
}{
|
||||
{name: "content only"},
|
||||
{name: "content and keyboard", withKeyboard: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotBody map[string]any
|
||||
api := newMessageContextTestAPI(t, roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
gotPath = req.URL.Path
|
||||
gotBody = readMessageContextRequest(t, req)
|
||||
return messageContextResponse(`{"message_id":11,"date":1}`), nil
|
||||
}))
|
||||
ctx := &MessageContext{
|
||||
API: api,
|
||||
Msg: &tgapi.Message{
|
||||
BusinessConnectionID: "business-1",
|
||||
Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate},
|
||||
},
|
||||
Logger: sneklog.NewLogger(),
|
||||
}
|
||||
original := &AnswerMessage{MessageID: 7, ctx: ctx}
|
||||
block := tgrich.P(tgrich.Bold(tgrich.Text("updated")))
|
||||
|
||||
var answer *AnswerMessage
|
||||
if tt.withKeyboard {
|
||||
kb := NewInlineKeyboardJSON(1).AddCallbackButton("A", "cmd")
|
||||
answer = original.EditRichKeyboard(kb, block)
|
||||
} else {
|
||||
answer = original.EditRich(block)
|
||||
}
|
||||
|
||||
if answer == nil {
|
||||
t.Fatal("rich edit returned nil")
|
||||
}
|
||||
if gotPath != "/bottoken/editMessageText" {
|
||||
t.Fatalf("unexpected request path: %s", gotPath)
|
||||
}
|
||||
if got := gotBody["chat_id"]; got != float64(42) {
|
||||
t.Fatalf("chat_id = %v, want 42", got)
|
||||
}
|
||||
if got := gotBody["message_id"]; got != float64(7) {
|
||||
t.Fatalf("message_id = %v, want 7", got)
|
||||
}
|
||||
if got := gotBody["business_connection_id"]; got != "business-1" {
|
||||
t.Fatalf("business_connection_id = %v, want business-1", got)
|
||||
}
|
||||
rich, ok := gotBody["rich_message"].(map[string]any)
|
||||
if !ok || rich["html"] != "<p><b>updated</b></p>" {
|
||||
t.Fatalf("rich_message = %#v", gotBody["rich_message"])
|
||||
}
|
||||
if _, exists := gotBody["text"]; exists {
|
||||
t.Fatalf("edit request unexpectedly contains text: %#v", gotBody)
|
||||
}
|
||||
_, hasKeyboard := gotBody["reply_markup"]
|
||||
if hasKeyboard != tt.withKeyboard {
|
||||
t.Fatalf("reply_markup presence = %v, want %v", hasKeyboard, tt.withKeyboard)
|
||||
}
|
||||
if answer.MessageID != 11 || answer.Text != "<p><b>updated</b></p>" || answer.RichHTML != answer.Text {
|
||||
t.Fatalf("unexpected answer: %#v", answer)
|
||||
}
|
||||
if answer.ctx != ctx {
|
||||
t.Fatal("edited answer lost its message context")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditCallbackRichEditsInlineMessage(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
api := newMessageContextTestAPI(t, roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
gotBody = readMessageContextRequest(t, req)
|
||||
return messageContextResponse("true"), nil
|
||||
}))
|
||||
ctx := &MessageContext{
|
||||
API: api,
|
||||
InlineMsgID: "inline-1",
|
||||
Logger: sneklog.NewLogger(),
|
||||
}
|
||||
kb := NewInlineKeyboardJSON(1).AddCallbackButton("A", "cmd")
|
||||
|
||||
answer := ctx.EditCallbackRich(kb, tgrich.P(tgrich.Text("inline")))
|
||||
if answer == nil {
|
||||
t.Fatal("EditCallbackRich returned nil")
|
||||
}
|
||||
if got := gotBody["inline_message_id"]; got != "inline-1" {
|
||||
t.Fatalf("inline_message_id = %v, want inline-1", got)
|
||||
}
|
||||
if _, exists := gotBody["chat_id"]; exists {
|
||||
t.Fatalf("inline edit unexpectedly contains chat_id: %#v", gotBody)
|
||||
}
|
||||
if _, exists := gotBody["business_connection_id"]; exists {
|
||||
t.Fatalf("inline edit unexpectedly contains business_connection_id: %#v", gotBody)
|
||||
}
|
||||
rich, ok := gotBody["rich_message"].(map[string]any)
|
||||
if !ok || rich["html"] != "<p>inline</p>" {
|
||||
t.Fatalf("rich_message = %#v", gotBody["rich_message"])
|
||||
}
|
||||
if _, exists := gotBody["reply_markup"]; !exists {
|
||||
t.Fatal("inline rich edit has no reply_markup")
|
||||
}
|
||||
if answer.MessageID != 0 || answer.Text != "<p>inline</p>" || answer.RichHTML != answer.Text {
|
||||
t.Fatalf("unexpected inline answer: %#v", answer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertKeyboardRichValidatesPhotoBeforeDelete(t *testing.T) {
|
||||
requests := 0
|
||||
api := newMessageContextTestAPI(t, roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
requests++
|
||||
t.Fatalf("unexpected request to %s", req.URL.Path)
|
||||
return nil, nil
|
||||
}))
|
||||
ctx := &MessageContext{
|
||||
API: api,
|
||||
CallbackMsgID: 7,
|
||||
Msg: &tgapi.Message{
|
||||
Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate},
|
||||
Photo: []tgapi.PhotoSize{{FileID: "photo-1"}},
|
||||
},
|
||||
Logger: sneklog.NewLogger(),
|
||||
}
|
||||
|
||||
answer := ctx.UpsertKeyboardRich(nil, tgrich.H(tgrich.Text("invalid"), 0))
|
||||
if answer != nil {
|
||||
t.Fatalf("UpsertKeyboardRich returned an answer for invalid blocks: %#v", answer)
|
||||
}
|
||||
if requests != 0 {
|
||||
t.Fatalf("invalid photo upsert made %d requests", requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertKeyboardRichReplacesPhotoCallback(t *testing.T) {
|
||||
var paths []string
|
||||
var sendBody map[string]any
|
||||
api := newMessageContextTestAPI(t, roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
paths = append(paths, req.URL.Path)
|
||||
switch req.URL.Path {
|
||||
case "/bottoken/deleteMessage":
|
||||
return messageContextResponse("true"), nil
|
||||
case "/bottoken/sendRichMessage":
|
||||
sendBody = readMessageContextRequest(t, req)
|
||||
return messageContextResponse(`{"message_id":12,"date":1}`), nil
|
||||
default:
|
||||
t.Fatalf("unexpected request path: %s", req.URL.Path)
|
||||
return nil, nil
|
||||
}
|
||||
}))
|
||||
ctx := &MessageContext{
|
||||
API: api,
|
||||
CallbackMsgID: 7,
|
||||
Msg: &tgapi.Message{
|
||||
Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate},
|
||||
Photo: []tgapi.PhotoSize{{FileID: "photo-1"}},
|
||||
},
|
||||
Logger: sneklog.NewLogger(),
|
||||
}
|
||||
kb := NewInlineKeyboardJSON(1).AddCallbackButton("A", "cmd")
|
||||
|
||||
answer := ctx.UpsertKeyboardRich(kb, tgrich.P(tgrich.Text("replacement")))
|
||||
if answer == nil {
|
||||
t.Fatal("UpsertKeyboardRich returned nil")
|
||||
}
|
||||
wantPaths := []string{"/bottoken/deleteMessage", "/bottoken/sendRichMessage"}
|
||||
if !reflect.DeepEqual(paths, wantPaths) {
|
||||
t.Fatalf("request paths = %#v, want %#v", paths, wantPaths)
|
||||
}
|
||||
rich, ok := sendBody["rich_message"].(map[string]any)
|
||||
if !ok || rich["html"] != "<p>replacement</p>" {
|
||||
t.Fatalf("rich_message = %#v", sendBody["rich_message"])
|
||||
}
|
||||
if _, exists := sendBody["reply_markup"]; !exists {
|
||||
t.Fatal("replacement rich message has no reply_markup")
|
||||
}
|
||||
if answer.MessageID != 12 || answer.Text != "<p>replacement</p>" || answer.RichHTML != answer.Text {
|
||||
t.Fatalf("unexpected replacement answer: %#v", answer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
|
||||
|
||||
Reference in New Issue
Block a user