- voice.Ref 增 SourceTTS 与 FallbackReason,Validate/IsZero/validSource 同步 - 出站成功:INSERT -> 补写 voice 列 -> notify(首帧即带 voice) - 出站降级:source=tts + status=failed + fallbackReason,正文原样 - 首条气泡挂出站 Media(新开条目,不复制 target.Media) - gateway historyVoice 投影 fallbackReason - 新增 outbound_voice.go 与整链 e2e 测试(含变异打红验证时序)
255 lines
9.4 KiB
Go
255 lines
9.4 KiB
Go
package voice
|
||
|
||
import (
|
||
"errors"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// TestEncodeDecodeRoundTrip 覆盖落库形状的核心往返:编码 → 解码逐字段还原。
|
||
// 三种真实形态各一条:有副本的 asr 真转写、无副本的 platform 转写(微信按既有裁决
|
||
// 不取字节)、无副本的失败(正文是那行中文诚实提示)。
|
||
func TestEncodeDecodeRoundTrip(t *testing.T) {
|
||
t.Parallel()
|
||
cases := []struct {
|
||
name string
|
||
ref Ref
|
||
}{
|
||
{"asr 真转写 + 可播副本", Ref{
|
||
Source: SourceASR, Status: StatusTranscribed,
|
||
Name: "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg", MIME: "audio/ogg",
|
||
Size: 12345, DurationMs: 3200,
|
||
}},
|
||
{"platform 自带转写 + 无副本", Ref{
|
||
Source: SourcePlatform, Status: StatusTranscribed,
|
||
}},
|
||
{"转写失败 + 无副本(但原件仍可能落盘)", Ref{
|
||
Source: SourceASR, Status: StatusFailed, Name: "ffeeddccbbaa99887766554433221100.wav",
|
||
MIME: "audio/wav", Size: 160044,
|
||
}},
|
||
{"转写为空 + 有副本", Ref{
|
||
Source: SourceASR, Status: StatusEmpty, Name: "0123456789abcdef0123456789abcdef.mp3",
|
||
MIME: "audio/mpeg", Size: 4321,
|
||
}},
|
||
{"出站合成成功 + 可播副本", Ref{
|
||
Source: SourceTTS, Status: StatusTranscribed,
|
||
Name: "fedcba9876543210fedcba9876543210.ogg", MIME: "audio/ogg",
|
||
Size: 8192, DurationMs: 1500,
|
||
}},
|
||
{"出站合成降级(正文照发文本)", Ref{
|
||
Source: SourceTTS, Status: StatusFailed, FallbackReason: "budget_exceeded",
|
||
}},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
raw, err := Encode(tc.ref)
|
||
if err != nil {
|
||
t.Fatalf("Encode: %v", err)
|
||
}
|
||
got, err := Decode(raw)
|
||
if err != nil {
|
||
t.Fatalf("Decode(%q): %v", raw, err)
|
||
}
|
||
if got != tc.ref {
|
||
t.Fatalf("往返 = %+v, want %+v", got, tc.ref)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestEncodeJSONShapeMatchesDesign 把**字面 JSON** 钉在设计上(design/24 §10.4 的示例):
|
||
// 字段顺序、键名、以及 durationMs 缺席(0 ⇒ omitempty)都必须逐字如此。
|
||
//
|
||
// 为什么需要字面量钉桩:这份 JSON 会落进 chat_messages.voice 列,是客户端与历史 DTO
|
||
// 共同的形状。字段顺序/键名漂移不会让任何单测变红(往返测试两侧同时漂移仍然相等),
|
||
// 只有把**值**钉死才能挡住(design/24 §9.7 的同一条测试质量教训)。
|
||
func TestEncodeJSONShapeMatchesDesign(t *testing.T) {
|
||
t.Parallel()
|
||
raw, err := Encode(Ref{
|
||
Source: SourceASR, Status: StatusTranscribed,
|
||
Name: "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg", MIME: "audio/ogg",
|
||
Size: 12345, DurationMs: 3200,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Encode: %v", err)
|
||
}
|
||
want := `{"source":"asr","status":"transcribed","name":"a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg",` +
|
||
`"mime":"audio/ogg","size":12345,"durationMs":3200}`
|
||
if raw != want {
|
||
t.Fatalf("落库 JSON = %s\n want %s", raw, want)
|
||
}
|
||
|
||
// 无副本时 name/mime/size 三个键**一起缺席**(同生共死),durationMs 缺席(= 0)。
|
||
raw, err = Encode(Ref{Source: SourcePlatform, Status: StatusTranscribed})
|
||
if err != nil {
|
||
t.Fatalf("Encode(no copy): %v", err)
|
||
}
|
||
if raw != `{"source":"platform","status":"transcribed"}` {
|
||
t.Fatalf("无副本 JSON = %s", raw)
|
||
}
|
||
}
|
||
|
||
// TestEncodeZeroRefIsEmptyColumn 钉住「不是语音消息」的落库形状:零值编码成**空串**,
|
||
// 于是列里空串 ⇒ DTO 里 voice 键缺席(不是 {} 也不是 null)。
|
||
func TestEncodeZeroRefIsEmptyColumn(t *testing.T) {
|
||
t.Parallel()
|
||
raw, err := Encode(Ref{})
|
||
if err != nil {
|
||
t.Fatalf("Encode(零值): %v", err)
|
||
}
|
||
if raw != "" {
|
||
t.Fatalf("零值编码 = %q, want 空串", raw)
|
||
}
|
||
got, err := Decode("")
|
||
if err != nil {
|
||
t.Fatalf("Decode(\"\"): %v", err)
|
||
}
|
||
if !got.IsZero() {
|
||
t.Fatalf("Decode(\"\") = %+v, want 零值", got)
|
||
}
|
||
}
|
||
|
||
// TestEncodeRejectsBrokenRef 覆盖「同生共死」与闭集两条纪律的**写侧**执行:坏形状必须在
|
||
// 写库前报错,绝不悄悄补字段(那会让库里记着的东西与客户端读到的对不上)。
|
||
func TestEncodeRejectsBrokenRef(t *testing.T) {
|
||
t.Parallel()
|
||
cases := []struct {
|
||
name string
|
||
ref Ref
|
||
}{
|
||
{"来源不在闭集", Ref{Source: "cloud", Status: StatusTranscribed}},
|
||
{"状态不在闭集", Ref{Source: SourceASR, Status: "ok"}},
|
||
{"来源为空", Ref{Status: StatusTranscribed}},
|
||
{"状态为空", Ref{Source: SourceASR}},
|
||
{"有 mime 无 name", Ref{Source: SourceASR, Status: StatusTranscribed, MIME: "audio/ogg"}},
|
||
{"有 size 无 name", Ref{Source: SourceASR, Status: StatusTranscribed, Size: 12}},
|
||
{"有 name 无 mime", Ref{Source: SourceASR, Status: StatusTranscribed, Name: "x.ogg", Size: 12}},
|
||
{"有 name 无 size", Ref{Source: SourceASR, Status: StatusTranscribed, Name: "x.ogg", MIME: "audio/ogg"}},
|
||
{"时长为负", Ref{Source: SourceASR, Status: StatusTranscribed, DurationMs: -1}},
|
||
{"降级原因挂在成功态", Ref{
|
||
Source: SourceTTS, Status: StatusTranscribed,
|
||
Name: "x.ogg", MIME: "audio/ogg", Size: 12, FallbackReason: "budget_exceeded",
|
||
}},
|
||
{"只有降级原因", Ref{FallbackReason: "budget_exceeded"}},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
raw, err := Encode(tc.ref)
|
||
if err == nil {
|
||
t.Fatalf("Encode(%+v) = %q, want 错误(坏形状绝不入库)", tc.ref, raw)
|
||
}
|
||
if !errors.Is(err, ErrBadRef) {
|
||
t.Fatalf("错误 = %v, want ErrBadRef", err)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestDecodeRejectsGarbage 覆盖读侧:坏列内容**报错**而不是当成「不是语音」。
|
||
// 静默当成空 = 客户端永远看不到这条语音,而库里明明有引用(正是要消灭的静默)。
|
||
func TestDecodeRejectsGarbage(t *testing.T) {
|
||
t.Parallel()
|
||
cases := []struct {
|
||
name string
|
||
raw string
|
||
}{
|
||
{"截断的 JSON", `{"source":"asr"`},
|
||
{"数组形状(本列从诞生就是单对象)", `[{"source":"asr","status":"transcribed"}]`},
|
||
{"字面量 null", `null`},
|
||
{"空对象(两个闭集字段都缺)", `{}`},
|
||
{"闭集外的来源", `{"source":"cloud","status":"transcribed"}`},
|
||
{"闭集外的状态", `{"source":"asr","status":"done"}`},
|
||
{"副本字段不同生共死", `{"source":"asr","status":"transcribed","size":9}`},
|
||
{"降级原因挂在成功态", `{"source":"tts","status":"transcribed","fallbackReason":"x"}`},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
got, err := Decode(tc.raw)
|
||
if err == nil {
|
||
t.Fatalf("Decode(%s) = %+v, want 错误", tc.raw, got)
|
||
}
|
||
if !errors.Is(err, ErrBadRef) {
|
||
t.Fatalf("错误 = %v, want ErrBadRef", err)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestOutboundTTSJSONShape 把**出站**两种落库形状钉死(design/25 §7.1):
|
||
// 成功 = source/status/name/mime/size(+ 可选 durationMs);降级 = source/status +
|
||
// fallbackReason,**没有 name/url**(正文照发文本,客户端据此只挂一个小标)。
|
||
func TestOutboundTTSJSONShape(t *testing.T) {
|
||
t.Parallel()
|
||
raw, err := Encode(Ref{
|
||
Source: SourceTTS, Status: StatusTranscribed,
|
||
Name: "fedcba9876543210fedcba9876543210.ogg", MIME: "audio/ogg", Size: 8192,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Encode(成功): %v", err)
|
||
}
|
||
want := `{"source":"tts","status":"transcribed","name":"fedcba9876543210fedcba9876543210.ogg",` +
|
||
`"mime":"audio/ogg","size":8192}`
|
||
if raw != want {
|
||
t.Fatalf("出站成功 JSON = %s\n want %s", raw, want)
|
||
}
|
||
|
||
raw, err = Encode(Ref{Source: SourceTTS, Status: StatusFailed, FallbackReason: "budget_exceeded"})
|
||
if err != nil {
|
||
t.Fatalf("Encode(降级): %v", err)
|
||
}
|
||
if want := `{"source":"tts","status":"failed","fallbackReason":"budget_exceeded"}`; raw != want {
|
||
t.Fatalf("出站降级 JSON = %s\n want %s", raw, want)
|
||
}
|
||
}
|
||
|
||
// TestRefIsVoiceAndHasCopy 钉住两个判据的边界:
|
||
// IsVoice = 来源与状态都在闭集内(= 这条消息来自一条语音);
|
||
// HasCopy = 有 name(name/mime/size 同生共死,因此 name 是代表)。
|
||
func TestRefIsVoiceAndHasCopy(t *testing.T) {
|
||
t.Parallel()
|
||
cases := []struct {
|
||
name string
|
||
ref Ref
|
||
isVoice bool
|
||
hasCopy bool
|
||
}{
|
||
{"零值(不是语音消息)", Ref{}, false, false},
|
||
{"平台转写无副本", Ref{Source: SourcePlatform, Status: StatusTranscribed}, true, false},
|
||
{"有副本", Ref{Source: SourceASR, Status: StatusFailed, Name: "n.wav", MIME: "audio/wav", Size: 4}, true, true},
|
||
{"出站合成成功", Ref{Source: SourceTTS, Status: StatusTranscribed, Name: "n.ogg", MIME: "audio/ogg", Size: 4}, true, true},
|
||
{"出站降级(算语音消息,但无可播副本)", Ref{Source: SourceTTS, Status: StatusFailed, FallbackReason: "no_capability"}, true, false},
|
||
{"只有来源(半截,不算语音)", Ref{Source: SourceASR}, false, false},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
if got := tc.ref.IsVoice(); got != tc.isVoice {
|
||
t.Fatalf("IsVoice = %v, want %v", got, tc.isVoice)
|
||
}
|
||
if got := tc.ref.HasCopy(); got != tc.hasCopy {
|
||
t.Fatalf("HasCopy = %v, want %v", got, tc.hasCopy)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestDecodeTrimsWhitespace 钉住宽容度只到「首尾空白」为止:列里带换行的内容(人手工
|
||
// 改过、或将来某处写入时多了换行)仍可读回,坏内容仍然报错。
|
||
func TestDecodeTrimsWhitespace(t *testing.T) {
|
||
t.Parallel()
|
||
raw := "\n {\"source\":\"asr\",\"status\":\"transcribed\"}\t \n"
|
||
got, err := Decode(raw)
|
||
if err != nil {
|
||
t.Fatalf("Decode(%q): %v", raw, err)
|
||
}
|
||
if got.Source != SourceASR || got.Status != StatusTranscribed {
|
||
t.Fatalf("Decode = %+v", got)
|
||
}
|
||
if strings.TrimSpace(raw) == "" {
|
||
t.Fatal("用例前提坏了:raw 不该是空串")
|
||
}
|
||
}
|