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()) } }