mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
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>
1162 lines
40 KiB
Go
1162 lines
40 KiB
Go
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) }
|