Files
Cyrene_v2/internal/services/channelbridge/outbound_voice_gateway_e2e_test.go
T
AskaEth 8ba4cde61d test(channelbridge): 握手回放也断言出站 voice(M58)
- dialOwnerWSHistory 交回 history 帧,replayVoiceOfID 按 id 取 voice 原文
- 成功与降级两条整链用例都补「重连 WS 回放」断言,与实时帧逐字相等
2026-10-27 22:08:38 +08:00

490 lines
21 KiB
Go
Raw 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 channelbridge
// 本文件是 M58 的**跨层整链**验收:出站语音结论(`loop.Result.Voice`,M57 的 speak 工具
// 结算)→ 桥接器落库并写 `chat_messages.voice` 列 → 真网关的**实时帧** + REST 读面;
// 以及同一回合的出站 `adapter.Media` 挂载。
//
// 手法与入站整链(inbound_voice_gateway_e2e_test.go)同源:**同一个**真 `history.Store`
// 喂给桥接器与网关,断言因此打在真 WS 帧与真 REST 读面上,而不是两个替身各自的想象。
// 唯一的差别是驱动方式:出站结论来自循环,所以这里用 `stubChatter` 直接给出带 `Voice`
// 的 `Result` —— 真循环 + 假模型跑不出 `Result.Voice`(假模型不会去调 speak 工具),
// 而本切片要钉的正是「结论已经在手里之后」那一段。
//
// 钉住四件事:
// 1. 合成成功 ⇒ 助手回复的**第一帧**就带 `voice`(source=tts/status=transcribed + 副本四键),
// 重新连 WS 的握手回放与 REST 读面逐字相同;
// 2. 合成降级 ⇒ `voice.status=failed` + `fallbackReason` 在帧上**与**历史回看(REST、握手回放)
// 上都可见,且正文照发原文(降级不是「少说一句话」);
// 3. 音频只挂在**第一个**气泡上(多段回复里重复挂会让对方收到 N 份同样的语音);
// 4. 出站 Media 的字节就是盘上那份副本(读不回来时宁可不挂,见 outbound_voice.go)。
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
)
const (
// outboundVoiceText 是所有出站用例的回复正文(单段,便于把「正文原样」写成字面量)。
outboundVoiceText = "晚安啦,做个好梦。"
// outboundVoiceStoredText 是它经回合可见回复规整(turnreply.NormalizeParagraphs)后
// **真正落库、真正上帧**的文本:单段被包进一对 `<p>`。降级用例按这个字面量比对,
// 因此「合成失败有没有动正文」是一个逐字问题,而不是「大概还在」。
outboundVoiceStoredText = "<p>晚安啦,做个好梦。</p>"
// outboundVoiceMultiText 是两段回复:出站音频只该跟着第一段走。
outboundVoiceMultiText = "第一段话,先说给你听。\n\n第二段话,说给明天听。"
// outboundVoiceAudioMIME 是 `.ogg` 副本的下发类型(voice/media.go 的成对裁决产物)。
outboundVoiceAudioMIME = "audio/ogg"
)
// outboundVoiceE2E 打包出站语音整链环境:真桥接器(可注入 `Result.Voice` 的循环替身 +
// 真历史存储 + 真音频落盘配置)+ 真网关(同一个历史存储)+ 组合根的持久化钩子。
type outboundVoiceE2E struct {
bridge *Bridge
chat *stubChatter
platform *fakeAdapter
// uploads 是桥接器会去读副本的那个目录(`documents.Store.Dir()`)。
uploads string
ts *httptest.Server
tok string
}
// newOutboundVoiceE2E 装配整链环境。历史存储是**同一个句柄**喂给桥接器与网关 ——
// 被验证的正是这条边界:桥接器写进 `voice` 列的事实,就是实时帧与 REST 读面投影出来的事实。
func newOutboundVoiceE2E(t *testing.T, sess session.Session) *outboundVoiceE2E {
t.Helper()
dataDir := t.TempDir()
uploads := filepath.Join(dataDir, "uploads", "documents")
log := slog.New(slog.NewTextHandler(&safeBuffer{}, nil))
st := openGatewayDocStore(t, log)
chat := &stubChatter{res: &loop.Result{Text: outboundVoiceText}}
ad := &fakeAdapter{name: "telegram"}
b, err := New(Options{
Log: log,
Loop: chat,
Sessions: &stubSessions{sess: sess},
Owner: testOwnerProfile("telegram", "100"),
History: st,
// 真音频落盘配置:副本由 documents.Store.SaveAudio 落下,出站时按同一目录读回。
DocumentStore: documents.StoreConfig{DataDir: dataDir, UploadsDir: uploads},
})
if err != nil {
t.Fatalf("构造桥接器: %v", err)
}
// 气泡间隔在生产里是 0.5-2s:多段用例不该为它付真实时间。
b.bubbleGapFn = func() time.Duration { return 0 }
b.Add(ad)
jwt := auth.New(config.AuthConfig{JWTSecret: gatewayDocSecret})
srv, err := gateway.New(
&config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: gatewayDocSecret}},
gateway.Deps{Auth: jwt, History: st, DataDir: dataDir},
)
if err != nil {
t.Fatalf("构造网关: %v", err)
}
t.Cleanup(srv.Close)
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
// 🔴 组合根的同一接线(cmd/cyrene/wire_frontend.go:SetOnPersisted(gw.BroadcastPersisted))。
b.SetOnPersisted(srv.BroadcastPersisted)
tok, err := jwt.Sign("acct", auth.RoleOwner, time.Hour)
if err != nil {
t.Fatalf("签发 token: %v", err)
}
return &outboundVoiceE2E{bridge: b, chat: chat, platform: ad, uploads: uploads, ts: ts, tok: tok}
}
// dialOwnerWS 连上真网关的 `/ws/chat`(owner token),并吃掉握手回放的 `history` 帧,
// 使后续读取从「本回合的第一帧」开始(与入站整链同一手法)。
func (e *outboundVoiceE2E) dialOwnerWS(t *testing.T) *websocket.Conn {
t.Helper()
conn, _ := e.dialOwnerWSHistory(t)
return conn
}
// dialOwnerWSHistory 同 dialOwnerWS,但把握手回放的 `history` 帧一并交回 —— 出站语音
// 也得在「隔天重新打开客户端」的那条路上看得见(列有值即带),这条读面因此要能被断言。
func (e *outboundVoiceE2E) dialOwnerWSHistory(t *testing.T) (*websocket.Conn, wsFrame) {
t.Helper()
wsURL := "ws" + strings.TrimPrefix(e.ts.URL, "http") + "/ws/chat?token=" + e.tok
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("连接 WS: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
ev := readWSFrame(t, conn)
if ev.Type != "history" {
t.Fatalf("第一个帧 = %q, want history(握手回放)", ev.Type)
}
return conn, ev
}
// replayVoiceOfID 从握手回放帧的 `data.messages` 里按 id 找到那一行,返回它的 `voice`
// 原始 JSON(键的在场性因此可判);该行没有 `voice` 键时返回 nil 与 false。
func replayVoiceOfID(t *testing.T, ev wsFrame, id string) (json.RawMessage, bool) {
t.Helper()
var data struct {
Messages []map[string]json.RawMessage `json:"messages"`
}
if err := json.Unmarshal(ev.Data, &data); err != nil {
t.Fatalf("解码握手回放帧 %s: %v", ev.Data, err)
}
for _, row := range data.Messages {
var gotID string
_ = json.Unmarshal(row["id"], &gotID)
if gotID != id {
continue
}
raw, ok := row["voice"]
return raw, ok
}
t.Fatalf("握手回放里没有 id=%q 的那一行(回放=%s)", id, ev.Data)
return nil, false
}
// storeOutboundCopy 用**真**音频落盘分支(`documents.Store.SaveAudio`)把一份合成产物
// 落到桥接器会去读的那个目录里,返回它签发的引用。名字/类型/大小都按生产口径算出来 ——
// 绝不手拼一个「看起来像」的文件名,否则本文件测的就不是那条读回路径。
func storeOutboundCopy(t *testing.T, uploads string, data []byte) documents.Ref {
t.Helper()
store := documents.NewStore(documents.StoreConfig{UploadsDir: uploads})
ref, err := store.SaveAudio(context.Background(), "voice.ogg", data)
if err != nil {
t.Fatalf("落盘出站语音副本: %v", err)
}
if ref.Name == "" || ref.MIME != outboundVoiceAudioMIME || ref.Size != int64(len(data)) {
t.Fatalf("落盘引用 = %+v, want 名字/%s/%d 字节", ref, outboundVoiceAudioMIME, len(data))
}
return ref
}
// nextPersistedFrameWithRole 读到**下一条指定 role 的持久化帧**(跳过其余帧)。出站用例
// 要的是**助手**那一行(本回合的用户行帧先到),所以按 role 过滤而不是按先后猜 ——
// 也因此不需要 sleep 或重试。
func nextPersistedFrameWithRole(t *testing.T, conn *websocket.Conn, role string) wsFrame {
t.Helper()
for i := 0; i < 10; i++ {
ev := readWSFrame(t, conn)
switch ev.Type {
case "message_persisted", "channel_persisted":
if messageRole(t, persistedMessage(t, ev)) == role {
return ev
}
}
}
t.Fatalf("读了 10 帧仍没等到 %s 行的持久化帧", role)
return wsFrame{}
}
// frameContent 取帧内消息的正文。
func frameContent(t *testing.T, msg map[string]json.RawMessage) string {
t.Helper()
var content string
if err := json.Unmarshal(msg["content"], &content); err != nil {
t.Fatalf("解析帧里的 content: %v", err)
}
return content
}
// TestIMOutboundVoiceFirstFrameCarriesVoice 钉住出站方向的首帧纪律(M44 的同一条口径):
// 合成成功的回复,其**第一帧**里就要有 `voice`,且与 REST 读面同一行逐字相等。
func TestIMOutboundVoiceFirstFrameCarriesVoice(t *testing.T) {
t.Parallel()
env := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true})
audio := []byte("OggS-outbound-voice-payload")
ref := storeOutboundCopy(t, env.uploads, audio)
env.chat.res = &loop.Result{
Text: outboundVoiceText,
Voice: &loop.OutboundVoice{
Requested: true, Name: ref.Name, Mime: ref.MIME, Size: ref.Size, DurationMs: 1500,
},
}
conn := env.dialOwnerWS(t)
env.bridge.process(context.Background(), directMessage())
ev := nextPersistedFrameWithRole(t, conn, history.RoleAssistant)
if ev.Type != "message_persisted" {
t.Fatalf("主人私聊的帧类型 = %q, want message_persisted", ev.Type)
}
msg := persistedMessage(t, ev)
raw, ok := msg["voice"]
if !ok {
t.Fatalf("🔴 出站语音的**第一帧**缺 data.message.voice: %s", ev.Data)
}
shape := canonicalVoice(t, raw)
for _, want := range []string{
`"source":"tts"`, `"status":"transcribed"`,
`"name":"` + ref.Name + `"`, `"mime":"` + outboundVoiceAudioMIME + `"`,
fmt.Sprintf(`"size":%d`, ref.Size), `"durationMs":1500`,
`"url":"/api/v1/media/` + ref.Name + `"`,
} {
if !strings.Contains(shape, want) {
t.Fatalf("帧里的 voice = %s, 缺 %s", shape, want)
}
}
// 与 REST 读面**同一行**逐字相等:同一份事实,不是两处各算。
id := messageIDOf(t, msg)
restVoice := voiceOfRowByID(t, fetchMessages(t, env.ts.URL, history.MainSessionID, env.tok), id)
if got, want := canonicalVoice(t, raw), canonicalVoice(t, restVoice); got != want {
t.Fatalf("帧里的 voice = %s, REST 读面同一行 = %s", got, want)
}
// 握手回放(隔天重新打开客户端):同一条消息在 `history` 帧里也带着这份出站语音。
_, replay := env.dialOwnerWSHistory(t)
replayRaw, ok := replayVoiceOfID(t, replay, id)
if !ok {
t.Fatalf("🔴 握手回放里这条出站语音缺 voice 键: %s", replay.Data)
}
if got, want := canonicalVoice(t, replayRaw), canonicalVoice(t, raw); got != want {
t.Fatalf("握手回放里的 voice = %s, 实时帧 = %s", got, want)
}
// 正文一字未改:语音是这条回复的另一种呈现,不是它的替代品。
if got := frameContent(t, msg); got != outboundVoiceStoredText {
t.Fatalf("帧里的正文 = %q, want %q", got, outboundVoiceStoredText)
}
}
// TestIMOutboundVoiceFallbackIsVisibleAsFailed 钉住降级的可见性(切片 M58 的第 3 条):
// `voice.status=failed` + `fallbackReason` 在**实时帧**与**历史回看**上都看得见,正文照发
// 原文,且渠道侧只收到纯文本气泡(绝不发一个空音频)。
func TestIMOutboundVoiceFallbackIsVisibleAsFailed(t *testing.T) {
t.Parallel()
env := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true})
env.chat.res = &loop.Result{
Text: outboundVoiceText,
Voice: &loop.OutboundVoice{Requested: true, FallbackReason: "tts_timeout"},
}
conn := env.dialOwnerWS(t)
env.bridge.process(context.Background(), directMessage())
ev := nextPersistedFrameWithRole(t, conn, history.RoleAssistant)
msg := persistedMessage(t, ev)
raw, ok := msg["voice"]
if !ok {
t.Fatalf("🔴 合成降级的**第一帧**缺 data.message.voice(客户端看不到「本来想用语音」): %s", ev.Data)
}
shape := canonicalVoice(t, raw)
for _, want := range []string{
`"source":"tts"`, `"status":"failed"`, `"fallbackReason":"tts_timeout"`,
} {
if !strings.Contains(shape, want) {
t.Fatalf("帧里的 voice = %s, 缺 %s", shape, want)
}
}
// 没有副本 ⇒ 副本四键**一起缺席**(不是空串、不是 0):客户端据此不画播放器。
for _, absent := range []string{`"name"`, `"mime"`, `"size"`, `"url"`} {
if strings.Contains(shape, absent) {
t.Fatalf("降级的 voice = %s, 不该有 %s", shape, absent)
}
}
// 历史回看(REST 读面)与帧上是同一份事实 —— 隔天再打开也看得到这条降级。
id := messageIDOf(t, msg)
restVoice := voiceOfRowByID(t, fetchMessages(t, env.ts.URL, history.MainSessionID, env.tok), id)
if got, want := canonicalVoice(t, raw), canonicalVoice(t, restVoice); got != want {
t.Fatalf("帧里的 voice = %s, REST 读面同一行 = %s", got, want)
}
// 握手回放(重新连 WS)同样带着这条降级:客户端的「本来想用语音」小标不只在实时帧上有。
_, replay := env.dialOwnerWSHistory(t)
replayRaw, ok := replayVoiceOfID(t, replay, id)
if !ok {
t.Fatalf("🔴 握手回放里这条降级回复缺 voice 键: %s", replay.Data)
}
if got, want := canonicalVoice(t, replayRaw), canonicalVoice(t, raw); got != want {
t.Fatalf("握手回放里的 voice = %s, 实时帧 = %s", got, want)
}
// 正文原样:降级只影响「有没有音频」,绝不影响说什么。
if got := frameContent(t, msg); got != outboundVoiceStoredText {
t.Fatalf("降级回复的正文 = %q, want %q(正文不该被降级改写)", got, outboundVoiceStoredText)
}
if len(env.platform.sends) != 1 {
t.Fatalf("渠道侧气泡数 = %d, want 1", len(env.platform.sends))
}
sent := env.platform.sends[0]
if sent.ContentType != adapter.ContentText {
t.Fatalf("降级气泡的 ContentType = %q, want %q", sent.ContentType, adapter.ContentText)
}
if len(sent.Media) != 0 {
t.Fatalf("降级气泡带上了 Media = %+v, want 空(绝不发空音频)", sent.Media)
}
}
// TestIMOutboundVoiceMountsMediaOnFirstBubbleOnly 钉住出站 Media:音频只挂**第一个**气泡,
// 字节就是盘上那份副本,第二个气泡仍是纯文本(同一条合成语音跟着每段各发一次,对方会收到
// N 份同样的音频)。
func TestIMOutboundVoiceMountsMediaOnFirstBubbleOnly(t *testing.T) {
t.Parallel()
env := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true})
audio := []byte("OggS-multi-paragraph-voice")
ref := storeOutboundCopy(t, env.uploads, audio)
env.chat.res = &loop.Result{
Text: outboundVoiceMultiText,
Voice: &loop.OutboundVoice{
Requested: true, Name: ref.Name, Mime: ref.MIME, Size: ref.Size,
},
}
env.bridge.process(context.Background(), directMessage())
if len(env.platform.sends) != 2 {
t.Fatalf("两段回复的气泡数 = %d, want 2(%+v)", len(env.platform.sends), env.platform.sends)
}
first := env.platform.sends[0]
if !strings.Contains(first.Content, "第一段话") {
t.Fatalf("第一个气泡的正文 = %q, want 第一段", first.Content)
}
if first.ContentType != adapter.ContentVoice {
t.Fatalf("第一个气泡的 ContentType = %q, want %q", first.ContentType, adapter.ContentVoice)
}
if len(first.Media) != 1 {
t.Fatalf("第一个气泡的 Media = %+v, want 恰好一条", first.Media)
}
md := first.Media[0]
if md.Kind != adapter.MediaVoice {
t.Fatalf("出站媒体 Kind = %q, want %q", md.Kind, adapter.MediaVoice)
}
if md.Name != ref.Name || md.MIMEType != outboundVoiceAudioMIME {
t.Fatalf("出站媒体 = %+v, want name=%s mime=%s", md, ref.Name, outboundVoiceAudioMIME)
}
if md.Size != int64(len(audio)) {
t.Fatalf("出站媒体 Size = %d, want %d", md.Size, len(audio))
}
if !bytes.Equal(md.Data, audio) {
t.Fatalf("出站媒体字节 = %q, want 盘上那份副本 %q", md.Data, audio)
}
// URL 刻意留空:`/api/v1/media/` 走 Bearer 鉴权,适配器没有(也不该有)owner 令牌。
if md.URL != "" {
t.Fatalf("出站媒体 URL = %q, want 空(适配器只拿字节)", md.URL)
}
second := env.platform.sends[1]
if !strings.Contains(second.Content, "第二段话") {
t.Fatalf("第二个气泡的正文 = %q, want 第二段", second.Content)
}
if second.ContentType != adapter.ContentText || len(second.Media) != 0 {
t.Fatalf("第二个气泡 = %+v, want 纯文本且无 Media", second)
}
}
// TestOutboundVoiceRefShapes 是 outbound_voice.go 的纯函数面:三种结局与「上游契约被破坏」
// 那一种都必须各归其位 —— 一个没有 mime 的 name 绝不写成 transcribed。
func TestOutboundVoiceRefShapes(t *testing.T) {
t.Parallel()
b, _, _, _ := newTestBridge(t)
const copyName = "0123456789abcdef0123456789abcdef.ogg"
cases := []struct {
name string
voice *loop.OutboundVoice
wantOK bool
wantRef string
wantSize int64
}{
{name: "nil 结论", voice: nil, wantOK: false},
{name: "未请求", voice: &loop.OutboundVoice{Requested: false}, wantOK: false},
{
name: "合成成功且副本完整",
voice: &loop.OutboundVoice{Requested: true, Name: copyName, Mime: outboundVoiceAudioMIME, Size: 8192, DurationMs: 1200},
wantOK: true, wantRef: `{"source":"tts","status":"transcribed","name":"` + copyName + `","mime":"audio/ogg","size":8192,"durationMs":1200}`, wantSize: 8192,
},
{
name: "缺 mime ⇒ 降级 copy_incomplete",
voice: &loop.OutboundVoice{Requested: true, Name: copyName, Size: 8192},
wantOK: true, wantRef: `{"source":"tts","status":"failed","fallbackReason":"copy_incomplete"}`, wantSize: 0,
},
{
name: "名字不是内容寻址名 ⇒ 降级 copy_incomplete",
voice: &loop.OutboundVoice{Requested: true, Name: "../../etc/passwd", Mime: outboundVoiceAudioMIME, Size: 10},
wantOK: true, wantRef: `{"source":"tts","status":"failed","fallbackReason":"copy_incomplete"}`, wantSize: 0,
},
{
name: "请求了但没有音频 ⇒ 降级原因来自上游",
voice: &loop.OutboundVoice{Requested: true, FallbackReason: "budget_exceeded"},
wantOK: true, wantRef: `{"source":"tts","status":"failed","fallbackReason":"budget_exceeded"}`, wantSize: 0,
},
{name: "请求了但既无副本又无原因", voice: &loop.OutboundVoice{Requested: true, CostMs: 90}, wantOK: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ref, ok := b.outboundVoiceRef(tc.voice)
if ok != tc.wantOK {
t.Fatalf("写列 = %v, want %v(ref=%+v)", ok, tc.wantOK, ref)
}
if !ok {
return
}
raw, err := voiceref.Encode(ref)
if err != nil {
t.Fatalf("编码 voice ref: %v", err)
}
if raw != tc.wantRef {
t.Fatalf("voice ref = %s, want %s", raw, tc.wantRef)
}
if ref.Size != tc.wantSize {
t.Fatalf("ref.Size = %d, want %d", ref.Size, tc.wantSize)
}
})
}
}
// voiceRefWithCopy 造一条「盘上应当有一份副本」的 voice 事实。字段按生产口径配齐
// (name/mime/size 同生共死,否则 Validate 会拒绝)—— 名字交给调用方给,本函数不猜。
func voiceRefWithCopy(name string, size int64) voiceref.Ref {
return voiceref.Ref{
Source: voiceref.SourceTTS, Status: voiceref.StatusTranscribed,
Name: name, MIME: outboundVoiceAudioMIME, Size: size,
}
}
// TestOutboundVoiceMediaSkipsUnreadableCopy 钉住「拿不到字节就不挂音频」:附件存储未接线、
// 或副本读不回来时,出站媒体一律不挂(正文照发文本,WARN 由 outbound_voice.go 记下)。
func TestOutboundVoiceMediaSkipsUnreadableCopy(t *testing.T) {
t.Parallel()
const copyName = "0123456789abcdef0123456789abcdef.ogg"
ref := voiceRefWithCopy(copyName, 1)
// ① 附件存储未接线(UploadsDir 与 DataDir 都空)⇒ inboundDocumentStore 返回 nil。
bare, _, _, _ := newTestBridge(t)
if md, ok := bare.outboundVoiceMedia(directMessage(), ref); ok {
t.Fatalf("存储未接线却挂上了出站媒体 = %+v", md)
}
// ② 目录配好了,但盘上没有这份副本 ⇒ 读不回来。
empty := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true})
if md, ok := empty.bridge.outboundVoiceMedia(directMessage(), ref); ok {
t.Fatalf("副本不存在却挂上了出站媒体 = %+v", md)
}
// ③ 副本真在盘上 ⇒ 挂得上,且字节逐字相等。
env := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true})
audio := []byte("OggS-readback")
stored := storeOutboundCopy(t, env.uploads, audio)
md, ok := env.bridge.outboundVoiceMedia(directMessage(), voiceRefWithCopy(stored.Name, stored.Size))
if !ok {
t.Fatalf("副本在盘上却没挂上出站媒体(name=%s)", stored.Name)
}
if !bytes.Equal(md.Data, audio) {
t.Fatalf("读回的字节 = %q, want %q", md.Data, audio)
}
}