「接她的话」缺的 A 层:平台给她自己那条出站消息分配的 id,此前在解析的那一刻 就被丢掉(HTTP 只校 retcode、WS 写完不等回声、契约层没有 id 出口)。 - internal/channel/adapter/outbound.go(新):SendReceipt/SendOutcome/SendIDStatus + 可选接口 OutboundReceiptAdapter + 调用方入口 SendWithReceipt。**不加进 PlatformAdapter** —— 四个未接线适配器与全部桩实现一行不改(向后兼容硬指标); 也不用回调:id 是某一次投递的属性,必须随那次投递返回。 - qq_obv11:HTTP 从响应信封 data.message_id 取;WS 登记 send_msg 的 echo 等待者 (复用 probe.go 的 action 等待表与 routeActionResponse,不另开通道)。取不到 一律空 id + 三态可分辨(reported/absent/unreported),绝不编造。 - send_msg echo 键改用单调计数器(旧 UnixNano 在 Windows 时钟粒度下会撞键, 撞键即拿不到自己的回执)。 - 既有 SendMessage/SendProactive 路径行为不变(wantReceipt=false 不等待)。 验收:go build ./... + go vet ./... + go test ./... 全绿(cmd/cyrene、internal/webui 的 setup 失败是 worktree 缺 dist 的既有环境限制,base 同样失败)。
151 lines
5.1 KiB
Go
151 lines
5.1 KiB
Go
package adapter
|
||
|
||
// 本文件证明出站 id 出口(outbound.go)的三条硬性质:**向后兼容**(老适配器一行不改、
|
||
// 探测失败即如实报「不知道」)、**三态可分辨**、**取不到就是空 / 绝不编造**。
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"testing"
|
||
)
|
||
|
||
// receiptAdapter 是同时实现了 PlatformAdapter 与 OutboundReceiptAdapter 的桩:
|
||
// 它记录两条路径各被调用了几次,用来证明「实现了 seam 的适配器**只**走凭据路径」
|
||
// (绝不出现一条消息发两遍)。
|
||
type receiptAdapter struct {
|
||
mockAdapter
|
||
receiptCalls int
|
||
receipts []SendReceipt
|
||
}
|
||
|
||
func (m *receiptAdapter) SendMessageWithReceipt(_ context.Context, resp UnifiedResponse) (SendOutcome, error) {
|
||
m.receiptCalls++
|
||
m.sent = resp.Messages
|
||
return SendOutcome{Receipts: m.receipts}, nil
|
||
}
|
||
|
||
var (
|
||
_ PlatformAdapter = (*receiptAdapter)(nil)
|
||
_ OutboundReceiptAdapter = (*receiptAdapter)(nil)
|
||
)
|
||
|
||
// failAdapter 的投递恒失败,用来验证错误原样穿过 SendWithReceipt。
|
||
type failAdapter struct {
|
||
mockAdapter
|
||
err error
|
||
}
|
||
|
||
func (f *failAdapter) SendMessage(context.Context, UnifiedResponse) error { return f.err }
|
||
|
||
func TestNewSendReceiptNeverReportsEmptyID(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
absent := NewSendReceipt("")
|
||
if absent.MessageID != "" || absent.IDStatus != SendIDAbsent || absent.Reported() {
|
||
t.Errorf("NewSendReceipt(\"\") = %+v, want absent with empty id", absent)
|
||
}
|
||
|
||
reported := NewSendReceipt("1700000012345")
|
||
if reported.MessageID != "1700000012345" || !reported.Reported() {
|
||
t.Errorf("NewSendReceipt(id) = %+v, want reported id", reported)
|
||
}
|
||
}
|
||
|
||
// TestSendReceiptZeroValueIsUnknown 钉住 fail-closed 的零值口径:一个只写了
|
||
// `SendReceipt{}` 的实现表示「不知道」,绝不能被读成「平台给了 id」。
|
||
func TestSendReceiptZeroValueIsUnknown(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var zero SendReceipt
|
||
if zero.IDStatus != SendIDUnreported {
|
||
t.Errorf("zero IDStatus = %q, want %q", zero.IDStatus, SendIDUnreported)
|
||
}
|
||
if zero.MessageID != "" || zero.Reported() {
|
||
t.Errorf("zero receipt = %+v, want empty and not reported", zero)
|
||
}
|
||
}
|
||
|
||
// TestPlainAdapterIsNotAReceiptAdapter 是**向后兼容**的运行时证据:一个只实现
|
||
// PlatformAdapter 的适配器(等价于 kook / telegram / weixin-ilink / webhook)**不是**
|
||
// OutboundReceiptAdapter —— 契约没被破坏,它们一行都不用改。
|
||
func TestPlainAdapterIsNotAReceiptAdapter(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var a PlatformAdapter = &mockAdapter{name: "telegram"}
|
||
if _, ok := a.(OutboundReceiptAdapter); ok {
|
||
t.Fatal("plain adapter must not satisfy OutboundReceiptAdapter")
|
||
}
|
||
}
|
||
|
||
// TestSendWithReceiptFallsBackToSendMessage 覆盖未接线渠道:投递照旧走 SendMessage,
|
||
// 凭据如实报「未取到」——**逐条**与 Messages 等长,且 id 全空(绝不编造)。
|
||
func TestSendWithReceiptFallsBackToSendMessage(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
ad := &mockAdapter{name: "telegram"}
|
||
resp := UnifiedResponse{Messages: []UnifiedMessage{
|
||
{ChannelType: ChannelDirect, ContentType: ContentText, Content: "一"},
|
||
{ChannelType: ChannelDirect, ContentType: ContentText, Content: "二"},
|
||
}}
|
||
|
||
out, err := SendWithReceipt(context.Background(), ad, resp)
|
||
if err != nil {
|
||
t.Fatalf("SendWithReceipt() error = %v", err)
|
||
}
|
||
if len(ad.sent) != 2 {
|
||
t.Fatalf("fallback did not deliver through SendMessage: sent = %+v", ad.sent)
|
||
}
|
||
if len(out.Receipts) != 2 {
|
||
t.Fatalf("Receipts len = %d, want 2 (one per message)", len(out.Receipts))
|
||
}
|
||
for i, r := range out.Receipts {
|
||
if r.MessageID != "" || r.IDStatus != SendIDUnreported || r.Reported() {
|
||
t.Errorf("receipt[%d] = %+v, want empty id with unreported status", i, r)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestSendWithReceiptUsesSeamWhenImplemented 证明实现了 seam 的适配器只走凭据路径
|
||
// (不会两条路径各发一遍)。
|
||
func TestSendWithReceiptUsesSeamWhenImplemented(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
ad := &receiptAdapter{receipts: []SendReceipt{NewSendReceipt("9001")}}
|
||
ad.name = "qq-obv11"
|
||
|
||
out, err := SendWithReceipt(context.Background(), ad, UnifiedResponse{Messages: []UnifiedMessage{
|
||
{ChannelType: ChannelGroup, GroupID: "123", ContentType: ContentText, Content: "pong"},
|
||
}})
|
||
if err != nil {
|
||
t.Fatalf("SendWithReceipt() error = %v", err)
|
||
}
|
||
if ad.receiptCalls != 1 {
|
||
t.Errorf("receipt path calls = %d, want 1", ad.receiptCalls)
|
||
}
|
||
if len(out.Receipts) != 1 || out.Receipts[0].MessageID != "9001" {
|
||
t.Errorf("Receipts = %+v, want one reported 9001", out.Receipts)
|
||
}
|
||
}
|
||
|
||
func TestSendWithReceiptPropagatesError(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
want := errors.New("boom")
|
||
ad := &failAdapter{err: want}
|
||
|
||
_, err := SendWithReceipt(context.Background(), ad, UnifiedResponse{Messages: []UnifiedMessage{
|
||
{ChannelType: ChannelDirect, ContentType: ContentText, Content: "x"},
|
||
}})
|
||
if !errors.Is(err, want) {
|
||
t.Fatalf("SendWithReceipt() error = %v, want %v", err, want)
|
||
}
|
||
}
|
||
|
||
func TestSendWithReceiptNilAdapter(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
if _, err := SendWithReceipt(context.Background(), nil, UnifiedResponse{}); !errors.Is(err, ErrNotConnected) {
|
||
t.Fatalf("SendWithReceipt(nil) error = %v, want ErrNotConnected", err)
|
||
}
|
||
}
|