Files
multica/server/internal/integrations/lark/http_client_test.go
chyax98 26ca943d45 feat(lark): add typing indicator lifecycle for inbound messages (#3860)
When a message is successfully ingested, send a Typing reaction to
the user's message. When the agent replies (EventChatDone) or fails
(EventTaskFailed), clear the reaction before the reply is visible.

- Add AddMessageReaction / DeleteMessageReaction to APIClient
- Implement reaction HTTP calls in httpAPIClient
- Introduce TypingIndicatorManager for per-session state tracking
- Wire into Hub (add on ingest) and Patcher (clear before reply)
- Skip typing for messages older than 2 minutes (WS replay guard)

Co-authored-by: miaolong001 <miaolong@xd.com>
2026-06-08 19:27:08 +08:00

1162 lines
40 KiB
Go
Raw Permalink 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 lark
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
// larkFakeServer is a tiny in-memory stand-in for the Lark Open
// Platform. Tests register handlers per path; the server panics if a
// path is hit without a registration (a missed assertion is louder
// than a 404).
//
// The handler shape mirrors http.HandlerFunc so each test can encode
// its own response without inheriting boilerplate.
type larkFakeServer struct {
t *testing.T
mux *http.ServeMux
srv *httptest.Server
tokenN atomic.Int32
sendN atomic.Int32
patchN atomic.Int32
bindN atomic.Int32
reactN atomic.Int32
delRN atomic.Int32
authObs atomic.Value // last Authorization header seen across all paths
}
func newLarkFake(t *testing.T) *larkFakeServer {
t.Helper()
f := &larkFakeServer{t: t, mux: http.NewServeMux()}
f.srv = httptest.NewServer(f)
t.Cleanup(f.srv.Close)
return f
}
func (f *larkFakeServer) URL() string { return f.srv.URL }
func (f *larkFakeServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if a := r.Header.Get("Authorization"); a != "" {
f.authObs.Store(a)
}
f.mux.ServeHTTP(w, r)
}
func (f *larkFakeServer) lastAuth() string {
v, _ := f.authObs.Load().(string)
return v
}
// stubToken installs a token endpoint that returns the supplied token
// with the supplied expire (seconds) and counts hits.
func (f *larkFakeServer) stubToken(token string, expireSec int64) {
f.mux.HandleFunc("/open-apis/auth/v3/tenant_access_token/internal", func(w http.ResponseWriter, r *http.Request) {
f.tokenN.Add(1)
if r.Method != http.MethodPost {
f.t.Errorf("token: want POST, got %s", r.Method)
}
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
f.t.Errorf("token: decode body: %v", err)
}
if body["app_id"] == "" || body["app_secret"] == "" {
f.t.Errorf("token: missing app credentials: %v", body)
}
writeJSON(w, map[string]any{
"code": 0,
"msg": "ok",
"tenant_access_token": token,
"expire": expireSec,
})
})
}
// stubTokenError installs a token endpoint returning a Lark-style
// error code (non-zero `code` with HTTP 200).
func (f *larkFakeServer) stubTokenError(code int, msg string) {
f.mux.HandleFunc("/open-apis/auth/v3/tenant_access_token/internal", func(w http.ResponseWriter, r *http.Request) {
f.tokenN.Add(1)
writeJSON(w, map[string]any{"code": code, "msg": msg})
})
}
// stubSend installs the IM-send endpoint. resp is the response body
// (typically the standard {code, msg, data:{message_id}} shape).
func (f *larkFakeServer) stubSend(resp map[string]any, verify func(r *http.Request, body map[string]string)) {
f.mux.HandleFunc("/open-apis/im/v1/messages", func(w http.ResponseWriter, r *http.Request) {
f.sendN.Add(1)
if r.Method != http.MethodPost {
f.t.Errorf("send: want POST, got %s", r.Method)
}
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
f.t.Errorf("send: decode body: %v", err)
}
if verify != nil {
verify(r, body)
}
writeJSON(w, resp)
})
}
// stubPatch installs the IM-patch endpoint. The Lark route is
// /open-apis/im/v1/messages/<id>; ServeMux uses prefix matching when
// we register the parent path explicitly. We register the parent
// SEND path above already, so the patch path needs the full prefix.
func (f *larkFakeServer) stubPatch(resp map[string]any, verify func(r *http.Request, id string, body map[string]string)) {
const prefix = "/open-apis/im/v1/messages/"
f.mux.HandleFunc(prefix, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPatch {
f.t.Errorf("patch: want PATCH, got %s", r.Method)
}
id := strings.TrimPrefix(r.URL.Path, prefix)
if id == "" {
f.t.Errorf("patch: missing message id")
}
f.patchN.Add(1)
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
f.t.Errorf("patch: decode body: %v", err)
}
if verify != nil {
verify(r, id, body)
}
writeJSON(w, resp)
})
}
// stubReaction installs the IM-reaction-create endpoint.
func (f *larkFakeServer) stubReaction(resp map[string]any, verify func(r *http.Request, id string, body map[string]any)) {
const suffix = "/reactions"
f.mux.HandleFunc("/open-apis/im/v1/messages/", func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, suffix) {
return // let other handlers match
}
if r.Method != http.MethodPost {
f.t.Errorf("reaction: want POST, got %s", r.Method)
}
f.reactN.Add(1)
rawID := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/open-apis/im/v1/messages/"), suffix)
if rawID == "" {
f.t.Errorf("reaction: missing message id")
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
f.t.Errorf("reaction: decode body: %v", err)
}
if verify != nil {
verify(r, rawID, body)
}
writeJSON(w, resp)
})
}
// stubReactionDelete installs the IM-reaction-delete endpoint.
func (f *larkFakeServer) stubReactionDelete(resp map[string]any, verify func(r *http.Request, msgID string, reactionID string)) {
const prefix = "/open-apis/im/v1/messages/"
f.mux.HandleFunc(prefix, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
return // let other handlers match
}
rest := strings.TrimPrefix(r.URL.Path, prefix)
parts := strings.Split(rest, "/reactions/")
if len(parts) != 2 {
return // not a delete path
}
f.delRN.Add(1)
if parts[0] == "" {
f.t.Errorf("reaction delete: missing message id")
}
if parts[1] == "" {
f.t.Errorf("reaction delete: missing reaction id")
}
if verify != nil {
verify(r, parts[0], parts[1])
}
writeJSON(w, resp)
})
}
func writeJSON(w http.ResponseWriter, body any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(body)
}
// newTestClient returns an httpAPIClient pointed at the fake server,
// using the supplied clock so token expiry can be controlled
// deterministically.
func newTestClient(fake *larkFakeServer, now func() time.Time) *httpAPIClient {
c := NewHTTPAPIClient(HTTPClientConfig{
BaseURL: fake.URL(),
Now: now,
}).(*httpAPIClient)
return c
}
func testCreds() InstallationCredentials {
return InstallationCredentials{AppID: "cli_app_xx", AppSecret: "secret_xx"}
}
func TestHTTPClient_IsConfigured(t *testing.T) {
c := NewHTTPAPIClient(HTTPClientConfig{})
if !c.IsConfigured() {
t.Fatalf("real client must report IsConfigured()=true")
}
}
// TestHTTPClient_StubReportsNotConfigured pins that the stub never
// claims wired outbound — handlers gate install / management UI on
// this signal.
func TestHTTPClient_StubReportsNotConfigured(t *testing.T) {
s := NewStubAPIClient(nil)
if s.IsConfigured() {
t.Errorf("stub IsConfigured must be false")
}
}
// TestHTTPClient_SendInteractiveCard_DefaultRendererBodyHasUpdateMulti
// is the send-side half of the must-fix wire check: when the Patcher
// uses NewDefaultRenderer to produce a card and ships it via
// SendInteractiveCard, the actual HTTP body Lark receives must carry
// config.update_multi=true so the card is patchable downstream.
// Without this, the first send succeeds but every subsequent patch
// silently no-ops on Lark's side while local DB status still flips.
func TestHTTPClient_SendInteractiveCard_DefaultRendererBodyHasUpdateMulti(t *testing.T) {
fake := newLarkFake(t)
fake.stubToken("tok_um_send", 7200)
var capturedContent string
fake.stubSend(
map[string]any{"code": 0, "data": map[string]string{"message_id": "om_send_um"}},
func(_ *http.Request, body map[string]string) {
capturedContent = body["content"]
},
)
r := NewDefaultRenderer()
render, err := r.Render(RenderInput{Kind: CardKindThinking, AgentName: "TestAgent"})
if err != nil {
t.Fatalf("render: %v", err)
}
c := newTestClient(fake, time.Now)
if _, err := c.SendInteractiveCard(context.Background(), SendCardParams{
InstallationID: testCreds(),
ChatID: ChatID("oc_send_um"),
CardJSON: render.JSON,
}); err != nil {
t.Fatalf("send: %v", err)
}
assertCardContentHasUpdateMulti(t, capturedContent)
}
// TestHTTPClient_PatchInteractiveCard_DefaultRendererBodyHasUpdateMulti
// is the patch-side half of the same wire check. Every PatchCardParams
// the Patcher produces goes through the default renderer; the body
// shipped over PATCH /open-apis/im/v1/messages/:id must still carry
// update_multi=true, otherwise Lark refuses to apply the patch to a
// card that was sent with update_multi=true (the two ends must agree).
func TestHTTPClient_PatchInteractiveCard_DefaultRendererBodyHasUpdateMulti(t *testing.T) {
fake := newLarkFake(t)
fake.stubToken("tok_um_patch", 7200)
var capturedContent string
fake.stubPatch(
map[string]any{"code": 0, "msg": "ok"},
func(_ *http.Request, _ string, body map[string]string) {
capturedContent = body["content"]
},
)
r := NewDefaultRenderer()
render, err := r.Render(RenderInput{Kind: CardKindRunning, AgentName: "TestAgent"})
if err != nil {
t.Fatalf("render: %v", err)
}
c := newTestClient(fake, time.Now)
if err := c.PatchInteractiveCard(context.Background(), PatchCardParams{
InstallationID: testCreds(),
LarkCardMessageID: "om_patch_um",
CardJSON: render.JSON,
}); err != nil {
t.Fatalf("patch: %v", err)
}
assertCardContentHasUpdateMulti(t, capturedContent)
}
func assertCardContentHasUpdateMulti(t *testing.T, content string) {
t.Helper()
if content == "" {
t.Fatalf("captured content empty — fake server did not receive the request body")
}
var doc map[string]any
if err := json.Unmarshal([]byte(content), &doc); err != nil {
t.Fatalf("card content is not valid JSON: %v (raw=%s)", err, content)
}
cfg, ok := doc["config"].(map[string]any)
if !ok {
t.Fatalf("card content missing config block (raw=%s)", content)
}
if v, _ := cfg["update_multi"].(bool); !v {
t.Fatalf("config.update_multi must be true so the card is patchable on Lark's side; got config=%v (raw=%s)", cfg, content)
}
}
func TestHTTPClient_SendInteractiveCard_HappyPath(t *testing.T) {
fake := newLarkFake(t)
fake.stubToken("tok_1", 7200)
fake.stubSend(
map[string]any{
"code": 0,
"msg": "ok",
"data": map[string]string{"message_id": "om_msg_42"},
},
func(r *http.Request, body map[string]string) {
if got := r.URL.Query().Get("receive_id_type"); got != "chat_id" {
t.Errorf("receive_id_type: got %q want chat_id", got)
}
if body["receive_id"] != "oc_chat_1" {
t.Errorf("receive_id: got %q", body["receive_id"])
}
if body["msg_type"] != "interactive" {
t.Errorf("msg_type: got %q want interactive", body["msg_type"])
}
if !strings.Contains(body["content"], "\"tag\"") {
t.Errorf("content not a card body: %q", body["content"])
}
},
)
c := newTestClient(fake, time.Now)
msgID, err := c.SendInteractiveCard(context.Background(), SendCardParams{
InstallationID: testCreds(),
ChatID: ChatID("oc_chat_1"),
CardJSON: `{"tag":"div","text":"hi"}`,
})
if err != nil {
t.Fatalf("send: %v", err)
}
if msgID != "om_msg_42" {
t.Errorf("message id: got %q want om_msg_42", msgID)
}
if got := fake.lastAuth(); got != "Bearer tok_1" {
t.Errorf("Authorization header: got %q want Bearer tok_1", got)
}
}
// TestHTTPClient_SendTextMessage_HappyPath pins the wire shape of the
// plain text outbound used for chat replies + /issue confirmations.
// Path, query, bearer auth, msg_type, and the double-JSON-encoded
// `content` envelope all matter — Lark rejects anything off-spec and
// the failures are silent-but-non-2xx, which is hard to debug
// in production without this kind of contract pin.
func TestHTTPClient_SendTextMessage_HappyPath(t *testing.T) {
fake := newLarkFake(t)
fake.stubToken("tok_text", 7200)
fake.stubSend(
map[string]any{
"code": 0,
"msg": "ok",
"data": map[string]string{"message_id": "om_text_1"},
},
func(r *http.Request, body map[string]string) {
if r.URL.Path != "/open-apis/im/v1/messages" {
t.Errorf("path: got %q want /open-apis/im/v1/messages", r.URL.Path)
}
if got := r.URL.Query().Get("receive_id_type"); got != "chat_id" {
t.Errorf("receive_id_type: got %q want chat_id", got)
}
if body["receive_id"] != "oc_chat_42" {
t.Errorf("receive_id: got %q want oc_chat_42", body["receive_id"])
}
if body["msg_type"] != "text" {
t.Errorf("msg_type: got %q want text (NOT interactive — chat replies are plain bubbles)", body["msg_type"])
}
// content is a JSON-encoded string Lark requires: the outer
// HTTP body is JSON, and `content` is another JSON
// document INSIDE it. Decode and inspect.
var inner map[string]string
if err := json.Unmarshal([]byte(body["content"]), &inner); err != nil {
t.Fatalf("content is not valid inner JSON: %v (raw=%q)", err, body["content"])
}
if inner["text"] != "Hello world" {
t.Errorf("inner content.text: got %q want Hello world", inner["text"])
}
},
)
c := newTestClient(fake, time.Now)
msgID, err := c.SendTextMessage(context.Background(), SendTextParams{
InstallationID: testCreds(),
ChatID: ChatID("oc_chat_42"),
Text: "Hello world",
})
if err != nil {
t.Fatalf("send: %v", err)
}
if msgID != "om_text_1" {
t.Errorf("message id: got %q want om_text_1", msgID)
}
if got := fake.lastAuth(); got != "Bearer tok_text" {
t.Errorf("Authorization header: got %q want Bearer tok_text", got)
}
}
// TestHTTPClient_SendMarkdownCard_HappyPath pins the wire shape of the
// schema-2.0 card we send for markdown chat replies. The MUST-haves:
// msg_type=interactive (not text), content is a JSON-encoded card
// envelope, the card has `schema: "2.0"` at the top level, and the
// body element is `{tag: "markdown", content: <agent's md verbatim>}`.
// Lark rejects malformed cards with a generic 9499xxxx code that's
// painful to root-cause in production, so we contract-pin every level.
func TestHTTPClient_SendMarkdownCard_HappyPath(t *testing.T) {
fake := newLarkFake(t)
fake.stubToken("tok_md", 7200)
fake.stubSend(
map[string]any{
"code": 0,
"msg": "ok",
"data": map[string]string{"message_id": "om_md_1"},
},
func(r *http.Request, body map[string]string) {
if r.URL.Path != "/open-apis/im/v1/messages" {
t.Errorf("path: got %q want /open-apis/im/v1/messages", r.URL.Path)
}
if got := r.URL.Query().Get("receive_id_type"); got != "chat_id" {
t.Errorf("receive_id_type: got %q want chat_id", got)
}
if body["msg_type"] != "interactive" {
t.Errorf("msg_type: got %q want interactive (markdown cards ride the interactive endpoint)", body["msg_type"])
}
var card map[string]any
if err := json.Unmarshal([]byte(body["content"]), &card); err != nil {
t.Fatalf("content is not valid card JSON: %v (raw=%q)", err, body["content"])
}
if card["schema"] != "2.0" {
t.Errorf("card.schema: got %v want \"2.0\"", card["schema"])
}
bodyDoc, _ := card["body"].(map[string]any)
elements, _ := bodyDoc["elements"].([]any)
if len(elements) != 1 {
t.Fatalf("expected exactly one body element; got %d", len(elements))
}
el, _ := elements[0].(map[string]any)
if el["tag"] != "markdown" {
t.Errorf("element.tag: got %v want \"markdown\"", el["tag"])
}
if el["content"] != "# Heading\n- list" {
t.Errorf("markdown body must be forwarded verbatim; got %q", el["content"])
}
},
)
c := newTestClient(fake, time.Now)
msgID, err := c.SendMarkdownCard(context.Background(), SendMarkdownCardParams{
InstallationID: testCreds(),
ChatID: ChatID("oc_chat_42"),
Markdown: "# Heading\n- list",
})
if err != nil {
t.Fatalf("send markdown card: %v", err)
}
if msgID != "om_md_1" {
t.Errorf("message id: got %q want om_md_1", msgID)
}
if got := fake.lastAuth(); got != "Bearer tok_md" {
t.Errorf("Authorization header: got %q want Bearer tok_md", got)
}
}
// TestHTTPClient_SendTextMessage_EncodesSpecialCharacters guards the
// inner JSON envelope's escaping. Lark's spec is "content MUST be a
// JSON-encoded string", which means newlines and quotes have to be
// double-escaped — once when we marshal the inner `{"text": ...}`,
// then once more implicitly when the outer body is encoded for the
// HTTP request. Forgetting either pass corrupts the text Lark renders
// (or worse, rejects the message with a body parse error).
func TestHTTPClient_SendTextMessage_EncodesSpecialCharacters(t *testing.T) {
cases := []struct {
name string
text string
}{
{"multiline", "first line\nsecond line"},
{"double_quote", `she said "hi"`},
{"backslash", `path\to\file`},
{"chinese", "你好,世界 🌏"},
{"tab_and_newline", "col1\tcol2\nrow2"},
{"json_lookalike", `{"fake": "json"}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
fake := newLarkFake(t)
fake.stubToken("tok", 7200)
fake.stubSend(
map[string]any{"code": 0, "data": map[string]string{"message_id": "om_x"}},
func(r *http.Request, body map[string]string) {
var inner map[string]string
if err := json.Unmarshal([]byte(body["content"]), &inner); err != nil {
t.Fatalf("content envelope not valid JSON after wire-encode round trip: %v (raw=%q)", err, body["content"])
}
if inner["text"] != tc.text {
t.Errorf("text round-trip failed\n got: %q\n want: %q", inner["text"], tc.text)
}
},
)
c := newTestClient(fake, time.Now)
if _, err := c.SendTextMessage(context.Background(), SendTextParams{
InstallationID: testCreds(),
ChatID: ChatID("oc_chat_1"),
Text: tc.text,
}); err != nil {
t.Fatalf("send: %v", err)
}
})
}
}
// TestHTTPClient_SendTextMessage_LarkErrorCode pins the failure path:
// non-zero `code` becomes a wrapped error; a missing message_id even
// with code=0 is still treated as failure (matches the success-card
// path so callers don't have to special-case the response shapes).
func TestHTTPClient_SendTextMessage_LarkErrorCode(t *testing.T) {
fake := newLarkFake(t)
fake.stubToken("tok", 7200)
fake.stubSend(
map[string]any{
"code": 234567,
"msg": "Permission denied",
},
nil,
)
c := newTestClient(fake, time.Now)
_, err := c.SendTextMessage(context.Background(), SendTextParams{
InstallationID: testCreds(),
ChatID: ChatID("oc"),
Text: "hi",
})
if err == nil {
t.Fatal("expected error on non-zero Lark code")
}
if !strings.Contains(err.Error(), "234567") {
t.Errorf("error should surface the Lark code; got %v", err)
}
}
func TestHTTPClient_SendInteractiveCard_TokenCached(t *testing.T) {
fake := newLarkFake(t)
fake.stubToken("tok_cached", 7200)
fake.stubSend(
map[string]any{
"code": 0,
"data": map[string]string{"message_id": "om_msg_x"},
},
nil,
)
c := newTestClient(fake, time.Now)
for i := 0; i < 3; i++ {
if _, err := c.SendInteractiveCard(context.Background(), SendCardParams{
InstallationID: testCreds(),
ChatID: ChatID("oc_chat_1"),
CardJSON: `{}`,
}); err != nil {
t.Fatalf("iter %d: %v", i, err)
}
}
if got := fake.tokenN.Load(); got != 1 {
t.Errorf("token endpoint hits: got %d want 1 (cached after first call)", got)
}
if got := fake.sendN.Load(); got != 3 {
t.Errorf("send endpoint hits: got %d want 3", got)
}
}
func TestHTTPClient_TokenRefreshAfterExpiry(t *testing.T) {
fake := newLarkFake(t)
fake.stubToken("tok_refresh", 120) // 120s expire → 60s usable after safety margin
fake.stubSend(
map[string]any{
"code": 0,
"data": map[string]string{"message_id": "om"},
},
nil,
)
now := time.Unix(1_700_000_000, 0)
clock := &fakeClock{now: now}
c := NewHTTPAPIClient(HTTPClientConfig{BaseURL: fake.URL(), Now: clock.Now}).(*httpAPIClient)
// First call — fetches token.
if _, err := c.SendInteractiveCard(context.Background(), SendCardParams{
InstallationID: testCreds(),
ChatID: ChatID("oc"),
CardJSON: `{}`,
}); err != nil {
t.Fatalf("first send: %v", err)
}
if fake.tokenN.Load() != 1 {
t.Fatalf("first call should have fetched a token, got tokenN=%d", fake.tokenN.Load())
}
// Advance past the cached token's expiry (token expire 120s,
// safety margin 60s → cache valid for 60s of wall-clock).
clock.Advance(90 * time.Second)
if _, err := c.SendInteractiveCard(context.Background(), SendCardParams{
InstallationID: testCreds(),
ChatID: ChatID("oc"),
CardJSON: `{}`,
}); err != nil {
t.Fatalf("post-expiry send: %v", err)
}
if got := fake.tokenN.Load(); got != 2 {
t.Errorf("token endpoint hits after expiry: got %d want 2", got)
}
}
func TestHTTPClient_SendInteractiveCard_LarkErrorCode(t *testing.T) {
fake := newLarkFake(t)
fake.stubToken("tok_e", 7200)
fake.stubSend(map[string]any{"code": 230001, "msg": "no permission"}, nil)
c := newTestClient(fake, time.Now)
_, err := c.SendInteractiveCard(context.Background(), SendCardParams{
InstallationID: testCreds(),
ChatID: ChatID("oc"),
CardJSON: `{}`,
})
if err == nil {
t.Fatal("want error on non-zero code")
}
if !strings.Contains(err.Error(), "code=230001") {
t.Errorf("error should surface code: %v", err)
}
}
func TestHTTPClient_SendInteractiveCard_TokenExpired_InvalidatesCache(t *testing.T) {
fake := newLarkFake(t)
fake.stubToken("tok_first", 7200)
// First send replies with expired-token. Second send (after the
// client should have dropped its cache) reaches the token
// endpoint again. We swap the send handler mid-test to model
// this without race conditions: send fails first, second call
// from the same fake gets the token-endpoint hit + a fresh send
// reply. To keep the test small we simply assert tokenN
// increments after the failing call when the caller retries.
var sendCalls atomic.Int32
fake.mux.HandleFunc("/open-apis/im/v1/messages", func(w http.ResponseWriter, r *http.Request) {
fake.sendN.Add(1)
n := sendCalls.Add(1)
if n == 1 {
writeJSON(w, map[string]any{"code": codeTokenExpired, "msg": "expired"})
return
}
writeJSON(w, map[string]any{"code": 0, "data": map[string]string{"message_id": "om_ok"}})
})
c := newTestClient(fake, time.Now)
_, err := c.SendInteractiveCard(context.Background(), SendCardParams{
InstallationID: testCreds(),
ChatID: ChatID("oc"),
CardJSON: `{}`,
})
if err == nil {
t.Fatal("first send must fail with token-expired")
}
if !strings.Contains(err.Error(), "code=99991663") {
t.Errorf("error should mention token-expired code: %v", err)
}
// Caller's retry — should re-fetch the token, then succeed.
msgID, err := c.SendInteractiveCard(context.Background(), SendCardParams{
InstallationID: testCreds(),
ChatID: ChatID("oc"),
CardJSON: `{}`,
})
if err != nil {
t.Fatalf("retry send: %v", err)
}
if msgID != "om_ok" {
t.Errorf("retry message id: got %q", msgID)
}
if got := fake.tokenN.Load(); got != 2 {
t.Errorf("token endpoint hits after invalidation: got %d want 2", got)
}
}
func TestHTTPClient_PatchInteractiveCard_HappyPath(t *testing.T) {
fake := newLarkFake(t)
fake.stubToken("tok_p", 7200)
fake.stubPatch(
map[string]any{"code": 0, "msg": "ok"},
func(r *http.Request, id string, body map[string]string) {
if id != "om_msg_42" {
t.Errorf("patch id: got %q want om_msg_42", id)
}
if !strings.Contains(body["content"], "updated") {
t.Errorf("patch content: %q", body["content"])
}
},
)
c := newTestClient(fake, time.Now)
if err := c.PatchInteractiveCard(context.Background(), PatchCardParams{
InstallationID: testCreds(),
LarkCardMessageID: "om_msg_42",
CardJSON: `{"text":"updated"}`,
}); err != nil {
t.Fatalf("patch: %v", err)
}
}
func TestHTTPClient_PatchInteractiveCard_LarkErrorCode(t *testing.T) {
fake := newLarkFake(t)
fake.stubToken("tok_p", 7200)
fake.stubPatch(map[string]any{"code": 230002, "msg": "card not found"}, nil)
c := newTestClient(fake, time.Now)
err := c.PatchInteractiveCard(context.Background(), PatchCardParams{
InstallationID: testCreds(),
LarkCardMessageID: "om_msg_x",
CardJSON: `{}`,
})
if err == nil || !strings.Contains(err.Error(), "code=230002") {
t.Errorf("want code=230002 in error, got %v", err)
}
}
func TestHTTPClient_SendBindingPromptCard_HappyPath(t *testing.T) {
fake := newLarkFake(t)
fake.stubToken("tok_b", 7200)
var capturedBody map[string]string
fake.mux.HandleFunc("/open-apis/im/v1/messages", func(w http.ResponseWriter, r *http.Request) {
fake.bindN.Add(1)
_ = json.NewDecoder(r.Body).Decode(&capturedBody)
if got := r.URL.Query().Get("receive_id_type"); got != "open_id" {
t.Errorf("receive_id_type: got %q want open_id", got)
}
writeJSON(w, map[string]any{"code": 0, "data": map[string]string{"message_id": "om_bind"}})
})
c := newTestClient(fake, time.Now)
if err := c.SendBindingPromptCard(context.Background(), BindingPromptParams{
InstallationID: testCreds(),
OpenID: OpenID("ou_user_1"),
BindURL: "https://multica.test/lark/bind?token=abc",
}); err != nil {
t.Fatalf("bind prompt: %v", err)
}
if capturedBody["receive_id"] != "ou_user_1" {
t.Errorf("receive_id: got %q", capturedBody["receive_id"])
}
if !strings.Contains(capturedBody["content"], "multica.test/lark/bind") {
t.Errorf("binding card should embed BindURL: %q", capturedBody["content"])
}
if !strings.Contains(capturedBody["content"], "去绑定") {
t.Errorf("binding card should carry the localized CTA: %q", capturedBody["content"])
}
}
func TestHTTPClient_TokenEndpointError(t *testing.T) {
fake := newLarkFake(t)
fake.stubTokenError(10003, "invalid app_id or app_secret")
c := newTestClient(fake, time.Now)
_, err := c.SendInteractiveCard(context.Background(), SendCardParams{
InstallationID: testCreds(),
ChatID: ChatID("oc"),
CardJSON: `{}`,
})
if err == nil || !strings.Contains(err.Error(), "code=10003") {
t.Errorf("want code=10003 surfaced, got %v", err)
}
}
func TestHTTPClient_AddMessageReaction_HappyPath(t *testing.T) {
fake := newLarkFake(t)
fake.stubToken("tok_react", 7200)
fake.stubReaction(map[string]any{"code": 0, "msg": "ok", "data": map[string]string{"reaction_id": "re_42"}}, func(r *http.Request, id string, body map[string]any) {
if id != "om_user_msg_1" {
t.Errorf("message id: got %q want om_user_msg_1", id)
}
if got := r.Header.Get("Authorization"); got != "Bearer tok_react" {
t.Errorf("Authorization=%q want Bearer tok_react", got)
}
reactionType, ok := body["reaction_type"].(map[string]any)
if !ok {
t.Fatalf("reaction_type missing or wrong shape: %v", body)
}
if got := reactionType["emoji_type"]; got != "Typing" {
t.Errorf("emoji_type=%v want Typing", got)
}
})
c := newTestClient(fake, time.Now)
reactionID, err := c.AddMessageReaction(context.Background(), AddReactionParams{
InstallationID: testCreds(),
MessageID: "om_user_msg_1",
EmojiType: "Typing",
})
if err != nil {
t.Fatalf("AddMessageReaction: %v", err)
}
if reactionID != "re_42" {
t.Errorf("reaction id: got %q want re_42", reactionID)
}
if got := fake.reactN.Load(); got != 1 {
t.Fatalf("reaction endpoint calls=%d want 1", got)
}
}
func TestHTTPClient_DeleteMessageReaction_HappyPath(t *testing.T) {
fake := newLarkFake(t)
fake.stubToken("tok_del", 7200)
fake.stubReactionDelete(map[string]any{"code": 0, "msg": "ok"}, func(r *http.Request, msgID string, reactionID string) {
if msgID != "om_user_msg_1" {
t.Errorf("message id: got %q want om_user_msg_1", msgID)
}
if reactionID != "re_42" {
t.Errorf("reaction id: got %q want re_42", reactionID)
}
if got := r.Header.Get("Authorization"); got != "Bearer tok_del" {
t.Errorf("Authorization=%q want Bearer tok_del", got)
}
})
c := newTestClient(fake, time.Now)
if err := c.DeleteMessageReaction(context.Background(), DeleteReactionParams{
InstallationID: testCreds(),
MessageID: "om_user_msg_1",
ReactionID: "re_42",
}); err != nil {
t.Fatalf("DeleteMessageReaction: %v", err)
}
if got := fake.delRN.Load(); got != 1 {
t.Fatalf("reaction delete endpoint calls=%d want 1", got)
}
}
func TestHTTPClient_AddMessageReaction_Validation(t *testing.T) {
c := NewHTTPAPIClient(HTTPClientConfig{}).(*httpAPIClient)
_, err := c.AddMessageReaction(context.Background(), AddReactionParams{MessageID: "m"})
if err == nil || !strings.Contains(err.Error(), "missing emoji_type") {
t.Errorf("want missing emoji_type error, got %v", err)
}
_, err = c.AddMessageReaction(context.Background(), AddReactionParams{EmojiType: "Typing"})
if err == nil || !strings.Contains(err.Error(), "missing message_id") {
t.Errorf("want missing message_id error, got %v", err)
}
}
func TestHTTPClient_DeleteMessageReaction_Validation(t *testing.T) {
c := NewHTTPAPIClient(HTTPClientConfig{}).(*httpAPIClient)
err := c.DeleteMessageReaction(context.Background(), DeleteReactionParams{ReactionID: "re"})
if err == nil || !strings.Contains(err.Error(), "missing message_id") {
t.Errorf("want missing message_id error, got %v", err)
}
err = c.DeleteMessageReaction(context.Background(), DeleteReactionParams{MessageID: "m"})
if err == nil || !strings.Contains(err.Error(), "missing reaction_id") {
t.Errorf("want missing reaction_id error, got %v", err)
}
}
func TestHTTPClient_MissingAppCredentials(t *testing.T) {
c := NewHTTPAPIClient(HTTPClientConfig{}).(*httpAPIClient)
_, err := c.tenantAccessToken(context.Background(), InstallationCredentials{AppSecret: "x"})
if err == nil || !strings.Contains(err.Error(), "app_id") {
t.Errorf("want missing app_id error, got %v", err)
}
_, err = c.tenantAccessToken(context.Background(), InstallationCredentials{AppID: "x"})
if err == nil || !strings.Contains(err.Error(), "app_secret") {
t.Errorf("want missing app_secret error, got %v", err)
}
}
func TestHTTPClient_MissingChatID_PreAuth(t *testing.T) {
// chat_id validation must short-circuit BEFORE any auth round-trip
// — otherwise a misuse leaks load to the token endpoint.
fake := newLarkFake(t)
c := newTestClient(fake, time.Now)
_, err := c.SendInteractiveCard(context.Background(), SendCardParams{
InstallationID: testCreds(),
CardJSON: `{}`,
})
if err == nil || !strings.Contains(err.Error(), "chat_id") {
t.Errorf("want missing chat_id error, got %v", err)
}
if got := fake.tokenN.Load(); got != 0 {
t.Errorf("token endpoint must not be hit on bad input: got %d", got)
}
}
func TestHTTPClient_MissingCardJSON(t *testing.T) {
c := NewHTTPAPIClient(HTTPClientConfig{}).(*httpAPIClient)
if _, err := c.SendInteractiveCard(context.Background(), SendCardParams{
InstallationID: testCreds(),
ChatID: ChatID("oc"),
}); err == nil || !strings.Contains(err.Error(), "card json") {
t.Errorf("send: want missing card json, got %v", err)
}
if err := c.PatchInteractiveCard(context.Background(), PatchCardParams{
InstallationID: testCreds(),
LarkCardMessageID: "om",
}); err == nil || !strings.Contains(err.Error(), "card json") {
t.Errorf("patch: want missing card json, got %v", err)
}
}
func TestHTTPClient_PatchMissingID(t *testing.T) {
c := NewHTTPAPIClient(HTTPClientConfig{}).(*httpAPIClient)
err := c.PatchInteractiveCard(context.Background(), PatchCardParams{
InstallationID: testCreds(),
CardJSON: `{}`,
})
if err == nil || !strings.Contains(err.Error(), "card message id") {
t.Errorf("want missing message id error, got %v", err)
}
}
func TestHTTPClient_BindingPromptValidation(t *testing.T) {
c := NewHTTPAPIClient(HTTPClientConfig{}).(*httpAPIClient)
if err := c.SendBindingPromptCard(context.Background(), BindingPromptParams{
InstallationID: testCreds(),
BindURL: "https://x",
}); err == nil || !strings.Contains(err.Error(), "open_id") {
t.Errorf("want missing open_id, got %v", err)
}
if err := c.SendBindingPromptCard(context.Background(), BindingPromptParams{
InstallationID: testCreds(),
OpenID: "ou",
}); err == nil || !strings.Contains(err.Error(), "bind url") {
t.Errorf("want missing bind url, got %v", err)
}
}
// TestHTTPClient_GetBotInfo_HappyPath drives the device-flow follow-up
// step: once RegistrationService has fresh client_id / client_secret
// from /oauth/v1/app/registration, it mints a tenant_access_token and
// asks /open-apis/bot/v3/info for the Bot's per-installation open_id,
// then resolves the bot's union_id via /open-apis/contact/v3/users/
// {open_id}?user_id_type=open_id. Both identifiers are persisted on
// the installation row; the union_id is what the WS decoder uses to
// route inbound @-mentions in multi-bot group chats (MUL-2671). The
// other fields on the bot/v3/info response (display name, avatar,
// IP whitelist) are deliberately dropped on the floor.
func TestHTTPClient_GetBotInfo_HappyPath(t *testing.T) {
fake := newLarkFake(t)
fake.stubToken("tok_bi", 7200)
fake.mux.HandleFunc("/open-apis/bot/v3/info", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("bot info: want GET, got %s", r.Method)
}
if got := r.Header.Get("Authorization"); got != "Bearer tok_bi" {
t.Errorf("bot info: Authorization=%q want Bearer tok_bi", got)
}
writeJSON(w, map[string]any{
"code": 0,
"msg": "ok",
"bot": map[string]any{
"open_id": "ou_bot_42",
"app_name": "PersonalAgent",
"avatar_url": "https://example/avatar.png",
},
})
})
fake.mux.HandleFunc("/open-apis/contact/v3/users/ou_bot_42", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("contact users: want GET, got %s", r.Method)
}
if got := r.URL.Query().Get("user_id_type"); got != "open_id" {
t.Errorf("contact users: user_id_type=%q want open_id", got)
}
if got := r.Header.Get("Authorization"); got != "Bearer tok_bi" {
t.Errorf("contact users: Authorization=%q want Bearer tok_bi", got)
}
writeJSON(w, map[string]any{
"code": 0,
"msg": "ok",
"data": map[string]any{
"user": map[string]any{
"union_id": "on_bot_42_stable",
},
},
})
})
c := NewHTTPAPIClient(HTTPClientConfig{BaseURL: fake.URL()})
info, err := c.GetBotInfo(context.Background(), testCreds())
if err != nil {
t.Fatalf("GetBotInfo: %v", err)
}
if string(info.OpenID) != "ou_bot_42" {
t.Errorf("OpenID: got %q want ou_bot_42", info.OpenID)
}
if info.UnionID != "on_bot_42_stable" {
t.Errorf("UnionID: got %q want on_bot_42_stable", info.UnionID)
}
}
// TestHTTPClient_GetBotInfo_UnionIDLookupSoftFails covers the case
// where /contact/v3/users returns a non-zero code (e.g. the app's
// contact scope was never approved). The install must still succeed
// with an empty UnionID so the operator can backfill later instead
// of the QR flow failing outright. The decoder transitional fallback
// keeps single-bot installs working in the gap.
func TestHTTPClient_GetBotInfo_UnionIDLookupSoftFails(t *testing.T) {
fake := newLarkFake(t)
fake.stubToken("tok_bi_softfail", 7200)
fake.mux.HandleFunc("/open-apis/bot/v3/info", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{
"code": 0,
"msg": "ok",
"bot": map[string]any{"open_id": "ou_bot_softfail"},
})
})
fake.mux.HandleFunc("/open-apis/contact/v3/users/ou_bot_softfail", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"code": 99991002, "msg": "no permission"})
})
c := NewHTTPAPIClient(HTTPClientConfig{BaseURL: fake.URL()})
info, err := c.GetBotInfo(context.Background(), testCreds())
if err != nil {
t.Fatalf("GetBotInfo unexpectedly errored on soft-fail: %v", err)
}
if string(info.OpenID) != "ou_bot_softfail" {
t.Errorf("OpenID: got %q want ou_bot_softfail", info.OpenID)
}
if info.UnionID != "" {
t.Errorf("UnionID: got %q want empty (soft-fail leaves backfill to operator)", info.UnionID)
}
}
// TestHTTPClient_GetBotInfo_LarkErrorCode surfaces a non-zero Lark
// error code (e.g. 230003 = bot disabled) as a wrapped error so
// RegistrationService can fail the install cleanly instead of
// recording a row with bot_open_id="".
func TestHTTPClient_GetBotInfo_LarkErrorCode(t *testing.T) {
fake := newLarkFake(t)
fake.stubToken("tok_bi_err", 7200)
fake.mux.HandleFunc("/open-apis/bot/v3/info", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"code": 230003, "msg": "bot disabled"})
})
c := NewHTTPAPIClient(HTTPClientConfig{BaseURL: fake.URL()})
_, err := c.GetBotInfo(context.Background(), testCreds())
if err == nil || !strings.Contains(err.Error(), "code=230003") {
t.Errorf("want code=230003 surfaced, got %v", err)
}
}
// TestHTTPClient_GetBotInfo_MissingCredentials short-circuits before
// any HTTP round-trip when the caller hands in zero-value credentials.
// A misuse here should NOT leak load to Lark's token endpoint.
func TestHTTPClient_GetBotInfo_MissingCredentials(t *testing.T) {
fake := newLarkFake(t)
c := newTestClient(fake, time.Now)
if _, err := c.GetBotInfo(context.Background(), InstallationCredentials{}); err == nil ||
!strings.Contains(err.Error(), "missing app credentials") {
t.Errorf("want missing credentials error, got %v", err)
}
if got := fake.tokenN.Load(); got != 0 {
t.Errorf("token endpoint must not be hit on bad input: got %d", got)
}
}
func TestHTTPClient_BadHTTPStatus(t *testing.T) {
fake := newLarkFake(t)
// Token returns success.
fake.stubToken("tok", 7200)
// Send replies with 500 + body — exercise the non-2xx branch.
fake.mux.HandleFunc("/open-apis/im/v1/messages", func(w http.ResponseWriter, r *http.Request) {
fake.sendN.Add(1)
w.WriteHeader(500)
_, _ = io.WriteString(w, "boom")
})
c := newTestClient(fake, time.Now)
_, err := c.SendInteractiveCard(context.Background(), SendCardParams{
InstallationID: testCreds(),
ChatID: ChatID("oc"),
CardJSON: `{}`,
})
if err == nil || !strings.Contains(err.Error(), "http 500") {
t.Errorf("want http 500 surfaced, got %v", err)
}
}
func TestHTTPClient_TokenExpire_ClampedToSafety(t *testing.T) {
// Lark returns expire=10s — well under the safety margin. The
// client must NOT cache a token that is already past its safe
// window; instead it clamps to 2× safety margin so the cached
// entry is at least usable for one safety margin of wall-clock.
fake := newLarkFake(t)
fake.stubToken("tok_short", 10)
fake.stubSend(map[string]any{"code": 0, "data": map[string]string{"message_id": "om"}}, nil)
now := time.Unix(1_700_000_000, 0)
clock := &fakeClock{now: now}
c := NewHTTPAPIClient(HTTPClientConfig{BaseURL: fake.URL(), Now: clock.Now}).(*httpAPIClient)
if _, err := c.SendInteractiveCard(context.Background(), SendCardParams{
InstallationID: testCreds(),
ChatID: ChatID("oc"),
CardJSON: `{}`,
}); err != nil {
t.Fatalf("send: %v", err)
}
clock.Advance(30 * time.Second) // still within clamped window
if _, err := c.SendInteractiveCard(context.Background(), SendCardParams{
InstallationID: testCreds(),
ChatID: ChatID("oc"),
CardJSON: `{}`,
}); err != nil {
t.Fatalf("send2: %v", err)
}
if got := fake.tokenN.Load(); got != 1 {
t.Errorf("token endpoint hits within clamped window: got %d want 1", got)
}
}
func TestBindingPromptTemplate_Shape(t *testing.T) {
raw, err := bindingPromptTemplate("https://multica.test/bind?token=abc")
if err != nil {
t.Fatalf("template: %v", err)
}
var doc map[string]any
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
t.Fatalf("template json: %v", err)
}
// Shape check — top-level keys exist and elements is non-empty.
if _, ok := doc["config"]; !ok {
t.Errorf("missing config")
}
if _, ok := doc["header"]; !ok {
t.Errorf("missing header")
}
elements, ok := doc["elements"].([]any)
if !ok || len(elements) < 2 {
t.Fatalf("elements: want >=2, got %v", doc["elements"])
}
// Last element should be the action button carrying the URL.
last, _ := elements[len(elements)-1].(map[string]any)
if last["tag"] != "action" {
t.Errorf("last element should be action: %v", last)
}
actions, _ := last["actions"].([]any)
if len(actions) == 0 {
t.Fatalf("no actions in card")
}
btn, _ := actions[0].(map[string]any)
if btn["url"] != "https://multica.test/bind?token=abc" {
t.Errorf("button url: got %v", btn["url"])
}
}
// fakeClock is a minimal monotonic clock for tests that need to drive
// the cache TTL deterministically.
type fakeClock struct{ now time.Time }
func (c *fakeClock) Now() time.Time { return c.now }
func (c *fakeClock) Advance(d time.Duration) { c.now = c.now.Add(d) }