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

178 lines
4.5 KiB
Go

package connection_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/qy/hw-ws-service/internal/connection"
)
// makeWSPair creates a real WebSocket pair for testing.
// Returns the server-side conn (what our code uses) and the client-side conn
// (what simulates the hardware). Call cleanup() after the test.
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 // hold handler open until cleanup
}))
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()
}
}
func TestConnection_InitialState(t *testing.T) {
svrWS, _, cleanup := makeWSPair(t)
defer cleanup()
conn := connection.New(svrWS, "AA:BB:CC:DD:EE:FF", "client-uuid")
if conn.DeviceID != "AA:BB:CC:DD:EE:FF" {
t.Errorf("DeviceID = %q", conn.DeviceID)
}
if conn.ClientID != "client-uuid" {
t.Errorf("ClientID = %q", conn.ClientID)
}
if conn.IsPlaying() {
t.Error("new connection should not be playing")
}
}
func TestConnection_StartStopPlayback(t *testing.T) {
svrWS, _, cleanup := makeWSPair(t)
defer cleanup()
conn := connection.New(svrWS, "dev1", "cli1")
ch := conn.StartPlayback()
if ch == nil {
t.Fatal("StartPlayback should return a non-nil channel")
}
if !conn.IsPlaying() {
t.Error("IsPlaying should be true after StartPlayback")
}
// Channel must still be open
select {
case <-ch:
t.Error("abortCh should not be closed yet")
default:
}
conn.StopPlayback()
if conn.IsPlaying() {
t.Error("IsPlaying should be false after StopPlayback")
}
}
// TestConnection_StartPlayback_AbortsOld verifies that calling StartPlayback a second
// time closes the previous abort channel, stopping any in-progress streaming.
func TestConnection_StartPlayback_AbortsOld(t *testing.T) {
svrWS, _, cleanup := makeWSPair(t)
defer cleanup()
conn := connection.New(svrWS, "dev1", "cli1")
ch1 := conn.StartPlayback()
ch2 := conn.StartPlayback() // should close ch1
// ch1 must be closed now
select {
case <-ch1:
// expected
case <-time.After(100 * time.Millisecond):
t.Error("first abortCh should be closed by second StartPlayback call")
}
// ch2 must still be open
select {
case <-ch2:
t.Error("second abortCh should not be closed yet")
default:
}
}
// TestConnection_SendJSON verifies JSON messages are delivered to the client.
func TestConnection_SendJSON(t *testing.T) {
svrWS, cliWS, cleanup := makeWSPair(t)
defer cleanup()
conn := connection.New(svrWS, "dev1", "cli1")
if err := conn.SendJSON(map[string]string{"type": "tts", "state": "start"}); err != nil {
t.Fatalf("SendJSON error: %v", err)
}
cliWS.SetReadDeadline(time.Now().Add(2 * time.Second))
msgType, data, err := cliWS.ReadMessage()
if err != nil {
t.Fatalf("client read error: %v", err)
}
if msgType != websocket.TextMessage {
t.Errorf("message type = %d, want TextMessage (%d)", msgType, websocket.TextMessage)
}
var got map[string]string
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("json.Unmarshal error: %v", err)
}
if got["type"] != "tts" {
t.Errorf("type = %q, want %q", got["type"], "tts")
}
if got["state"] != "start" {
t.Errorf("state = %q, want %q", got["state"], "start")
}
}
// TestConnection_SendBinary verifies binary (Opus) frames are delivered to the client.
func TestConnection_SendBinary(t *testing.T) {
svrWS, cliWS, cleanup := makeWSPair(t)
defer cleanup()
conn := connection.New(svrWS, "dev1", "cli1")
payload := []byte{0x01, 0x02, 0x03, 0x04}
if err := conn.SendBinary(payload); err != nil {
t.Fatalf("SendBinary error: %v", err)
}
cliWS.SetReadDeadline(time.Now().Add(2 * time.Second))
msgType, data, err := cliWS.ReadMessage()
if err != nil {
t.Fatalf("client read error: %v", err)
}
if msgType != websocket.BinaryMessage {
t.Errorf("message type = %d, want BinaryMessage (%d)", msgType, websocket.BinaryMessage)
}
if string(data) != string(payload) {
t.Errorf("payload = %v, want %v", data, payload)
}
}