package weixin_ilink import ( "bytes" "context" "crypto/aes" "encoding/base64" "errors" "net/http" "net/http/httptest" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) // sendOKServer replies ret:0 to sendmessage, recording every request. func sendOKServer(t *testing.T, rec *reqRecorder) *httptest.Server { t.Helper() return newJSONServer(t, rec, func(req recordedReq) handlerResult { if req.method == http.MethodPost && req.path == "/ilink/bot/sendmessage" { return handlerResult{body: jsonBody(map[string]any{"ret": 0})} } return handlerResult{status: 404} }) } func TestSendMessage_RequiresConnection(t *testing.T) { t.Parallel() ad, err := New(Config{BaseURL: "https://ilinkai.weixin.qq.com"}, nil, nil) if err != nil { t.Fatalf("New: %v", err) } err = ad.SendMessage(context.Background(), adapter.UnifiedResponse{ Messages: []adapter.UnifiedMessage{{SenderID: "u-1", Content: "hi"}}, }) if !errors.Is(err, adapter.ErrNotConnected) { t.Fatalf("err = %v, want ErrNotConnected", err) } } func TestSendMessage_RequiresContextToken(t *testing.T) { t.Parallel() rec := &reqRecorder{} srv := sendOKServer(t, rec) ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, BotToken: "tok"}, srv) err := ad.SendMessage(context.Background(), adapter.UnifiedResponse{ Messages: []adapter.UnifiedMessage{{SenderID: "u-1", Content: "hi"}}, }) if !errors.Is(err, ErrNoContextToken) { t.Fatalf("err = %v, want ErrNoContextToken", err) } } func TestSendMessage_SendsTextReply(t *testing.T) { t.Parallel() rec := &reqRecorder{} srv := sendOKServer(t, rec) ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, BotToken: "tok"}, srv) ad.recordInbound("tok-1", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: ad.clock()}) resp := adapter.UnifiedResponse{ Messages: []adapter.UnifiedMessage{{SenderID: "u-1", Content: "pong"}}, ReplyTo: "anything", } if err := ad.SendMessage(context.Background(), resp); err != nil { t.Fatalf("SendMessage: %v", err) } body := getSendBody(t, rec) msg := body["msg"].(map[string]any) if msg["to_user_id"] != "u-1" { t.Fatalf("to_user_id = %v, want u-1", msg["to_user_id"]) } if msg["context_token"] != "tok-1" { t.Fatalf("context_token = %v, want tok-1", msg["context_token"]) } item := msg["item_list"].([]any)[0].(map[string]any) if typ, ok := item["type"].(float64); !ok || typ != 1 { t.Fatalf("item type = %v, want 1", item["type"]) } if txt := item["text_item"].(map[string]any)["text"]; txt != "pong" { t.Fatalf("text = %v, want pong", txt) } if msg["client_id"] == "" || msg["client_id"] == nil { t.Fatal("client_id missing") } } func TestSendProactive_WithinWindow(t *testing.T) { t.Parallel() rec := &reqRecorder{} srv := sendOKServer(t, rec) ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, BotToken: "tok"}, srv) now := time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC) ad.clock = func() time.Time { return now } ad.recordInbound("tok-p", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: now}) err := ad.SendProactive(context.Background(), adapter.UnifiedMessage{ SenderID: "u-1", Content: "daily tip", }) if err != nil { t.Fatalf("SendProactive within window: %v", err) } body := getSendBody(t, rec) msg := body["msg"].(map[string]any) if msg["to_user_id"] != "u-1" || msg["context_token"] != "tok-p" { t.Fatalf("proactive send = to_user_id %v context %v", msg["to_user_id"], msg["context_token"]) } } func TestSendProactive_ExpiredWindow(t *testing.T) { t.Parallel() rec := &reqRecorder{} srv := sendOKServer(t, rec) ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, BotToken: "tok"}, srv) now := time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC) ad.clock = func() time.Time { return now } ad.recordInbound("tok-p", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: now}) // Advance beyond the 24h window. now = now.Add(25 * time.Hour) err := ad.SendProactive(context.Background(), adapter.UnifiedMessage{ SenderID: "u-1", Content: "too late", }) if !errors.Is(err, ErrProactiveWindow) { t.Fatalf("err = %v, want ErrProactiveWindow", err) } if len(getupdatesReqs(rec)) != 0 { t.Fatal("expired window must not send") } } func TestSendProactive_NoPriorInbound(t *testing.T) { t.Parallel() srv := sendOKServer(t, &reqRecorder{}) ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, BotToken: "tok"}, srv) err := ad.SendProactive(context.Background(), adapter.UnifiedMessage{ SenderID: "u-1", Content: "hello", }) if !errors.Is(err, ErrProactiveWindow) { t.Fatalf("err = %v, want ErrProactiveWindow (no prior inbound)", err) } } func TestSendMessage_OutboundMedia(t *testing.T) { t.Parallel() rec := &reqRecorder{} var srv *httptest.Server srv = newJSONServer(t, rec, func(req recordedReq) handlerResult { switch { case req.method == http.MethodPost && req.path == "/ilink/bot/getuploadurl": return handlerResult{body: jsonBody(map[string]any{"upload_url": srv.URL + "/upload/1"})} case req.method == http.MethodPut && req.path == "/upload/1": return handlerResult{status: 200, body: []byte("ok")} case req.method == http.MethodPost && req.path == "/ilink/bot/sendmessage": return handlerResult{body: jsonBody(map[string]any{"ret": 0})} default: return handlerResult{status: 404} } }) ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, CDNBaseURL: srv.URL, BotToken: "tok"}, srv) ad.recordInbound("tok-m", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: ad.clock()}) imageData := []byte("fake-jpeg-bytes") err := ad.SendMessage(context.Background(), adapter.UnifiedResponse{ Messages: []adapter.UnifiedMessage{{ SenderID: "u-1", Media: []adapter.Media{{Kind: adapter.MediaImage, Data: imageData, Name: "a.jpg"}}, }}, }) if err != nil { t.Fatalf("SendMessage media: %v", err) } body := getSendBody(t, rec) msg := body["msg"].(map[string]any) item := msg["item_list"].([]any)[0].(map[string]any) if typ, ok := item["type"].(float64); !ok || typ != 2 { t.Fatalf("media item type = %v, want 2", item["type"]) } if item["cdn_ref"] != srv.URL+"/upload/1" { t.Fatalf("cdn_ref = %v, want upload url", item["cdn_ref"]) } aesKeyEncoded, _ := item["aes_key"].(string) if aesKeyEncoded == "" { t.Fatal("aes_key missing on media item") } key, err := base64.StdEncoding.DecodeString(aesKeyEncoded) if err != nil { t.Fatalf("decode aes_key: %v", err) } if len(key) != aes.BlockSize { t.Fatalf("aes_key = %d bytes, want %d", len(key), aes.BlockSize) } // The uploaded bytes (PUT body) must decrypt back to the original media. puts := rec.find(func(q recordedReq) bool { return q.method == http.MethodPut }) if len(puts) != 1 { t.Fatalf("PUT requests = %d, want 1", len(puts)) } decrypted, err := aesECBDecrypt(key, puts[0].body) if err != nil { t.Fatalf("decrypt uploaded media: %v", err) } if !bytes.Equal(decrypted, imageData) { t.Fatalf("uploaded media mismatch: got %q want %q", decrypted, imageData) } } // getSendBody returns the decoded body of the first sendmessage request. func getSendBody(t *testing.T, rec *reqRecorder) map[string]any { t.Helper() reqs := rec.find(func(q recordedReq) bool { return q.method == http.MethodPost && q.path == "/ilink/bot/sendmessage" }) if len(reqs) == 0 { t.Fatal("no sendmessage request recorded") } body := reqs[0].bodyJSON() if body == nil { t.Fatal("sendmessage body is not JSON") } return body }