rtc_backend/hw_service_go/internal/handler/audio_sender_test.go
repair-agent c219ec2fcf
Some checks failed
Build and Deploy Backend / build-and-deploy (push) Failing after 4m28s
feat(hw-ws-service): 将 Go WebSocket 服务纳入 CI/CD 并通过 Traefik 统一入口
## 变更内容

### k8s/ingress.yaml
- 新增 /xiaozhi/v1/ 路径规则,将 WebSocket 流量路由到 hw-ws-svc:8888
- Traefik 最长前缀优先,/xiaozhi/v1/ 不影响 / 下的 Django 路由

### hw_service_go/k8s/service.yaml
- Service 类型由 LoadBalancer 改为 ClusterIP
- 移除阿里云 SLB 注解(通过 Traefik Ingress 统一暴露,不再需要独立公网 IP)

### hw_service_go/k8s/deployment.yaml
- 镜像地址改为 ${CI_REGISTRY_IMAGE}/hw-ws-service:latest 占位符
- CI/CD 部署时统一通过 sed 替换为华为云 SWR 实际地址

### hw_service_go/internal/server/server.go
- 新增 GET /xiaozhi/v1/healthz 接口,返回 {"status":"ok","active_connections":N}
- 用于部署后验证服务存活及当前连接数

### .gitea/workflows/deploy.yaml
- 新增 Build and Push HW WebSocket Service 步骤,构建并推送 hw_service_go 镜像
- 部署步骤新增 kubectl apply hw_service_go/k8s/deployment.yaml 和 service.yaml
- 新增 kubectl rollout restart deployment/hw-ws-service

### run.sh
- 本地同时启动 Django + hw_service_go 的开发脚本

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 17:16:26 +08:00

196 lines
5.0 KiB
Go

package handler_test
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/qy/hw-ws-service/internal/audio"
"github.com/qy/hw-ws-service/internal/connection"
"github.com/qy/hw-ws-service/internal/handler"
)
// makeWSPair creates a real WebSocket pair for testing.
// svrWS is the server side (used by our Connection), cliWS simulates the hardware.
func makeWSPair(t *testing.T) (svrWS *websocket.Conn, cliWS *websocket.Conn, cleanup func()) {
t.Helper()
ch := make(chan *websocket.Conn, 1)
done := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
up := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
c, err := up.Upgrade(w, r, nil)
if err != nil {
t.Logf("upgrade error: %v", err)
return
}
ch <- c
<-done
}))
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
cli, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
close(done)
srv.Close()
t.Fatalf("dial error: %v", err)
}
svr := <-ch
return svr, cli, func() {
close(done)
svr.Close()
cli.Close()
srv.Close()
}
}
// makeFrames creates n fake Opus frames of 4 bytes each.
func makeFrames(n int) [][]byte {
frames := make([][]byte, n)
for i := range frames {
frames[i] = []byte{byte(i), byte(i >> 8), 0x00, 0xff}
}
return frames
}
// TestSendOpusStream_Empty verifies that an empty frame list returns immediately.
func TestSendOpusStream_Empty(t *testing.T) {
svrWS, _, cleanup := makeWSPair(t)
defer cleanup()
conn := connection.New(svrWS, "dev1", "cli1")
abort := make(chan struct{})
done := make(chan struct{})
go func() {
handler.SendOpusStream(conn, nil, abort)
close(done)
}()
select {
case <-done:
case <-time.After(500 * time.Millisecond):
t.Fatal("SendOpusStream did not return immediately for empty frames")
}
}
// TestSendOpusStream_AllFramesSent verifies that all frames reach the client.
// Uses PreBufferCount+2 frames so both pre-buffer and timed paths are exercised.
func TestSendOpusStream_AllFramesSent(t *testing.T) {
svrWS, cliWS, cleanup := makeWSPair(t)
defer cleanup()
conn := connection.New(svrWS, "dev1", "cli1")
totalFrames := audio.PreBufferCount + 2 // 3 pre-buffer + 2 timed
frames := makeFrames(totalFrames)
abort := make(chan struct{})
senderDone := make(chan struct{})
go func() {
handler.SendOpusStream(conn, frames, abort)
close(senderDone)
}()
// Read all frames from the client side (simulates hardware receiving)
received := 0
cliWS.SetReadDeadline(time.Now().Add(10 * time.Second))
for received < totalFrames {
msgType, _, err := cliWS.ReadMessage()
if err != nil {
t.Fatalf("client read error after %d frames: %v", received, err)
}
if msgType == websocket.BinaryMessage {
received++
}
}
select {
case <-senderDone:
case <-time.After(2 * time.Second):
t.Fatal("SendOpusStream did not finish after all frames were sent")
}
if received != totalFrames {
t.Errorf("received %d frames, want %d", received, totalFrames)
}
}
// TestSendOpusStream_Abort verifies that closing abortCh stops streaming early.
func TestSendOpusStream_Abort(t *testing.T) {
svrWS, _, cleanup := makeWSPair(t)
defer cleanup()
conn := connection.New(svrWS, "dev1", "cli1")
// Many frames so timing control is active (pre-buffer finishes quickly,
// then the time.After select can receive the abort signal)
frames := makeFrames(100)
abort := make(chan struct{})
senderDone := make(chan struct{})
go func() {
handler.SendOpusStream(conn, frames, abort)
close(senderDone)
}()
// Close abort after pre-buffer has had time to finish but before timed frames complete
time.Sleep(20 * time.Millisecond)
close(abort)
select {
case <-senderDone:
// SendOpusStream returned early — correct behaviour
case <-time.After(2 * time.Second):
t.Fatal("SendOpusStream did not abort within 2s after closing abortCh")
}
}
// TestSendOpusStream_PreBufferOnly verifies frames <= PreBufferCount are all sent
// without entering the timed loop (should finish nearly instantly).
func TestSendOpusStream_PreBufferOnly(t *testing.T) {
svrWS, cliWS, cleanup := makeWSPair(t)
defer cleanup()
conn := connection.New(svrWS, "dev1", "cli1")
frames := makeFrames(audio.PreBufferCount) // exactly the pre-buffer count
abort := make(chan struct{})
start := time.Now()
senderDone := make(chan struct{})
go func() {
handler.SendOpusStream(conn, frames, abort)
close(senderDone)
}()
received := 0
cliWS.SetReadDeadline(time.Now().Add(3 * time.Second))
for received < len(frames) {
msgType, _, err := cliWS.ReadMessage()
if err != nil {
t.Fatalf("read error: %v", err)
}
if msgType == websocket.BinaryMessage {
received++
}
}
select {
case <-senderDone:
case <-time.After(time.Second):
t.Fatal("sender did not finish")
}
elapsed := time.Since(start)
// Pre-buffer frames should not wait on the timer; allow 200ms for overhead
if elapsed > 200*time.Millisecond {
t.Errorf("pre-buffer-only send took too long: %v (want < 200ms)", elapsed)
}
}