Files
Cyrene_v2/internal/services/channelbridge/inbound_voice_copy_honesty_test.go
T
AskaEth ede00d9928 feat(voice): 语音消息落库 + 转码接入(切片 0020)
- 迁移 0020_chat_message_voice:按 design/24 §10 与契约 §7.2 已登记的 voice 形状建列/索引
- 语音回合进编排并落库;voice 引用与统计如实回填(转码/转写没成功就如实标未成功,不假装)
- 复用既有 internal/capability/transcode:非 wav(微信 AMR 等)先转码再送 ASR;失败可分类且不丢消息

⚠️ 部分完成(如实登记):生产端到端未成立(本片只到落库与转码接入),且**未经验真机**。
2026-10-06 09:04:06 +08:00

169 lines
6.6 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
// 本文件是本片(CODE-VOICE)**诚实路径**的用例:副本落不成、标记缺一块、消息被闸门拦下
// 这几种情形都必须**照实**表现 —— 报错分类进日志、库里不出现假副本、消息本身一个字不丢。
// 夹具与替身(voiceCopyEnv / fakeAudioStore / fakeVoiceTranscoder)在同目录的
// inbound_voice_copy_test.go 里,两半共用一套。
import (
"context"
"log/slog"
"strings"
"testing"
"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/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
mmvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
)
// TestInboundVoiceCopyRejectedWhenStoreRefIncomplete 钉住「同生共死」在**跨模块边界**上的
// 执行:存储侧若给了一份残缺引用(有 name 没有 mime),我们**不给副本**(那是客户端读不懂
// 的形状),并按 ERROR 报(这是不该发生的状态,绝不静默)。
func TestInboundVoiceCopyRejectedWhenStoreRefIncomplete(t *testing.T) {
t.Parallel()
store := &fakeAudioStore{incomplete: true}
env := newVoiceCopyEnv(t, transcodingSTT("语音内容"), newFakeVoiceTranscoder(), store)
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("OGGDATA")))
ref := env.voiceRefOf(t)
if ref.HasCopy() {
t.Fatalf("voice 事实 = %+v, want 无副本(残缺引用绝不进库)", ref)
}
if log := env.logs.String(); !strings.Contains(log, "ref is incomplete") {
t.Fatalf("缺少残缺引用的 ERROR:%q", log)
}
}
// TestInboundVoiceMarkFollowsTranscriptionStatus 钉住三态与正文的**同源性**:服务正常的
// 空结果 ⇒ `empty`;没拿到文本 ⇒ `failed`。两种情况下副本照样落(能不能回放与能不能转写
// 是两件事)。
func TestInboundVoiceMarkFollowsTranscriptionStatus(t *testing.T) {
t.Parallel()
cases := []struct {
name string
out mmvoice.STTOutcome
status voiceref.Status
}{
{
name: "识别成功但没人说话",
out: mmvoice.STTOutcome{Text: " ", Provider: "cloud"},
status: voiceref.StatusEmpty,
},
{
name: "上游失败",
out: mmvoice.STTOutcome{Text: "", Provider: "cloud", Cause: mmvoice.ErrAuthFailed},
status: voiceref.StatusFailed,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
store := &fakeAudioStore{}
env := newVoiceCopyEnv(t, &fakeVoiceSTT{reply: tc.out}, newFakeVoiceTranscoder(), store)
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("OGGDATA")))
ref := env.voiceRefOf(t)
if ref.Status != tc.status {
t.Fatalf("status = %q, want %q", ref.Status, tc.status)
}
if ref.Source != voiceref.SourceASR {
t.Fatalf("source = %q, want asr", ref.Source)
}
if !ref.HasCopy() {
t.Fatalf("voice 事实 = %+v, want 有副本(回放与转写无关)", ref)
}
})
}
}
// TestInboundVoiceMarkNotPersistedWhenHistoryLacksTheColumn 钉住「未实现该视图」的诚实:
// 历史存储还没有 voice 列写路径时,**如实报 WARN**(不假装写了),消息与正文照旧落库。
func TestInboundVoiceMarkNotPersistedWhenHistoryLacksTheColumn(t *testing.T) {
t.Parallel()
logs := &safeBuffer{}
log := slog.New(slog.NewTextHandler(logs, nil))
ag, err := agent.New("测试系统提示词", nil, nil, agent.WithModel(&stubModelAdapter{}, nil))
if err != nil {
t.Fatalf("构造 agent: %v", err)
}
// 只实现 HistoryStore + document 列视图(documentHistory 会给每条消息分配 id),
// 但**没有** UpdateMessageVoice。
hist := &documentHistory{captureHistory: &captureHistory{}}
store := &fakeAudioStore{}
b, err := New(Options{
Log: log, Loop: loop.New(loop.Options{Agent: ag, MaxRounds: 2, Log: log}),
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}},
Owner: testOwnerProfile("telegram", "100"),
History: hist,
VoiceSTT: transcodingSTT("语音内容"),
VoiceTranscoder: newFakeVoiceTranscoder(),
VoiceAudioStore: store,
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.Add(&fakeAdapter{name: "telegram"})
b.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("OGGDATA")))
if got := userContentIn(t, hist.captureHistory); got != "语音内容" {
t.Fatalf("落库正文 = %q, want 转写文本", got)
}
if l := logs.String(); !strings.Contains(l, "history store has no voice column path") {
t.Fatalf("缺少「无 voice 列写路径」的 WARN:%q", l)
}
}
// TestInboundVoiceCopyNotStoredForRejectedMessage 钉住位置纪律:副本落盘必须在**闸门之后**。
// 被黑白名单拦下的群消息**绝不在盘上留下任何文件**(与 IM 附件同一纪律),也因此不产生
// 任何转码 CPU 开销。
func TestInboundVoiceCopyNotStoredForRejectedMessage(t *testing.T) {
t.Parallel()
store := &fakeAudioStore{}
tr := newFakeVoiceTranscoder()
logs := &safeBuffer{}
log := slog.New(slog.NewTextHandler(logs, nil))
ag, err := agent.New("测试系统提示词", nil, nil, agent.WithModel(&stubModelAdapter{}, nil))
if err != nil {
t.Fatalf("构造 agent: %v", err)
}
hist := &voiceHistory{documentHistory: &documentHistory{captureHistory: &captureHistory{}}}
b, err := New(Options{
Log: log, Loop: loop.New(loop.Options{Agent: ag, MaxRounds: 2, Log: log}),
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1"}},
Owner: testOwnerProfile("telegram", "100"),
History: hist,
VoiceSTT: transcodingSTT("群里的语音"),
VoiceTranscoder: tr,
VoiceAudioStore: store,
})
if err != nil {
t.Fatalf("New: %v", err)
}
// 该群被显式拉黑 ⇒ 消息在闸门处被丢弃。
b.Add(&fakeAdapter{name: "telegram", cfg: map[string]any{"denyGroups": []string{"-20"}}})
msg := voiceMessage("v.amr", "audio/amr", []byte("#!AMR\nraw"))
msg.ChannelType = adapter.ChannelGroup
msg.ChannelID = "-20"
msg.GroupID = "-20"
msg.Sender = adapter.Actor{Role: adapter.RoleMember, IsGroup: true, GroupID: "-20"}
msg.SenderID = "9"
b.process(context.Background(), msg)
if store.callCount() != 0 {
t.Fatalf("音频存储调用次数 = %d, want 0(被拦下的消息绝不落文件)", store.callCount())
}
if tr.callCount() != 0 {
t.Fatalf("转码调用次数 = %d, want 0(闸门之后才谈转码)", tr.callCount())
}
if len(hist.records()) != 0 {
t.Fatalf("被拦下的消息不该落库:%+v", hist.records())
}
}