diff --git a/cmd/protocolgen/golden_test.go b/cmd/protocolgen/golden_test.go
new file mode 100644
index 0000000..0b60a71
--- /dev/null
+++ b/cmd/protocolgen/golden_test.go
@@ -0,0 +1,76 @@
+package main
+
+import (
+ "bytes"
+ "flag"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+var update = flag.Bool("update", false, "用生成结果覆盖 testdata/sample 中的期望文件")
+
+var sampleDir = filepath.Join("testdata", "sample")
+
+func sampleOutputs(t *testing.T) []output {
+ t.Helper()
+ outputs, err := generateFrom(filepath.Join(sampleDir, "protocol", "idl.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return outputs
+}
+
+func TestGenerateMatchesGolden(t *testing.T) {
+ generated := map[string]bool{"protocol/idl.json": true}
+ for _, out := range sampleOutputs(t) {
+ generated[out.path] = true
+ path := filepath.Join(sampleDir, filepath.FromSlash(out.path))
+ if *update {
+ if err := writeOutput(path, out.data); err != nil {
+ t.Fatal(err)
+ }
+ continue
+ }
+ want, err := os.ReadFile(path)
+ if err != nil {
+ t.Errorf("%s 缺少期望文件:%v", out.path, err)
+ continue
+ }
+ if !bytes.Equal(out.data, want) {
+ t.Errorf("%s 与期望不一致,%s", out.path, firstDiff(out.data, want))
+ }
+ }
+ err := filepath.WalkDir(sampleDir, func(path string, e fs.DirEntry, err error) error {
+ if err != nil || e.IsDir() {
+ return err
+ }
+ rel, err := filepath.Rel(sampleDir, path)
+ if err == nil && !generated[filepath.ToSlash(rel)] {
+ t.Errorf("期望文件 %s 没有对应的生成物", filepath.ToSlash(rel))
+ }
+ return err
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func firstDiff(got, want []byte) string {
+ g, w := bytes.Split(got, []byte("\n")), bytes.Split(want, []byte("\n"))
+ for i := range max(len(g), len(w)) {
+ var a, b []byte
+ if i < len(g) {
+ a = g[i]
+ }
+ if i < len(w) {
+ b = w[i]
+ }
+ if !bytes.Equal(a, b) {
+ return fmt.Sprintf("第 %d 行:\n生成 %q\n期望 %q", i+1, a, b)
+ }
+ }
+ return "只有行尾不同"
+}
diff --git a/cmd/protocolgen/idl_test.go b/cmd/protocolgen/idl_test.go
new file mode 100644
index 0000000..4054032
--- /dev/null
+++ b/cmd/protocolgen/idl_test.go
@@ -0,0 +1,613 @@
+package main
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "reflect"
+ "regexp"
+ "slices"
+ "strings"
+ "testing"
+)
+
+func TestProtocolVersionIgnoresKeyOrderAndWhitespace(t *testing.T) {
+ compact, err := protocolVersion([]byte(`{"a":{"x":1.0,"y":"中"},"b":[1,2]}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if compact != "ce476f5a8616ebf9" {
+ t.Errorf("版本 = %s,应为规范化文本 SHA-256 的前 16 个十六进制字符", compact)
+ }
+ reordered, err := protocolVersion([]byte("{\n \"b\": [1, 2],\n \"a\": {\"y\": \"\\u4e2d\", \"x\": 1.0}\n}\n"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if reordered != compact {
+ t.Errorf("只改了键的顺序和空白,版本却从 %s 变成了 %s", compact, reordered)
+ }
+ swapped, err := protocolVersion([]byte(`{"a":{"x":1.0,"y":"中"},"b":[2,1]}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if swapped == compact {
+ t.Error("数组元素的顺序变了,版本应随之改变")
+ }
+}
+
+func object(properties map[string]any, required ...string) map[string]any {
+ s := map[string]any{"type": "object", "additionalProperties": false, "properties": properties}
+ if len(required) > 0 {
+ s["required"] = required
+ }
+ return s
+}
+
+func ref(name string) map[string]any {
+ return map[string]any{"$ref": "#/$defs/" + name}
+}
+
+func minimalIDL() map[string]any {
+ return map[string]any{
+ "title": "Minimal",
+ "$defs": map[string]any{
+ "Empty": object(map[string]any{}),
+ "ErrorInfo": object(map[string]any{"message": map[string]any{"type": "string"}}, "message"),
+ },
+ "x-ws": map[string]any{
+ "events": []any{map[string]any{"type": "error", "data": ref("ErrorInfo")}},
+ },
+ }
+}
+
+func defs(d map[string]any) map[string]any {
+ return d["$defs"].(map[string]any)
+}
+
+func roomRequest(request map[string]any) map[string]any {
+ return map[string]any{"type": "room.sync", "http": map[string]any{"method": "POST", "path": "/api/room/sync"}, "request": request, "response": ref("Empty")}
+}
+
+func roomBinding(method, path string, properties map[string]any, required ...string) map[string]any {
+ m := roomRequest(object(properties, required...))
+ m["http"] = map[string]any{"method": method, "path": path}
+ return m
+}
+
+func bodylessEndpoint(name, method, path string) map[string]any {
+ return map[string]any{"name": name, "method": method, "path": path, "auth": "none", "response": ref("Empty")}
+}
+
+func str(keywords ...any) map[string]any {
+ s := map[string]any{"type": "string"}
+ for i := 0; i+1 < len(keywords); i += 2 {
+ s[keywords[i].(string)] = keywords[i+1]
+ }
+ return s
+}
+
+func condition(value string, then map[string]any) map[string]any {
+ return map[string]any{
+ "if": map[string]any{"properties": map[string]any{"kind": map[string]any{"const": value}}, "required": []string{"kind"}},
+ "then": then,
+ }
+}
+
+func conditional(then map[string]any) map[string]any {
+ s := object(map[string]any{"kind": ref("Kind"), "id": str()}, "kind", "id")
+ s["allOf"] = []any{condition("link", then)}
+ return s
+}
+
+func TestAllOfConditions(t *testing.T) {
+ d := minimalIDL()
+ defs(d)["Kind"] = map[string]any{"type": "string", "enum": []string{"link", "bilibili"}}
+ defs(d)["Link"] = str("x-maxBytes", 2048)
+ defs(d)["Track"] = map[string]any{
+ "type": "object", "additionalProperties": false,
+ "properties": map[string]any{"source": ref("Kind"), "id": str()},
+ "required": []string{"source", "id"},
+ "allOf": []any{
+ map[string]any{"if": map[string]any{"properties": map[string]any{"source": map[string]any{"const": "link"}}, "required": []string{"source"}}, "then": map[string]any{"properties": map[string]any{"id": ref("Link")}}},
+ map[string]any{"if": map[string]any{"properties": map[string]any{"source": map[string]any{"const": "bilibili"}}, "required": []string{"source"}}, "then": map[string]any{"properties": map[string]any{"id": map[string]any{"pattern": "^BV"}}}},
+ },
+ }
+ data, err := json.Marshal(d)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := parseIDL(data); err != nil {
+ t.Fatalf("两个条件 = %v", err)
+ }
+}
+
+func withKind(d map[string]any) {
+ defs(d)["Kind"] = map[string]any{"type": "string", "enum": []string{"link", "netease"}}
+}
+
+func TestParseIDLRejects(t *testing.T) {
+ tests := []struct {
+ name string
+ change func(d map[string]any)
+ want string
+ }{
+ {"拼错的关键字", func(d map[string]any) {
+ defs(d)["Empty"].(map[string]any)["minLenght"] = 1
+ }, `unknown field "minLenght"`},
+ {"引用不存在的类型", func(d map[string]any) {
+ defs(d)["ErrorInfo"] = object(map[string]any{"message": ref("Missing")}, "message")
+ }, "引用的类型 #/$defs/Missing 不存在"},
+ {"对象没有写 additionalProperties", func(d map[string]any) {
+ delete(defs(d)["Empty"].(map[string]any), "additionalProperties")
+ }, `"additionalProperties": false`},
+ {"required 写了不存在的字段", func(d map[string]any) {
+ defs(d)["ErrorInfo"] = object(map[string]any{"message": map[string]any{"type": "string"}}, "message", "code")
+ }, "required 中的 code 不在 properties 里"},
+ {"请求中有数组", func(d map[string]any) {
+ d["x-requests"] = []any{roomRequest(object(map[string]any{
+ "ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
+ }, "ids"))}
+ }, "数组只能出现在响应和事件中"},
+ {"请求引用的类型中有数组", func(d map[string]any) {
+ defs(d)["Batch"] = object(map[string]any{
+ "ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
+ }, "ids")
+ d["x-requests"] = []any{roomRequest(object(map[string]any{"batch": ref("Batch")}, "batch"))}
+ }, "$defs.Batch.properties.ids:数组只能出现在响应和事件中"},
+ {"生成的类型名重名", func(d map[string]any) {
+ defs(d)["RoomSyncRequest"] = object(map[string]any{})
+ d["x-requests"] = []any{roomRequest(object(map[string]any{}))}
+ }, "生成的类型名 RoomSyncRequest 与其他类型重复"},
+ {"oneOf 分支没有限定字段", func(d map[string]any) {
+ d["x-requests"] = []any{roomRequest(map[string]any{
+ "type": "object", "additionalProperties": false,
+ "properties": map[string]any{"a": map[string]any{"type": "string"}, "b": map[string]any{"type": "string"}},
+ "oneOf": []any{map[string]any{"required": []string{"a"}}, map[string]any{"required": []string{"b"}}},
+ })}
+ }, "分支应由 required、properties"},
+ {"if 判断的不是枚举字段", func(d map[string]any) {
+ s := object(map[string]any{"kind": map[string]any{"type": "string"}, "id": map[string]any{"type": "string"}}, "kind", "id")
+ s["allOf"] = []any{condition("link", map[string]any{"properties": map[string]any{"id": map[string]any{"pattern": "^h"}}})}
+ defs(d)["Track"] = s
+ }, "只支持按必填的枚举字段取值判断"},
+ {"GET 接口带请求体", func(d map[string]any) {
+ d["x-http"] = []any{map[string]any{
+ "name": "listRooms", "method": "GET", "path": "/api/rooms", "auth": "none",
+ "request": object(map[string]any{}), "response": ref("Empty"),
+ }}
+ }, "GET 请求没有请求体"},
+ {"只有路径参数名不同的重复路由", func(d map[string]any) {
+ d["x-http"] = []any{bodylessEndpoint("getRoom", "GET", "/api/rooms/{ref}"), bodylessEndpoint("findRoom", "GET", "/api/rooms/{id}")}
+ }, "GET /api/rooms/{id} 与接口 getRoom 的路由重复"},
+ {"接口放在房间内请求的路径下", func(d map[string]any) {
+ d["x-http"] = []any{bodylessEndpoint("listQueue", "GET", "/api/room/queue")}
+ }, `x-http[0]:path "/api/room/queue" 是 /api/room 或在它之下`},
+ {"接口的路径是 /api/room", func(d map[string]any) {
+ d["x-http"] = []any{bodylessEndpoint("getRoomState", "GET", "/api/room")}
+ }, `x-http[0]:path "/api/room" 是 /api/room 或在它之下`},
+ {"房间内请求没有 http", func(d map[string]any) {
+ m := roomRequest(object(map[string]any{}))
+ delete(m, "http")
+ d["x-requests"] = []any{m}
+ }, "x-requests[0]:缺少 http"},
+ {"连接请求带了 http", func(d map[string]any) {
+ d["x-ws"].(map[string]any)["requests"] = []any{map[string]any{
+ "type": "ping", "http": map[string]any{"method": "POST", "path": "/api/room/ping"},
+ "request": object(map[string]any{}), "response": ref("Empty"),
+ }}
+ }, "x-ws.requests[0]:只在 WebSocket 上使用的请求没有 http"},
+ {"房间内请求的路径不在 /api/room 之下", func(d map[string]any) {
+ d["x-requests"] = []any{roomBinding("GET", "/api/rooms/sync", map[string]any{})}
+ }, `x-requests[0].http:path "/api/rooms/sync" 应是 /api/room 或在它之下`},
+ {"房间内请求写明 200 的状态码", func(d map[string]any) {
+ m := roomBinding("GET", "/api/room", map[string]any{})
+ m["http"].(map[string]any)["status"] = 200
+ d["x-requests"] = []any{m}
+ }, "x-requests[0].http:status 只能是 201 或 204,缺省为 200"},
+ {"与事件流的路由重复", func(d map[string]any) {
+ d["x-requests"] = []any{roomBinding("GET", "/api/room/events", map[string]any{})}
+ }, "x-requests[0].http:GET /api/room/events 与房间的事件流重复"},
+ {"两个房间内请求的路由只有参数名不同", func(d map[string]any) {
+ first := roomBinding("DELETE", "/api/room/queue/{entryId}", map[string]any{"entryId": str()}, "entryId")
+ second := roomBinding("DELETE", "/api/room/queue/{id}", map[string]any{"id": str()}, "id")
+ second["type"] = "queue.remove"
+ d["x-requests"] = []any{first, second}
+ }, "x-requests[1].http:DELETE /api/room/queue/{id} 与房间内请求 room.sync 的路由重复"},
+ {"路径参数不是请求中的字段", func(d map[string]any) {
+ d["x-requests"] = []any{roomBinding("DELETE", "/api/room/queue/{entryId}", map[string]any{})}
+ }, "x-requests[0].http:路径参数 entryId 应是请求中必填的字符串字段"},
+ {"路径参数是可选字段", func(d map[string]any) {
+ d["x-requests"] = []any{roomBinding("DELETE", "/api/room/queue/{entryId}", map[string]any{"entryId": str()})}
+ }, "x-requests[0].http:路径参数 entryId 应是请求中必填的字符串字段"},
+ {"路径参数是整数字段", func(d map[string]any) {
+ d["x-requests"] = []any{roomBinding("DELETE", "/api/room/queue/{entryId}", map[string]any{"entryId": map[string]any{"type": "integer"}}, "entryId")}
+ }, "x-requests[0].http:路径参数 entryId 应是请求中必填的字符串字段"},
+ {"路径参数重复", func(d map[string]any) {
+ d["x-requests"] = []any{roomBinding("GET", "/api/room/{id}/{id}", map[string]any{"id": str()}, "id")}
+ }, "x-requests[0].http:路径参数 id 重复"},
+ {"GET 请求有对象字段", func(d map[string]any) {
+ withKind(d)
+ defs(d)["Track"] = object(map[string]any{"source": ref("Kind"), "id": str()}, "source", "id")
+ d["x-requests"] = []any{roomBinding("GET", "/api/room", map[string]any{"track": ref("Track")}, "track")}
+ }, "x-requests[0].http:GET 请求的字段 track 来自路径或查询串,应是字符串、整数或枚举"},
+ {"204 的房间内请求有响应", func(d map[string]any) {
+ m := roomBinding("POST", "/api/room/messages", map[string]any{})
+ m["http"].(map[string]any)["status"] = 204
+ m["response"] = object(map[string]any{"id": str()}, "id")
+ d["x-requests"] = []any{m}
+ }, "x-requests[0].http:204 的请求的 response 应为空对象,HTTP 不返回它"},
+ {"无法编译的 pattern", func(d map[string]any) {
+ defs(d)["ErrorInfo"] = object(map[string]any{"message": map[string]any{"type": "string", "pattern": "("}}, "message")
+ }, "pattern 无法编译"},
+ {"整数枚举", func(d map[string]any) {
+ defs(d)["Level"] = map[string]any{"type": "integer", "enum": []string{"1"}}
+ }, "$defs.Level:枚举应为非空的字符串枚举"},
+ {"枚举带了不支持的关键字", func(d map[string]any) {
+ defs(d)["Level"] = map[string]any{"type": "string", "enum": []string{"low"}, "minLength": 1}
+ }, "$defs.Level:这里不支持 minLength"},
+ {"x-httpStatus 不是 HTTP 状态码", func(d map[string]any) {
+ defs(d)["Code"] = map[string]any{"type": "string", "enum": []string{"NOT_FOUND"}, "x-httpStatus": map[string]int{"NOT_FOUND": 4040}}
+ }, "x-httpStatus 中的 NOT_FOUND: 4040 不是枚举值或不是 HTTP 状态码"},
+ {"字段名不是小驼峰式", func(d map[string]any) {
+ defs(d)["ErrorInfo"] = object(map[string]any{"message": str(), "Detail": str()}, "message")
+ }, "$defs.ErrorInfo.properties.Detail:字段名应为小写字母开头的驼峰式"},
+ {"字段生成相同的 Go 字段名", func(d map[string]any) {
+ defs(d)["ErrorInfo"] = object(map[string]any{"message": str(), "userID": str(), "userId": str()}, "message")
+ }, "$defs.ErrorInfo.properties:userID 和 userId 生成相同的 Go 字段名 UserID"},
+ {"请求中可以为 null 的字段", func(d map[string]any) {
+ d["x-requests"] = []any{roomRequest(object(map[string]any{"note": map[string]any{"anyOf": []any{str(), map[string]any{"type": "null"}}}}, "note"))}
+ }, "x-requests[0].request.properties.note:可以为 null 的字段只能出现在响应和事件中"},
+ {"anyOf 的第二项不是 null", func(d map[string]any) {
+ defs(d)["ErrorInfo"] = object(map[string]any{"message": map[string]any{"anyOf": []any{str(), map[string]any{"type": "integer"}}}}, "message")
+ }, `anyOf 只支持 [类型, {"type": "null"}]`},
+ {"数组缺少 items", func(d map[string]any) {
+ defs(d)["ErrorInfo"] = object(map[string]any{"message": map[string]any{"type": "array"}}, "message")
+ }, "$defs.ErrorInfo.properties.message:数组缺少 items"},
+ {"请求中的布尔字段", func(d map[string]any) {
+ d["x-requests"] = []any{roomRequest(object(map[string]any{"force": map[string]any{"type": "boolean"}}, "force"))}
+ }, "x-requests[0].request.properties.force:请求中不支持 boolean"},
+ {"不支持的类型", func(d map[string]any) {
+ defs(d)["ErrorInfo"] = object(map[string]any{"message": map[string]any{"type": "object"}}, "message")
+ }, `$defs.ErrorInfo.properties.message:不支持的类型 "object"`},
+ {"引用带了其他关键字", func(d map[string]any) {
+ defs(d)["ErrorInfo"] = object(map[string]any{"message": map[string]any{"$ref": "#/$defs/Empty", "minLength": 1}}, "message")
+ }, "$defs.ErrorInfo.properties.message:这里不支持 minLength"},
+ {"负的 maxLength", func(d map[string]any) {
+ defs(d)["ErrorInfo"] = object(map[string]any{"message": str("maxLength", -1)}, "message")
+ }, "$defs.ErrorInfo.properties.message:minLength、maxLength 和 x-maxBytes 不能是负数"},
+ {"minLength 大于 maxLength", func(d map[string]any) {
+ defs(d)["ErrorInfo"] = object(map[string]any{"message": str("minLength", 5, "maxLength", 4)}, "message")
+ }, "$defs.ErrorInfo.properties.message:minLength 大于 maxLength"},
+ {"minimum 大于 maximum", func(d map[string]any) {
+ defs(d)["ErrorInfo"] = object(map[string]any{"message": str(), "page": map[string]any{"type": "integer", "minimum": 2, "maximum": 1}}, "message", "page")
+ }, "$defs.ErrorInfo.properties.page:minimum 大于 maximum"},
+ {"oneOf 只有一个分支", func(d map[string]any) {
+ d["x-requests"] = []any{roomRequest(map[string]any{
+ "type": "object", "additionalProperties": false,
+ "properties": map[string]any{"a": str()},
+ "oneOf": []any{map[string]any{"required": []string{"a"}, "properties": map[string]any{"a": map[string]any{}}, "additionalProperties": false}},
+ })}
+ }, "x-requests[0].request:oneOf 至少要有两个分支"},
+ {"字段不属于任何分支", func(d map[string]any) {
+ d["x-requests"] = []any{roomRequest(map[string]any{
+ "type": "object", "additionalProperties": false,
+ "properties": map[string]any{"a": str(), "b": str(), "c": str()},
+ "oneOf": []any{
+ map[string]any{"required": []string{"a"}, "properties": map[string]any{"a": map[string]any{}}, "additionalProperties": false},
+ map[string]any{"required": []string{"b"}, "properties": map[string]any{"b": map[string]any{}}, "additionalProperties": false},
+ },
+ })}
+ }, "x-requests[0].request:c 不属于 oneOf 的任何分支"},
+ {"分支中的字段带了约束", func(d map[string]any) {
+ d["x-requests"] = []any{roomRequest(map[string]any{
+ "type": "object", "additionalProperties": false,
+ "properties": map[string]any{"a": str(), "b": str()},
+ "oneOf": []any{
+ map[string]any{"required": []string{"a"}, "properties": map[string]any{"a": str()}, "additionalProperties": false},
+ map[string]any{"required": []string{"b"}, "properties": map[string]any{"b": map[string]any{}}, "additionalProperties": false},
+ },
+ })}
+ }, "x-requests[0].request.oneOf[0]:properties 中的 a 应为 {}"},
+ {"分支的可选字段不同", func(d map[string]any) {
+ d["x-requests"] = []any{roomRequest(map[string]any{
+ "type": "object", "additionalProperties": false,
+ "properties": map[string]any{"a": str(), "b": str(), "c": str()},
+ "oneOf": []any{
+ map[string]any{"required": []string{"a"}, "properties": map[string]any{"a": map[string]any{}, "c": map[string]any{}}, "additionalProperties": false},
+ map[string]any{"required": []string{"b"}, "properties": map[string]any{"b": map[string]any{}}, "additionalProperties": false},
+ },
+ })}
+ }, "x-requests[0].request.oneOf[1]:可选字段应与第一个分支相同(c),实际为()"},
+ {"分支的 required 有重复的字段", func(d map[string]any) {
+ d["x-requests"] = []any{roomRequest(map[string]any{
+ "type": "object", "additionalProperties": false,
+ "properties": map[string]any{"a": str(), "b": str()},
+ "oneOf": []any{
+ map[string]any{"required": []string{"a", "a"}, "properties": map[string]any{"a": map[string]any{}}, "additionalProperties": false},
+ map[string]any{"required": []string{"b"}, "properties": map[string]any{"b": map[string]any{}}, "additionalProperties": false},
+ },
+ })}
+ }, "x-requests[0].request.oneOf[0]:required 中的 a 重复"},
+ {"分支中的字段是外层的必填字段", func(d map[string]any) {
+ d["x-requests"] = []any{roomRequest(map[string]any{
+ "type": "object", "additionalProperties": false,
+ "properties": map[string]any{"a": str(), "b": str()},
+ "required": []string{"a"},
+ "oneOf": []any{
+ map[string]any{"required": []string{"a"}, "properties": map[string]any{"a": map[string]any{}}, "additionalProperties": false},
+ map[string]any{"required": []string{"b"}, "properties": map[string]any{"b": map[string]any{}}, "additionalProperties": false},
+ },
+ })}
+ }, "x-requests[0].request.oneOf[0]:a 应是外层对象的可选字段"},
+ {"allOf 元素缺少 then", func(d map[string]any) {
+ withKind(d)
+ s := conditional(map[string]any{"properties": map[string]any{"id": map[string]any{"pattern": "^h"}}})
+ delete(s["allOf"].([]any)[0].(map[string]any), "then")
+ defs(d)["Track"] = s
+ }, "$defs.Track.allOf[0]:要同时包含 if 和 then"},
+ {"allOf 元素带了其他关键字", func(d map[string]any) {
+ withKind(d)
+ s := conditional(map[string]any{"properties": map[string]any{"id": map[string]any{"pattern": "^h"}}})
+ s["allOf"].([]any)[0].(map[string]any)["properties"] = map[string]any{}
+ defs(d)["Track"] = s
+ }, "$defs.Track.allOf[0]:这里不支持 properties"},
+ {"空的 allOf", func(d map[string]any) {
+ withKind(d)
+ s := conditional(map[string]any{"properties": map[string]any{"id": map[string]any{"pattern": "^h"}}})
+ s["allOf"] = []any{}
+ defs(d)["Track"] = s
+ }, "$defs.Track:allOf 至少要有一个条件"},
+ {"同一个字段的同一个取值有两个条件", func(d map[string]any) {
+ withKind(d)
+ s := conditional(map[string]any{"properties": map[string]any{"id": map[string]any{"pattern": "^h"}}})
+ s["allOf"] = append(s["allOf"].([]any), condition("link", map[string]any{"properties": map[string]any{"id": map[string]any{"pattern": "^g"}}}))
+ defs(d)["Track"] = s
+ }, "$defs.Track.allOf[1]:与 allOf[0] 重复"},
+ {"if 判断了两个字段", func(d map[string]any) {
+ withKind(d)
+ s := conditional(map[string]any{"properties": map[string]any{"id": map[string]any{"pattern": "^h"}}})
+ s["allOf"] = []any{map[string]any{
+ "if": map[string]any{"properties": map[string]any{"kind": map[string]any{"const": "link"}, "id": map[string]any{"const": "x"}}, "required": []string{"kind", "id"}},
+ "then": map[string]any{"properties": map[string]any{"id": map[string]any{"pattern": "^h"}}},
+ }}
+ defs(d)["Track"] = s
+ }, "$defs.Track.allOf[0].if:应写成"},
+ {"if 中的 const 不是枚举的取值", func(d map[string]any) {
+ withKind(d)
+ s := conditional(map[string]any{"properties": map[string]any{"id": map[string]any{"pattern": "^h"}}})
+ s["allOf"] = []any{condition("qq", map[string]any{"properties": map[string]any{"id": map[string]any{"pattern": "^h"}}})}
+ defs(d)["Track"] = s
+ }, `$defs.Track.allOf[0].if:"qq" 不是 #/$defs/Kind 的取值`},
+ {"then 约束的不是必填的字符串字段", func(d map[string]any) {
+ withKind(d)
+ s := conditional(map[string]any{"properties": map[string]any{"title": map[string]any{"maxLength": 10}}})
+ s["properties"].(map[string]any)["title"] = str()
+ defs(d)["Track"] = s
+ }, "$defs.Track.allOf[0].then.properties.title:then 只能约束必填的字符串字段"},
+ {"then 中负的 x-maxBytes", func(d map[string]any) {
+ withKind(d)
+ defs(d)["Track"] = conditional(map[string]any{"properties": map[string]any{"id": map[string]any{"x-maxBytes": -1}}})
+ }, "$defs.Track.allOf[0].then.properties.id:minLength、maxLength 和 x-maxBytes 不能是负数"},
+ {"事件的 data 引用了枚举", func(d map[string]any) {
+ withKind(d)
+ d["x-events"] = []any{map[string]any{"type": "queue.updated", "data": ref("Kind")}}
+ }, "x-events[0].data:应引用对象类型"},
+ {"事件的 data 引用带了其他关键字", func(d map[string]any) {
+ d["x-events"] = []any{map[string]any{"type": "queue.updated", "data": map[string]any{"$ref": "#/$defs/Empty", "description": "空"}}}
+ }, "x-events[0].data:这里不支持 description"},
+ {"接口名与房间请求的 operationId 重复", func(d map[string]any) {
+ d["x-requests"] = []any{roomRequest(object(map[string]any{}))}
+ d["x-http"] = []any{bodylessEndpoint("roomSync", "GET", "/api/rooms")}
+ }, `x-http[0]:接口名 "roomSync" 格式不对,或与其他接口、房间请求的 operationId 重复`},
+ {"路径不在 /api 下", func(d map[string]any) {
+ d["x-http"] = []any{bodylessEndpoint("listRooms", "GET", "/rooms")}
+ }, `x-http[0]:method "GET" 或 path "/rooms" 不合法`},
+ {"路径参数与 client 中的标识符重名", func(d map[string]any) {
+ d["x-http"] = []any{bodylessEndpoint("getRoom", "GET", "/api/rooms/{send}")}
+ }, "x-http[0]:路径参数 send 重复,或与生成的 TypeScript client 中的标识符重名"},
+ {"同一个路径形状用了不同的参数名", func(d map[string]any) {
+ d["x-http"] = []any{bodylessEndpoint("getRoom", "GET", "/api/rooms/{ref}"), bodylessEndpoint("deleteRoom", "DELETE", "/api/rooms/{id}")}
+ }, "x-http[1]:路径 /api/rooms/{id} 与 /api/rooms/{ref} 只有参数名不同"},
+ {"未知的凭证", func(d map[string]any) {
+ e := bodylessEndpoint("listRooms", "GET", "/api/rooms")
+ e["auth"] = "ticket"
+ d["x-http"] = []any{e}
+ }, "x-http[0]:auth 应为 none、member 或 admin"},
+ {"写明 200 的状态码", func(d map[string]any) {
+ e := bodylessEndpoint("listRooms", "GET", "/api/rooms")
+ e["status"] = 200
+ d["x-http"] = []any{e}
+ }, "x-http[0]:status 只能是 201 或 204,缺省为 200"},
+ {"不是 201 的接口带 Location", func(d map[string]any) {
+ e := bodylessEndpoint("deleteRooms", "DELETE", "/api/rooms")
+ e["status"], e["location"] = 204, true
+ delete(e, "response")
+ d["x-http"] = []any{e}
+ }, "x-http[0]:location 只能用于 201 的接口"},
+ {"204 的接口带 response", func(d map[string]any) {
+ e := bodylessEndpoint("deleteRooms", "DELETE", "/api/rooms")
+ e["status"] = 204
+ d["x-http"] = []any{e}
+ }, "x-http[0]:204 的接口没有 response"},
+ {"不是 204 的接口没有 response", func(d map[string]any) {
+ e := bodylessEndpoint("deleteRooms", "DELETE", "/api/rooms")
+ delete(e, "response")
+ d["x-http"] = []any{e}
+ }, "x-http[0].response:缺少定义"},
+ {"关闭码超出范围", func(d map[string]any) {
+ d["x-ws"].(map[string]any)["closeCodes"] = []any{map[string]any{"name": "gone", "code": 999}}
+ }, `x-ws.closeCodes[0]:名称 "gone" 或关闭码 999 不合法或重复`},
+ {"类型名与请求的常量重名", func(d map[string]any) {
+ d["x-requests"] = []any{roomRequest(object(map[string]any{}))}
+ defs(d)["RequestRoomSync"] = object(map[string]any{})
+ }, "生成的 Go 标识符 RequestRoomSync 重复:既是类型 RequestRoomSync,也是请求 room.sync 的常量"},
+ {"路径参数是 JavaScript 的保留字", func(d map[string]any) {
+ d["x-http"] = []any{bodylessEndpoint("getRoom", "GET", "/api/rooms/{default}")}
+ }, "x-http[0]:路径参数 default 是 JavaScript 的保留字"},
+ {"标量类型不是字符串或整数", func(d map[string]any) {
+ defs(d)["Score"] = map[string]any{"type": "number"}
+ }, `$defs.Score:类型应为对象、字符串枚举、"string" 或 "integer"`},
+ {"then 引用了整数的标量类型", func(d map[string]any) {
+ withKind(d)
+ defs(d)["Count"] = map[string]any{"type": "integer", "minimum": 1}
+ defs(d)["Track"] = conditional(map[string]any{"properties": map[string]any{"id": ref("Count")}})
+ }, "$defs.Track.allOf[0].then.properties.id:只能引用字符串的标量类型"},
+ {"then 中的引用带了其他关键字", func(d map[string]any) {
+ withKind(d)
+ defs(d)["Link"] = str("x-maxBytes", 2048)
+ defs(d)["Track"] = conditional(map[string]any{"properties": map[string]any{"id": map[string]any{"$ref": "#/$defs/Link", "minLength": 1}}})
+ }, "$defs.Track.allOf[0].then.properties.id:这里不支持 minLength"},
+ {"字段上的 x-url", func(d map[string]any) {
+ defs(d)["ErrorInfo"] = object(map[string]any{"message": str("x-url", true)}, "message")
+ }, "$defs.ErrorInfo.properties.message:这里不支持 x-url"},
+ {"then 中直接写 x-url", func(d map[string]any) {
+ withKind(d)
+ defs(d)["Track"] = conditional(map[string]any{"properties": map[string]any{"id": map[string]any{"x-url": true}}})
+ }, "$defs.Track.allOf[0].then.properties.id:这里不支持 x-url"},
+ {"整数的标量类型带了 x-url", func(d map[string]any) {
+ defs(d)["Count"] = map[string]any{"type": "integer", "x-url": true}
+ }, "$defs.Count:这里不支持 x-url"},
+ {"x-url 与 pattern 同时使用", func(d map[string]any) {
+ defs(d)["Link"] = str("x-url", true, "pattern", "^https://")
+ }, "$defs.Link:x-url 只能是 true,并且不能与 pattern 同时使用"},
+ {"单独的大小写变体", func(d map[string]any) {
+ defs(d)["ErrorInfo"] = object(map[string]any{"message": map[string]any{"type": "string", "MAXLENGTH": 5}}, "message")
+ }, `$defs.ErrorInfo.properties.message:键 "MAXLENGTH" 应写作 "maxLength"`},
+ {"类型名与 TypeScript 中固定的类型重名", func(d map[string]any) {
+ defs(d)["ServerMessage"] = object(map[string]any{})
+ }, "生成的 TypeScript 标识符 ServerMessage 重复:既是生成器写出的 ServerMessage,也是类型 ServerMessage"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ d := minimalIDL()
+ tt.change(d)
+ data, err := json.Marshal(d)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = parseIDL(data)
+ if err == nil || !strings.Contains(err.Error(), tt.want) {
+ t.Fatalf("错误 = %v,应包含 %q", err, tt.want)
+ }
+ })
+ }
+}
+
+func TestParseIDLRejectsNullAndDuplicateKeys(t *testing.T) {
+ tests := map[string]string{
+ `{"title": "T", "x-requests": [null]}`: "x-requests[0]:不能是 null",
+ // encoding/json 按 Unicode 的大小写折叠匹配字段名,长 s(ſ)和 s 被视为同一个字符。
+ `{"title": "T", "$defſ": {}}`: `顶层:键 "$defſ" 应写作 "$defs"`,
+ `{"title": "T", "$defs": {"Empty": {"type": "object", "additionalProperties": false, "properties": {"a": {"type": "string"}, "a": {"type": "integer"}}}}}`: `$defs.Empty.properties:键 "a" 重复`,
+ }
+ for raw, want := range tests {
+ if _, err := parseIDL([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) {
+ t.Errorf("parseIDL(%s) 的错误 = %v,应包含 %q", raw, err, want)
+ }
+ }
+}
+
+func TestKeywordsCoverEverySchemaField(t *testing.T) {
+ typ := reflect.TypeFor[schema]()
+ for i := range typ.NumField() {
+ field := typ.Field(i)
+ keyword, _, _ := strings.Cut(field.Tag.Get("json"), ",")
+ if keyword == "" {
+ continue
+ }
+ var s schema
+ v := reflect.ValueOf(&s).Elem().Field(i)
+ switch v.Kind() {
+ case reflect.String:
+ v.SetString("x")
+ case reflect.Pointer:
+ v.Set(reflect.New(field.Type.Elem()))
+ case reflect.Slice:
+ v.Set(reflect.MakeSlice(field.Type, 0, 0))
+ case reflect.Map:
+ v.Set(reflect.MakeMap(field.Type))
+ }
+ if got := s.keywords(); !slices.Equal(got, []string{keyword}) {
+ t.Errorf("只设置 %s 时 keywords() = %v,期望 [%s]", field.Name, got, keyword)
+ }
+ }
+}
+
+func TestParseIDLAcceptsRoutesDifferingByMethod(t *testing.T) {
+ d := minimalIDL()
+ d["x-http"] = []any{bodylessEndpoint("listRooms", "GET", "/api/rooms"), bodylessEndpoint("clearRooms", "DELETE", "/api/rooms")}
+ data, err := json.Marshal(d)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := parseIDL(data); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func parseMinimal(t *testing.T, change func(d map[string]any)) *idl {
+ t.Helper()
+ d := minimalIDL()
+ change(d)
+ data, err := json.Marshal(d)
+ if err != nil {
+ t.Fatal(err)
+ }
+ parsed, err := parseIDL(data)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return parsed
+}
+
+func TestGeneratedNamesAvoidHandwrittenCode(t *testing.T) {
+ dir := filepath.Join("..", "..", "protocol")
+ tests := []struct {
+ name string
+ change func(d map[string]any)
+ want string
+ }{
+ {"类型", func(d map[string]any) {
+ defs(d)["Message"] = object(map[string]any{})
+ }, "生成的 Go 标识符 Message 与 ws.go 中手写的声明重名:它是类型 Message"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if err := parseMinimal(t, tt.change).checkHandwritten(dir); err == nil || !strings.Contains(err.Error(), tt.want) {
+ t.Fatalf("错误 = %v,应包含 %q", err, tt.want)
+ }
+ })
+ }
+ if err := parseMinimal(t, func(map[string]any) {}).checkHandwritten(dir); err != nil {
+ t.Errorf("最小的 IDL 不应与手写的代码重名:%v", err)
+ }
+}
+
+var tsExport = regexp.MustCompile(`(?m)^export (?:type|interface|const|function) (\w+)`)
+
+func TestTSNamesCoverGeneratedCode(t *testing.T) {
+ data, err := os.ReadFile(filepath.Join("..", "..", "protocol", "idl.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ d, err := parseIDL(data)
+ if err != nil {
+ t.Fatal(err)
+ }
+ outputs, err := generate(d)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, out := range outputs {
+ if !strings.HasSuffix(out.path, ".ts") {
+ continue
+ }
+ for _, m := range tsExport.FindAllSubmatch(out.data, -1) {
+ name := string(m[1])
+ validator, ok := strings.CutPrefix(name, "is")
+ if d.named[name] == nil && !slices.Contains(tsFixedNames, name) && (!ok || !slices.Contains(d.urlDefs(), validator)) {
+ t.Errorf("%s 导出的 %s 既不是 IDL 中的类型和带 x-url 的标量类型的校验函数,也不在 tsFixedNames 中", out.path, name)
+ }
+ }
+ }
+}
diff --git a/cmd/protocolgen/main_test.go b/cmd/protocolgen/main_test.go
new file mode 100644
index 0000000..dda6a81
--- /dev/null
+++ b/cmd/protocolgen/main_test.go
@@ -0,0 +1,94 @@
+package main
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func sampleRoot(t *testing.T) string {
+ t.Helper()
+ data, err := os.ReadFile(filepath.Join(sampleDir, "protocol", "idl.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ root := t.TempDir()
+ if err := writeOutput(filepath.Join(root, "protocol", "idl.json"), data); err != nil {
+ t.Fatal(err)
+ }
+ return root
+}
+
+func TestRunWritesThenChecks(t *testing.T) {
+ root := sampleRoot(t)
+ var stderr bytes.Buffer
+ if code := run([]string{"-root", root}, &stderr); code != 0 {
+ t.Fatalf("生成退出码 = %d,stderr:%s", code, &stderr)
+ }
+ outputs := sampleOutputs(t)
+ for _, out := range outputs {
+ got, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(out.path)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(got, out.data) {
+ t.Errorf("写入的 %s 与生成结果不同", out.path)
+ }
+ }
+ if code := run([]string{"-root", root, "-check"}, &stderr); code != 0 {
+ t.Fatalf("刚生成完就检查,退出码 = %d,stderr:%s", code, &stderr)
+ }
+
+ stale := outputs[0].path
+ if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(stale)), []byte("旧内容\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ stderr.Reset()
+ if code := run([]string{"-root", root, "-check"}, &stderr); code != 1 {
+ t.Fatalf("生成物过期时退出码 = %d", code)
+ }
+ if !strings.Contains(stderr.String(), stale+" 不是最新的") {
+ t.Errorf("stderr 没有指出过期的文件:%s", &stderr)
+ }
+}
+
+func TestRunCheckReportsMissingAndUnreadableOutputs(t *testing.T) {
+ root := sampleRoot(t)
+ var stderr bytes.Buffer
+ if code := run([]string{"-root", root}, &stderr); code != 0 {
+ t.Fatalf("生成退出码 = %d,stderr:%s", code, &stderr)
+ }
+ target := filepath.Join(root, "protocol", "types.gen.go")
+ if err := os.Remove(target); err != nil {
+ t.Fatal(err)
+ }
+ if code := run([]string{"-root", root, "-check"}, &stderr); code != 1 || !strings.Contains(stderr.String(), "protocol/types.gen.go 不是最新的") {
+ t.Fatalf("缺少生成物时退出码 = %d,stderr:%s", code, &stderr)
+ }
+ if err := os.Mkdir(target, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ stderr.Reset()
+ if code := run([]string{"-root", root, "-check"}, &stderr); code != 1 {
+ t.Fatalf("生成物读不出来时退出码 = %d", code)
+ }
+ if got := stderr.String(); !strings.Contains(got, "读取 protocol/types.gen.go") || strings.Contains(got, "不是最新的") {
+ t.Errorf("stderr = %s,期望报告读取的错误", got)
+ }
+}
+
+func TestRunReportsInvalidIDL(t *testing.T) {
+ root := t.TempDir()
+ if err := writeOutput(filepath.Join(root, "protocol", "idl.json"), []byte(`{"title": "Broken", "$defs": {}}`)); err != nil {
+ t.Fatal(err)
+ }
+ var stderr bytes.Buffer
+ if code := run([]string{"-root", root}, &stderr); code != 1 {
+ t.Fatalf("退出码 = %d", code)
+ }
+ if !strings.Contains(stderr.String(), "缺少对象类型 Empty") {
+ t.Errorf("stderr = %s", &stderr)
+ }
+}
diff --git a/cmd/protocolgen/testdata/sample/protocol/decode.gen.go b/cmd/protocolgen/testdata/sample/protocol/decode.gen.go
new file mode 100644
index 0000000..7b4734e
--- /dev/null
+++ b/cmd/protocolgen/testdata/sample/protocol/decode.gen.go
@@ -0,0 +1,344 @@
+// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
+
+package protocol
+
+import (
+ "encoding/json"
+ "regexp"
+)
+
+var (
+ patternAdminUpdateRoomRequestNote = regexp.MustCompile("^[^`]*$")
+ patternAdminUpdateRoomRequestSlug = regexp.MustCompile(`^[a-z0-9-]{0,32}$`)
+ patternTrackIDWhenLink = regexp.MustCompile(`^https?://\S+$`)
+ patternTrackIDWhenNetease = regexp.MustCompile(`^[0-9]+$`)
+)
+
+func decodeConnectionRequest(typ string, raw json.RawMessage, path string) (any, bool, error) {
+ switch typ {
+ case RequestHello:
+ v, err := decodeHelloRequest(raw, path)
+ return v, true, err
+ }
+ return nil, false, nil
+}
+
+func decodeRoomRequest(typ string, raw json.RawMessage, path string) (any, bool, error) {
+ switch typ {
+ case RequestRoomSync:
+ v, err := decodeRoomSyncRequest(raw, path)
+ return v, true, err
+ case RequestQueueAdd:
+ v, err := decodeQueueAddRequest(raw, path)
+ return v, true, err
+ case RequestQueueRemove:
+ v, err := decodeQueueRemoveRequest(raw, path)
+ return v, true, err
+ case RequestQueueMove:
+ v, err := decodeQueueMoveRequest(raw, path)
+ return v, true, err
+ case RequestSearchTracks:
+ v, err := decodeSearchTracksRequest(raw, path)
+ return v, true, err
+ }
+ return nil, false, nil
+}
+
+func DecodeCreateMemberRequest(body []byte) (CreateMemberRequest, error) {
+ return decodeCreateMemberRequest(body, "")
+}
+
+func DecodeCreateRoomRequest(body []byte) (CreateRoomRequest, error) {
+ return decodeCreateRoomRequest(body, "")
+}
+
+func DecodeAdminUpdateRoomRequest(body []byte) (AdminUpdateRoomRequest, error) {
+ return decodeAdminUpdateRoomRequest(body, "")
+}
+
+func decodeAdminUpdateRoomRequest(raw json.RawMessage, path string) (AdminUpdateRoomRequest, error) {
+ var v AdminUpdateRoomRequest
+ m, err := decodeObject(raw, path, "homepage", "limit", "name", "note", "slug")
+ if err != nil {
+ return v, err
+ }
+ if r, ok := m["homepage"]; ok {
+ x, err := decodeString(r, field(path, "homepage"))
+ if err != nil {
+ return v, err
+ }
+ if err = checkMaxLength(x, field(path, "homepage"), 200); err != nil {
+ return v, err
+ }
+ if err = checkMaxBytes(x, field(path, "homepage"), 600); err != nil {
+ return v, err
+ }
+ if err = checkURL(x, field(path, "homepage")); err != nil {
+ return v, err
+ }
+ v.Homepage = &x
+ }
+ if r, ok := m["limit"]; ok {
+ x, err := decodeInteger(r, field(path, "limit"))
+ if err != nil {
+ return v, err
+ }
+ if err = checkMinimum(x, field(path, "limit"), 1); err != nil {
+ return v, err
+ }
+ if err = checkMaximum(x, field(path, "limit"), 100); err != nil {
+ return v, err
+ }
+ v.Limit = &x
+ }
+ if r, ok := m["name"]; ok {
+ x, err := decodeString(r, field(path, "name"))
+ if err != nil {
+ return v, err
+ }
+ if err = checkMinLength(x, field(path, "name"), 1); err != nil {
+ return v, err
+ }
+ if err = checkMaxLength(x, field(path, "name"), 80); err != nil {
+ return v, err
+ }
+ v.Name = &x
+ }
+ if r, ok := m["note"]; ok {
+ x, err := decodeString(r, field(path, "note"))
+ if err != nil {
+ return v, err
+ }
+ if err = checkPattern(x, field(path, "note"), patternAdminUpdateRoomRequestNote); err != nil {
+ return v, err
+ }
+ v.Note = &x
+ }
+ if r, ok := m["slug"]; ok {
+ x, err := decodeString(r, field(path, "slug"))
+ if err != nil {
+ return v, err
+ }
+ if err = checkPattern(x, field(path, "slug"), patternAdminUpdateRoomRequestSlug); err != nil {
+ return v, err
+ }
+ v.Slug = &x
+ }
+ return v, nil
+}
+
+func decodeCreateMemberRequest(raw json.RawMessage, path string) (CreateMemberRequest, error) {
+ var v CreateMemberRequest
+ _, err := decodeObject(raw, path)
+ return v, err
+}
+
+func decodeCreateRoomRequest(raw json.RawMessage, path string) (CreateRoomRequest, error) {
+ var v CreateRoomRequest
+ m, err := decodeObject(raw, path, "name")
+ if err != nil {
+ return v, err
+ }
+ if err = requireFields(m, path, "name"); err != nil {
+ return v, err
+ }
+ if v.Name, err = decodeString(m["name"], field(path, "name")); err != nil {
+ return v, err
+ }
+ if err = checkMinLength(v.Name, field(path, "name"), 1); err != nil {
+ return v, err
+ }
+ return v, nil
+}
+
+func decodeHelloRequest(raw json.RawMessage, path string) (HelloRequest, error) {
+ var v HelloRequest
+ m, err := decodeObject(raw, path, "clientTimeMs", "roomId")
+ if err != nil {
+ return v, err
+ }
+ if err = requireFields(m, path, "clientTimeMs", "roomId"); err != nil {
+ return v, err
+ }
+ if v.ClientTimeMs, err = decodeInteger(m["clientTimeMs"], field(path, "clientTimeMs")); err != nil {
+ return v, err
+ }
+ if err = checkMinimum(v.ClientTimeMs, field(path, "clientTimeMs"), 0); err != nil {
+ return v, err
+ }
+ if v.RoomID, err = decodeString(m["roomId"], field(path, "roomId")); err != nil {
+ return v, err
+ }
+ return v, nil
+}
+
+func decodeQueueAddRequest(raw json.RawMessage, path string) (QueueAddRequest, error) {
+ var v QueueAddRequest
+ m, err := decodeObject(raw, path, "keyword", "requester", "source", "track")
+ if err != nil {
+ return v, err
+ }
+ if err = checkOneOf(m, path, "只能提供以下字段组合之一:track;source、keyword", []string{"requester"}, []string{"track"}, []string{"source", "keyword"}); err != nil {
+ return v, err
+ }
+ if r, ok := m["keyword"]; ok {
+ x, err := decodeString(r, field(path, "keyword"))
+ if err != nil {
+ return v, err
+ }
+ if err = checkMinLength(x, field(path, "keyword"), 1); err != nil {
+ return v, err
+ }
+ if err = checkMaxLength(x, field(path, "keyword"), 500); err != nil {
+ return v, err
+ }
+ v.Keyword = &x
+ }
+ if r, ok := m["requester"]; ok {
+ x, err := decodeString(r, field(path, "requester"))
+ if err != nil {
+ return v, err
+ }
+ if err = checkMinLength(x, field(path, "requester"), 1); err != nil {
+ return v, err
+ }
+ v.Requester = &x
+ }
+ if r, ok := m["source"]; ok {
+ x, err := decodeSource(r, field(path, "source"))
+ if err != nil {
+ return v, err
+ }
+ v.Source = &x
+ }
+ if r, ok := m["track"]; ok {
+ x, err := decodeTrack(r, field(path, "track"))
+ if err != nil {
+ return v, err
+ }
+ v.Track = &x
+ }
+ return v, nil
+}
+
+func decodeQueueMoveRequest(raw json.RawMessage, path string) (QueueMoveRequest, error) {
+ var v QueueMoveRequest
+ m, err := decodeObject(raw, path, "entryId", "position")
+ if err != nil {
+ return v, err
+ }
+ if err = requireFields(m, path, "entryId", "position"); err != nil {
+ return v, err
+ }
+ if v.EntryID, err = decodeString(m["entryId"], field(path, "entryId")); err != nil {
+ return v, err
+ }
+ if v.Position, err = decodeInteger(m["position"], field(path, "position")); err != nil {
+ return v, err
+ }
+ if err = checkMinimum(v.Position, field(path, "position"), 1); err != nil {
+ return v, err
+ }
+ return v, nil
+}
+
+func decodeQueueRemoveRequest(raw json.RawMessage, path string) (QueueRemoveRequest, error) {
+ var v QueueRemoveRequest
+ m, err := decodeObject(raw, path, "entryId")
+ if err != nil {
+ return v, err
+ }
+ if err = requireFields(m, path, "entryId"); err != nil {
+ return v, err
+ }
+ if v.EntryID, err = decodeString(m["entryId"], field(path, "entryId")); err != nil {
+ return v, err
+ }
+ return v, nil
+}
+
+func decodeRoomSyncRequest(raw json.RawMessage, path string) (RoomSyncRequest, error) {
+ var v RoomSyncRequest
+ _, err := decodeObject(raw, path)
+ return v, err
+}
+
+func decodeSearchTracksRequest(raw json.RawMessage, path string) (SearchTracksRequest, error) {
+ var v SearchTracksRequest
+ m, err := decodeObject(raw, path, "keyword", "limit", "source")
+ if err != nil {
+ return v, err
+ }
+ if err = requireFields(m, path, "keyword", "source"); err != nil {
+ return v, err
+ }
+ if v.Keyword, err = decodeString(m["keyword"], field(path, "keyword")); err != nil {
+ return v, err
+ }
+ if err = checkMinLength(v.Keyword, field(path, "keyword"), 1); err != nil {
+ return v, err
+ }
+ if err = checkMaxLength(v.Keyword, field(path, "keyword"), 500); err != nil {
+ return v, err
+ }
+ if r, ok := m["limit"]; ok {
+ x, err := decodeInteger(r, field(path, "limit"))
+ if err != nil {
+ return v, err
+ }
+ if err = checkMinimum(x, field(path, "limit"), 1); err != nil {
+ return v, err
+ }
+ if err = checkMaximum(x, field(path, "limit"), 100); err != nil {
+ return v, err
+ }
+ v.Limit = &x
+ }
+ if v.Source, err = decodeSource(m["source"], field(path, "source")); err != nil {
+ return v, err
+ }
+ return v, nil
+}
+
+func decodeSource(raw json.RawMessage, path string) (Source, error) {
+ s, err := decodeString(raw, path)
+ if err != nil {
+ return "", err
+ }
+ switch v := Source(s); v {
+ case SourceNetease, SourceQQ, SourceLink:
+ return v, nil
+ }
+ return "", invalid(path, "取值应为 netease、qq、link 之一")
+}
+
+func decodeTrack(raw json.RawMessage, path string) (Track, error) {
+ var v Track
+ m, err := decodeObject(raw, path, "id", "source")
+ if err != nil {
+ return v, err
+ }
+ if err = requireFields(m, path, "id", "source"); err != nil {
+ return v, err
+ }
+ if v.ID, err = decodeString(m["id"], field(path, "id")); err != nil {
+ return v, err
+ }
+ if v.Source, err = decodeSource(m["source"], field(path, "source")); err != nil {
+ return v, err
+ }
+ if v.Source == SourceLink {
+ if err = checkMaxBytes(v.ID, field(path, "id"), 2048); err != nil {
+ return v, err
+ }
+ if err = checkPattern(v.ID, field(path, "id"), patternTrackIDWhenLink); err != nil {
+ return v, err
+ }
+ }
+ if v.Source == SourceNetease {
+ if err = checkPattern(v.ID, field(path, "id"), patternTrackIDWhenNetease); err != nil {
+ return v, err
+ }
+ }
+ return v, nil
+}
diff --git a/cmd/protocolgen/testdata/sample/protocol/idl.json b/cmd/protocolgen/testdata/sample/protocol/idl.json
new file mode 100644
index 0000000..e812c76
--- /dev/null
+++ b/cmd/protocolgen/testdata/sample/protocol/idl.json
@@ -0,0 +1,273 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "Sample",
+ "$defs": {
+ "Empty": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {}
+ },
+ "ErrorCode": {
+ "type": "string",
+ "enum": ["INVALID_ARGUMENT", "NOT_FOUND", "CLIENT_OUTDATED"],
+ "x-httpStatus": {"INVALID_ARGUMENT": 400, "NOT_FOUND": 404}
+ },
+ "ErrorInfo": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "code": {"$ref": "#/$defs/ErrorCode"},
+ "message": {"type": "string"}
+ },
+ "required": ["code", "message"]
+ },
+ "Keyword": {"type": "string", "minLength": 1, "maxLength": 500},
+ "Limit": {"type": "integer", "description": "条数上限", "minimum": 1, "maximum": 100},
+ "Website": {"type": "string", "maxLength": 200, "x-maxBytes": 600, "x-url": true},
+ "Member": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "id": {"type": "string"},
+ "name": {"type": "string"}
+ },
+ "required": ["id", "name"]
+ },
+ "Room": {
+ "type": "object",
+ "additionalProperties": false,
+ "description": "房间",
+ "properties": {
+ "gain": {"type": "number"},
+ "id": {"type": "string"},
+ "members": {"type": "array", "items": {"$ref": "#/$defs/Member"}},
+ "open": {"type": "boolean"},
+ "slug": {"anyOf": [{"type": "string"}, {"type": "null"}]}
+ },
+ "required": ["gain", "id", "members", "open", "slug"]
+ },
+ "Source": {
+ "type": "string",
+ "enum": ["netease", "qq", "link"]
+ },
+ "Track": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "id": {"type": "string"},
+ "source": {"$ref": "#/$defs/Source"}
+ },
+ "required": ["id", "source"],
+ "allOf": [
+ {
+ "if": {"properties": {"source": {"const": "link"}}, "required": ["source"]},
+ "then": {"properties": {"id": {"pattern": "^https?://\\S+$", "x-maxBytes": 2048}}}
+ },
+ {
+ "if": {"properties": {"source": {"const": "netease"}}, "required": ["source"]},
+ "then": {"properties": {"id": {"pattern": "^[0-9]+$"}}}
+ }
+ ]
+ }
+ },
+ "x-ws": {
+ "requests": [
+ {
+ "type": "hello",
+ "request": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "clientTimeMs": {"type": "integer", "minimum": 0},
+ "roomId": {"type": "string"}
+ },
+ "required": ["clientTimeMs", "roomId"]
+ },
+ "response": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "owner": {"anyOf": [{"$ref": "#/$defs/Member"}, {"type": "null"}]},
+ "room": {"$ref": "#/$defs/Room"}
+ },
+ "required": ["owner", "room"]
+ }
+ }
+ ],
+ "events": [
+ {"type": "error", "data": {"$ref": "#/$defs/ErrorInfo"}}
+ ],
+ "closeCodes": [
+ {"name": "invalidArgument", "code": 1008},
+ {"name": "clientOutdated", "code": 4426}
+ ]
+ },
+ "x-requests": [
+ {
+ "type": "room.sync",
+ "description": "获取房间快照",
+ "http": {"method": "GET", "path": "/api/room"},
+ "request": {"type": "object", "additionalProperties": false, "properties": {}},
+ "response": {"$ref": "#/$defs/Room"}
+ },
+ {
+ "type": "queue.add",
+ "http": {"method": "POST", "path": "/api/room/queue", "status": 201, "location": true},
+ "request": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "keyword": {"$ref": "#/$defs/Keyword"},
+ "requester": {"type": "string", "minLength": 1},
+ "source": {"$ref": "#/$defs/Source"},
+ "track": {"$ref": "#/$defs/Track"}
+ },
+ "oneOf": [
+ {"required": ["track"], "properties": {"track": {}, "requester": {}}, "additionalProperties": false},
+ {"required": ["source", "keyword"], "properties": {"source": {}, "keyword": {}, "requester": {}}, "additionalProperties": false}
+ ]
+ },
+ "response": {"$ref": "#/$defs/Empty"}
+ },
+ {
+ "type": "queue.remove",
+ "http": {"method": "DELETE", "path": "/api/room/queue/{entryId}", "status": 204},
+ "request": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "entryId": {"type": "string"}
+ },
+ "required": ["entryId"]
+ },
+ "response": {"$ref": "#/$defs/Empty"}
+ },
+ {
+ "type": "queue.move",
+ "http": {"method": "PATCH", "path": "/api/room/queue/{entryId}", "status": 204},
+ "request": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "entryId": {"type": "string"},
+ "position": {"type": "integer", "minimum": 1}
+ },
+ "required": ["entryId", "position"]
+ },
+ "response": {"$ref": "#/$defs/Empty"}
+ },
+ {
+ "type": "search.tracks",
+ "http": {"method": "GET", "path": "/api/room/search/tracks"},
+ "request": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "keyword": {"$ref": "#/$defs/Keyword"},
+ "limit": {"$ref": "#/$defs/Limit"},
+ "source": {"$ref": "#/$defs/Source"}
+ },
+ "required": ["keyword", "source"]
+ },
+ "response": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "total": {"type": "integer"}
+ },
+ "required": ["total"]
+ }
+ }
+ ],
+ "x-events": [
+ {
+ "type": "members.updated",
+ "data": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "members": {"type": "array", "items": {"$ref": "#/$defs/Member"}},
+ "note": {"type": "string"}
+ },
+ "required": ["members"]
+ }
+ }
+ ],
+ "x-http": [
+ {
+ "name": "createMember",
+ "description": "创建成员",
+ "method": "POST",
+ "path": "/api/members",
+ "auth": "none",
+ "status": 201,
+ "request": {"type": "object", "additionalProperties": false, "properties": {}},
+ "response": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "member": {"$ref": "#/$defs/Member"},
+ "token": {"type": "string"}
+ },
+ "required": ["member", "token"]
+ }
+ },
+ {
+ "name": "getRoom",
+ "method": "GET",
+ "path": "/api/rooms/{ref}",
+ "auth": "none",
+ "response": {"$ref": "#/$defs/Room"}
+ },
+ {
+ "name": "createRoom",
+ "method": "POST",
+ "path": "/api/rooms",
+ "auth": "none",
+ "status": 201,
+ "location": true,
+ "request": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "name": {"type": "string", "minLength": 1}
+ },
+ "required": ["name"]
+ },
+ "response": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "id": {"type": "string"}
+ },
+ "required": ["id"]
+ }
+ },
+ {
+ "name": "getSeat",
+ "description": "座位上的成员,座位号 从 1 开始 & 不超过 8",
+ "method": "GET",
+ "path": "/api/rooms/{ref}/seats/{seat}",
+ "auth": "member",
+ "response": {"$ref": "#/$defs/Member"}
+ },
+ {
+ "name": "adminUpdateRoom",
+ "method": "PATCH",
+ "path": "/api/admin/rooms/{id}",
+ "auth": "admin",
+ "status": 204,
+ "request": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "homepage": {"$ref": "#/$defs/Website"},
+ "limit": {"$ref": "#/$defs/Limit", "description": "一页最多返回的条数"},
+ "name": {"type": "string", "minLength": 1, "maxLength": 80},
+ "note": {"type": "string", "pattern": "^[^`]*$"},
+ "slug": {"type": "string", "pattern": "^[a-z0-9-]{0,32}$"}
+ }
+ }
+ }
+ ]
+}
diff --git a/cmd/protocolgen/testdata/sample/protocol/openapi.gen.json b/cmd/protocolgen/testdata/sample/protocol/openapi.gen.json
new file mode 100644
index 0000000..9360e7a
--- /dev/null
+++ b/cmd/protocolgen/testdata/sample/protocol/openapi.gen.json
@@ -0,0 +1,779 @@
+{
+ "components": {
+ "schemas": {
+ "AdminUpdateRoomRequest": {
+ "additionalProperties": false,
+ "properties": {
+ "homepage": {
+ "format": "iri",
+ "maxLength": 200,
+ "type": "string",
+ "x-maxBytes": 600,
+ "x-url": true
+ },
+ "limit": {
+ "description": "一页最多返回的条数",
+ "maximum": 100,
+ "minimum": 1,
+ "type": "integer"
+ },
+ "name": {
+ "maxLength": 80,
+ "minLength": 1,
+ "type": "string"
+ },
+ "note": {
+ "pattern": "^[^`]*$",
+ "type": "string"
+ },
+ "slug": {
+ "pattern": "^[a-z0-9-]{0,32}$",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "CreateMemberRequest": {
+ "additionalProperties": false,
+ "type": "object"
+ },
+ "CreateMemberResponse": {
+ "additionalProperties": false,
+ "properties": {
+ "member": {
+ "$ref": "#/components/schemas/Member"
+ },
+ "token": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "member",
+ "token"
+ ],
+ "type": "object"
+ },
+ "CreateRoomRequest": {
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "required": [
+ "name"
+ ],
+ "type": "object"
+ },
+ "CreateRoomResponse": {
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id"
+ ],
+ "type": "object"
+ },
+ "Empty": {
+ "additionalProperties": false,
+ "type": "object"
+ },
+ "ErrorCode": {
+ "enum": [
+ "INVALID_ARGUMENT",
+ "NOT_FOUND",
+ "CLIENT_OUTDATED"
+ ],
+ "type": "string",
+ "x-httpStatus": {
+ "INVALID_ARGUMENT": 400,
+ "NOT_FOUND": 404
+ }
+ },
+ "ErrorInfo": {
+ "additionalProperties": false,
+ "properties": {
+ "code": {
+ "$ref": "#/components/schemas/ErrorCode"
+ },
+ "message": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "code",
+ "message"
+ ],
+ "type": "object"
+ },
+ "ErrorResponse": {
+ "additionalProperties": false,
+ "properties": {
+ "error": {
+ "$ref": "#/components/schemas/ErrorInfo"
+ }
+ },
+ "required": [
+ "error"
+ ],
+ "type": "object"
+ },
+ "Member": {
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "name"
+ ],
+ "type": "object"
+ },
+ "QueueAddRequest": {
+ "additionalProperties": false,
+ "oneOf": [
+ {
+ "additionalProperties": false,
+ "properties": {
+ "requester": {},
+ "track": {}
+ },
+ "required": [
+ "track"
+ ]
+ },
+ {
+ "additionalProperties": false,
+ "properties": {
+ "keyword": {},
+ "requester": {},
+ "source": {}
+ },
+ "required": [
+ "source",
+ "keyword"
+ ]
+ }
+ ],
+ "properties": {
+ "keyword": {
+ "maxLength": 500,
+ "minLength": 1,
+ "type": "string"
+ },
+ "requester": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "source": {
+ "$ref": "#/components/schemas/Source"
+ },
+ "track": {
+ "$ref": "#/components/schemas/Track"
+ }
+ },
+ "type": "object"
+ },
+ "Room": {
+ "additionalProperties": false,
+ "description": "房间",
+ "properties": {
+ "gain": {
+ "type": "number"
+ },
+ "id": {
+ "type": "string"
+ },
+ "members": {
+ "items": {
+ "$ref": "#/components/schemas/Member"
+ },
+ "type": "array"
+ },
+ "open": {
+ "type": "boolean"
+ },
+ "slug": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "required": [
+ "gain",
+ "id",
+ "members",
+ "open",
+ "slug"
+ ],
+ "type": "object"
+ },
+ "SearchTracksResponse": {
+ "additionalProperties": false,
+ "properties": {
+ "total": {
+ "type": "integer"
+ }
+ },
+ "required": [
+ "total"
+ ],
+ "type": "object"
+ },
+ "Source": {
+ "enum": [
+ "netease",
+ "qq",
+ "link"
+ ],
+ "type": "string"
+ },
+ "Track": {
+ "additionalProperties": false,
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "source": {
+ "const": "link"
+ }
+ },
+ "required": [
+ "source"
+ ]
+ },
+ "then": {
+ "properties": {
+ "id": {
+ "pattern": "^https?://\\S+$",
+ "x-maxBytes": 2048
+ }
+ }
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "source": {
+ "const": "netease"
+ }
+ },
+ "required": [
+ "source"
+ ]
+ },
+ "then": {
+ "properties": {
+ "id": {
+ "pattern": "^[0-9]+$"
+ }
+ }
+ }
+ }
+ ],
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "source": {
+ "$ref": "#/components/schemas/Source"
+ }
+ },
+ "required": [
+ "id",
+ "source"
+ ],
+ "type": "object"
+ }
+ },
+ "securitySchemes": {
+ "adminToken": {
+ "scheme": "bearer",
+ "type": "http"
+ },
+ "memberToken": {
+ "scheme": "bearer",
+ "type": "http"
+ },
+ "ticket": {
+ "description": "进房票据或常驻房间的 API Key",
+ "scheme": "bearer",
+ "type": "http"
+ }
+ }
+ },
+ "info": {
+ "title": "Sample",
+ "version": "362d144af81ba8bf"
+ },
+ "openapi": "3.1.0",
+ "paths": {
+ "/api/admin/rooms/{id}": {
+ "patch": {
+ "operationId": "adminUpdateRoom",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AdminUpdateRoomRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "204": {
+ "description": "成功"
+ },
+ "default": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "失败"
+ }
+ },
+ "security": [
+ {
+ "adminToken": []
+ }
+ ]
+ }
+ },
+ "/api/members": {
+ "post": {
+ "operationId": "createMember",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateMemberRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "201": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateMemberResponse"
+ }
+ }
+ },
+ "description": "成功"
+ },
+ "default": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "失败"
+ }
+ },
+ "security": [],
+ "summary": "创建成员"
+ }
+ },
+ "/api/room": {
+ "get": {
+ "operationId": "roomSync",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Room"
+ }
+ }
+ },
+ "description": "成功"
+ },
+ "default": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "失败"
+ }
+ },
+ "security": [
+ {
+ "ticket": []
+ }
+ ],
+ "summary": "获取房间快照"
+ }
+ },
+ "/api/room/queue": {
+ "post": {
+ "operationId": "queueAdd",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/QueueAddRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "201": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Empty"
+ }
+ }
+ },
+ "description": "成功",
+ "headers": {
+ "Location": {
+ "description": "新资源的地址",
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "default": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "失败"
+ }
+ },
+ "security": [
+ {
+ "ticket": []
+ }
+ ]
+ }
+ },
+ "/api/room/queue/{entryId}": {
+ "delete": {
+ "operationId": "queueRemove",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "entryId",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "成功"
+ },
+ "default": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "失败"
+ }
+ },
+ "security": [
+ {
+ "ticket": []
+ }
+ ]
+ },
+ "patch": {
+ "operationId": "queueMove",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "entryId",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": false,
+ "properties": {
+ "position": {
+ "minimum": 1,
+ "type": "integer"
+ }
+ },
+ "required": [
+ "position"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "204": {
+ "description": "成功"
+ },
+ "default": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "失败"
+ }
+ },
+ "security": [
+ {
+ "ticket": []
+ }
+ ]
+ }
+ },
+ "/api/room/search/tracks": {
+ "get": {
+ "operationId": "searchTracks",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "keyword",
+ "required": true,
+ "schema": {
+ "maxLength": 500,
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "limit",
+ "required": false,
+ "schema": {
+ "description": "条数上限",
+ "maximum": 100,
+ "minimum": 1,
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "source",
+ "required": true,
+ "schema": {
+ "$ref": "#/components/schemas/Source"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SearchTracksResponse"
+ }
+ }
+ },
+ "description": "成功"
+ },
+ "default": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "失败"
+ }
+ },
+ "security": [
+ {
+ "ticket": []
+ }
+ ]
+ }
+ },
+ "/api/rooms": {
+ "post": {
+ "operationId": "createRoom",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateRoomRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "201": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateRoomResponse"
+ }
+ }
+ },
+ "description": "成功",
+ "headers": {
+ "Location": {
+ "description": "新资源的地址",
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "default": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "失败"
+ }
+ },
+ "security": []
+ }
+ },
+ "/api/rooms/{ref}": {
+ "get": {
+ "operationId": "getRoom",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "ref",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Room"
+ }
+ }
+ },
+ "description": "成功"
+ },
+ "default": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "失败"
+ }
+ },
+ "security": []
+ }
+ },
+ "/api/rooms/{ref}/seats/{seat}": {
+ "get": {
+ "operationId": "getSeat",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "ref",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "path",
+ "name": "seat",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Member"
+ }
+ }
+ },
+ "description": "成功"
+ },
+ "default": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "失败"
+ }
+ },
+ "security": [
+ {
+ "memberToken": []
+ }
+ ],
+ "summary": "座位上的成员,座位号 从 1 开始 & 不超过 8"
+ }
+ }
+ }
+}
diff --git a/cmd/protocolgen/testdata/sample/protocol/types.gen.go b/cmd/protocolgen/testdata/sample/protocol/types.gen.go
new file mode 100644
index 0000000..b03f45f
--- /dev/null
+++ b/cmd/protocolgen/testdata/sample/protocol/types.gen.go
@@ -0,0 +1,179 @@
+// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
+
+package protocol
+
+import "encoding/json"
+
+const Version = "362d144af81ba8bf"
+
+type AdminUpdateRoomRequest struct {
+ Homepage *string `json:"homepage,omitempty"`
+ Limit *int64 `json:"limit,omitempty"`
+ Name *string `json:"name,omitempty"`
+ Note *string `json:"note,omitempty"`
+ Slug *string `json:"slug,omitempty"`
+}
+
+type CreateMemberRequest struct{}
+
+type CreateMemberResponse struct {
+ Member Member `json:"member"`
+ Token string `json:"token"`
+}
+
+type CreateRoomRequest struct {
+ Name string `json:"name"`
+}
+
+type CreateRoomResponse struct {
+ ID string `json:"id"`
+}
+
+type Empty struct{}
+
+type ErrorCode string
+
+const (
+ ErrorCodeInvalidArgument ErrorCode = "INVALID_ARGUMENT"
+ ErrorCodeNotFound ErrorCode = "NOT_FOUND"
+ ErrorCodeClientOutdated ErrorCode = "CLIENT_OUTDATED"
+)
+
+type ErrorInfo struct {
+ Code ErrorCode `json:"code"`
+ Message string `json:"message"`
+}
+
+type HelloRequest struct {
+ ClientTimeMs int64 `json:"clientTimeMs"`
+ RoomID string `json:"roomId"`
+}
+
+type HelloResponse struct {
+ Owner *Member `json:"owner"`
+ Room Room `json:"room"`
+}
+
+type Member struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+type MembersUpdatedEvent struct {
+ Members []Member `json:"members"`
+ Note *string `json:"note,omitempty"`
+}
+
+func (v MembersUpdatedEvent) MarshalJSON() ([]byte, error) {
+ type plain MembersUpdatedEvent
+ if v.Members == nil {
+ v.Members = []Member{}
+ }
+ return json.Marshal(plain(v))
+}
+
+type QueueAddRequest struct {
+ Keyword *string `json:"keyword,omitempty"`
+ Requester *string `json:"requester,omitempty"`
+ Source *Source `json:"source,omitempty"`
+ Track *Track `json:"track,omitempty"`
+}
+
+type QueueMoveRequest struct {
+ EntryID string `json:"entryId"`
+ Position int64 `json:"position"`
+}
+
+type QueueRemoveRequest struct {
+ EntryID string `json:"entryId"`
+}
+
+type Room struct {
+ Gain float64 `json:"gain"`
+ ID string `json:"id"`
+ Members []Member `json:"members"`
+ Open bool `json:"open"`
+ Slug *string `json:"slug"`
+}
+
+func (v Room) MarshalJSON() ([]byte, error) {
+ type plain Room
+ if v.Members == nil {
+ v.Members = []Member{}
+ }
+ return json.Marshal(plain(v))
+}
+
+type RoomSyncRequest struct{}
+
+type SearchTracksRequest struct {
+ Keyword string `json:"keyword"`
+ Limit *int64 `json:"limit,omitempty"`
+ Source Source `json:"source"`
+}
+
+type SearchTracksResponse struct {
+ Total int64 `json:"total"`
+}
+
+type Source string
+
+const (
+ SourceNetease Source = "netease"
+ SourceQQ Source = "qq"
+ SourceLink Source = "link"
+)
+
+type Track struct {
+ ID string `json:"id"`
+ Source Source `json:"source"`
+}
+
+type ErrorResponse struct {
+ Error ErrorInfo `json:"error"`
+}
+
+const (
+ RequestHello = "hello"
+ RequestRoomSync = "room.sync"
+ RequestQueueAdd = "queue.add"
+ RequestQueueRemove = "queue.remove"
+ RequestQueueMove = "queue.move"
+ RequestSearchTracks = "search.tracks"
+)
+
+const (
+ EventError = "error"
+ EventMembersUpdated = "members.updated"
+)
+
+const (
+ CloseInvalidArgument = 1008
+ CloseClientOutdated = 4426
+)
+
+var (
+ RouteCreateMember = Route{Pattern: "POST /api/members", Auth: AuthNone, Status: 201}
+ RouteGetRoom = Route{Pattern: "GET /api/rooms/{ref}", Auth: AuthNone, Status: 200}
+ RouteCreateRoom = Route{Pattern: "POST /api/rooms", Auth: AuthNone, Status: 201, Location: true}
+ RouteGetSeat = Route{Pattern: "GET /api/rooms/{ref}/seats/{seat}", Auth: AuthMember, Status: 200}
+ RouteAdminUpdateRoom = Route{Pattern: "PATCH /api/admin/rooms/{id}", Auth: AuthAdmin, Status: 204}
+)
+
+var RoomRoutes = []RoomRoute{
+ {Route: Route{Pattern: "GET /api/room", Auth: AuthTicket, Status: 200}, Type: RequestRoomSync},
+ {Route: Route{Pattern: "POST /api/room/queue", Auth: AuthTicket, Status: 201, Location: true}, Type: RequestQueueAdd, body: true},
+ {Route: Route{Pattern: "DELETE /api/room/queue/{entryId}", Auth: AuthTicket, Status: 204}, Type: RequestQueueRemove, params: []string{"entryId"}},
+ {Route: Route{Pattern: "PATCH /api/room/queue/{entryId}", Auth: AuthTicket, Status: 204}, Type: RequestQueueMove, params: []string{"entryId"}, body: true},
+ {Route: Route{Pattern: "GET /api/room/search/tracks", Auth: AuthTicket, Status: 200}, Type: RequestSearchTracks, query: map[string]queryKind{"keyword": queryString, "limit": queryInteger, "source": queryString}},
+}
+
+var errorCodeHTTPStatus = map[ErrorCode]int{
+ ErrorCodeInvalidArgument: 400,
+ ErrorCodeNotFound: 404,
+}
+
+// IDL 没有为某个取值指定 HTTP 状态码时返回 0。
+func (v ErrorCode) HTTPStatus() int {
+ return errorCodeHTTPStatus[v]
+}
diff --git a/cmd/protocolgen/testdata/sample/web/src/core/api/http.gen.ts b/cmd/protocolgen/testdata/sample/web/src/core/api/http.gen.ts
new file mode 100644
index 0000000..966bbad
--- /dev/null
+++ b/cmd/protocolgen/testdata/sample/web/src/core/api/http.gen.ts
@@ -0,0 +1,51 @@
+// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
+
+import type { ErrorInfo, Member, Room } from '../realtime/protocol.gen'
+
+export interface AdminUpdateRoomRequest {
+ homepage?: string
+ limit?: number
+ name?: string
+ note?: string
+ slug?: string
+}
+
+export type CreateMemberRequest = Record
+
+export interface CreateMemberResponse {
+ member: Member
+ token: string
+}
+
+export interface CreateRoomRequest {
+ name: string
+}
+
+export interface CreateRoomResponse {
+ id: string
+}
+
+export interface ErrorResponse {
+ error: ErrorInfo
+}
+
+export type Auth = 'none' | 'member' | 'admin'
+
+export interface HttpRequest {
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
+ path: string
+ auth: Auth
+ body?: unknown
+}
+
+export type HttpSend = (request: HttpRequest) => Promise
+
+export function httpClient(send: HttpSend) {
+ return {
+ createMember: () => send({ method: 'POST', path: '/api/members', auth: 'none', body: {} }),
+ getRoom: (ref: string) => send({ method: 'GET', path: `/api/rooms/${encodeURIComponent(ref)}`, auth: 'none' }),
+ createRoom: (body: CreateRoomRequest) => send({ method: 'POST', path: '/api/rooms', auth: 'none', body }),
+ getSeat: (ref: string, seat: string) => send({ method: 'GET', path: `/api/rooms/${encodeURIComponent(ref)}/seats/${encodeURIComponent(seat)}`, auth: 'member' }),
+ adminUpdateRoom: (id: string, body: AdminUpdateRoomRequest) => send({ method: 'PATCH', path: `/api/admin/rooms/${encodeURIComponent(id)}`, auth: 'admin', body }),
+ }
+}
diff --git a/cmd/protocolgen/testdata/sample/web/src/core/realtime/protocol.gen.test.ts b/cmd/protocolgen/testdata/sample/web/src/core/realtime/protocol.gen.test.ts
new file mode 100644
index 0000000..79eb1c3
--- /dev/null
+++ b/cmd/protocolgen/testdata/sample/web/src/core/realtime/protocol.gen.test.ts
@@ -0,0 +1,15 @@
+// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
+
+import { readFileSync } from 'node:fs'
+import { describe, expect, it } from 'vitest'
+import { isWebsite } from './protocol.gen'
+
+function cases(name: string): { value: string, valid: boolean }[] {
+ return JSON.parse(readFileSync(new URL(`../../../../protocol/testdata/${name}.json`, import.meta.url), 'utf8')) as { value: string, valid: boolean }[]
+}
+
+describe('isWebsite', () => {
+ it('对 protocol/testdata/Website.json 中的每个用例与服务端的判断相同', () => {
+ expect(cases('Website').filter(c => isWebsite(c.value) !== c.valid).map(c => c.value)).toEqual([])
+ })
+})
diff --git a/cmd/protocolgen/testdata/sample/web/src/core/realtime/protocol.gen.ts b/cmd/protocolgen/testdata/sample/web/src/core/realtime/protocol.gen.ts
new file mode 100644
index 0000000..640a91a
--- /dev/null
+++ b/cmd/protocolgen/testdata/sample/web/src/core/realtime/protocol.gen.ts
@@ -0,0 +1,197 @@
+// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
+
+export const PROTOCOL_VERSION = '362d144af81ba8bf'
+
+export type Empty = Record
+
+export type ErrorCode = 'INVALID_ARGUMENT' | 'NOT_FOUND' | 'CLIENT_OUTDATED'
+
+export interface ErrorInfo {
+ code: ErrorCode
+ message: string
+}
+
+export interface HelloRequest {
+ clientTimeMs: number
+ roomId: string
+}
+
+export interface HelloResponse {
+ owner: Member | null
+ room: Room
+}
+
+export interface Member {
+ id: string
+ name: string
+}
+
+export interface MembersUpdatedEvent {
+ members: Member[]
+ note?: string
+}
+
+export type QueueAddRequest = { track: Track, requester?: string } | { source: Source, keyword: string, requester?: string }
+
+export interface QueueMoveRequest {
+ entryId: string
+ position: number
+}
+
+export interface QueueRemoveRequest {
+ entryId: string
+}
+
+export interface Room {
+ gain: number
+ id: string
+ members: Member[]
+ open: boolean
+ slug: string | null
+}
+
+export type RoomSyncRequest = Record
+
+export interface SearchTracksRequest {
+ keyword: string
+ limit?: number
+ source: Source
+}
+
+export interface SearchTracksResponse {
+ total: number
+}
+
+export type Source = 'netease' | 'qq' | 'link'
+
+export interface Track {
+ id: string
+ source: Source
+}
+
+export interface RequestDataMap {
+ 'hello': HelloRequest
+ 'room.sync': RoomSyncRequest
+ 'queue.add': QueueAddRequest
+ 'queue.remove': QueueRemoveRequest
+ 'queue.move': QueueMoveRequest
+ 'search.tracks': SearchTracksRequest
+}
+
+export interface ResponseDataMap {
+ 'hello': HelloResponse
+ 'room.sync': Room
+ 'queue.add': Empty
+ 'queue.remove': Empty
+ 'queue.move': Empty
+ 'search.tracks': SearchTracksResponse
+}
+
+export interface ConnectionEventDataMap {
+ 'error': ErrorInfo
+}
+
+export interface RoomEventDataMap {
+ 'members.updated': MembersUpdatedEvent
+}
+
+export type RequestType = keyof RequestDataMap
+export type ConnectionEventType = keyof ConnectionEventDataMap
+export type RoomEventType = keyof RoomEventDataMap
+
+export interface RequestMessage {
+ kind: 'request'
+ type: T
+ id: string
+ data: RequestDataMap[T]
+}
+
+export type ResponseMessage = {
+ [K in T]: { kind: 'response', type: K, id: string } & (
+ | { data: ResponseDataMap[K], error?: undefined }
+ | { data: Empty, error: ErrorInfo }
+ )
+}[T]
+
+export type ConnectionEventMessage = {
+ [K in T]: { kind: 'event', type: K, data: ConnectionEventDataMap[K] }
+}[T]
+
+export type RoomEventMessage = {
+ [K in T]: { kind: 'event', type: K, term: number, seq: number, data: RoomEventDataMap[K] }
+}[T]
+
+export type ServerMessage = ResponseMessage | ConnectionEventMessage | RoomEventMessage
+
+export const CloseCode = {
+ invalidArgument: 1008,
+ clientOutdated: 4426,
+} as const
+
+export type Send = (type: T, data: RequestDataMap[T]) => Promise
+
+export function roomRequests(send: Send) {
+ return {
+ roomSync: () => send('room.sync', {}),
+ queueAdd: (data: QueueAddRequest) => send('queue.add', data),
+ queueRemove: (data: QueueRemoveRequest) => send('queue.remove', data),
+ queueMove: (data: QueueMoveRequest) => send('queue.move', data),
+ searchTracks: (data: SearchTracksRequest) => send('search.tracks', data),
+ }
+}
+
+function validURL(value: string): boolean {
+ if (/[\u0000-\u0020\u007F\\]/.test(value))
+ return false
+ const scheme = /^https?:\/\//i.exec(value)
+ if (scheme === null)
+ return false
+ const rest = value.slice(scheme[0].length)
+ const end = rest.search(/[/?#]/)
+ const authority = end < 0 ? rest : rest.slice(0, end)
+ const tail = end < 0 ? '' : rest.slice(end)
+ let host = authority
+ let port = ''
+ if (authority.startsWith('[')) {
+ const close = authority.indexOf(']') + 1
+ host = authority.slice(0, close)
+ if (close === 0 || !/^\[[0-9A-Fa-f:.]+\]$/.test(host))
+ return false
+ const after = authority.slice(close)
+ if (after !== '') {
+ if (!after.startsWith(':'))
+ return false
+ port = after.slice(1)
+ }
+ }
+ else {
+ const colon = authority.indexOf(':')
+ if (colon >= 0) {
+ host = authority.slice(0, colon)
+ port = authority.slice(colon + 1)
+ }
+ if (!/^[\w.~\u0080-\u{10FFFF}-]+$/u.test(host))
+ return false
+ const labels = host.replace(/\.$/, '').split('.')
+ if (/^(?:\d+|0x[0-9a-f]*)$/i.test(labels[labels.length - 1]) && !/^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/.test(host))
+ return false
+ }
+ if (!/^\d*$/.test(port) || Number(port) > 65535)
+ return false
+ const hash = tail.indexOf('#')
+ const path = (hash < 0 ? tail : tail.slice(0, hash)).split('?')[0]
+ const fragment = hash < 0 ? '' : tail.slice(hash + 1)
+ const badEscape = /%(?![0-9A-Fa-f]{2})/
+ if (badEscape.test(path) || badEscape.test(fragment))
+ return false
+ try {
+ return new URL(value).hostname !== ''
+ }
+ catch {
+ return false
+ }
+}
+
+export function isWebsite(value: string): boolean {
+ return [...value].length <= 200 && new TextEncoder().encode(value).length <= 600 && validURL(value)
+}
diff --git a/internal/adapter/in/api/api_test.go b/internal/adapter/in/api/api_test.go
new file mode 100644
index 0000000..6c7d8d9
--- /dev/null
+++ b/internal/adapter/in/api/api_test.go
@@ -0,0 +1,426 @@
+package api
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "slices"
+ "strings"
+ "testing"
+
+ "github.com/prometheus/client_golang/prometheus"
+ "go.opentelemetry.io/otel/trace/noop"
+
+ "github.com/dihemoe/alisten/internal/app/admin"
+ "github.com/dihemoe/alisten/internal/app/identity"
+ "github.com/dihemoe/alisten/internal/app/lobby"
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/app/port/porttest"
+ "github.com/dihemoe/alisten/internal/app/realtime"
+ "github.com/dihemoe/alisten/internal/app/site"
+ "github.com/dihemoe/alisten/internal/domain/member"
+ "github.com/dihemoe/alisten/internal/domain/room"
+ "github.com/dihemoe/alisten/internal/lib/logctx"
+ "github.com/dihemoe/alisten/protocol"
+)
+
+const allowedOrigin = "https://allowed.example"
+
+// 没有调用 realtime.Start:这些测试不运行房间。
+func services(store *porttest.Store, rooms port.RoomDirectory, adminToken string) Services {
+ music := newCatalog(rooms, 10)
+ node := porttest.NewRedis().Node("a")
+ _, gateway := realtime.New(store, rooms, music, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
+ l := lobby.New(rooms, node)
+ return Services{
+ Identity: identity.New(store, rooms, store, gateway),
+ Lobby: l,
+ Admin: admin.New(adminToken, l, rooms, store, store, porttest.NewNetEaseQRLogin(), porttest.NewQQQRLogin(), porttest.NewBilibiliQRLogin(), porttest.NewYouTubeCookies(), gateway),
+ Gateway: gateway,
+ Catalog: music,
+ Site: site.New(store),
+ }
+}
+
+func start(t *testing.T, s Services) (*httptest.Server, *bytes.Buffer) {
+ t.Helper()
+ var logs bytes.Buffer
+ server := httptest.NewServer(SameOrigin([]string{allowedOrigin, "http://localhost:9876"}, New(s, prometheus.NewRegistry(), noop.NewTracerProvider(), slog.New(logctx.NewHandler(slog.NewJSONHandler(&logs, nil))))))
+ t.Cleanup(server.Close)
+ return server, &logs
+}
+
+type response struct {
+ status int
+ body string
+}
+
+func send(t *testing.T, server *httptest.Server, method, path, body string, header ...string) response {
+ t.Helper()
+ resp, _ := exchange(t, server, method, path, body, header...)
+ return resp
+}
+
+func exchange(t *testing.T, server *httptest.Server, method, path, body string, header ...string) (response, http.Header) {
+ t.Helper()
+ return roundTrip(t, server.Client(), method, server.URL+path, body, header...)
+}
+
+func roundTrip(t *testing.T, client *http.Client, method, url, body string, header ...string) (response, http.Header) {
+ t.Helper()
+ req, err := http.NewRequestWithContext(t.Context(), method, url, strings.NewReader(body))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for i := 0; i+1 < len(header); i += 2 {
+ req.Header.Set(header[i], header[i+1])
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ b, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return response{status: resp.StatusCode, body: strings.TrimSpace(string(b))}, resp.Header
+}
+
+func checkCreated(t *testing.T, what string, resp response, header http.Header, location string) {
+ t.Helper()
+ if resp.status != http.StatusCreated || header.Get("Location") != location {
+ t.Errorf("%s = %#v、Location %q,期望 201、Location %q", what, resp, header.Get("Location"), location)
+ }
+}
+
+func checkNoContent(t *testing.T, what string, resp response, header http.Header) {
+ t.Helper()
+ if resp != (response{status: http.StatusNoContent}) || header.Get("Content-Type") != "" {
+ t.Errorf("%s = %#v、Content-Type %q,期望 204,没有响应体和 Content-Type", what, resp, header.Get("Content-Type"))
+ }
+}
+
+func failure(code protocol.ErrorCode, message string) response {
+ b, _ := json.Marshal(protocol.ErrorResponse{Error: protocol.ErrorInfo{Code: code, Message: message}})
+ return response{status: code.HTTPStatus(), body: string(b)}
+}
+
+func memberToken(t *testing.T, server *httptest.Server) string {
+ t.Helper()
+ var created protocol.CreateMemberResponse
+ if err := json.Unmarshal([]byte(send(t, server, http.MethodPost, "/api/members", "{}").body), &created); err != nil {
+ t.Fatal(err)
+ }
+ return created.MemberToken
+}
+
+func TestSameOrigin(t *testing.T) {
+ store := porttest.New()
+ server, _ := start(t, services(store, store, ""))
+
+ for _, header := range [][]string{{"Origin", "https://allowed.example"}, {"Origin", "http://localhost:9876"}, nil} {
+ if got := send(t, server, http.MethodPost, "/api/members", "{}", header...); got.status != http.StatusCreated {
+ t.Errorf("请求头 %q 时 = %#v,期望成功", header, got)
+ }
+ }
+ want := failure(protocol.ErrorCodeForbidden, "服务器不接受从这个网址发出的操作,请用网站的正式地址打开。")
+ if got := send(t, server, http.MethodPost, "/api/members", "{}", "Origin", "https://evil.example"); got != want {
+ t.Errorf("列表外来源的请求 = %#v,期望 %#v", got, want)
+ }
+}
+
+func TestRequestBodyLimit(t *testing.T) {
+ store := porttest.New()
+ server, _ := start(t, services(store, store, ""))
+ body := `{"name":"房间","description":""}`
+ fits := body + strings.Repeat(" ", 64<<10-len(body))
+
+ if got := send(t, server, http.MethodPost, "/api/rooms", fits); got.status != http.StatusCreated {
+ t.Errorf("64 KiB 的请求体 = %#v,期望成功", got)
+ }
+ want := failure(protocol.ErrorCodeInvalidArgument, "请求体不能超过 64 KiB")
+ if got := send(t, server, http.MethodPost, "/api/rooms", fits+" "); got != want {
+ t.Errorf("超过 64 KiB 的请求体 = %#v,期望 %#v", got, want)
+ }
+}
+
+func TestMalformedPathParametersAreNotFound(t *testing.T) {
+ store := porttest.New()
+ server, logs := start(t, services(store, store, ""))
+ if _, err := store.CreateRoom(t.Context(), port.NewRoom{Kind: room.Persistent, Name: "大厅", Slug: "hall"}); err != nil {
+ t.Fatal(err)
+ }
+
+ if got := send(t, server, http.MethodGet, "/api/rooms/hall", ""); got.status != http.StatusOK {
+ t.Fatalf("按别名查找 = %#v,期望成功", got)
+ }
+ want := failure(protocol.ErrorCodeNotFound, "房间不存在")
+ for _, path := range []string{"/api/rooms/%FF"} {
+ if got := send(t, server, http.MethodGet, path, ""); got != want {
+ t.Errorf("GET %s = %#v,期望 %#v", path, got, want)
+ }
+ }
+ if logs.Len() != 0 {
+ t.Errorf("不存在的房间不是内部错误,不应记录日志:\n%s", logs)
+ }
+}
+
+func TestUnknownEndpointsAreJSONNotFound(t *testing.T) {
+ store := porttest.New()
+ server, _ := start(t, services(store, store, ""))
+
+ want := failure(protocol.ErrorCodeNotFound, "接口不存在")
+ for _, tt := range []struct{ method, path string }{
+ {http.MethodGet, "/api/nope"},
+ {http.MethodGet, "/api/members"},
+ } {
+ if got := send(t, server, tt.method, tt.path, ""); got != want {
+ t.Errorf("%s %s = %#v,期望 %#v", tt.method, tt.path, got, want)
+ }
+ }
+ if got := send(t, server, http.MethodGet, "/api/rooms", ""); got.status != http.StatusOK {
+ t.Errorf("GET /api/rooms = %#v,期望成功", got)
+ }
+}
+
+func TestCredentialsAreCheckedFirst(t *testing.T) {
+ store := porttest.New()
+ disabled, _ := start(t, services(store, store, ""))
+ enabled, _ := start(t, services(store, store, "secret"))
+
+ tests := []struct {
+ name string
+ server *httptest.Server
+ method string
+ path string
+ header []string
+ want response
+ }{
+ {"后台没有启用", disabled, http.MethodPost, "/api/admin/rooms", nil, failure(protocol.ErrorCodeForbidden, "后台没有启用:服务端没有配置管理 token。")},
+ {"后台没有启用时带着 token", disabled, http.MethodPost, "/api/admin/rooms", []string{"Authorization", "Bearer secret"}, failure(protocol.ErrorCodeForbidden, "后台没有启用:服务端没有配置管理 token。")},
+ {"管理 token 错误", enabled, http.MethodPost, "/api/admin/rooms", []string{"Authorization", "Bearer wrong"}, failure(protocol.ErrorCodeUnauthorized, "管理 token 无效")},
+ {"缺少成员 token", enabled, http.MethodPatch, "/api/me", nil, failure(protocol.ErrorCodeUnauthorized, "成员 token 无效")},
+ {"成员 token 错误", enabled, http.MethodPost, "/api/rooms/hall/tickets", []string{"Authorization", "Bearer wrong"}, failure(protocol.ErrorCodeUnauthorized, "成员 token 无效")},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := send(t, tt.server, tt.method, tt.path, "不是 JSON", tt.header...); got != tt.want {
+ t.Errorf("%s %s = %#v,期望 %#v", tt.method, tt.path, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestBearerScheme(t *testing.T) {
+ store := porttest.New()
+ server, _ := start(t, services(store, store, ""))
+ token := memberToken(t, server)
+
+ for _, authorization := range []string{"Bearer " + token, "bearer " + token, "Bearer " + token} {
+ if got := send(t, server, http.MethodPatch, "/api/me", "{}", "Authorization", authorization); got.status != http.StatusOK {
+ t.Errorf("Authorization: %s = %#v,期望成功", authorization, got)
+ }
+ }
+ for _, authorization := range []string{token, "Basic " + token, "Bearer" + token, "Bearer\t" + token} {
+ if got := send(t, server, http.MethodPatch, "/api/me", "{}", "Authorization", authorization); got.status != http.StatusUnauthorized {
+ t.Errorf("Authorization: %s = %#v,期望 401", authorization, got)
+ }
+ }
+}
+
+func TestErrorInfo(t *testing.T) {
+ tests := []struct {
+ err error
+ want protocol.ErrorInfo
+ expected bool
+ }{
+ {fmt.Errorf("查询:%w", context.DeadlineExceeded), protocol.ErrorInfo{Code: protocol.ErrorCodeUnavailable, Message: "服务器没有及时响应,请稍后再试。"}, true},
+ }
+ for _, tt := range tests {
+ if got, expected := errorInfo(tt.err); got != tt.want || expected != tt.expected {
+ t.Errorf("errorInfo(%v) = %#v, %v,期望 %#v, %v", tt.err, got, expected, tt.want, tt.expected)
+ }
+ }
+}
+
+type brokenRooms struct{ *porttest.Store }
+
+func (brokenRooms) ListRooms(context.Context) ([]port.RoomRecord, error) {
+ return nil, errors.New("连接中断")
+}
+
+func TestUnexpectedErrorIsLogged(t *testing.T) {
+ store := porttest.New()
+ server, logs := start(t, services(store, brokenRooms{store}, ""))
+
+ if got, want := send(t, server, http.MethodGet, "/api/rooms", ""), failure(protocol.ErrorCodeInternal, "服务器出错了,请稍后再试。"); got != want {
+ t.Errorf("GET /api/rooms = %#v,期望 %#v", got, want)
+ }
+ var entry map[string]any
+ if err := json.Unmarshal(logs.Bytes(), &entry); err != nil {
+ t.Fatalf("应记录一行 JSON 日志:%v\n%s", err, logs)
+ }
+ if entry["error"] != "连接中断" || entry["path"] != "/api/rooms" {
+ t.Errorf("日志 = %v,期望带有错误和路径", entry)
+ }
+}
+
+type abortingRooms struct{ *porttest.Store }
+
+func (abortingRooms) ListRooms(context.Context) ([]port.RoomRecord, error) {
+ panic(http.ErrAbortHandler)
+}
+
+func TestAbortHandlerIsLeftToNetHTTP(t *testing.T) {
+ store := porttest.New()
+ var logs bytes.Buffer
+ handler := New(services(store, abortingRooms{store}, ""), prometheus.NewRegistry(), noop.NewTracerProvider(), slog.New(slog.NewJSONHandler(&logs, nil)))
+
+ defer func() {
+ if value := recover(); value != http.ErrAbortHandler {
+ t.Errorf("recover() = %v,期望 http.ErrAbortHandler", value)
+ }
+ if logs.Len() != 0 {
+ t.Errorf("不应记录日志:\n%s", logs.String())
+ }
+ }()
+ handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/rooms", nil))
+}
+
+type brokenProfiles struct{ *porttest.Store }
+
+func (brokenProfiles) UpdateMember(context.Context, string, *string, *string) (member.Member, error) {
+ return member.Member{}, errors.New("连接中断")
+}
+
+func TestUnexpectedErrorLogCarriesMember(t *testing.T) {
+ store := porttest.New()
+ s := services(store, store, "")
+ s.Identity = identity.New(brokenProfiles{store}, store, store, s.Gateway)
+ server, logs := start(t, s)
+ var created protocol.CreateMemberResponse
+ if err := json.Unmarshal([]byte(send(t, server, http.MethodPost, "/api/members", "{}").body), &created); err != nil {
+ t.Fatal(err)
+ }
+
+ if got, want := send(t, server, http.MethodPatch, "/api/me", `{"name":"新名字"}`, "Authorization", "Bearer "+created.MemberToken), failure(protocol.ErrorCodeInternal, "服务器出错了,请稍后再试。"); got != want {
+ t.Errorf("PATCH /api/me = %#v,期望 %#v", got, want)
+ }
+ var entry map[string]any
+ if err := json.Unmarshal(logs.Bytes(), &entry); err != nil {
+ t.Fatalf("应记录一行 JSON 日志:%v\n%s", err, logs)
+ }
+ if entry["member_id"] != created.Member.ID || entry["path"] != "/api/me" {
+ t.Errorf("日志 = %v,期望带着成员 %s 和路径", entry, created.Member.ID)
+ }
+}
+
+func TestFailureLogsCarryTheRequestID(t *testing.T) {
+ var logs bytes.Buffer
+ s := &server{logger: slog.New(logctx.NewHandler(slog.NewJSONHandler(&logs, nil)))}
+ broken := errors.New("连接中断")
+ request := httptest.NewRequest(http.MethodPost, "/api/room/messages", nil)
+
+ s.fail(t.Context(), httptest.NewRecorder(), request, &realtime.RequestError{ID: "a/r7", Err: broken})
+ s.requestError(t.Context(), "chat.send", &realtime.RequestError{ID: "a/r8", Err: broken})
+ s.fail(t.Context(), httptest.NewRecorder(), request, broken)
+ s.requestError(t.Context(), "search.tracks", broken)
+
+ var got []any
+ for line := range strings.Lines(strings.TrimSpace(logs.String())) {
+ var entry map[string]any
+ if err := json.Unmarshal([]byte(line), &entry); err != nil {
+ t.Fatalf("日志不是 JSON:%v\n%s", err, line)
+ }
+ got = append(got, entry["request_id"])
+ }
+ if want := []any{"a/r7", "a/r8", nil, nil}; !slices.Equal(got, want) {
+ t.Errorf("日志中的 request_id = %v,期望 %v\n%s", got, want, logs.String())
+ }
+}
+
+type writeRecorder struct {
+ http.ResponseWriter
+ wrote bool
+}
+
+func (w *writeRecorder) WriteHeader(status int) {
+ w.wrote = true
+ w.ResponseWriter.WriteHeader(status)
+}
+
+func (w *writeRecorder) Write(b []byte) (int, error) {
+ w.wrote = true
+ return w.ResponseWriter.Write(b)
+}
+
+func TestClientGoneWhileUploadingIsSilent(t *testing.T) {
+ store := porttest.New()
+ var logs bytes.Buffer
+ handler := New(services(store, store, ""), prometheus.NewRegistry(), noop.NewTracerProvider(), slog.New(slog.NewJSONHandler(&logs, nil)))
+ wrote := make(chan bool, 1)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ recorder := &writeRecorder{ResponseWriter: w}
+ handler.ServeHTTP(recorder, r)
+ wrote <- recorder.wrote
+ }))
+ t.Cleanup(server.Close)
+
+ conn, err := net.Dial("tcp", server.Listener.Addr().String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := io.WriteString(conn, "POST /api/members HTTP/1.1\r\nHost: alisten.example\r\nContent-Length: 100\r\n\r\n{"); err != nil {
+ t.Fatal(err)
+ }
+ if err := conn.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ if <-wrote {
+ t.Error("客户端已经断开,不应再写响应")
+ }
+ if logs.Len() != 0 {
+ t.Errorf("客户端断开不是内部错误,不应记录日志:\n%s", logs.String())
+ }
+}
+
+func TestUnreadableBodyIsInvalidArgument(t *testing.T) {
+ store := porttest.New()
+ server, logs := start(t, services(store, store, ""))
+
+ conn, err := net.Dial("tcp", server.Listener.Addr().String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = conn.Close() }()
+ if _, err := io.WriteString(conn, "POST /api/members HTTP/1.1\r\nHost: alisten.example\r\nTransfer-Encoding: chunked\r\n\r\nzz\r\n{}\r\n0\r\n\r\n"); err != nil {
+ t.Fatal(err)
+ }
+ resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ b, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ want := failure(protocol.ErrorCodeInvalidArgument, "无法读取请求体")
+ if got := (response{status: resp.StatusCode, body: strings.TrimSpace(string(b))}); got != want {
+ t.Errorf("分块编码不合法的请求 = %#v,期望 %#v", got, want)
+ }
+ if logs.Len() != 0 {
+ t.Errorf("请求体不合法不是内部错误,不应记录日志:\n%s", logs)
+ }
+}
diff --git a/internal/adapter/in/api/book_title_test.go b/internal/adapter/in/api/book_title_test.go
new file mode 100644
index 0000000..4199872
--- /dev/null
+++ b/internal/adapter/in/api/book_title_test.go
@@ -0,0 +1,14 @@
+package api
+
+import "testing"
+
+func TestBookTitle(t *testing.T) {
+ for title, want := range map[string]string{
+ "晴天": "《晴天》",
+ "日语填词《5:20AM》(完整版)": "日语填词《5:20AM》(完整版)",
+ } {
+ if got := bookTitle(title); got != want {
+ t.Errorf("bookTitle(%q) = %q,期望 %q", title, got, want)
+ }
+ }
+}
diff --git a/internal/adapter/in/api/catalog_test.go b/internal/adapter/in/api/catalog_test.go
new file mode 100644
index 0000000..b399bbe
--- /dev/null
+++ b/internal/adapter/in/api/catalog_test.go
@@ -0,0 +1,62 @@
+package api
+
+import (
+ "context"
+ "sync"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/catalog"
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/app/port/porttest"
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+type noTracks struct{}
+
+func (noTracks) PreparedTrack(context.Context, track.Track) (port.PreparedTrack, bool, error) {
+ return port.PreparedTrack{}, false, nil
+}
+
+func (noTracks) SavePreparedTrack(context.Context, track.Track, port.PreparedTrack) error {
+ return nil
+}
+
+type history struct{}
+
+func (history) AutoDJSeeds(context.Context, string, bool) ([]track.Track, error) {
+ return []track.Track{sunnyTrack}, nil
+}
+
+func (history) AutoDJExclusions(context.Context, string) ([]track.Track, error) {
+ return nil, nil
+}
+
+type quota struct {
+ mu sync.Mutex
+ left int
+}
+
+func (q *quota) TakeSearch(context.Context, string, int, time.Duration) (time.Duration, error) {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+ if q.left == 0 {
+ return time.Minute, nil
+ }
+ q.left--
+ return 0, nil
+}
+
+var rain = port.Song{
+ Track: track.Track{Source: track.NetEase, ID: "185811"},
+ Meta: track.Meta{Title: "七里香", Artist: "周杰伦", Album: "七里香", PageURL: "https://music.163.com/#/song?id=185811", Duration: 299 * time.Second},
+}
+
+func newCatalog(rooms port.RoomDirectory, left int) *catalog.Service {
+ netease := porttest.NewSource(track.NetEase)
+ netease.Add(sunnyTrack.ID, sunnyEntry.Meta, "", nil)
+ netease.Add(rain.Track.ID, rain.Meta, "", nil)
+ netease.SetSimilar(sunnyTrack.ID, rain.Track.ID)
+ netease.AddPlaylist(port.Playlist{ID: "p1", Name: "周杰伦精选", Creator: "alice", TrackCount: 2, PlayCount: 9}, sunnyTrack.ID, rain.Track.ID)
+ bilibili := porttest.NewBilibili()
+ return catalog.New(netease, porttest.NewSource(track.QQ), bilibili, bilibili, noTracks{}, history{}, rooms, "a{left: left})
+}
diff --git a/internal/adapter/in/api/convert_test.go b/internal/adapter/in/api/convert_test.go
new file mode 100644
index 0000000..374712e
--- /dev/null
+++ b/internal/adapter/in/api/convert_test.go
@@ -0,0 +1,171 @@
+package api
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/catalog"
+ "github.com/dihemoe/alisten/internal/app/media"
+ "github.com/dihemoe/alisten/internal/domain/member"
+ "github.com/dihemoe/alisten/internal/domain/room"
+ "github.com/dihemoe/alisten/internal/domain/track"
+ "github.com/dihemoe/alisten/protocol"
+)
+
+var (
+ alice = member.Member{ID: "0199f1c2-8a4b-7c3d-9e5f-6a7b8c9d0e1f", Name: "alice", AvatarHash: "c160f8cc69a4f0bf2b0362752353d060"}
+ sunnyTrack = track.Track{Source: track.NetEase, ID: "186016"}
+ sunnyEntry = room.Entry{
+ ID: 12,
+ Track: sunnyTrack,
+ Meta: track.Meta{Title: "晴天", Artist: "周杰伦", Album: "叶惠美", CoverURL: "https://p1.music.126.net/example.jpg", PageURL: "https://music.163.com/#/song?id=186016", Duration: 269 * time.Second},
+ By: alice,
+ Likes: 2,
+ Ready: true,
+ Prep: track.Prepared{Duration: 269500 * time.Millisecond, Gain: 0.75, Lyric: "[00:01.00]故事的小黄花"},
+ }
+ linkTrack = track.Track{Source: track.Link, ID: "https://www.youtube.com/watch?v=x"}
+)
+
+func jsonOf(t *testing.T, v any) string {
+ t.Helper()
+ b, err := json.Marshal(v)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(b)
+}
+
+func TestSnapshotDTO(t *testing.T) {
+ s := &server{Services: Services{StorageURL: "https://alisten.example/storage/alisten"}}
+ auto := room.Entry{ID: 14, Track: track.Track{Source: track.NetEase, ID: "185811"}, Meta: track.Meta{Title: "七里香"}, Auto: true, Ready: true}
+ pending := room.Entry{ID: 13, Track: linkTrack, Meta: track.Meta{Title: linkTrack.ID, PageURL: linkTrack.ID}, By: alice}
+ view := room.View{
+ Term: 3,
+ Seq: 128,
+ Snapshot: room.Snapshot{
+ Info: room.Info{ID: "0199f1c2-0000-7000-8000-000000000001", Name: "大厅", Description: "常驻", Kind: room.Persistent},
+ Mode: room.Random,
+ Waiting: []room.Entry{auto},
+ Pending: &pending,
+ Current: &room.NowPlaying{Entry: sunnyEntry, StartedAt: time.UnixMilli(1760000000123)},
+ },
+ Sessions: []room.Session{{ID: "s1", Member: alice}, {ID: "s2", Member: alice}},
+ }
+ want := `{"items":[{"auto":true,"by":null,"id":"14","likes":0,"meta":{"album":"","artist":"","coverUrl":"","durationMs":0,"pageUrl":"","title":"七里香"},"status":"ready","track":{"id":"185811","source":"netease"}}],` +
+ `"members":{"onlineCount":2,"sessions":[{"avatarHash":"c160f8cc69a4f0bf2b0362752353d060","id":"0199f1c2-8a4b-7c3d-9e5f-6a7b8c9d0e1f","name":"alice"},{"avatarHash":"c160f8cc69a4f0bf2b0362752353d060","id":"0199f1c2-8a4b-7c3d-9e5f-6a7b8c9d0e1f","name":"alice"}]},` +
+ `"nowPlaying":{"durationMs":269500,"entry":{"auto":false,"by":{"avatarHash":"c160f8cc69a4f0bf2b0362752353d060","id":"0199f1c2-8a4b-7c3d-9e5f-6a7b8c9d0e1f","name":"alice"},"id":"12","likes":2,"meta":{"album":"叶惠美","artist":"周杰伦","coverUrl":"https://p1.music.126.net/example.jpg","durationMs":269000,"pageUrl":"https://music.163.com/#/song?id=186016","title":"晴天"},"status":"ready","track":{"id":"186016","source":"netease"}},"gain":0.75,"lyric":"[00:01.00]故事的小黄花","mediaUrl":"/media/netease/186016","startedAtMs":1760000000123},` +
+ `"pending":{"auto":false,"by":{"avatarHash":"c160f8cc69a4f0bf2b0362752353d060","id":"0199f1c2-8a4b-7c3d-9e5f-6a7b8c9d0e1f","name":"alice"},"id":"13","likes":0,"meta":{"album":"","artist":"","coverUrl":"","durationMs":0,"pageUrl":"https://www.youtube.com/watch?v=x","title":"https://www.youtube.com/watch?v=x"},"status":"preparing","track":{"id":"https://www.youtube.com/watch?v=x","source":"link"}},` +
+ `"playMode":"random","room":{"description":"常驻","id":"0199f1c2-0000-7000-8000-000000000001","kind":"persistent","name":"大厅"},"seq":128,"term":3}`
+ if got := jsonOf(t, s.snapshot(view)); got != want {
+ t.Errorf("快照编码为\n%s\n期望\n%s", got, want)
+ }
+
+ empty := room.View{Term: 1, Snapshot: room.Snapshot{Info: room.Info{ID: "r", Kind: room.Ephemeral}, Mode: room.Sequential}}
+ wantEmpty := `{"items":[],"members":{"onlineCount":0,"sessions":[]},"nowPlaying":null,"pending":null,"playMode":"sequential","room":{"description":"","id":"r","kind":"ephemeral","name":""},"seq":0,"term":1}`
+ if got := jsonOf(t, s.snapshot(empty)); got != wantEmpty {
+ t.Errorf("空房间的快照编码为\n%s\n期望\n%s", got, wantEmpty)
+ }
+}
+
+func TestEventDTO(t *testing.T) {
+ s := &server{}
+ sentAt := time.UnixMilli(1760000000456)
+ tests := []struct {
+ event room.Event
+ typ string
+ data string
+ }{
+ {room.ChatPosted{Member: alice, Text: "你好", SentAt: sentAt}, "chat.message", `{"content":"你好","member":{"avatarHash":"c160f8cc69a4f0bf2b0362752353d060","id":"0199f1c2-8a4b-7c3d-9e5f-6a7b8c9d0e1f","name":"alice"},"sentAtMs":1760000000456}`},
+ }
+ for _, tt := range tests {
+ typ, data := s.event(tt.event)
+ if got := jsonOf(t, data); typ != tt.typ || got != tt.data {
+ t.Errorf("%T 编码为 %s %s,期望 %s %s", tt.event, typ, got, tt.typ, tt.data)
+ }
+ }
+}
+
+func TestActivityDTO(t *testing.T) {
+ s := &server{}
+ plain := room.Entry{ID: 7, Track: sunnyTrack, Meta: track.Meta{Title: "晴天"}, By: alice, Likes: 3}
+ tests := []struct {
+ activity room.Activity
+ member bool
+ votes bool
+ message string
+ }{
+ {room.Activity{Kind: room.ActivityRemoved, Member: alice, Entry: plain}, true, false, "移除了《晴天》"},
+ {room.Activity{Kind: room.ActivityVoted, Member: alice, Entry: plain, Votes: 1, Required: 2}, true, true, "投票切歌(1/2)"},
+ }
+ for _, tt := range tests {
+ got := s.activity(tt.activity)
+ if got.Message != tt.message || (got.Member != nil) != tt.member || (got.Votes != nil) != tt.votes || (got.Required != nil) != tt.votes || got.Entry == nil {
+ t.Errorf("%v 编码为 %s,期望消息 %q", tt.activity.Kind, jsonOf(t, got), tt.message)
+ }
+ }
+ voted := s.activity(room.Activity{Kind: room.ActivityVoted, Member: alice, Entry: plain, Votes: 1, Required: 2})
+ if *voted.Votes != 1 || *voted.Required != 2 || voted.Kind != protocol.ActivityKindVoted {
+ t.Errorf("投票的动态 = %s", jsonOf(t, voted))
+ }
+}
+
+func TestRoomErrorCodes(t *testing.T) {
+ kinds := map[room.ErrorKind]protocol.ErrorCode{
+ room.Conflict: protocol.ErrorCodeConflict,
+ room.QueueFull: protocol.ErrorCodeQueueFull,
+ room.RateLimited: protocol.ErrorCodeRateLimited,
+ room.NothingPlaying: protocol.ErrorCodeNothingPlaying,
+ }
+ for kind, code := range kinds {
+ info, expected := errorInfo(&room.Error{Kind: kind, Message: "说明"})
+ if info != (protocol.ErrorInfo{Code: code, Message: "说明"}) || !expected {
+ t.Errorf("领域错误 %d 映射为 %#v, %v,期望 %s", kind, info, expected, code)
+ }
+ }
+ if _, expected := errorInfo(errors.New("连接中断")); expected {
+ t.Error("未知的错误应按内部错误处理")
+ }
+}
+
+func TestPlayModes(t *testing.T) {
+ modes := map[room.PlayMode]protocol.PlayMode{
+ room.Sequential: protocol.PlayModeSequential,
+ room.Random: protocol.PlayModeRandom,
+ room.Rotation: protocol.PlayModeRotation,
+ }
+ for mode, dto := range modes {
+ if got := playMode(mode); got != dto {
+ t.Errorf("播放模式 %d 编码为 %q,期望 %q", mode, got, dto)
+ }
+ if got := playModeOf(dto); got != mode {
+ t.Errorf("%q 解码为播放模式 %d,期望 %d", dto, got, mode)
+ }
+ }
+}
+
+func TestRejectedLinkIsInvalidArgument(t *testing.T) {
+ info, expected := errorInfo(fmt.Errorf("点歌:%w", &catalog.RejectedLinkError{Reason: "哔哩哔哩的番剧不能点播"}))
+ if info != (protocol.ErrorInfo{Code: protocol.ErrorCodeInvalidArgument, Message: "哔哩哔哩的番剧不能点播"}) || !expected {
+ t.Errorf("不能点的链接 = %#v, %v,期望 INVALID_ARGUMENT,message 是音源给出的原因", info, expected)
+ }
+}
+
+func TestMediaURL(t *testing.T) {
+ s := &server{Services: Services{StorageURL: "https://alisten.example/storage/alisten"}}
+ video := track.Track{Source: track.Bilibili, ID: "BV1xx411c7mD/2"}
+ tests := map[track.Track]string{
+ sunnyTrack: "/media/netease/186016",
+ {Source: track.QQ, ID: "0039MnYb0qxYhV"}: "/media/qq/0039MnYb0qxYhV",
+ linkTrack: "https://alisten.example/storage/alisten/link/" + media.Key(linkTrack.ID) + "/audio.mp3",
+ video: "https://alisten.example/storage/alisten/link/" + media.Key(video.ID) + "/audio.mp3",
+ }
+ for tr, want := range tests {
+ if got := s.mediaURL(tr); got != want {
+ t.Errorf("mediaURL(%v) = %q,期望 %q", tr, got, want)
+ }
+ }
+}
diff --git a/internal/adapter/in/api/events_test.go b/internal/adapter/in/api/events_test.go
new file mode 100644
index 0000000..9e0edb5
--- /dev/null
+++ b/internal/adapter/in/api/events_test.go
@@ -0,0 +1,363 @@
+package api
+
+import (
+ "bufio"
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "testing/synctest"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/admin"
+ "github.com/dihemoe/alisten/internal/app/identity"
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/app/port/porttest"
+ "github.com/dihemoe/alisten/internal/domain/room"
+ "github.com/dihemoe/alisten/protocol"
+)
+
+func (f *wsFixture) openStream(t *testing.T, credential string) *http.Response {
+ t.Helper()
+ req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://alisten.test/api/room/events", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if credential != "" {
+ req.Header.Set("Authorization", "Bearer "+credential)
+ }
+ resp, err := f.client.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = resp.Body.Close() })
+ return resp
+}
+
+func (f *wsFixture) stream(t *testing.T, credential string) *bufio.Reader {
+ t.Helper()
+ resp := f.openStream(t, credential)
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ t.Fatalf("打开事件流 = %d %s", resp.StatusCode, body)
+ }
+ return bufio.NewReader(resp.Body)
+}
+
+func rejected(t *testing.T, resp *http.Response) response {
+ t.Helper()
+ if resp.StatusCode == http.StatusOK {
+ t.Fatal("事件流应被拒绝,实际打开了")
+ }
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := resp.Header.Get("Content-Type"); got != "application/json" {
+ t.Errorf("拒绝时的 Content-Type = %q", got)
+ }
+ return response{status: resp.StatusCode, body: strings.TrimSpace(string(body))}
+}
+
+// 返回下一个事件的各行,不含结尾的空行。
+func readEvent(t *testing.T, r *bufio.Reader) []string {
+ t.Helper()
+ var lines []string
+ for {
+ line, err := r.ReadString('\n')
+ if err != nil {
+ t.Fatalf("读事件流:%v,已读到 %q", err, lines)
+ }
+ if line == "\n" {
+ return lines
+ }
+ lines = append(lines, strings.TrimSuffix(line, "\n"))
+ }
+}
+
+// data 按 JSON 比较,服务端生成的时间戳换成 1。
+func checkEvent(t *testing.T, r *bufio.Reader, want ...string) {
+ t.Helper()
+ canonical := func(lines []string) string {
+ for i, line := range lines {
+ if data, ok := strings.CutPrefix(line, "data: "); ok {
+ lines[i] = "data: " + jsonOf(t, decodeMessage(t, []byte(data)))
+ }
+ }
+ return strings.Join(lines, "\n")
+ }
+ if got, want := canonical(readEvent(t, r)), canonical(want); got != want {
+ t.Errorf("收到\n%s\n期望\n%s", got, want)
+ }
+}
+
+func checkEnded(t *testing.T, r *bufio.Reader) {
+ t.Helper()
+ if line, err := r.ReadString('\n'); !errors.Is(err, io.EOF) {
+ t.Errorf("close 之后又读到 %q, %v,期望事件流结束", line, err)
+ }
+}
+
+func TestRoomEvents(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newWSFixture(t, store, store, store)
+ hall, err := f.admin.CreateRoom(t.Context(), admin.NewRoom{Name: "大厅"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ key, err := f.admin.ResetRoomAPIKey(t.Context(), hall)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp := f.openStream(t, key)
+ want := map[string]string{"Content-Type": "text/event-stream", "Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
+ for name, value := range want {
+ if got := resp.Header.Get(name); resp.StatusCode != http.StatusOK || got != value {
+ t.Errorf("事件流的响应 %d,%s = %q,期望 200 和 %q", resp.StatusCode, name, got, value)
+ }
+ }
+ r := bufio.NewReader(resp.Body)
+ checkEvent(t, r, "event: snapshot", `data: {"room":{"term":1,"seq":0,"room":{"id":"`+hall+`","name":"大厅","description":"","kind":"persistent"},"playMode":"sequential","nowPlaying":null,"pending":null,"items":[],"members":{"sessions":[],"onlineCount":0}}}`)
+
+ _, m, ticket := f.enter(t, hall)
+ conn := f.join(t, hall, ticket)
+ self := `{"avatarHash":"","id":"` + m.ID + `","name":"` + m.Name + `"}`
+ checkEvent(t, r, "event: members.updated", "id: 1", `data: {"kind":"event","type":"members.updated","term":1,"seq":1,"data":{"sessions":[`+self+`],"onlineCount":1}}`)
+ writeFrame(t, conn, `{"kind":"request","type":"chat.send","id":"c1","data":{"content":"你好"}}`)
+ _, frame, err := conn.Read(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := readEvent(t, r); strings.Join(got, "\n") != "event: chat.message\nid: 2\ndata: "+string(frame) {
+ t.Errorf("事件流收到\n%s\n期望 data 与 WebSocket 上的报文相同:\n%s", strings.Join(got, "\n"), frame)
+ }
+
+ start := time.Now()
+ checkEvent(t, r, ": ping")
+ if elapsed := time.Since(start); elapsed != streamHeartbeat {
+ t.Errorf("%v 之后收到心跳,期望 %v", elapsed, streamHeartbeat)
+ }
+
+ if _, err := f.admin.ResetRoomAPIKey(t.Context(), hall); err != nil {
+ t.Fatal(err)
+ }
+ checkEvent(t, r, "event: close", `data: {"code":4401,"message":"凭证已失效:房间设置或修改了密码,或者 API Key 已重置"}`)
+ checkEnded(t, r)
+ })
+}
+
+func TestRoomEventsCloseWithTheRoom(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newWSFixture(t, store, store, store)
+ deleted, err := f.admin.CreateRoom(t.Context(), admin.NewRoom{Name: "删除"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, _, deletedTicket := f.enter(t, deleted)
+ _, _, ephemeralTicket := f.enter(t, "")
+ gone, restarted := f.stream(t, deletedTicket), f.stream(t, ephemeralTicket)
+ readEvent(t, gone)
+ readEvent(t, restarted)
+
+ if err := f.admin.DeleteRoom(t.Context(), deleted); err != nil {
+ t.Fatal(err)
+ }
+ checkEvent(t, gone, "event: close", `data: {"code":4404,"message":"房间已关闭或被删除"}`)
+ checkEnded(t, gone)
+ if err := f.gateway.Shutdown(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ checkEvent(t, restarted, "event: close", `data: {"code":1012,"message":"服务端实例退出或订阅中断,请重新连接"}`)
+ checkEnded(t, restarted)
+ })
+}
+
+func TestRoomEventsLimit(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newWSFixture(t, store, store, store)
+ hall, err := f.admin.CreateRoom(t.Context(), admin.NewRoom{Name: "大厅"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ key, err := f.admin.ResetRoomAPIKey(t.Context(), hall)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, _, ticket := f.enter(t, hall)
+ oldest := f.stream(t, key)
+ readEvent(t, oldest)
+ for range 4 {
+ f.stream(t, key)
+ }
+ want := failure(protocol.ErrorCodeRateLimited, "这个房间的事件流超过 5 条,请先关闭不用的事件流")
+ if got := rejected(t, f.openStream(t, ticket)); got != want {
+ t.Errorf("名额用完时用别的凭证打开 = %#v,期望 %#v", got, want)
+ }
+ replacing := f.stream(t, key)
+ checkEvent(t, oldest, "event: close", `data: {"code":4409,"message":"同一凭证打开了新的事件流"}`)
+ checkEnded(t, oldest)
+ if got := readEvent(t, replacing); got[0] != "event: snapshot" {
+ t.Errorf("顶替旧事件流的事件流收到 %q,期望从快照开始", got)
+ }
+ })
+}
+
+// 每条事件流用自己的票据,名额用完时不会顶替别的事件流。
+func (f *wsFixture) fill(t *testing.T, roomID string) {
+ t.Helper()
+ for range 5 {
+ _, _, ticket := f.enter(t, roomID)
+ f.stream(t, ticket)
+ }
+}
+
+// 接管房间时重置房间的 API Key 但不发通知,就像通知在事件流生效之前就已经发出。
+type keyResettingSnapshots struct {
+ *porttest.Store
+}
+
+func (s keyResettingSnapshots) TakeRoom(ctx context.Context, id string) (uint32, room.Snapshot, error) {
+ _, k := identity.NewAPIKey()
+ if err := s.ResetRoomAPIKey(ctx, id, k); err != nil {
+ return 0, room.Snapshot{}, err
+ }
+ return s.Store.TakeRoom(ctx, id)
+}
+
+func TestRoomEventsRejectTicketRevokedWhileOpening(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newWSFixture(t, store, keyResettingSnapshots{store}, store)
+ hall, err := f.admin.CreateRoom(t.Context(), admin.NewRoom{Name: "大厅"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ key, err := f.admin.ResetRoomAPIKey(t.Context(), hall)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := failure(protocol.ErrorCodeUnauthorized, "票据无效")
+ if got := rejected(t, f.openStream(t, key)); got != want {
+ t.Errorf("事件流生效之前 Key 已经重置时 = %#v,期望 %#v", got, want)
+ }
+ f.fill(t, hall)
+ })
+}
+
+// 校验票据一直没有结果。
+type stallingTickets struct {
+ *porttest.Store
+}
+
+func (stallingTickets) TicketHolder(ctx context.Context, _ []byte) (port.TicketHolder, error) {
+ <-ctx.Done()
+ return port.TicketHolder{}, ctx.Err()
+}
+
+func TestRoomEventsOpeningTimesOut(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newWSFixtureWithTickets(t, store, stallingTickets{store}, store, store)
+ // 期限之后客户端也放弃:服务端没有期限时,这里不会一直等下去。
+ ctx, cancel := context.WithTimeout(t.Context(), 2*helloTimeout)
+ defer cancel()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://alisten.test/api/room/events", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Authorization", "Bearer 票据")
+ start := time.Now()
+ resp, err := f.client.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ want := failure(protocol.ErrorCodeUnavailable, "服务器没有及时响应,请稍后再试。")
+ if got := rejected(t, resp); got != want {
+ t.Errorf("打开事件流超过期限时 = %#v,期望 %#v", got, want)
+ }
+ if elapsed := time.Since(start); elapsed != helloTimeout {
+ t.Errorf("%v 之后才回复,期望 %v", elapsed, helloTimeout)
+ }
+ })
+}
+
+// 第二次查找票据,即事件流生效之后的确认,发生 panic。
+type panickingRecheck struct {
+ *porttest.Store
+ lookups atomic.Int32
+}
+
+func (s *panickingRecheck) TicketHolder(ctx context.Context, hash []byte) (port.TicketHolder, error) {
+ if s.lookups.Add(1) == 2 {
+ panic("票据存储坏了")
+ }
+ return s.Store.TicketHolder(ctx, hash)
+}
+
+func TestRoomEventsReleaseSlotWhenRecheckPanics(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newWSFixtureWithTickets(t, store, &panickingRecheck{Store: store}, store, store)
+ hall, err := f.admin.CreateRoom(t.Context(), admin.NewRoom{Name: "大厅"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ key, err := f.admin.ResetRoomAPIKey(t.Context(), hall)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := failure(protocol.ErrorCodeInternal, "服务器出错了,请稍后再试。")
+ if got := rejected(t, f.openStream(t, key)); got != want {
+ t.Errorf("确认票据时 panic = %#v,期望 %#v", got, want)
+ }
+ if !strings.Contains(f.logs.String(), "票据存储坏了") {
+ t.Errorf("日志中没有这次 panic:\n%s", f.logs)
+ }
+ f.logs.Reset()
+ f.fill(t, hall)
+ })
+}
+
+func TestRoomEventsEndWhenClientStopsReading(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newWSFixture(t, store, store, store)
+ hall, err := f.admin.CreateRoom(t.Context(), admin.NewRoom{Name: "大厅"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, m, ticket := f.enter(t, hall)
+ f.stream(t, ticket)
+ // 内存连接上的写入要等对方读取:客户端不读,这个事件就一直写不出去。
+ if _, err := f.gateway.Do(t.Context(), hall, m, room.Chat{Text: "你好"}); err != nil {
+ t.Fatal(err)
+ }
+ time.Sleep(streamWriteTimeout)
+ synctest.Wait()
+ f.fill(t, hall)
+ })
+}
+
+func TestRoomEventsNeedRoomCredential(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newWSFixture(t, store, store, store)
+ session, err := f.identity.CreateMember(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := failure(protocol.ErrorCodeUnauthorized, "票据无效")
+ for _, credential := range []string{"", "不存在", session.MemberToken} {
+ if got := rejected(t, f.openStream(t, credential)); got != want {
+ t.Errorf("凭证 %q 时 = %#v,期望 %#v", credential, got, want)
+ }
+ }
+ })
+}
diff --git a/internal/adapter/in/api/media_test.go b/internal/adapter/in/api/media_test.go
new file mode 100644
index 0000000..c647841
--- /dev/null
+++ b/internal/adapter/in/api/media_test.go
@@ -0,0 +1,259 @@
+package api
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "log/slog"
+ "maps"
+ "net/http"
+ "net/http/httptest"
+ "slices"
+ "testing"
+ "time"
+
+ "github.com/prometheus/client_golang/prometheus"
+ "go.opentelemetry.io/otel/trace/noop"
+
+ "github.com/dihemoe/alisten/internal/app/media"
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/app/port/porttest"
+ "github.com/dihemoe/alisten/internal/domain/track"
+ "github.com/dihemoe/alisten/internal/lib/logctx"
+ "github.com/dihemoe/alisten/protocol"
+)
+
+type storedMedia struct {
+ items []port.StoredMedia
+ used bool
+}
+
+func (m *storedMedia) ListStoredMedia(context.Context) ([]port.StoredMedia, error) {
+ return m.items, nil
+}
+
+func (m *storedMedia) StoredMedia(_ context.Context, key string) (port.StoredMedia, bool, error) {
+ for _, item := range m.items {
+ if item.Key == key {
+ return item, true, nil
+ }
+ }
+ return port.StoredMedia{}, false, nil
+}
+
+func (m *storedMedia) DeleteStoredMedia(context.Context, string) error {
+ m.items = nil
+ return nil
+}
+
+func (m *storedMedia) TrackInUse(context.Context, track.Track) (bool, error) {
+ return m.used, nil
+}
+
+func TestAdminMedia(t *testing.T) {
+ key := media.Key("https://example.com/a.mp3")
+ video := media.Key("BV1xx411c7mD/2")
+ played := time.UnixMilli(1760000000456)
+ stored := &storedMedia{items: []port.StoredMedia{
+ {Key: key, Source: track.Link, Link: "https://example.com/a.mp3", Title: "晴天", Size: 12290, StoredAt: time.UnixMilli(1760000000123), LastPlayedAt: &played},
+ {Key: media.Key("https://example.com/b.mp3"), Source: track.Link, Link: "https://example.com/b.mp3", Title: "七里香", Size: 1, StoredAt: time.UnixMilli(1760000000789)},
+ {Key: video, Source: track.Bilibili, Link: "BV1xx411c7mD/2", Title: "晴天(钢琴版)", Size: 2, StoredAt: time.UnixMilli(1760000000999)},
+ }}
+ store := porttest.New()
+ s := services(store, store, "admin-secret")
+ s.Media = media.NewLibrary(stored, stored, porttest.NewMediaStore())
+ server, _ := start(t, s)
+ admin := []string{"Authorization", "Bearer admin-secret"}
+
+ want := response{status: http.StatusOK, body: `{"items":[` +
+ `{"key":"` + key + `","lastPlayedAtMs":1760000000456,"link":"https://example.com/a.mp3","size":12290,"source":"link","storedAtMs":1760000000123,"title":"晴天"},` +
+ `{"key":"` + media.Key("https://example.com/b.mp3") + `","lastPlayedAtMs":null,"link":"https://example.com/b.mp3","size":1,"source":"link","storedAtMs":1760000000789,"title":"七里香"},` +
+ `{"key":"` + video + `","lastPlayedAtMs":null,"link":"https://www.bilibili.com/video/BV1xx411c7mD?p=2","size":2,"source":"bilibili","storedAtMs":1760000000999,"title":"晴天(钢琴版)"}]}`}
+ if got := send(t, server, http.MethodGet, "/api/admin/media", "", admin...); got != want {
+ t.Errorf("列出已存储的媒体 = %#v\n期望 %#v", got, want)
+ }
+
+ stored.used = true
+ if got, want := send(t, server, http.MethodDelete, "/api/admin/media/"+key, "", admin...), failure(protocol.ErrorCodeConflict, "这首歌还在房间的播放列表中,不能删除"); got != want {
+ t.Errorf("删除仍被房间引用的媒体 = %#v,期望 %#v", got, want)
+ }
+ stored.used = false
+ resp, header := exchange(t, server, http.MethodDelete, "/api/admin/media/"+key, "", admin...)
+ checkNoContent(t, "删除媒体", resp, header)
+}
+
+type playURL map[string]string
+
+type inRooms []track.Track
+
+func (r inRooms) TrackInUse(_ context.Context, t track.Track) (bool, error) {
+ return slices.Contains(r, t), nil
+}
+
+func (p playURL) PlayURL(_ context.Context, id string) (string, error) {
+ if url, ok := p[id]; ok {
+ return url, nil
+ }
+ return "", &port.RejectedError{Err: errors.New("网易云没有提供这首歌的播放地址")}
+}
+
+func TestMediaRedirect(t *testing.T) {
+ store := porttest.New()
+ s := services(store, store, "")
+ s.Playback = media.NewPlayback(map[track.Source]port.PlayURLs{track.NetEase: playURL{"186016": "http://m701.music.126.net/a.mp3"}}, inRooms{{Source: track.NetEase, ID: "186016"}, {Source: track.NetEase, ID: "1"}})
+ metrics := prometheus.NewRegistry()
+ server := httptest.NewServer(SameOrigin([]string{allowedOrigin, "http://localhost:9876"}, New(s, metrics, noop.NewTracerProvider(), slog.New(slog.DiscardHandler))))
+ defer server.Close()
+ client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
+ do := func(method, path string) *http.Response {
+ t.Helper()
+ req, err := http.NewRequestWithContext(t.Context(), method, server.URL+path, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = resp.Body.Close()
+ return resp
+ }
+
+ resp := do(http.MethodGet, "/media/netease/186016")
+ if resp.StatusCode != http.StatusFound || resp.Header.Get("Location") != "https://m701.music.126.net/a.mp3" || resp.Header.Get("Cache-Control") != "no-store" {
+ t.Errorf("跳转 = %d %q %q,期望 302 到 https 的地址,不缓存", resp.StatusCode, resp.Header.Get("Location"), resp.Header.Get("Cache-Control"))
+ }
+ for path, want := range map[string]int{
+ "/media/netease/1": http.StatusBadGateway,
+ "/media/qq/1": http.StatusBadGateway,
+ "/media/link/x": http.StatusNotFound,
+ } {
+ if got := do(http.MethodGet, path).StatusCode; got != want {
+ t.Errorf("%s = %d,期望 %d", path, got, want)
+ }
+ }
+ if got := do(http.MethodPost, "/media/netease/186016").StatusCode; got != http.StatusNotFound {
+ t.Errorf("POST /media = %d,期望 404", got)
+ }
+ if got, want := send(t, server, http.MethodGet, "/media/netease/185811", ""), failure(protocol.ErrorCodeNotFound, "这首歌不在任何房间中"); got != want {
+ t.Errorf("不在房间里的歌 = %#v,期望 %#v", got, want)
+ }
+
+ want := map[string]float64{"netease ok": 1, "netease PROVIDER_ERROR": 1, "netease NOT_FOUND": 1, "qq PROVIDER_ERROR": 1}
+ if got := redirectCounts(t, metrics); !maps.Equal(got, want) {
+ t.Errorf("跳转的计数 = %v,期望 %v", got, want)
+ }
+}
+
+func redirectCounts(t *testing.T, metrics prometheus.Gatherer) map[string]float64 {
+ t.Helper()
+ families, err := metrics.Gather()
+ if err != nil {
+ t.Fatal(err)
+ }
+ counts := map[string]float64{}
+ for _, f := range families {
+ if f.GetName() != "alisten_media_redirects_total" {
+ continue
+ }
+ for _, m := range f.GetMetric() {
+ labels := map[string]string{}
+ for _, l := range m.GetLabel() {
+ labels[l.GetName()] = l.GetValue()
+ }
+ counts[labels["source"]+" "+labels["result"]] = m.GetCounter().GetValue()
+ }
+ }
+ return counts
+}
+
+type blockingPlayURL struct {
+ entered chan struct{}
+ release chan struct{}
+}
+
+func (b blockingPlayURL) PlayURL(ctx context.Context, _ string) (string, error) {
+ close(b.entered)
+ select {
+ case <-b.release:
+ return "https://m701.music.126.net/a.mp3", nil
+ case <-ctx.Done():
+ return "", ctx.Err()
+ }
+}
+
+func TestMediaRedirectIgnoresDepartedClients(t *testing.T) {
+ source := blockingPlayURL{entered: make(chan struct{}), release: make(chan struct{})}
+ defer close(source.release)
+ store := porttest.New()
+ s := services(store, store, "")
+ s.Playback = media.NewPlayback(map[track.Source]port.PlayURLs{track.NetEase: source}, inRooms{{Source: track.NetEase, ID: "186016"}})
+ metrics := prometheus.NewRegistry()
+ handler := SameOrigin([]string{allowedOrigin, "http://localhost:9876"}, New(s, metrics, noop.NewTracerProvider(), slog.New(slog.DiscardHandler)))
+ returned := make(chan struct{})
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ handler.ServeHTTP(w, r)
+ close(returned)
+ }))
+ defer server.Close()
+
+ ctx, leave := context.WithCancel(t.Context())
+ defer leave()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/media/netease/186016", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ go func() {
+ if resp, err := server.Client().Do(req); err == nil {
+ _ = resp.Body.Close()
+ }
+ }()
+ <-source.entered
+ leave()
+ select {
+ case <-returned:
+ case <-time.After(10 * time.Second):
+ t.Fatal("客户端离开之后请求没有返回")
+ }
+
+ families, err := metrics.Gather()
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, f := range families {
+ if f.GetName() == "alisten_media_redirects_total" && len(f.GetMetric()) != 0 {
+ t.Errorf("客户端离开的请求被计入了跳转的结果:%v", f.GetMetric())
+ }
+ }
+}
+
+type panickingUsage struct{}
+
+func (panickingUsage) TrackInUse(context.Context, track.Track) (bool, error) {
+ panic("存储坏了")
+}
+
+func TestPanickingMediaRedirectIsInternal(t *testing.T) {
+ store := porttest.New()
+ s := services(store, store, "")
+ s.Playback = media.NewPlayback(map[track.Source]port.PlayURLs{track.NetEase: playURL{}}, panickingUsage{})
+ var logs bytes.Buffer
+ metrics := prometheus.NewRegistry()
+ server := httptest.NewServer(SameOrigin([]string{allowedOrigin, "http://localhost:9876"}, New(s, metrics, noop.NewTracerProvider(), slog.New(logctx.NewHandler(slog.NewJSONHandler(&logs, nil))))))
+ defer server.Close()
+
+ if got, want := send(t, server, http.MethodGet, "/media/netease/1", ""), failure(protocol.ErrorCodeInternal, "服务器出错了,请稍后再试。"); got != want {
+ t.Errorf("panic 的跳转 = %#v,期望 %#v", got, want)
+ }
+ var entry map[string]any
+ if err := json.Unmarshal(logs.Bytes(), &entry); err != nil {
+ t.Fatalf("应记录一行 JSON 日志:%v\n%s", err, logs.String())
+ }
+ if entry["msg"] != "处理 HTTP 请求时发生 panic" || entry["path"] != "/media/netease/1" {
+ t.Errorf("日志 = %v,期望记录跳转请求中的 panic", entry)
+ }
+ if got, want := redirectCounts(t, metrics), map[string]float64{"netease INTERNAL": 1}; !maps.Equal(got, want) {
+ t.Errorf("跳转的计数 = %v,期望 %v", got, want)
+ }
+}
diff --git a/internal/adapter/in/api/qrlogin_test.go b/internal/adapter/in/api/qrlogin_test.go
new file mode 100644
index 0000000..67b5910
--- /dev/null
+++ b/internal/adapter/in/api/qrlogin_test.go
@@ -0,0 +1,203 @@
+package api
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "image/png"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/admin"
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/app/port/porttest"
+ "github.com/dihemoe/alisten/protocol"
+)
+
+var adminHeader = []string{"Authorization", "Bearer admin-secret"}
+
+func startQRLogin(t *testing.T, netease port.NetEaseQRLoginSource, qq port.QQQRLoginSource) (*httptest.Server, *bytes.Buffer) {
+ t.Helper()
+ store := porttest.New()
+ s := services(store, store, "admin-secret")
+ s.Admin = admin.New("admin-secret", s.Lobby, store, store, store, netease, qq, porttest.NewBilibiliQRLogin(), porttest.NewYouTubeCookies(), s.Gateway)
+ return start(t, s)
+}
+
+func qrLoginOf(t *testing.T, got response) protocol.QRLogin {
+ t.Helper()
+ var login protocol.QRLogin
+ if err := json.Unmarshal([]byte(got.body), &login); err != nil || got.status != http.StatusCreated {
+ t.Fatalf("生成二维码 = %#v", got)
+ }
+ return login
+}
+
+type noQRCode struct{ *porttest.NetEaseQRLogin }
+
+func (noQRCode) CreateQRLogin(context.Context) (port.QRLoginSession, error) {
+ return port.QRLoginSession{Key: "netease-1", ExpiresAt: time.Now().Add(time.Minute)}, nil
+}
+
+func TestQRLoginImage(t *testing.T) {
+ server, _ := startQRLogin(t, porttest.NewNetEaseQRLogin(), porttest.NewQQQRLogin())
+ before := time.Now()
+
+ netease := qrLoginOf(t, send(t, server, http.MethodPost, "/api/admin/credentials/netease/qr-logins", "{}", adminHeader...))
+ data, ok := strings.CutPrefix(netease.QrImage, "data:image/png;base64,")
+ if !ok {
+ t.Fatalf("网易云的二维码 = %q,期望由登录链接生成的 PNG", netease.QrImage)
+ }
+ raw, err := base64.StdEncoding.DecodeString(data)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if img, err := png.Decode(bytes.NewReader(raw)); err != nil || img.Bounds().Dx() == 0 {
+ t.Errorf("生成的二维码不是能解码的 PNG:%v", err)
+ }
+ if netease.Key != "netease-1" || netease.ExpiresAtMs <= before.UnixMilli() {
+ t.Errorf("网易云的登录会话 = key %q、过期于 %d", netease.Key, netease.ExpiresAtMs)
+ }
+
+ empty, _ := startQRLogin(t, noQRCode{porttest.NewNetEaseQRLogin()}, porttest.NewQQQRLogin())
+ if got, want := send(t, empty, http.MethodPost, "/api/admin/credentials/netease/qr-logins", "{}", adminHeader...), failure(protocol.ErrorCodeProviderError, "音乐平台没有正常响应,请稍后再试。"); got != want {
+ t.Errorf("音源没有给出二维码图片和登录链接时 = %#v,期望 %#v", got, want)
+ }
+}
+
+func TestQRLoginKeyIsNotLogged(t *testing.T) {
+ netease, qq := porttest.NewNetEaseQRLogin(), porttest.NewQQQRLogin()
+ server, logs := startQRLogin(t, netease, qq)
+ key := qrLoginOf(t, send(t, server, http.MethodPost, "/api/admin/credentials/netease/qr-logins", "{}", adminHeader...)).Key
+ wx := qrLoginOf(t, send(t, server, http.MethodPost, "/api/admin/credentials/qq/qr-logins", `{"type":"wx"}`, adminHeader...)).Key
+
+ netease.SetResult(key, port.QRLoginResult{Status: port.QRLoginFailed, Message: "二维码已失效"})
+ if got := send(t, server, http.MethodPost, "/api/admin/credentials/netease/qr-logins/status", `{"key":"`+key+`"}`, adminHeader...); got != (response{status: http.StatusOK, body: `{"message":"二维码已失效","status":"failed"}`}) {
+ t.Errorf("网易云的扫码结果 = %#v", got)
+ }
+ qq.SetResult(wx, port.QRLoginResult{Status: port.QRLoginSuccess})
+ if got := send(t, server, http.MethodPost, "/api/admin/credentials/qq/qr-logins/status", `{"type":"wx","key":"`+wx+`"}`, adminHeader...); got != (response{status: http.StatusOK, body: `{"message":"","status":"success"}`}) {
+ t.Errorf("微信扫码的结果 = %#v", got)
+ }
+
+ netease.Fail(errors.New("登录会话 " + key + " 已失效"))
+ qq.Fail(errors.New("登录会话 " + wx + " 已失效"))
+ for path, body := range map[string]string{
+ "/api/admin/credentials/netease/qr-logins/status": `{"key":"` + key + `"}`,
+ "/api/admin/credentials/qq/qr-logins/status": `{"type":"wx","key":"` + wx + `"}`,
+ } {
+ if got, want := send(t, server, http.MethodPost, path, body, adminHeader...), failure(protocol.ErrorCodeProviderError, "音乐平台没有正常响应,请稍后再试。"); got != want {
+ t.Errorf("音源出错时 %s = %#v,期望 %#v", path, got, want)
+ }
+ }
+ if strings.Contains(logs.String(), key) || strings.Contains(logs.String(), wx) {
+ t.Errorf("日志中出现了扫码登录的 key:\n%s", logs)
+ }
+}
+
+func TestCredentialStatus(t *testing.T) {
+ store := porttest.New()
+ netease, qq := porttest.NewNetEaseQRLogin(), porttest.NewQQQRLogin()
+ s := services(store, store, "admin-secret")
+ s.Admin = admin.New("admin-secret", s.Lobby, store, store, store, netease, qq, porttest.NewBilibiliQRLogin(), porttest.NewYouTubeCookies(), s.Gateway)
+ server, _ := start(t, s)
+ for _, path := range []string{"/api/admin/credentials/netease", "/api/admin/credentials/qq"} {
+ want := response{status: http.StatusOK, body: `{"account":null,"set":false}`}
+ if got := send(t, server, http.MethodGet, path, "", adminHeader...); got != want {
+ t.Errorf("没有凭据时 %s = %#v,期望 %#v", path, got, want)
+ }
+ }
+
+ if err := store.SetNeteaseCookie(t.Context(), "netease-cookie-secret"); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.SetQQCookie(t.Context(), "qq-cookie-secret"); err != nil {
+ t.Fatal(err)
+ }
+ name, vip := "测试账号", true
+ netease.SetAccount(&port.SourceAccount{ID: "123", Name: &name, VIP: &vip, Verified: true})
+ qq.SetAccount(&port.SourceAccount{ID: "456"})
+ for path, want := range map[string]string{
+ "/api/admin/credentials/netease": `{"account":{"id":"123","name":"测试账号","verified":true,"vip":true},"set":true}`,
+ "/api/admin/credentials/qq": `{"account":{"id":"456","name":null,"verified":false,"vip":null},"set":true}`,
+ } {
+ got := send(t, server, http.MethodGet, path, "", adminHeader...)
+ if got.status != http.StatusOK || got.body != want {
+ t.Errorf("有账号资料时 %s = %#v,期望 %s", path, got, want)
+ }
+ if strings.Contains(got.body, "cookie-secret") {
+ t.Errorf("%s 的响应泄漏了 Cookie:%s", path, got.body)
+ }
+ }
+
+ netease.FailAccount(errors.New("上游失败"))
+ if got, want := send(t, server, http.MethodGet, "/api/admin/credentials/netease", "", adminHeader...), failure(protocol.ErrorCodeProviderError, "音乐平台没有正常响应,请稍后再试。"); got != want {
+ t.Errorf("账号查询失败 = %#v,期望 %#v", got, want)
+ }
+}
+
+func startAdmin(t *testing.T, bilibili port.BilibiliQRLoginSource, youtube port.YouTubeCookies) (*httptest.Server, *bytes.Buffer) {
+ t.Helper()
+ store := porttest.New()
+ s := services(store, store, "admin-secret")
+ s.Admin = admin.New("admin-secret", s.Lobby, store, store, store, porttest.NewNetEaseQRLogin(), porttest.NewQQQRLogin(), bilibili, youtube, s.Gateway)
+ return start(t, s)
+}
+
+func TestBilibiliQRLogin(t *testing.T) {
+ bilibili := porttest.NewBilibiliQRLogin()
+ server, logs := startAdmin(t, bilibili, porttest.NewYouTubeCookies())
+ login := qrLoginOf(t, send(t, server, http.MethodPost, "/api/admin/credentials/bilibili/qr-logins", "{}", adminHeader...))
+ if !strings.HasPrefix(login.QrImage, "data:image/png;base64,") || login.Key != "bilibili-1" {
+ t.Errorf("B 站的二维码 = %+v,期望由登录链接生成的 PNG", login)
+ }
+ bilibili.SetResult(login.Key, port.QRLoginResult{Status: port.QRLoginSuccess})
+ check := `{"key":"` + login.Key + `"}`
+ if got := send(t, server, http.MethodPost, "/api/admin/credentials/bilibili/qr-logins/status", check, adminHeader...); got != (response{status: http.StatusOK, body: `{"message":"","status":"success"}`}) {
+ t.Errorf("B 站的扫码结果 = %#v", got)
+ }
+ bilibili.Fail(errors.New("登录会话 " + login.Key + " 已失效"))
+ if got, want := send(t, server, http.MethodPost, "/api/admin/credentials/bilibili/qr-logins/status", check, adminHeader...), failure(protocol.ErrorCodeProviderError, "音乐平台没有正常响应,请稍后再试。"); got != want {
+ t.Errorf("音源出错时 = %#v,期望 %#v", got, want)
+ }
+ if strings.Contains(logs.String(), login.Key) {
+ t.Errorf("日志中出现了扫码登录的 key:\n%s", logs)
+ }
+}
+
+func TestBilibiliAndYouTubeCredentialStatus(t *testing.T) {
+ bilibili, youtube := porttest.NewBilibiliQRLogin(), porttest.NewYouTubeCookies()
+ server, _ := startAdmin(t, bilibili, youtube)
+ for path, want := range map[string]string{
+ "/api/admin/credentials/bilibili": `{"account":null,"set":false}`,
+ "/api/admin/credentials/youtube": `{"set":false,"updatedAtMs":0}`,
+ } {
+ if got := send(t, server, http.MethodGet, path, "", adminHeader...); got != (response{status: http.StatusOK, body: want}) {
+ t.Errorf("没有凭据时 %s = %#v,期望 %s", path, got, want)
+ }
+ }
+ name := "测试账号"
+ bilibili.SetCredential(&port.SourceAccount{ID: "789", Name: &name, Verified: true})
+ youtube.Set(time.UnixMilli(1760000000123))
+ for path, want := range map[string]string{
+ "/api/admin/credentials/bilibili": `{"account":{"id":"789","name":"测试账号","verified":true,"vip":null},"set":true}`,
+ "/api/admin/credentials/youtube": `{"set":true,"updatedAtMs":1760000000123}`,
+ } {
+ if got := send(t, server, http.MethodGet, path, "", adminHeader...); got != (response{status: http.StatusOK, body: want}) {
+ t.Errorf("有凭据时 %s = %#v,期望 %s", path, got, want)
+ }
+ }
+ bilibili.FailStatus(errors.New("上游失败"))
+ if got, want := send(t, server, http.MethodGet, "/api/admin/credentials/bilibili", "", adminHeader...), failure(protocol.ErrorCodeProviderError, "音乐平台没有正常响应,请稍后再试。"); got != want {
+ t.Errorf("查询 B 站的登录状态失败 = %#v,期望 %#v", got, want)
+ }
+ youtube.Fail(errors.New("数据库不可用"))
+ if got, want := send(t, server, http.MethodGet, "/api/admin/credentials/youtube", "", adminHeader...), failure(protocol.ErrorCodeInternal, "服务器出错了,请稍后再试。"); got != want {
+ t.Errorf("读取 YouTube cookies 的状态失败 = %#v,期望 %#v", got, want)
+ }
+}
diff --git a/internal/adapter/in/api/room_test.go b/internal/adapter/in/api/room_test.go
new file mode 100644
index 0000000..8990c43
--- /dev/null
+++ b/internal/adapter/in/api/room_test.go
@@ -0,0 +1,409 @@
+package api
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "log/slog"
+ "maps"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "regexp"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "testing/synctest"
+ "time"
+
+ "github.com/prometheus/client_golang/prometheus"
+ "go.opentelemetry.io/otel/trace/noop"
+
+ "github.com/dihemoe/alisten/internal/app/admin"
+ "github.com/dihemoe/alisten/internal/app/identity"
+ "github.com/dihemoe/alisten/internal/app/lobby"
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/app/port/porttest"
+ "github.com/dihemoe/alisten/internal/app/realtime"
+ "github.com/dihemoe/alisten/internal/domain/member"
+ "github.com/dihemoe/alisten/internal/domain/room"
+ "github.com/dihemoe/alisten/internal/domain/track"
+ "github.com/dihemoe/alisten/internal/lib/logctx"
+ "github.com/dihemoe/alisten/protocol"
+)
+
+type roomFixture struct {
+ server *httptest.Server
+ store *porttest.Store
+ gateway *realtime.Gateway
+}
+
+func newRoomFixture(t *testing.T) roomFixture {
+ t.Helper()
+ store := porttest.New()
+ music := newCatalog(store, 10)
+ node := porttest.NewRedis().Node("a")
+ host, gateway := realtime.New(store, store, music, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
+ ctx, stop := context.WithCancel(context.Background())
+ wait, err := realtime.Start(ctx, host, gateway)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ if err := host.Shutdown(context.Background()); err != nil {
+ t.Error(err)
+ }
+ if err := gateway.Shutdown(context.Background()); err != nil {
+ t.Error(err)
+ }
+ stop()
+ wait()
+ })
+ l := lobby.New(store, node)
+ server, _ := start(t, Services{
+ Identity: identity.New(store, store, store, gateway),
+ Lobby: l,
+ Admin: admin.New("secret", l, store, store, store, porttest.NewNetEaseQRLogin(), porttest.NewQQQRLogin(), porttest.NewBilibiliQRLogin(), porttest.NewYouTubeCookies(), gateway),
+ Gateway: gateway,
+ Catalog: music,
+ StorageURL: "https://alisten.example/storage/alisten",
+ })
+ return roomFixture{server: server, store: store, gateway: gateway}
+}
+
+func decode[T any](t *testing.T, r response) T {
+ t.Helper()
+ var v T
+ if r.status != http.StatusOK && r.status != http.StatusCreated {
+ t.Fatalf("请求失败:%#v", r)
+ }
+ if err := json.Unmarshal([]byte(r.body), &v); err != nil {
+ t.Fatal(err)
+ }
+ return v
+}
+
+func (f roomFixture) enter(t *testing.T) (string, member.Member, string) {
+ t.Helper()
+ session := decode[protocol.CreateMemberResponse](t, send(t, f.server, http.MethodPost, "/api/members", "{}"))
+ created := decode[protocol.CreateRoomResponse](t, send(t, f.server, http.MethodPost, "/api/rooms", `{"name":"临时","description":""}`))
+ ticket := decode[protocol.CreateTicketResponse](t, send(t, f.server, http.MethodPost, "/api/rooms/"+created.ID+"/tickets", "{}", "Authorization", "Bearer "+session.MemberToken))
+ m := member.Member{ID: session.Member.ID, Name: session.Member.Name}
+ return created.ID, m, ticket.Ticket
+}
+
+var pathParam = regexp.MustCompile(`\{[A-Za-z]+\}`)
+
+func TestRoomRoutesNeedTicket(t *testing.T) {
+ f := newRoomFixture(t)
+ want := failure(protocol.ErrorCodeUnauthorized, "票据无效")
+ for _, route := range protocol.RoomRoutes {
+ method, path, _ := strings.Cut(route.Pattern, " ")
+ path = pathParam.ReplaceAllString(path, "1")
+ for _, header := range [][]string{nil, {"Authorization", "Bearer 不存在"}} {
+ if got := send(t, f.server, method, path, "不是 JSON", header...); got != want {
+ t.Errorf("%s %s,请求头 %q 时 = %#v,期望 %#v", method, path, header, got, want)
+ }
+ }
+ }
+}
+
+type countingServer struct {
+ server *httptest.Server
+ handler http.Handler
+ metrics *prometheus.Registry
+ logs *bytes.Buffer
+}
+
+func startCounting(t *testing.T, s Services) countingServer {
+ t.Helper()
+ metrics := prometheus.NewRegistry()
+ var logs bytes.Buffer
+ handler := SameOrigin([]string{allowedOrigin, "http://localhost:9876"}, New(s, metrics, noop.NewTracerProvider(), slog.New(logctx.NewHandler(slog.NewJSONHandler(&logs, nil)))))
+ server := httptest.NewServer(handler)
+ t.Cleanup(server.Close)
+ return countingServer{server: server, handler: handler, metrics: metrics, logs: &logs}
+}
+
+func commandCounts(t *testing.T, metrics prometheus.Gatherer) map[string]float64 {
+ t.Helper()
+ families, err := metrics.Gather()
+ if err != nil {
+ t.Fatal(err)
+ }
+ counts := map[string]float64{}
+ for _, f := range families {
+ if f.GetName() != "alisten_room_commands_total" {
+ continue
+ }
+ for _, m := range f.GetMetric() {
+ labels := map[string]string{}
+ for _, l := range m.GetLabel() {
+ labels[l.GetName()] = l.GetValue()
+ }
+ counts[labels["type"]+"/"+labels["result"]] = m.GetCounter().GetValue()
+ }
+ }
+ return counts
+}
+
+func (c countingServer) ticket(t *testing.T) string {
+ t.Helper()
+ created := decode[protocol.CreateRoomResponse](t, send(t, c.server, http.MethodPost, "/api/rooms", `{"name":"临时","description":""}`))
+ return decode[protocol.CreateTicketResponse](t, send(t, c.server, http.MethodPost, "/api/rooms/"+created.ID+"/tickets", "{}", "Authorization", "Bearer "+memberToken(t, c.server))).Ticket
+}
+
+var searchPath = "/api/room/search/tracks?" + url.Values{"source": {"netease"}, "keyword": {"晴天"}, "page": {"1"}, "pageSize": {"10"}}.Encode()
+
+func TestPanickingRoomRequestIsCountedAsInternal(t *testing.T) {
+ store := porttest.New()
+ directory := &panickingDirectory{Store: store}
+ s := services(store, store, "")
+ s.Catalog = newCatalog(directory, 10)
+ c := startCounting(t, s)
+ ticket := c.ticket(t)
+ if got := send(t, c.server, http.MethodGet, searchPath, "", "Authorization", "Bearer "+ticket); got.status != http.StatusOK {
+ t.Fatalf("panic 之前的搜索 = %#v,期望成功", got)
+ }
+
+ directory.armed.Store(true)
+ req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, searchPath, nil)
+ req.Header.Set("Authorization", "Bearer "+ticket)
+ rec := httptest.NewRecorder()
+ c.handler.ServeHTTP(rec, req)
+ got := response{status: rec.Code, body: strings.TrimSpace(rec.Body.String())}
+ if want := failure(protocol.ErrorCodeInternal, "服务器出错了,请稍后再试。"); got != want || rec.Header().Get("Content-Type") != "application/json" {
+ t.Errorf("panic 的请求 = %#v、Content-Type = %q,期望 %#v、application/json", got, rec.Header().Get("Content-Type"), want)
+ }
+ var entry map[string]any
+ if err := json.Unmarshal(c.logs.Bytes(), &entry); err != nil {
+ t.Fatalf("日志应只有一行 JSON:%v\n%s", err, c.logs)
+ }
+ stack, _ := entry["stack"].(string)
+ if entry["level"] != "ERROR" || entry["msg"] != "处理 HTTP 请求时发生 panic" || entry["panic"] != "房间目录坏了" || entry["method"] != http.MethodGet || entry["path"] != "/api/room/search/tracks" || !strings.Contains(stack, "panickingDirectory") {
+ t.Errorf("日志 = %v,期望记录 panic 的值、请求的方法和路径,stack 字段是 panic 处的调用栈", entry)
+ }
+ if entry["room_id"] == nil || entry["member_id"] == nil {
+ t.Errorf("日志 = %v,期望带着票据所属的房间和成员", entry)
+ }
+ want := map[string]float64{"search.tracks/ok": 1, "search.tracks/INTERNAL": 1}
+ if got := commandCounts(t, c.metrics); !maps.Equal(got, want) {
+ t.Errorf("alisten_room_commands_total = %v,期望 %v", got, want)
+ }
+}
+
+type disconnectingDirectory struct {
+ *porttest.Store
+ armed atomic.Bool
+ disconnect context.CancelFunc
+}
+
+func (d *disconnectingDirectory) FindRoom(ctx context.Context, ref string) (port.RoomRecord, error) {
+ if !d.armed.Load() {
+ return d.Store.FindRoom(ctx, ref)
+ }
+ d.disconnect()
+ <-ctx.Done()
+ return port.RoomRecord{}, ctx.Err()
+}
+
+func TestRoomRequestOfDisconnectedClientIsNotCounted(t *testing.T) {
+ store := porttest.New()
+ ctx, disconnect := context.WithCancel(t.Context())
+ directory := &disconnectingDirectory{Store: store, disconnect: disconnect}
+ s := services(store, store, "")
+ s.Catalog = newCatalog(directory, 10)
+ c := startCounting(t, s)
+ ticket := c.ticket(t)
+ if got := send(t, c.server, http.MethodGet, searchPath, "", "Authorization", "Bearer "+ticket); got.status != http.StatusOK {
+ t.Fatalf("断开之前的搜索 = %#v,期望成功", got)
+ }
+
+ directory.armed.Store(true)
+ req := httptest.NewRequestWithContext(ctx, http.MethodGet, searchPath, nil)
+ req.Header.Set("Authorization", "Bearer "+ticket)
+ c.handler.ServeHTTP(httptest.NewRecorder(), req)
+ want := map[string]float64{"search.tracks/ok": 1}
+ if got := commandCounts(t, c.metrics); !maps.Equal(got, want) {
+ t.Errorf("alisten_room_commands_total = %v,期望 %v", got, want)
+ }
+}
+
+func TestRoomListingShowsOnlineCount(t *testing.T) {
+ f := newRoomFixture(t)
+ id, m, _ := f.enter(t)
+ session, err := f.gateway.Join(t.Context(), id, m)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { f.gateway.Leave(session) })
+
+ rooms := decode[protocol.ListRoomsResponse](t, send(t, f.server, http.MethodGet, "/api/rooms", ""))
+ if len(rooms.Items) != 1 || rooms.Items[0].OnlineCount != 1 {
+ t.Errorf("房间列表 = %+v,期望在线人数 1", rooms.Items)
+ }
+ if got := decode[protocol.RoomListing](t, send(t, f.server, http.MethodGet, "/api/rooms/"+id, "")); got.OnlineCount != 1 {
+ t.Errorf("GET /api/rooms/{ref} = %+v,期望在线人数 1", got)
+ }
+}
+
+func TestProfileChangeReachesRooms(t *testing.T) {
+ f := newRoomFixture(t)
+ session := decode[protocol.CreateMemberResponse](t, send(t, f.server, http.MethodPost, "/api/members", "{}"))
+ created := decode[protocol.CreateRoomResponse](t, send(t, f.server, http.MethodPost, "/api/rooms", `{"name":"临时","description":""}`))
+ m := member.Member{ID: session.Member.ID, Name: session.Member.Name}
+ s, err := f.gateway.Join(t.Context(), created.ID, m)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { f.gateway.Leave(s) })
+
+ decode[protocol.UpdateMeResponse](t, send(t, f.server, http.MethodPatch, "/api/me", `{"name":"甲"}`, "Authorization", "Bearer "+session.MemberToken))
+ // 第一条是握手的结果,第二条是改名之后的在线列表。
+ ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
+ defer cancel()
+ for range 2 {
+ msg, _, err := s.Next(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if e, ok := msg.(realtime.Event); ok {
+ changed, ok := e.Emit.Event.(room.MembersChanged)
+ if !ok || len(changed.Sessions) != 1 || changed.Sessions[0].Member.Name != "甲" {
+ t.Errorf("改名之后收到 %+v,期望新的在线列表", e.Emit.Event)
+ }
+ return
+ }
+ }
+ t.Error("改名之后没有收到在线列表")
+}
+
+func TestAdminRoomEndpoints(t *testing.T) {
+ f := newRoomFixture(t)
+ admin := []string{"Authorization", "Bearer secret"}
+ created := decode[protocol.AdminCreateRoomResponse](t, send(t, f.server, http.MethodPost, "/api/admin/rooms", `{"name":"大厅","description":"常驻","slug":"hall"}`, admin...))
+ path := "/api/admin/rooms/" + created.ID
+
+ resp, header := exchange(t, f.server, http.MethodPatch, path, `{"name":"新大厅","slug":""}`, admin...)
+ checkNoContent(t, "PATCH "+path, resp, header)
+ listing := decode[protocol.RoomListing](t, send(t, f.server, http.MethodGet, "/api/rooms/"+created.ID, ""))
+ if listing.Name != "新大厅" || listing.Description != "常驻" || listing.Slug != nil {
+ t.Errorf("修改之后 = %+v,期望新的名称、原来的描述、没有别名", listing)
+ }
+
+ tests := []struct {
+ method string
+ path string
+ body string
+ header []string
+ want response
+ }{
+ {http.MethodPatch, path, `{"slug":"Hall"}`, admin, failure(protocol.ErrorCodeInvalidArgument, "slug:格式不正确")},
+ {http.MethodPatch, path, `{}`, []string{"Authorization", "Bearer wrong"}, failure(protocol.ErrorCodeUnauthorized, "管理 token 无效")},
+ {http.MethodPatch, "/api/admin/rooms/hall", `{}`, admin, failure(protocol.ErrorCodeNotFound, "房间不存在")},
+ {http.MethodDelete, "/api/admin/rooms/%00", "", admin, failure(protocol.ErrorCodeNotFound, "房间不存在")},
+ }
+ for _, tt := range tests {
+ if got := send(t, f.server, tt.method, tt.path, tt.body, tt.header...); got != tt.want {
+ t.Errorf("%s %s = %#v,期望 %#v", tt.method, tt.path, got, tt.want)
+ }
+ }
+
+ resp, header = exchange(t, f.server, http.MethodDelete, path, "", admin...)
+ checkNoContent(t, "DELETE "+path, resp, header)
+ if got := send(t, f.server, http.MethodGet, "/api/rooms/"+created.ID, ""); got != failure(protocol.ErrorCodeNotFound, "房间不存在") {
+ t.Errorf("删除之后 GET = %#v", got)
+ }
+ if _, err := f.store.FindRoom(t.Context(), created.ID); err == nil {
+ t.Error("删除之后房间仍在存储中")
+ }
+}
+
+func TestCreatedResponses(t *testing.T) {
+ f := newRoomFixture(t)
+ admin := []string{"Authorization", "Bearer secret"}
+
+ resp, header := exchange(t, f.server, http.MethodPost, "/api/members", "{}")
+ checkCreated(t, "POST /api/members", resp, header, "")
+ session := decode[protocol.CreateMemberResponse](t, resp)
+ resp, header = exchange(t, f.server, http.MethodPost, "/api/rooms", `{"name":"临时","description":""}`)
+ open := decode[protocol.CreateRoomResponse](t, resp)
+ checkCreated(t, "POST /api/rooms", resp, header, "/api/rooms/"+open.ID)
+ resp, header = exchange(t, f.server, http.MethodPost, "/api/rooms/"+open.ID+"/tickets", "{}", "Authorization", "Bearer "+session.MemberToken)
+ checkCreated(t, "POST /api/rooms/{id}/tickets", resp, header, "")
+ resp, header = exchange(t, f.server, http.MethodPost, "/api/admin/rooms", `{"name":"大厅","description":""}`, admin...)
+ hall := decode[protocol.AdminCreateRoomResponse](t, resp)
+ checkCreated(t, "POST /api/admin/rooms", resp, header, "/api/rooms/"+hall.ID)
+ resp, header = exchange(t, f.server, http.MethodPost, "/api/admin/rooms/"+hall.ID+"/api-key", "{}", admin...)
+ checkCreated(t, "POST /api/admin/rooms/{id}/api-key", resp, header, "/api/admin/rooms/"+hall.ID+"/api-key")
+ if key := decode[protocol.APIKey](t, resp); key.Key == "" {
+ t.Errorf("生成的 API Key 为空:%#v", resp)
+ }
+}
+
+func (f *wsFixture) exchange(t *testing.T, method, path, body string, header ...string) (response, http.Header) {
+ t.Helper()
+ return roundTrip(t, f.client, method, "http://alisten.test"+path, body, header...)
+}
+
+func (f *wsFixture) send(t *testing.T, method, path, body string, header ...string) response {
+ t.Helper()
+ resp, _ := f.exchange(t, method, path, body, header...)
+ return resp
+}
+
+func TestRoomRoutes(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newWSFixture(t, store, store, store)
+ f.preparer.Add(sunnyTrack, sunnyEntry.Meta, sunnyEntry.Prep)
+ f.preparer.Add(rain.Track, rain.Meta, track.Prepared{Duration: rain.Meta.Duration, Gain: 1})
+ _, _, ticket := f.enter(t, "")
+ auth := []string{"Authorization", "Bearer " + ticket}
+
+ if got, want := f.send(t, http.MethodPost, "/api/room/skip-votes", "", auth...), failure(protocol.ErrorCodeNothingPlaying, "现在没有在放的歌。"); got != want {
+ t.Errorf("没有歌时投票 = %#v,期望 %#v", got, want)
+ }
+ resp, header := f.exchange(t, http.MethodPost, "/api/room/queue", `{"track":{"source":"netease","id":"186016"}}`, auth...)
+ checkCreated(t, "点歌", resp, header, "/api/room/queue/1")
+ // 等这首歌准备好开播。
+ synctest.Wait()
+ resp, header = f.exchange(t, http.MethodPost, "/api/room/queue", `{"track":{"source":"netease","id":"185811"}}`, auth...)
+ checkCreated(t, "再点一首", resp, header, "/api/room/queue/2")
+ if added := decode[protocol.QueueAddResponse](t, resp); added.Entry.ID != "2" || added.Entry.Meta.Title != "七里香" {
+ t.Errorf("点歌的响应 = %+v", added)
+ }
+ if got := f.send(t, http.MethodPost, "/api/room/queue/2/likes", "", auth...); got != (response{status: http.StatusOK, body: `{"likes":1}`}) {
+ t.Errorf("点赞 = %#v", got)
+ }
+ resp, header = f.exchange(t, http.MethodDelete, "/api/room/queue/2", "", auth...)
+ checkNoContent(t, "删歌", resp, header)
+ resp, header = f.exchange(t, http.MethodPatch, "/api/room/settings", `{"playMode":"rotation"}`, auth...)
+ checkNoContent(t, "切换播放模式", resp, header)
+ resp, header = f.exchange(t, http.MethodPost, "/api/room/messages", `{"content":"你好"}`, auth...)
+ checkNoContent(t, "聊天", resp, header)
+ if got := f.send(t, http.MethodPost, "/api/room/skip-votes", "{}", auth...); got != (response{status: http.StatusOK, body: `{"required":1,"skipped":true,"votes":1}`}) {
+ t.Errorf("投票 = %#v", got)
+ }
+ snapshot := decode[protocol.RoomSyncResponse](t, f.send(t, http.MethodGet, "/api/room", "", auth...)).Room
+ if snapshot.PlayMode != protocol.PlayModeRotation || snapshot.NowPlaying != nil || len(snapshot.Items) != 0 {
+ t.Errorf("房间快照 = %+v,期望轮流播放、没有正在播放和等待的歌", snapshot)
+ }
+
+ if found := decode[protocol.SearchTracksResponse](t, f.send(t, http.MethodGet, searchPath, "", auth...)); found.Total != 1 || found.Items[0].Track.ID != sunnyTrack.ID {
+ t.Errorf("搜索 = %+v", found)
+ }
+ if tracks := decode[protocol.PlaylistTracksResponse](t, f.send(t, http.MethodGet, "/api/room/playlists/netease/p1/tracks", "", auth...)); tracks.Total != 2 {
+ t.Errorf("歌单内的歌曲 = %+v", tracks)
+ }
+ if recommended := decode[protocol.RecommendListResponse](t, f.send(t, http.MethodGet, "/api/room/recommendations", "", auth...)); len(recommended.Items) != 1 || recommended.Items[0].Track.ID != rain.Track.ID {
+ t.Errorf("推荐列表 = %+v", recommended)
+ }
+ if got, want := f.send(t, http.MethodGet, "/api/room/search/tracks?source=netease&keyword=a&page=one&pageSize=10", "", auth...), failure(protocol.ErrorCodeInvalidArgument, "page:应为整数"); got != want {
+ t.Errorf("页码不是整数 = %#v,期望 %#v", got, want)
+ }
+ if got, want := f.send(t, http.MethodPost, "/api/room/messages?echo=1", `{"content":"你好"}`, auth...), failure(protocol.ErrorCodeInvalidArgument, "echo:未知参数"); got != want {
+ t.Errorf("POST 带了查询参数 = %#v,期望 %#v", got, want)
+ }
+ })
+}
diff --git a/internal/adapter/in/api/site_test.go b/internal/adapter/in/api/site_test.go
new file mode 100644
index 0000000..d97ab89
--- /dev/null
+++ b/internal/adapter/in/api/site_test.go
@@ -0,0 +1,45 @@
+package api
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/dihemoe/alisten/internal/app/port/porttest"
+ "github.com/dihemoe/alisten/protocol"
+)
+
+func TestSite(t *testing.T) {
+ store := porttest.New()
+ server, logs := start(t, services(store, store, "admin-secret"))
+ brandName := func(name string) response {
+ return response{status: http.StatusOK, body: `{"brandName":"` + name + `"}`}
+ }
+
+ if got := send(t, server, http.MethodGet, "/api/site", ""); got != brandName("Alisten") {
+ t.Errorf("没有设置过时 GET /api/site = %#v,期望默认的 Alisten", got)
+ }
+ if got := send(t, server, http.MethodPatch, "/api/admin/site", `{"brandName":" 周末听歌会 "}`, adminHeader...); got != brandName("周末听歌会") {
+ t.Errorf("PATCH /api/admin/site = %#v,期望返回去掉首尾空白之后存下的名字", got)
+ }
+ if got := send(t, server, http.MethodGet, "/api/site", ""); got != brandName("周末听歌会") {
+ t.Errorf("修改之后 GET /api/site = %#v,期望去掉首尾空白的新名字", got)
+ }
+
+ invalid := failure(protocol.ErrorCodeInvalidArgument, "brandName:去掉首尾空白后应有 1 到 20 个字符,其中至少一个看得见,不能包含控制字符")
+ for _, name := range []string{" ", strings.Repeat("歌", 21)} {
+ if got := send(t, server, http.MethodPatch, "/api/admin/site", `{"brandName":"`+name+`"}`, adminHeader...); got != invalid {
+ t.Errorf("PATCH 品牌名 %q = %#v,期望 %#v", name, got, invalid)
+ }
+ }
+ unauthorized := failure(protocol.ErrorCodeUnauthorized, "管理 token 无效")
+ if got := send(t, server, http.MethodPatch, "/api/admin/site", `{"brandName":"别的名字"}`); got != unauthorized {
+ t.Errorf("不带管理 token 的 PATCH = %#v,期望 %#v", got, unauthorized)
+ }
+ if got := send(t, server, http.MethodGet, "/api/site", ""); got != brandName("周末听歌会") {
+ t.Errorf("被拒绝的修改之后 GET /api/site = %#v,期望不变", got)
+ }
+ if logs.Len() != 0 {
+ t.Errorf("这些请求都不应产生日志:\n%s", logs)
+ }
+}
diff --git a/internal/adapter/in/api/trace_test.go b/internal/adapter/in/api/trace_test.go
new file mode 100644
index 0000000..4f914c2
--- /dev/null
+++ b/internal/adapter/in/api/trace_test.go
@@ -0,0 +1,55 @@
+package api
+
+import (
+ "slices"
+ "strings"
+ "testing"
+ "testing/synctest"
+
+ "go.opentelemetry.io/otel/attribute"
+ "go.opentelemetry.io/otel/trace"
+
+ "github.com/dihemoe/alisten/internal/app/port/porttest"
+)
+
+func TestWebSocketRequestSpans(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newWSFixture(t, store, store, store)
+ roomID, _, ticket := f.enter(t, "")
+ conn := f.join(t, roomID, ticket)
+
+ writeFrame(t, conn, `{"kind":"request","type":"ping","id":"p1","data":{"clientTimeMs":1}}`)
+ readMessage(t, conn)
+ writeFrame(t, conn, `[]`)
+ readMessage(t, conn)
+ writeFrame(t, conn, `{"kind":"request","type":"chat.send","id":"c1","data":{"content":"你好"}}`)
+ readMessage(t, conn)
+ readMessage(t, conn)
+ writeFrame(t, conn, `{"kind":"request","type":"queue.add","id":"a1","data":{"source":"qq","keyword":"晴天"}}`)
+ if m := readMessage(t, conn); m["error"].(map[string]any)["code"] != "PROVIDER_ERROR" {
+ t.Fatalf("按关键词点歌的回复 = %v,期望 PROVIDER_ERROR", m)
+ }
+ synctest.Wait()
+
+ var got []string
+ traces := map[trace.TraceID]bool{}
+ for _, span := range f.spans.Ended() {
+ if span.SpanKind() != trace.SpanKindServer || span.Parent().IsValid() {
+ t.Errorf("%s 的类型 = %v、父 span = %v,期望没有父 span 的 server span", span.Name(), span.SpanKind(), span.Parent())
+ }
+ traces[span.SpanContext().TraceID()] = true
+ got = append(got, span.Name()+" "+span.Status().Code.String())
+ if !slices.Contains(span.Attributes(), attribute.String("alisten.room_id", roomID)) {
+ t.Errorf("%s 的属性 = %v,期望带着房间 ID", span.Name(), span.Attributes())
+ }
+ }
+ slices.Sort(got)
+ if want := []string{"WS chat.send Unset", "WS hello Unset", "WS queue.add Error"}; !slices.Equal(got, want) {
+ t.Errorf("span = %s,期望 %s", strings.Join(got, "、"), strings.Join(want, "、"))
+ }
+ if len(traces) != len(got) {
+ t.Errorf("%d 个 span 只在 %d 条 trace 中,期望各是一条 trace", len(got), len(traces))
+ }
+ })
+}
diff --git a/internal/adapter/in/api/ws_test.go b/internal/adapter/in/api/ws_test.go
new file mode 100644
index 0000000..6eb93a2
--- /dev/null
+++ b/internal/adapter/in/api/ws_test.go
@@ -0,0 +1,685 @@
+package api
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+ "maps"
+ "net"
+ "net/http"
+ "slices"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "testing/synctest"
+ "time"
+
+ "github.com/coder/websocket"
+ "github.com/prometheus/client_golang/prometheus"
+ sdktrace "go.opentelemetry.io/otel/sdk/trace"
+ "go.opentelemetry.io/otel/sdk/trace/tracetest"
+ "go.opentelemetry.io/otel/trace"
+
+ "github.com/dihemoe/alisten/internal/app/admin"
+ "github.com/dihemoe/alisten/internal/app/identity"
+ "github.com/dihemoe/alisten/internal/app/lobby"
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/app/port/porttest"
+ "github.com/dihemoe/alisten/internal/app/realtime"
+ "github.com/dihemoe/alisten/internal/domain/member"
+ "github.com/dihemoe/alisten/internal/domain/room"
+ "github.com/dihemoe/alisten/internal/lib/logctx"
+ "github.com/dihemoe/alisten/protocol"
+)
+
+// 内存中的监听器:synctest 的时钟只在 bubble 内的全部 goroutine 都持久阻塞时前进,阻塞在真实网络连接上不算持久阻塞。
+type pipeListener struct {
+ conns chan net.Conn
+ closed chan struct{}
+ once sync.Once
+ // 在 dial 之前设置。
+ onServerClose func()
+}
+
+type hookedConn struct {
+ net.Conn
+ once sync.Once
+ onClose func()
+}
+
+func (c *hookedConn) Close() error {
+ c.once.Do(c.onClose)
+ return c.Conn.Close()
+}
+
+func newPipeListener() *pipeListener {
+ return &pipeListener{conns: make(chan net.Conn), closed: make(chan struct{})}
+}
+
+func (l *pipeListener) Accept() (net.Conn, error) {
+ select {
+ case c := <-l.conns:
+ return c, nil
+ case <-l.closed:
+ return nil, net.ErrClosed
+ }
+}
+
+func (l *pipeListener) Close() error {
+ l.once.Do(func() { close(l.closed) })
+ return nil
+}
+
+func (l *pipeListener) Addr() net.Addr { return &net.TCPAddr{} }
+
+func (l *pipeListener) dial(ctx context.Context, _, _ string) (net.Conn, error) {
+ server, client := net.Pipe()
+ conn := server
+ if l.onServerClose != nil {
+ conn = &hookedConn{Conn: server, onClose: l.onServerClose}
+ }
+ select {
+ case l.conns <- conn:
+ return client, nil
+ case <-l.closed:
+ return nil, net.ErrClosed
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+}
+
+type spanFields struct{ slog.Handler }
+
+func (h spanFields) Handle(ctx context.Context, r slog.Record) error {
+ if sc := trace.SpanContextFromContext(ctx); sc.IsSampled() {
+ r.AddAttrs(slog.String("trace_id", sc.TraceID().String()), slog.String("span_id", sc.SpanID().String()))
+ }
+ return h.Handler.Handle(ctx, r)
+}
+
+func (h spanFields) WithAttrs(attrs []slog.Attr) slog.Handler {
+ return spanFields{h.Handler.WithAttrs(attrs)}
+}
+
+func (h spanFields) WithGroup(name string) slog.Handler { return spanFields{h.Handler.WithGroup(name)} }
+
+type wsFixture struct {
+ identity *identity.Service
+ lobby *lobby.Service
+ admin *admin.Service
+ gateway *realtime.Gateway
+ node *porttest.Node
+ preparer *porttest.Preparer
+ client *http.Client
+ listener *pipeListener
+ metrics *prometheus.Registry
+ spans *tracetest.SpanRecorder
+ // 这些场景都不应产生日志;需要日志的用例读取之后要清空它。
+ logs *bytes.Buffer
+}
+
+// 只能在 synctest 的 bubble 中调用。
+func newWSFixture(t *testing.T, store *porttest.Store, snapshots port.RoomSnapshots, directory port.RoomDirectory) *wsFixture {
+ t.Helper()
+ return newWSFixtureWithTickets(t, store, store, snapshots, directory)
+}
+
+// tickets 用来校验票据。只能在 synctest 的 bubble 中调用。
+func newWSFixtureWithTickets(t *testing.T, store *porttest.Store, tickets port.Tickets, snapshots port.RoomSnapshots, directory port.RoomDirectory) *wsFixture {
+ t.Helper()
+ music := newCatalog(directory, 10)
+ node := porttest.NewRedis().Node("a")
+ preparer := porttest.NewPreparer()
+ host, gateway := realtime.New(snapshots, store, music, preparer, realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
+ ctx, stop := context.WithCancel(context.Background())
+ wait, err := realtime.Start(ctx, host, gateway)
+ if err != nil {
+ t.Fatal(err)
+ }
+ l := lobby.New(directory, node)
+ f := &wsFixture{
+ identity: identity.New(store, directory, tickets, gateway),
+ lobby: l,
+ admin: admin.New("secret", l, store, store, store, porttest.NewNetEaseQRLogin(), porttest.NewQQQRLogin(), porttest.NewBilibiliQRLogin(), porttest.NewYouTubeCookies(), gateway),
+ gateway: gateway,
+ node: node,
+ preparer: preparer,
+ listener: newPipeListener(),
+ metrics: prometheus.NewRegistry(),
+ spans: tracetest.NewSpanRecorder(),
+ }
+ var logs bytes.Buffer
+ f.logs = &logs
+ services := Services{Identity: f.identity, Lobby: l, Admin: f.admin, Gateway: gateway, Catalog: music}
+ tracer := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(f.spans))
+ handler := SameOrigin([]string{allowedOrigin, "http://localhost:9876"}, New(services, f.metrics, tracer, slog.New(logctx.NewHandler(spanFields{slog.NewJSONHandler(&logs, nil)}))))
+ listener := f.listener
+ server := &http.Server{Handler: handler}
+ go func() { _ = server.Serve(listener) }()
+ transport := &http.Transport{DialContext: listener.dial}
+ f.client = &http.Client{Transport: transport}
+ t.Cleanup(func() {
+ if err := host.Shutdown(context.Background()); err != nil {
+ t.Error(err)
+ }
+ if err := gateway.Shutdown(context.Background()); err != nil {
+ t.Error(err)
+ }
+ stop()
+ wait()
+ _ = server.Close()
+ transport.CloseIdleConnections()
+ if logs.Len() != 0 {
+ t.Errorf("这些场景都不应产生日志:\n%s", logs.String())
+ }
+ })
+ return f
+}
+
+func (f *wsFixture) enter(t *testing.T, roomID string) (string, member.Member, string) {
+ t.Helper()
+ session, err := f.identity.CreateMember(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if roomID == "" {
+ if roomID, err = f.lobby.CreateRoom(t.Context(), lobby.NewRoom{Name: "临时"}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ ticket, err := f.identity.IssueTicket(t.Context(), session.Member.ID, roomID, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return roomID, session.Member, ticket
+}
+
+func (f *wsFixture) dial(t *testing.T, header http.Header) *websocket.Conn {
+ t.Helper()
+ conn, _, err := websocket.Dial(t.Context(), "ws://alisten.test/ws", &websocket.DialOptions{HTTPClient: f.client, HTTPHeader: header})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = conn.CloseNow() })
+ return conn
+}
+
+func (f *wsFixture) join(t *testing.T, roomID, ticket string) *websocket.Conn {
+ t.Helper()
+ conn := f.dial(t, nil)
+ writeFrame(t, conn, hello(roomID, ticket))
+ if m := readMessage(t, conn); m["error"] != nil {
+ t.Fatalf("握手失败:%v", m)
+ }
+ return conn
+}
+
+func writeFrame(t *testing.T, conn *websocket.Conn, frame string) {
+ t.Helper()
+ if err := conn.Write(t.Context(), websocket.MessageText, []byte(frame)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func hello(roomID, ticket string) string {
+ return `{"kind":"request","type":"hello","id":"h1","data":{"roomId":"` + roomID + `","ticket":"` + ticket + `","protocolVersion":"` + protocol.Version + `"}}`
+}
+
+func readMessage(t *testing.T, conn *websocket.Conn) map[string]any {
+ t.Helper()
+ _, frame, err := conn.Read(t.Context())
+ if err != nil {
+ t.Fatalf("读消息:%v", err)
+ }
+ return decodeMessage(t, frame)
+}
+
+// 服务端生成的时间戳换成 1,便于与期望的报文比较。
+func decodeMessage(t *testing.T, frame []byte) map[string]any {
+ t.Helper()
+ var m map[string]any
+ if err := json.Unmarshal(frame, &m); err != nil {
+ t.Fatal(err)
+ }
+ return normalize(m).(map[string]any)
+}
+
+func normalize(v any) any {
+ switch v := v.(type) {
+ case map[string]any:
+ for k, x := range v {
+ if n, ok := x.(float64); ok && n > 0 && (strings.HasSuffix(k, "AtMs") || k == "serverTimeMs") {
+ v[k] = float64(1)
+ continue
+ }
+ v[k] = normalize(x)
+ }
+ case []any:
+ for i := range v {
+ v[i] = normalize(v[i])
+ }
+ }
+ return v
+}
+
+func checkMessage(t *testing.T, conn *websocket.Conn, want string) {
+ t.Helper()
+ var w any
+ if err := json.Unmarshal([]byte(want), &w); err != nil {
+ t.Fatal(err)
+ }
+ if got, want := jsonOf(t, readMessage(t, conn)), jsonOf(t, w); got != want {
+ t.Errorf("收到\n%s\n期望\n%s", got, want)
+ }
+}
+
+func checkClosed(t *testing.T, conn *websocket.Conn, code websocket.StatusCode) {
+ t.Helper()
+ _, frame, err := conn.Read(t.Context())
+ if got := websocket.CloseStatus(err); got != code {
+ t.Errorf("关闭码 = %d(%v %s),期望 %d", got, err, frame, code)
+ }
+}
+
+func TestHandshakeTimeout(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newWSFixture(t, store, store, store)
+ conn := f.dial(t, nil)
+ start := time.Now()
+ checkClosed(t, conn, protocol.CloseHandshakeTimeout)
+ if elapsed := time.Since(start); elapsed != helloTimeout {
+ t.Errorf("%v 后断开,期望 %v", elapsed, helloTimeout)
+ }
+ })
+}
+
+type stallingDirectory struct {
+ *porttest.Store
+ returned chan struct{}
+ lookup atomic.Pointer[context.Context]
+}
+
+func (d *stallingDirectory) FindRoom(ctx context.Context, _ string) (port.RoomRecord, error) {
+ d.lookup.Store(&ctx)
+ <-ctx.Done()
+ close(d.returned)
+ return port.RoomRecord{}, ctx.Err()
+}
+
+func TestHandshakeTimeoutCancelsLookups(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ directory := &stallingDirectory{Store: store, returned: make(chan struct{})}
+ f := newWSFixture(t, store, store, directory)
+ // 关闭连接时同步检查查询是否已被取消,结果不取决于 goroutine 的调度。
+ var cancelledFirst atomic.Bool
+ f.listener.onServerClose = func() {
+ if ctx := directory.lookup.Load(); ctx != nil && (*ctx).Err() != nil {
+ cancelledFirst.Store(true)
+ }
+ }
+ conn := f.dial(t, nil)
+ start := time.Now()
+ writeFrame(t, conn, hello("01a15778-1911-7a28-8b2e-e08c2a25f388", "不存在"))
+ checkClosed(t, conn, protocol.CloseHandshakeTimeout)
+ if elapsed := time.Since(start); elapsed != helloTimeout {
+ t.Errorf("%v 后断开,期望 %v", elapsed, helloTimeout)
+ }
+ synctest.Wait()
+ select {
+ case <-directory.returned:
+ default:
+ t.Fatal("以 4408 断开之后,握手中的查询仍在等待")
+ }
+ if cancelledFirst.Load() {
+ t.Error("连接以 4408 断开之前,握手中的查询就已经取消了")
+ }
+ })
+}
+
+// 握手要能查到房间,所以 armed 之后才 panic。
+type panickingDirectory struct {
+ *porttest.Store
+ armed atomic.Bool
+}
+
+func (d *panickingDirectory) FindRoom(ctx context.Context, ref string) (port.RoomRecord, error) {
+ if d.armed.Load() {
+ panic("房间目录坏了")
+ }
+ return d.Store.FindRoom(ctx, ref)
+}
+
+func TestPanickingRequestFailsOnlyThatRequest(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ directory := &panickingDirectory{Store: store}
+ f := newWSFixture(t, store, store, directory)
+ roomID, m, ticket := f.enter(t, "")
+ conn := f.join(t, roomID, ticket)
+
+ directory.armed.Store(true)
+ writeFrame(t, conn, `{"kind":"request","type":"search.tracks","id":"s1","data":{"source":"netease","keyword":"晴天","page":1,"pageSize":10}}`)
+ checkMessage(t, conn, `{"kind":"response","type":"search.tracks","id":"s1","data":{},"error":{"code":"INTERNAL","message":"服务器出错了,请稍后再试。"}}`)
+
+ var entry map[string]any
+ if err := json.Unmarshal(f.logs.Bytes(), &entry); err != nil {
+ t.Fatalf("日志应只有一行 JSON:%v\n%s", err, f.logs)
+ }
+ stack, _ := entry["stack"].(string)
+ if entry["level"] != "ERROR" || entry["panic"] != "房间目录坏了" || entry["type"] != "search.tracks" || !strings.Contains(stack, "panickingDirectory") {
+ t.Errorf("日志 = %v,期望记录 panic 的值和请求类型,stack 字段是 panic 处的调用栈", entry)
+ }
+ if entry["room_id"] != roomID || entry["member_id"] != m.ID {
+ t.Errorf("日志 = %v,期望带着会话的房间 %s 和成员 %s", entry, roomID, m.ID)
+ }
+ f.logs.Reset()
+
+ writeFrame(t, conn, `{"kind":"request","type":"chat.send","id":"c1","data":{"content":"还在"}}`)
+ self := `{"avatarHash":"","id":"` + m.ID + `","name":"` + m.Name + `"}`
+ checkMessage(t, conn, `{"kind":"event","type":"chat.message","term":1,"seq":2,"data":{"member":`+self+`,"content":"还在","sentAtMs":1}}`)
+ checkMessage(t, conn, `{"kind":"response","type":"chat.send","id":"c1","data":{}}`)
+ })
+}
+
+type brokenDirectory struct {
+ *porttest.Store
+ armed atomic.Bool
+}
+
+func (d *brokenDirectory) FindRoom(ctx context.Context, ref string) (port.RoomRecord, error) {
+ if d.armed.Load() {
+ return port.RoomRecord{}, errors.New("房间目录坏了")
+ }
+ return d.Store.FindRoom(ctx, ref)
+}
+
+func TestUnexpectedRequestErrorIsLoggedOnceInTheRequestSpan(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ directory := &brokenDirectory{Store: store}
+ f := newWSFixture(t, store, store, directory)
+ roomID, m, ticket := f.enter(t, "")
+ conn := f.join(t, roomID, ticket)
+
+ directory.armed.Store(true)
+ writeFrame(t, conn, `{"kind":"request","type":"search.tracks","id":"s1","data":{"source":"netease","keyword":"晴天","page":1,"pageSize":10}}`)
+ checkMessage(t, conn, `{"kind":"response","type":"search.tracks","id":"s1","data":{},"error":{"code":"INTERNAL","message":"服务器出错了,请稍后再试。"}}`)
+ synctest.Wait()
+
+ var request sdktrace.ReadOnlySpan
+ for _, span := range f.spans.Ended() {
+ if span.Name() == "WS search.tracks" {
+ request = span
+ }
+ }
+ if request == nil {
+ t.Fatal("没有这个请求的 span")
+ }
+ lines := slices.Collect(strings.Lines(strings.TrimSpace(f.logs.String())))
+ if len(lines) != 1 {
+ t.Fatalf("日志有 %d 行,期望恰好 1 行:\n%s", len(lines), f.logs.String())
+ }
+ var entry map[string]any
+ if err := json.Unmarshal([]byte(lines[0]), &entry); err != nil {
+ t.Fatal(err)
+ }
+ want := map[string]any{
+ "type": "search.tracks", "error": "房间目录坏了", "room_id": roomID, "member_id": m.ID,
+ "trace_id": request.SpanContext().TraceID().String(), "span_id": request.SpanContext().SpanID().String(),
+ }
+ for key, value := range want {
+ if entry[key] != value {
+ t.Errorf("日志的 %s = %v,期望 %v:%v", key, entry[key], value, entry)
+ }
+ }
+ f.logs.Reset()
+ })
+}
+
+func TestHandshakeFailures(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newWSFixture(t, store, store, store)
+ roomID, _, ticket := f.enter(t, "")
+ other, _, _ := f.enter(t, "")
+ failure := func(code, message string) string {
+ return `{"kind":"response","type":"hello","id":"h1","data":{},"error":{"code":"` + code + `","message":"` + message + `"}}`
+ }
+ tests := []struct {
+ name string
+ frame string
+ want string
+ code websocket.StatusCode
+ }{
+ {"第一帧不是 hello", `{"kind":"request","type":"room.sync","id":"r1","data":{}}`,
+ `{"kind":"response","type":"room.sync","id":"r1","data":{},"error":{"code":"INVALID_ARGUMENT","message":"第一帧必须是 hello"}}`, protocol.CloseInvalidArgument},
+ {"不是 JSON", `hello`,
+ `{"kind":"event","type":"error","data":{"code":"INVALID_ARGUMENT","message":"消息必须是 JSON 对象"}}`, protocol.CloseInvalidArgument},
+ {"缺少字段", `{"kind":"request","type":"hello","id":"h1","data":{"roomId":"` + roomID + `"}}`,
+ failure("INVALID_ARGUMENT", "data.protocolVersion:缺少必填字段"), protocol.CloseInvalidArgument},
+ {"协议版本不一致", `{"kind":"request","type":"hello","id":"h1","data":{"roomId":"` + roomID + `","ticket":"` + ticket + `","protocolVersion":"0000000000000000"}}`,
+ failure("CLIENT_OUTDATED", "前端与服务端的协议版本不一致,请刷新页面"), protocol.CloseClientOutdated},
+ {"房间 ID 不是 UUID", hello("hall", ticket), failure("NOT_FOUND", "房间不存在"), protocol.CloseNotFound},
+ {"房间不存在", hello("01a15778-1911-7a28-8b2e-e08c2a25f388", "不存在"), failure("NOT_FOUND", "房间不存在"), protocol.CloseNotFound},
+ {"票据无效", hello(roomID, "不存在"), failure("UNAUTHORIZED", "票据无效"), protocol.CloseUnauthorized},
+ {"票据不属于这个房间", hello(other, ticket), failure("UNAUTHORIZED", "票据无效"), protocol.CloseUnauthorized},
+ }
+ for _, tt := range tests {
+ conn := f.dial(t, nil)
+ writeFrame(t, conn, tt.frame)
+ checkMessage(t, conn, tt.want)
+ checkClosed(t, conn, tt.code)
+ if t.Failed() {
+ t.Fatalf("场景:%s", tt.name)
+ }
+ }
+ })
+}
+
+func TestHandshakeWhileRedisIsDown(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newWSFixture(t, store, store, store)
+ roomID, _, ticket := f.enter(t, "")
+ f.node.SetDown(true)
+ conn := f.dial(t, nil)
+ writeFrame(t, conn, hello(roomID, ticket))
+ checkMessage(t, conn, `{"kind":"response","type":"hello","id":"h1","data":{},"error":{"code":"UNAVAILABLE","message":"房间暂时不可用,请稍后再试。"}}`)
+ checkClosed(t, conn, protocol.CloseUnavailable)
+ f.node.SetDown(false)
+ })
+}
+
+func TestSessionRequests(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newWSFixture(t, store, store, store)
+ roomID, m, ticket := f.enter(t, "")
+ conn := f.dial(t, nil)
+ writeFrame(t, conn, hello(roomID, ticket))
+ self := `{"avatarHash":"","id":"` + m.ID + `","name":"` + m.Name + `"}`
+ snapshot := func(seq int) string {
+ return fmt.Sprintf(`{"term":1,"seq":%d,"room":{"id":"%s","name":"临时","description":"","kind":"ephemeral"},"playMode":"sequential","nowPlaying":null,"pending":null,"items":[],"members":{"sessions":[%s],"onlineCount":1}}`, seq, roomID, self)
+ }
+ // 加入时的在线列表事件已经包含在快照中,不再单独发送。
+ checkMessage(t, conn, `{"kind":"response","type":"hello","id":"h1","data":{"member":`+self+`,"serverTimeMs":1,"room":`+snapshot(1)+`}}`)
+
+ writeFrame(t, conn, `{"kind":"request","type":"ping","id":"p1","data":{"clientTimeMs":1234}}`)
+ checkMessage(t, conn, `{"kind":"response","type":"ping","id":"p1","data":{"clientTimeMs":1234,"serverTimeMs":1}}`)
+
+ writeFrame(t, conn, `{"kind":"request","type":"chat.send","id":"c1","data":{"content":"你好"}}`)
+ checkMessage(t, conn, `{"kind":"event","type":"chat.message","term":1,"seq":2,"data":{"member":`+self+`,"content":"你好","sentAtMs":1}}`)
+ checkMessage(t, conn, `{"kind":"response","type":"chat.send","id":"c1","data":{}}`)
+
+ writeFrame(t, conn, `[]`)
+ checkMessage(t, conn, `{"kind":"event","type":"error","data":{"code":"INVALID_ARGUMENT","message":"消息必须是 JSON 对象"}}`)
+ if err := conn.Write(t.Context(), websocket.MessageBinary, []byte(`{}`)); err != nil {
+ t.Fatal(err)
+ }
+ checkMessage(t, conn, `{"kind":"event","type":"error","data":{"code":"INVALID_ARGUMENT","message":"只接受 UTF-8 编码的 JSON 文本帧"}}`)
+ writeFrame(t, conn, hello(roomID, ticket))
+ checkMessage(t, conn, `{"kind":"response","type":"hello","id":"h1","data":{},"error":{"code":"INVALID_ARGUMENT","message":"已经完成握手"}}`)
+ writeFrame(t, conn, `{"kind":"request","type":"queue.clear","id":"x1","data":{}}`)
+ checkMessage(t, conn, `{"kind":"response","type":"queue.clear","id":"x1","data":{},"error":{"code":"INVALID_ARGUMENT","message":"type:未知的请求类型"}}`)
+ writeFrame(t, conn, `{"kind":"request","type":"recommend.list","id":"x2","data":{}}`)
+ checkMessage(t, conn, `{"kind":"response","type":"recommend.list","id":"x2","data":{"items":[{"meta":{"album":"七里香","artist":"周杰伦","coverUrl":"","durationMs":299000,"pageUrl":"https://music.163.com/#/song?id=185811","title":"七里香"},"track":{"id":"185811","source":"netease"}}]}}`)
+
+ writeFrame(t, conn, `{"kind":"request","type":"room.sync","id":"s1","data":{}}`)
+ checkMessage(t, conn, `{"kind":"response","type":"room.sync","id":"s1","data":{"room":`+snapshot(2)+`}}`)
+ })
+}
+
+func TestFrameSizeLimit(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newWSFixture(t, store, store, store)
+ roomID, _, ticket := f.enter(t, "")
+ conn := f.join(t, roomID, ticket)
+
+ ping := `{"kind":"request","type":"ping","id":"p1","data":{"clientTimeMs":1}}`
+ writeFrame(t, conn, ping+strings.Repeat(" ", maxBodyBytes-len(ping)))
+ checkMessage(t, conn, `{"kind":"response","type":"ping","id":"p1","data":{"clientTimeMs":1,"serverTimeMs":1}}`)
+ writeFrame(t, conn, ping+strings.Repeat(" ", maxBodyBytes-len(ping)+1))
+ checkClosed(t, conn, websocket.StatusMessageTooBig)
+ })
+}
+
+func TestIdleSessionIsDisconnected(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newWSFixture(t, store, store, store)
+ roomID, _, ticket := f.enter(t, "")
+ conn := f.join(t, roomID, ticket)
+ // 客户端要一直读取,Ping 才能收到 pong。
+ frames := make(chan []byte)
+ closed := make(chan error, 1)
+ go func() {
+ for {
+ _, frame, err := conn.Read(context.Background())
+ if err != nil {
+ closed <- err
+ return
+ }
+ frames <- frame
+ }
+ }()
+ ping := `{"kind":"request","type":"ping","id":"p1","data":{"clientTimeMs":1}}`
+
+ time.Sleep(idleTimeout - time.Second)
+ writeFrame(t, conn, ping)
+ <-frames
+ time.Sleep(idleTimeout - time.Second)
+ if err := conn.Ping(t.Context()); err != nil {
+ t.Fatalf("ping 控制帧:%v", err)
+ }
+ time.Sleep(idleTimeout - time.Second)
+ writeFrame(t, conn, ping)
+ <-frames
+
+ time.Sleep(idleTimeout - time.Nanosecond)
+ synctest.Wait()
+ select {
+ case err := <-closed:
+ t.Fatalf("不到 30 秒就断开了:%v", err)
+ default:
+ }
+ time.Sleep(time.Nanosecond)
+ synctest.Wait()
+ select {
+ case err := <-closed:
+ if websocket.CloseStatus(err) != -1 {
+ t.Errorf("空闲的连接应直接断开,实际收到关闭帧:%v", err)
+ }
+ default:
+ t.Error("30 秒没有收到任何帧之后连接应已断开")
+ }
+ })
+}
+
+// 房间忙于落盘时,请求都在等待房间回复。
+type slowSnapshots struct {
+ *porttest.Store
+ release chan struct{}
+}
+
+func (s slowSnapshots) SaveRoom(ctx context.Context, term uint32, snap room.Snapshot, plays []room.RecordPlay) error {
+ <-s.release
+ return s.Store.SaveRoom(ctx, term, snap, plays)
+}
+
+func TestOutstandingRequestLimit(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ snapshots := slowSnapshots{Store: store, release: make(chan struct{})}
+ f := newWSFixture(t, store, snapshots, store)
+ roomID, _, ticket := f.enter(t, "")
+ conn := f.join(t, roomID, ticket)
+
+ for i := range 16 {
+ writeFrame(t, conn, fmt.Sprintf(`{"kind":"request","type":"settings.setPlayMode","id":"m%d","data":{"playMode":"random"}}`, i))
+ }
+ writeFrame(t, conn, `{"kind":"request","type":"settings.setPlayMode","id":"m16","data":{"playMode":"random"}}`)
+ checkMessage(t, conn, `{"kind":"response","type":"settings.setPlayMode","id":"m16","data":{},"error":{"code":"RATE_LIMITED","message":"操作太快了,请稍后再试。"}}`)
+ writeFrame(t, conn, `{"kind":"request","type":"ping","id":"p1","data":{"clientTimeMs":1}}`)
+ checkMessage(t, conn, `{"kind":"response","type":"ping","id":"p1","data":{"clientTimeMs":1,"serverTimeMs":1}}`)
+
+ close(snapshots.release)
+ responses := 0
+ for responses < 16 {
+ if m := readMessage(t, conn); m["kind"] == "response" {
+ if m["error"] != nil {
+ t.Errorf("请求失败:%v", m)
+ }
+ responses++
+ }
+ }
+ want := map[string]float64{"settings.setPlayMode/ok": 16, "settings.setPlayMode/RATE_LIMITED": 1}
+ if got := commandCounts(t, f.metrics); !maps.Equal(got, want) {
+ t.Errorf("alisten_room_commands_total = %v,期望 %v", got, want)
+ }
+ })
+}
+
+func TestSessionCloseCodes(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newWSFixture(t, store, store, store)
+ locked, err := f.admin.CreateRoom(t.Context(), admin.NewRoom{Name: "上锁"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ deleted, err := f.admin.CreateRoom(t.Context(), admin.NewRoom{Name: "删除"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, _, lockedTicket := f.enter(t, locked)
+ _, _, deletedTicket := f.enter(t, deleted)
+ ephemeral, _, ephemeralTicket := f.enter(t, "")
+ revoked := f.join(t, locked, lockedTicket)
+ gone := f.join(t, deleted, deletedTicket)
+ restarted := f.join(t, ephemeral, ephemeralTicket)
+
+ password := "芝麻开门"
+ if err := f.admin.UpdateRoom(t.Context(), locked, admin.RoomChange{Password: &password}); err != nil {
+ t.Fatal(err)
+ }
+ checkClosed(t, revoked, protocol.CloseUnauthorized)
+ if err := f.admin.DeleteRoom(t.Context(), deleted); err != nil {
+ t.Fatal(err)
+ }
+ checkClosed(t, gone, protocol.CloseNotFound)
+ // Shutdown 会等待会话断开,而内存连接上的写入要等对方读取,所以一边关闭一边读取。
+ shutdown := make(chan error, 1)
+ go func() { shutdown <- f.gateway.Shutdown(t.Context()) }()
+ checkClosed(t, restarted, protocol.CloseServiceRestart)
+ if err := <-shutdown; err != nil {
+ t.Fatal(err)
+ }
+ })
+}
diff --git a/internal/adapter/in/api/youtube_test.go b/internal/adapter/in/api/youtube_test.go
new file mode 100644
index 0000000..f817838
--- /dev/null
+++ b/internal/adapter/in/api/youtube_test.go
@@ -0,0 +1,55 @@
+package api
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/dihemoe/alisten/internal/app/port/porttest"
+ "github.com/dihemoe/alisten/protocol"
+)
+
+func TestAdminYouTubeCookies(t *testing.T) {
+ cookies := porttest.NewYouTubeCookies()
+ server, _ := startAdmin(t, porttest.NewBilibiliQRLogin(), cookies)
+
+ resp, header := exchange(t, server, http.MethodPut, "/api/admin/credentials/youtube", `{"cookies":"session=fictional"}`, adminHeader...)
+ checkNoContent(t, "PUT", resp, header)
+ saved := cookies.Cookies()
+ if !strings.HasPrefix(saved, "# Netscape HTTP Cookie File\n") || !strings.Contains(saved, "\tsession\tfictional") {
+ t.Errorf("保存的 cookies = %q,期望转换后的 Netscape 格式", saved)
+ }
+
+ want := failure(protocol.ErrorCodeInvalidArgument, "不是能识别的 cookies:请粘贴扩展导出的 cookies.txt,或开发者工具里请求头 cookie 的值。")
+ if got := send(t, server, http.MethodPut, "/api/admin/credentials/youtube", `{"cookies":"fictional"}`, adminHeader...); got != want {
+ t.Errorf("PUT 无法识别的 cookies = %#v,期望 %#v", got, want)
+ }
+
+ want = failure(protocol.ErrorCodeInvalidArgument, "cookies:长度不能少于 1 个字符")
+ if got := send(t, server, http.MethodPut, "/api/admin/credentials/youtube", `{"cookies":""}`, adminHeader...); got != want {
+ t.Errorf("PUT 空的 cookies = %#v,期望 %#v", got, want)
+ }
+ if got := cookies.Cookies(); got != saved {
+ t.Errorf("PUT 空的 cookies 之后保存的 cookies = %q,期望不变", got)
+ }
+
+ want = failure(protocol.ErrorCodeUnauthorized, "管理 token 无效")
+ if got := send(t, server, http.MethodDelete, "/api/admin/credentials/youtube", ""); got != want {
+ t.Errorf("不带管理 token 的 DELETE = %#v,期望 %#v", got, want)
+ }
+ if got := cookies.Cookies(); got != saved {
+ t.Errorf("不带管理 token 的 DELETE 之后保存的 cookies = %q,期望不变", got)
+ }
+
+ resp, header = exchange(t, server, http.MethodDelete, "/api/admin/credentials/youtube", "", adminHeader...)
+ checkNoContent(t, "DELETE", resp, header)
+ if got := cookies.Cookies(); got != "" {
+ t.Errorf("DELETE 之后保存的 cookies = %q,期望清空", got)
+ }
+
+ body := `{"cookies":"` + strings.Repeat("x", 64<<10) + `"}`
+ want = failure(protocol.ErrorCodeInvalidArgument, "请求体不能超过 64 KiB")
+ if got := send(t, server, http.MethodPut, "/api/admin/credentials/youtube", body, adminHeader...); got != want {
+ t.Errorf("超过 64 KiB 的请求 = %#v,期望 %#v", got, want)
+ }
+}
diff --git a/internal/adapter/in/ops/ops_test.go b/internal/adapter/in/ops/ops_test.go
new file mode 100644
index 0000000..b37990f
--- /dev/null
+++ b/internal/adapter/in/ops/ops_test.go
@@ -0,0 +1,38 @@
+package ops
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+
+ "github.com/prometheus/client_golang/prometheus"
+)
+
+func TestHandler(t *testing.T) {
+ registry := prometheus.NewRegistry()
+ registry.MustRegister(prometheus.NewGauge(prometheus.GaugeOpts{Name: "test_gauge", Help: "测试用指标。"}))
+ var ready atomic.Bool
+ handler := NewHandler(ready.Load, registry)
+
+ get := func(path string) *httptest.ResponseRecorder {
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
+ return rec
+ }
+
+ t.Run("未就绪时存活检查仍然成功", func(t *testing.T) {
+ if rec := get("/healthz"); rec.Code != http.StatusOK {
+ t.Errorf("/healthz 状态码 = %d,期望 200", rec.Code)
+ }
+ })
+ t.Run("metrics 输出注册的指标", func(t *testing.T) {
+ rec := get("/metrics")
+ body, _ := io.ReadAll(rec.Body)
+ if rec.Code != http.StatusOK || !strings.Contains(string(body), "test_gauge 0") {
+ t.Errorf("/metrics 状态码 = %d,响应中没有 test_gauge:\n%s", rec.Code, body)
+ }
+ })
+}
diff --git a/internal/adapter/in/worker/main_test.go b/internal/adapter/in/worker/main_test.go
new file mode 100644
index 0000000..2f3196c
--- /dev/null
+++ b/internal/adapter/in/worker/main_test.go
@@ -0,0 +1,93 @@
+package worker
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "os"
+ "sync/atomic"
+ "testing"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+ "github.com/riverqueue/river/riverdriver/riverpgxv5"
+ "github.com/riverqueue/river/rivermigrate"
+ "github.com/testcontainers/testcontainers-go"
+ tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
+)
+
+const riverTemplate = "river_template"
+
+var (
+ server *pgxpool.Config
+ admin *pgxpool.Pool
+ databases atomic.Int64
+)
+
+func TestMain(m *testing.M) {
+ os.Exit(runTests(m))
+}
+
+func runTests(m *testing.M) int {
+ ctx := context.Background()
+ container, err := tcpostgres.Run(ctx, "postgres:18-alpine", tcpostgres.BasicWaitStrategies())
+ defer func() { _ = testcontainers.TerminateContainer(container) }()
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "启动 PostgreSQL 容器:", err)
+ return 1
+ }
+ url, err := container.ConnectionString(ctx, "sslmode=disable")
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ return 1
+ }
+ if server, err = pgxpool.ParseConfig(url); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ return 1
+ }
+ if admin, err = pgxpool.NewWithConfig(ctx, server); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ return 1
+ }
+ defer admin.Close()
+ if err := createRiverTemplate(ctx); err != nil {
+ fmt.Fprintln(os.Stderr, "准备模板数据库:", err)
+ return 1
+ }
+ return m.Run()
+}
+
+func createRiverTemplate(ctx context.Context) error {
+ if _, err := admin.Exec(ctx, "CREATE DATABASE "+riverTemplate); err != nil {
+ return err
+ }
+ cfg := server.Copy()
+ cfg.ConnConfig.Database = riverTemplate
+ pool, err := pgxpool.NewWithConfig(ctx, cfg)
+ if err != nil {
+ return err
+ }
+ // 作为模板的数据库在复制时不能有连接。
+ defer pool.Close()
+ migrator, err := rivermigrate.New(riverpgxv5.New(pool), &rivermigrate.Config{Logger: slog.New(slog.DiscardHandler)})
+ if err != nil {
+ return err
+ }
+ _, err = migrator.Migrate(ctx, rivermigrate.DirectionUp, nil)
+ return err
+}
+
+func newPool(t *testing.T) *pgxpool.Pool {
+ t.Helper()
+ name := fmt.Sprintf("test_%d", databases.Add(1))
+ if _, err := admin.Exec(t.Context(), fmt.Sprintf("CREATE DATABASE %s TEMPLATE %s", name, riverTemplate)); err != nil {
+ t.Fatal(err)
+ }
+ cfg := server.Copy()
+ cfg.ConnConfig.Database = name
+ pool, err := pgxpool.NewWithConfig(t.Context(), cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(pool.Close)
+ return pool
+}
diff --git a/internal/adapter/in/worker/worker_test.go b/internal/adapter/in/worker/worker_test.go
new file mode 100644
index 0000000..a54b395
--- /dev/null
+++ b/internal/adapter/in/worker/worker_test.go
@@ -0,0 +1,207 @@
+package worker
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "log/slog"
+ "slices"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/riverqueue/river"
+ "go.opentelemetry.io/otel/trace/noop"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/domain/track"
+ "github.com/dihemoe/alisten/internal/lib/logctx"
+)
+
+func run(_ context.Context, t track.Track) error {
+ switch t.ID {
+ case "ok":
+ return nil
+ case "rejected":
+ return &port.RejectedError{Err: errors.New("网易云没有提供这首歌的播放地址")}
+ }
+ return errors.New("连接被重置")
+}
+
+type recorder struct {
+ mu sync.Mutex
+ outcomes []port.PrepareOutcome
+ results []string
+}
+
+func (r *recorder) finished(o port.PrepareOutcome) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.outcomes = append(r.outcomes, o)
+}
+
+func (r *recorder) observe(source track.Source, result string, elapsed time.Duration) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if elapsed <= 0 {
+ result += "(没有耗时)"
+ }
+ r.results = append(r.results, string(source)+" "+result)
+}
+
+func (r *recorder) count() int {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return len(r.outcomes)
+}
+
+func TestWorkerReportsFinalOutcomes(t *testing.T) {
+ var logs bytes.Buffer
+ logger := slog.New(logctx.NewHandler(slog.NewJSONHandler(&logs, nil)))
+ client, err := NewClient(newPool(t), func(ctx context.Context, tr track.Track) error {
+ logger.InfoContext(ctx, "执行准备任务", "track_id", tr.ID)
+ return run(ctx, tr)
+ }, noop.NewTracerProvider(), logger)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec := &recorder{}
+ watching := make(chan struct{})
+ go func() {
+ defer close(watching)
+ Watch(client, rec.finished, rec.observe, logger)()
+ }()
+ if err := client.Start(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+
+ jobs := map[string]int64{}
+ for _, id := range []string{"ok", "rejected", "broken"} {
+ res, err := client.Insert(t.Context(), prepareArgs{Source: "netease", ID: id}, &river.InsertOpts{Queue: prepareQueue, MaxAttempts: 2})
+ if err != nil {
+ t.Fatal(err)
+ }
+ jobs[id] = res.Job.ID
+ }
+ // River 第一次重试的间隔约 1 秒。
+ for deadline := time.Now().Add(30 * time.Second); rec.count() < 3; time.Sleep(50 * time.Millisecond) {
+ if time.Now().After(deadline) {
+ t.Fatalf("30 秒后只收到 %d 个结果", rec.count())
+ }
+ }
+ if err := client.Stop(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ <-watching
+
+ netease := func(id string) track.Track { return track.Track{Source: track.NetEase, ID: id} }
+ want := []port.PrepareOutcome{
+ {Track: netease("ok"), JobID: jobs["ok"]},
+ {Track: netease("rejected"), JobID: jobs["rejected"], Reason: "网易云没有提供这首歌的播放地址"},
+ {Track: netease("broken"), JobID: jobs["broken"], Reason: port.UnavailableReason},
+ }
+ byJob := func(a, b port.PrepareOutcome) int { return int(a.JobID - b.JobID) }
+ slices.SortFunc(rec.outcomes, byJob)
+ if !slices.Equal(rec.outcomes, want) {
+ t.Errorf("任务的结果 = %+v\n期望 %+v", rec.outcomes, want)
+ }
+ slices.Sort(rec.results)
+ if want := []string{"netease failed", "netease ok", "netease rejected"}; !slices.Equal(rec.results, want) {
+ t.Errorf("记下的结果 = %q,期望 %q:重试之前的失败不算", rec.results, want)
+ }
+ for _, msg := range []string{`"msg":"准备任务失败,稍后重试"`, `"msg":"准备任务失败"`} {
+ if !strings.Contains(logs.String(), msg) {
+ t.Errorf("日志中没有 %s:\n%s", msg, logs.String())
+ }
+ }
+ for line := range strings.Lines(strings.TrimSpace(logs.String())) {
+ var entry struct {
+ Msg string
+ TrackID string `json:"track_id"`
+ JobID int64 `json:"job_id"`
+ }
+ if err := json.Unmarshal([]byte(line), &entry); err != nil {
+ t.Fatalf("日志不是 JSON:%v\n%s", err, line)
+ }
+ if strings.HasPrefix(entry.Msg, "执行准备任务") || strings.HasPrefix(entry.Msg, "准备任务失败") {
+ if entry.JobID != jobs[entry.TrackID] {
+ t.Errorf("日志 %s 的 job_id = %d,期望 %d", line, entry.JobID, jobs[entry.TrackID])
+ }
+ }
+ }
+ if strings.Contains(logs.String(), "River client started") {
+ t.Errorf("River 的 info 日志不应输出:\n%s", logs.String())
+ }
+ if job, err := client.JobGet(t.Context(), jobs["rejected"]); err != nil || job.State != "cancelled" {
+ t.Errorf("被音源拒绝的任务 = %v, %v,期望直接取消", job, err)
+ }
+}
+
+func TestMediaClientRunsOneJobPerQueue(t *testing.T) {
+ var mu sync.Mutex
+ running, peak := map[track.Source]int{}, map[track.Source]int{}
+ var ran []string
+ run := func(_ context.Context, tr track.Track) error {
+ mu.Lock()
+ running[tr.Source]++
+ peak[tr.Source] = max(peak[tr.Source], running[tr.Source])
+ ran = append(ran, tr.ID)
+ mu.Unlock()
+ time.Sleep(200 * time.Millisecond)
+ mu.Lock()
+ running[tr.Source]--
+ mu.Unlock()
+ return nil
+ }
+ logger := slog.New(slog.DiscardHandler)
+ client, err := NewMediaClient(newPool(t), run, noop.NewTracerProvider(), logger)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec := &recorder{}
+ watching := make(chan struct{})
+ go func() {
+ defer close(watching)
+ Watch(client, rec.finished, rec.observe, logger)()
+ }()
+ if err := client.Start(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ for _, id := range []string{"a", "b", "c"} {
+ if _, err := client.Insert(t.Context(), prepareArgs{Source: "link", ID: "https://example.com/" + id}, &river.InsertOpts{Queue: linkQueue}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := client.Insert(t.Context(), prepareArgs{Source: "bilibili", ID: "BV-" + id}, &river.InsertOpts{Queue: bilibiliQueue}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := client.Insert(t.Context(), prepareArgs{Source: "netease", ID: "186016"}, &river.InsertOpts{Queue: prepareQueue}); err != nil {
+ t.Fatal(err)
+ }
+ for deadline := time.Now().Add(30 * time.Second); rec.count() < 6; time.Sleep(50 * time.Millisecond) {
+ if time.Now().After(deadline) {
+ t.Fatalf("30 秒后只收到 %d 个结果", rec.count())
+ }
+ }
+ if err := client.Stop(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ <-watching
+
+ mu.Lock()
+ defer mu.Unlock()
+ if peak[track.Link] != 1 || peak[track.Bilibili] != 1 {
+ t.Errorf("每个队列同时执行的任务数的峰值 = %v,期望各 1 个", peak)
+ }
+ want := []string{"BV-a", "BV-b", "BV-c", "https://example.com/a", "https://example.com/b", "https://example.com/c"}
+ if slices.Sort(ran); !slices.Equal(ran, want) {
+ t.Errorf("执行的任务 = %q,期望只有 ytdlp 和 bilibili 队列中的", ran)
+ }
+ results := slices.Clone(rec.results)
+ slices.Sort(results)
+ if want := []string{"bilibili ok", "bilibili ok", "bilibili ok", "link ok", "link ok", "link ok"}; !slices.Equal(results, want) {
+ t.Errorf("记下的结果 = %q,期望 %q", results, want)
+ }
+}
diff --git a/internal/adapter/out/bilibili/bilibili_test.go b/internal/adapter/out/bilibili/bilibili_test.go
new file mode 100644
index 0000000..9a1b87d
--- /dev/null
+++ b/internal/adapter/out/bilibili/bilibili_test.go
@@ -0,0 +1,145 @@
+package bilibili
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "slices"
+ "sync"
+ "testing"
+ "time"
+)
+
+type observer struct {
+ mu sync.Mutex
+ calls []string
+}
+
+func (o *observer) observe(source, op string, _ time.Duration, err error) {
+ result := "ok"
+ if err != nil {
+ result = "error"
+ }
+ o.mu.Lock()
+ defer o.mu.Unlock()
+ o.calls = append(o.calls, source+":"+op+":"+result)
+}
+
+func (o *observer) check(t *testing.T, want ...string) {
+ t.Helper()
+ o.mu.Lock()
+ defer o.mu.Unlock()
+ if !slices.Equal(o.calls, want) {
+ t.Fatalf("观察到的调用 = %v,期望 %v", o.calls, want)
+ }
+}
+
+type credentialStore struct {
+ mu sync.Mutex
+ value string
+ loadErr error
+ saveErr error
+ saves []string
+}
+
+func (s *credentialStore) load(context.Context) (string, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.value, s.loadErr
+}
+
+func (s *credentialStore) save(_ context.Context, value string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.saveErr != nil {
+ return s.saveErr
+ }
+ s.value = value
+ s.saves = append(s.saves, value)
+ return nil
+}
+
+func (s *credentialStore) set(t *testing.T, cookie, refreshToken string) {
+ t.Helper()
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.value = encodeCredential(cookie, refreshToken)
+}
+
+type recordedRequest struct {
+ scheme string
+ host string
+ path string
+ query string
+ cookie string
+ referer string
+ range_ string
+}
+
+// 把所有请求改写到 httptest 服务器,同时记录原来的地址和请求头。
+type redirectTransport struct {
+ target *url.URL
+ rt http.RoundTripper
+ mu sync.Mutex
+ reqs []recordedRequest
+}
+
+func (t *redirectTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ t.mu.Lock()
+ t.reqs = append(t.reqs, recordedRequest{
+ scheme: req.URL.Scheme,
+ host: req.URL.Host,
+ path: req.URL.Path,
+ query: req.URL.RawQuery,
+ cookie: req.Header.Get("Cookie"),
+ referer: req.Header.Get("Referer"),
+ range_: req.Header.Get("Range"),
+ })
+ t.mu.Unlock()
+ r := req.Clone(req.Context())
+ r.URL.Scheme = t.target.Scheme
+ r.URL.Host = t.target.Host
+ return t.rt.RoundTrip(r)
+}
+
+func (t *redirectTransport) requests() []recordedRequest {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return slices.Clone(t.reqs)
+}
+
+func (t *redirectTransport) countRequests(path string) int {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ n := 0
+ for _, r := range t.reqs {
+ if r.path == path {
+ n++
+ }
+ }
+ return n
+}
+
+func serve(t *testing.T, routes map[string]http.HandlerFunc) (*Client, *redirectTransport, *observer, *credentialStore) {
+ t.Helper()
+ mux := http.NewServeMux()
+ for path, h := range routes {
+ mux.HandleFunc(path, h)
+ }
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+ target, err := url.Parse(srv.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rt := &redirectTransport{target: target, rt: &http.Transport{}}
+ obs := &observer{}
+ creds := &credentialStore{}
+ return New(&http.Client{Transport: rt}, t.TempDir(), creds.load, creds.save, obs.observe), rt, obs, creds
+}
+
+func respondJSON(w http.ResponseWriter, body string) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(body))
+}
diff --git a/internal/adapter/out/bilibili/download_test.go b/internal/adapter/out/bilibili/download_test.go
new file mode 100644
index 0000000..5136975
--- /dev/null
+++ b/internal/adapter/out/bilibili/download_test.go
@@ -0,0 +1,645 @@
+package bilibili
+
+import (
+ "errors"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+const dashResponse = `{"code":0,"message":"0","data":{"dash":{"audio":[
+{"id":30216,"baseUrl":"https://upos-sz-mirrorcos.bilivideo.com/upgcxcode/audio30216.m4s?sig=SIG16"},
+{"id":30280,"baseUrl":"https://upos-sz-mirrorcos.bilivideo.com/upgcxcode/audio30280.m4s?sig=SIG80"},
+{"id":30232,"baseUrl":""}],
+"flac":{"audio":{"id":30251,"baseUrl":"https://upos-sz-mirrorcos.bilivideo.com/upgcxcode/flac.flac?sig=SIGFLAC"}},
+"dolby":{"audio":[{"id":30250,"baseUrl":"https://upos-sz-mirrorcos.bilivideo.com/upgcxcode/dolby.m4s?sig=SIGDOLBY"}]}}}}`
+
+type playurlCalls struct {
+ mu sync.Mutex
+ queries []url.Values
+}
+
+func (p *playurlCalls) handler(body func(q url.Values) string) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ p.mu.Lock()
+ p.queries = append(p.queries, r.URL.Query())
+ p.mu.Unlock()
+ respondJSON(w, body(r.URL.Query()))
+ }
+}
+
+func (p *playurlCalls) all() []url.Values {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ return append([]url.Values(nil), p.queries...)
+}
+
+func fileHandler(content string) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ _, _ = io.WriteString(w, content)
+ }
+}
+
+func readLinkFile(t *testing.T, f port.LinkFile) string {
+ t.Helper()
+ r, err := f.Open()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = r.Close() }()
+ b, err := io.ReadAll(r)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(b)
+}
+
+func linkFilePath(t *testing.T, f port.LinkFile) string {
+ t.Helper()
+ path, ok := f.(file)
+ if !ok {
+ t.Fatalf("LinkFile 的实现是 %T,期望本包 file", f)
+ }
+ return string(path)
+}
+
+func countDirs(t *testing.T, root string) int {
+ t.Helper()
+ entries, err := filepath.Glob(filepath.Join(root, "bilibili-*"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return len(entries)
+}
+
+// 不提供 nav 路由:下载不应请求 nav,playurl 统一 fnval=80,不区分大会员;误调会因 404 失败。
+func baseDownloadRoutes(calls *playurlCalls, playurl func(q url.Values) string) map[string]http.HandlerFunc {
+ return map[string]http.HandlerFunc{
+ "/x/web-interface/view": func(w http.ResponseWriter, r *http.Request) {
+ respondJSON(w, viewResponse)
+ },
+ "/x/player/pagelist": func(w http.ResponseWriter, r *http.Request) {
+ respondJSON(w, singlePage)
+ },
+ "/x/player/playurl": calls.handler(playurl),
+ "/upgcxcode/audio30280.m4s": fileHandler("AUDIO-DATA"),
+ "/upgcxcode/low.mp4": fileHandler("LOW-DATA"),
+ "/bfs/archive/cover.jpg": fileHandler("COVER-DATA"),
+ }
+}
+
+func lastRequest(tr *redirectTransport, path string) *recordedRequest {
+ reqs := tr.requests()
+ for i := len(reqs) - 1; i >= 0; i-- {
+ if reqs[i].path == path {
+ return &reqs[i]
+ }
+ }
+ return nil
+}
+
+func TestDownloadWithCredential(t *testing.T) {
+ calls := &playurlCalls{}
+ c, tr, obs, creds := serve(t, baseDownloadRoutes(calls, func(url.Values) string { return dashResponse }))
+ creds.set(t, "SESSDATA=sess123; bili_jct=jct456", "refresh789")
+
+ d, release, err := c.Download(t.Context(), "BV1xx411c7mD")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := (track.Meta{Title: "视频标题", Artist: "UP主名字", Duration: 200 * time.Second}); d.Meta != want {
+ t.Errorf("元数据 = %+v,期望 %+v", d.Meta, want)
+ }
+ if got := readLinkFile(t, d.Audio); got != "AUDIO-DATA" {
+ t.Errorf("音频 = %q", got)
+ }
+ if got := readLinkFile(t, d.Cover); got != "COVER-DATA" {
+ t.Errorf("封面 = %q", got)
+ }
+ if d.CoverExt != "jpg" {
+ t.Errorf("CoverExt = %q", d.CoverExt)
+ }
+
+ queries := calls.all()
+ if len(queries) != 1 {
+ t.Fatalf("playurl 请求数 = %d", len(queries))
+ }
+ // playurl 统一 fnval=80,不区分大会员,也不为判定大会员请求 nav。
+ if q := queries[0]; q.Get("fnval") != "80" || q.Get("qn") != "127" || q.Get("bvid") != "BV1xx411c7mD" || q.Get("cid") != "111" {
+ t.Errorf("playurl 参数 = %v", q)
+ }
+ if tr.countRequests("/x/web-interface/nav") != 0 {
+ t.Error("下载不应请求 nav")
+ }
+ // 不选 FLAC 和杜比,即使它们存在。
+ if tr.countRequests("/upgcxcode/flac.flac") != 0 || tr.countRequests("/upgcxcode/dolby.m4s") != 0 {
+ t.Errorf("下载了 FLAC 或杜比音轨:%+v", tr.requests())
+ }
+ audioReq := lastRequest(tr, "/upgcxcode/audio30280.m4s")
+ if audioReq == nil {
+ t.Fatalf("没有下载 30280 音轨:%+v", tr.requests())
+ }
+ if audioReq.scheme != "https" || audioReq.host != "upos-sz-mirrorcos.bilivideo.com" {
+ t.Errorf("音频请求地址 = %s://%s", audioReq.scheme, audioReq.host)
+ }
+ if audioReq.cookie != "SESSDATA=sess123; bili_jct=jct456" || audioReq.referer != "https://www.bilibili.com/" {
+ t.Errorf("音频请求头 = %+v", audioReq)
+ }
+ // 完整文件的 GET 会被部分 CDN 节点(EdgeOne)拦截,Range 请求会回源放行。
+ if audioReq.range_ != "bytes=0-" {
+ t.Errorf("音频请求没有带 Range 头:%+v", audioReq)
+ }
+ if coverReq := lastRequest(tr, "/bfs/archive/cover.jpg"); coverReq == nil || coverReq.referer != "https://www.bilibili.com/" {
+ t.Errorf("封面请求 = %+v", coverReq)
+ } else if coverReq.range_ != "bytes=0-" {
+ t.Errorf("封面请求没有带 Range 头:%+v", coverReq)
+ }
+
+ audioPath := linkFilePath(t, d.Audio)
+ coverPath := linkFilePath(t, d.Cover)
+ if _, err := os.Stat(audioPath); err != nil {
+ t.Fatalf("音频文件不在磁盘上:%v", err)
+ }
+ release()
+ for _, path := range []string{audioPath, coverPath} {
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
+ t.Errorf("release 之后 %s 仍存在(%v)", path, err)
+ }
+ }
+ obs.check(t,
+ "bilibili:download_pagelist:ok",
+ "bilibili:download_view:ok",
+ "bilibili:download_playurl:ok",
+ "bilibili:download_audio:ok",
+ "bilibili:download_cover:ok",
+ )
+}
+
+func TestDownloadWithoutCredential(t *testing.T) {
+ calls := &playurlCalls{}
+ c, tr, _, _ := serve(t, baseDownloadRoutes(calls, func(url.Values) string { return dashResponse }))
+
+ d, release, err := c.Download(t.Context(), "BV1xx411c7mD")
+ if err != nil {
+ t.Fatal(err)
+ }
+ release()
+ if got := calls.all()[0].Get("fnval"); got != "80" {
+ t.Errorf("没有凭据时的 fnval = %q,期望 80", got)
+ }
+ if tr.countRequests("/x/web-interface/nav") != 0 {
+ t.Error("没有凭据时不应请求 nav")
+ }
+ for _, r := range tr.requests() {
+ if r.cookie != "" {
+ t.Errorf("没有凭据时不应带 Cookie:%+v", r)
+ }
+ }
+ if d.Cover == nil {
+ t.Error("封面缺失")
+ }
+}
+
+func TestDownloadDurlFallback(t *testing.T) {
+ calls := &playurlCalls{}
+ c, tr, _, _ := serve(t, baseDownloadRoutes(calls, func(q url.Values) string {
+ if q.Get("fnval") == "0" {
+ return `{"code":0,"message":"0","data":{"durl":[{"url":"https://upos-sz-mirrorcos.bilivideo.com/upgcxcode/low.mp4?sig=SIGLOW"}]}}`
+ }
+ return `{"code":0,"message":"0","data":{"dash":{"audio":[],
+"flac":{"audio":{"id":30251,"baseUrl":"https://upos-sz-mirrorcos.bilivideo.com/upgcxcode/flac.flac?sig=SIGFLAC"}},
+"dolby":{"audio":[{"id":30250,"baseUrl":"https://upos-sz-mirrorcos.bilivideo.com/upgcxcode/dolby.m4s?sig=SIGDOLBY"}]}}}}`
+ }))
+
+ d, release, err := c.Download(t.Context(), "BV1xx411c7mD")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer release()
+ if got := readLinkFile(t, d.Audio); got != "LOW-DATA" {
+ t.Errorf("回退下载的内容 = %q,期望 LOW-DATA", got)
+ }
+ queries := calls.all()
+ if len(queries) != 2 {
+ t.Fatalf("playurl 请求数 = %d,期望 2", len(queries))
+ }
+ if queries[0].Get("fnval") == "0" || queries[1].Get("fnval") != "0" || queries[1].Get("qn") != "16" {
+ t.Errorf("回退请求参数不对:%v", queries)
+ }
+ if tr.countRequests("/upgcxcode/flac.flac") != 0 || tr.countRequests("/upgcxcode/dolby.m4s") != 0 {
+ t.Error("回退时下载了 FLAC 或杜比音轨")
+ }
+}
+
+func TestDownloadNoStreams(t *testing.T) {
+ calls := &playurlCalls{}
+ c, _, _, _ := serve(t, baseDownloadRoutes(calls, func(url.Values) string {
+ return `{"code":0,"message":"0","data":{}}`
+ }))
+ _, release, err := c.Download(t.Context(), "BV1xx411c7mD")
+ if release != nil {
+ release()
+ }
+ var rejected *port.RejectedError
+ if err == nil || errors.As(err, &rejected) {
+ t.Fatalf("没有任何流 = %v,期望普通错误", err)
+ }
+}
+
+func TestDownloadPlayurlRejected(t *testing.T) {
+ calls := &playurlCalls{}
+ c, _, _, _ := serve(t, baseDownloadRoutes(calls, func(url.Values) string {
+ return `{"code":-403,"message":"地区限制","data":{}}`
+ }))
+ _, release, err := c.Download(t.Context(), "BV1xx411c7mD")
+ if release != nil {
+ release()
+ }
+ var rejected *port.RejectedError
+ if !errors.As(err, &rejected) || !strings.Contains(err.Error(), "地区限制") {
+ t.Fatalf("地区限制 = %v,期望带平台提示的 RejectedError", err)
+ }
+}
+
+// view 被风控返回 -404 时降级使用 pagelist 的元数据,下载继续。
+func TestDownloadViewFailureFallsBack(t *testing.T) {
+ calls := &playurlCalls{}
+ routes := baseDownloadRoutes(calls, func(url.Values) string { return dashResponse })
+ routes["/x/web-interface/view"] = func(w http.ResponseWriter, r *http.Request) {
+ respondJSON(w, `{"code":-404,"message":"啥都木有","data":null}`)
+ }
+ c, tr, obs, _ := serve(t, routes)
+ d, release, err := c.Download(t.Context(), "BV1xx411c7mD")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer release()
+ if d.Meta.Title != "BV1xx411c7mD" || d.Meta.Artist != "" {
+ t.Errorf("降级后的元数据 = %+v,期望标题为 BV 号、歌手为空", d.Meta)
+ }
+ if d.Cover != nil {
+ t.Errorf("降级后不应有封面")
+ }
+ if tr.countRequests("/x/player/playurl") != 1 {
+ t.Error("view 失败后应继续下载")
+ }
+ obs.check(t,
+ "bilibili:download_pagelist:ok",
+ "bilibili:download_view:error",
+ "bilibili:download_playurl:ok",
+ "bilibili:download_audio:ok",
+ )
+}
+
+func TestDownloadRedirectToAllowedHost(t *testing.T) {
+ calls := &playurlCalls{}
+ routes := baseDownloadRoutes(calls, func(url.Values) string {
+ return `{"code":0,"message":"0","data":{"dash":{"audio":[{"id":30280,"baseUrl":"https://upos-sz-mirrorcos.bilivideo.com/upgcxcode/redirect.m4s?sig=SIG80"}]}}}`
+ })
+ routes["/upgcxcode/redirect.m4s"] = func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Location", "https://upos-sz-mirrorali.bilivideo.com/upgcxcode/audio30280.m4s?sig=SIG80B")
+ w.WriteHeader(http.StatusFound)
+ }
+ c, tr, _, _ := serve(t, routes)
+ d, release, err := c.Download(t.Context(), "BV1xx411c7mD")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer release()
+ if got := readLinkFile(t, d.Audio); got != "AUDIO-DATA" {
+ t.Errorf("音频 = %q", got)
+ }
+ if tr.countRequests("/upgcxcode/redirect.m4s") != 1 || tr.countRequests("/upgcxcode/audio30280.m4s") != 1 {
+ t.Errorf("跳转没有被跟随:%+v", tr.requests())
+ }
+}
+
+func TestDownloadRedirectOutsideRejected(t *testing.T) {
+ calls := &playurlCalls{}
+ routes := baseDownloadRoutes(calls, func(url.Values) string {
+ return `{"code":0,"message":"0","data":{"dash":{"audio":[{"id":30280,"baseUrl":"https://upos-sz-mirrorcos.bilivideo.com/upgcxcode/redirect.m4s?sig=SIG80"}]}}}`
+ })
+ routes["/upgcxcode/redirect.m4s"] = func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Location", "https://evil.example/upgcxcode/evil.m4s?sig=SIGREDIRECT")
+ w.WriteHeader(http.StatusFound)
+ }
+ c, tr, _, _ := serve(t, routes)
+ _, release, err := c.Download(t.Context(), "BV1xx411c7mD")
+ if release != nil {
+ release()
+ }
+ if err == nil {
+ t.Fatal("跳转到站外地址没有返回错误")
+ }
+ var rejected *port.RejectedError
+ if errors.As(err, &rejected) {
+ t.Fatalf("跳转校验失败不应是 RejectedError:%v", err)
+ }
+ for _, secret := range []string{"SIGREDIRECT", "SIG80", "evil.m4s"} {
+ if strings.Contains(err.Error(), secret) {
+ t.Fatalf("错误泄露了 %q:%v", secret, err)
+ }
+ }
+ if tr.countRequests("/upgcxcode/evil.m4s") != 0 {
+ t.Errorf("跟随了到站外的跳转:%+v", tr.requests())
+ }
+}
+
+func TestDownloadProtocolRelativeCover(t *testing.T) {
+ calls := &playurlCalls{}
+ routes := baseDownloadRoutes(calls, func(url.Values) string { return dashResponse })
+ routes["/x/web-interface/view"] = func(w http.ResponseWriter, r *http.Request) {
+ respondJSON(w, `{"code":0,"message":"0","data":{"bvid":"BV1xx411c7mD","title":"视频标题","pic":"//i0.hdslb.com/bfs/archive/cover.jpg","owner":{"name":"UP主名字","mid":99}}}`)
+ }
+ c, tr, _, _ := serve(t, routes)
+ d, release, err := c.Download(t.Context(), "BV1xx411c7mD")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer release()
+ if d.Cover == nil {
+ t.Fatal("协议相对封面地址没有升级成 HTTPS 后下载")
+ }
+ if got := readLinkFile(t, d.Cover); got != "COVER-DATA" {
+ t.Errorf("封面 = %q", got)
+ }
+ if d.CoverExt != "jpg" {
+ t.Errorf("CoverExt = %q", d.CoverExt)
+ }
+ if r := lastRequest(tr, "/bfs/archive/cover.jpg"); r == nil || r.scheme != "https" {
+ t.Errorf("封面请求 = %+v,期望 https", r)
+ }
+}
+
+func TestDownloadAudioHostNotAllowed(t *testing.T) {
+ calls := &playurlCalls{}
+ c, tr, _, _ := serve(t, baseDownloadRoutes(calls, func(url.Values) string {
+ return `{"code":0,"message":"0","data":{"dash":{"audio":[{"id":30280,"baseUrl":"https://evil.example/upgcxcode/x.m4s?sig=SIGX"}]}}}`
+ }))
+ _, release, err := c.Download(t.Context(), "BV1xx411c7mD")
+ if release != nil {
+ release()
+ }
+ var rejected *port.RejectedError
+ if err == nil || errors.As(err, &rejected) {
+ t.Fatalf("站外音频地址 = %v,期望普通错误", err)
+ }
+ if strings.Contains(err.Error(), "SIGX") {
+ t.Fatalf("错误包含签名地址:%v", err)
+ }
+ if tr.countRequests("/upgcxcode/x.m4s") != 0 {
+ t.Error("不应请求站外地址")
+ }
+}
+
+func TestDownloadAudioFailureSanitized(t *testing.T) {
+ calls := &playurlCalls{}
+ routes := baseDownloadRoutes(calls, func(url.Values) string {
+ return `{"code":0,"message":"0","data":{"dash":{"audio":[{"id":30280,"baseUrl":"https://upos-sz-mirrorcos.bilivideo.com/upgcxcode/audio30280.m4s?sig=SECRETSIG"}]}}}`
+ })
+ routes["/upgcxcode/audio30280.m4s"] = func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ }
+ c, _, _, creds := serve(t, routes)
+ creds.set(t, "SESSDATA=sess123", "refresh789")
+
+ _, release, err := c.Download(t.Context(), "BV1xx411c7mD")
+ if release != nil {
+ release()
+ }
+ if err == nil {
+ t.Fatal("音频 403 没有返回错误")
+ }
+ for _, secret := range []string{"SECRETSIG", "sess123", "refresh789", "audio30280.m4s"} {
+ if strings.Contains(err.Error(), secret) {
+ t.Fatalf("错误泄露了 %q:%v", secret, err)
+ }
+ }
+ var rejected *port.RejectedError
+ if errors.As(err, &rejected) {
+ t.Fatalf("下载失败不应是 RejectedError:%v", err)
+ }
+}
+
+func TestDownloadSchemeUpgrade(t *testing.T) {
+ calls := &playurlCalls{}
+ c, tr, _, _ := serve(t, baseDownloadRoutes(calls, func(url.Values) string {
+ return `{"code":0,"message":"0","data":{"dash":{"audio":[{"id":30280,"baseUrl":"http://upos-sz-mirrorcos.bilivideo.com/upgcxcode/audio30280.m4s?sig=SIG80"}]}}}`
+ }))
+ d, release, err := c.Download(t.Context(), "BV1xx411c7mD")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer release()
+ if got := readLinkFile(t, d.Audio); got != "AUDIO-DATA" {
+ t.Errorf("音频 = %q", got)
+ }
+ if r := lastRequest(tr, "/upgcxcode/audio30280.m4s"); r == nil || r.scheme != "https" {
+ t.Errorf("http 地址没有升级成 https:%+v", r)
+ }
+}
+
+func TestDownloadCoverSkipped(t *testing.T) {
+ for name, pic := range map[string]string{
+ "没有封面": ``,
+ "不支持的格式": `https://i0.hdslb.com/bfs/archive/cover.gif`,
+ "站外封面": `https://evil.example/cover.jpg`,
+ } {
+ t.Run(name, func(t *testing.T) {
+ calls := &playurlCalls{}
+ routes := baseDownloadRoutes(calls, func(url.Values) string { return dashResponse })
+ routes["/x/web-interface/view"] = func(w http.ResponseWriter, r *http.Request) {
+ if pic == "" {
+ respondJSON(w, `{"code":0,"message":"0","data":{"bvid":"BV1xx411c7mD","title":"视频标题","pic":"","owner":{"name":"UP主名字","mid":99}}}`)
+ } else {
+ respondJSON(w, `{"code":0,"message":"0","data":{"bvid":"BV1xx411c7mD","title":"视频标题","pic":`+strconv.Quote(pic)+`,"owner":{"name":"UP主名字","mid":99}}}`)
+ }
+ }
+ c, tr, _, _ := serve(t, routes)
+ d, release, err := c.Download(t.Context(), "BV1xx411c7mD")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer release()
+ if d.Cover != nil || d.CoverExt != "" {
+ t.Errorf("封面 = %v, %q,期望都为空", d.Cover != nil, d.CoverExt)
+ }
+ if tr.countRequests("/bfs/archive/cover.gif") != 0 || tr.countRequests("/cover.jpg") != 0 {
+ t.Errorf("不应下载封面:%+v", tr.requests())
+ }
+ })
+ }
+}
+
+func TestDownloadCoverFailure(t *testing.T) {
+ calls := &playurlCalls{}
+ routes := baseDownloadRoutes(calls, func(url.Values) string { return dashResponse })
+ routes["/bfs/archive/cover.jpg"] = func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ }
+ c, _, _, _ := serve(t, routes)
+ before := countDirs(t, c.root)
+ _, release, err := c.Download(t.Context(), "BV1xx411c7mD")
+ if release != nil {
+ release()
+ }
+ if err == nil {
+ t.Fatal("封面下载失败时没有返回错误")
+ }
+ if after := countDirs(t, c.root); after != before {
+ t.Fatalf("失败后留下了临时目录:之前 %d 个,之后 %d 个", before, after)
+ }
+}
+
+// 下载的中转文件必须建在调用方给出的目录里,进程崩溃留下的残留才能被 worker 启动时的清理删除。
+func TestDownloadUnderGivenRoot(t *testing.T) {
+ calls := &playurlCalls{}
+ c, _, _, _ := serve(t, baseDownloadRoutes(calls, func(url.Values) string { return dashResponse }))
+ d, release, err := c.Download(t.Context(), "BV1xx411c7mD")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer release()
+ if path := linkFilePath(t, d.Audio); !strings.HasPrefix(path, c.root) {
+ t.Errorf("音频文件 %s 不在工作目录 %s 内", path, c.root)
+ }
+}
+
+// 不下载的进程(serve)以空目录构造;误调用 Download 应当显式失败,而不是把文件写进系统临时目录。
+func TestDownloadWithoutRoot(t *testing.T) {
+ c, tr, _, _ := serve(t, baseDownloadRoutes(&playurlCalls{}, func(url.Values) string { return dashResponse }))
+ noRoot := *c
+ noRoot.root = ""
+ _, release, err := noRoot.Download(t.Context(), "BV1xx411c7mD")
+ if release != nil {
+ release()
+ }
+ if err == nil || !strings.Contains(err.Error(), "下载目录") {
+ t.Fatalf("没有下载目录 = %v,期望显式错误", err)
+ }
+ if len(tr.requests()) != 0 {
+ t.Errorf("不应请求平台:%+v", tr.requests())
+ }
+}
+
+func TestDownloadInvalidID(t *testing.T) {
+ c, tr, _, _ := serve(t, nil)
+ if _, release, err := c.Download(t.Context(), "nonsense"); err == nil || release != nil {
+ t.Fatalf("非法 ID = %v, release 非空 %v", err, release != nil)
+ }
+ if len(tr.requests()) != 0 {
+ t.Fatalf("非法 ID 不应请求平台,请求了 %+v", tr.requests())
+ }
+}
+
+// baseUrl 指向的节点(比如 EdgeOne 承载的镜像)会拦截下载,同一条流的 backup_url 仍可用。
+func TestDownloadFallsBackToBackupNode(t *testing.T) {
+ calls := &playurlCalls{}
+ routes := baseDownloadRoutes(calls, func(url.Values) string {
+ return `{"code":0,"message":"0","data":{"dash":{"audio":[{"id":30280,` +
+ `"baseUrl":"https://upos-sz-mirrorcosov.bilivideo.com/upgcxcode/blocked.m4s?sig=SIGBLOCKED",` +
+ `"base_url":"https://upos-sz-mirrorcosov.bilivideo.com/upgcxcode/blocked.m4s?sig=SIGBLOCKED",` +
+ `"backup_url":["https://upos-sz-mirrorcos.bilivideo.com/upgcxcode/audio30280.m4s?sig=SIG80"]}]}}}`
+ })
+ routes["/upgcxcode/blocked.m4s"] = func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) }
+ c, tr, _, _ := serve(t, routes)
+ d, release, err := c.Download(t.Context(), "BV1xx411c7mD")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer release()
+ if got := readLinkFile(t, d.Audio); got != "AUDIO-DATA" {
+ t.Errorf("音频 = %q", got)
+ }
+ if tr.countRequests("/upgcxcode/blocked.m4s") != 1 || tr.countRequests("/upgcxcode/audio30280.m4s") != 1 {
+ t.Errorf("应先试 baseUrl 再换备用节点:%+v", tr.requests())
+ }
+}
+
+// 所有候选节点都失败时才返回错误。
+func TestDownloadAllNodesFail(t *testing.T) {
+ calls := &playurlCalls{}
+ routes := baseDownloadRoutes(calls, func(url.Values) string {
+ return `{"code":0,"message":"0","data":{"dash":{"audio":[{"id":30280,` +
+ `"baseUrl":"https://upos-sz-mirrorcosov.bilivideo.com/upgcxcode/blocked.m4s?sig=SIGA",` +
+ `"backup_url":["https://upos-sz-mirrorcos.bilivideo.com/upgcxcode/blocked2.m4s?sig=SIGB"]}]}}}`
+ })
+ routes["/upgcxcode/blocked.m4s"] = func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) }
+ routes["/upgcxcode/blocked2.m4s"] = func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) }
+ c, tr, _, _ := serve(t, routes)
+ _, release, err := c.Download(t.Context(), "BV1xx411c7mD")
+ if release != nil {
+ release()
+ }
+ if err == nil {
+ t.Fatal("所有节点失败应返回错误")
+ }
+ if tr.countRequests("/upgcxcode/blocked.m4s") != 1 || tr.countRequests("/upgcxcode/blocked2.m4s") != 1 {
+ t.Errorf("应尝试全部候选节点:%+v", tr.requests())
+ }
+ for _, secret := range []string{"SIGA", "SIGB", "blocked.m4s", "blocked2.m4s"} {
+ if strings.Contains(err.Error(), secret) {
+ t.Fatalf("错误泄露了 %q:%v", secret, err)
+ }
+ }
+}
+
+// durl 兜底同样有备用地址。
+func TestDownloadDurlFallsBackToBackupNode(t *testing.T) {
+ calls := &playurlCalls{}
+ routes := baseDownloadRoutes(calls, func(q url.Values) string {
+ if q.Get("fnval") == "0" {
+ return `{"code":0,"message":"0","data":{"durl":[{` +
+ `"url":"https://upos-sz-mirrorcosov.bilivideo.com/upgcxcode/blocked.mp4?sig=SIGLOW",` +
+ `"backup_url":["https://upos-sz-mirrorcos.bilivideo.com/upgcxcode/low.mp4?sig=SIGLOW2"]}]}}`
+ }
+ return `{"code":0,"message":"0","data":{"dash":{"audio":[]}}}`
+ })
+ routes["/upgcxcode/blocked.mp4"] = func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) }
+ c, tr, _, _ := serve(t, routes)
+ d, release, err := c.Download(t.Context(), "BV1xx411c7mD")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer release()
+ if got := readLinkFile(t, d.Audio); got != "LOW-DATA" {
+ t.Errorf("音频 = %q", got)
+ }
+ if tr.countRequests("/upgcxcode/blocked.mp4") != 1 || tr.countRequests("/upgcxcode/low.mp4") != 1 {
+ t.Errorf("durl 应先试 url 再换备用地址:%+v", tr.requests())
+ }
+}
+
+// 节点对 Range 请求返回 206 Partial Content。
+func TestDownloadAcceptsPartialContent(t *testing.T) {
+ calls := &playurlCalls{}
+ routes := baseDownloadRoutes(calls, func(url.Values) string { return dashResponse })
+ routes["/upgcxcode/audio30280.m4s"] = func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Range") != "bytes=0-" {
+ t.Error("下载没有带 Range 头")
+ }
+ w.Header().Set("Content-Range", "bytes 0-9/10")
+ w.Header().Set("Content-Length", "10")
+ w.WriteHeader(http.StatusPartialContent)
+ _, _ = io.WriteString(w, "AUDIO-DATA")
+ }
+ c, _, _, _ := serve(t, routes)
+ d, release, err := c.Download(t.Context(), "BV1xx411c7mD")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer release()
+ if got := readLinkFile(t, d.Audio); got != "AUDIO-DATA" {
+ t.Errorf("音频 = %q", got)
+ }
+}
diff --git a/internal/adapter/out/bilibili/links_test.go b/internal/adapter/out/bilibili/links_test.go
new file mode 100644
index 0000000..aeee816
--- /dev/null
+++ b/internal/adapter/out/bilibili/links_test.go
@@ -0,0 +1,119 @@
+package bilibili
+
+import (
+ "errors"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+)
+
+func TestRecognizeVideoLinks(t *testing.T) {
+ c, tr, _, _ := serve(t, nil)
+ links := map[string]string{
+ "BV1xx411c7mD": "BV1xx411c7mD",
+ "BV1xx411c7mD/2": "BV1xx411c7mD/2",
+ "av170001": "BV17x411w7KC",
+ "https://www.bilibili.com/video/BV1xx411c7mD": "BV1xx411c7mD",
+ "https://www.bilibili.com/video/BV1xx411c7mD/": "BV1xx411c7mD",
+ "https://www.bilibili.com/video/BV1xx411c7mD?p=1": "BV1xx411c7mD",
+ "https://www.bilibili.com/video/BV1xx411c7mD?p=2": "BV1xx411c7mD/2",
+ "https://www.bilibili.com/video/BV1xx411c7mD/?p=12&vd_source=x": "BV1xx411c7mD/12",
+ "https://m.bilibili.com/video/BV1xx411c7mD?p=3": "BV1xx411c7mD/3",
+ "https://www.bilibili.com/video/av170001": "BV17x411w7KC",
+ "https://www.bilibili.com/video/av170001?p=2": "BV17x411w7KC/2",
+ }
+ for link, want := range links {
+ id, ok, err := c.Recognize(t.Context(), link)
+ if err != nil || !ok || id != want {
+ t.Errorf("Recognize(%q) = %q, %v, %v,期望 %q, true, nil", link, id, ok, err, want)
+ }
+ }
+ if len(tr.requests()) != 0 {
+ t.Fatalf("直接识别不应请求平台,请求了 %+v", tr.requests())
+ }
+}
+
+func TestRecognizeNonBilibili(t *testing.T) {
+ c, _, _, _ := serve(t, nil)
+ for _, link := range []string{
+ "https://example.com/video/BV1xx411c7mD",
+ "https://www.bilibili.com.evil.example/video/BV1xx411c7mD",
+ "https://b23.tv.evil.example/abc",
+ "https://www.youtube.com/watch?v=x",
+ "hello world",
+ } {
+ if id, ok, err := c.Recognize(t.Context(), link); err != nil || ok || id != "" {
+ t.Errorf("Recognize(%q) = %q, %v, %v,期望 \"\", false, nil", link, id, ok, err)
+ }
+ }
+}
+
+func TestRecognizeNonVideo(t *testing.T) {
+ c, _, _, _ := serve(t, nil)
+ for _, link := range []string{
+ "https://www.bilibili.com/bangumi/play/ep733316",
+ "https://www.bilibili.com/bangumi/play/ss45929",
+ "https://live.bilibili.com/22603245",
+ "https://space.bilibili.com/208259",
+ "https://www.bilibili.com/",
+ "https://www.bilibili.com/video/BV123",
+ "https://www.bilibili.com/video/BV1xx411c7mD?p=10000",
+ } {
+ _, _, err := c.Recognize(t.Context(), link)
+ var rejected *port.RejectedError
+ if !errors.As(err, &rejected) {
+ t.Errorf("Recognize(%q) = %v,期望 RejectedError", link, err)
+ }
+ }
+}
+
+func TestRecognizeShortLink(t *testing.T) {
+ c, tr, obs, _ := serve(t, map[string]http.HandlerFunc{
+ "/x7aBc": func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Location", "https://www.bilibili.com/video/BV1xx411c7mD?p=2")
+ w.WriteHeader(http.StatusFound)
+ },
+ })
+ id, ok, err := c.Recognize(t.Context(), "https://b23.tv/x7aBc")
+ if err != nil || !ok || id != "BV1xx411c7mD/2" {
+ t.Fatalf("短链 = %q, %v, %v", id, ok, err)
+ }
+ reqs := tr.requests()
+ if len(reqs) != 1 || reqs[0].host != "b23.tv" {
+ t.Fatalf("请求 = %+v", reqs)
+ }
+ obs.check(t, "bilibili:recognize:ok")
+}
+
+func TestRecognizeShortLinkToNonVideo(t *testing.T) {
+ c, _, _, _ := serve(t, map[string]http.HandlerFunc{
+ "/x7aBc": func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Location", "https://live.bilibili.com/22603245")
+ w.WriteHeader(http.StatusFound)
+ },
+ })
+ _, _, err := c.Recognize(t.Context(), "https://b23.tv/x7aBc")
+ var rejected *port.RejectedError
+ if !errors.As(err, &rejected) {
+ t.Fatalf("指向直播的短链 = %v,期望 RejectedError", err)
+ }
+}
+
+func TestRecognizeShortLinkFailure(t *testing.T) {
+ c, _, obs, _ := serve(t, map[string]http.HandlerFunc{
+ "/x7aBc": func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ },
+ })
+ _, _, err := c.Recognize(t.Context(), "https://b23.tv/x7aBc")
+ var rejected *port.RejectedError
+ if err == nil || errors.As(err, &rejected) {
+ t.Fatalf("短链请求失败 = %v,期望普通错误", err)
+ }
+ if strings.Contains(err.Error(), "x7aBc") {
+ t.Fatalf("错误包含短链地址:%v", err)
+ }
+ obs.check(t, "bilibili:recognize:error")
+}
diff --git a/internal/adapter/out/bilibili/login_test.go b/internal/adapter/out/bilibili/login_test.go
new file mode 100644
index 0000000..1b12bb9
--- /dev/null
+++ b/internal/adapter/out/bilibili/login_test.go
@@ -0,0 +1,295 @@
+package bilibili
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+)
+
+func TestCreateQRLogin(t *testing.T) {
+ c, tr, obs, _ := serve(t, map[string]http.HandlerFunc{
+ "/x/passport-login/web/qrcode/generate": func(w http.ResponseWriter, r *http.Request) {
+ respondJSON(w, `{"code":0,"message":"0","data":{"url":"https://passport.bilibili.com/h5-app/passport/login/scan?qrcode_key=KEY123","qrcode_key":"KEY123"}}`)
+ },
+ })
+ before := time.Now()
+ session, err := c.CreateQRLogin(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if session.Key != "KEY123" {
+ t.Errorf("Key = %q", session.Key)
+ }
+ if want := "https://passport.bilibili.com/h5-app/passport/login/scan?qrcode_key=KEY123"; session.LoginURL != want {
+ t.Errorf("LoginURL = %q,期望 %q", session.LoginURL, want)
+ }
+ if session.QRImage != "" {
+ t.Errorf("QRImage = %q,期望留空", session.QRImage)
+ }
+ if session.ExpiresAt.Before(before.Add(3*time.Minute-time.Second)) || session.ExpiresAt.After(time.Now().Add(3*time.Minute+time.Second)) {
+ t.Errorf("ExpiresAt = %v,期望约为 3 分钟后", session.ExpiresAt)
+ }
+ reqs := tr.requests()
+ if len(reqs) != 1 || reqs[0].host != "passport.bilibili.com" {
+ t.Fatalf("请求 = %+v", reqs)
+ }
+ if reqs[0].referer != "https://www.bilibili.com/" {
+ t.Errorf("Referer = %q", reqs[0].referer)
+ }
+ obs.check(t, "bilibili:login_create:ok")
+}
+
+func TestCreateQRLoginFailure(t *testing.T) {
+ for name, body := range map[string]string{
+ "平台错误": `{"code":-1,"message":"操作频繁","data":{}}`,
+ "缺少key": `{"code":0,"message":"0","data":{"url":"https://example.com","qrcode_key":""}}`,
+ "格式错误": `不是 JSON`,
+ } {
+ t.Run(name, func(t *testing.T) {
+ c, _, obs, _ := serve(t, map[string]http.HandlerFunc{
+ "/x/passport-login/web/qrcode/generate": func(w http.ResponseWriter, r *http.Request) {
+ respondJSON(w, body)
+ },
+ })
+ if _, err := c.CreateQRLogin(t.Context()); err == nil {
+ t.Fatal("没有返回错误")
+ } else if strings.Contains(err.Error(), "example.com") {
+ t.Fatalf("错误包含平台地址:%v", err)
+ }
+ obs.check(t, "bilibili:login_create:error")
+ })
+ }
+
+ c, _, _, _ := serve(t, map[string]http.HandlerFunc{
+ "/x/passport-login/web/qrcode/generate": func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ },
+ })
+ if _, err := c.CreateQRLogin(t.Context()); err == nil {
+ t.Fatal("HTTP 500 没有返回错误")
+ }
+}
+
+func TestCheckQRLoginStatusMapping(t *testing.T) {
+ for code, want := range map[int]port.QRLoginStatus{
+ 86101: port.QRLoginWaiting,
+ 86090: port.QRLoginScanned,
+ 86038: port.QRLoginExpired,
+ 86200: port.QRLoginFailed,
+ } {
+ t.Run(string(want), func(t *testing.T) {
+ c, tr, obs, _ := serve(t, map[string]http.HandlerFunc{
+ "/x/passport-login/web/qrcode/poll": func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Query().Get("qrcode_key") != "KEY123" {
+ t.Errorf("qrcode_key = %q", r.URL.Query().Get("qrcode_key"))
+ }
+ respondJSON(w, `{"code":0,"message":"0","data":{"code":`+strconv.Itoa(code)+`,"message":"状态提示"}}`)
+ },
+ })
+ result, err := c.CheckQRLogin(t.Context(), "KEY123")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Status != want || result.Message != "状态提示" {
+ t.Fatalf("结果 = %+v,期望状态 %q", result, want)
+ }
+ if tr.countRequests("/x/passport-login/web/qrcode/poll") != 1 {
+ t.Fatalf("poll 请求数 = %d", tr.countRequests("/x/passport-login/web/qrcode/poll"))
+ }
+ obs.check(t, "bilibili:login_check:ok")
+ })
+ }
+}
+
+func TestCheckQRLoginBlankKey(t *testing.T) {
+ c, tr, _, _ := serve(t, map[string]http.HandlerFunc{
+ "/x/passport-login/web/qrcode/poll": func(w http.ResponseWriter, r *http.Request) {
+ t.Error("空白 key 不应请求平台")
+ },
+ })
+ for _, key := range []string{"", " "} {
+ if _, err := c.CheckQRLogin(t.Context(), key); err == nil {
+ t.Errorf("key = %q 没有返回错误", key)
+ }
+ }
+ if len(tr.requests()) != 0 {
+ t.Fatalf("空白 key 不应请求平台,请求了 %+v", tr.requests())
+ }
+}
+
+func TestCheckQRLoginMessageFallback(t *testing.T) {
+ c, _, _, _ := serve(t, map[string]http.HandlerFunc{
+ "/x/passport-login/web/qrcode/poll": func(w http.ResponseWriter, r *http.Request) {
+ respondJSON(w, `{"code":0,"message":"顶层提示","data":{"code":86101,"message":""}}`)
+ },
+ })
+ result, err := c.CheckQRLogin(t.Context(), "KEY123")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Message != "顶层提示" {
+ t.Fatalf("Message = %q,期望回落到顶层 message", result.Message)
+ }
+}
+
+func TestCheckQRLoginSuccess(t *testing.T) {
+ c, _, obs, creds := serve(t, map[string]http.HandlerFunc{
+ "/x/passport-login/web/qrcode/poll": func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Add("Set-Cookie", "bili_jct=jct456; Path=/")
+ w.Header().Add("Set-Cookie", "SESSDATA=sess123; Path=/; HttpOnly")
+ respondJSON(w, `{"code":0,"message":"0","data":{"code":0,"message":"登录成功","refresh_token":"refresh789"}}`)
+ },
+ })
+ result, err := c.CheckQRLogin(t.Context(), "KEY123")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Status != port.QRLoginSuccess || result.Message != "登录成功" {
+ t.Fatalf("结果 = %+v", result)
+ }
+ if len(creds.saves) != 1 {
+ t.Fatalf("保存次数 = %d", len(creds.saves))
+ }
+ saved := creds.saves[0]
+ for _, want := range []string{"SESSDATA=sess123; bili_jct=jct456", "refresh789"} {
+ if !strings.Contains(saved, want) {
+ t.Errorf("保存的凭据不含 %q:%q", want, saved)
+ }
+ }
+ // 结果不能把凭据带出去。
+ if strings.Contains(result.Message, "sess123") || strings.Contains(result.Message, "refresh789") {
+ t.Fatalf("结果泄露了凭据:%+v", result)
+ }
+ obs.check(t, "bilibili:login_check:ok")
+}
+
+func TestCheckQRLoginSuccessWithoutCookies(t *testing.T) {
+ c, _, _, creds := serve(t, map[string]http.HandlerFunc{
+ "/x/passport-login/web/qrcode/poll": func(w http.ResponseWriter, r *http.Request) {
+ respondJSON(w, `{"code":0,"message":"0","data":{"code":0,"message":"登录成功","refresh_token":"refresh789"}}`)
+ },
+ })
+ if _, err := c.CheckQRLogin(t.Context(), "KEY123"); err == nil {
+ t.Fatal("扫码成功但没有 Cookie 时没有返回错误")
+ }
+ if len(creds.saves) != 0 {
+ t.Fatalf("不应保存凭据,保存了 %d 次", len(creds.saves))
+ }
+}
+
+func TestCheckQRLoginFailure(t *testing.T) {
+ c, _, _, _ := serve(t, map[string]http.HandlerFunc{
+ "/x/passport-login/web/qrcode/poll": func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ },
+ })
+ if _, err := c.CheckQRLogin(t.Context(), "KEY123"); err == nil {
+ t.Fatal("HTTP 500 没有返回错误")
+ }
+
+ c, _, _, creds := serve(t, map[string]http.HandlerFunc{
+ "/x/passport-login/web/qrcode/poll": func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Add("Set-Cookie", "SESSDATA=sess123; Path=/")
+ respondJSON(w, `{"code":0,"message":"0","data":{"code":0,"message":"登录成功","refresh_token":"refresh789"}}`)
+ },
+ })
+ creds.saveErr = errors.New("数据库不可用")
+ if _, err := c.CheckQRLogin(t.Context(), "KEY123"); err == nil {
+ t.Fatal("保存失败时没有返回错误")
+ }
+ if len(creds.saves) != 0 {
+ t.Fatal("保存失败不应留下凭据")
+ }
+}
+
+func TestCredentialStatusNotSet(t *testing.T) {
+ c, tr, obs, _ := serve(t, nil)
+ status, err := c.CredentialStatus(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if status.Set || status.Account != nil {
+ t.Fatalf("状态 = %+v,期望未设置", status)
+ }
+ if len(tr.requests()) != 0 {
+ t.Fatalf("没有凭据时不应请求平台,请求了 %+v", tr.requests())
+ }
+ obs.check(t)
+}
+
+func TestCredentialStatusLoggedIn(t *testing.T) {
+ for name, tc := range map[string]struct {
+ vipStatus int
+ vipType int
+ vip bool
+ }{
+ "大会员": {1, 2, true},
+ "非大会员": {0, 0, false},
+ "过期大会员": {1, 0, false},
+ } {
+ t.Run(name, func(t *testing.T) {
+ c, tr, obs, creds := serve(t, map[string]http.HandlerFunc{
+ "/x/web-interface/nav": func(w http.ResponseWriter, r *http.Request) {
+ respondJSON(w, `{"code":0,"message":"0","data":{"isLogin":true,"uname":"测试用户","mid":12345,"vipStatus":`+strconv.Itoa(tc.vipStatus)+`,"vipType":`+strconv.Itoa(tc.vipType)+`}}`)
+ },
+ })
+ creds.set(t, "SESSDATA=sess123; bili_jct=jct456", "refresh789")
+ status, err := c.CredentialStatus(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !status.Set || status.Account == nil {
+ t.Fatalf("状态 = %+v,期望已登录", status)
+ }
+ a := status.Account
+ if a.ID != "12345" || a.Name == nil || *a.Name != "测试用户" || a.VIP == nil || *a.VIP != tc.vip || !a.Verified {
+ t.Errorf("账号 = %+v", a)
+ }
+ reqs := tr.requests()
+ if len(reqs) != 1 || reqs[0].cookie != "SESSDATA=sess123; bili_jct=jct456" {
+ t.Fatalf("nav 请求 = %+v", reqs)
+ }
+ obs.check(t, "bilibili:credential_status:ok")
+ })
+ }
+}
+
+func TestCredentialStatusNotLoggedIn(t *testing.T) {
+ c, _, _, creds := serve(t, map[string]http.HandlerFunc{
+ "/x/web-interface/nav": func(w http.ResponseWriter, r *http.Request) {
+ respondJSON(w, `{"code":-101,"message":"账号未登录","data":{"isLogin":false}}`)
+ },
+ })
+ creds.set(t, "SESSDATA=expired", "refresh789")
+ status, err := c.CredentialStatus(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !status.Set || status.Account != nil {
+ t.Fatalf("状态 = %+v,期望已保存但未识别到账号", status)
+ }
+}
+
+func TestCredentialStatusFailure(t *testing.T) {
+ c, _, _, creds := serve(t, map[string]http.HandlerFunc{
+ "/x/web-interface/nav": func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ },
+ })
+ creds.set(t, "SESSDATA=sess123", "")
+ if _, err := c.CredentialStatus(t.Context()); err == nil {
+ t.Fatal("nav 请求失败时没有返回错误")
+ } else if strings.Contains(err.Error(), "sess123") {
+ t.Fatalf("错误泄露了凭据:%v", err)
+ }
+
+ creds.loadErr = errors.New("数据库不可用")
+ if _, err := c.CredentialStatus(t.Context()); err == nil {
+ t.Fatal("读取凭据失败时没有返回错误")
+ }
+}
diff --git a/internal/adapter/out/bilibili/meta_test.go b/internal/adapter/out/bilibili/meta_test.go
new file mode 100644
index 0000000..1e0ff5e
--- /dev/null
+++ b/internal/adapter/out/bilibili/meta_test.go
@@ -0,0 +1,144 @@
+package bilibili
+
+import (
+ "errors"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+const viewResponse = `{"code":0,"message":"0","data":{"bvid":"BV1xx411c7mD","title":"视频标题","pic":"http://i0.hdslb.com/bfs/archive/cover.jpg","owner":{"name":"UP主名字","mid":99},"pages":[{"cid":111,"page":1,"part":"","duration":200}]}}`
+
+func metaRoutes(pagelist string) map[string]http.HandlerFunc {
+ return map[string]http.HandlerFunc{
+ "/x/web-interface/view": func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Query().Get("bvid") != "BV1xx411c7mD" {
+ http.Error(w, "bad bvid", http.StatusBadRequest)
+ return
+ }
+ respondJSON(w, viewResponse)
+ },
+ "/x/player/pagelist": func(w http.ResponseWriter, r *http.Request) {
+ respondJSON(w, pagelist)
+ },
+ }
+}
+
+const singlePage = `{"code":0,"message":"0","data":[{"cid":111,"page":1,"part":"","duration":200}]}`
+
+const twoPages = `{"code":0,"message":"0","data":[{"cid":111,"page":1,"part":"","duration":200},{"cid":222,"page":2,"part":"第二P","duration":120}]}`
+
+func TestTrackMetaSinglePage(t *testing.T) {
+ c, tr, obs, creds := serve(t, metaRoutes(singlePage))
+ creds.set(t, "SESSDATA=sess123", "")
+ meta, err := c.TrackMeta(t.Context(), "BV1xx411c7mD")
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := track.Meta{Title: "视频标题", Artist: "UP主名字", Duration: 200 * time.Second}
+ if meta != want {
+ t.Fatalf("元数据 = %+v,期望 %+v", meta, want)
+ }
+ for _, r := range tr.requests() {
+ if r.cookie != "SESSDATA=sess123" || r.referer != "https://www.bilibili.com/" {
+ t.Errorf("请求头不对:%+v", r)
+ }
+ }
+ obs.check(t, "bilibili:meta_pagelist:ok", "bilibili:meta_view:ok")
+}
+
+func TestTrackMetaSecondPage(t *testing.T) {
+ c, _, _, _ := serve(t, metaRoutes(twoPages))
+ meta, err := c.TrackMeta(t.Context(), "BV1xx411c7mD/2")
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := track.Meta{Title: "视频标题 - 第二P", Artist: "UP主名字", Duration: 120 * time.Second}
+ if meta != want {
+ t.Fatalf("元数据 = %+v,期望 %+v", meta, want)
+ }
+}
+
+func TestTrackMetaPageMissing(t *testing.T) {
+ c, _, _, _ := serve(t, metaRoutes(twoPages))
+ _, err := c.TrackMeta(t.Context(), "BV1xx411c7mD/3")
+ var rejected *port.RejectedError
+ if !errors.As(err, &rejected) {
+ t.Fatalf("不存在的分 P = %v,期望 RejectedError", err)
+ }
+}
+
+// pagelist 返回 -400(不存在的视频)时拒绝准备。
+func TestTrackMetaRejected(t *testing.T) {
+ c, _, obs, _ := serve(t, map[string]http.HandlerFunc{
+ "/x/player/pagelist": func(w http.ResponseWriter, r *http.Request) {
+ respondJSON(w, `{"code":-400,"message":"请求错误","ttl":1}`)
+ },
+ })
+ _, err := c.TrackMeta(t.Context(), "BV1xx411c7mD")
+ var rejected *port.RejectedError
+ if !errors.As(err, &rejected) {
+ t.Fatalf("视频不存在 = %v,期望 RejectedError", err)
+ }
+ obs.check(t, "bilibili:meta_pagelist:error")
+}
+
+// view 接口经常被风控(未登录 412、登录后伪装 -404),失败时退化为 pagelist 的元数据。
+func TestTrackMetaViewFallback(t *testing.T) {
+ routes := metaRoutes(twoPages)
+ routes["/x/web-interface/view"] = func(w http.ResponseWriter, r *http.Request) {
+ respondJSON(w, `{"code":-404,"message":"啥都木有","data":null}`)
+ }
+ c, _, obs, _ := serve(t, routes)
+ meta, err := c.TrackMeta(t.Context(), "BV1xx411c7mD/2")
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := track.Meta{Title: "第二P", Duration: 120 * time.Second}
+ if meta != want {
+ t.Fatalf("降级后的元数据 = %+v,期望 %+v", meta, want)
+ }
+ obs.check(t, "bilibili:meta_pagelist:ok", "bilibili:meta_view:error")
+}
+
+// 单 P 视频的分 P 标题为空时,降级用 BV 号做标题。
+func TestTrackMetaViewFallbackUntitled(t *testing.T) {
+ routes := metaRoutes(singlePage)
+ routes["/x/web-interface/view"] = func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusPreconditionFailed)
+ }
+ c, _, _, _ := serve(t, routes)
+ meta, err := c.TrackMeta(t.Context(), "BV1xx411c7mD")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if meta.Title != "BV1xx411c7mD" || meta.Artist != "" {
+ t.Fatalf("降级后的元数据 = %+v,期望标题为 BV 号、歌手为空", meta)
+ }
+}
+
+func TestTrackMetaPagelistFailure(t *testing.T) {
+ routes := metaRoutes(singlePage)
+ routes["/x/player/pagelist"] = func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ }
+ c, _, obs, _ := serve(t, routes)
+ if _, err := c.TrackMeta(t.Context(), "BV1xx411c7mD"); err == nil {
+ t.Fatal("pagelist 失败时没有返回错误")
+ }
+ // pagelist 是第一步,失败时不应再请求 view。
+ obs.check(t, "bilibili:meta_pagelist:error")
+}
+
+func TestTrackMetaInvalidID(t *testing.T) {
+ c, tr, _, _ := serve(t, nil)
+ if _, err := c.TrackMeta(t.Context(), "nonsense"); err == nil {
+ t.Fatal("非法 ID 没有返回错误")
+ }
+ if len(tr.requests()) != 0 {
+ t.Fatalf("非法 ID 不应请求平台,请求了 %+v", tr.requests())
+ }
+}
diff --git a/internal/adapter/out/ffmpeg/ffmpeg_test.go b/internal/adapter/out/ffmpeg/ffmpeg_test.go
new file mode 100644
index 0000000..dd94302
--- /dev/null
+++ b/internal/adapter/out/ffmpeg/ffmpeg_test.go
@@ -0,0 +1,425 @@
+package ffmpeg
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "log/slog"
+ "math"
+ "net"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "slices"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/app/port/porttest"
+)
+
+const markup = ""
+
+var discard = slog.New(slog.DiscardHandler)
+
+// 输入用 lavfi 现场生成,只用 ffmpeg 内置的编码器。带视频的输入把视频放在前面,让音轨不是第 0 条流。
+var (
+ sine = []string{"-f", "lavfi", "-i", "sine=frequency=440:duration=2"}
+ video = []string{"-f", "lavfi", "-i", "testsrc=size=160x120:rate=10:duration=2"}
+)
+
+func generate(t *testing.T, name string, args ...string) string {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), name)
+ argv := slices.Concat([]string{"-nostdin", "-y", "-loglevel", "error"}, args, []string{path})
+ if out, err := exec.CommandContext(t.Context(), "ffmpeg", argv...).CombinedOutput(); err != nil {
+ t.Fatalf("ffmpeg 生成 %s:%v\n%s", name, err, out)
+ }
+ return path
+}
+
+func source(t *testing.T, path string) port.LinkFile {
+ t.Helper()
+ b, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return porttest.LinkFile(b)
+}
+
+func readAll(t *testing.T, f port.LinkFile) []byte {
+ t.Helper()
+ r, err := f.Open()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = r.Close() }()
+ b, err := io.ReadAll(r)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return b
+}
+
+type probed struct {
+ Streams []struct {
+ CodecType string `json:"codec_type"`
+ CodecName string `json:"codec_name"`
+ Channels int `json:"channels"`
+ } `json:"streams"`
+ Chapters []json.RawMessage `json:"chapters"`
+ Format struct {
+ Name string `json:"format_name"`
+ Duration string `json:"duration"`
+ Tags map[string]string `json:"tags"`
+ } `json:"format"`
+}
+
+func ffprobe(t *testing.T, path string) probed {
+ t.Helper()
+ out, err := exec.CommandContext(t.Context(), "ffprobe", "-v", "error", "-print_format", "json", "-show_format", "-show_streams", "-show_chapters", path).Output()
+ if err != nil {
+ t.Fatalf("ffprobe %s:%v", filepath.Base(path), err)
+ }
+ var p probed
+ if err := json.Unmarshal(out, &p); err != nil {
+ t.Fatal(err)
+ }
+ return p
+}
+
+func (p probed) seconds(t *testing.T) float64 {
+ t.Helper()
+ d, err := strconv.ParseFloat(p.Format.Duration, 64)
+ if err != nil {
+ t.Fatalf("ffprobe 给出的时长 %q:%v", p.Format.Duration, err)
+ }
+ return d
+}
+
+func (p probed) title() (string, bool) {
+ for k, v := range p.Format.Tags {
+ if strings.EqualFold(k, "title") {
+ return v, true
+ }
+ }
+ return "", false
+}
+
+// 转码成功,返回输出的 ffprobe 结果。
+func transcode(t *testing.T, input string) probed {
+ t.Helper()
+ mp3, release, err := New(t.TempDir(), discard).ToMP3(t.Context(), source(t, input))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer release()
+ output := filepath.Join(t.TempDir(), "audio.mp3")
+ if err := os.WriteFile(output, readAll(t, mp3), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return ffprobe(t, output)
+}
+
+func requireEmpty(t *testing.T, dir string) {
+ t.Helper()
+ left, err := os.ReadDir(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(left) != 0 {
+ t.Errorf("%s 中还有 %v", dir, left)
+ }
+}
+
+type record struct {
+ Level string `json:"level"`
+ Stderr string `json:"stderr"`
+}
+
+func recorder() (*slog.Logger, func(t *testing.T) []record) {
+ var buf bytes.Buffer
+ return slog.New(slog.NewJSONHandler(&buf, nil)), func(t *testing.T) []record {
+ t.Helper()
+ var records []record
+ dec := json.NewDecoder(bytes.NewReader(buf.Bytes()))
+ for dec.More() {
+ var r record
+ if err := dec.Decode(&r); err != nil {
+ t.Fatal(err)
+ }
+ records = append(records, r)
+ }
+ return records
+ }
+}
+
+func TestToMP3(t *testing.T) {
+ title := "title=" + markup
+ tests := []struct {
+ name string
+ file string
+ args []string
+ }{
+ {"分片 MP4 的 AAC", "audio.m4s", slices.Concat(sine, []string{"-c:a", "aac", "-movflags", "+frag_keyframe+empty_moov+default_base_moof", "-metadata", title, "-f", "mp4"})},
+ {"带视频的 FLV", "video.flv", slices.Concat(video, sine, []string{"-c:v", "flv", "-c:a", "aac", "-metadata", title})},
+ {"moov 在末尾的 MP4", "video.mp4", slices.Concat(video, sine, []string{"-c:v", "mpeg4", "-c:a", "aac", "-metadata", title})},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ input := generate(t, tt.file, tt.args...)
+ in := ffprobe(t, input)
+ if got, ok := in.title(); !ok || got != markup {
+ t.Fatalf("测试用的输入没有被 ffprobe 读出标题:%q, %v", got, ok)
+ }
+
+ out := transcode(t, input)
+ if len(out.Streams) != 1 || out.Streams[0].CodecType != "audio" || out.Streams[0].CodecName != "mp3" || out.Format.Name != "mp3" {
+ t.Errorf("输出 = %+v,期望只有一条 mp3 音轨", out)
+ }
+ if diff := math.Abs(out.seconds(t) - in.seconds(t)); diff >= 0.2 {
+ t.Errorf("输出时长 %v 秒,输入时长 %v 秒", out.Format.Duration, in.Format.Duration)
+ }
+ if got, ok := out.title(); ok {
+ t.Errorf("输出还带着来源的标题 %q", got)
+ }
+ })
+ }
+}
+
+// 第二条音轨是声道更多的默认音轨:不指定映射时 ffmpeg 会选它。
+func TestToMP3UsesFirstAudioTrack(t *testing.T) {
+ input := generate(t, "two.mp4", slices.Concat(sine, []string{
+ "-f", "lavfi", "-i", "sine=frequency=880:duration=2", "-map", "0:a", "-map", "1:a", "-c:a", "aac",
+ "-ac:a:1", "2", "-disposition:a:0", "0", "-disposition:a:1", "default",
+ })...)
+ if out := transcode(t, input); len(out.Streams) != 1 || out.Streams[0].Channels != 1 {
+ t.Errorf("输出 = %+v,期望只有第一条(单声道)音轨", out.Streams)
+ }
+}
+
+func TestToMP3DropsSourceChapters(t *testing.T) {
+ chapters := filepath.Join(t.TempDir(), "chapters.txt")
+ if err := os.WriteFile(chapters, []byte(";FFMETADATA1\n[CHAPTER]\nTIMEBASE=1/1000\nSTART=0\nEND=2000\ntitle="+markup+"\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ input := generate(t, "chapters.mp4", slices.Concat(sine, []string{"-f", "ffmetadata", "-i", chapters, "-map", "0:a", "-map_chapters", "1", "-c:a", "aac"})...)
+ if n := len(ffprobe(t, input).Chapters); n != 1 {
+ t.Fatalf("测试用的输入有 %d 个章节,期望 1 个", n)
+ }
+ if out := transcode(t, input); len(out.Chapters) != 0 {
+ t.Errorf("输出还带着来源的章节 %s", out.Chapters)
+ }
+}
+
+// 与 yt-dlp 的默认质量一样用 VBR:ffmpeg 给码率变化的 mp3 写 Xing 头,固定码率时写 Info 头。
+func TestToMP3EncodesVBR(t *testing.T) {
+ input := generate(t, "audio.m4a", slices.Concat(sine, []string{"-c:a", "aac"})...)
+ mp3, release, err := New(t.TempDir(), discard).ToMP3(t.Context(), source(t, input))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer release()
+ head := readAll(t, mp3)
+ if !bytes.Contains(head[:min(len(head), 4096)], []byte("Xing")) {
+ t.Error("输出的第一帧没有 Xing 头,不是 VBR 编码")
+ }
+}
+
+func TestToMP3FailsWithoutAudio(t *testing.T) {
+ input := generate(t, "silent.mp4", slices.Concat(video, []string{"-c:v", "mpeg4"})...)
+ root := t.TempDir()
+ logger, records := recorder()
+ _, release, err := New(root, logger).ToMP3(t.Context(), source(t, input))
+ if err == nil {
+ release()
+ t.Fatal("没有音轨的输入转码成功了")
+ }
+ var rejected *port.RejectedError
+ if errors.As(err, &rejected) {
+ t.Errorf("转码失败 = %v,期望是普通错误,让准备流程重试", err)
+ }
+ requireEmpty(t, root)
+ if got := records(t); len(got) != 1 || got[0].Level != "WARN" || got[0].Stderr == "" {
+ t.Errorf("日志 = %+v,期望一条带 stderr 的 WARN", got)
+ }
+}
+
+// 握手完成的连接留在 accept 队列里,不必先 Accept 也不会漏掉。
+func listenLocal(t *testing.T) (address string, connected func() bool) {
+ t.Helper()
+ l, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = l.Close() })
+ tcp := l.(*net.TCPListener)
+ return l.Addr().String(), func() bool {
+ _ = tcp.SetDeadline(time.Now().Add(100 * time.Millisecond))
+ conn, err := tcp.Accept()
+ if err != nil {
+ return false
+ }
+ _ = conn.Close()
+ return true
+ }
+}
+
+// 输入复制成了不带扩展名的文件,ffmpeg 不会把它当 HLS 播放列表,所以 HLS 一行走不到打开分片的一步;DASH 清单按内容识别,会走到那一步,被白名单拒绝。
+// ffmpeg 读本地文件时默认的白名单是 file,crypto,data:stderr 里写着 'file',说明命令行上的 -protocol_whitelist file 起了作用。
+func TestToMP3DoesNotUseNetwork(t *testing.T) {
+ tests := []struct {
+ name string
+ manifest string
+ wantStderr string
+ }{
+ {"HLS 播放列表", "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:2\n#EXT-X-MEDIA-SEQUENCE:0\n#EXTINF:2.0,\nhttp://%s/seg0.ts\n#EXT-X-ENDLIST\n", ""},
+ {"DASH 清单", `
+
+
+http://%s/audio.m4s
+
+
+`, "not on whitelist 'file'!"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ address, connected := listenLocal(t)
+ root := t.TempDir()
+ logger, records := recorder()
+ // 地址如果被访问,ffmpeg 会一直等响应:限定期限,免得挂住测试。
+ ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
+ defer cancel()
+ _, release, err := New(root, logger).ToMP3(ctx, porttest.LinkFile(fmt.Sprintf(tt.manifest, address)))
+ if err == nil {
+ release()
+ t.Fatal("转码成功了,期望输入不是可以转码的音频")
+ }
+ if connected() {
+ t.Error("ffmpeg 连接了输入中引用的地址")
+ }
+ requireEmpty(t, root)
+ if got := records(t); len(got) != 1 || !strings.Contains(got[0].Stderr, tt.wantStderr) {
+ t.Errorf("日志 = %+v,期望 stderr 中有 %q", got, tt.wantStderr)
+ }
+ })
+ }
+}
+
+func TestToMP3StopsFFmpegWhenContextEnds(t *testing.T) {
+ // 10 分钟的静音 AAC 生成得很快,转成 mp3 花的时间却远远超过期限。
+ input := source(t, generate(t, "long.mp4", "-f", "lavfi", "-i", "anullsrc", "-t", "600", "-c:a", "aac", "-b:a", "32k"))
+ tests := []struct {
+ name string
+ newCtx func(parent context.Context) (context.Context, context.CancelFunc)
+ want error
+ logged int
+ }{
+ {"超时", func(parent context.Context) (context.Context, context.CancelFunc) {
+ return context.WithTimeout(parent, 200*time.Millisecond)
+ }, context.DeadlineExceeded, 1},
+ {"取消", func(parent context.Context) (context.Context, context.CancelFunc) {
+ ctx, cancel := context.WithCancel(parent)
+ time.AfterFunc(200*time.Millisecond, cancel)
+ return ctx, cancel
+ }, context.Canceled, 0},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ root := t.TempDir()
+ logger, records := recorder()
+ ctx, cancel := tt.newCtx(t.Context())
+ defer cancel()
+ _, release, err := New(root, logger).ToMP3(ctx, input)
+ if !errors.Is(err, tt.want) {
+ if err == nil {
+ release()
+ }
+ t.Errorf("转码 = %v,期望 %v", err, tt.want)
+ }
+ requireEmpty(t, root)
+ if got := records(t); len(got) != tt.logged {
+ t.Errorf("日志 = %+v,期望 %d 条", got, tt.logged)
+ }
+ })
+ }
+}
+
+func TestReleaseDeletesTempDir(t *testing.T) {
+ input := generate(t, "audio.m4s", slices.Concat(sine, []string{"-c:a", "aac", "-f", "mp4"})...)
+ root := t.TempDir()
+ mp3, release, err := New(root, discard).ToMP3(t.Context(), source(t, input))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer release()
+
+ dirs, err := os.ReadDir(root)
+ if err != nil || len(dirs) != 1 || !dirs[0].IsDir() {
+ t.Fatalf("%s 中有 %v(%v),期望只有这次转码的目录", root, dirs, err)
+ }
+ dir := filepath.Join(root, dirs[0].Name())
+ if files, err := os.ReadDir(dir); err != nil || len(files) != 1 || files[0].Name() != "audio.mp3" {
+ t.Errorf("转码之后 %s 中有 %v(%v),期望只剩 audio.mp3", dir, files, err)
+ }
+
+ // 分析和上传同时打开同一个文件。
+ var readers [2]io.ReadSeekCloser
+ for i := range readers {
+ if readers[i], err = mp3.Open(); err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = readers[i].Close() }()
+ }
+ var contents [2][]byte
+ var errs [2]error
+ var wg sync.WaitGroup
+ for i := range readers {
+ wg.Go(func() { contents[i], errs[i] = io.ReadAll(readers[i]) })
+ }
+ wg.Wait()
+ if err := errors.Join(errs[:]...); err != nil {
+ t.Fatal(err)
+ }
+ if len(contents[0]) == 0 || !bytes.Equal(contents[0], contents[1]) {
+ t.Errorf("同时打开两次读到 %d 和 %d 字节,期望相同且不为空", len(contents[0]), len(contents[1]))
+ }
+ if again := readAll(t, mp3); !bytes.Equal(again, contents[0]) {
+ t.Errorf("再次打开读到 %d 字节,期望与第一次相同的 %d 字节", len(again), len(contents[0]))
+ }
+
+ release()
+ if _, err := os.Stat(dir); !errors.Is(err, fs.ErrNotExist) {
+ t.Errorf("release 之后 %s 的状态 = %v,期望不存在", dir, err)
+ }
+ requireEmpty(t, root)
+}
+
+func TestTailKeepsLastBytes(t *testing.T) {
+ tests := []struct {
+ writes []string
+ want string
+ }{
+ {[]string{"ab", "cd"}, "abcd"},
+ {[]string{"ab", "cdefg", "h"}, "defgh"},
+ {[]string{"abcdefgh"}, "defgh"},
+ }
+ for _, tt := range tests {
+ tl := &tail{limit: 5}
+ for _, w := range tt.writes {
+ if n, err := tl.Write([]byte(w)); n != len(w) || err != nil {
+ t.Fatalf("写入 %q = %d, %v", w, n, err)
+ }
+ }
+ if string(tl.buf) != tt.want {
+ t.Errorf("依次写入 %q 之后保留 %q,期望 %q", tt.writes, tl.buf, tt.want)
+ }
+ }
+}
diff --git a/internal/adapter/out/ffmpeg/main_test.go b/internal/adapter/out/ffmpeg/main_test.go
new file mode 100644
index 0000000..8fff267
--- /dev/null
+++ b/internal/adapter/out/ffmpeg/main_test.go
@@ -0,0 +1,22 @@
+package ffmpeg
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "testing"
+)
+
+func TestMain(m *testing.M) {
+ os.Exit(runTests(m))
+}
+
+func runTests(m *testing.M) int {
+ for _, tool := range []string{"ffmpeg", "ffprobe"} {
+ if _, err := exec.LookPath(tool); err != nil {
+ fmt.Fprintf(os.Stderr, "测试需要 PATH 中有 %s:%v\n", tool, err)
+ return 1
+ }
+ }
+ return m.Run()
+}
diff --git a/internal/adapter/out/linkcookies/linkcookies_test.go b/internal/adapter/out/linkcookies/linkcookies_test.go
new file mode 100644
index 0000000..5b3eb18
--- /dev/null
+++ b/internal/adapter/out/linkcookies/linkcookies_test.go
@@ -0,0 +1,180 @@
+package linkcookies
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+const savedCookies = "# Netscape HTTP Cookie File\n.youtube.com\tTRUE\t/\tTRUE\t0\tVISITOR_INFO1_LIVE\tfake-value\n"
+
+// YouTube 链接不读取数据库就直接跳过。
+func TestCookieFileSkipsNonYouTubeLinks(t *testing.T) {
+ manager := New(t.TempDir(), func(context.Context) (string, error) {
+ t.Error("非 YouTube 链接不应读取 cookies")
+ return "", nil
+ })
+ for _, link := range []string{
+ "https://www.bilibili.com/video/BV1xx411c7mD",
+ "https://example.com/watch?v=dQw4w9WgXcQ",
+ "https://youtube.com.evil.example/watch?v=dQw4w9WgXcQ",
+ "https://user@example.com/youtube",
+ "/local/path",
+ "",
+ } {
+ path, release, ok, err := manager.CookieFile(context.Background(), link)
+ if ok || err != nil || path != "" || release != nil {
+ t.Fatalf("%s 应跳过,得到 path=%q release=%v ok=%v err=%v", link, path, release != nil, ok, err)
+ }
+ }
+}
+
+func TestCookieFileRecognizesYouTubeHosts(t *testing.T) {
+ root := t.TempDir()
+ manager := New(root, func(context.Context) (string, error) { return savedCookies, nil })
+ for _, link := range []string{
+ "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
+ "https://music.youtube.com/watch?v=dQw4w9WgXcQ",
+ "https://m.youtube.com/watch?v=dQw4w9WgXcQ",
+ "https://YouTube.com/watch?v=dQw4w9WgXcQ",
+ "https://youtu.be/dQw4w9WgXcQ",
+ "youtu.be/dQw4w9WgXcQ",
+ } {
+ path, release, ok, err := manager.CookieFile(context.Background(), link)
+ if !ok || err != nil {
+ t.Fatalf("%s 应识别为 YouTube 链接,得到 ok=%v err=%v", link, ok, err)
+ }
+ if !strings.HasPrefix(path, root) {
+ t.Fatalf("cookies 文件应创建在工作目录下:%s", path)
+ }
+ release()
+ }
+}
+
+// 未填写 cookies 时按无 cookies 下载,而不是失败。
+func TestCookieFileWithoutSavedCookies(t *testing.T) {
+ root := t.TempDir()
+ for _, cookies := range []string{"", " \n\t"} {
+ manager := New(root, func(context.Context) (string, error) { return cookies, nil })
+ path, release, ok, err := manager.CookieFile(context.Background(), "https://www.youtube.com/watch?v=dQw4w9WgXcQ")
+ if ok || err != nil || path != "" || release != nil {
+ t.Fatalf("cookies=%q 应跳过,得到 path=%q release=%v ok=%v err=%v", cookies, path, release != nil, ok, err)
+ }
+ }
+ entries, err := os.ReadDir(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(entries) != 0 {
+ t.Fatalf("跳过时不应创建文件:%v", entries)
+ }
+}
+
+func TestCookieFileCreatesFile(t *testing.T) {
+ root := t.TempDir()
+ manager := New(root, func(context.Context) (string, error) { return savedCookies, nil })
+ path, release, ok, err := manager.CookieFile(context.Background(), "https://www.youtube.com/watch?v=dQw4w9WgXcQ")
+ if err != nil || !ok {
+ t.Fatalf("ok=%v err=%v", ok, err)
+ }
+ defer release()
+
+ content, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(content) != savedCookies {
+ t.Fatalf("cookies 文件内容应与保存的内容一致,得到 %q", content)
+ }
+ dirInfo, err := os.Stat(filepath.Dir(path))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if dirInfo.Mode().Perm() != 0o700 {
+ t.Fatalf("临时目录权限应为 0700,得到 %#o", dirInfo.Mode().Perm())
+ }
+ fileInfo, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fileInfo.Mode().Perm() != 0o600 {
+ t.Fatalf("cookies 文件权限应为 0600,得到 %#o", fileInfo.Mode().Perm())
+ }
+}
+
+// release 幂等,且 yt-dlp 回写的内容也一并删除。
+func TestReleaseRemovesRewrittenFile(t *testing.T) {
+ root := t.TempDir()
+ manager := New(root, func(context.Context) (string, error) { return savedCookies, nil })
+ path, release, ok, err := manager.CookieFile(context.Background(), "https://www.youtube.com/watch?v=dQw4w9WgXcQ")
+ if err != nil || !ok {
+ t.Fatalf("ok=%v err=%v", ok, err)
+ }
+ if err := os.WriteFile(path, []byte("# yt-dlp 回写的新 cookies\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ release()
+ release()
+ if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("release 后 cookies 文件应被删除,err=%v", err)
+ }
+ if _, err := os.Stat(filepath.Dir(path)); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("release 后临时目录应被删除,err=%v", err)
+ }
+ entries, err := os.ReadDir(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(entries) != 0 {
+ t.Fatalf("release 后工作目录应为空:%v", entries)
+ }
+}
+
+// 读取失败时返回错误,错误信息不含 cookies 内容,也不留下临时文件。
+func TestCookieFileLoadFailure(t *testing.T) {
+ root := t.TempDir()
+ manager := New(root, func(context.Context) (string, error) { return "", errors.New("数据库连接失败") })
+ path, release, ok, err := manager.CookieFile(context.Background(), "https://www.youtube.com/watch?v=dQw4w9WgXcQ")
+ if ok || err == nil || path != "" || release != nil {
+ t.Fatalf("应返回错误,得到 path=%q release=%v ok=%v err=%v", path, release != nil, ok, err)
+ }
+ if strings.Contains(err.Error(), "VISITOR_INFO1_LIVE") || strings.Contains(err.Error(), "fake-value") {
+ t.Fatalf("错误信息不应包含 cookies 内容:%s", err)
+ }
+ entries, readErr := os.ReadDir(root)
+ if readErr != nil {
+ t.Fatal(readErr)
+ }
+ if len(entries) != 0 {
+ t.Fatalf("失败时不应留下临时文件:%v", entries)
+ }
+}
+
+// 工作根目录不存在时返回错误而不是 panic。
+func TestCookieFileMissingRoot(t *testing.T) {
+ manager := New(filepath.Join(t.TempDir(), "missing"), func(context.Context) (string, error) { return savedCookies, nil })
+ _, _, ok, err := manager.CookieFile(context.Background(), "https://youtu.be/dQw4w9WgXcQ")
+ if ok || err == nil {
+ t.Fatalf("根目录不存在应返回错误,得到 ok=%v err=%v", ok, err)
+ }
+}
+
+// 写入失败时清理已创建的临时目录。
+func TestCookieFileCleansUpAfterWriteFailure(t *testing.T) {
+ if os.Geteuid() == 0 {
+ t.Skip("root 不受权限位约束")
+ }
+ root := t.TempDir()
+ fileRoot := filepath.Join(root, "file")
+ if err := os.WriteFile(fileRoot, nil, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ manager := New(fileRoot, func(context.Context) (string, error) { return savedCookies, nil })
+ _, _, ok, err := manager.CookieFile(context.Background(), "https://youtu.be/dQw4w9WgXcQ")
+ if ok || err == nil {
+ t.Fatalf("根目录是文件应返回错误,得到 ok=%v err=%v", ok, err)
+ }
+}
diff --git a/internal/adapter/out/netease/account_test.go b/internal/adapter/out/netease/account_test.go
new file mode 100644
index 0000000..47c5765
--- /dev/null
+++ b/internal/adapter/out/netease/account_test.go
@@ -0,0 +1,82 @@
+package netease
+
+import (
+ "net/http"
+ "reflect"
+ "testing"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+)
+
+func TestCurrentAccount(t *testing.T) {
+ tests := []struct {
+ name string
+ body string
+ id string
+ nickname string
+ vip bool
+ empty bool
+ wantErr bool
+ }{
+ {
+ name: "会员账号",
+ body: `{"code":200,"profile":{"userId":9007199254740993,"nickname":"测试账号","vipType":1}}`,
+ id: "9007199254740993",
+ nickname: "测试账号",
+ vip: true,
+ },
+ {
+ name: "非会员且没有昵称",
+ body: `{"code":200,"profile":{"userId":42,"nickname":"","vipType":0}}`,
+ id: "42",
+ },
+ {
+ name: "成功但没有账号资料",
+ body: `{"code":200,"profile":{}}`,
+ empty: true,
+ },
+ {
+ name: "业务错误",
+ body: `{"code":301,"profile":{}}`,
+ wantErr: true,
+ },
+ {
+ name: "响应不是 JSON",
+ body: `not-json`,
+ wantErr: true,
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ client, _ := serve(t, map[string]http.HandlerFunc{
+ "/weapi/nuser/account/get": func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = w.Write([]byte(tc.body))
+ },
+ })
+ got, err := client.CurrentAccount(t.Context())
+ if tc.wantErr {
+ if err == nil {
+ t.Fatal("账号查询错误 = nil,期望失败")
+ }
+ return
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ if tc.empty {
+ if got != nil {
+ t.Fatalf("没有账号资料时 = %+v,期望 nil", got)
+ }
+ return
+ }
+ var name *string
+ if tc.nickname != "" {
+ name = &tc.nickname
+ }
+ want := &port.SourceAccount{ID: tc.id, Name: name, VIP: &tc.vip, Verified: true}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("账号资料 = %+v,期望 %+v", got, want)
+ }
+ })
+ }
+}
diff --git a/internal/adapter/out/netease/crypto_test.go b/internal/adapter/out/netease/crypto_test.go
new file mode 100644
index 0000000..a4a871a
--- /dev/null
+++ b/internal/adapter/out/netease/crypto_test.go
@@ -0,0 +1,57 @@
+package netease
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/md5"
+ "encoding/base64"
+ "encoding/hex"
+ "regexp"
+ "testing"
+)
+
+func decryptCBC(t *testing.T, text, key string) string {
+ t.Helper()
+ data, err := base64.StdEncoding.DecodeString(text)
+ if err != nil {
+ t.Fatal(err)
+ }
+ block, err := aes.NewCipher([]byte(key))
+ if err != nil {
+ t.Fatal(err)
+ }
+ out := make([]byte, len(data))
+ cipher.NewCBCDecrypter(block, []byte(weapiIV)).CryptBlocks(out, data)
+ return string(out[:len(out)-int(out[len(out)-1])])
+}
+
+func TestWeapiParams(t *testing.T) {
+ params, encSecKey := weapiParams([]byte(`{"id":"186016"}`), "abcdefghijklmnop")
+ if got := decryptCBC(t, decryptCBC(t, params, "abcdefghijklmnop"), weapiNonce); got != `{"id":"186016"}` {
+ t.Errorf("两层解密之后 = %q", got)
+ }
+ if !regexp.MustCompile(`^[0-9a-f]{256}$`).MatchString(encSecKey) {
+ t.Errorf("encSecKey = %q,期望 256 位十六进制", encSecKey)
+ }
+ if key := newSecretKey(); !regexp.MustCompile(`^[0-9A-Za-z]{16}$`).MatchString(key) {
+ t.Errorf("随机密钥 = %q", key)
+ }
+}
+
+func TestEapiParams(t *testing.T) {
+ plain := decryptECB(t, eapiParams("/eapi/song/enhance/player/url/v1", []byte(`{"ids":[1]}`)), eapiKey)
+ digest := md5.Sum([]byte(`nobody/api/song/enhance/player/url/v1use{"ids":[1]}md5forencrypt`))
+ if want := `/api/song/enhance/player/url/v1-36cd479b6b5-{"ids":[1]}-36cd479b6b5-` + hex.EncodeToString(digest[:]); plain != want {
+ t.Errorf("eapi 参数解密之后 = %q\n期望 %q", plain, want)
+ }
+}
+
+func TestLinuxParams(t *testing.T) {
+ params := linuxParams([]byte(`{"method":"POST"}`))
+ if !regexp.MustCompile(`^[0-9A-F]+$`).MatchString(params) {
+ t.Errorf("linuxapi 参数 = %q,期望大写十六进制", params)
+ }
+ if got := decryptECB(t, params, linuxKey); got != `{"method":"POST"}` {
+ t.Errorf("linuxapi 参数解密之后 = %q", got)
+ }
+}
diff --git a/internal/adapter/out/netease/login_test.go b/internal/adapter/out/netease/login_test.go
new file mode 100644
index 0000000..a7243bc
--- /dev/null
+++ b/internal/adapter/out/netease/login_test.go
@@ -0,0 +1,200 @@
+package netease
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+)
+
+func serveLogin(t *testing.T, routes map[string]http.HandlerFunc, saved *string) (*Client, *observed) {
+ t.Helper()
+ return serveLoginWithStore(t, routes, func(_ context.Context, value string) error {
+ *saved = value
+ return nil
+ })
+}
+
+func serveLoginWithStore(t *testing.T, routes map[string]http.HandlerFunc, store func(context.Context, string) error) (*Client, *observed) {
+ t.Helper()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ checkClientIP(t, r)
+ handle, ok := routes[r.URL.Path]
+ if !ok {
+ t.Errorf("意外的请求 %s %s", r.Method, r.URL)
+ http.NotFound(w, r)
+ return
+ }
+ handle(w, r)
+ }))
+ t.Cleanup(server.Close)
+ target, err := url.Parse(server.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ obs := &observed{}
+ load := func(context.Context) (string, error) { return "MUSIC_U=loaded", nil }
+ return New(&http.Client{Transport: redirect{target}}, load, store, obs.record), obs
+}
+
+func TestCreateQRLogin(t *testing.T) {
+ c, obs := serveLogin(t, map[string]http.HandlerFunc{
+ "/api/login/qrcode/unikey": func(w http.ResponseWriter, r *http.Request) {
+ wantUA := "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Safari/537.36 Chrome/91.0.4472.164 NeteaseMusicDesktop/3.0.18.203152"
+ if got := r.Header.Get("User-Agent"); got != wantUA {
+ t.Errorf("扫码登录的 User-Agent = %q,期望 %q", got, wantUA)
+ }
+ if r.Method != http.MethodPost || r.FormValue("type") != "3" {
+ t.Errorf("创建登录的请求 = %s, type = %q", r.Method, r.FormValue("type"))
+ }
+ body(`{"code":200,"unikey":"test-key"}`)(w, r)
+ },
+ }, new(string))
+ before := time.Now().Add(5 * time.Minute)
+ got, err := c.CreateQRLogin(t.Context())
+ after := time.Now().Add(5 * time.Minute)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Key != "test-key" || got.LoginURL != "https://music.163.com/login?codekey=test-key" || got.QRImage != "" {
+ t.Errorf("登录会话 = %#v", got)
+ }
+ if got.ExpiresAt.Before(before) || got.ExpiresAt.After(after) {
+ t.Errorf("登录到期时间 = %v,期望约为 5 分钟后", got.ExpiresAt)
+ }
+ obs.check(t, "login_create")
+}
+
+func TestCreateQRLoginRejectsInvalidResponse(t *testing.T) {
+ for _, response := range []string{
+ `{"code":500,"unikey":"test-key"}`,
+ `{"code":200}`,
+ } {
+ c, obs := serveLogin(t, map[string]http.HandlerFunc{
+ "/api/login/qrcode/unikey": body(response),
+ }, new(string))
+ if got, err := c.CreateQRLogin(t.Context()); err == nil {
+ t.Errorf("响应 %s 的登录会话 = %#v,期望错误", response, got)
+ }
+ obs.check(t, "login_create 失败")
+ }
+}
+
+func TestQRLoginStatuses(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ response string
+ want port.QRLoginResult
+ }{
+ {"过期", `{"code":800,"message":"二维码已过期","cookie":"MUSIC_U=do-not-save"}`, port.QRLoginResult{Status: port.QRLoginExpired, Message: "二维码已过期"}},
+ {"等待扫码", `{"code":801,"message":"等待扫码","cookie":"MUSIC_U=do-not-save"}`, port.QRLoginResult{Status: port.QRLoginWaiting, Message: "等待扫码"}},
+ {"已扫码", `{"code":802,"message":"已扫码","cookie":"MUSIC_U=do-not-save"}`, port.QRLoginResult{Status: port.QRLoginScanned, Message: "已扫码"}},
+ {"其他状态", `{"code":500,"message":"登录失败","cookie":"MUSIC_U=do-not-save"}`, port.QRLoginResult{Status: port.QRLoginFailed, Message: "登录失败"}},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ saved := "未保存"
+ c, obs := serveLogin(t, map[string]http.HandlerFunc{
+ "/api/login/qrcode/client/login": func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || r.FormValue("key") != "test-key" || r.FormValue("type") != "3" {
+ t.Errorf("轮询登录的请求 = %s, key = %q, type = %q", r.Method, r.FormValue("key"), r.FormValue("type"))
+ }
+ w.Header().Add("Set-Cookie", "MUSIC_U=do-not-save; Path=/")
+ body(tt.response)(w, r)
+ },
+ }, &saved)
+ got, err := c.CheckQRLogin(t.Context(), "test-key")
+ if err != nil || got != tt.want {
+ t.Errorf("登录结果 = %#v, %v,期望 %#v", got, err, tt.want)
+ }
+ if saved != "未保存" {
+ t.Errorf("非成功状态保存了 Cookie %q", saved)
+ }
+ obs.check(t, "login_check")
+ })
+ }
+}
+
+func TestCheckQRLoginSavesCookie(t *testing.T) {
+ var saved string
+ c, obs := serveLogin(t, map[string]http.HandlerFunc{
+ "/api/login/qrcode/client/login": func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Add("Set-Cookie", "MUSIC_U=header; Path=/")
+ body(`{"code":803,"message":"授权成功","cookie":"MUSIC_U=test"}`)(w, r)
+ },
+ }, &saved)
+ got, err := c.CheckQRLogin(t.Context(), "test-key")
+ want := port.QRLoginResult{Status: port.QRLoginSuccess, Message: "授权成功"}
+ if err != nil || got != want || saved != "MUSIC_U=test" {
+ t.Fatalf("登录结果 = %#v, %v,保存的 Cookie = %q", got, err, saved)
+ }
+ if cached, err := c.cookie.get(t.Context()); err != nil || cached != "MUSIC_U=test" {
+ t.Errorf("本地缓存的 Cookie = %q, %v", cached, err)
+ }
+ obs.check(t, "login_check")
+}
+
+func TestCheckQRLoginUsesSortedResponseCookies(t *testing.T) {
+ var saved string
+ c, _ := serveLogin(t, map[string]http.HandlerFunc{
+ "/api/login/qrcode/client/login": func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Add("Set-Cookie", "z_cookie=last; Path=/")
+ w.Header().Add("Set-Cookie", "MUSIC_U=test; Path=/")
+ w.Header().Add("Set-Cookie", "a_cookie=first; Path=/")
+ body(`{"code":803,"message":"授权成功"}`)(w, r)
+ },
+ }, &saved)
+ if _, err := c.CheckQRLogin(t.Context(), "test-key"); err != nil {
+ t.Fatal(err)
+ }
+ if want := "MUSIC_U=test; a_cookie=first; z_cookie=last"; saved != want {
+ t.Errorf("保存的 Cookie = %q,期望 %q", saved, want)
+ }
+}
+
+func TestCheckQRLoginRequiresCookie(t *testing.T) {
+ saved := "未保存"
+ c, obs := serveLogin(t, map[string]http.HandlerFunc{
+ "/api/login/qrcode/client/login": body(`{"code":803,"message":"授权成功"}`),
+ }, &saved)
+ if got, err := c.CheckQRLogin(t.Context(), "test-key"); err == nil {
+ t.Errorf("没有 Cookie 的登录结果 = %#v,期望错误", got)
+ }
+ if saved != "未保存" {
+ t.Errorf("没有 Cookie 时保存了 %q", saved)
+ }
+ obs.check(t, "login_check 失败")
+}
+
+func TestCheckQRLoginDoesNotCacheFailedSave(t *testing.T) {
+ saveErr := errors.New("保存测试 Cookie 失败")
+ c, obs := serveLoginWithStore(t, map[string]http.HandlerFunc{
+ "/api/login/qrcode/client/login": body(`{"code":803,"message":"授权成功","cookie":"MUSIC_U=test"}`),
+ }, func(context.Context, string) error { return saveErr })
+ if _, err := c.cookie.get(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := c.CheckQRLogin(t.Context(), "test-key"); !errors.Is(err, saveErr) || got.Status == port.QRLoginSuccess {
+ t.Errorf("保存失败的登录结果 = %#v, %v", got, err)
+ }
+ if cached, err := c.cookie.get(t.Context()); err != nil || cached != "MUSIC_U=loaded" {
+ t.Errorf("保存失败后的本地 Cookie = %q, %v", cached, err)
+ }
+ obs.check(t, "login_check 失败")
+}
+
+func TestCheckQRLoginRejectsEmptyKey(t *testing.T) {
+ saved := "未保存"
+ c, obs := serveLogin(t, nil, &saved)
+ if got, err := c.CheckQRLogin(t.Context(), ""); err == nil {
+ t.Errorf("空 key 的登录结果 = %#v,期望错误", got)
+ }
+ if saved != "未保存" {
+ t.Errorf("空 key 保存了 Cookie %q", saved)
+ }
+ obs.check(t)
+}
diff --git a/internal/adapter/out/netease/netease_test.go b/internal/adapter/out/netease/netease_test.go
new file mode 100644
index 0000000..0323610
--- /dev/null
+++ b/internal/adapter/out/netease/netease_test.go
@@ -0,0 +1,629 @@
+package netease
+
+import (
+ "context"
+ "crypto/aes"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "net/netip"
+ "net/url"
+ "os"
+ "path/filepath"
+ "reflect"
+ "slices"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "testing/synctest"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/app/prepare"
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+// 测试服务器按路径返回 testdata 中录制的网易云响应,Client 的所有请求都转到它。
+func serve(t *testing.T, routes map[string]http.HandlerFunc) (*Client, *observed) {
+ t.Helper()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ checkClientIP(t, r)
+ handle, ok := routes[r.URL.Path]
+ if !ok {
+ t.Errorf("意外的请求 %s %s", r.Method, r.URL)
+ http.NotFound(w, r)
+ return
+ }
+ handle(w, r)
+ }))
+ t.Cleanup(server.Close)
+ target, err := url.Parse(server.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ obs := &observed{}
+ cookie := func(context.Context) (string, error) { return "MUSIC_U=secret", nil }
+ return New(&http.Client{Transport: redirect{target}}, cookie, func(context.Context, string) error { return nil }, obs.record), obs
+}
+
+type redirect struct{ target *url.URL }
+
+func (r redirect) RoundTrip(req *http.Request) (*http.Response, error) {
+ req = req.Clone(req.Context())
+ req.URL.Scheme, req.URL.Host = r.target.Scheme, r.target.Host
+ return http.DefaultTransport.RoundTrip(req)
+}
+
+// 国内运营商的地址段。
+var chinaPrefixes = [][2]byte{{116, 255}, {116, 228}, {218, 192}, {124, 0}, {14, 132}, {183, 14}, {58, 14}, {113, 116}, {120, 230}}
+
+// 请求接口时带上同一个随机的国内 IP,下载音频时不带。
+func checkClientIP(t *testing.T, r *http.Request) {
+ realIP, forwarded := r.Header.Get("X-Real-IP"), r.Header.Get("X-Forwarded-For")
+ if r.URL.Path == audioPath {
+ if realIP != "" || forwarded != "" {
+ t.Errorf("下载音频时带上了 X-Real-IP %q、X-Forwarded-For %q", realIP, forwarded)
+ }
+ return
+ }
+ addr, err := netip.ParseAddr(realIP)
+ if err != nil || !addr.Is4() || !slices.Contains(chinaPrefixes, [2]byte(addr.AsSlice())) || forwarded != realIP {
+ t.Errorf("%s 的 X-Real-IP 为 %q、X-Forwarded-For 为 %q,期望同一个国内运营商的 IP", r.URL.Path, realIP, forwarded)
+ }
+}
+
+func fixture(t *testing.T, name string) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if got := r.Header.Get("Cookie"); r.Method == http.MethodPost && got != "MUSIC_U=secret" {
+ t.Errorf("%s 的 Cookie = %q", r.URL.Path, got)
+ }
+ b, err := os.ReadFile(filepath.Join("testdata", name))
+ if err != nil {
+ t.Error(err)
+ }
+ _, _ = w.Write(b)
+ }
+}
+
+func status(code int) http.HandlerFunc {
+ return func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(code) }
+}
+
+func body(text string) http.HandlerFunc {
+ return func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, text) }
+}
+
+type observed struct {
+ mu sync.Mutex
+ ops []string
+}
+
+func (o *observed) record(source, op string, _ time.Duration, err error) {
+ o.mu.Lock()
+ defer o.mu.Unlock()
+ if source != "netease" {
+ op = source + " " + op
+ }
+ if err != nil {
+ op += " 失败"
+ }
+ o.ops = append(o.ops, op)
+}
+
+func (o *observed) check(t *testing.T, want ...string) {
+ t.Helper()
+ o.mu.Lock()
+ defer o.mu.Unlock()
+ if !slices.Equal(o.ops, want) {
+ t.Errorf("请求了网易云的接口 %q,期望 %q", o.ops, want)
+ }
+}
+
+func decryptECB(t *testing.T, hexText, key string) string {
+ t.Helper()
+ data, err := hex.DecodeString(hexText)
+ if err != nil {
+ t.Fatal(err)
+ }
+ block, err := aes.NewCipher([]byte(key))
+ if err != nil {
+ t.Fatal(err)
+ }
+ out := make([]byte, len(data))
+ for i := 0; i < len(data); i += aes.BlockSize {
+ block.Decrypt(out[i:i+aes.BlockSize], data[i:i+aes.BlockSize])
+ }
+ return string(out[:len(out)-int(out[len(out)-1])])
+}
+
+// linuxapi 的请求中是转发给搜索接口的参数。
+func searchParams(t *testing.T, r *http.Request) map[string]any {
+ t.Helper()
+ var forward struct {
+ Params map[string]any `json:"params"`
+ }
+ if err := json.Unmarshal([]byte(decryptECB(t, r.FormValue("eparams"), linuxKey)), &forward); err != nil {
+ t.Error(err)
+ }
+ return forward.Params
+}
+
+func wantSong(id string, title, artist, album, cover string, ms int) port.Song {
+ return port.Song{
+ Track: track.Track{Source: track.NetEase, ID: id},
+ Meta: track.Meta{
+ Title: title, Artist: artist, Album: album, CoverURL: cover,
+ PageURL: "https://music.163.com/#/song?id=" + id, Duration: time.Duration(ms) * time.Millisecond,
+ },
+ }
+}
+
+var cover = wantSong("3387653989", "晴天 (Cover 蓝心羽)", "小汤圆", "", "https://p1.music.126.net/pvManIVaLdiDyKMDzuChuA==/109951173298787727.jpg", 176773)
+
+func TestSearchTracks(t *testing.T) {
+ c, obs := serve(t, map[string]http.HandlerFunc{
+ "/api/linux/forward": func(w http.ResponseWriter, r *http.Request) {
+ params := searchParams(t, r)
+ if params["s"] != "晴天" || params["type"] != float64(1) || params["limit"] != float64(100) {
+ t.Errorf("搜索参数 = %v,期望一次取 100 首歌", params)
+ }
+ fixture(t, "search-tracks.json")(w, r)
+ },
+ })
+ want := []port.Song{
+ wantSong("2652820720", "晴天(深情版)", "Lucky小爱", "晴天(深情版)", "http://p1.music.126.net/F0fTkmBTVykCa2o7Vgu1rQ==/109951173569626660.jpg", 278961),
+ wantSong("3387653989", "晴天 (Cover 蓝心羽)", "小汤圆", "", "http://p1.music.126.net/pvManIVaLdiDyKMDzuChuA==/109951173298787727.jpg", 176773),
+ }
+ for range 2 {
+ got, err := c.SearchTracks(t.Context(), "晴天")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("搜索结果 = %#v\n期望 %#v", got, want)
+ }
+ }
+ obs.check(t, "search_tracks")
+}
+
+func TestSearchPlaylists(t *testing.T) {
+ c, obs := serve(t, map[string]http.HandlerFunc{
+ "/api/linux/forward": func(w http.ResponseWriter, r *http.Request) {
+ if params := searchParams(t, r); params["type"] != float64(1000) || params["limit"] != float64(100) {
+ t.Errorf("搜索参数 = %v,期望一次取 100 个歌单", params)
+ }
+ fixture(t, "search-playlists.json")(w, r)
+ },
+ })
+ got, err := c.SearchPlaylists(t.Context(), "晴天")
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []port.Playlist{
+ {ID: "6792103822", Name: "周杰伦-Jay 『网易云精选』", CoverURL: "http://p1.music.126.net/WFQ4EKF5QabD33U3NUOPWQ==/109951169535051638.jpg", Creator: "Buradarrr", Description: "【持续更新】欢迎投稿", TrackCount: 143, PlayCount: 34028960},
+ {ID: "17745261207", Name: "周杰伦歌单", CoverURL: "http://p1.music.126.net/-79-XFhWolhMzGESC8ifkg==/109951170218252280.jpg", Creator: "双鱼不困", TrackCount: 29, PlayCount: 514834},
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("歌单 = %#v\n期望 %#v", got, want)
+ }
+ obs.check(t, "search_playlists")
+}
+
+func TestPlaylistTracks(t *testing.T) {
+ c, obs := serve(t, map[string]http.HandlerFunc{
+ "/weapi/v3/playlist/detail": fixture(t, "playlist-detail.json"),
+ "/weapi/v3/song/detail": fixture(t, "playlist-songs.json"),
+ })
+ got, err := c.PlaylistTracks(t.Context(), "3778678")
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []port.Song{
+ wantSong("287398", "我不难过", "孙燕姿", "未完成", "https://p1.music.126.net/Pzxy6py26NgNmhJjYMf2RQ==/109951169724216323.jpg", 320400),
+ wantSong("1973665667", "海屿你", "马也_Crabbit", "海屿你", "https://p1.music.126.net/RfguLkQJ8aMVkiIaxdy70Q==/109951170483263672.jpg", 295940),
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("歌单内的歌曲 = %#v\n期望 %#v", got, want)
+ }
+ obs.check(t, "playlist_tracks", "track_detail")
+}
+
+func TestTrackMeta(t *testing.T) {
+ c, _ := serve(t, map[string]http.HandlerFunc{"/weapi/v3/song/detail": fixture(t, "song-detail.json")})
+ got, err := c.TrackMeta(t.Context(), "3387653989")
+ if err != nil || got != cover.Meta {
+ t.Errorf("元数据 = %#v, %v,期望 %#v", got, err, cover.Meta)
+ }
+
+ c, _ = serve(t, map[string]http.HandlerFunc{"/weapi/v3/song/detail": fixture(t, "song-detail-missing.json")})
+ var rejected *port.RejectedError
+ if _, err := c.TrackMeta(t.Context(), "1"); !errors.As(err, &rejected) {
+ t.Errorf("不存在的歌的错误 = %v,期望音源拒绝", err)
+ }
+}
+
+// 歌曲的元数据和歌词会写入 PostgreSQL,text 类型不能存储 U+0000。
+func TestTextDropsNUL(t *testing.T) {
+ c, _ := serve(t, map[string]http.HandlerFunc{
+ "/weapi/v3/song/detail": body(`{"songs":[{"name":"晴\u0000天","id":1,"ar":[{"name":"周\u0000杰伦"}],"al":{"name":"叶\u0000惠美","picUrl":"https://p1.music.126.net/a\u0000.jpg"},"dt":1000}],"code":200}`),
+ "/weapi/song/lyric": body(`{"lrc":{"lyric":"[00:01.00]故\u0000事"},"code":200}`),
+ })
+ want := track.Meta{Title: "晴天", Artist: "周杰伦", Album: "叶惠美", CoverURL: "https://p1.music.126.net/a.jpg", PageURL: "https://music.163.com/#/song?id=1", Duration: time.Second}
+ if got, err := c.TrackMeta(t.Context(), "1"); err != nil || got != want {
+ t.Errorf("元数据 = %#v, %v,期望 %#v", got, err, want)
+ }
+ if got, err := c.lyric(t.Context(), "1"); err != nil || got != "[00:01.00]故事" {
+ t.Errorf("歌词 = %q, %v", got, err)
+ }
+}
+
+func TestSimilarTracks(t *testing.T) {
+ c, obs := serve(t, map[string]http.HandlerFunc{"/weapi/discovery/simiSong": fixture(t, "similar.json")})
+ for range 2 {
+ got, err := c.SimilarTracks(t.Context(), "108914")
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []port.Song{
+ wantSong("1842728629", "如果呢", "郑润泽", "如果呢", "https://p2.music.126.net/-xMsNLpquZTmMZlIztTgHg==/109951165953469081.jpg", 257532),
+ wantSong("1391891631", "嗜好", "颜人中", "嗜好", "https://p2.music.126.net/J6HaJjtgv-yVVjyUm-h-AA==/109951164373633387.jpg", 284113),
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("相似歌曲 = %#v\n期望 %#v", got, want)
+ }
+ }
+ obs.check(t, "similar")
+}
+
+const audioPath = "/song.mp3"
+
+func resolveRoutes(t *testing.T) map[string]http.HandlerFunc {
+ return map[string]http.HandlerFunc{
+ "/weapi/v3/song/detail": fixture(t, "song-detail.json"),
+ "/weapi/song/lyric": fixture(t, "lyric.json"),
+ audioPath: func(w http.ResponseWriter, r *http.Request) {
+ // 音频来自 CDN,网易云的 Cookie 不能发给它。
+ if got := r.Header.Get("Cookie"); got != "" {
+ t.Errorf("下载音频时带上了 Cookie %q", got)
+ }
+ _, _ = io.WriteString(w, "ID3 音频")
+ },
+ }
+}
+
+func readAudio(t *testing.T, r port.Resolved) string {
+ t.Helper()
+ defer func() { _ = r.Audio.Close() }()
+ b, err := io.ReadAll(r.Audio)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(b)
+}
+
+func TestPlayURL(t *testing.T) {
+ routes := resolveRoutes(t)
+ routes["/eapi/song/enhance/player/url/v1"] = fixture(t, "play-url.json")
+ c, obs := serve(t, routes)
+ var source port.PlayURLs = c
+ got, err := source.PlayURL(t.Context(), "3387653989")
+ if err != nil {
+ t.Fatal(err)
+ }
+ u, err := url.Parse(got)
+ if err != nil || u.Path != audioPath {
+ t.Fatalf("播放地址 = %q, %v,期望音频路径 %q", got, err, audioPath)
+ }
+ obs.check(t, "play_url")
+}
+
+func TestResolve(t *testing.T) {
+ routes := resolveRoutes(t)
+ routes["/eapi/song/enhance/player/url/v1"] = func(w http.ResponseWriter, r *http.Request) {
+ plain := decryptECB(t, r.FormValue("params"), eapiKey)
+ if !strings.HasPrefix(plain, "/api/song/enhance/player/url/v1-36cd479b6b5-") || !strings.Contains(plain, `"ids":[3387653989]`) || !strings.Contains(plain, `"level":"exhigh"`) {
+ t.Errorf("eapi 的参数 = %s", plain)
+ }
+ fixture(t, "play-url.json")(w, r)
+ }
+ c, obs := serve(t, routes)
+ got, err := c.Resolve(t.Context(), "3387653989")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Meta != cover.Meta || got.Lyric != "[00:16.35]故事的小黄花\n[00:20.16]从出生那年就飘着\n" {
+ t.Errorf("解析结果 = %#v", got)
+ }
+ if audio := readAudio(t, got); audio != "ID3 音频" {
+ t.Errorf("音频 = %q", audio)
+ }
+ obs.check(t, "track_detail", "lyric", "play_url")
+}
+
+func TestResolveFallsBackToStandardQuality(t *testing.T) {
+ routes := resolveRoutes(t)
+ routes["/eapi/song/enhance/player/url/v1"] = fixture(t, "play-url-missing.json")
+ routes["/weapi/song/enhance/player/url"] = fixture(t, "play-url.json")
+ c, obs := serve(t, routes)
+ got, err := c.Resolve(t.Context(), "3387653989")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if audio := readAudio(t, got); audio != "ID3 音频" {
+ t.Errorf("音频 = %q", audio)
+ }
+ obs.check(t, "track_detail", "lyric", "play_url", "play_url")
+}
+
+// 播放地址有时效性,两次准备之间不能复用;元数据和歌词命中缓存。
+func TestResolveDoesNotCachePlayURL(t *testing.T) {
+ routes := resolveRoutes(t)
+ routes["/eapi/song/enhance/player/url/v1"] = fixture(t, "play-url.json")
+ c, obs := serve(t, routes)
+ for range 2 {
+ got, err := c.Resolve(t.Context(), "3387653989")
+ if err != nil {
+ t.Fatal(err)
+ }
+ readAudio(t, got)
+ }
+ obs.check(t, "track_detail", "lyric", "play_url", "play_url")
+}
+
+// 没有会员权限的账号只能取到试听片段:播放地址有值,freeTrialInfo 给出片段的范围。
+// 录制不到这样的响应,按解析时读取的字段构造。
+func trialClip() http.HandlerFunc {
+ return body(`{"data":[{"id":3387653989,"url":"http://m801.music.126.net` + audioPath + `?trial=1","br":320000,"code":200,"freeTrialInfo":{"start":0,"end":30},"fee":1}],"code":200}`)
+}
+
+// 记下写入曲目表的歌。
+type savedTracks struct{ saved []track.Track }
+
+func (*savedTracks) PreparedTrack(context.Context, track.Track) (port.PreparedTrack, bool, error) {
+ return port.PreparedTrack{}, false, nil
+}
+
+func (s *savedTracks) SavePreparedTrack(_ context.Context, t track.Track, _ port.PreparedTrack) error {
+ s.saved = append(s.saved, t)
+ return nil
+}
+
+// 试听片段不是完整的歌,存下来这首歌就只有 30 秒:按音源拒绝处理,不下载,也不写入曲目表。
+func TestTrialClipIsRejectedAndNotStored(t *testing.T) {
+ missing := fixture(t, "play-url-missing.json")
+ for _, tt := range []struct {
+ name string
+ eapi, weapi http.HandlerFunc
+ }{
+ {"两个接口都只给试听片段", trialClip(), trialClip()},
+ {"回落的接口没有播放地址", trialClip(), missing},
+ {"eapi 没有播放地址,回落的接口只给试听片段", missing, trialClip()},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ routes := resolveRoutes(t)
+ routes["/eapi/song/enhance/player/url/v1"] = tt.eapi
+ routes["/weapi/song/enhance/player/url"] = tt.weapi
+ routes[audioPath] = func(http.ResponseWriter, *http.Request) { t.Error("试听片段不应下载") }
+ c, _ := serve(t, routes)
+ tracks := &savedTracks{}
+ err := prepare.NewRunner(map[track.Source]port.Source{track.NetEase: c}, tracks, slog.New(slog.DiscardHandler)).Run(t.Context(), track.Track{Source: track.NetEase, ID: "3387653989"})
+ var rejected *port.RejectedError
+ if !errors.As(err, &rejected) || err.Error() != "这首歌需要网易云会员账号才能完整播放,请管理员在后台设置会员账号的 Cookie" {
+ t.Errorf("试听片段的错误 = %v,期望音源拒绝,原因说明需要会员账号", err)
+ }
+ if len(tracks.saved) != 0 {
+ t.Errorf("曲目表中写入了 %v", tracks.saved)
+ }
+ })
+ }
+}
+
+// 第一个接口只给试听片段时,回落的接口给出完整的歌就用它。
+func TestResolveFallsBackFromTrialClip(t *testing.T) {
+ routes := resolveRoutes(t)
+ routes["/eapi/song/enhance/player/url/v1"] = trialClip()
+ routes["/weapi/song/enhance/player/url"] = fixture(t, "play-url.json")
+ fullSong := routes[audioPath]
+ routes[audioPath] = func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Query().Has("trial") {
+ _, _ = io.WriteString(w, "ID3 试听片段")
+ return
+ }
+ fullSong(w, r)
+ }
+ c, _ := serve(t, routes)
+ got, err := c.Resolve(t.Context(), "3387653989")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if audio := readAudio(t, got); audio != "ID3 音频" {
+ t.Errorf("音频 = %q,期望回落的接口给出的完整的歌", audio)
+ }
+}
+
+func TestResolveRejectsSongWithoutURL(t *testing.T) {
+ routes := resolveRoutes(t)
+ routes["/eapi/song/enhance/player/url/v1"] = fixture(t, "play-url-missing.json")
+ routes["/weapi/song/enhance/player/url"] = fixture(t, "play-url-missing.json")
+ c, _ := serve(t, routes)
+ var rejected *port.RejectedError
+ if _, err := c.Resolve(t.Context(), "3387653989"); !errors.As(err, &rejected) || err.Error() != "网易云没有提供这首歌的播放地址" {
+ t.Errorf("没有播放地址时的错误 = %v,期望音源拒绝", err)
+ }
+}
+
+func TestResolveRetriesUpstreamFailures(t *testing.T) {
+ routes := resolveRoutes(t)
+ routes["/eapi/song/enhance/player/url/v1"] = status(http.StatusBadGateway)
+ routes["/weapi/song/enhance/player/url"] = status(http.StatusBadGateway)
+ c, obs := serve(t, routes)
+ var rejected *port.RejectedError
+ if _, err := c.Resolve(t.Context(), "3387653989"); err == nil || errors.As(err, &rejected) {
+ t.Errorf("网易云出错时的错误 = %v,期望可以重试的错误", err)
+ }
+ obs.check(t, "track_detail", "lyric", "play_url 失败", "play_url 失败")
+
+ routes = resolveRoutes(t)
+ routes["/eapi/song/enhance/player/url/v1"] = fixture(t, "play-url.json")
+ routes[audioPath] = status(http.StatusForbidden)
+ c, _ = serve(t, routes)
+ if _, err := c.Resolve(t.Context(), "3387653989"); err == nil || errors.As(err, &rejected) {
+ t.Errorf("下载音频失败时的错误 = %v,期望可以重试的错误", err)
+ }
+}
+
+func TestFailedResponseIsNotCached(t *testing.T) {
+ var calls atomic.Int32
+ c, obs := serve(t, map[string]http.HandlerFunc{
+ "/api/linux/forward": func(w http.ResponseWriter, r *http.Request) {
+ if calls.Add(1) == 1 {
+ _, _ = io.WriteString(w, `{"code":-460,"message":"Cheating"}`)
+ return
+ }
+ fixture(t, "search-tracks.json")(w, r)
+ },
+ })
+ if _, err := c.SearchTracks(t.Context(), "晴天"); err == nil {
+ t.Fatal("错误码不是 200 时应失败")
+ }
+ if songs, err := c.SearchTracks(t.Context(), "晴天"); err != nil || len(songs) != 2 {
+ t.Errorf("再次搜索 = %d 首, %v,失败的响应不应缓存", len(songs), err)
+ }
+ obs.check(t, "search_tracks 失败", "search_tracks")
+}
+
+func TestCookieIsReloadedEvery30Seconds(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ loads := 0
+ c := cookieCache{load: func(context.Context) (string, error) {
+ loads++
+ return "MUSIC_U=" + string(rune('0'+loads)), nil
+ }}
+ for range 2 {
+ if got, err := c.get(t.Context()); err != nil || got != "MUSIC_U=1" {
+ t.Fatalf("Cookie = %q, %v", got, err)
+ }
+ }
+ time.Sleep(30*time.Second - time.Nanosecond)
+ if got, err := c.get(t.Context()); err != nil || got != "MUSIC_U=1" {
+ t.Fatalf("不到 30 秒的 Cookie = %q, %v,期望仍是缓存的", got, err)
+ }
+ time.Sleep(time.Nanosecond)
+ if got, err := c.get(t.Context()); err != nil || got != "MUSIC_U=2" {
+ t.Errorf("30 秒之后的 Cookie = %q, %v,期望重新读取", got, err)
+ }
+ })
+}
+
+// 读取 Cookie 是读 PostgreSQL,期限与其他读写 PostgreSQL 的操作相同,不占用接口调用的 30 秒。
+func TestCookieLoadHasOwnDeadline(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ c := cookieCache{load: func(ctx context.Context) (string, error) {
+ <-ctx.Done()
+ return "", ctx.Err()
+ }}
+ ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
+ defer cancel()
+ start := time.Now()
+ if _, err := c.get(ctx); !errors.Is(err, context.DeadlineExceeded) {
+ t.Errorf("读取卡住时的错误 = %v,期望超过期限", err)
+ }
+ if waited := time.Since(start); waited != 10*time.Second {
+ t.Errorf("等了 %v,期望 10 秒", waited)
+ }
+ })
+}
+
+// 把 HTTPS 的请求转给 TLS 服务器,HTTP 的请求转给明文服务器。
+type split struct {
+ secure, plain *url.URL
+ transport http.RoundTripper
+}
+
+func (s split) RoundTrip(req *http.Request) (*http.Response, error) {
+ req = req.Clone(req.Context())
+ if req.URL.Scheme == "https" {
+ req.URL.Host = s.secure.Host
+ return s.transport.RoundTrip(req)
+ }
+ req.URL.Host = s.plain.Host
+ return http.DefaultTransport.RoundTrip(req)
+}
+
+// Go 会把手动设置的 Cookie 头带到同主机的重定向目标,不管目标是不是 HTTPS。
+func TestCookieIsNotSentOverPlainHTTPAfterRedirect(t *testing.T) {
+ plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Errorf("明文的 HTTP 服务器收到了 %s,Cookie = %q", r.URL, r.Header.Get("Cookie"))
+ fixture(t, "song-detail.json")(w, r)
+ }))
+ t.Cleanup(plain.Close)
+ secure := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "http://music.163.com"+r.URL.Path, http.StatusTemporaryRedirect)
+ }))
+ t.Cleanup(secure.Close)
+ plainURL, err := url.Parse(plain.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ secureURL, err := url.Parse(secure.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ obs := &observed{}
+ cookie := func(context.Context) (string, error) { return "MUSIC_U=secret", nil }
+ c := New(&http.Client{Transport: split{secure: secureURL, plain: plainURL, transport: secure.Client().Transport}}, cookie, func(context.Context, string) error { return nil }, obs.record)
+
+ if _, err := c.TrackMeta(t.Context(), "3387653989"); err == nil || !strings.Contains(err.Error(), "HTTP 307") {
+ t.Errorf("接口重定向时的错误 = %v,期望 HTTP 307", err)
+ }
+ obs.check(t, "track_detail 失败")
+}
+
+// 音频下载不带 Cookie,沿用默认的重定向策略:网易云给出的 CDN 地址可能再跳转一次。
+func TestResolveFollowsAudioRedirects(t *testing.T) {
+ routes := resolveRoutes(t)
+ routes["/eapi/song/enhance/player/url/v1"] = fixture(t, "play-url.json")
+ routes[audioPath] = func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Query().Get("cdn") == "" {
+ http.Redirect(w, r, audioPath+"?cdn=1", http.StatusFound)
+ return
+ }
+ _, _ = io.WriteString(w, "ID3 音频")
+ }
+ c, _ := serve(t, routes)
+ got, err := c.Resolve(t.Context(), "3387653989")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if audio := readAudio(t, got); audio != "ID3 音频" {
+ t.Errorf("跳转之后的音频 = %q", audio)
+ }
+}
+
+type failingTransport struct{}
+
+func (failingTransport) RoundTrip(*http.Request) (*http.Response, error) {
+ return nil, errors.New("网络中断")
+}
+
+func TestDownloadErrorDoesNotExposeSignedURL(t *testing.T) {
+ signedURL := "https://m701.music.126.net/audio.mp3?sign=signed-secret&uid=123"
+ c := New(
+ &http.Client{Transport: failingTransport{}},
+ func(context.Context) (string, error) { return "", nil },
+ func(context.Context, string) error { return nil },
+ func(string, string, time.Duration, error) {},
+ )
+ _, err := c.download(t.Context(), signedURL)
+ if err == nil || !strings.Contains(err.Error(), "网络中断") {
+ t.Fatalf("下载错误 = %v,期望保留网络错误", err)
+ }
+ if strings.Contains(err.Error(), "signed-secret") || strings.Contains(err.Error(), signedURL) {
+ t.Errorf("下载错误携带了签名地址:%v", err)
+ }
+}
diff --git a/internal/adapter/out/netease/testdata/lyric.json b/internal/adapter/out/netease/testdata/lyric.json
new file mode 100644
index 0000000..26ed894
--- /dev/null
+++ b/internal/adapter/out/netease/testdata/lyric.json
@@ -0,0 +1 @@
+{"sgc":false,"sfy":false,"qfy":false,"lrc":{"version":2,"lyric":"[00:16.35]故事的小黄花\n[00:20.16]从出生那年就飘着\n"},"code":200}
diff --git a/internal/adapter/out/netease/testdata/play-url-missing.json b/internal/adapter/out/netease/testdata/play-url-missing.json
new file mode 100644
index 0000000..39ce9e6
--- /dev/null
+++ b/internal/adapter/out/netease/testdata/play-url-missing.json
@@ -0,0 +1 @@
+{"data":[{"id":186016,"url":null,"br":0,"size":0,"md5":null,"code":404,"expi":1200,"type":null,"level":null,"encodeType":null,"freeTrialInfo":null,"fee":0}],"code":200}
diff --git a/internal/adapter/out/netease/testdata/play-url.json b/internal/adapter/out/netease/testdata/play-url.json
new file mode 100644
index 0000000..d1bce88
--- /dev/null
+++ b/internal/adapter/out/netease/testdata/play-url.json
@@ -0,0 +1 @@
+{"data":[{"id":3387653989,"url":"https://audio.example.test/song.mp3","br":320000,"size":7075048,"md5":"010358dd803427effbc480e412ed3a76","code":200,"expi":1200,"type":"mp3","level":"exhigh","encodeType":"mp3","freeTrialInfo":null,"fee":0}],"code":200}
diff --git a/internal/adapter/out/netease/testdata/playlist-detail.json b/internal/adapter/out/netease/testdata/playlist-detail.json
new file mode 100644
index 0000000..868564e
--- /dev/null
+++ b/internal/adapter/out/netease/testdata/playlist-detail.json
@@ -0,0 +1 @@
+{"code":200,"playlist":{"id":3778678,"name":"热歌榜","trackCount":200,"trackIds":[{"id":287398,"v":56,"t":0,"at":1777770863933},{"id":1973665667,"v":20,"t":0,"at":1767573351833}],"tracks":[]}}
diff --git a/internal/adapter/out/netease/testdata/playlist-songs.json b/internal/adapter/out/netease/testdata/playlist-songs.json
new file mode 100644
index 0000000..71840d3
--- /dev/null
+++ b/internal/adapter/out/netease/testdata/playlist-songs.json
@@ -0,0 +1 @@
+{"songs":[{"name":"我不难过","id":287398,"ar":[{"id":9272,"name":"孙燕姿"}],"al":{"id":28539,"name":"未完成","picUrl":"https://p1.music.126.net/Pzxy6py26NgNmhJjYMf2RQ==/109951169724216323.jpg"},"dt":320400,"fee":1},{"name":"海屿你","id":1973665667,"ar":[{"id":13288861,"name":"马也_Crabbit"}],"al":{"id":150006421,"name":"海屿你","picUrl":"https://p1.music.126.net/RfguLkQJ8aMVkiIaxdy70Q==/109951170483263672.jpg"},"dt":295940,"fee":8}],"code":200}
diff --git a/internal/adapter/out/netease/testdata/search-playlists.json b/internal/adapter/out/netease/testdata/search-playlists.json
new file mode 100644
index 0000000..8bb2193
--- /dev/null
+++ b/internal/adapter/out/netease/testdata/search-playlists.json
@@ -0,0 +1 @@
+{"result":{"playlists":[{"id":6792103822,"name":"周杰伦-Jay 『网易云精选』","coverImgUrl":"http://p1.music.126.net/WFQ4EKF5QabD33U3NUOPWQ==/109951169535051638.jpg","creator":{"nickname":"Buradarrr","userId":361038766},"trackCount":143,"playCount":34028960,"description":"【持续更新】欢迎投稿"},{"id":17745261207,"name":"周杰伦歌单","coverImgUrl":"http://p1.music.126.net/-79-XFhWolhMzGESC8ifkg==/109951170218252280.jpg","creator":{"nickname":"双鱼不困","userId":17165106366},"trackCount":29,"playCount":514834,"description":null}],"playlistCount":290},"code":200}
diff --git a/internal/adapter/out/netease/testdata/search-tracks.json b/internal/adapter/out/netease/testdata/search-tracks.json
new file mode 100644
index 0000000..f2d8ef7
--- /dev/null
+++ b/internal/adapter/out/netease/testdata/search-tracks.json
@@ -0,0 +1 @@
+{"result":{"songs":[{"name":"晴天(深情版)","id":2652820720,"ar":[{"id":96154669,"name":"Lucky小爱"}],"al":{"id":255723258,"name":"晴天(深情版)","picUrl":"http://p1.music.126.net/F0fTkmBTVykCa2o7Vgu1rQ==/109951173569626660.jpg"},"dt":278961,"fee":8},{"name":"晴天 (Cover 蓝心羽)","id":3387653989,"ar":[{"id":0,"name":"小汤圆"}],"al":{"id":0,"name":"","picUrl":"http://p1.music.126.net/pvManIVaLdiDyKMDzuChuA==/109951173298787727.jpg"},"dt":176773,"fee":0}],"songCount":338},"code":200}
diff --git a/internal/adapter/out/netease/testdata/similar.json b/internal/adapter/out/netease/testdata/similar.json
new file mode 100644
index 0000000..1e2f4b2
--- /dev/null
+++ b/internal/adapter/out/netease/testdata/similar.json
@@ -0,0 +1 @@
+{"songs":[{"name":"如果呢","id":1842728629,"duration":257532,"fee":8,"artists":[{"name":"郑润泽","id":29051613}],"album":{"name":"如果呢","id":126985670,"picUrl":"https://p2.music.126.net/-xMsNLpquZTmMZlIztTgHg==/109951165953469081.jpg"}},{"name":"嗜好","id":1391891631,"duration":284113,"fee":8,"artists":[{"name":"颜人中","id":31376161}],"album":{"name":"嗜好","id":81762080,"picUrl":"https://p2.music.126.net/J6HaJjtgv-yVVjyUm-h-AA==/109951164373633387.jpg"}}],"code":200}
diff --git a/internal/adapter/out/netease/testdata/song-detail-missing.json b/internal/adapter/out/netease/testdata/song-detail-missing.json
new file mode 100644
index 0000000..c3fb7a3
--- /dev/null
+++ b/internal/adapter/out/netease/testdata/song-detail-missing.json
@@ -0,0 +1 @@
+{"songs":[],"privileges":[{"id":1,"st":-200,"code":0}],"code":200}
diff --git a/internal/adapter/out/netease/testdata/song-detail.json b/internal/adapter/out/netease/testdata/song-detail.json
new file mode 100644
index 0000000..93f803e
--- /dev/null
+++ b/internal/adapter/out/netease/testdata/song-detail.json
@@ -0,0 +1 @@
+{"songs":[{"name":"晴天 (Cover 蓝心羽)","id":3387653989,"ar":[{"id":0,"name":"小汤圆"}],"al":{"id":0,"name":"","picUrl":"https://p1.music.126.net/pvManIVaLdiDyKMDzuChuA==/109951173298787727.jpg"},"dt":176773,"fee":0}],"privileges":[{"id":3387653989,"fee":0,"st":0,"pl":320000}],"code":200}
diff --git a/internal/adapter/out/postgres/apikeys_test.go b/internal/adapter/out/postgres/apikeys_test.go
new file mode 100644
index 0000000..98c3c28
--- /dev/null
+++ b/internal/adapter/out/postgres/apikeys_test.go
@@ -0,0 +1,152 @@
+package postgres
+
+import (
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/domain/room"
+)
+
+func newAPIKey(key string) port.NewAPIKey {
+ return port.NewAPIKey{TokenHash: tokenHash(key), BotTokenHash: tokenHash("bot " + key)}
+}
+
+func resetAPIKey(t *testing.T, store *Store, roomID, key string) {
+ t.Helper()
+ if err := store.ResetRoomAPIKey(t.Context(), roomID, newAPIKey(key)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func memberTickets(t *testing.T, pool *pgxpool.Pool, memberID string) int {
+ t.Helper()
+ var n int
+ if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM room_tickets WHERE member_id = $1", memberID).Scan(&n); err != nil {
+ t.Fatal(err)
+ }
+ return n
+}
+
+func TestResetRoomAPIKey(t *testing.T) {
+ store, pool := newStore(t)
+ id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
+ if status, err := store.RoomAPIKeyStatus(t.Context(), id); err != nil || status.Set || !status.CreatedAt.IsZero() {
+ t.Fatalf("还没有 Key 时的状态 = %+v, %v,期望未生成", status, err)
+ }
+
+ resetAPIKey(t, store, id, "k1")
+ bot, err := store.TicketHolder(t.Context(), tokenHash("k1"))
+ if err != nil || bot.RoomID != id || !bot.Bot || bot.Member.Name != "bot" || bot.Member.AvatarHash != "" {
+ t.Fatalf("Key 的持有者 = %#v, %v,期望这个房间的 bot 成员,昵称 bot,没有头像", bot, err)
+ }
+ if _, err := store.MemberByToken(t.Context(), tokenHash("k1")); !errors.Is(err, port.ErrMemberNotFound) {
+ t.Errorf("用 Key 查成员的错误 = %v,期望 ErrMemberNotFound:bot 的成员 token 不能是 Key", err)
+ }
+ var createdAt time.Time
+ if err := pool.QueryRow(t.Context(), "SELECT created_at FROM room_tickets WHERE token_hash = $1", tokenHash("k1")).Scan(&createdAt); err != nil {
+ t.Fatal(err)
+ }
+ if status, err := store.RoomAPIKeyStatus(t.Context(), id); err != nil || !status.Set || !status.CreatedAt.Equal(createdAt) {
+ t.Errorf("生成之后的状态 = %+v, %v,期望已生成,时间是 bot 票据的创建时间 %v", status, err, createdAt)
+ }
+
+ resetAPIKey(t, store, id, "k2")
+ if _, err := store.TicketHolder(t.Context(), tokenHash("k1")); !errors.Is(err, port.ErrTicketNotFound) {
+ t.Errorf("重置之后旧 Key 的错误 = %v,期望 ErrTicketNotFound", err)
+ }
+ if holder, err := store.TicketHolder(t.Context(), tokenHash("k2")); err != nil || holder != bot {
+ t.Errorf("重置之后新 Key 的持有者 = %#v, %v,期望 bot 成员不变", holder, err)
+ }
+ if n := memberTickets(t, pool, bot.Member.ID); n != 1 {
+ t.Errorf("重置之后 bot 有 %d 张票据,期望只有新 Key 一张", n)
+ }
+
+ missing := "01a15778-1911-7a28-8b2e-e08c2a25f388"
+ if err := store.ResetRoomAPIKey(t.Context(), missing, newAPIKey("k3")); !errors.Is(err, port.ErrRoomNotFound) {
+ t.Errorf("重置不存在的房间的错误 = %v,期望 ErrRoomNotFound", err)
+ }
+}
+
+func TestSettingPasswordKeepsAPIKey(t *testing.T) {
+ store, pool := newStore(t)
+ id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
+ resetAPIKey(t, store, id, "k1")
+ bot, err := store.TicketHolder(t.Context(), tokenHash("k1"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ addTicket(t, pool, id)
+
+ if err := store.UpdateRoomAccess(t.Context(), id, port.AccessChange{PasswordHash: ptr("h1")}); err != nil {
+ t.Fatal(err)
+ }
+ if holder, err := store.TicketHolder(t.Context(), tokenHash("k1")); err != nil || holder != bot {
+ t.Errorf("设置密码之后 Key 的持有者 = %#v, %v,期望 Key 仍然有效", holder, err)
+ }
+ if n := ticketCount(t, pool, id); n != 1 {
+ t.Errorf("设置密码之后房间有 %d 张票据,期望成员的票据删除,只剩 Key", n)
+ }
+}
+
+func TestDeletingRoomDeletesAPIKey(t *testing.T) {
+ store, pool := newStore(t)
+ id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
+ lounge := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "二厅"})
+ resetAPIKey(t, store, id, "k1")
+ resetAPIKey(t, store, lounge, "k2")
+ bot, err := store.TicketHolder(t.Context(), tokenHash("k1"))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ term, _ := takeRoom(t, store, id)
+ if err := store.DeleteRoom(t.Context(), id, term); err != nil {
+ t.Fatal(err)
+ }
+ if n := memberTickets(t, pool, bot.Member.ID); n != 0 {
+ t.Errorf("删除房间之后 bot 还有 %d 张票据", n)
+ }
+ if status, err := store.RoomAPIKeyStatus(t.Context(), lounge); err != nil || !status.Set {
+ t.Errorf("删除大厅之后二厅的状态 = %+v, %v,期望不受影响", status, err)
+ }
+}
+
+func TestConcurrentFirstResetsCreateOneBot(t *testing.T) {
+ store, pool := newStore(t)
+ id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
+
+ // 先锁住房间行,两个首次生成都在等它,放开之后同时进行。
+ tx, err := pool.Begin(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = tx.Rollback(t.Context()) }()
+ if _, err := tx.Exec(t.Context(), "SELECT 1 FROM rooms WHERE id = $1 FOR NO KEY UPDATE", id); err != nil {
+ t.Fatal(err)
+ }
+ done := make(chan error, 2)
+ for _, key := range []string{"k1", "k2"} {
+ go func() { done <- store.ResetRoomAPIKey(t.Context(), id, newAPIKey(key)) }()
+ }
+ waitForLockWait(t, pool, 2)
+ if err := tx.Commit(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ for range 2 {
+ if err := <-done; err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ var bots int
+ if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM members WHERE name = 'bot'").Scan(&bots); err != nil {
+ t.Fatal(err)
+ }
+ if n := ticketCount(t, pool, id); n != 1 || bots != 1 {
+ t.Errorf("有 %d 把 Key、%d 个 bot 成员,期望各一个", n, bots)
+ }
+}
diff --git a/internal/adapter/out/postgres/bilibili_credential_test.go b/internal/adapter/out/postgres/bilibili_credential_test.go
new file mode 100644
index 0000000..0cf0e24
--- /dev/null
+++ b/internal/adapter/out/postgres/bilibili_credential_test.go
@@ -0,0 +1,18 @@
+package postgres
+
+import "testing"
+
+func TestBilibiliCredential(t *testing.T) {
+ s, _ := newStore(t)
+ if got, err := s.BilibiliCredential(t.Context()); err != nil || got != "" {
+ t.Fatalf("初始 Bilibili 凭据 = %q, %v", got, err)
+ }
+ for _, value := range []string{`{"cookie":"SESSDATA=x","refresh_token":"y"}`, ""} {
+ if err := s.SetBilibiliCredential(t.Context(), value); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := s.BilibiliCredential(t.Context()); err != nil || got != value {
+ t.Errorf("Bilibili 凭据 = %q, %v,期望 %q", got, err, value)
+ }
+ }
+}
diff --git a/internal/adapter/out/postgres/credentials_test.go b/internal/adapter/out/postgres/credentials_test.go
new file mode 100644
index 0000000..c594327
--- /dev/null
+++ b/internal/adapter/out/postgres/credentials_test.go
@@ -0,0 +1,34 @@
+package postgres
+
+import "testing"
+
+func TestClearedCookieHasNoRow(t *testing.T) {
+ store, pool := newStore(t)
+ for _, cookie := range []string{"MUSIC_U=1", ""} {
+ if err := store.SetNeteaseCookie(t.Context(), cookie); err != nil {
+ t.Fatal(err)
+ }
+ }
+ var rows int
+ if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM credentials").Scan(&rows); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := store.NeteaseCookie(t.Context()); err != nil || got != "" || rows != 0 {
+ t.Errorf("清除之后 = %q, %v,凭据表有 %d 行,期望空串和 0 行", got, err, rows)
+ }
+}
+
+func TestNeteaseCookie(t *testing.T) {
+ store, _ := newStore(t)
+ if cookie, err := store.NeteaseCookie(t.Context()); err != nil || cookie != "" {
+ t.Fatalf("没有设置过时 = %q, %v,期望空字符串", cookie, err)
+ }
+ for _, want := range []string{"MUSIC_U=1", "MUSIC_U=2"} {
+ if err := store.SetNeteaseCookie(t.Context(), want); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := store.NeteaseCookie(t.Context()); err != nil || got != want {
+ t.Errorf("NeteaseCookie = %q, %v,期望 %q", got, err, want)
+ }
+ }
+}
diff --git a/internal/adapter/out/postgres/history_test.go b/internal/adapter/out/postgres/history_test.go
new file mode 100644
index 0000000..404874d
--- /dev/null
+++ b/internal/adapter/out/postgres/history_test.go
@@ -0,0 +1,237 @@
+package postgres
+
+import (
+ "fmt"
+ "slices"
+ "testing"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/domain/room"
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+func picked(id room.EntryID, t track.Track) room.Entry {
+ return room.Entry{ID: id, Track: t, By: alice, AddedAt: t0}
+}
+
+func autoPicked(id room.EntryID, t track.Track) room.Entry {
+ return room.Entry{ID: id, Track: t, Auto: true, AddedAt: t0}
+}
+
+func played(e room.Entry, minute int) room.RecordPlay {
+ start := t0.Add(time.Duration(minute) * time.Minute)
+ return room.RecordPlay{Entry: e, StartedAt: start, EndedAt: start.Add(time.Minute)}
+}
+
+func trackIDs(tracks []track.Track) []string {
+ ids := make([]string, len(tracks))
+ for i, t := range tracks {
+ if t.Source == track.NetEase {
+ ids[i] = t.ID
+ } else {
+ ids[i] = string(t.Source) + ":" + t.ID
+ }
+ }
+ return ids
+}
+
+func TestAutoDJSeeds(t *testing.T) {
+ store, _ := newStore(t)
+ id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
+ other := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "二厅"})
+ term, snap := takeRoom(t, store, id)
+ otherTerm, otherSnap := takeRoom(t, store, other)
+ saveRoom(t, store, otherTerm, otherSnap, played(picked(1, netease("elsewhere")), 9))
+
+ snap.EntrySeq = 12
+ snap.Current = &room.NowPlaying{Entry: picked(6, netease("c")), StartedAt: t0.Add(5 * time.Minute)}
+ snap.Pending = &room.Entry{ID: 7, Track: netease("p"), By: alice, AddedAt: t0}
+ w1, w2, again := picked(8, netease("w1")), picked(9, netease("w2")), picked(10, netease("a"))
+ w2.Likes = 1
+ snap.Waiting = []room.Entry{w2, w1, again, autoPicked(11, netease("w3")), picked(12, track.Track{Source: track.QQ, ID: "w4"})}
+ saveRoom(t, store, term, snap,
+ played(picked(1, netease("a")), 0),
+ played(picked(2, netease("b")), 1),
+ played(autoPicked(3, netease("x")), 2),
+ played(picked(4, track.Track{Source: track.QQ, ID: "q"}), 3),
+ played(picked(5, netease("a")), 4),
+ )
+
+ seeds, err := store.AutoDJSeeds(t.Context(), id, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, want := trackIDs(seeds), []string{"c", "a", "b"}; !slices.Equal(got, want) {
+ t.Errorf("自动补歌的种子 = %v,期望 %v", got, want)
+ }
+ seeds, err = store.AutoDJSeeds(t.Context(), id, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, want := trackIDs(seeds), []string{"c", "p", "w2", "w1", "a", "b"}; !slices.Equal(got, want) {
+ t.Errorf("推荐列表的种子 = %v,期望 %v", got, want)
+ }
+
+ // 轮流播放时等待队列中点赞数相同的先比轮次,种子的顺序与队列相同。
+ snap.Mode = room.Rotation
+ snap.Waiting[1].Round, snap.Waiting[2].Round = 2, 1
+ saveRoom(t, store, term, snap)
+ seeds, err = store.AutoDJSeeds(t.Context(), id, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, want := trackIDs(seeds), []string{"c", "p", "w2", "a", "w1", "b"}; !slices.Equal(got, want) {
+ t.Errorf("轮流播放时推荐列表的种子 = %v,期望 %v", got, want)
+ }
+}
+
+func TestAutoDJSeedsKeepsMostRecent32(t *testing.T) {
+ store, _ := newStore(t)
+ id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
+ term, snap := takeRoom(t, store, id)
+ var plays []room.RecordPlay
+ for i := range 40 {
+ plays = append(plays, played(picked(room.EntryID(i+1), netease(fmt.Sprint(i))), i))
+ }
+ snap.EntrySeq = 41
+ snap.Current = &room.NowPlaying{Entry: picked(41, netease("c")), StartedAt: t0.Add(time.Hour)}
+ saveRoom(t, store, term, snap, plays...)
+
+ seeds, err := store.AutoDJSeeds(t.Context(), id, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []string{"c"}
+ for i := 39; len(want) < 32; i-- {
+ want = append(want, fmt.Sprint(i))
+ }
+ if got := trackIDs(seeds); !slices.Equal(got, want) {
+ t.Errorf("种子 = %v,期望正在播放的加上最近 31 首 %v", got, want)
+ }
+}
+
+func TestAutoDJExclusions(t *testing.T) {
+ store, _ := newStore(t)
+ id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
+ other := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "二厅"})
+ term, snap := takeRoom(t, store, id)
+ otherTerm, otherSnap := takeRoom(t, store, other)
+ // 其他房间自动播放的和正在播放的歌比本房间的所有记录都新,查询如果去掉 room_id 条件,它们就会挤进最近 128 首。
+ otherSnap.EntrySeq = 3
+ otherSnap.Current = &room.NowPlaying{Entry: autoPicked(3, netease("elsewhere-current")), StartedAt: t0.Add(600 * time.Minute)}
+ otherSnap.Waiting = []room.Entry{picked(2, netease("elsewhere-waiting"))}
+ saveRoom(t, store, otherTerm, otherSnap, played(autoPicked(1, netease("elsewhere-auto")), 500))
+
+ var plays []room.RecordPlay
+ for i := range 130 {
+ plays = append(plays, played(autoPicked(room.EntryID(i+1), netease(fmt.Sprintf("x%03d", i))), i))
+ }
+ plays = append(plays, played(picked(131, netease("m")), 130))
+ snap.EntrySeq = 135
+ snap.Current = &room.NowPlaying{Entry: autoPicked(132, netease("y")), StartedAt: t0.Add(3 * time.Hour)}
+ snap.Pending = &room.Entry{ID: 133, Track: netease("p"), By: alice, AddedAt: t0}
+ snap.Waiting = []room.Entry{picked(134, track.Track{Source: track.QQ, ID: "w"}), autoPicked(135, netease("z"))}
+ saveRoom(t, store, term, snap, plays...)
+
+ excluded, err := store.AutoDJExclusions(t.Context(), id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := trackIDs(excluded)
+ for _, want := range []string{"y", "x129", "x003", "p", "qq:w", "z"} {
+ if !slices.Contains(got, want) {
+ t.Errorf("排除项中缺少 %s", want)
+ }
+ }
+ for _, unwanted := range []string{"x002", "m", "elsewhere-auto", "elsewhere-current", "elsewhere-waiting"} {
+ if slices.Contains(got, unwanted) {
+ t.Errorf("排除项不应包含 %s", unwanted)
+ }
+ }
+ if len(got) != 128+3 {
+ t.Errorf("排除项有 %d 首,期望最近 128 首 AutoDJ 的歌加上待播和等待队列的 3 首", len(got))
+ }
+}
+
+func TestAutoDJLooksAtLast1000Plays(t *testing.T) {
+ store, _ := newStore(t)
+ id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
+ term, snap := takeRoom(t, store, id)
+ var plays []room.RecordPlay
+ add := func(e room.Entry) { plays = append(plays, played(e, len(plays))) }
+ for i := range 50 {
+ add(picked(1, netease(fmt.Sprint("old-", i))))
+ add(autoPicked(1, netease(fmt.Sprint("old-auto-", i))))
+ }
+ // 最近的第 1002、1001 次播放在窗口之外,第 1000、999 次在窗口之中。
+ add(picked(1, netease("outside")))
+ add(autoPicked(1, netease("outside-auto")))
+ add(picked(1, netease("edge")))
+ add(autoPicked(1, netease("edge-auto")))
+ for len(plays) < 1102 {
+ switch len(plays) {
+ case 500:
+ add(picked(1, netease("inside")))
+ case 600:
+ add(autoPicked(1, netease("inside-auto")))
+ default:
+ add(picked(1, track.Track{Source: track.QQ, ID: fmt.Sprint(len(plays))}))
+ }
+ }
+ snap.EntrySeq = 1
+ snap.Current = &room.NowPlaying{Entry: picked(1, netease("c")), StartedAt: t0.Add(2000 * time.Minute)}
+ saveRoom(t, store, term, snap, plays...)
+
+ seeds, err := store.AutoDJSeeds(t.Context(), id, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, want := trackIDs(seeds), []string{"c", "inside", "edge"}; !slices.Equal(got, want) {
+ t.Errorf("种子 = %v,期望正在播放的歌加上最近 1000 次播放中成员点的网易云歌曲 %v", got, want)
+ }
+ excluded, err := store.AutoDJExclusions(t.Context(), id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, want := trackIDs(excluded), []string{"edge-auto", "inside-auto"}; !slices.Equal(got, want) {
+ t.Errorf("排除项 = %v,期望最近 1000 次播放中 AutoDJ 加入的歌 %v", got, want)
+ }
+}
+
+func TestTrackInUse(t *testing.T) {
+ store, _ := newStore(t)
+ id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
+ term, snap := takeRoom(t, store, id)
+ link := track.Track{Source: track.Link, ID: "https://example.com/a.mp3"}
+ pending := picked(3, netease("3"))
+ snap.EntrySeq = 3
+ snap.Current = &room.NowPlaying{Entry: picked(1, link), StartedAt: t0}
+ snap.Waiting = []room.Entry{picked(2, netease("1"))}
+ snap.Pending = &pending
+ saveRoom(t, store, term, snap)
+
+ for tr, want := range map[track.Track]bool{
+ link: true,
+ netease("1"): true,
+ netease("3"): true,
+ netease("2"): false,
+ {Source: track.QQ, ID: "1"}: false,
+ } {
+ got, err := store.TrackInUse(t.Context(), tr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != want {
+ t.Errorf("TrackInUse(%v) = %v,期望 %v", tr, got, want)
+ }
+ }
+
+ snap.Current, snap.Pending, snap.Waiting = nil, nil, nil
+ saveRoom(t, store, term, snap)
+ for _, tr := range []track.Track{link, netease("1"), netease("3")} {
+ if used, err := store.TrackInUse(t.Context(), tr); err != nil || used {
+ t.Errorf("条目移出房间后 TrackInUse(%v) = %v, %v,期望 false", tr, used, err)
+ }
+ }
+}
diff --git a/internal/adapter/out/postgres/main_test.go b/internal/adapter/out/postgres/main_test.go
new file mode 100644
index 0000000..3a6255e
--- /dev/null
+++ b/internal/adapter/out/postgres/main_test.go
@@ -0,0 +1,97 @@
+package postgres
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "sync/atomic"
+ "testing"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+ "github.com/testcontainers/testcontainers-go"
+ tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
+)
+
+const schemaTemplate = "schema_template"
+
+var (
+ server *pgxpool.Config
+ admin *pgxpool.Pool
+ databases atomic.Int64
+)
+
+func TestMain(m *testing.M) {
+ os.Exit(runTests(m))
+}
+
+func runTests(m *testing.M) int {
+ ctx := context.Background()
+ container, err := tcpostgres.Run(ctx, "postgres:18-alpine", tcpostgres.BasicWaitStrategies())
+ defer func() { _ = testcontainers.TerminateContainer(container) }()
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "启动 PostgreSQL 容器:", err)
+ return 1
+ }
+
+ url, err := container.ConnectionString(ctx, "sslmode=disable")
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ return 1
+ }
+ if server, err = pgxpool.ParseConfig(url); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ return 1
+ }
+ if admin, err = pgxpool.NewWithConfig(ctx, server); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ return 1
+ }
+ defer admin.Close()
+ if err := createSchemaTemplate(ctx); err != nil {
+ fmt.Fprintln(os.Stderr, "准备模板数据库:", err)
+ return 1
+ }
+ return m.Run()
+}
+
+func createSchemaTemplate(ctx context.Context) error {
+ if _, err := admin.Exec(ctx, "CREATE DATABASE "+schemaTemplate); err != nil {
+ return err
+ }
+ cfg := server.Copy()
+ cfg.ConnConfig.Database = schemaTemplate
+ pool, err := pgxpool.NewWithConfig(ctx, cfg)
+ if err != nil {
+ return err
+ }
+ // 作为模板的数据库在复制时不能有连接。
+ defer pool.Close()
+ return MigrateSchema(ctx, pool, discard)
+}
+
+func newDatabase(t *testing.T, template string) *pgxpool.Config {
+ t.Helper()
+ name := fmt.Sprintf("test_%d", databases.Add(1))
+ if _, err := admin.Exec(t.Context(), fmt.Sprintf("CREATE DATABASE %s TEMPLATE %s", name, template)); err != nil {
+ t.Fatal(err)
+ }
+ cfg := server.Copy()
+ cfg.ConnConfig.Database = name
+ return cfg
+}
+
+func connect(t *testing.T, cfg *pgxpool.Config) *pgxpool.Pool {
+ t.Helper()
+ pool, err := pgxpool.NewWithConfig(t.Context(), cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(pool.Close)
+ return pool
+}
+
+func newStore(t *testing.T) (*Store, *pgxpool.Pool) {
+ t.Helper()
+ pool := connect(t, newDatabase(t, schemaTemplate))
+ return New(pool), pool
+}
diff --git a/internal/adapter/out/postgres/media_test.go b/internal/adapter/out/postgres/media_test.go
new file mode 100644
index 0000000..a58c86b
--- /dev/null
+++ b/internal/adapter/out/postgres/media_test.go
@@ -0,0 +1,80 @@
+package postgres
+
+import (
+ "reflect"
+ "testing"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/domain/room"
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+func saveStored(t *testing.T, store *Store, tr track.Track, pageURL, key, cover string, size int64) {
+ t.Helper()
+ p := port.PreparedTrack{
+ Meta: track.Meta{Title: "标题 " + key, CoverKey: track.ObjectKey(cover), PageURL: pageURL},
+ Result: track.Prepared{Duration: time.Minute, Gain: 1},
+ MediaKey: key,
+ MediaSize: size,
+ }
+ if err := store.SavePreparedTrack(t.Context(), tr, p); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestStoredMedia(t *testing.T) {
+ store, _ := newStore(t)
+ a := track.Track{Source: track.Link, ID: "https://example.com/a.mp3"}
+ video := track.Track{Source: track.Bilibili, ID: "BV1xx411c7mD/2"}
+ saveStored(t, store, a, a.ID, "aaa", "link/aaa/cover.jpg", 100)
+ saveStored(t, store, track.Track{Source: track.Link, ID: "https://example.com/b.mp3"}, "https://example.com/b.mp3", "bbb", "", 200)
+ saveStored(t, store, video, "https://www.bilibili.com/video/BV1xx411c7mD?p=2", "ccc", "link/ccc/cover.jpg", 300)
+ addTrack(t, store, netease("1"), track.Meta{Title: "晴天"}, track.Prepared{Duration: time.Minute, Gain: 1})
+
+ id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
+ term, snap := takeRoom(t, store, id)
+ played := room.Entry{ID: 1, Track: a, By: alice}
+ saveRoom(t, store, term, snap, room.RecordPlay{Entry: played, StartedAt: t0, EndedAt: t0.Add(time.Minute)})
+
+ list, err := store.ListStoredMedia(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(list) != 3 || list[0].Key != "ccc" || list[1].Key != "bbb" || list[2].Key != "aaa" {
+ t.Fatalf("已存储的媒体 = %+v,期望两首链接和一首 B 站的歌,后存入的在前", list)
+ }
+ got := list[2]
+ if got.Source != track.Link || got.Link != a.ID || got.Title != "标题 aaa" || got.Size != 100 || got.CoverKey != "link/aaa/cover.jpg" ||
+ got.StoredAt.IsZero() || got.LastPlayedAt == nil || !got.LastPlayedAt.Equal(t0) {
+ t.Errorf("播放过的链接 = %+v", got)
+ }
+ if b := list[1]; b.Source != track.Link || b.CoverKey != "" || b.LastPlayedAt != nil {
+ t.Errorf("没有封面、没有播放过的链接 = %+v", b)
+ }
+ if c := list[0]; c.Source != track.Bilibili || c.Link != video.ID || c.CoverKey != "link/ccc/cover.jpg" || c.Size != 300 {
+ t.Errorf("B 站的歌 = %+v", c)
+ }
+
+ for key, want := range map[string]port.StoredMedia{"aaa": got, "ccc": list[0]} {
+ if found, ok, err := store.StoredMedia(t.Context(), key); err != nil || !ok || !reflect.DeepEqual(found, want) {
+ t.Errorf("StoredMedia(%s) = %+v, %v, %v,期望 %+v", key, found, ok, err, want)
+ }
+ }
+ for _, key := range []string{"aaa", "ccc"} {
+ if err := store.DeleteStoredMedia(t.Context(), key); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok, err := store.StoredMedia(t.Context(), key); err != nil || ok {
+ t.Errorf("清除之后 StoredMedia(%s) = %v, %v", key, ok, err)
+ }
+ }
+ for _, tr := range []track.Track{a, video} {
+ if _, ok, err := store.PreparedTrack(t.Context(), tr); err != nil || ok {
+ t.Errorf("清除之后 %v 的准备结果 = %v, %v,期望没有", tr, ok, err)
+ }
+ }
+ if list, err := store.ListStoredMedia(t.Context()); err != nil || len(list) != 1 {
+ t.Errorf("清除之后的列表 = %+v, %v", list, err)
+ }
+}
diff --git a/internal/adapter/out/postgres/members_test.go b/internal/adapter/out/postgres/members_test.go
new file mode 100644
index 0000000..a6f2b64
--- /dev/null
+++ b/internal/adapter/out/postgres/members_test.go
@@ -0,0 +1,102 @@
+package postgres
+
+import (
+ "crypto/sha256"
+ "errors"
+ "testing"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/domain/member"
+)
+
+func tokenHash(token string) []byte {
+ sum := sha256.Sum256([]byte(token))
+ return sum[:]
+}
+
+func createMember(t *testing.T, store *Store, token, name string) member.Member {
+ t.Helper()
+ m, err := store.CreateMember(t.Context(), tokenHash(token), name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return m
+}
+
+func TestCreateAndFindMember(t *testing.T) {
+ store, _ := newStore(t)
+ m := createMember(t, store, "alice", "游客-1a2b")
+ if !isUUID(m.ID) || m.ID[14] != '7' {
+ t.Errorf("成员 ID %q 应是数据库生成的 UUIDv7", m.ID)
+ }
+ if want := (member.Member{ID: m.ID, Name: "游客-1a2b"}); m != want {
+ t.Errorf("新成员 = %#v,期望 %#v", m, want)
+ }
+
+ got, err := store.MemberByToken(t.Context(), tokenHash("alice"))
+ if err != nil || got != m {
+ t.Errorf("MemberByToken = %#v, %v,期望 %#v", got, err, m)
+ }
+ if _, err := store.MemberByToken(t.Context(), tokenHash("bob")); !errors.Is(err, port.ErrMemberNotFound) {
+ t.Errorf("未知 token 的错误 = %v,期望 ErrMemberNotFound", err)
+ }
+ if _, err := store.CreateMember(t.Context(), tokenHash("alice"), "重复"); err == nil {
+ t.Error("token 哈希重复时应失败")
+ }
+}
+
+func TestMemberAvatarIsNullWhenUnset(t *testing.T) {
+ store, pool := newStore(t)
+ m := createMember(t, store, "alice", "游客-1a2b")
+ avatar := func() string {
+ t.Helper()
+ var hash string
+ if err := pool.QueryRow(t.Context(), "SELECT coalesce(avatar_hash, 'NULL') FROM members WHERE id = $1", m.ID).Scan(&hash); err != nil {
+ t.Fatal(err)
+ }
+ return hash
+ }
+ if hash := avatar(); hash != "NULL" {
+ t.Errorf("新成员的 avatar_hash = %q,期望 NULL", hash)
+ }
+ if _, err := store.UpdateMember(t.Context(), m.ID, nil, ptr("0bc83cb571cd1c50ba6f3e8a78ef1346")); err != nil {
+ t.Fatal(err)
+ }
+ if hash := avatar(); hash != "0bc83cb571cd1c50ba6f3e8a78ef1346" {
+ t.Errorf("设置头像之后 avatar_hash = %q", hash)
+ }
+ got, err := store.UpdateMember(t.Context(), m.ID, nil, ptr(""))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if hash := avatar(); hash != "NULL" || got.AvatarHash != "" {
+ t.Errorf("清除头像之后 avatar_hash = %q、成员的头像哈希 = %q,期望 NULL 和空串", hash, got.AvatarHash)
+ }
+}
+
+func TestUpdateMemberKeepsOmittedFields(t *testing.T) {
+ store, _ := newStore(t)
+ m := createMember(t, store, "alice", "游客-1a2b")
+
+ got, err := store.UpdateMember(t.Context(), m.ID, ptr("甲"), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := (member.Member{ID: m.ID, Name: "甲"}); got != want {
+ t.Errorf("只改昵称 = %#v,期望 %#v", got, want)
+ }
+ got, err = store.UpdateMember(t.Context(), m.ID, nil, ptr("0bc83cb571cd1c50ba6f3e8a78ef1346"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := (member.Member{ID: m.ID, Name: "甲", AvatarHash: "0bc83cb571cd1c50ba6f3e8a78ef1346"}); got != want {
+ t.Errorf("只改头像 = %#v,期望 %#v", got, want)
+ }
+ if found, _ := store.MemberByToken(t.Context(), tokenHash("alice")); found != got {
+ t.Errorf("修改后按 token 查到 %#v,期望 %#v", found, got)
+ }
+
+ if _, err := store.UpdateMember(t.Context(), "01a15778-1911-7a28-8b2e-e08c2a25f388", ptr("乙"), nil); !errors.Is(err, port.ErrMemberNotFound) {
+ t.Errorf("修改不存在的成员,错误 = %v,期望 ErrMemberNotFound", err)
+ }
+}
diff --git a/internal/adapter/out/postgres/migrate_test.go b/internal/adapter/out/postgres/migrate_test.go
new file mode 100644
index 0000000..21bff2a
--- /dev/null
+++ b/internal/adapter/out/postgres/migrate_test.go
@@ -0,0 +1,130 @@
+package postgres
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+func migrate(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) error {
+ return WithMigrationLock(ctx, pool, func(ctx context.Context) error {
+ if err := MigrateSchema(ctx, pool, logger); err != nil {
+ return err
+ }
+ return MigrateRiver(ctx, pool, logger)
+ })
+}
+
+var discard = slog.New(slog.DiscardHandler)
+
+func TestMigrationLockOutlivesSessionTimeouts(t *testing.T) {
+ for _, setting := range []string{"idle_in_transaction_session_timeout = '100ms'", "transaction_timeout = '100ms'"} {
+ t.Run(setting, func(t *testing.T) {
+ cfg := newDatabase(t, "template1")
+ if _, err := admin.Exec(t.Context(), fmt.Sprintf("ALTER DATABASE %s SET %s", cfg.ConnConfig.Database, setting)); err != nil {
+ t.Fatal(err)
+ }
+ holder, observer := connect(t, cfg), connect(t, cfg)
+
+ err := WithMigrationLock(t.Context(), holder, func(ctx context.Context) error {
+ time.Sleep(500 * time.Millisecond)
+ var held bool
+ if err := observer.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM pg_locks
+ WHERE locktype = 'advisory' AND granted
+ AND database = (SELECT oid FROM pg_database WHERE datname = current_database()))`).Scan(&held); err != nil {
+ return err
+ }
+ if !held {
+ return errors.New("超过数据库设置的超时之后,迁移锁失效了")
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ })
+ }
+}
+
+func TestMigrateWaitsForMigrationLock(t *testing.T) {
+ for _, tc := range []struct{ name, setting string }{
+ {name: "lock_timeout", setting: "lock_timeout = '200ms'"},
+ {name: "statement_timeout", setting: "statement_timeout = '200ms'"},
+ {name: "transaction_timeout", setting: "transaction_timeout = '200ms'"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ cfg := newDatabase(t, "template1")
+ // 先完成迁移:等到锁之后的迁移只剩检查,不会被 statement_timeout 打断。
+ if err := migrate(t.Context(), connect(t, cfg), discard); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := admin.Exec(t.Context(), fmt.Sprintf("ALTER DATABASE %s SET %s", cfg.ConnConfig.Database, tc.setting)); err != nil {
+ t.Fatal(err)
+ }
+ holder, other := connect(t, cfg), connect(t, cfg)
+
+ done := make(chan error, 1)
+ err := WithMigrationLock(t.Context(), holder, func(ctx context.Context) error {
+ go func() { done <- migrate(t.Context(), other, discard) }()
+ for {
+ var waiting int
+ if err := holder.QueryRow(ctx, `SELECT count(*) FROM pg_locks
+ WHERE locktype = 'advisory' AND NOT granted
+ AND database = (SELECT oid FROM pg_database WHERE datname = current_database())`).Scan(&waiting); err != nil {
+ return err
+ }
+ if waiting > 0 {
+ break
+ }
+ select {
+ case err := <-done:
+ return fmt.Errorf("迁移没有等待锁就结束了:%v", err)
+ case <-time.After(20 * time.Millisecond):
+ }
+ }
+ // 持锁的时间超过设置的超时之后,等待中的迁移仍在等待。
+ select {
+ case err := <-done:
+ return fmt.Errorf("迁移在锁释放之前就结束了:%v", err)
+ case <-time.After(500 * time.Millisecond):
+ return nil
+ }
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := <-done; err != nil {
+ t.Fatal(err)
+ }
+ })
+ }
+}
+
+func TestMigrationLogsKeepFixedFields(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewJSONHandler(&buf, nil)).With("version", "test")
+ if err := migrate(t.Context(), connect(t, newDatabase(t, "template1")), logger); err != nil {
+ t.Fatal(err)
+ }
+
+ lines := bytes.Split(bytes.TrimSpace(buf.Bytes()), []byte("\n"))
+ if len(lines) < 2 {
+ t.Fatalf("goose 和 River 都应记录执行的迁移:\n%s", buf.String())
+ }
+ for _, line := range lines {
+ var entry map[string]any
+ if err := json.Unmarshal(line, &entry); err != nil {
+ t.Fatalf("日志不是 JSON:%v\n%s", err, line)
+ }
+ if entry["version"] != "test" {
+ t.Errorf("日志覆盖了固定字段 version:%s", line)
+ }
+ }
+}
diff --git a/internal/adapter/out/postgres/qq_cookie_test.go b/internal/adapter/out/postgres/qq_cookie_test.go
new file mode 100644
index 0000000..f0958ab
--- /dev/null
+++ b/internal/adapter/out/postgres/qq_cookie_test.go
@@ -0,0 +1,18 @@
+package postgres
+
+import "testing"
+
+func TestQQCookie(t *testing.T) {
+ s, _ := newStore(t)
+ if got, err := s.QQCookie(t.Context()); err != nil || got != "" {
+ t.Fatalf("初始 QQ Cookie = %q, %v", got, err)
+ }
+ for _, value := range []string{"uin=o1; qm_keyst=key", ""} {
+ if err := s.SetQQCookie(t.Context(), value); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := s.QQCookie(t.Context()); err != nil || got != value {
+ t.Errorf("QQ Cookie = %q, %v,期望 %q", got, err, value)
+ }
+ }
+}
diff --git a/internal/adapter/out/postgres/ready_test.go b/internal/adapter/out/postgres/ready_test.go
new file mode 100644
index 0000000..785d85b
--- /dev/null
+++ b/internal/adapter/out/postgres/ready_test.go
@@ -0,0 +1,39 @@
+package postgres
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestCheckMigrations(t *testing.T) {
+ empty := connect(t, newDatabase(t, "template1"))
+ if err := CheckMigrations(t.Context(), empty); err == nil {
+ t.Error("没有执行迁移时应未就绪")
+ }
+ var created bool
+ if err := empty.QueryRow(t.Context(), "SELECT to_regclass('goose_db_version') IS NOT NULL").Scan(&created); err != nil {
+ t.Fatal(err)
+ }
+ if created {
+ t.Error("就绪检查不应创建 goose 的版本表")
+ }
+
+ schemaOnly := connect(t, newDatabase(t, schemaTemplate))
+ if err := CheckMigrations(t.Context(), schemaOnly); err == nil || !strings.Contains(err.Error(), "River") {
+ t.Errorf("只执行了 goose 迁移时的错误 = %v,期望指出 River 迁移未完成", err)
+ }
+
+ migrated := connect(t, newDatabase(t, "template1"))
+ if err := migrate(t.Context(), migrated, discard); err != nil {
+ t.Fatal(err)
+ }
+ if err := CheckMigrations(t.Context(), migrated); err != nil {
+ t.Errorf("迁移完成后应就绪:%v", err)
+ }
+ if _, err := migrated.Exec(t.Context(), "INSERT INTO goose_db_version (version_id, is_applied) VALUES (99, true)"); err != nil {
+ t.Fatal(err)
+ }
+ if err := CheckMigrations(t.Context(), migrated); err != nil {
+ t.Errorf("迁移版本高于要求时也应就绪:%v", err)
+ }
+}
diff --git a/internal/adapter/out/postgres/rooms_test.go b/internal/adapter/out/postgres/rooms_test.go
new file mode 100644
index 0000000..7314ec8
--- /dev/null
+++ b/internal/adapter/out/postgres/rooms_test.go
@@ -0,0 +1,162 @@
+package postgres
+
+import (
+ "errors"
+ "slices"
+ "testing"
+
+ "github.com/jackc/pgx/v5/pgconn"
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/domain/room"
+)
+
+func createRoom(t *testing.T, store *Store, r port.NewRoom) string {
+ t.Helper()
+ id, err := store.CreateRoom(t.Context(), r)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return id
+}
+
+func addTicket(t *testing.T, pool *pgxpool.Pool, roomID string) {
+ t.Helper()
+ _, err := pool.Exec(t.Context(), `
+ WITH m AS (
+ INSERT INTO members (token_hash, name) VALUES (sha256(gen_random_uuid()::text::bytea), '甲')
+ RETURNING id
+ )
+ INSERT INTO room_tickets (token_hash, room_id, member_id) SELECT sha256(gen_random_uuid()::text::bytea), $1, id FROM m`,
+ roomID)
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func ticketCount(t *testing.T, pool *pgxpool.Pool, roomID string) int {
+ t.Helper()
+ var n int
+ if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM room_tickets WHERE room_id = $1", roomID).Scan(&n); err != nil {
+ t.Fatal(err)
+ }
+ return n
+}
+
+func ptr(s string) *string { return &s }
+
+func TestCreateListAndFindRooms(t *testing.T) {
+ store, _ := newStore(t)
+ lobby := createRoom(t, store, port.NewRoom{Kind: room.Ephemeral, Name: "临时", Description: "随便听听"})
+ hall := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅", Slug: "hall", PasswordHash: "hash"})
+
+ for _, id := range []string{lobby, hall} {
+ if !isUUID(id) || id[14] != '7' {
+ t.Errorf("房间 ID %q 应是数据库生成的 UUIDv7", id)
+ }
+ }
+ want := []port.RoomRecord{
+ {Info: room.Info{ID: lobby, Name: "临时", Description: "随便听听", Kind: room.Ephemeral}},
+ {Info: room.Info{ID: hall, Name: "大厅", Kind: room.Persistent}, Slug: "hall", PasswordHash: "hash"},
+ }
+ got, err := store.ListRooms(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !slices.Equal(got, want) {
+ t.Fatalf("房间列表 = %#v,期望按创建顺序 %#v", got, want)
+ }
+
+ for ref, want := range map[string]port.RoomRecord{lobby: want[0], hall: want[1], "hall": want[1]} {
+ got, err := store.FindRoom(t.Context(), ref)
+ if err != nil || got != want {
+ t.Errorf("FindRoom(%q) = %#v, %v,期望 %#v", ref, got, err, want)
+ }
+ }
+ for _, ref := range []string{"lobby", "01a15778-1911-7a28-8b2e-e08c2a25f388", "not-a-uuid-but-36-characters-long!!!"} {
+ if _, err := store.FindRoom(t.Context(), ref); !errors.Is(err, port.ErrRoomNotFound) {
+ t.Errorf("FindRoom(%q) 的错误 = %v,期望 ErrRoomNotFound", ref, err)
+ }
+ }
+}
+
+func TestSlugIsUniqueAndOnlyForPersistentRooms(t *testing.T) {
+ store, _ := newStore(t)
+ createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅", Slug: "hall"})
+ other := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "二厅"})
+
+ if _, err := store.CreateRoom(t.Context(), port.NewRoom{Kind: room.Persistent, Name: "重名", Slug: "hall"}); !errors.Is(err, port.ErrSlugTaken) {
+ t.Errorf("新建房间使用已占用的别名,错误 = %v,期望 ErrSlugTaken", err)
+ }
+ if err := store.UpdateRoomAccess(t.Context(), other, port.AccessChange{Slug: ptr("hall")}); !errors.Is(err, port.ErrSlugTaken) {
+ t.Errorf("改用已占用的别名,错误 = %v,期望 ErrSlugTaken", err)
+ }
+ _, err := store.CreateRoom(t.Context(), port.NewRoom{Kind: room.Ephemeral, Name: "临时", Slug: "temp"})
+ var pgErr *pgconn.PgError
+ // 这条约束同时引用 slug 和 kind,PostgreSQL 给它起的名字是 rooms_check。
+ if !errors.As(err, &pgErr) || pgErr.Code != "23514" || pgErr.ConstraintName != "rooms_check" {
+ t.Errorf("临时房间设别名的错误 = %v,期望违反只许常驻房间有别名的约束", err)
+ }
+}
+
+func TestUpdateRoomAccess(t *testing.T) {
+ store, pool := newStore(t)
+ id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅", Slug: "hall"})
+ addTicket(t, pool, id)
+
+ if err := store.UpdateRoomAccess(t.Context(), id, port.AccessChange{Slug: ptr("")}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.FindRoom(t.Context(), "hall"); !errors.Is(err, port.ErrRoomNotFound) {
+ t.Errorf("取消别名后仍能按别名找到房间:%v", err)
+ }
+ if ticketCount(t, pool, id) != 1 {
+ t.Error("只改别名不影响票据")
+ }
+
+ if err := store.UpdateRoomAccess(t.Context(), id, port.AccessChange{Slug: ptr("main"), PasswordHash: ptr("hash")}); err != nil {
+ t.Fatal(err)
+ }
+ if got, _ := store.FindRoom(t.Context(), "main"); got.PasswordHash != "hash" {
+ t.Errorf("设置别名和密码后 = %#v", got)
+ }
+ if ticketCount(t, pool, id) != 0 {
+ t.Error("设置密码时应删除全部票据")
+ }
+
+ addTicket(t, pool, id)
+ if err := store.UpdateRoomAccess(t.Context(), id, port.AccessChange{PasswordHash: ptr("")}); err != nil {
+ t.Fatal(err)
+ }
+ if got, _ := store.FindRoom(t.Context(), id); got.PasswordHash != "" || got.Slug != "main" {
+ t.Errorf("取消密码后 = %#v", got)
+ }
+ if ticketCount(t, pool, id) != 1 {
+ t.Error("取消密码时票据保持有效")
+ }
+
+ for _, missing := range []string{"01a15778-1911-7a28-8b2e-e08c2a25f388", "hall"} {
+ if err := store.UpdateRoomAccess(t.Context(), missing, port.AccessChange{Slug: ptr("x")}); !errors.Is(err, port.ErrRoomNotFound) {
+ t.Errorf("UpdateRoomAccess(%q) 的错误 = %v,期望 ErrRoomNotFound", missing, err)
+ }
+ }
+}
+
+func TestUpdateRoomAccessIsAtomic(t *testing.T) {
+ store, pool := newStore(t)
+ createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅", Slug: "hall"})
+ id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "二厅", PasswordHash: "old"})
+ addTicket(t, pool, id)
+
+ err := store.UpdateRoomAccess(t.Context(), id, port.AccessChange{Slug: ptr("hall"), PasswordHash: ptr("new")})
+ if !errors.Is(err, port.ErrSlugTaken) {
+ t.Fatalf("错误 = %v,期望 ErrSlugTaken", err)
+ }
+ if got, _ := store.FindRoom(t.Context(), id); got.PasswordHash != "old" {
+ t.Errorf("别名冲突时密码不应改变:%#v", got)
+ }
+ if ticketCount(t, pool, id) != 1 {
+ t.Error("别名冲突时票据不应删除")
+ }
+}
diff --git a/internal/adapter/out/postgres/site_test.go b/internal/adapter/out/postgres/site_test.go
new file mode 100644
index 0000000..8cc9391
--- /dev/null
+++ b/internal/adapter/out/postgres/site_test.go
@@ -0,0 +1,28 @@
+package postgres
+
+import "testing"
+
+func TestBrandName(t *testing.T) {
+ store, pool := newStore(t)
+ if name, err := store.BrandName(t.Context()); err != nil || name != "" {
+ t.Fatalf("没有设置过时 = %q, %v,期望空字符串", name, err)
+ }
+ for _, want := range []string{"周末听歌会", "Alisten"} {
+ if err := store.SetBrandName(t.Context(), want); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := store.BrandName(t.Context()); err != nil || got != want {
+ t.Errorf("BrandName = %q, %v,期望 %q", got, err, want)
+ }
+ }
+ var rows int
+ if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM site").Scan(&rows); err != nil {
+ t.Fatal(err)
+ }
+ if rows != 1 {
+ t.Errorf("覆盖写之后 site 表有 %d 行,期望 1 行", rows)
+ }
+ if _, err := pool.Exec(t.Context(), "INSERT INTO site (id, brand_name) VALUES (false, '第二行')"); err == nil {
+ t.Error("site 表接受了第二行")
+ }
+}
diff --git a/internal/adapter/out/postgres/snapshot_test.go b/internal/adapter/out/postgres/snapshot_test.go
new file mode 100644
index 0000000..5bd02b5
--- /dev/null
+++ b/internal/adapter/out/postgres/snapshot_test.go
@@ -0,0 +1,331 @@
+package postgres
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "reflect"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/domain/member"
+ "github.com/dihemoe/alisten/internal/domain/room"
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+// PostgreSQL 的时间精确到微秒。
+var t0 = time.Date(2026, 1, 1, 0, 0, 0, 1000, time.UTC)
+
+var alice = member.Member{ID: "01a15778-1911-7a28-8b2e-e08c2a25f381", Name: "甲", AvatarHash: "0bc83cb571cd1c50ba6f3e8a78ef1346"}
+
+func netease(id string) track.Track { return track.Track{Source: track.NetEase, ID: id} }
+
+func takeRoom(t *testing.T, store *Store, id string) (uint32, room.Snapshot) {
+ t.Helper()
+ term, snap, err := store.TakeRoom(t.Context(), id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return term, snap
+}
+
+func saveRoom(t *testing.T, store *Store, term uint32, snap room.Snapshot, plays ...room.RecordPlay) {
+ t.Helper()
+ if err := store.SaveRoom(t.Context(), term, snap, plays); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func addTrack(t *testing.T, store *Store, tr track.Track, m track.Meta, p track.Prepared) {
+ t.Helper()
+ if err := store.SavePreparedTrack(t.Context(), tr, port.PreparedTrack{Meta: m, Result: p}); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestTakeRoomIncrementsTerm(t *testing.T) {
+ store, _ := newStore(t)
+ id := createRoom(t, store, port.NewRoom{Kind: room.Ephemeral, Name: "临时", Description: "描述"})
+
+ term, snap := takeRoom(t, store, id)
+ want := room.Snapshot{Info: room.Info{ID: id, Name: "临时", Description: "描述", Kind: room.Ephemeral}, Mode: room.Sequential}
+ if term != 1 || !reflect.DeepEqual(snap, want) {
+ t.Fatalf("新房间第一次接管 = %d, %#v,期望任期 1 和 %#v", term, snap, want)
+ }
+ if term, _ := takeRoom(t, store, id); term != 2 {
+ t.Errorf("再次接管的任期 = %d,期望 2", term)
+ }
+
+ for _, missing := range []string{"01a15778-1911-7a28-8b2e-e08c2a25f388", "r1"} {
+ if _, _, err := store.TakeRoom(t.Context(), missing); !errors.Is(err, port.ErrRoomNotFound) {
+ t.Errorf("TakeRoom(%q) 的错误 = %v,期望 ErrRoomNotFound", missing, err)
+ }
+ }
+}
+
+func TestSavedSnapshotIsRestored(t *testing.T) {
+ store, _ := newStore(t)
+ id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
+ term, _ := takeRoom(t, store, id)
+
+ playing := track.Prepared{Duration: 3 * time.Minute, Gain: 0.8, Lyric: "[00:00.00]歌词"}
+ queued := track.Prepared{Duration: 2*time.Minute + 500*time.Millisecond, Gain: 1.2}
+ link := track.Track{Source: track.Link, ID: "https://example.com/a.mp3"}
+ meta := track.Meta{Title: "歌", Artist: "人", Album: "专辑", CoverURL: "https://p.example/c.jpg", PageURL: "https://music.163.com/#/song?id=1", Duration: playing.Duration}
+ linkMeta := track.Meta{Title: "链接里的歌", PageURL: link.ID, Duration: queued.Duration}
+ addTrack(t, store, netease("1"), meta, playing)
+ addTrack(t, store, link, linkMeta, queued)
+
+ // 准备中的链接曲目也可能带有封面的对象键:例如音频已不在对象存储中、要重新准备的歌。
+ unprepared := track.Track{Source: track.Link, ID: "https://example.com/c.mp3"}
+ saved := room.Snapshot{
+ Info: room.Info{ID: id, Name: "新名字", Description: "新描述", Kind: room.Persistent},
+ Mode: room.Random,
+ EntrySeq: 5,
+ Current: &room.NowPlaying{
+ Entry: room.Entry{ID: 1, Track: netease("1"), Meta: meta, By: alice, Round: 1, Ready: true, Prep: playing, AddedAt: t0},
+ StartedAt: t0.Add(time.Second),
+ },
+ Pending: &room.Entry{
+ ID: 2, Track: track.Track{Source: track.QQ, ID: "2"}, By: alice, Round: 2, AddedAt: t0,
+ Meta: track.Meta{Title: "待播", PageURL: "https://y.qq.com/n/ryqq/songDetail/2", Duration: time.Minute},
+ },
+ Waiting: []room.Entry{
+ {ID: 3, Track: link, Meta: linkMeta, By: alice, Likes: 2, Round: 3, Ready: true, Prep: queued, AddedAt: t0},
+ {ID: 4, Track: netease("4"), Auto: true, Round: 1, AddedAt: t0.Add(time.Minute)},
+ {ID: 5, Track: unprepared, Meta: track.Meta{Title: unprepared.ID, CoverKey: "link/c/cover.png", PageURL: unprepared.ID}, By: alice, Round: 4, AddedAt: t0},
+ },
+ }
+ saveRoom(t, store, term, saved)
+
+ term, restored := takeRoom(t, store, id)
+ if term != 2 {
+ t.Errorf("任期 = %d,期望 2", term)
+ }
+ if !reflect.DeepEqual(restored, saved) {
+ t.Fatalf("恢复出的快照与落盘的不同:\n得到 %#v\n期望 %#v", restored, saved)
+ }
+}
+
+func TestPlayModesAreStored(t *testing.T) {
+ store, _ := newStore(t)
+ for _, mode := range []room.PlayMode{room.Sequential, room.Random, room.Rotation} {
+ id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
+ term, snap := takeRoom(t, store, id)
+ snap.Mode = mode
+ saveRoom(t, store, term, snap)
+ if _, restored := takeRoom(t, store, id); restored.Mode != mode {
+ t.Errorf("落盘的播放模式 %d 恢复为 %d", mode, restored.Mode)
+ }
+ }
+}
+
+func TestSnapshotStoresMissingValuesAsNull(t *testing.T) {
+ store, pool := newStore(t)
+ id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅", Description: "介绍"})
+ term, _ := takeRoom(t, store, id)
+ bob := member.Member{ID: "01a15778-1911-7a28-8b2e-e08c2a25f382", Name: "乙"}
+ saved := room.Snapshot{
+ Info: room.Info{ID: id, Name: "大厅", Kind: room.Persistent},
+ Mode: room.Sequential,
+ EntrySeq: 2,
+ Waiting: []room.Entry{
+ {ID: 1, Track: netease("1"), Meta: track.Meta{Title: "无名", PageURL: "https://music.163.com/#/song?id=1"}, By: bob, AddedAt: t0},
+ {ID: 2, Track: netease("2"), Meta: track.Meta{Title: "推荐", PageURL: "https://music.163.com/#/song?id=2"}, Auto: true, AddedAt: t0},
+ },
+ }
+ saveRoom(t, store, term, saved)
+
+ var description *string
+ if err := pool.QueryRow(t.Context(), "SELECT description FROM rooms WHERE id = $1", id).Scan(&description); err != nil {
+ t.Fatal(err)
+ }
+ if description == nil || *description != "" {
+ t.Errorf("清空的房间描述 = %v,期望空串", description)
+ }
+ rows, err := pool.Query(t.Context(), `SELECT entry_id, concat_ws(',', CASE WHEN artist IS NULL THEN 'artist' END, CASE WHEN album IS NULL THEN 'album' END,
+ CASE WHEN cover_url IS NULL THEN 'cover_url' END, CASE WHEN by_name IS NULL THEN 'by_name' END, CASE WHEN by_avatar_hash IS NULL THEN 'by_avatar_hash' END)
+ FROM room_entries WHERE room_id = $1 ORDER BY entry_id`, id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer rows.Close()
+ var missing []string
+ for rows.Next() {
+ var entry int
+ var columns string
+ if err := rows.Scan(&entry, &columns); err != nil {
+ t.Fatal(err)
+ }
+ missing = append(missing, fmt.Sprintf("%d:%s", entry, columns))
+ }
+ if want := []string{"1:artist,album,cover_url,by_avatar_hash", "2:artist,album,cover_url,by_name,by_avatar_hash"}; !slices.Equal(missing, want) {
+ t.Errorf("各条目为 NULL 的列 = %v,期望 %v", missing, want)
+ }
+ if _, restored := takeRoom(t, store, id); !reflect.DeepEqual(restored, saved) {
+ t.Errorf("恢复出的快照与落盘的不同:\n得到 %#v\n期望 %#v", restored, saved)
+ }
+}
+
+func TestReadinessComesFromTracks(t *testing.T) {
+ store, _ := newStore(t)
+ id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
+ term, snap := takeRoom(t, store, id)
+
+ prepared := track.Prepared{Duration: time.Minute, Gain: 1}
+ link := track.Track{Source: track.Link, ID: "https://example.com/b.mp3"}
+ snap.EntrySeq = 2
+ snap.Waiting = []room.Entry{
+ {ID: 1, Track: netease("removed"), Ready: true, Prep: prepared, AddedAt: t0},
+ {ID: 2, Track: link, Meta: track.Meta{Title: link.ID, PageURL: link.ID}, AddedAt: t0},
+ }
+ saveRoom(t, store, term, snap)
+ meta := track.Meta{Title: "链接里的歌", Artist: "人", PageURL: link.ID, Duration: prepared.Duration}
+ addTrack(t, store, link, meta, prepared)
+
+ _, restored := takeRoom(t, store, id)
+ if got := restored.Waiting[0]; got.Ready || got.Prep != (track.Prepared{}) {
+ t.Errorf("曲目表中没有的条目应恢复为准备中:%#v", got)
+ }
+ if got := restored.Waiting[1]; !got.Ready || got.Prep != prepared || got.Meta != meta {
+ t.Errorf("曲目表中已有结果的条目应恢复为已就绪,元数据取自曲目表:%#v", got)
+ }
+}
+
+func TestSaveRoomRequiresCurrentTerm(t *testing.T) {
+ store, _ := newStore(t)
+ id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
+ stale, snap := takeRoom(t, store, id)
+ takeRoom(t, store, id)
+
+ snap.Info.Name = "旧 owner 改的名字"
+ if err := store.SaveRoom(t.Context(), stale, snap, nil); !errors.Is(err, port.ErrTermExpired) {
+ t.Fatalf("过期任期落盘的错误 = %v,期望 ErrTermExpired", err)
+ }
+ if _, got := takeRoom(t, store, id); got.Info.Name != "大厅" {
+ t.Errorf("过期任期的落盘不应生效:%#v", got.Info)
+ }
+}
+
+func TestFailedSaveKeepsLastSnapshot(t *testing.T) {
+ store, _ := newStore(t)
+ id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
+ term, snap := takeRoom(t, store, id)
+ snap.EntrySeq = 1
+ snap.Waiting = []room.Entry{{ID: 1, Track: netease("1"), AddedAt: t0}}
+ saveRoom(t, store, term, snap)
+
+ // 超过 btree 索引项的上限,插入条目时失败。
+ var long strings.Builder
+ long.WriteString("https://example.com/")
+ for i := 0; long.Len() < 4096; i++ {
+ sum := sha256.Sum256([]byte{byte(i)})
+ long.WriteString(hex.EncodeToString(sum[:]))
+ }
+ failed := snap
+ failed.Info.Name = "不会落盘"
+ failed.EntrySeq = 2
+ failed.Waiting = append(failed.Waiting, room.Entry{ID: 2, Track: track.Track{Source: track.Link, ID: long.String()}, AddedAt: t0})
+ if err := store.SaveRoom(t.Context(), term, failed, nil); err == nil {
+ t.Fatal("超长的曲目 ID 应让落盘失败")
+ }
+
+ _, restored := takeRoom(t, store, id)
+ if !reflect.DeepEqual(restored, snap) {
+ t.Fatalf("落盘失败后应保留上一次的快照:\n得到 %#v\n期望 %#v", restored, snap)
+ }
+}
+
+func TestSaveRoomRecordsPlays(t *testing.T) {
+ store, pool := newStore(t)
+ first := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "一厅"})
+ second := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "二厅"})
+ addTrack(t, store, netease("1"), track.Meta{}, track.Prepared{Duration: time.Minute, Gain: 1})
+ firstTerm, firstSnap := takeRoom(t, store, first)
+ secondTerm, secondSnap := takeRoom(t, store, second)
+
+ song := room.Entry{ID: 1, Track: netease("1"), By: alice, AddedAt: t0}
+ saveRoom(t, store, secondTerm, secondSnap, room.RecordPlay{Entry: song, StartedAt: t0.Add(2 * time.Minute), EndedAt: t0.Add(3 * time.Minute)})
+ saveRoom(t, store, firstTerm, firstSnap,
+ room.RecordPlay{Entry: song, StartedAt: t0, EndedAt: t0.Add(time.Minute)},
+ room.RecordPlay{Entry: room.Entry{ID: 2, Track: netease("2"), Auto: true}, StartedAt: t0.Add(time.Minute), EndedAt: t0.Add(2 * time.Minute)},
+ )
+
+ var lastPlayed time.Time
+ if err := pool.QueryRow(t.Context(), "SELECT last_played_at FROM tracks WHERE track_id = '1'").Scan(&lastPlayed); err != nil {
+ t.Fatal(err)
+ }
+ if !lastPlayed.Equal(t0.Add(2 * time.Minute)) {
+ t.Errorf("last_played_at = %v,期望最近一次开播的时间,不被后写入的较早记录覆盖", lastPlayed)
+ }
+
+ rows, err := pool.Query(t.Context(), `
+ SELECT track_id, coalesce(by_member_id::text, 'NULL'), coalesce(by_name, 'NULL'), coalesce(by_avatar_hash, 'NULL'), auto, started_at, ended_at
+ FROM plays WHERE room_id = $1 ORDER BY started_at`, first)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer rows.Close()
+ type play struct {
+ track, by, name, avatar string
+ auto bool
+ started, ended time.Time
+ }
+ var got []play
+ for rows.Next() {
+ var p play
+ if err := rows.Scan(&p.track, &p.by, &p.name, &p.avatar, &p.auto, &p.started, &p.ended); err != nil {
+ t.Fatal(err)
+ }
+ p.started, p.ended = p.started.UTC(), p.ended.UTC()
+ got = append(got, p)
+ }
+ want := []play{
+ {"1", alice.ID, alice.Name, alice.AvatarHash, false, t0, t0.Add(time.Minute)},
+ {"2", "NULL", "NULL", "NULL", true, t0.Add(time.Minute), t0.Add(2 * time.Minute)},
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("播放记录 = %v,期望 %v", got, want)
+ }
+}
+
+func TestDeleteRoom(t *testing.T) {
+ store, pool := newStore(t)
+ id := createRoom(t, store, port.NewRoom{Kind: room.Ephemeral, Name: "临时"})
+ addTicket(t, pool, id)
+ addTrack(t, store, netease("1"), track.Meta{}, track.Prepared{Duration: time.Minute, Gain: 1})
+ stale, _ := takeRoom(t, store, id)
+ term, snap := takeRoom(t, store, id)
+ song := room.Entry{ID: 1, Track: netease("1"), By: alice, AddedAt: t0}
+ snap.EntrySeq = 1
+ snap.Waiting = []room.Entry{song}
+ saveRoom(t, store, term, snap, room.RecordPlay{Entry: song, StartedAt: t0, EndedAt: t0.Add(time.Minute)})
+
+ if err := store.DeleteRoom(t.Context(), id, stale); !errors.Is(err, port.ErrTermExpired) {
+ t.Fatalf("过期任期删除房间的错误 = %v,期望 ErrTermExpired", err)
+ }
+ if err := store.DeleteRoom(t.Context(), id, term); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := store.TakeRoom(t.Context(), id); !errors.Is(err, port.ErrRoomNotFound) {
+ t.Errorf("删除后接管的错误 = %v,期望 ErrRoomNotFound", err)
+ }
+
+ var entries, tickets, plays, tracks int
+ err := pool.QueryRow(t.Context(), `SELECT
+ (SELECT count(*) FROM room_entries), (SELECT count(*) FROM room_tickets),
+ (SELECT count(*) FROM plays), (SELECT count(*) FROM tracks)`).Scan(&entries, &tickets, &plays, &tracks)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if entries != 0 || tickets != 0 || plays != 0 {
+ t.Errorf("删除房间后应级联删除条目、票据和播放记录:条目 %d、票据 %d、播放记录 %d", entries, tickets, plays)
+ }
+ if tracks != 1 {
+ t.Error("曲目表不随房间删除")
+ }
+}
diff --git a/internal/adapter/out/postgres/tickets_test.go b/internal/adapter/out/postgres/tickets_test.go
new file mode 100644
index 0000000..701bc2b
--- /dev/null
+++ b/internal/adapter/out/postgres/tickets_test.go
@@ -0,0 +1,111 @@
+package postgres
+
+import (
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/domain/room"
+)
+
+func TestTicketHolder(t *testing.T) {
+ store, _ := newStore(t)
+ alice := createMember(t, store, "alice", "甲")
+ id := createRoom(t, store, port.NewRoom{Kind: room.Ephemeral, Name: "临时"})
+
+ if err := store.CreateTicket(t.Context(), port.NewTicket{TokenHash: tokenHash("t1"), RoomID: id, MemberID: alice.ID}); err != nil {
+ t.Fatal(err)
+ }
+ got, err := store.TicketHolder(t.Context(), tokenHash("t1"))
+ if want := (port.TicketHolder{RoomID: id, Member: alice}); err != nil || got != want {
+ t.Errorf("TicketHolder = %#v, %v,期望 %#v", got, err, want)
+ }
+
+ renamed, err := store.UpdateMember(t.Context(), alice.ID, ptr("新名字"), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, _ := store.TicketHolder(t.Context(), tokenHash("t1")); got.Member != renamed {
+ t.Errorf("持有者 = %#v,期望成员的当前资料 %#v", got.Member, renamed)
+ }
+
+ if _, err := store.TicketHolder(t.Context(), tokenHash("t2")); !errors.Is(err, port.ErrTicketNotFound) {
+ t.Errorf("未知票据的错误 = %v,期望 ErrTicketNotFound", err)
+ }
+}
+
+func TestCreateTicketChecksPasswordHash(t *testing.T) {
+ store, _ := newStore(t)
+ alice := createMember(t, store, "alice", "甲")
+ id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅", PasswordHash: "h1"})
+
+ stale := port.NewTicket{TokenHash: tokenHash("t1"), RoomID: id, MemberID: alice.ID}
+ if err := store.CreateTicket(t.Context(), stale); !errors.Is(err, port.ErrPasswordChanged) {
+ t.Errorf("按没有密码签发,错误 = %v,期望 ErrPasswordChanged", err)
+ }
+ current := port.NewTicket{TokenHash: tokenHash("t1"), RoomID: id, MemberID: alice.ID, PasswordHash: "h1"}
+ if err := store.CreateTicket(t.Context(), current); err != nil {
+ t.Fatal(err)
+ }
+
+ missing := port.NewTicket{TokenHash: tokenHash("t2"), RoomID: "01a15778-1911-7a28-8b2e-e08c2a25f388", MemberID: alice.ID}
+ if err := store.CreateTicket(t.Context(), missing); !errors.Is(err, port.ErrRoomNotFound) {
+ t.Errorf("房间不存在时的错误 = %v,期望 ErrRoomNotFound", err)
+ }
+}
+
+func TestCreateTicketWaitsForPasswordChange(t *testing.T) {
+ store, pool := newStore(t)
+ alice := createMember(t, store, "alice", "甲")
+ id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅", PasswordHash: "h1"})
+
+ // 模拟 UpdateRoomAccess 设置密码的事务,先不提交。
+ tx, err := pool.Begin(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = tx.Rollback(t.Context()) }()
+ if _, err := tx.Exec(t.Context(), "UPDATE rooms SET password_hash = 'h2' WHERE id = $1", id); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := tx.Exec(t.Context(), "DELETE FROM room_tickets WHERE room_id = $1", id); err != nil {
+ t.Fatal(err)
+ }
+
+ result := make(chan error, 1)
+ go func() {
+ result <- store.CreateTicket(t.Context(), port.NewTicket{TokenHash: tokenHash("t1"), RoomID: id, MemberID: alice.ID, PasswordHash: "h1"})
+ }()
+ waitForLockWait(t, pool, 1)
+ if err := tx.Commit(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := <-result; !errors.Is(err, port.ErrPasswordChanged) {
+ t.Errorf("改密码提交后,签发的错误 = %v,期望 ErrPasswordChanged", err)
+ }
+ if n := ticketCount(t, pool, id); n != 0 {
+ t.Errorf("改密码之后还剩 %d 张票据", n)
+ }
+}
+
+// 等到至少 n 个查询在等锁。
+func waitForLockWait(t *testing.T, pool *pgxpool.Pool, n int) {
+ t.Helper()
+ for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(10 * time.Millisecond) {
+ var waiting int
+ err := pool.QueryRow(t.Context(),
+ "SELECT count(*) FROM pg_stat_activity WHERE datname = current_database() AND wait_event_type = 'Lock'",
+ ).Scan(&waiting)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if waiting >= n {
+ return
+ }
+ }
+ t.Fatalf("10 秒内没有 %d 个查询在等锁", n)
+}
diff --git a/internal/adapter/out/postgres/tracks_test.go b/internal/adapter/out/postgres/tracks_test.go
new file mode 100644
index 0000000..3a1335c
--- /dev/null
+++ b/internal/adapter/out/postgres/tracks_test.go
@@ -0,0 +1,122 @@
+package postgres
+
+import (
+ "testing"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/domain/room"
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+func TestPreparedTrackRoundTrip(t *testing.T) {
+ store, _ := newStore(t)
+ song := track.Track{Source: track.QQ, ID: "0039MnYb0qxYhV"}
+ if _, ok, err := store.PreparedTrack(t.Context(), song); err != nil || ok {
+ t.Fatalf("还没有准备过的歌 = %v, %v,期望没有记录", ok, err)
+ }
+
+ saved := port.PreparedTrack{
+ Meta: track.Meta{
+ Title: "晴天", Artist: "周杰伦", Album: "叶惠美", CoverURL: "https://y.gtimg.cn/c.jpg",
+ PageURL: "https://y.qq.com/n/ryqq/songDetail/0039MnYb0qxYhV", Duration: 269 * time.Second,
+ },
+ Result: track.Prepared{Duration: 269500 * time.Millisecond, Gain: 0.75, Lyric: "[00:01.00]故事的小黄花"},
+ }
+ if err := store.SavePreparedTrack(t.Context(), song, saved); err != nil {
+ t.Fatal(err)
+ }
+ got, ok, err := store.PreparedTrack(t.Context(), song)
+ if err != nil || !ok {
+ t.Fatalf("保存之后读取 = %v, %v", ok, err)
+ }
+ want := saved
+ want.Meta.Duration = saved.Result.Duration
+ if got != want {
+ t.Errorf("读出 %#v\n期望 %#v:元数据中的时长取准备得到的时长", got, want)
+ }
+}
+
+func TestSavePreparedTrackOverwritesResult(t *testing.T) {
+ store, pool := newStore(t)
+ song := netease("1")
+ addTrack(t, store, song, track.Meta{Title: "旧"}, track.Prepared{Duration: time.Minute, Gain: 1})
+ id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
+ term, snap := takeRoom(t, store, id)
+ saveRoom(t, store, term, snap, room.RecordPlay{Entry: room.Entry{ID: 1, Track: song, By: alice}, StartedAt: t0, EndedAt: t0.Add(time.Minute)})
+
+ again := port.PreparedTrack{Meta: track.Meta{Title: "新"}, Result: track.Prepared{Duration: 2 * time.Minute, Gain: 0.5, Lyric: "词"}}
+ addTrack(t, store, song, again.Meta, again.Result)
+ got, _, err := store.PreparedTrack(t.Context(), song)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Meta.Title != "新" || got.Result != again.Result {
+ t.Errorf("再次准备之后 = %#v,期望以后一次的结果为准", got)
+ }
+ var lastPlayed time.Time
+ if err := pool.QueryRow(t.Context(), "SELECT last_played_at FROM tracks WHERE track_id = '1'").Scan(&lastPlayed); err != nil {
+ t.Fatal(err)
+ }
+ if !lastPlayed.Equal(t0) {
+ t.Errorf("last_played_at = %v,再次准备不应清除最近一次播放的时间", lastPlayed)
+ }
+}
+
+func TestPreparedTrackStoresMissingValuesAsNull(t *testing.T) {
+ store, pool := newStore(t)
+ song := netease("1")
+ saved := port.PreparedTrack{
+ Meta: track.Meta{Title: "无名", PageURL: "https://music.163.com/#/song?id=1", Duration: time.Minute},
+ Result: track.Prepared{Duration: time.Minute, Gain: 1},
+ }
+ if err := store.SavePreparedTrack(t.Context(), song, saved); err != nil {
+ t.Fatal(err)
+ }
+ var missing string
+ err := pool.QueryRow(t.Context(), `SELECT concat_ws(',', CASE WHEN artist IS NULL THEN 'artist' END, CASE WHEN album IS NULL THEN 'album' END,
+ CASE WHEN cover_url IS NULL THEN 'cover_url' END, CASE WHEN cover_key IS NULL THEN 'cover_key' END, CASE WHEN lyric IS NULL THEN 'lyric' END)
+ FROM tracks WHERE track_id = '1'`).Scan(&missing)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if missing != "artist,album,cover_url,cover_key,lyric" {
+ t.Errorf("为 NULL 的列 = %s,期望 artist,album,cover_url,cover_key,lyric", missing)
+ }
+ if got, ok, err := store.PreparedTrack(t.Context(), song); err != nil || !ok || got != saved {
+ t.Errorf("读出 %#v, %v, %v\n期望 %#v", got, ok, err, saved)
+ }
+}
+
+func TestLinkTrackRoundTrip(t *testing.T) {
+ store, pool := newStore(t)
+ link := track.Track{Source: track.Link, ID: "https://example.com/a.mp3"}
+ saved := port.PreparedTrack{
+ Meta: track.Meta{Title: "晴天", Artist: "周杰伦", CoverKey: "link/abc/cover.webp", PageURL: link.ID, Duration: 3 * time.Second},
+ Result: track.Prepared{Duration: 3 * time.Second, Gain: 1},
+ MediaKey: "abc",
+ MediaSize: 12290,
+ }
+ if err := store.SavePreparedTrack(t.Context(), link, saved); err != nil {
+ t.Fatal(err)
+ }
+ if got, ok, err := store.PreparedTrack(t.Context(), link); err != nil || !ok || got != saved {
+ t.Errorf("读出 %#v, %v, %v\n期望 %#v", got, ok, err, saved)
+ }
+ var coverURL, coverKey string
+ if err := pool.QueryRow(t.Context(), "SELECT coalesce(cover_url, 'NULL'), cover_key FROM tracks WHERE track_id = $1", link.ID).Scan(&coverURL, &coverKey); err != nil {
+ t.Fatal(err)
+ }
+ if coverURL != "NULL" || coverKey != "link/abc/cover.webp" {
+ t.Errorf("cover_url = %q,cover_key = %q", coverURL, coverKey)
+ }
+
+ id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
+ term, snap := takeRoom(t, store, id)
+ snap.EntrySeq = 1
+ snap.Waiting = []room.Entry{{ID: 1, Track: link, Meta: track.Meta{Title: link.ID, PageURL: link.ID}, By: alice, AddedAt: t0}}
+ saveRoom(t, store, term, snap)
+ if _, snap = takeRoom(t, store, id); !snap.Waiting[0].Ready || snap.Waiting[0].Meta != saved.Meta {
+ t.Errorf("恢复出的条目 = %+v,期望已就绪,元数据取自曲目表", snap.Waiting[0])
+ }
+}
diff --git a/internal/adapter/out/postgres/youtube_cookies_test.go b/internal/adapter/out/postgres/youtube_cookies_test.go
new file mode 100644
index 0000000..3200a1a
--- /dev/null
+++ b/internal/adapter/out/postgres/youtube_cookies_test.go
@@ -0,0 +1,72 @@
+package postgres
+
+import (
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+)
+
+func TestYouTubeCookies(t *testing.T) {
+ s, _ := newStore(t)
+ ctx := t.Context()
+
+ status, err := s.Status(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if status.Set || !status.UpdatedAt.IsZero() {
+ t.Fatalf("初始状态 = %+v,期望未设置且时间为零值", status)
+ }
+
+ before := time.Now()
+ if err := s.SetYouTubeCookies(ctx, "session=fictional"); err != nil {
+ t.Fatal(err)
+ }
+ first, err := s.Status(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ after := time.Now()
+ if !first.Set {
+ t.Fatal("写入后状态仍为未设置")
+ }
+ if first.UpdatedAt.Before(before.Add(-time.Second)) || first.UpdatedAt.After(after.Add(time.Second)) {
+ t.Fatalf("更新时间 = %s,期望接近当前时间 [%s, %s]", first.UpdatedAt, before, after)
+ }
+
+ time.Sleep(2 * time.Millisecond)
+ if err := s.SetCookies(ctx, "session=replacement"); err != nil {
+ t.Fatal(err)
+ }
+ second, err := s.Status(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !second.Set || !second.UpdatedAt.After(first.UpdatedAt) {
+ t.Fatalf("覆盖后状态 = %+v,上次更新时间 = %s,期望已设置且时间更新", second, first.UpdatedAt)
+ }
+ cookies, err := s.YouTubeCookies(ctx)
+ if err != nil || cookies != "session=replacement" {
+ t.Fatalf("读取 cookies = %q, %v,期望最近一次写入的内容", cookies, err)
+ }
+
+ if err := s.SetYouTubeCookies(ctx, ""); err != nil {
+ t.Fatal(err)
+ }
+ status, err = s.Status(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if status.Set || !status.UpdatedAt.IsZero() {
+ t.Fatalf("删除后状态 = %+v,期望未设置且时间为零值", status)
+ }
+ cookies, err = s.YouTubeCookies(ctx)
+ if err != nil || cookies != "" {
+ t.Fatalf("删除后读取 cookies = %q, %v,期望空字符串", cookies, err)
+ }
+ if _, err := s.q.Credential(ctx, "youtube"); !errors.Is(err, pgx.ErrNoRows) {
+ t.Fatalf("空字符串写入后凭据行仍存在,查询错误 = %v", err)
+ }
+}
diff --git a/internal/adapter/out/qq/account_test.go b/internal/adapter/out/qq/account_test.go
new file mode 100644
index 0000000..0db8d62
--- /dev/null
+++ b/internal/adapter/out/qq/account_test.go
@@ -0,0 +1,63 @@
+package qq
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "reflect"
+ "testing"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+)
+
+func accountTestClient(cookie string, loadErr error) *Client {
+ load := func(context.Context) (string, error) { return cookie, loadErr }
+ return New(&http.Client{}, load, nil, (&observed{}).record)
+}
+
+func TestCurrentAccount(t *testing.T) {
+ tests := []struct {
+ name string
+ cookie string
+ wantID string
+ }{
+ {"QQ uin 去前缀和前导零", "uin=o000123", "123"},
+ {"微信 uin", "wxuin=000101", "101"},
+ {"ptui_loginuin", "ptui_loginuin=o000102", "102"},
+ {"luin", "luin=o000103", "103"},
+ {"pt2gguin", "pt2gguin=o000104", "104"},
+ {"superuin", "superuin=o000105", "105"},
+ {"p_uin", "p_uin=o000106", "106"},
+ {"musicid", "musicid=107", "107"},
+ {"userid", "userid=108", "108"},
+ {"user_id", "user_id=109", "109"},
+ {"微信 uin 优先", "uin=000123; wxuin=000456", "456"},
+ {"没有账号标识", "qqmusic_key=fictitious", ""},
+ {"账号标识为零", "uin=o0000", ""},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := accountTestClient(tc.cookie, nil).CurrentAccount(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if tc.wantID == "" {
+ if got != nil {
+ t.Fatalf("没有账号标识时 = %+v,期望 nil", got)
+ }
+ return
+ }
+ want := &port.SourceAccount{ID: tc.wantID}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("账号资料 = %+v,期望 %+v", got, want)
+ }
+ })
+ }
+}
+
+func TestCurrentAccountCookieError(t *testing.T) {
+ want := errors.New("读取失败")
+ if _, err := accountTestClient("", want).CurrentAccount(t.Context()); !errors.Is(err, want) {
+ t.Fatalf("读取凭据的错误 = %v,期望 %v", err, want)
+ }
+}
diff --git a/internal/adapter/out/qq/login_test.go b/internal/adapter/out/qq/login_test.go
new file mode 100644
index 0000000..de205b0
--- /dev/null
+++ b/internal/adapter/out/qq/login_test.go
@@ -0,0 +1,1093 @@
+package qq
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "slices"
+ "sort"
+ "strconv"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+)
+
+const (
+ testQRSig = "a"
+ testLoginSig = "login-sig"
+ testMusicKey = "test"
+)
+
+var testQRPNG = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}
+
+func serveQQQRLogin(t *testing.T, routes map[string]http.HandlerFunc, store func(context.Context, string) error) (func() *Client, *observed) {
+ t.Helper()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ checkClientIP(t, r)
+ handle, ok := routes[r.URL.Path]
+ if !ok {
+ t.Errorf("意外的请求 %s %s", r.Method, r.URL)
+ http.NotFound(w, r)
+ return
+ }
+ handle(w, r)
+ }))
+ t.Cleanup(server.Close)
+ target, err := url.Parse(server.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ obs := &observed{}
+ return func() *Client {
+ return New(
+ &http.Client{Transport: redirect{target}},
+ func(context.Context) (string, error) { return testCookie, nil },
+ store,
+ obs.record,
+ )
+ }, obs
+}
+
+func checkQQLoginValues(t *testing.T, got url.Values, want url.Values) {
+ t.Helper()
+ for key, values := range want {
+ if !slices.Equal(got[key], values) {
+ t.Errorf("参数 %s = %q,期望 %q", key, got[key], values)
+ }
+ }
+}
+
+func addQQQRCreateRoutes(t *testing.T, routes map[string]http.HandlerFunc) {
+ t.Helper()
+ addQQQRCreateRoutesAtPath(t, routes, "/")
+}
+
+func addQQQRCreateRoutesAtPath(t *testing.T, routes map[string]http.HandlerFunc, cookiePath string) {
+ t.Helper()
+ routes["/cgi-bin/xlogin"] = func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ t.Errorf("预热请求方法 = %s", r.Method)
+ }
+ checkQQLoginValues(t, r.URL.Query(), url.Values{
+ "appid": {"716027609"},
+ "daid": {"383"},
+ "style": {"33"},
+ "login_text": {"登录"},
+ "hide_title_bar": {"1"},
+ "hide_border": {"1"},
+ "target": {"self"},
+ "s_url": {"https://graph.qq.com/oauth2.0/login_jump"},
+ "pt_3rd_aid": {"100497308"},
+ "pt_feedback_link": {"https://support.qq.com/products/77942?customInfo=.appid100497308"},
+ "theme": {"2"},
+ "verify_theme": {""},
+ })
+ http.SetCookie(w, &http.Cookie{Name: "pt_login_sig", Value: testLoginSig, Path: "/"})
+ http.SetCookie(w, &http.Cookie{Name: "prewarm", Value: "temp", Path: "/"})
+ }
+ routes["/ssl/ptqrshow"] = func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ t.Errorf("二维码请求方法 = %s", r.Method)
+ }
+ checkQQLoginValues(t, r.URL.Query(), url.Values{
+ "appid": {"716027609"},
+ "e": {"2"},
+ "l": {"M"},
+ "s": {"3"},
+ "d": {"72"},
+ "v": {"4"},
+ "daid": {"383"},
+ "pt_3rd_aid": {"100497308"},
+ "u1": {"https://graph.qq.com/oauth2.0/login_jump"},
+ })
+ if r.URL.Query().Get("t") == "" {
+ t.Error("二维码请求缺少时间戳")
+ }
+ for name, want := range map[string]string{"pt_login_sig": testLoginSig, "prewarm": "temp"} {
+ cookie, err := r.Cookie(name)
+ if err != nil || cookie.Value != want {
+ t.Errorf("二维码请求的 Cookie %s = %#v, %v,期望 %q", name, cookie, err, want)
+ }
+ }
+ http.SetCookie(w, &http.Cookie{Name: "qrsig", Value: testQRSig, Path: cookiePath})
+ http.SetCookie(w, &http.Cookie{Name: "qr_temp", Value: "qr-temp", Path: cookiePath})
+ _, _ = w.Write(testQRPNG)
+ }
+}
+
+func addQQQRPollRoute(t *testing.T, routes map[string]http.HandlerFunc, response string) {
+ t.Helper()
+ routes["/ssl/ptqrlogin"] = func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ t.Errorf("轮询请求方法 = %s", r.Method)
+ }
+ checkQQLoginValues(t, r.URL.Query(), url.Values{
+ "u1": {"https://graph.qq.com/oauth2.0/login_jump"},
+ "ptqrtoken": {"97"},
+ "ptredirect": {"0"},
+ "h": {"1"},
+ "t": {"1"},
+ "g": {"1"},
+ "from_ui": {"1"},
+ "ptlang": {"2052"},
+ "js_ver": {"26071711"},
+ "js_type": {"1"},
+ "login_sig": {testLoginSig},
+ "pt_uistyle": {"40"},
+ "aid": {"716027609"},
+ "daid": {"383"},
+ "pt_3rd_aid": {"100497308"},
+ "pt_js_version": {"c1987b96"},
+ })
+ action := r.URL.Query().Get("action")
+ milliseconds, err := strconv.ParseInt(strings.TrimPrefix(action, "0-0-"), 10, 64)
+ if !strings.HasPrefix(action, "0-0-") || err != nil || milliseconds <= 0 {
+ t.Errorf("轮询 action = %q", action)
+ }
+ for name, want := range map[string]string{
+ "qrsig": testQRSig, "pt_login_sig": testLoginSig, "prewarm": "temp", "qr_temp": "qr-temp",
+ } {
+ cookie, err := r.Cookie(name)
+ if err != nil || cookie.Value != want {
+ t.Errorf("轮询请求的 Cookie %s = %#v, %v,期望 %q", name, cookie, err, want)
+ }
+ }
+ body(response)(w, r)
+ }
+}
+
+func createTestQQQRSession(t *testing.T, newClient func() *Client) port.QRLoginSession {
+ t.Helper()
+ session, err := newClient().CreateQRLogin(t.Context(), port.QQLoginQQ)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return session
+}
+
+func TestCreateQQQRLogin(t *testing.T) {
+ saved := "未保存"
+ routes := map[string]http.HandlerFunc{}
+ addQQQRCreateRoutes(t, routes)
+ newClient, obs := serveQQQRLogin(t, routes, func(_ context.Context, value string) error {
+ saved = value
+ return nil
+ })
+
+ before := time.Now().Add(2 * time.Minute)
+ session, err := newClient().CreateQRLogin(t.Context(), port.QQLoginQQ)
+ after := time.Now().Add(2 * time.Minute)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := "data:image/png;base64," + base64.StdEncoding.EncodeToString(testQRPNG); session.QRImage != want {
+ t.Errorf("二维码图片 = %q,期望 %q", session.QRImage, want)
+ }
+ if session.LoginURL != "" {
+ t.Errorf("QQ 账号扫码的 LoginURL = %q,期望空字符串", session.LoginURL)
+ }
+ if session.ExpiresAt.Before(before) || session.ExpiresAt.After(after) {
+ t.Errorf("登录到期时间 = %v,期望约为 2 分钟后", session.ExpiresAt)
+ }
+ decoded, err := base64.RawURLEncoding.DecodeString(session.Key)
+ if err != nil {
+ t.Fatalf("解码登录 key:%v", err)
+ }
+ var fields map[string]json.RawMessage
+ if err := json.Unmarshal(decoded, &fields); err != nil {
+ t.Fatal(err)
+ }
+ if len(fields) != 3 || fields["qrsig"] == nil || fields["login_sig"] == nil || fields["cookies"] == nil {
+ t.Errorf("登录 key 的字段 = %v", fields)
+ }
+ var state struct {
+ QRSig string `json:"qrsig"`
+ LoginSig string `json:"login_sig"`
+ Cookies map[string]string `json:"cookies"`
+ }
+ if err := json.Unmarshal(decoded, &state); err != nil {
+ t.Fatal(err)
+ }
+ if state.QRSig != testQRSig || state.LoginSig != testLoginSig {
+ t.Errorf("登录状态 = %#v", state)
+ }
+ wantCookies := map[string]string{"qrsig": testQRSig, "pt_login_sig": testLoginSig, "prewarm": "temp", "qr_temp": "qr-temp"}
+ if len(state.Cookies) != len(wantCookies) {
+ t.Errorf("临时 Cookie = %#v", state.Cookies)
+ }
+ for name, want := range wantCookies {
+ if state.Cookies[name] != want {
+ t.Errorf("临时 Cookie %s = %q,期望 %q", name, state.Cookies[name], want)
+ }
+ }
+ if saved != "未保存" {
+ t.Errorf("创建登录保存了 Cookie %q", saved)
+ }
+ obs.check(t, "qq login_create")
+}
+
+func TestQQQRLoginRetainsSSLPathCookiesAcrossClients(t *testing.T) {
+ saved := "未保存"
+ routes := map[string]http.HandlerFunc{}
+ addQQQRCreateRoutesAtPath(t, routes, "/ssl")
+ addQQQRPollRoute(t, routes, `ptuiCB('66','0','','0','等待扫码','')`)
+ newClient, obs := serveQQQRLogin(t, routes, func(_ context.Context, value string) error {
+ saved = value
+ return nil
+ })
+ session := createTestQQQRSession(t, newClient)
+ got, err := newClient().CheckQRLogin(t.Context(), port.QQLoginQQ, session.Key)
+ want := port.QRLoginResult{Status: port.QRLoginWaiting, Message: "等待扫码"}
+ if err != nil || got != want {
+ t.Errorf("Path=/ssl 的扫码结果 = %#v, %v,期望 %#v", got, err, want)
+ }
+ if saved != "未保存" {
+ t.Errorf("等待扫码时保存了 Cookie %q", saved)
+ }
+ obs.check(t, "qq login_create", "qq login_check")
+}
+
+func TestQQQRLoginStatusesAcrossClients(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ response string
+ want port.QRLoginResult
+ }{
+ {"过期", `ptuiCB('65','0','','0','二维码已过期','')`, port.QRLoginResult{Status: port.QRLoginExpired, Message: "二维码已过期"}},
+ {"等待扫码", `ptuiCB('66','0','','0','等待扫码','')`, port.QRLoginResult{Status: port.QRLoginWaiting, Message: "等待扫码"}},
+ {"已扫码", `ptuiCB('67','0','','0','已扫码','')`, port.QRLoginResult{Status: port.QRLoginScanned, Message: "已扫码"}},
+ {"其他状态", `ptuiCB('68','0','','0','登录失败','')`, port.QRLoginResult{Status: port.QRLoginFailed, Message: "登录失败"}},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ saved := "未保存"
+ routes := map[string]http.HandlerFunc{}
+ addQQQRCreateRoutes(t, routes)
+ addQQQRPollRoute(t, routes, tt.response)
+ newClient, obs := serveQQQRLogin(t, routes, func(_ context.Context, value string) error {
+ saved = value
+ return nil
+ })
+ session := createTestQQQRSession(t, newClient)
+ got, err := newClient().CheckQRLogin(t.Context(), port.QQLoginQQ, session.Key)
+ if err != nil || got != tt.want {
+ t.Errorf("跨 Client 登录结果 = %#v, %v,期望 %#v", got, err, tt.want)
+ }
+ if saved != "未保存" {
+ t.Errorf("非成功状态保存了 Cookie %q", saved)
+ }
+ obs.check(t, "qq login_create", "qq login_check")
+ })
+ }
+}
+
+func addQQQOAuthRoutes(t *testing.T, routes map[string]http.HandlerFunc, musicResponse string) {
+ t.Helper()
+ addQQQRPollRoute(t, routes, `ptuiCB('0','0','https://graph.qq.com/oauth2.0/login_jump','0','登录成功','')`)
+ routes["/oauth2.0/login_jump"] = func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "https://graph.qq.com/oauth2.0/login_jump_done", http.StatusFound)
+ }
+ routes["/oauth2.0/login_jump_done"] = func(w http.ResponseWriter, r *http.Request) {
+ http.SetCookie(w, &http.Cookie{Name: "p_skey", Value: "a", Domain: ".qq.com", Path: "/"})
+ body("ok")(w, r)
+ }
+ routes["/oauth2.0/authorize"] = func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ t.Errorf("授权请求方法 = %s", r.Method)
+ }
+ if err := r.ParseForm(); err != nil {
+ t.Error(err)
+ }
+ checkQQLoginValues(t, r.PostForm, url.Values{
+ "response_type": {"code"},
+ "client_id": {"100497308"},
+ "redirect_uri": {"https://y.qq.com/portal/wx_redirect.html?login_type=1&surl=https%3A%2F%2Fy.qq.com%2F"},
+ "scope": {"get_user_info,get_app_friends"},
+ "state": {"state"},
+ "switch": {""},
+ "from_ptlogin": {"1"},
+ "src": {"1"},
+ "update_auth": {"1"},
+ "openapi": {"1010_1030"},
+ "g_tk": {"177670"},
+ })
+ if milliseconds, err := strconv.ParseInt(r.PostForm.Get("auth_time"), 10, 64); err != nil || milliseconds <= 0 {
+ t.Errorf("授权时间 = %q", r.PostForm.Get("auth_time"))
+ }
+ if r.PostForm.Get("ui") == "" {
+ t.Error("授权请求缺少 ui")
+ }
+ if cookie, err := r.Cookie("p_skey"); err != nil || cookie.Value != "a" {
+ t.Errorf("授权请求的 p_skey = %#v, %v", cookie, err)
+ }
+ http.SetCookie(w, &http.Cookie{Name: "oauth_temp", Value: "keep", Domain: ".qq.com", Path: "/"})
+ w.Header().Set("Location", "https://y.qq.com/portal/wx_redirect.html?login_type=1&surl=https%3A%2F%2Fy.qq.com%2F&code=fictitious-code")
+ w.WriteHeader(http.StatusFound)
+ }
+ routes["/portal/wx_redirect.html"] = func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Query().Get("code") != "fictitious-code" {
+ t.Errorf("授权回调 code = %q", r.URL.Query().Get("code"))
+ }
+ http.SetCookie(w, &http.Cookie{Name: "redirect_extra", Value: "keep", Domain: ".qq.com", Path: "/"})
+ body("ok")(w, r)
+ }
+ routes["/cgi-bin/musicu.fcg"] = func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "application/json" {
+ t.Errorf("音乐登录请求 = %s, Content-Type = %q", r.Method, r.Header.Get("Content-Type"))
+ }
+ var payload struct {
+ Comm struct {
+ GTK int `json:"g_tk"`
+ Platform string `json:"platform"`
+ CT int `json:"ct"`
+ CV int `json:"cv"`
+ } `json:"comm"`
+ Req struct {
+ Module string `json:"module"`
+ Method string `json:"method"`
+ Param struct {
+ Code string `json:"code"`
+ } `json:"param"`
+ } `json:"req"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ t.Error(err)
+ }
+ if payload.Comm.GTK != 5381 || payload.Comm.Platform != "yqq" || payload.Comm.CT != 24 || payload.Comm.CV != 0 ||
+ payload.Req.Module != "QQConnectLogin.LoginServer" || payload.Req.Method != "QQLogin" || payload.Req.Param.Code != "fictitious-code" {
+ t.Errorf("音乐登录请求体 = %#v", payload)
+ }
+ if cookie, err := r.Cookie("p_skey"); err != nil || cookie.Value != "a" {
+ t.Errorf("音乐登录请求的 p_skey = %#v, %v", cookie, err)
+ }
+ if _, err := r.Cookie("qrsig"); err == nil {
+ t.Error("音乐登录请求携带了 xui 的临时 qrsig")
+ }
+ http.SetCookie(w, &http.Cookie{Name: "session", Value: "keep", Domain: ".qq.com", Path: "/"})
+ body(musicResponse)(w, r)
+ }
+}
+
+func parseSavedQQCookies(t *testing.T, saved string) map[string]string {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodGet, "https://y.qq.com/", nil)
+ req.Header.Set("Cookie", saved)
+ cookies := req.Cookies()
+ names := make([]string, len(cookies))
+ values := make(map[string]string, len(cookies))
+ for i, cookie := range cookies {
+ names[i] = cookie.Name
+ values[cookie.Name] = cookie.Value
+ }
+ if !sort.StringsAreSorted(names) {
+ t.Errorf("保存的 Cookie 未按名称排序:%q", saved)
+ }
+ return values
+}
+
+func requireQQCookies(t *testing.T, saved string, want map[string]string) {
+ t.Helper()
+ got := parseSavedQQCookies(t, saved)
+ for name, value := range want {
+ if got[name] != value {
+ t.Errorf("保存的 Cookie %s = %q,期望 %q;全部 Cookie = %#v", name, got[name], value, got)
+ }
+ }
+}
+
+func TestQQQRLoginCompletesOAuthAndSavesSortedCookies(t *testing.T) {
+ var saved string
+ routes := map[string]http.HandlerFunc{}
+ addQQQRCreateRoutes(t, routes)
+ addQQQOAuthRoutes(t, routes, `{"code":0,"req":{"code":0,"data":{"musickey":"test","musicid":"o1","json_extra":"keep","empty":""}}}`)
+ newClient, obs := serveQQQRLogin(t, routes, func(_ context.Context, value string) error {
+ saved = value
+ return nil
+ })
+ session := createTestQQQRSession(t, newClient)
+ client := newClient()
+ result, err := client.CheckQRLogin(t.Context(), port.QQLoginQQ, session.Key)
+ want := port.QRLoginResult{Status: port.QRLoginSuccess, Message: "登录成功"}
+ if err != nil || result != want {
+ t.Fatalf("QQ 登录 = %#v, %v", result, err)
+ }
+ requireQQCookies(t, saved, map[string]string{
+ "qm_keyst": testMusicKey, "qqmusic_key": testMusicKey, "musickey": testMusicKey, "uin": "o1",
+ "session": "keep", "redirect_extra": "keep", "oauth_temp": "keep",
+ })
+ if strings.Contains(saved, "json_extra=") || strings.Contains(saved, "empty=") {
+ t.Errorf("保存了未知或空 Cookie:%q", saved)
+ }
+ if cached, err := client.cookie.get(t.Context()); err != nil || cached != saved {
+ t.Errorf("本地缓存的 Cookie = %q, %v,期望 %q", cached, err, saved)
+ }
+ obs.check(t, "qq login_create", "qq login_check")
+}
+
+func TestQQQRLoginRejectsInvalidJSONMusicKeys(t *testing.T) {
+ for _, name := range []string{"qm_keyst", "qqmusic_key", "musickey", "music_key", "strMusicKey"} {
+ t.Run(name, func(t *testing.T) {
+ saved := "未保存"
+ routes := map[string]http.HandlerFunc{}
+ addQQQRCreateRoutes(t, routes)
+ response := `{"code":0,"req":{"code":0,"data":{"` + name + `":"bad;injected=value","musicid":"o1"}}}`
+ addQQQOAuthRoutes(t, routes, response)
+ newClient, obs := serveQQQRLogin(t, routes, func(_ context.Context, value string) error {
+ saved = value
+ return nil
+ })
+ session := createTestQQQRSession(t, newClient)
+ result, err := newClient().CheckQRLogin(t.Context(), port.QQLoginQQ, session.Key)
+ if err == nil || result.Status == port.QRLoginSuccess {
+ t.Errorf("非法音乐 key 的登录结果 = %#v, %v,期望 Go 错误且不返回成功", result, err)
+ }
+ if saved != "未保存" {
+ t.Errorf("非法音乐 key 时保存了 Cookie %q", saved)
+ }
+ obs.check(t, "qq login_create", "qq login_check 失败")
+ })
+ }
+}
+
+func TestQQQRLoginOmitsInvalidOptionalJSONCredentials(t *testing.T) {
+ var saved string
+ routes := map[string]http.HandlerFunc{}
+ addQQQRCreateRoutes(t, routes)
+ addQQQOAuthRoutes(t, routes, `{"code":0,"req":{"code":0,"data":{"musickey":"test","musicid":"o1","openid":"bad;injected=value"}}}`)
+ newClient, obs := serveQQQRLogin(t, routes, func(_ context.Context, value string) error {
+ saved = value
+ return nil
+ })
+ session := createTestQQQRSession(t, newClient)
+ result, err := newClient().CheckQRLogin(t.Context(), port.QQLoginQQ, session.Key)
+ want := port.QRLoginResult{Status: port.QRLoginSuccess, Message: "登录成功"}
+ if err != nil || result != want {
+ t.Fatalf("带非法可选凭据的登录结果 = %#v, %v,期望成功并省略该字段", result, err)
+ }
+ requireQQCookies(t, saved, map[string]string{"musickey": testMusicKey, "uin": "o1"})
+ if strings.Contains(saved, "openid=") || strings.Contains(saved, "injected") {
+ t.Errorf("保存了非法可选凭据或畸形 Cookie 头:%q", saved)
+ }
+ obs.check(t, "qq login_create", "qq login_check")
+}
+
+func TestQQQRLoginMissingMusicKeyFailsWithoutSaving(t *testing.T) {
+ saved := "未保存"
+ routes := map[string]http.HandlerFunc{}
+ addQQQRCreateRoutes(t, routes)
+ addQQQOAuthRoutes(t, routes, `{"code":0,"req":{"code":0,"data":{"musicid":"o1","json_extra":"keep"}}}`)
+ newClient, obs := serveQQQRLogin(t, routes, func(_ context.Context, value string) error {
+ saved = value
+ return nil
+ })
+ session := createTestQQQRSession(t, newClient)
+ result, err := newClient().CheckQRLogin(t.Context(), port.QQLoginQQ, session.Key)
+ if err != nil || result.Status != port.QRLoginFailed {
+ t.Errorf("缺少音乐 key 的登录结果 = %#v, %v", result, err)
+ }
+ if saved != "未保存" {
+ t.Errorf("缺少音乐 key 时保存了 %q", saved)
+ }
+ obs.check(t, "qq login_create", "qq login_check")
+}
+
+func TestQQQRLoginRejectsInvalidMusicResponseCodes(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ response string
+ }{
+ {"顶层 code 缺失", `{"req":{"code":0,"data":{"musickey":"test","uin":"o1"}}}`},
+ {"顶层 code 为 null", `{"code":null,"req":{"code":0,"data":{"musickey":"test","uin":"o1"}}}`},
+ {"req.code 缺失", `{"code":0,"req":{"data":{"musickey":"test","uin":"o1"}}}`},
+ {"req.code 为 null", `{"code":0,"req":{"code":null,"data":{"musickey":"test","uin":"o1"}}}`},
+ {"顶层 code 非零", `{"code":1,"req":{"code":0,"data":{"musickey":"test","uin":"o1"}}}`},
+ {"req.code 非零", `{"code":0,"req":{"code":1,"data":{"musickey":"test","uin":"o1"}}}`},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ saved := "未保存"
+ routes := map[string]http.HandlerFunc{}
+ addQQQRCreateRoutes(t, routes)
+ addQQQOAuthRoutes(t, routes, tt.response)
+ newClient, obs := serveQQQRLogin(t, routes, func(_ context.Context, value string) error {
+ saved = value
+ return nil
+ })
+ session := createTestQQQRSession(t, newClient)
+ result, err := newClient().CheckQRLogin(t.Context(), port.QQLoginQQ, session.Key)
+ if err == nil || result.Status == port.QRLoginSuccess {
+ t.Errorf("无效音乐响应码的登录结果 = %#v, %v,期望错误", result, err)
+ }
+ if saved != "未保存" {
+ t.Errorf("无效音乐响应码时保存了 %q", saved)
+ }
+ obs.check(t, "qq login_create", "qq login_check 失败")
+ })
+ }
+}
+
+func TestQQQRLoginSaveFailureIsObserved(t *testing.T) {
+ saveErr := errors.New("保存测试 Cookie 失败")
+ routes := map[string]http.HandlerFunc{}
+ addQQQRCreateRoutes(t, routes)
+ addQQQOAuthRoutes(t, routes, `{"code":0,"req":{"code":0,"data":{"qm_keyst":"test","uin":"o1"}}}`)
+ newClient, obs := serveQQQRLogin(t, routes, func(context.Context, string) error { return saveErr })
+ session := createTestQQQRSession(t, newClient)
+ client := newClient()
+ if _, err := client.cookie.get(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ result, err := client.CheckQRLogin(t.Context(), port.QQLoginQQ, session.Key)
+ if !errors.Is(err, saveErr) || result.Status == port.QRLoginSuccess {
+ t.Errorf("保存失败的登录结果 = %#v, %v", result, err)
+ }
+ if cached, err := client.cookie.get(t.Context()); err != nil || cached != testCookie {
+ t.Errorf("保存失败后的本地 Cookie = %q, %v", cached, err)
+ }
+ obs.check(t, "qq login_create", "qq login_check 失败")
+}
+
+func TestQQQRLoginRejectsUnsupportedTypeAndEmptyKey(t *testing.T) {
+ newClient, obs := serveQQQRLogin(t, nil, func(context.Context, string) error { return nil })
+ client := newClient()
+ loginType := port.QQLoginType("unknown")
+ if session, err := client.CreateQRLogin(t.Context(), loginType); err == nil {
+ t.Errorf("类型 %q 创建的会话 = %#v,期望错误", loginType, session)
+ }
+ if result, err := client.CheckQRLogin(t.Context(), loginType, "key"); err == nil {
+ t.Errorf("类型 %q 的轮询结果 = %#v,期望错误", loginType, result)
+ }
+ for _, loginType := range []port.QQLoginType{port.QQLoginQQ, port.QQLoginWeChat} {
+ if result, err := client.CheckQRLogin(t.Context(), loginType, ""); err == nil {
+ t.Errorf("类型 %q 空 key 的轮询结果 = %#v,期望错误", loginType, result)
+ }
+ }
+ obs.check(t)
+}
+
+const (
+ testWeChatUUID = "fictitious-wechat-uuid"
+ testWeChatCode = "fictitious-wechat-code"
+ testWeChatRedirectURI = "https://y.qq.com/portal/wx_redirect.html?login_type=2&surl=https://y.qq.com/"
+)
+
+func addWeChatQRCreateRoute(t *testing.T, routes map[string]http.HandlerFunc, html string) {
+ t.Helper()
+ routes["/connect/qrconnect"] = func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ t.Errorf("微信创建请求方法 = %s", r.Method)
+ }
+ checkQQLoginValues(t, r.URL.Query(), url.Values{
+ "appid": {"wx48db31d50e334801"},
+ "redirect_uri": {testWeChatRedirectURI},
+ "response_type": {"code"},
+ "scope": {"snsapi_login"},
+ "href": {"https://y.qq.com/mediastyle/music_v17/src/css/popup_wechat.css#wechat_redirect"},
+ })
+ if r.URL.Query().Get("state") == "" {
+ t.Error("微信创建请求缺少 state")
+ }
+ body(html)(w, r)
+ }
+}
+
+func addWeChatQRPollRoute(t *testing.T, routes map[string]http.HandlerFunc, response string) {
+ t.Helper()
+ routes["/connect/l/qrconnect"] = func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ t.Errorf("微信轮询请求方法 = %s", r.Method)
+ }
+ checkQQLoginValues(t, r.URL.Query(), url.Values{"uuid": {testWeChatUUID}})
+ if len(r.URL.Query()) != 2 {
+ t.Errorf("微信轮询参数 = %v,期望只有 uuid 和时间戳", r.URL.Query())
+ }
+ milliseconds, err := strconv.ParseInt(r.URL.Query().Get("_"), 10, 64)
+ if err != nil || milliseconds <= 0 {
+ t.Errorf("微信轮询时间戳 = %q", r.URL.Query().Get("_"))
+ }
+ body(response)(w, r)
+ }
+}
+
+func addWeChatQRMusicRoute(t *testing.T, routes map[string]http.HandlerFunc, response string) {
+ t.Helper()
+ routes["/cgi-bin/musicu.fcg"] = func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ t.Errorf("微信音乐登录请求方法 = %s", r.Method)
+ }
+ if got := r.Header.Get("Content-Type"); got != "application/x-www-form-urlencoded" {
+ t.Errorf("微信音乐登录 Content-Type = %q", got)
+ }
+ if got := r.Header.Get("Referer"); got != testWeChatRedirectURI {
+ t.Errorf("微信音乐登录 Referer = %q", got)
+ }
+ if got := r.Header.Get("Origin"); got != "https://y.qq.com" {
+ t.Errorf("微信音乐登录 Origin = %q", got)
+ }
+ if got := r.Header.Get("Cookie"); got != "login_type=2" {
+ t.Errorf("微信音乐登录 Cookie = %q", got)
+ }
+ var payload struct {
+ Comm struct {
+ TMEAppID string `json:"tmeAppID"`
+ TMELoginType string `json:"tmeLoginType"`
+ GTK int `json:"g_tk"`
+ Platform string `json:"platform"`
+ CT int `json:"ct"`
+ CV int `json:"cv"`
+ } `json:"comm"`
+ Req struct {
+ Module string `json:"module"`
+ Method string `json:"method"`
+ Param struct {
+ AppID string `json:"strAppid"`
+ Code string `json:"code"`
+ } `json:"param"`
+ } `json:"req"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ t.Error(err)
+ }
+ if payload.Comm.TMEAppID != "qqmusic" || payload.Comm.TMELoginType != "1" ||
+ payload.Comm.GTK != 5381 || payload.Comm.Platform != "yqq" || payload.Comm.CT != 24 || payload.Comm.CV != 0 ||
+ payload.Req.Module != "music.login.LoginServer" || payload.Req.Method != "Login" ||
+ payload.Req.Param.AppID != "wx48db31d50e334801" || payload.Req.Param.Code != testWeChatCode {
+ t.Errorf("微信音乐登录请求体 = %#v", payload)
+ }
+ http.SetCookie(w, &http.Cookie{Name: "session", Value: "keep", Domain: ".qq.com", Path: "/"})
+ http.SetCookie(w, &http.Cookie{Name: "empty", Value: "", Domain: ".qq.com", Path: "/"})
+ body(response)(w, r)
+ }
+}
+
+func createTestWeChatQRSession(t *testing.T, newClient func() *Client) port.QRLoginSession {
+ t.Helper()
+ session, err := newClient().CreateQRLogin(t.Context(), port.QQLoginWeChat)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return session
+}
+
+func TestCreateWeChatQRLogin(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ html string
+ }{
+ {"轮询地址", ``},
+ {"QRLogin 字段", ``},
+ {"二维码地址", `
`},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ saved := "未保存"
+ routes := map[string]http.HandlerFunc{}
+ addWeChatQRCreateRoute(t, routes, tt.html)
+ newClient, obs := serveQQQRLogin(t, routes, func(_ context.Context, value string) error {
+ saved = value
+ return nil
+ })
+
+ before := time.Now().Add(5 * time.Minute)
+ session, err := newClient().CreateQRLogin(t.Context(), port.QQLoginWeChat)
+ after := time.Now().Add(5 * time.Minute)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := "https://open.weixin.qq.com/connect/qrcode/" + testWeChatUUID; session.QRImage != want {
+ t.Errorf("微信二维码图片 = %q,期望 %q", session.QRImage, want)
+ }
+ if session.ExpiresAt.Before(before) || session.ExpiresAt.After(after) {
+ t.Errorf("微信登录到期时间 = %v,期望约为 5 分钟后", session.ExpiresAt)
+ }
+ loginURL, err := url.Parse(session.LoginURL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loginURL.Scheme != "https" || loginURL.Host != "open.weixin.qq.com" || loginURL.Path != "/connect/qrconnect" {
+ t.Errorf("微信扫码页面 URL = %q", session.LoginURL)
+ }
+ decoded, err := base64.RawURLEncoding.DecodeString(session.Key)
+ if err != nil {
+ t.Fatalf("解码微信登录 key:%v", err)
+ }
+ var fields map[string]json.RawMessage
+ if err := json.Unmarshal(decoded, &fields); err != nil {
+ t.Fatal(err)
+ }
+ if len(fields) != 2 || fields["uuid"] == nil || fields["state"] == nil {
+ t.Errorf("微信登录 key 的字段 = %v", fields)
+ }
+ var state struct {
+ UUID string `json:"uuid"`
+ State string `json:"state"`
+ }
+ if err := json.Unmarshal(decoded, &state); err != nil {
+ t.Fatal(err)
+ }
+ if state.UUID != testWeChatUUID || state.State == "" || state.State != loginURL.Query().Get("state") {
+ t.Errorf("微信登录状态 = %#v,页面 state = %q", state, loginURL.Query().Get("state"))
+ }
+ if saved != "未保存" {
+ t.Errorf("创建微信登录保存了 Cookie %q", saved)
+ }
+ obs.check(t, "qq login_create")
+ })
+ }
+}
+
+func TestWeChatQRLoginStatusesAcrossClients(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ response string
+ want port.QRLoginResult
+ }{
+ {"过期", `window.wx_errcode=402;`, port.QRLoginResult{Status: port.QRLoginExpired}},
+ {"已扫码", `window.wx_errcode=404;`, port.QRLoginResult{Status: port.QRLoginScanned}},
+ {"等待扫码", `window.wx_errcode=408;`, port.QRLoginResult{Status: port.QRLoginWaiting}},
+ {"其他状态", `window.wx_errcode=500;`, port.QRLoginResult{Status: port.QRLoginFailed}},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ saved := "未保存"
+ routes := map[string]http.HandlerFunc{}
+ addWeChatQRCreateRoute(t, routes, `window.QRLogin.uuid = "`+testWeChatUUID+`";`)
+ addWeChatQRPollRoute(t, routes, tt.response)
+ newClient, obs := serveQQQRLogin(t, routes, func(_ context.Context, value string) error {
+ saved = value
+ return nil
+ })
+ session := createTestWeChatQRSession(t, newClient)
+ got, err := newClient().CheckQRLogin(t.Context(), port.QQLoginWeChat, session.Key)
+ if err != nil || got != tt.want {
+ t.Errorf("跨 Client 微信登录结果 = %#v, %v,期望 %#v", got, err, tt.want)
+ }
+ if saved != "未保存" {
+ t.Errorf("微信非成功状态保存了 Cookie %q", saved)
+ }
+ obs.check(t, "qq login_create", "qq login_check")
+ })
+ }
+}
+
+func TestWeChatQRLoginCompletesMusicLoginAndSavesSortedCookies(t *testing.T) {
+ var saved string
+ routes := map[string]http.HandlerFunc{}
+ addWeChatQRCreateRoute(t, routes, `window.QRLogin.uuid = "`+testWeChatUUID+`";`)
+ addWeChatQRPollRoute(t, routes, `window.wx_errcode=405;window.wx_code='`+testWeChatCode+`';`)
+ addWeChatQRMusicRoute(t, routes, `{"code":0,"req":{"code":0,"data":{"musickey":"test","musicid":"wx-user","refresh_key":"refresh-key","refresh_token":"refresh-token","openid":"openid","unionid":"unionid","wxaccess_token":"access-token","json_extra":"keep","empty":""}}}`)
+ newClient, obs := serveQQQRLogin(t, routes, func(_ context.Context, value string) error {
+ saved = value
+ return nil
+ })
+ session := createTestWeChatQRSession(t, newClient)
+ client := newClient()
+ result, err := client.CheckQRLogin(t.Context(), port.QQLoginWeChat, session.Key)
+ want := port.QRLoginResult{Status: port.QRLoginSuccess}
+ if err != nil || result != want {
+ t.Fatalf("微信登录 = %#v, %v", result, err)
+ }
+ requireQQCookies(t, saved, map[string]string{
+ "qm_keyst": testMusicKey, "qqmusic_key": testMusicKey, "musickey": testMusicKey, "uin": "wx-user",
+ "refresh_key": "refresh-key", "refresh_token": "refresh-token", "openid": "openid", "unionid": "unionid",
+ "wxaccess_token": "access-token", "session": "keep",
+ })
+ if strings.Contains(saved, "json_extra=") || strings.Contains(saved, "empty=") || strings.Contains(saved, "login_type=") {
+ t.Errorf("保存了未知、空值或临时微信 Cookie:%q", saved)
+ }
+ if cached, err := client.cookie.get(t.Context()); err != nil || cached != saved {
+ t.Errorf("本地缓存的 Cookie = %q, %v,期望 %q", cached, err, saved)
+ }
+ obs.check(t, "qq login_create", "qq login_check")
+}
+
+func TestWeChatQRLoginMissingAuthorizationCodeFailsWithoutSaving(t *testing.T) {
+ saved := "未保存"
+ routes := map[string]http.HandlerFunc{}
+ addWeChatQRCreateRoute(t, routes, `window.QRLogin.uuid = "`+testWeChatUUID+`";`)
+ addWeChatQRPollRoute(t, routes, `window.wx_errcode=405;`)
+ newClient, obs := serveQQQRLogin(t, routes, func(_ context.Context, value string) error {
+ saved = value
+ return nil
+ })
+ session := createTestWeChatQRSession(t, newClient)
+ result, err := newClient().CheckQRLogin(t.Context(), port.QQLoginWeChat, session.Key)
+ if err == nil || result.Status == port.QRLoginSuccess {
+ t.Errorf("缺少微信授权 code 的登录结果 = %#v, %v,期望 Go 错误且不返回成功", result, err)
+ }
+ if saved != "未保存" {
+ t.Errorf("缺少微信授权 code 时保存了 %q", saved)
+ }
+ obs.check(t, "qq login_create", "qq login_check 失败")
+}
+
+func TestWeChatQRLoginMissingMusicKeyFailsWithoutSaving(t *testing.T) {
+ saved := "未保存"
+ routes := map[string]http.HandlerFunc{}
+ addWeChatQRCreateRoute(t, routes, `window.QRLogin.uuid = "`+testWeChatUUID+`";`)
+ addWeChatQRPollRoute(t, routes, `window.wx_errcode=405;window.wx_code='`+testWeChatCode+`';`)
+ addWeChatQRMusicRoute(t, routes, `{"code":0,"req":{"code":0,"data":{"musicid":"wx-user","openid":"openid"}}}`)
+ newClient, obs := serveQQQRLogin(t, routes, func(_ context.Context, value string) error {
+ saved = value
+ return nil
+ })
+ session := createTestWeChatQRSession(t, newClient)
+ result, err := newClient().CheckQRLogin(t.Context(), port.QQLoginWeChat, session.Key)
+ if err != nil || result.Status != port.QRLoginFailed {
+ t.Errorf("微信缺少音乐 key 的登录结果 = %#v, %v", result, err)
+ }
+ if saved != "未保存" {
+ t.Errorf("微信缺少音乐 key 时保存了 %q", saved)
+ }
+ obs.check(t, "qq login_create", "qq login_check")
+}
+
+func TestWeChatQRLoginRejectsInvalidMusicResponseCodes(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ response string
+ }{
+ {"顶层 code 缺失", `{"req":{"code":0,"data":{"musickey":"test","uin":"wx-user"}}}`},
+ {"顶层 code 为 null", `{"code":null,"req":{"code":0,"data":{"musickey":"test","uin":"wx-user"}}}`},
+ {"req.code 缺失", `{"code":0,"req":{"data":{"musickey":"test","uin":"wx-user"}}}`},
+ {"req.code 为 null", `{"code":0,"req":{"code":null,"data":{"musickey":"test","uin":"wx-user"}}}`},
+ {"顶层 code 非零", `{"code":1,"req":{"code":0,"data":{"musickey":"test","uin":"wx-user"}}}`},
+ {"req.code 非零", `{"code":0,"req":{"code":1,"data":{"musickey":"test","uin":"wx-user"}}}`},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ saved := "未保存"
+ routes := map[string]http.HandlerFunc{}
+ addWeChatQRCreateRoute(t, routes, `window.QRLogin.uuid = "`+testWeChatUUID+`";`)
+ addWeChatQRPollRoute(t, routes, `window.wx_errcode=405;window.wx_code='`+testWeChatCode+`';`)
+ addWeChatQRMusicRoute(t, routes, tt.response)
+ newClient, obs := serveQQQRLogin(t, routes, func(_ context.Context, value string) error {
+ saved = value
+ return nil
+ })
+ session := createTestWeChatQRSession(t, newClient)
+ result, err := newClient().CheckQRLogin(t.Context(), port.QQLoginWeChat, session.Key)
+ if err == nil || result.Status == port.QRLoginSuccess {
+ t.Errorf("微信无效音乐响应码的登录结果 = %#v, %v,期望错误", result, err)
+ }
+ if saved != "未保存" {
+ t.Errorf("微信无效音乐响应码时保存了 %q", saved)
+ }
+ obs.check(t, "qq login_create", "qq login_check 失败")
+ })
+ }
+}
+
+func TestWeChatQRLoginSaveFailureIsObserved(t *testing.T) {
+ saveErr := errors.New("保存测试 Cookie 失败")
+ routes := map[string]http.HandlerFunc{}
+ addWeChatQRCreateRoute(t, routes, `window.QRLogin.uuid = "`+testWeChatUUID+`";`)
+ addWeChatQRPollRoute(t, routes, `window.wx_errcode=405;window.wx_code='`+testWeChatCode+`';`)
+ addWeChatQRMusicRoute(t, routes, `{"code":0,"req":{"code":0,"data":{"qm_keyst":"test","uin":"wx-user"}}}`)
+ newClient, obs := serveQQQRLogin(t, routes, func(context.Context, string) error { return saveErr })
+ session := createTestWeChatQRSession(t, newClient)
+ client := newClient()
+ if _, err := client.cookie.get(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ result, err := client.CheckQRLogin(t.Context(), port.QQLoginWeChat, session.Key)
+ if !errors.Is(err, saveErr) || result.Status == port.QRLoginSuccess {
+ t.Errorf("微信保存失败的登录结果 = %#v, %v", result, err)
+ }
+ if cached, err := client.cookie.get(t.Context()); err != nil || cached != testCookie {
+ t.Errorf("微信保存失败后的本地 Cookie = %q, %v", cached, err)
+ }
+ obs.check(t, "qq login_create", "qq login_check 失败")
+}
+
+func TestQQLoginRedirectRejectsUnsafeTargetsBeforeRequest(t *testing.T) {
+ for _, target := range []string{
+ "http://xui.ptlogin2.qq.com/start",
+ "https://example.com/start",
+ "https://notqq.com/start",
+ "https://qq.com.example.com/start",
+ } {
+ t.Run(target, func(t *testing.T) {
+ var hits atomic.Int64
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hits.Add(1)
+ checkClientIP(t, r)
+ }))
+ t.Cleanup(server.Close)
+ serverURL, err := url.Parse(server.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ client := &http.Client{Transport: redirect{serverURL}}
+ finalURL, err := followQQLogin(t.Context(), client, target)
+ if err == nil {
+ t.Errorf("不安全跳转 %q 的最终 URL = %q,期望错误", target, finalURL)
+ } else if strings.Contains(err.Error(), target) {
+ t.Errorf("跳转错误泄露了完整登录地址:%v", err)
+ }
+ if hits.Load() != 0 {
+ t.Errorf("不安全目标收到 %d 个请求", hits.Load())
+ }
+ })
+ }
+}
+
+type failingQQLoginTransport struct{}
+
+func (failingQQLoginTransport) RoundTrip(*http.Request) (*http.Response, error) {
+ return nil, errors.New("测试网络失败")
+}
+
+func TestQQLoginNetworkErrorHidesLoginQuery(t *testing.T) {
+ client := &http.Client{Transport: failingQQLoginTransport{}}
+ target := "https://xui.ptlogin2.qq.com/ssl/ptqrlogin?login_sig=fictitious-secret"
+ if _, err := followQQLogin(t.Context(), client, target); err == nil {
+ t.Fatal("网络失败的跳转返回了 nil 错误")
+ } else if strings.Contains(err.Error(), "fictitious-secret") {
+ t.Errorf("网络错误泄露了登录参数:%v", err)
+ }
+}
+
+func checkMalformedQQLoginError(t *testing.T, err error, location, credential string) {
+ t.Helper()
+ if err == nil {
+ t.Fatal("malformed Location 返回了 nil 错误")
+ }
+ if strings.Contains(err.Error(), credential) || strings.Contains(err.Error(), location) {
+ t.Errorf("malformed Location 错误泄露了敏感地址:%v", err)
+ }
+}
+
+func TestQQLoginMalformedLocationErrorIsSanitized(t *testing.T) {
+ const credential = "fictitious-code-token"
+ location := "https://y.qq.com/callback/" + credential + "%zz"
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ checkClientIP(t, r)
+ w.Header().Set("Location", location)
+ w.WriteHeader(http.StatusFound)
+ }))
+ t.Cleanup(server.Close)
+ serverURL, err := url.Parse(server.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ client := &http.Client{Transport: redirect{serverURL}}
+ _, err = followQQLogin(t.Context(), client, "https://xui.ptlogin2.qq.com/start")
+ checkMalformedQQLoginError(t, err, location, credential)
+}
+
+func TestQQQOAuthMalformedLocationErrorIsSanitized(t *testing.T) {
+ const credential = "fictitious-oauth-token"
+ location := "https://y.qq.com/callback/" + credential + "%zz"
+ saved := "未保存"
+ routes := map[string]http.HandlerFunc{}
+ addQQQRCreateRoutes(t, routes)
+ addQQQOAuthRoutes(t, routes, `{"code":0,"req":{"code":0,"data":{"musickey":"test","uin":"o1"}}}`)
+ routes["/oauth2.0/authorize"] = func(w http.ResponseWriter, r *http.Request) {
+ checkClientIP(t, r)
+ w.Header().Set("Location", location)
+ w.WriteHeader(http.StatusFound)
+ }
+ newClient, obs := serveQQQRLogin(t, routes, func(_ context.Context, value string) error {
+ saved = value
+ return nil
+ })
+ session := createTestQQQRSession(t, newClient)
+ result, err := newClient().CheckQRLogin(t.Context(), port.QQLoginQQ, session.Key)
+ checkMalformedQQLoginError(t, err, location, credential)
+ if result.Status == port.QRLoginSuccess || saved != "未保存" {
+ t.Errorf("malformed OAuth Location 的结果 = %#v,保存的 Cookie = %q", result, saved)
+ }
+ obs.check(t, "qq login_create", "qq login_check 失败")
+}
+
+func TestQQLoginRedirectRejectsUnsafeLocation(t *testing.T) {
+ var targetHits atomic.Int64
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ checkClientIP(t, r)
+ switch r.URL.Path {
+ case "/start":
+ w.Header().Set("Location", "http://xui.ptlogin2.qq.com/target")
+ w.WriteHeader(http.StatusFound)
+ case "/target":
+ targetHits.Add(1)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ t.Cleanup(server.Close)
+ serverURL, err := url.Parse(server.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ client := &http.Client{Transport: redirect{serverURL}}
+ if finalURL, err := followQQLogin(t.Context(), client, "https://xui.ptlogin2.qq.com/start"); err == nil {
+ t.Errorf("不安全 Location 的最终 URL = %q,期望错误", finalURL)
+ }
+ if targetHits.Load() != 0 {
+ t.Errorf("不安全 Location 目标收到 %d 个请求", targetHits.Load())
+ }
+}
+
+func TestQQLoginRedirectLimit(t *testing.T) {
+ var hits atomic.Int64
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ checkClientIP(t, r)
+ hits.Add(1)
+ w.Header().Set("Location", "https://xui.ptlogin2.qq.com/loop")
+ w.WriteHeader(http.StatusFound)
+ }))
+ t.Cleanup(server.Close)
+ serverURL, err := url.Parse(server.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ client := &http.Client{Transport: redirect{serverURL}}
+ if finalURL, err := followQQLogin(t.Context(), client, "https://xui.ptlogin2.qq.com/loop"); err == nil {
+ t.Errorf("循环跳转的最终 URL = %q,期望错误", finalURL)
+ }
+ if want := int64(11); hits.Load() != want {
+ t.Errorf("10 次跳转限制的请求数 = %d,期望 %d", hits.Load(), want)
+ }
+}
+
+type weChatEndpointTransport struct {
+ calls []string
+}
+
+func (t *weChatEndpointTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ t.calls = append(t.calls, req.URL.Host)
+ resp := &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: http.NoBody, Request: req}
+ switch req.URL.Host {
+ case "u.y.qq.com":
+ resp.StatusCode = http.StatusBadGateway
+ case "szu.y.qq.com":
+ resp.Body = io.NopCloser(strings.NewReader(`{"code":0,"req":{"code":0,"data":{"musickey":"test","musicid":"wx-user"}}}`))
+ default:
+ resp.StatusCode = http.StatusBadGateway
+ }
+ return resp, nil
+}
+
+func TestWeChatMusicLoginFallsBackAcrossEndpoints(t *testing.T) {
+ transport := &weChatEndpointTransport{}
+ c := New(
+ &http.Client{Transport: transport},
+ func(context.Context) (string, error) { return "", nil },
+ func(context.Context, string) error { return nil },
+ func(string, string, time.Duration, error) {},
+ )
+ cookie, err := c.completeWeChatLogin(t.Context(), c.api, testWeChatCode)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(cookie, "musickey=test") || !strings.Contains(cookie, "uin=wx-user") {
+ t.Fatalf("备用域名保存的 Cookie = %q", cookie)
+ }
+ if want := []string{"u.y.qq.com", "szu.y.qq.com"}; !slices.Equal(transport.calls, want) {
+ t.Fatalf("音乐登录调用的域名 = %v,期望 %v", transport.calls, want)
+ }
+}
diff --git a/internal/adapter/out/qq/play_test.go b/internal/adapter/out/qq/play_test.go
new file mode 100644
index 0000000..15b1cc2
--- /dev/null
+++ b/internal/adapter/out/qq/play_test.go
@@ -0,0 +1,217 @@
+package qq
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "slices"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+)
+
+func checkPlayURLRequest(t *testing.T, r *http.Request) {
+ t.Helper()
+ if got := r.Header.Get("Cookie"); got != testCookie {
+ t.Errorf("QQ 播放地址接口的 Cookie = %q,期望虚构 QQ Cookie", got)
+ }
+ var payload struct {
+ Req1 struct {
+ Module string `json:"module"`
+ Method string `json:"method"`
+ Param struct {
+ Filenames []string `json:"filename"`
+ SongMIDs []string `json:"songmid"`
+ } `json:"param"`
+ } `json:"req_1"`
+ }
+ if err := json.Unmarshal([]byte(r.URL.Query().Get("data")), &payload); err != nil {
+ t.Errorf("解析播放地址请求:%v", err)
+ return
+ }
+ if payload.Req1.Module != "music.vkey.GetVkey" || payload.Req1.Method != "UrlGetVkey" {
+ t.Errorf("播放地址请求的方法和模块 = %q %q", payload.Req1.Method, payload.Req1.Module)
+ }
+ wantFilenames := []string{"F000idid.flac", "M800idid.mp3", "M500idid.mp3"}
+ if !slices.Equal(payload.Req1.Param.Filenames, wantFilenames) || !slices.Equal(payload.Req1.Param.SongMIDs, []string{"id"}) {
+ t.Errorf("播放地址请求的参数 = %#v", payload.Req1.Param)
+ }
+}
+
+func TestPlayURLSelectsRequestedQualityOrder(t *testing.T) {
+ tests := []struct {
+ name string
+ response string
+ want string
+ }{
+ {
+ name: "FLAC",
+ response: `{"code":0,"req_1":{"code":0,"data":{"midurlinfo":[{"filename":"M500idid.mp3","purl":"128"},{"filename":"F000idid.flac","purl":"flac"},{"filename":"M800idid.mp3","purl":"320"}]}}}`,
+ want: "https://ws.stream.qqmusic.qq.com/flac",
+ },
+ {
+ name: "320k MP3",
+ response: `{"code":0,"req_1":{"code":0,"data":{"midurlinfo":[{"filename":"M500idid.mp3","purl":"128"},{"filename":"F000idid.flac","purl":""},{"filename":"M800idid.mp3","purl":"320"}]}}}`,
+ want: "https://ws.stream.qqmusic.qq.com/320",
+ },
+ {
+ name: "128k MP3",
+ response: `{"code":0,"req_1":{"code":0,"data":{"midurlinfo":[{"filename":"M500idid.mp3","purl":"128"},{"filename":"F000idid.flac","purl":""},{"filename":"M800idid.mp3","purl":""}]}}}`,
+ want: "https://ws.stream.qqmusic.qq.com/128",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c, obs := serve(t, map[string]http.HandlerFunc{
+ "/cgi-bin/musicu.fcg": func(w http.ResponseWriter, r *http.Request) {
+ checkPlayURLRequest(t, r)
+ body(tt.response)(w, r)
+ },
+ })
+ got, err := c.PlayURL(t.Context(), "id")
+ if err != nil || got != tt.want {
+ t.Fatalf("播放地址 = %q, %v,期望 %q", got, err, tt.want)
+ }
+ obs.check(t, "qq play_url")
+ })
+ }
+}
+
+func TestPlayURLRejectsEmptyPURL(t *testing.T) {
+ c, _ := serve(t, map[string]http.HandlerFunc{
+ "/cgi-bin/musicu.fcg": body(`{"code":0,"req_1":{"code":0,"data":{"midurlinfo":[{"filename":"F000idid.flac","purl":""},{"filename":"M800idid.mp3","purl":""},{"filename":"M500idid.mp3","purl":""},{"filename":"F000otherother.flac","purl":"other"}]}}}`),
+ })
+ var rejected *port.RejectedError
+ if got, err := c.PlayURL(t.Context(), "id"); got != "" || !errors.As(err, &rejected) || err.Error() != "QQ 音乐没有提供这首歌的播放地址" {
+ t.Errorf("空播放地址的结果 = %q, %v,期望音源拒绝", got, err)
+ }
+}
+
+func TestPlayURLUpstreamErrorsAreRetryable(t *testing.T) {
+ tests := []struct {
+ name string
+ handler http.HandlerFunc
+ }{
+ {name: "HTTP 502", handler: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadGateway) }},
+ {name: "响应 JSON 畸形", handler: body(`{"code":0,`)},
+ {name: "顶层 code 缺失", handler: body(`{"req_1":{"code":0,"data":{"midurlinfo":[]}}}`)},
+ {name: "顶层 code 为 null", handler: body(`{"code":null,"req_1":{"code":0,"data":{"midurlinfo":[]}}}`)},
+ {name: "req_1.code 缺失", handler: body(`{"code":0,"req_1":{"data":{"midurlinfo":[]}}}`)},
+ {name: "req_1.code 为 null", handler: body(`{"code":0,"req_1":{"code":null,"data":{"midurlinfo":[]}}}`)},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c, _ := serve(t, map[string]http.HandlerFunc{"/cgi-bin/musicu.fcg": tt.handler})
+ var rejected *port.RejectedError
+ if _, err := c.PlayURL(t.Context(), "id"); err == nil || errors.As(err, &rejected) {
+ t.Errorf("上游错误 = %v,期望可重试且不进入音源拒绝", err)
+ }
+ })
+ }
+}
+
+func TestResolveDoesNotFallbackForInvalidPlayURLResponses(t *testing.T) {
+ tests := []struct {
+ name string
+ response string
+ }{
+ {name: "响应 JSON 畸形", response: `{"code":0,`},
+ {name: "顶层 code 缺失", response: `{"req_1":{"code":0,"data":{"midurlinfo":[]}}}`},
+ {name: "顶层 code 为 null", response: `{"code":null,"req_1":{"code":0,"data":{"midurlinfo":[]}}}`},
+ {name: "req_1.code 缺失", response: `{"code":0,"req_1":{"data":{"midurlinfo":[]}}}`},
+ {name: "req_1.code 为 null", response: `{"code":0,"req_1":{"code":null,"data":{"midurlinfo":[]}}}`},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ routes := resolveRoutes(t)
+ routes["/cgi-bin/musicu.fcg"] = body(tt.response)
+ c, obs := serve(t, routes)
+ got, err := c.Resolve(t.Context(), "004Fs2FP1EvZYc")
+ if got.Audio != nil {
+ _ = got.Audio.Close()
+ }
+ var rejected *port.RejectedError
+ if err == nil || errors.As(err, &rejected) {
+ t.Errorf("无效原生播放响应的 Resolve 错误 = %v,期望普通可重试错误", err)
+ }
+ obs.check(t, "qq track_detail", "qq lyric", "qq play_url 失败")
+ })
+ }
+}
+
+func TestResolveRejectsEmptyPlayURL(t *testing.T) {
+ c, obs := serve(t, resolveRoutes(t))
+ _, err := c.Resolve(t.Context(), "004Fs2FP1EvZYc")
+ var rejected *port.RejectedError
+ if !errors.As(err, &rejected) || err.Error() != "QQ 音乐没有提供这首歌的播放地址" {
+ t.Fatalf("QQ 没有播放地址时的错误 = %v,期望 QQ 音源拒绝", err)
+ }
+ obs.check(t, "qq track_detail", "qq lyric", "qq play_url")
+}
+
+func TestPlayURLIsNotCached(t *testing.T) {
+ var calls atomic.Int32
+ c, obs := serve(t, map[string]http.HandlerFunc{
+ "/cgi-bin/musicu.fcg": func(w http.ResponseWriter, r *http.Request) {
+ purl := "first"
+ if calls.Add(1) == 2 {
+ purl = "second"
+ }
+ body(`{"code":0,"req_1":{"code":0,"data":{"midurlinfo":[{"filename":"M500idid.mp3","purl":"`+purl+`"}]}}}`)(w, r)
+ },
+ })
+ for _, want := range []string{"https://ws.stream.qqmusic.qq.com/first", "https://ws.stream.qqmusic.qq.com/second"} {
+ got, err := c.PlayURL(t.Context(), "id")
+ if err != nil || got != want {
+ t.Fatalf("播放地址 = %q, %v,期望 %q", got, err, want)
+ }
+ }
+ if got := calls.Load(); got != 2 {
+ t.Errorf("播放地址接口调用了 %d 次,期望不缓存地址", got)
+ }
+ obs.check(t, "qq play_url", "qq play_url")
+}
+
+func TestPlayURLDoesNotFollowRedirects(t *testing.T) {
+ c, obs := serve(t, map[string]http.HandlerFunc{
+ "/cgi-bin/musicu.fcg": func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "/redirected", http.StatusTemporaryRedirect)
+ },
+ "/redirected": func(w http.ResponseWriter, r *http.Request) {
+ t.Error("带 Cookie 的 QQ 接口自动跟随了重定向")
+ body(`{"code":0,"req_1":{"code":0,"data":{"midurlinfo":[]}}}`)(w, r)
+ },
+ })
+ var rejected *port.RejectedError
+ if _, err := c.PlayURL(t.Context(), "id"); err == nil || errors.As(err, &rejected) {
+ t.Errorf("重定向结果 = %v,期望 HTTP 错误", err)
+ }
+ obs.check(t, "qq play_url 失败")
+}
+
+type failingTransport struct{}
+
+func (failingTransport) RoundTrip(*http.Request) (*http.Response, error) {
+ return nil, errors.New("网络中断")
+}
+
+func TestDownloadErrorDoesNotExposeSignedURL(t *testing.T) {
+ signedURL := "https://ws.stream.qqmusic.qq.com/audio.flac?sign=signed-secret&uin=123"
+ c := New(
+ &http.Client{Transport: failingTransport{}},
+ func(context.Context) (string, error) { return "", nil },
+ func(context.Context, string) error { return nil },
+ func(string, string, time.Duration, error) {},
+ )
+ _, err := c.download(t.Context(), signedURL)
+ if err == nil || !strings.Contains(err.Error(), "网络中断") {
+ t.Fatalf("下载错误 = %v,期望保留网络错误", err)
+ }
+ if strings.Contains(err.Error(), "signed-secret") || strings.Contains(err.Error(), signedURL) {
+ t.Errorf("下载错误携带了签名地址:%v", err)
+ }
+}
diff --git a/internal/adapter/out/qq/qq_test.go b/internal/adapter/out/qq/qq_test.go
new file mode 100644
index 0000000..d04cefe
--- /dev/null
+++ b/internal/adapter/out/qq/qq_test.go
@@ -0,0 +1,367 @@
+package qq
+
+import (
+ "context"
+ "encoding/base64"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/netip"
+ "net/url"
+ "os"
+ "path/filepath"
+ "reflect"
+ "slices"
+ "sync"
+ "testing"
+ "testing/synctest"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+const testCookie = "qq_test_cookie=fictitious"
+
+// 测试服务器按路径返回 testdata 中录制的响应,Client 的所有请求都转到它。
+func serve(t *testing.T, routes map[string]http.HandlerFunc) (*Client, *observed) {
+ t.Helper()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ checkClientIP(t, r)
+ handle, ok := routes[r.URL.Path]
+ if !ok {
+ t.Errorf("意外的请求 %s %s", r.Method, r.URL)
+ http.NotFound(w, r)
+ return
+ }
+ handle(w, r)
+ }))
+ t.Cleanup(server.Close)
+ target, err := url.Parse(server.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ obs := &observed{}
+ return New(
+ &http.Client{Transport: redirect{target}},
+ func(context.Context) (string, error) { return testCookie, nil },
+ func(context.Context, string) error { return nil },
+ obs.record,
+ ), obs
+}
+
+type redirect struct{ target *url.URL }
+
+func (r redirect) RoundTrip(req *http.Request) (*http.Response, error) {
+ req = req.Clone(req.Context())
+ req.URL.Scheme, req.URL.Host = r.target.Scheme, r.target.Host
+ return http.DefaultTransport.RoundTrip(req)
+}
+
+// 国内运营商的地址段。
+var chinaPrefixes = [][2]byte{{116, 255}, {116, 228}, {218, 192}, {124, 0}, {14, 132}, {183, 14}, {58, 14}, {113, 116}, {120, 230}}
+
+// 请求接口时带上同一个随机的国内 IP,下载音频时不带。
+func checkClientIP(t *testing.T, r *http.Request) {
+ realIP, forwarded := r.Header.Get("X-Real-IP"), r.Header.Get("X-Forwarded-For")
+ if r.URL.Path == audioPath {
+ if realIP != "" || forwarded != "" {
+ t.Errorf("下载音频时带上了 X-Real-IP %q、X-Forwarded-For %q", realIP, forwarded)
+ }
+ return
+ }
+ addr, err := netip.ParseAddr(realIP)
+ if err != nil || !addr.Is4() || !slices.Contains(chinaPrefixes, [2]byte(addr.AsSlice())) || forwarded != realIP {
+ t.Errorf("%s 的 X-Real-IP 为 %q、X-Forwarded-For 为 %q,期望同一个国内运营商的 IP", r.URL.Path, realIP, forwarded)
+ }
+}
+
+func fixture(t *testing.T, name string) http.HandlerFunc {
+ return func(w http.ResponseWriter, _ *http.Request) {
+ b, err := os.ReadFile(filepath.Join("testdata", name))
+ if err != nil {
+ t.Error(err)
+ }
+ _, _ = w.Write(b)
+ }
+}
+
+func body(text string) http.HandlerFunc {
+ return func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, text) }
+}
+
+type observed struct {
+ mu sync.Mutex
+ ops []string
+}
+
+func (o *observed) record(source, op string, _ time.Duration, err error) {
+ o.mu.Lock()
+ defer o.mu.Unlock()
+ if err != nil {
+ op += " 失败"
+ }
+ o.ops = append(o.ops, source+" "+op)
+}
+
+func (o *observed) check(t *testing.T, want ...string) {
+ t.Helper()
+ o.mu.Lock()
+ defer o.mu.Unlock()
+ if !slices.Equal(o.ops, want) {
+ t.Errorf("请求了接口 %q,期望 %q", o.ops, want)
+ }
+}
+
+func wantSong(mid, title, album, albumMID string, seconds int) port.Song {
+ return port.Song{
+ Track: track.Track{Source: track.QQ, ID: mid},
+ Meta: track.Meta{
+ Title: title, Artist: "周杰伦", Album: album,
+ CoverURL: "https://y.gtimg.cn/music/photo_new/T002R300x300M000" + albumMID + ".jpg",
+ PageURL: "https://y.qq.com/n/ryqq/songDetail/" + mid,
+ Duration: time.Duration(seconds) * time.Second,
+ },
+ }
+}
+
+var live = wantSong("004Fs2FP1EvZYc", "晴天 (Live)", "周杰伦地表最强世界巡回演唱会", "004fXSyj3bWTMN", 249)
+
+func TestSearchTracks(t *testing.T) {
+ c, obs := serve(t, map[string]http.HandlerFunc{
+ "/soso/fcgi-bin/search_for_qq_cp": func(w http.ResponseWriter, r *http.Request) {
+ if q := r.URL.Query(); q.Get("w") != "晴天" || q.Get("n") != "20" {
+ t.Errorf("搜索参数 = %v,期望一次取 20 首歌", q)
+ }
+ fixture(t, "search-tracks.json")(w, r)
+ },
+ })
+ want := []port.Song{wantSong("0039MnYb0qxYhV", "晴天", "叶惠美", "000MkMni19ClKG", 269), live}
+ for range 2 {
+ got, err := c.SearchTracks(t.Context(), "晴天")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("搜索结果 = %#v\n期望 %#v", got, want)
+ }
+ }
+ obs.check(t, "qq search_tracks")
+}
+
+func TestSearchPlaylists(t *testing.T) {
+ c, _ := serve(t, map[string]http.HandlerFunc{
+ "/soso/fcgi-bin/client_music_search_songlist": func(w http.ResponseWriter, r *http.Request) {
+ if q := r.URL.Query(); q.Get("query") != "晴天" || q.Get("num_per_page") != "20" {
+ t.Errorf("搜索参数 = %v,期望一次取 20 个歌单", q)
+ }
+ fixture(t, "search-playlists.json")(w, r)
+ },
+ })
+ got, err := c.SearchPlaylists(t.Context(), "晴天")
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []port.Playlist{
+ {ID: "7382629476", Name: "精选 | 好听到单曲循环的热歌", CoverURL: "http://p.qpic.cn/music_cover/Z89aLA93LOSOicz0QOnMboqgLaiaFohjweglHh6JSoL8hrjOfFOC6DXw/600", Creator: "故事未完待续", Description: "音乐,是生命中的一束光。", TrackCount: 130, PlayCount: 1646891961},
+ {ID: "7709684645", Name: "抖音热播 | 瞬间心动的神仙歌曲", CoverURL: "http://qpic.y.qq.com/music_cover/aDicdhbLYu1KGBxNOsA6ff1v3vCMPkgFNhdQf18FKSOUiaVCmJ7TOIXQ/300", Creator: "行走的荷尔蒙", Description: "抖音热播的神仙歌曲,建议收藏。", TrackCount: 121, PlayCount: 979011192},
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("歌单 = %#v\n期望 %#v", got, want)
+ }
+}
+
+func TestTrackMeta(t *testing.T) {
+ c, _ := serve(t, map[string]http.HandlerFunc{"/v8/fcg-bin/fcg_play_single_song.fcg": fixture(t, "song-detail.json")})
+ if got, err := c.TrackMeta(t.Context(), "004Fs2FP1EvZYc"); err != nil || got != live.Meta {
+ t.Errorf("元数据 = %#v, %v,期望 %#v:标题要带上版本", got, err, live.Meta)
+ }
+
+ c, _ = serve(t, map[string]http.HandlerFunc{"/v8/fcg-bin/fcg_play_single_song.fcg": fixture(t, "song-detail-missing.json")})
+ var rejected *port.RejectedError
+ if _, err := c.TrackMeta(t.Context(), "000000000000xx"); !errors.As(err, &rejected) {
+ t.Errorf("不存在的歌的错误 = %v,期望音源拒绝", err)
+ }
+}
+
+// 歌曲的元数据和歌词会写入 PostgreSQL,text 类型不能存储 U+0000,歌词是 base64 编码的,要在解码之后清除。
+func TestTextDropsNUL(t *testing.T) {
+ lyric := base64.StdEncoding.EncodeToString([]byte("[00:01.00]故\x00事"))
+ c, _ := serve(t, map[string]http.HandlerFunc{
+ "/v8/fcg-bin/fcg_play_single_song.fcg": body(`{"code":0,"data":[{"mid":"004Fs2FP1EvZYc","title":"晴\u0000天","interval":249,"album":{"mid":"004fXS\u0000yj3bWTMN","name":"叶\u0000惠美"},"singer":[{"name":"周\u0000杰伦"}]}]}`),
+ "/lyric/fcgi-bin/fcg_query_lyric_new.fcg": body(`{"code":0,"lyric":"` + lyric + `"}`),
+ })
+ want := wantSong("004Fs2FP1EvZYc", "晴天", "叶惠美", "004fXSyj3bWTMN", 249).Meta
+ if got, err := c.TrackMeta(t.Context(), "004Fs2FP1EvZYc"); err != nil || got != want {
+ t.Errorf("元数据 = %#v, %v,期望 %#v", got, err, want)
+ }
+ if got, err := c.lyric(t.Context(), "004Fs2FP1EvZYc"); err != nil || got != "[00:01.00]故事" {
+ t.Errorf("歌词 = %q, %v", got, err)
+ }
+}
+
+// 歌词是 base64 编码的字节,不保证是 UTF-8,比如 GBK 编码的歌词;PostgreSQL 的 text 类型拒绝不合法的 UTF-8,
+// 这样的歌词存不进去,准备任务每次都失败。
+func TestLyricReplacesInvalidUTF8(t *testing.T) {
+ lyric := base64.StdEncoding.EncodeToString([]byte("[00:01.00]\xb9\xca\xca\xc2\n[00:02.00]晴天"))
+ c, _ := serve(t, map[string]http.HandlerFunc{
+ "/lyric/fcgi-bin/fcg_query_lyric_new.fcg": body(`{"code":0,"lyric":"` + lyric + `"}`),
+ })
+ if got, err := c.lyric(t.Context(), "004Fs2FP1EvZYc"); err != nil || got != "[00:01.00]\uFFFD\n[00:02.00]晴天" {
+ t.Errorf("歌词 = %q, %v,期望不合法的字节替换为 U+FFFD", got, err)
+ }
+}
+
+const audioPath = "/qq/80456317.flac"
+
+func resolveRoutes(t *testing.T) map[string]http.HandlerFunc {
+ return map[string]http.HandlerFunc{
+ "/v8/fcg-bin/fcg_play_single_song.fcg": fixture(t, "song-detail.json"),
+ "/lyric/fcgi-bin/fcg_query_lyric_new.fcg": fixture(t, "lyric.json"),
+ "/cgi-bin/musicu.fcg": func(w http.ResponseWriter, r *http.Request) {
+ if got := r.Header.Get("Cookie"); got != testCookie {
+ t.Errorf("QQ 播放地址接口的 Cookie = %q,期望虚构 QQ Cookie", got)
+ }
+ body(`{"code":0,"req_1":{"code":0,"data":{"midurlinfo":[]}}}`)(w, r)
+ },
+ audioPath: func(w http.ResponseWriter, r *http.Request) {
+ if got := r.Header.Get("Cookie"); got != "" {
+ t.Errorf("音频 CDN 收到了 Cookie %q", got)
+ }
+ body("ID3 音频")(w, r)
+ },
+ }
+}
+
+func TestResolve(t *testing.T) {
+ routes := resolveRoutes(t)
+ routes["/cgi-bin/musicu.fcg"] = func(w http.ResponseWriter, r *http.Request) {
+ if got := r.Header.Get("Cookie"); got != testCookie {
+ t.Errorf("QQ 播放地址接口的 Cookie = %q,期望虚构 QQ Cookie", got)
+ }
+ body(`{"code":0,"req_1":{"code":0,"data":{"midurlinfo":[{"filename":"F000004Fs2FP1EvZYc004Fs2FP1EvZYc.flac","purl":"`+audioPath[1:]+`"}]}}}`)(w, r)
+ }
+ c, obs := serve(t, routes)
+ got, err := c.Resolve(t.Context(), "004Fs2FP1EvZYc")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = got.Audio.Close() }()
+ if got.Meta != live.Meta || got.Lyric != "[ti:晴天]\n[ar:周杰伦]\n[00:00.00]晴天 - 周杰伦 (Jay Chou)\n" {
+ t.Errorf("解析结果 = %#v", got)
+ }
+ if b, err := io.ReadAll(got.Audio); err != nil || string(b) != "ID3 音频" {
+ t.Errorf("音频 = %q, %v", b, err)
+ }
+ obs.check(t, "qq track_detail", "qq lyric", "qq play_url")
+}
+
+func TestResolveRejects(t *testing.T) {
+ c, _ := serve(t, resolveRoutes(t))
+ var rejected *port.RejectedError
+ if _, err := c.Resolve(t.Context(), "004Fs2FP1EvZYc"); !errors.As(err, &rejected) || err.Error() != "QQ 音乐没有提供这首歌的播放地址" {
+ t.Errorf("QQ 没有播放地址时的错误 = %v,期望音源拒绝", err)
+ }
+}
+
+func TestResolveRetriesUpstreamFailures(t *testing.T) {
+ var rejected *port.RejectedError
+ routes := resolveRoutes(t)
+ routes["/cgi-bin/musicu.fcg"] = func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadGateway) }
+ c, obs := serve(t, routes)
+ if _, err := c.Resolve(t.Context(), "004Fs2FP1EvZYc"); err == nil || errors.As(err, &rejected) {
+ t.Errorf("QQ 原生播放接口出错时的错误 = %v,期望可以重试的错误", err)
+ }
+ obs.check(t, "qq track_detail", "qq lyric", "qq play_url 失败")
+}
+
+func TestLyricMissing(t *testing.T) {
+ c, _ := serve(t, map[string]http.HandlerFunc{"/lyric/fcgi-bin/fcg_query_lyric_new.fcg": fixture(t, "lyric-missing.json")})
+ if got, err := c.lyric(t.Context(), "000000000000xx"); err != nil || got != "" {
+ t.Errorf("没有歌词的歌 = %q, %v,期望空字符串", got, err)
+ }
+}
+
+func TestCookieIsReloadedEvery30Seconds(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ loads := 0
+ c := cookieCache{load: func(context.Context) (string, error) {
+ loads++
+ return "qq_cookie=" + string(rune('0'+loads)), nil
+ }}
+ for range 2 {
+ if got, err := c.get(t.Context()); err != nil || got != "qq_cookie=1" {
+ t.Fatalf("Cookie = %q, %v", got, err)
+ }
+ }
+ time.Sleep(30*time.Second - time.Nanosecond)
+ if got, err := c.get(t.Context()); err != nil || got != "qq_cookie=1" {
+ t.Fatalf("不到 30 秒的 Cookie = %q, %v,期望仍是缓存的", got, err)
+ }
+ time.Sleep(time.Nanosecond)
+ if got, err := c.get(t.Context()); err != nil || got != "qq_cookie=2" {
+ t.Errorf("30 秒之后的 Cookie = %q, %v,期望重新读取", got, err)
+ }
+ })
+}
+
+func TestCookieLoadHasOwnDeadline(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ c := cookieCache{load: func(ctx context.Context) (string, error) {
+ <-ctx.Done()
+ return "", ctx.Err()
+ }}
+ ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
+ defer cancel()
+ start := time.Now()
+ if _, err := c.get(ctx); !errors.Is(err, context.DeadlineExceeded) {
+ t.Errorf("读取卡住时的错误 = %v,期望超过期限", err)
+ }
+ if waited := time.Since(start); waited != 10*time.Second {
+ t.Errorf("等了 %v,期望 10 秒", waited)
+ }
+ })
+}
+
+func TestCookieSaveHasOwnDeadline(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ c := cookieCache{store: func(ctx context.Context, _ string) error {
+ <-ctx.Done()
+ return ctx.Err()
+ }}
+ ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
+ defer cancel()
+ start := time.Now()
+ if err := c.put(ctx, testCookie); !errors.Is(err, context.DeadlineExceeded) {
+ t.Errorf("保存卡住时的错误 = %v,期望超过期限", err)
+ }
+ if waited := time.Since(start); waited != 10*time.Second {
+ t.Errorf("等了 %v,期望 10 秒", waited)
+ }
+ })
+}
+
+func TestCookiePutUpdatesCache(t *testing.T) {
+ loads := 0
+ saved := ""
+ c := cookieCache{
+ load: func(context.Context) (string, error) {
+ loads++
+ return "loaded", nil
+ },
+ store: func(_ context.Context, value string) error {
+ saved = value
+ return nil
+ },
+ }
+ if err := c.put(t.Context(), testCookie); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := c.get(t.Context()); err != nil || got != testCookie {
+ t.Errorf("保存后的 Cookie = %q, %v,期望 %q", got, err, testCookie)
+ }
+ if saved != testCookie || loads != 0 {
+ t.Errorf("保存的 Cookie = %q,重新加载了 %d 次", saved, loads)
+ }
+}
diff --git a/internal/adapter/out/qq/testdata/lyric-missing.json b/internal/adapter/out/qq/testdata/lyric-missing.json
new file mode 100644
index 0000000..509dc28
--- /dev/null
+++ b/internal/adapter/out/qq/testdata/lyric-missing.json
@@ -0,0 +1 @@
+{"retcode":1101,"code":1101,"subcode":1101}
diff --git a/internal/adapter/out/qq/testdata/lyric.json b/internal/adapter/out/qq/testdata/lyric.json
new file mode 100644
index 0000000..bcae5e5
--- /dev/null
+++ b/internal/adapter/out/qq/testdata/lyric.json
@@ -0,0 +1 @@
+{"retcode":0,"code":0,"subcode":0,"lyric":"W3RpOuaZtOWkqV0KW2FyOuWRqOadsOS8pl0KWzAwOjAwLjAwXeaZtOWkqSAtIOWRqOadsOS8piAoSmF5IENob3UpCg=="}
diff --git a/internal/adapter/out/qq/testdata/search-playlists.json b/internal/adapter/out/qq/testdata/search-playlists.json
new file mode 100644
index 0000000..3ef63f5
--- /dev/null
+++ b/internal/adapter/out/qq/testdata/search-playlists.json
@@ -0,0 +1 @@
+{"code":0,"subcode":0,"message":"","data":{"display_num":321321,"list":[{"dissid":"7382629476","dissname":"精选 | 好听到单曲循环的热歌","imgurl":"http://p.qpic.cn/music_cover/Z89aLA93LOSOicz0QOnMboqgLaiaFohjweglHh6JSoL8hrjOfFOC6DXw/600","introduction":"音乐,是生命中的一束光。","listennum":1646891961,"song_count":130,"creator":{"name":"故事未完待续","qq":1968987427}},{"dissid":"7709684645","dissname":"抖音热播 | 瞬间心动的神仙歌曲","imgurl":"http://qpic.y.qq.com/music_cover/aDicdhbLYu1KGBxNOsA6ff1v3vCMPkgFNhdQf18FKSOUiaVCmJ7TOIXQ/300","introduction":"抖音热播的神仙歌曲,建议收藏。","listennum":979011192,"song_count":121,"creator":{"name":"行走的荷尔蒙","qq":1945851593}}]}}
diff --git a/internal/adapter/out/qq/testdata/search-tracks.json b/internal/adapter/out/qq/testdata/search-tracks.json
new file mode 100644
index 0000000..fac19f9
--- /dev/null
+++ b/internal/adapter/out/qq/testdata/search-tracks.json
@@ -0,0 +1 @@
+{"code":0,"data":{"keyword":"晴天","song":{"curnum":2,"curpage":1,"list":[{"albumid":8220,"albummid":"000MkMni19ClKG","albumname":"叶惠美","interval":269,"songid":97773,"songmid":"0039MnYb0qxYhV","songname":"晴天","singer":[{"id":4558,"mid":"0025NhlN2yWrP4","name":"周杰伦"}]},{"albumid":9040723,"albummid":"004fXSyj3bWTMN","albumname":"周杰伦地表最强世界巡回演唱会","interval":249,"songid":244115385,"songmid":"004Fs2FP1EvZYc","songname":"晴天 (Live)","singer":[{"id":4558,"mid":"0025NhlN2yWrP4","name":"周杰伦"}]}],"totalnum":600}}}
diff --git a/internal/adapter/out/qq/testdata/song-detail-missing.json b/internal/adapter/out/qq/testdata/song-detail-missing.json
new file mode 100644
index 0000000..19b2eb8
--- /dev/null
+++ b/internal/adapter/out/qq/testdata/song-detail-missing.json
@@ -0,0 +1 @@
+{"code":0,"data":[],"url":null,"url1":{},"extra_data":[],"joox":0,"joox_login":0,"msgid":0}
diff --git a/internal/adapter/out/qq/testdata/song-detail.json b/internal/adapter/out/qq/testdata/song-detail.json
new file mode 100644
index 0000000..e315f3c
--- /dev/null
+++ b/internal/adapter/out/qq/testdata/song-detail.json
@@ -0,0 +1 @@
+{"code":0,"data":[{"id":244115385,"mid":"004Fs2FP1EvZYc","name":"晴天","title":"晴天 (Live)","interval":249,"album":{"id":9040723,"mid":"004fXSyj3bWTMN","name":"周杰伦地表最强世界巡回演唱会","title":"周杰伦地表最强世界巡回演唱会"},"singer":[{"id":4558,"mid":"0025NhlN2yWrP4","name":"周杰伦","title":"周杰伦"}]}]}
diff --git a/internal/adapter/out/queue/main_test.go b/internal/adapter/out/queue/main_test.go
new file mode 100644
index 0000000..e72d334
--- /dev/null
+++ b/internal/adapter/out/queue/main_test.go
@@ -0,0 +1,93 @@
+package queue
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "os"
+ "sync/atomic"
+ "testing"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+ "github.com/riverqueue/river/riverdriver/riverpgxv5"
+ "github.com/riverqueue/river/rivermigrate"
+ "github.com/testcontainers/testcontainers-go"
+ tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
+)
+
+const riverTemplate = "river_template"
+
+var (
+ server *pgxpool.Config
+ admin *pgxpool.Pool
+ databases atomic.Int64
+)
+
+func TestMain(m *testing.M) {
+ os.Exit(runTests(m))
+}
+
+func runTests(m *testing.M) int {
+ ctx := context.Background()
+ container, err := tcpostgres.Run(ctx, "postgres:18-alpine", tcpostgres.BasicWaitStrategies())
+ defer func() { _ = testcontainers.TerminateContainer(container) }()
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "启动 PostgreSQL 容器:", err)
+ return 1
+ }
+ url, err := container.ConnectionString(ctx, "sslmode=disable")
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ return 1
+ }
+ if server, err = pgxpool.ParseConfig(url); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ return 1
+ }
+ if admin, err = pgxpool.NewWithConfig(ctx, server); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ return 1
+ }
+ defer admin.Close()
+ if err := createRiverTemplate(ctx); err != nil {
+ fmt.Fprintln(os.Stderr, "准备模板数据库:", err)
+ return 1
+ }
+ return m.Run()
+}
+
+func createRiverTemplate(ctx context.Context) error {
+ if _, err := admin.Exec(ctx, "CREATE DATABASE "+riverTemplate); err != nil {
+ return err
+ }
+ cfg := server.Copy()
+ cfg.ConnConfig.Database = riverTemplate
+ pool, err := pgxpool.NewWithConfig(ctx, cfg)
+ if err != nil {
+ return err
+ }
+ // 作为模板的数据库在复制时不能有连接。
+ defer pool.Close()
+ migrator, err := rivermigrate.New(riverpgxv5.New(pool), &rivermigrate.Config{Logger: slog.New(slog.DiscardHandler)})
+ if err != nil {
+ return err
+ }
+ _, err = migrator.Migrate(ctx, rivermigrate.DirectionUp, nil)
+ return err
+}
+
+func newPool(t *testing.T) *pgxpool.Pool {
+ t.Helper()
+ name := fmt.Sprintf("test_%d", databases.Add(1))
+ if _, err := admin.Exec(t.Context(), fmt.Sprintf("CREATE DATABASE %s TEMPLATE %s", name, riverTemplate)); err != nil {
+ t.Fatal(err)
+ }
+ cfg := server.Copy()
+ cfg.ConnConfig.Database = name
+ pool, err := pgxpool.NewWithConfig(t.Context(), cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(pool.Close)
+ return pool
+}
diff --git a/internal/adapter/out/queue/queue_test.go b/internal/adapter/out/queue/queue_test.go
new file mode 100644
index 0000000..a083069
--- /dev/null
+++ b/internal/adapter/out/queue/queue_test.go
@@ -0,0 +1,177 @@
+package queue
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/riverqueue/river"
+ "github.com/riverqueue/river/riverdriver/riverpgxv5"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+type testWorker struct {
+ river.WorkerDefaults[prepareArgs]
+}
+
+func (testWorker) Work(_ context.Context, job *river.Job[prepareArgs]) error {
+ switch job.Args.ID {
+ case "ok":
+ return nil
+ case "rejected":
+ return river.JobCancel(errors.New("网易云没有提供这首歌的播放地址"))
+ }
+ return errors.New("连接被重置")
+}
+
+func newClient(t *testing.T, work bool) *river.Client[pgx.Tx] {
+ t.Helper()
+ workers := river.NewWorkers()
+ river.AddWorker(workers, testWorker{})
+ cfg := &river.Config{Workers: workers, Logger: slog.New(slog.DiscardHandler)}
+ if work {
+ cfg.Queues = map[string]river.QueueConfig{"prepare": {MaxWorkers: 4}}
+ }
+ client, err := river.NewClient(riverpgxv5.New(newPool(t)), cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if work {
+ if err := client.Start(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = client.Stop(context.Background()) })
+ }
+ return client
+}
+
+func netease(id string) track.Track { return track.Track{Source: track.NetEase, ID: id} }
+
+func TestSubmitDeduplicatesUnfinishedJobs(t *testing.T) {
+ q := New(newClient(t, false))
+ first, err := q.Submit(t.Context(), netease("1"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if again, err := q.Submit(t.Context(), netease("1")); err != nil || again != first {
+ t.Errorf("再次提交同一首歌 = %d, %v,期望已有的任务 %d", again, err, first)
+ }
+ if other, err := q.Submit(t.Context(), track.Track{Source: track.QQ, ID: "1"}); err != nil || other == first {
+ t.Errorf("另一首歌的任务 = %d, %v,期望新的任务", other, err)
+ }
+ if state, _, err := q.Status(t.Context(), first); err != nil || state != port.JobPending {
+ t.Errorf("等待执行的任务的状态 = %v, %v", state, err)
+ }
+ if state, _, err := q.Status(t.Context(), first+100); err != nil || state != port.JobFinished {
+ t.Errorf("不存在的任务的状态 = %v, %v,期望当作已完成", state, err)
+ }
+}
+
+// River 第一次重试的间隔约 1 秒。
+func waitFinished(t *testing.T, q *Queue, job int64) (port.JobState, string) {
+ t.Helper()
+ deadline := time.Now().Add(30 * time.Second)
+ for {
+ state, reason, err := q.Status(t.Context(), job)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if state != port.JobPending {
+ return state, reason
+ }
+ if time.Now().After(deadline) {
+ t.Fatalf("任务 %d 30 秒后仍未结束", job)
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+}
+
+func TestStatusFollowsJob(t *testing.T) {
+ q := New(newClient(t, true))
+ cases := []struct {
+ id string
+ state port.JobState
+ reason string
+ attempts int
+ }{
+ {"ok", port.JobFinished, "", 1},
+ {"rejected", port.JobFailed, "网易云没有提供这首歌的播放地址", 1},
+ {"broken", port.JobFailed, port.UnavailableReason, 2},
+ }
+ for _, c := range cases {
+ job, err := q.Submit(t.Context(), netease(c.id))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if state, reason := waitFinished(t, q, job); state != c.state || reason != c.reason {
+ t.Errorf("任务 %s 的状态 = %v, %q,期望 %v, %q", c.id, state, reason, c.state, c.reason)
+ }
+ row, err := q.client.JobGet(t.Context(), job)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if row.Attempt != c.attempts {
+ t.Errorf("任务 %s 执行了 %d 次,期望 %d 次:失败之后只重试 1 次,被音源拒绝的任务不重试", c.id, row.Attempt, c.attempts)
+ }
+ }
+
+ job, err := q.Submit(t.Context(), netease("ok"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if state, _ := waitFinished(t, q, job); state != port.JobFinished {
+ t.Errorf("再次提交已完成的歌之后的状态 = %v", state)
+ }
+ rows, err := q.client.JobList(t.Context(), river.NewJobListParams().Kinds("prepare").First(100))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(rows.Jobs) != 4 {
+ t.Errorf("共有 %d 个任务,期望再次提交已完成的歌时新建任务", len(rows.Jobs))
+ }
+}
+
+func TestJobsGoToTheirSourcesQueues(t *testing.T) {
+ pool := newPool(t)
+ client, err := river.NewClient(riverpgxv5.New(pool), &river.Config{Logger: slog.New(slog.DiscardHandler)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ q := New(client)
+ link := track.Track{Source: track.Link, ID: "https://www.youtube.com/watch?v=x"}
+ first, err := q.Submit(t.Context(), link)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if again, err := q.Submit(t.Context(), link); err != nil || again != first {
+ t.Errorf("再次提交同一个链接 = %d, %v,期望已有的任务 %d", again, err, first)
+ }
+ other, err := q.Submit(t.Context(), netease("1"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ video, err := q.Submit(t.Context(), track.Track{Source: track.Bilibili, ID: "BV1xx411c7mD"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for job, want := range map[int64]string{first: "ytdlp", other: "prepare", video: "bilibili"} {
+ row, err := client.JobGet(t.Context(), job)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if row.Queue != want || row.MaxAttempts != 2 {
+ t.Errorf("任务 %d 在队列 %s、最多执行 %d 次,期望 %s、2 次", job, row.Queue, row.MaxAttempts, want)
+ }
+ }
+ if _, err := q.Submit(t.Context(), track.Track{Source: track.Link, ID: "https://example.com/a.mp3"}); err != nil {
+ t.Fatal(err)
+ }
+ if n, err := WaitingLinkJobs(t.Context(), pool); err != nil || n != 2 {
+ t.Errorf("等待执行的链接准备任务 = %d, %v,期望 2", n, err)
+ }
+}
diff --git a/internal/adapter/out/redis/bus_test.go b/internal/adapter/out/redis/bus_test.go
new file mode 100644
index 0000000..1c0748f
--- /dev/null
+++ b/internal/adapter/out/redis/bus_test.go
@@ -0,0 +1,887 @@
+package redis
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+ "maps"
+ "net"
+ "reflect"
+ "slices"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/prometheus/client_golang/prometheus"
+ goredis "github.com/redis/go-redis/v9"
+ "go.opentelemetry.io/otel/attribute"
+ sdktrace "go.opentelemetry.io/otel/sdk/trace"
+ "go.opentelemetry.io/otel/sdk/trace/tracetest"
+ "go.opentelemetry.io/otel/trace"
+ "go.opentelemetry.io/otel/trace/noop"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/domain/member"
+ "github.com/dihemoe/alisten/internal/domain/room"
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+type busFixture struct {
+ bus *Bus
+ instance string
+ deliveries chan port.Delivery
+ metrics *prometheus.Registry
+ logs *logBuffer
+ tracer *sdktrace.TracerProvider
+ spans *tracetest.SpanRecorder
+}
+
+type logBuffer struct {
+ mu sync.Mutex
+ buf bytes.Buffer
+}
+
+func (b *logBuffer) Write(p []byte) (int, error) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.buf.Write(p)
+}
+
+func (b *logBuffer) String() string {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.buf.String()
+}
+
+// Pub/Sub 不按逻辑库隔离,所以实例 ID 和房间 ID 都是随机的。
+func newBus(t *testing.T, client *goredis.Client, version string) *busFixture {
+ t.Helper()
+ f := &busFixture{instance: rand.Text(), deliveries: make(chan port.Delivery, 64), metrics: prometheus.NewRegistry(), logs: &logBuffer{}, spans: tracetest.NewSpanRecorder()}
+ f.tracer = sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(f.spans))
+ // 生产环境的 logger 固定带有本实例的构建版本 version 字段。
+ f.bus = NewBus(client, f.instance, version, f.metrics, f.tracer, slog.New(slog.NewJSONHandler(f.logs, nil)).With("version", version))
+ ctx, cancel := context.WithCancel(context.Background())
+ var wg sync.WaitGroup
+ wg.Go(func() {
+ for {
+ d, err := f.bus.Receive(ctx)
+ if err != nil {
+ return
+ }
+ f.deliveries <- d
+ }
+ })
+ t.Cleanup(func() {
+ cancel()
+ wg.Wait()
+ })
+ return f
+}
+
+func (f *busFixture) next(t *testing.T) port.Delivery {
+ t.Helper()
+ select {
+ case d := <-f.deliveries:
+ return d
+ case <-time.After(20 * time.Second):
+ t.Fatal("没有收到消息")
+ return nil
+ }
+}
+
+func randomRoom() string { return rand.Text() }
+
+var (
+ when = time.Date(2026, 10, 22, 8, 30, 0, 123456789, time.UTC)
+ alice = member.Member{ID: "00000000-0000-7000-8000-00000000000a", Name: "alice", AvatarHash: "c160f8cc69a4f0bf2b0362752353d060"}
+ sunny = track.Track{Source: track.NetEase, ID: "186016"}
+ waiting = room.Entry{
+ ID: 12, Track: sunny, By: alice, Likes: 2, Ready: true, AddedAt: when,
+ Meta: track.Meta{Title: "晴天", Artist: "周杰伦", Album: "叶惠美", CoverURL: "https://p1.music.126.net/example.jpg", PageURL: "https://music.163.com/#/song?id=186016", Duration: 269 * time.Second},
+ Prep: track.Prepared{Duration: 269500 * time.Millisecond, Gain: 0.75, Lyric: "[00:01.00]故事的小黄花"},
+ }
+ link = room.Entry{ID: 13, Track: track.Track{Source: track.Link, ID: "https://www.youtube.com/watch?v=x"}, Meta: track.Meta{Title: "https://www.youtube.com/watch?v=x"}, Auto: true, AddedAt: when}
+ current = room.NowPlaying{Entry: waiting, StartedAt: when.Add(time.Minute)}
+ view = room.View{
+ Term: 3, Seq: 128,
+ Snapshot: room.Snapshot{
+ Info: room.Info{ID: "01a15778-1911-7a28-8b2e-e08c2a25f388", Name: "大厅", Description: "常驻", Kind: room.Persistent},
+ Mode: room.Random,
+ EntrySeq: 13,
+ Waiting: []room.Entry{link},
+ Pending: &waiting,
+ Current: ¤t,
+ },
+ Sessions: []room.Session{{ID: "a/s1", Member: alice}},
+ }
+)
+
+func roomMessageSamples() []port.RoomMessage {
+ return []port.RoomMessage{
+ port.OwnerChanged{Term: 3},
+ port.Emitted{Emit: room.Emit{Term: 3, Seq: 1, Event: room.PlaybackChanged{Current: ¤t}}},
+ port.Emitted{Emit: room.Emit{Term: 3, Seq: 3, Event: room.QueueChanged{Pending: &waiting, Waiting: []room.Entry{link, waiting}}}},
+ port.Emitted{Emit: room.Emit{Term: 3, Seq: 4, Event: room.MembersChanged{Sessions: view.Sessions}}},
+ port.Emitted{Emit: room.Emit{Term: 3, Seq: 5, Event: room.ModeChanged{Mode: room.Random}}},
+ port.Emitted{Emit: room.Emit{Term: 3, Seq: 6, Event: room.InfoChanged{Name: "大厅", Description: "常驻"}}},
+ port.Emitted{Emit: room.Emit{Term: 3, Seq: 7, Event: room.ChatPosted{Member: alice, Text: "你好", SentAt: when}}},
+ port.Emitted{Emit: room.Emit{Term: 3, Seq: 1 << 40, Event: room.Activity{Kind: room.ActivityVoted, Member: alice, Entry: waiting, Votes: 1, Required: 2, Reason: "原因"}}},
+ port.Replied{Reply: room.Reply{RequestID: "a/r1", Result: room.Done{}}},
+ port.Replied{Reply: room.Reply{RequestID: "a/r2", Result: room.EnqueueResult{Entry: waiting}}},
+ port.Replied{Reply: room.Reply{RequestID: "a/r3", Result: room.LikeResult{Likes: 3}}},
+ port.Replied{Reply: room.Reply{RequestID: "a/r4", Result: room.VoteResult{Votes: 1, Required: 2, Skipped: true}}},
+ port.Replied{Reply: room.Reply{RequestID: "a/r5", Result: room.SyncResult{View: view}}},
+ port.Replied{Reply: room.Reply{RequestID: "a/r6", Err: &room.Error{Kind: room.QueueFull, Message: "临时房间的播放列表最多 10 首歌,等播放几首之后再点。"}}},
+ port.Rejected{RequestID: "a/r8", NotFound: true},
+ port.RoomClosed{},
+ port.OwnerReleased{},
+ port.TicketsRevoked{},
+ port.APIKeyReset{},
+ }
+}
+
+func TestRoomMessagesArriveInOrder(t *testing.T) {
+ client := newClient(t)
+ a, b := newBus(t, client, "v1"), newBus(t, client, "v1")
+ roomID := randomRoom()
+ if err := b.bus.SubscribeRoom(t.Context(), roomID); err != nil {
+ t.Fatal(err)
+ }
+ samples := roomMessageSamples()
+ for _, m := range samples {
+ if err := a.bus.PublishRoom(t.Context(), roomID, m); err != nil {
+ t.Fatalf("发布 %T:%v", m, err)
+ }
+ }
+ for _, want := range samples {
+ got := b.next(t)
+ if !reflect.DeepEqual(got, port.RoomDelivery{Room: roomID, Message: want}) {
+ t.Errorf("收到 %#v\n期望 %#v", got, want)
+ }
+ }
+}
+
+func TestRoomInputs(t *testing.T) {
+ client := newClient(t)
+ a, b := newBus(t, client, "v1"), newBus(t, client, "v1")
+ join := port.RoomInput{Room: randomRoom(), Gateway: a.instance, Input: room.Join{Session: "a/s1", Member: alice}, Takeover: true}
+ if ok, err := a.bus.SendInput(t.Context(), b.instance, join); err != nil || ok {
+ t.Errorf("对方还没有订阅时 SendInput = %v, %v,期望 false", ok, err)
+ }
+ if err := b.bus.SubscribeInstance(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ name := "新大厅"
+ samples := []port.RoomInput{join}
+ for _, a := range []room.Action{room.Enqueue{Track: sunny, Meta: waiting.Meta}, room.Remove{Entry: 12}, room.Like{Entry: 12}, room.VoteSkip{}, room.SetPlayMode{Mode: room.Random}, room.Chat{Text: "你好"}, room.Sync{}} {
+ samples = append(samples, port.RoomInput{Room: join.Room, Gateway: "g", Input: room.Command{RequestID: "g/r1", Member: alice, Action: a}, Takeover: true})
+ }
+ samples = append(samples,
+ port.RoomInput{Room: join.Room, Gateway: "g", Input: room.Leave{Session: "a/s1"}},
+ port.RoomInput{Room: join.Room, Gateway: "g", Input: room.MemberUpdated{Member: alice}},
+ port.RoomInput{Room: join.Room, Gateway: "g", Input: room.Delete{RequestID: "g/r2"}, Takeover: true},
+ port.RoomInput{Room: join.Room, Gateway: "g", Patch: &port.InfoPatch{RequestID: "g/r3", Name: &name}, Takeover: true},
+ )
+ for _, in := range samples {
+ if ok, err := a.bus.SendInput(t.Context(), b.instance, in); err != nil || !ok {
+ t.Fatalf("SendInput(%#v) = %v, %v", in.Input, ok, err)
+ }
+ }
+ for _, want := range samples {
+ got, ok := b.next(t).(port.InputDelivery)
+ if !ok || !reflect.DeepEqual(got.Input, want) {
+ t.Errorf("收到 %#v\n期望 %#v", got, want)
+ }
+ if ok && trace.SpanContextFromContext(got.Context).IsValid() {
+ t.Errorf("发送时没有 span 的输入带着追踪上下文:%#v", got)
+ }
+ }
+ if n := len(b.spans.Ended()); n != 0 {
+ t.Errorf("没有追踪上下文的输入记了 %d 个 span", n)
+ }
+ if err := a.bus.PublishMember(t.Context(), alice); err != nil {
+ t.Fatal(err)
+ }
+ if got := b.next(t); got != (port.MemberDelivery{Member: alice}) {
+ t.Errorf("收到 %#v,期望成员资料", got)
+ }
+ if _, err := a.bus.SendInput(t.Context(), b.instance, port.RoomInput{Room: join.Room, Input: room.Timer{Token: 1}}); err == nil {
+ t.Error("计时器只在本实例内投递,不应能编码")
+ }
+}
+
+func TestInputCarriesTraceContext(t *testing.T) {
+ client := newClient(t)
+ a, b := newBus(t, client, "v1"), newBus(t, client, "v1")
+ if err := b.bus.SubscribeInstance(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ ctx, sending := a.tracer.Tracer("test").Start(t.Context(), "WS queue.add")
+ in := port.RoomInput{Room: randomRoom(), Gateway: a.instance, Input: room.Command{RequestID: "a/r1", Member: alice, Action: room.Sync{}}, Takeover: true}
+ if ok, err := a.bus.SendInput(ctx, b.instance, in); err != nil || !ok {
+ t.Fatalf("SendInput = %v, %v", ok, err)
+ }
+ sending.End()
+
+ got, ok := b.next(t).(port.InputDelivery)
+ if !ok || !reflect.DeepEqual(got.Input, in) {
+ t.Fatalf("收到 %#v,期望 %#v", got, in)
+ }
+ received := b.spans.Ended()
+ if len(received) != 1 {
+ t.Fatalf("收到输入时记了 %d 个 span,期望 1 个", len(received))
+ }
+ span := received[0]
+ if span.Name() != "receive room input" || span.SpanKind() != trace.SpanKindConsumer {
+ t.Errorf("span = %s(%v),期望 consumer span receive room input", span.Name(), span.SpanKind())
+ }
+ if span.SpanContext().TraceID() != sending.SpanContext().TraceID() || span.Parent().SpanID() != sending.SpanContext().SpanID() || !span.Parent().IsRemote() {
+ t.Errorf("span 的父 span = %v,期望是网关一侧发送时的 span %v", span.Parent(), sending.SpanContext())
+ }
+ if attrs := span.Attributes(); !slices.ContainsFunc(attrs, func(kv attribute.KeyValue) bool { return kv.Key == "alisten.room_id" && kv.Value.AsString() == in.Room }) {
+ t.Errorf("span 的属性 = %v,期望带着房间 ID", attrs)
+ }
+ if sc := trace.SpanContextFromContext(got.Context); sc.SpanID() != span.SpanContext().SpanID() {
+ t.Errorf("交给房间的 ctx 中的 span = %v,期望是收到输入时记的 span", sc)
+ }
+}
+
+func TestDroppedInputRecordsNoSpan(t *testing.T) {
+ client := newClient(t)
+ a, b := newBus(t, client, "v1"), newBus(t, client, "v1")
+ if err := b.bus.SubscribeInstance(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ const traceparent = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
+ payload := fmt.Sprintf(`{"Version":"v1","Kind":"RoomInput","Trace":{"traceparent":%q},"Data":{"Input":"Teleported","Data":{}}}`, traceparent)
+ if err := client.Publish(t.Context(), instanceChannel(b.instance), payload).Err(); err != nil {
+ t.Fatal(err)
+ }
+ // 同一个频道上的消息按顺序处理:收到后面这条有效的输入时,前面那条已经丢弃了。
+ valid := port.RoomInput{Room: randomRoom(), Gateway: a.instance, Input: room.Join{Session: "a/s1", Member: alice}, Takeover: true}
+ if ok, err := a.bus.SendInput(t.Context(), b.instance, valid); err != nil || !ok {
+ t.Fatalf("SendInput = %v, %v", ok, err)
+ }
+ if got, ok := b.next(t).(port.InputDelivery); !ok || !reflect.DeepEqual(got.Input, valid) {
+ t.Fatalf("收到 %#v,期望前面无法解码的输入被丢弃", got)
+ }
+ if got := droppedCounts(t, b.metrics)["malformed"]; got != 1 {
+ t.Errorf("alisten_bus_dropped_total{malformed} = %v,期望 1", got)
+ }
+ if n := len(b.spans.Started()); n != 0 {
+ t.Errorf("丢弃的输入开始了 %d 个 span", n)
+ }
+}
+
+func TestPreparedOutcomes(t *testing.T) {
+ client := newClient(t)
+ a, b := newBus(t, client, "v1"), newBus(t, client, "v1")
+ for _, f := range []*busFixture{a, b} {
+ if err := f.bus.SubscribeInstance(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ }
+ outcomes := []port.PrepareOutcome{
+ {Track: sunny, JobID: 1},
+ {Track: track.Track{Source: track.QQ, ID: "0039MnYb0qxYhV"}, JobID: 1 << 40, Reason: "酷我没有提供这首歌的播放地址"},
+ }
+ for _, o := range outcomes {
+ if err := a.bus.PublishPrepared(t.Context(), o); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for _, f := range []*busFixture{a, b} {
+ for _, o := range outcomes {
+ if got := f.next(t); got != (port.PreparedDelivery{Outcome: o}) {
+ t.Errorf("收到 %#v,期望 %#v", got, o)
+ }
+ }
+ }
+}
+
+func TestUnsubscribeRoom(t *testing.T) {
+ client := newClient(t)
+ a, b := newBus(t, client, "v1"), newBus(t, client, "v1")
+ gone, kept := randomRoom(), randomRoom()
+ if err := b.bus.SubscribeRoom(t.Context(), gone); err != nil {
+ t.Fatal(err)
+ }
+ if err := b.bus.UnsubscribeRoom(t.Context(), gone); err != nil {
+ t.Fatal(err)
+ }
+ // 退订不等服务端确认。同一个连接上的命令按顺序处理,之后的订阅生效时,退订已经生效。
+ if err := b.bus.SubscribeRoom(t.Context(), kept); err != nil {
+ t.Fatal(err)
+ }
+ for _, id := range []string{gone, kept} {
+ if err := a.bus.PublishRoom(t.Context(), id, port.RoomClosed{}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if got := b.next(t); got != (port.RoomDelivery{Room: kept, Message: port.RoomClosed{}}) {
+ t.Errorf("退订之后收到 %#v,期望只收到另一个房间的消息", got)
+ }
+}
+
+func TestDroppedMessages(t *testing.T) {
+ client := newClient(t)
+ old, b := newBus(t, client, "v0"), newBus(t, client, "v1")
+ roomID := randomRoom()
+ if err := b.bus.SubscribeRoom(t.Context(), roomID); err != nil {
+ t.Fatal(err)
+ }
+ if err := old.bus.PublishRoom(t.Context(), roomID, port.RoomClosed{}); err != nil {
+ t.Fatal(err)
+ }
+ for _, payload := range []string{"不是 JSON", `{"Version":"v1","Kind":"Teleported","Data":{}}`} {
+ if err := client.Publish(t.Context(), roomChannel(roomID), payload).Err(); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := b.bus.PublishRoom(t.Context(), roomID, port.OwnerReleased{}); err != nil {
+ t.Fatal(err)
+ }
+ if got := b.next(t); got != (port.RoomDelivery{Room: roomID, Message: port.OwnerReleased{}}) {
+ t.Errorf("收到 %#v,期望前三条消息都被丢弃", got)
+ }
+ if got, want := droppedCounts(t, b.metrics), map[string]float64{"version": 1, "malformed": 2}; !maps.Equal(got, want) {
+ t.Errorf("alisten_bus_dropped_total = %v,期望 %v", got, want)
+ }
+ // 对端的构建版本不能占用 logger 固定的 version 字段,否则 JSON 里有两个 version,Loki 只会留下其中一个。
+ type logged struct {
+ Level, From, Version string
+ RemoteVersion string `json:"remote_version"`
+ }
+ var got []logged
+ for line := range strings.Lines(strings.TrimSpace(b.logs.String())) {
+ if n := strings.Count(line, `"version"`); n != 1 {
+ t.Errorf("日志中有 %d 个 version 字段,期望 1 个:%s", n, line)
+ }
+ var entry logged
+ if err := json.Unmarshal([]byte(line), &entry); err != nil {
+ t.Fatal(err)
+ }
+ got = append(got, entry)
+ }
+ want := []logged{
+ {Level: "ERROR", From: old.instance, Version: "v1", RemoteVersion: "v0"},
+ {Level: "ERROR", Version: "v1"},
+ {Level: "ERROR", Version: "v1"},
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("日志 = %+v,期望 %+v", got, want)
+ }
+}
+
+func droppedCounts(t *testing.T, metrics *prometheus.Registry) map[string]float64 {
+ t.Helper()
+ families, err := metrics.Gather()
+ if err != nil {
+ t.Fatal(err)
+ }
+ counts := map[string]float64{}
+ for _, f := range families {
+ if f.GetName() != "alisten_bus_dropped_total" {
+ continue
+ }
+ for _, m := range f.GetMetric() {
+ counts[m.GetLabel()[0].GetValue()] = m.GetCounter().GetValue()
+ }
+ }
+ return counts
+}
+
+func TestSubscribeRoomWaitsForServer(t *testing.T) {
+ b := newBus(t, newClient(t), "v1")
+ if err := b.bus.SubscribeInstance(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ pause := 500 * time.Millisecond
+ if err := b.bus.client.Do(t.Context(), "CLIENT", "PAUSE", pause.Milliseconds(), "ALL").Err(); err != nil {
+ t.Fatal(err)
+ }
+ start := time.Now()
+ if err := b.bus.SubscribeRoom(t.Context(), randomRoom()); err != nil {
+ t.Fatal(err)
+ }
+ if elapsed := time.Since(start); elapsed < pause*4/5 {
+ t.Errorf("服务端暂停 %v 时订阅 %v 就返回了,期望等到服务端处理之后", pause, elapsed)
+ }
+}
+
+func killSubscriptions(t *testing.T, client *goredis.Client) {
+ t.Helper()
+ clients, err := client.Do(t.Context(), "CLIENT", "LIST", "TYPE", "pubsub").Text()
+ if err != nil {
+ t.Fatal(err)
+ }
+ db := fmt.Sprintf(" db=%d ", client.Options().DB)
+ killed := 0
+ for line := range strings.Lines(clients) {
+ id, _, _ := strings.Cut(strings.TrimPrefix(line, "id="), " ")
+ if strings.Contains(line, db) {
+ if err := client.Do(t.Context(), "CLIENT", "KILL", "ID", id).Err(); err != nil {
+ t.Fatal(err)
+ }
+ killed++
+ }
+ }
+ if killed != 1 {
+ t.Fatalf("断开了 %d 个订阅连接,期望 1 个", killed)
+ }
+}
+
+func TestSubscriptionLostAfterDisconnect(t *testing.T) {
+ a, b := newBus(t, newClient(t), "v1"), newBus(t, newClient(t), "v1")
+ roomID := randomRoom()
+ if err := b.bus.SubscribeRoom(t.Context(), roomID); err != nil {
+ t.Fatal(err)
+ }
+ killSubscriptions(t, b.bus.client)
+ if got := b.next(t); got != (port.SubscriptionLost{}) {
+ t.Fatalf("订阅连接断开之后收到 %#v,期望 SubscriptionLost", got)
+ }
+ if err := b.bus.SubscribeRoom(t.Context(), randomRoom()); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.bus.PublishRoom(t.Context(), roomID, port.RoomClosed{}); err != nil {
+ t.Fatal(err)
+ }
+ if got := b.next(t); got != (port.RoomDelivery{Room: roomID, Message: port.RoomClosed{}}) {
+ t.Errorf("重新连接之后收到 %#v", got)
+ }
+}
+
+func TestSubscriptionLostWhenConnectionStalls(t *testing.T) {
+ a, b := newBus(t, newClient(t), "v1"), newBus(t, newClient(t), "v1")
+ roomID := randomRoom()
+ // 接收循环的超时从它收到订阅确认时算起,早于 CLIENT PAUSE 返回,所以从订阅之前开始计时。
+ start := time.Now()
+ if err := b.bus.SubscribeRoom(t.Context(), roomID); err != nil {
+ t.Fatal(err)
+ }
+ pause := 2*pingInterval + 2*time.Second
+ if err := a.bus.client.Do(t.Context(), "CLIENT", "PAUSE", pause.Milliseconds(), "ALL").Err(); err != nil {
+ t.Fatal(err)
+ }
+ if got := b.next(t); got != (port.SubscriptionLost{}) {
+ t.Fatalf("连接停止响应之后收到 %#v,期望 SubscriptionLost", got)
+ }
+ if elapsed := time.Since(start); elapsed < 2*pingInterval || elapsed > pause {
+ t.Errorf("%v 之后才发现连接停止响应,期望约 %v", elapsed, 2*pingInterval)
+ }
+ if err := b.bus.SubscribeRoom(t.Context(), randomRoom()); err != nil {
+ t.Fatal(err)
+ }
+ if err := a.bus.PublishRoom(t.Context(), roomID, port.RoomClosed{}); err != nil {
+ t.Fatal(err)
+ }
+ if got := b.next(t); got != (port.RoomDelivery{Room: roomID, Message: port.RoomClosed{}}) {
+ t.Errorf("换了新连接之后收到 %#v", got)
+ }
+}
+
+func TestLateCallerDoesNotBreakTheSharedConnection(t *testing.T) {
+ a, b := newBus(t, newClient(t), "v1"), newBus(t, newClient(t), "v1")
+ kept, other := randomRoom(), randomRoom()
+ for _, id := range []string{kept, other} {
+ if err := b.bus.SubscribeRoom(t.Context(), id); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ expired, cancel := context.WithTimeout(t.Context(), -time.Millisecond)
+ defer cancel()
+ if err := b.bus.SubscribeRoom(expired, randomRoom()); err == nil {
+ t.Error("期限已经过去的调用方订阅成功了,期望失败")
+ }
+ if err := b.bus.UnsubscribeRoom(expired, other); err == nil {
+ t.Error("期限已经过去的调用方退订成功了,期望失败")
+ }
+ short, cancel := context.WithTimeout(t.Context(), 10*time.Millisecond)
+ defer cancel()
+ // 能否等到服务端的确认取决于调度,这里只要求订阅连接没有被打断。
+ _ = b.bus.SubscribeRoom(short, randomRoom())
+ _ = b.bus.UnsubscribeRoom(short, other)
+
+ if err := a.bus.PublishRoom(t.Context(), kept, port.RoomClosed{}); err != nil {
+ t.Fatal(err)
+ }
+ if got := b.next(t); got != (port.RoomDelivery{Room: kept, Message: port.RoomClosed{}}) {
+ t.Errorf("迟到的调用方之后收到 %#v,期望订阅连接没有被打断", got)
+ }
+ if err := b.bus.SubscribeRoom(t.Context(), randomRoom()); err != nil {
+ t.Errorf("迟到的调用方之后订阅失败:%v", err)
+ }
+}
+
+// Freeze 之后,已有的连接保持打开,但收到的字节全部丢弃,不再转发:
+// 对客户端来说这是半开的 TCP 连接,写得出去,读不到错误,也等不到回复。Freeze 之后新建的连接照常转发。
+type freezeProxy struct {
+ listener net.Listener
+
+ mu sync.Mutex
+ conns []*proxiedConn
+ accepted int
+ hold, held chan struct{}
+}
+
+type proxiedConn struct {
+ client, server net.Conn
+ frozen atomic.Bool
+}
+
+func startFreezeProxy(t *testing.T, target string) *freezeProxy {
+ t.Helper()
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ p := &freezeProxy{listener: listener}
+ t.Cleanup(p.close)
+ go func() {
+ for {
+ client, err := listener.Accept()
+ if err != nil {
+ return
+ }
+ p.mu.Lock()
+ p.accepted++
+ hold, held := p.hold, p.held
+ p.mu.Unlock()
+ if hold != nil {
+ select {
+ case held <- struct{}{}:
+ default:
+ }
+ <-hold
+ }
+ server, err := (&net.Dialer{Timeout: 5 * time.Second}).DialContext(t.Context(), "tcp", target)
+ if err != nil {
+ _ = client.Close()
+ continue
+ }
+ c := &proxiedConn{client: client, server: server}
+ p.mu.Lock()
+ p.conns = append(p.conns, c)
+ p.mu.Unlock()
+ go c.pump(server, client)
+ go c.pump(client, server)
+ }
+ }()
+ return p
+}
+
+func (p *freezeProxy) addr() string { return p.listener.Addr().String() }
+
+func (p *freezeProxy) Freeze() {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ for _, c := range p.conns {
+ c.frozen.Store(true)
+ }
+}
+
+// 此后新建的连接在建立后停住,客户端收不到任何回复,直到调用返回的 release;每停住一个连接,held 通知一次。
+func (p *freezeProxy) HoldNew() (held <-chan struct{}, release func()) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.hold, p.held = make(chan struct{}), make(chan struct{}, 1)
+ hold := p.hold
+ return p.held, func() {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.hold, p.held = nil, nil
+ close(hold)
+ }
+}
+
+func (p *freezeProxy) DropAll() {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ for _, c := range p.conns {
+ c.shut()
+ }
+}
+
+func (p *freezeProxy) Accepted() int {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ return p.accepted
+}
+
+func (p *freezeProxy) close() {
+ _ = p.listener.Close()
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ for _, c := range p.conns {
+ c.shut()
+ }
+}
+
+func (c *proxiedConn) shut() {
+ _ = c.client.Close()
+ _ = c.server.Close()
+}
+
+func (c *proxiedConn) pump(dst, src net.Conn) {
+ defer c.shut()
+ buf := make([]byte, 32<<10)
+ for {
+ n, err := src.Read(buf)
+ if n > 0 && !c.frozen.Load() {
+ if _, werr := dst.Write(buf[:n]); werr != nil {
+ return
+ }
+ }
+ if err != nil {
+ return
+ }
+ }
+}
+
+func TestHalfOpenSubscriptionIsReplaced(t *testing.T) {
+ proxy := startFreezeProxy(t, server.Addr)
+ opts := *server
+ opts.Addr = proxy.addr()
+ opts.DB = int(databases.Add(1))
+ through := goredis.NewClient(&opts)
+ t.Cleanup(func() { _ = through.Close() })
+
+ a, b := newBus(t, newClient(t), "v1"), newBus(t, through, "v1")
+ roomID := randomRoom()
+ if err := b.bus.SubscribeRoom(t.Context(), roomID); err != nil {
+ t.Fatal(err)
+ }
+ proxy.Freeze()
+ if got := b.next(t); got != (port.SubscriptionLost{}) {
+ t.Fatalf("连接半开之后收到 %#v,期望 SubscriptionLost", got)
+ }
+
+ // 订阅生效之前发布的消息会丢失,所以反复发布,直到收到为止。
+ closed := port.RoomDelivery{Room: roomID, Message: port.RoomClosed{}}
+ timeout := time.After(20 * time.Second)
+ for {
+ if err := a.bus.PublishRoom(t.Context(), roomID, port.RoomClosed{}); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case got := <-b.deliveries:
+ if got != closed {
+ t.Fatalf("换了新连接之后收到 %#v,期望 %#v", got, closed)
+ }
+ return
+ case <-timeout:
+ t.Fatal("连接半开之后 20 秒内仍收不到房间的消息,订阅连接没有换成新的")
+ case <-time.After(500 * time.Millisecond):
+ }
+ }
+}
+
+func proxiedClient(t *testing.T, proxy *freezeProxy) *goredis.Client {
+ t.Helper()
+ opts := *server
+ opts.Addr = proxy.addr()
+ opts.DB = int(databases.Add(1))
+ client := goredis.NewClient(&opts)
+ t.Cleanup(func() { _ = client.Close() })
+ return client
+}
+
+func TestCallerWaitingOnReconnectDoesNotBreakTheNewConnection(t *testing.T) {
+ proxy := startFreezeProxy(t, server.Addr)
+ b := newBus(t, proxiedClient(t, proxy), "v1")
+ if err := b.bus.SubscribeRoom(t.Context(), randomRoom()); err != nil {
+ t.Fatal(err)
+ }
+ held, release := proxy.HoldNew()
+ proxy.DropAll()
+ select {
+ case <-held:
+ case <-time.After(10 * time.Second):
+ t.Fatal("连接断开之后 go-redis 没有重新连接")
+ }
+ ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
+ defer cancel()
+ done := make(chan error, 1)
+ go func() { done <- b.bus.SubscribeRoom(ctx, randomRoom()) }()
+ time.Sleep(500 * time.Millisecond)
+ release()
+ if err := <-done; err == nil {
+ t.Error("期限已经用完的调用方订阅成功了,期望失败")
+ }
+ time.Sleep(2 * time.Second)
+ if n := proxy.Accepted(); n != 2 {
+ t.Errorf("代理接受了 %d 个连接,期望 2 个:迟到的写入让刚建立的连接被当作坏连接重建了", n)
+ }
+}
+
+func TestSubscribeGivesUpWithCallerWhileConnecting(t *testing.T) {
+ opts := *server
+ opts.Dialer = func(ctx context.Context, _, _ string) (net.Conn, error) {
+ <-ctx.Done()
+ return nil, ctx.Err()
+ }
+ client := goredis.NewClient(&opts)
+ t.Cleanup(func() { _ = client.Close() })
+ bus := NewBus(client, "a", "v1", prometheus.NewRegistry(), noop.NewTracerProvider(), slog.New(slog.DiscardHandler))
+ start := time.Now()
+ var wg sync.WaitGroup
+ for i := range 3 {
+ wg.Go(func() {
+ ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
+ defer cancel()
+ if err := bus.SubscribeRoom(ctx, fmt.Sprint("r", i)); err == nil {
+ t.Error("连不上 Redis 时订阅成功了")
+ }
+ })
+ }
+ wg.Wait()
+ if elapsed := time.Since(start); elapsed > 2*time.Second {
+ t.Errorf("3 个期限 200ms 的订阅 %v 之后才全部返回,期望随调用方结束", elapsed)
+ }
+}
+
+func TestSubscribeIsConfirmedOnReplacedConnection(t *testing.T) {
+ proxy := startFreezeProxy(t, server.Addr)
+ b := newBus(t, proxiedClient(t, proxy), "v1")
+ if err := b.bus.SubscribeInstance(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ proxy.Freeze()
+ // 半开的连接要 2 个 pingInterval 才被发现并换掉。
+ ctx, cancel := context.WithTimeout(t.Context(), 4*pingInterval)
+ defer cancel()
+ if err := b.bus.SubscribeRoom(ctx, randomRoom()); err != nil {
+ t.Fatalf("换了新连接之后订阅仍没有得到确认:%v", err)
+ }
+}
+
+func TestReplaceDoesNotWaitForWriterOnOldSubscription(t *testing.T) {
+ proxy := startFreezeProxy(t, server.Addr)
+ bus := NewBus(proxiedClient(t, proxy), "a", "v1", prometheus.NewRegistry(), noop.NewTracerProvider(), slog.New(slog.DiscardHandler))
+ old := bus.ps
+ var wg sync.WaitGroup
+ defer wg.Wait()
+ ctx, cancel := context.WithCancel(t.Context())
+ defer cancel()
+ held, release := proxy.HoldNew()
+ // old 还没有连接,这次订阅持有 old 的锁建立连接,而代理不回复。
+ wg.Go(func() { _ = bus.SubscribeRoom(ctx, randomRoom()) })
+ select {
+ case <-held:
+ case <-time.After(10 * time.Second):
+ release()
+ t.Fatal("订阅没有建立连接")
+ }
+
+ // ReceiveTimeout 也要 old 的锁,无法从外部安排写入恰好在接收循环最后一次接收之后取得锁,所以直接调用 replace。
+ replaced := make(chan struct{})
+ go func() {
+ bus.replace(old)
+ close(replaced)
+ }()
+ select {
+ case <-replaced:
+ case <-time.After(time.Second):
+ t.Error("换下订阅对象 1 秒内没有返回,期望不等旧对象上卡在网络上的写入")
+ }
+ expired, cancelExpired := context.WithTimeout(t.Context(), -time.Millisecond)
+ defer cancelExpired()
+ unsubscribed := make(chan struct{})
+ go func() {
+ _ = bus.UnsubscribeRoom(expired, randomRoom())
+ close(unsubscribed)
+ }()
+ select {
+ case <-unsubscribed:
+ case <-time.After(time.Second):
+ t.Error("换下订阅对象之后退订 1 秒内没有返回,期望不等旧对象上卡在网络上的写入")
+ }
+
+ release()
+ <-replaced
+ deadline := time.Now().Add(10 * time.Second)
+ for !errors.Is(old.Ping(t.Context()), goredis.ErrClosed) {
+ if time.Now().After(deadline) {
+ t.Fatal("旧的订阅对象没有关闭")
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+}
+
+func TestSubscribeAfterCloseDoesNotWaitForReconnect(t *testing.T) {
+ proxy := startFreezeProxy(t, server.Addr)
+ bus := NewBus(proxiedClient(t, proxy), "a", "v1", prometheus.NewRegistry(), noop.NewTracerProvider(), slog.New(slog.DiscardHandler))
+ var wg sync.WaitGroup
+ defer wg.Wait()
+ ctx, cancel := context.WithCancel(t.Context())
+ defer cancel()
+ wg.Go(func() {
+ for {
+ if _, err := bus.Receive(ctx); err != nil {
+ return
+ }
+ }
+ })
+ if err := bus.SubscribeRoom(t.Context(), randomRoom()); err != nil {
+ t.Fatal(err)
+ }
+ held, release := proxy.HoldNew()
+ proxy.DropAll()
+ select {
+ case <-held:
+ case <-time.After(10 * time.Second):
+ release()
+ t.Fatal("连接断开之后 go-redis 没有重新连接")
+ }
+
+ // 接收循环正持有订阅对象的锁重新连接,而代理不回复。
+ cancel()
+ <-bus.done
+ subscribed := make(chan error, 1)
+ go func() { subscribed <- bus.SubscribeRoom(t.Context(), randomRoom()) }()
+ select {
+ case err := <-subscribed:
+ if !errors.Is(err, errBusClosed) {
+ t.Errorf("关闭之后订阅返回 %v,期望 %v", err, errBusClosed)
+ }
+ case <-time.After(time.Second):
+ t.Error("关闭之后订阅 1 秒内没有返回,期望不等 go-redis 重新连接")
+ }
+ release()
+}
+
+func TestInputOnRoomChannelIsDropped(t *testing.T) {
+ b := newBus(t, newClient(t), "v1")
+ roomID := randomRoom()
+ if err := b.bus.SubscribeRoom(t.Context(), roomID); err != nil {
+ t.Fatal(err)
+ }
+ data, err := encodeInput(port.RoomInput{Room: roomID, Gateway: "x", Input: room.Leave{Session: "x/s1"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ payload, err := json.Marshal(envelope{Version: "v1", From: "x", Kind: kindInput, Data: data})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := b.bus.client.Publish(t.Context(), roomChannel(roomID), payload).Err(); err != nil {
+ t.Fatal(err)
+ }
+ if err := b.bus.PublishRoom(t.Context(), roomID, port.RoomClosed{}); err != nil {
+ t.Fatal(err)
+ }
+ if got := b.next(t); got != (port.RoomDelivery{Room: roomID, Message: port.RoomClosed{}}) {
+ t.Errorf("收到 %#v,期望房间频道上的 input 被丢弃", got)
+ }
+ if got := droppedCounts(t, b.metrics)["malformed"]; got != 1 {
+ t.Errorf("丢弃的 malformed 消息 = %v,期望 1", got)
+ }
+}
diff --git a/internal/adapter/out/redis/lease_test.go b/internal/adapter/out/redis/lease_test.go
new file mode 100644
index 0000000..4443776
--- /dev/null
+++ b/internal/adapter/out/redis/lease_test.go
@@ -0,0 +1,127 @@
+package redis
+
+import (
+ "slices"
+ "testing"
+ "time"
+)
+
+const (
+ hall = "01a15778-1911-7a28-8b2e-e08c2a25f388"
+ party = "01a15778-1911-7a28-8b2e-e08c2a25f389"
+)
+
+func TestLeaseBelongsToOneInstance(t *testing.T) {
+ client := newClient(t)
+ a, b := NewLeases(client, "a"), NewLeases(client, "b")
+ ctx := t.Context()
+
+ if ok, err := a.Acquire(ctx, hall, 15*time.Second); err != nil || !ok {
+ t.Fatalf("a 取得空闲的租约 = %v, %v", ok, err)
+ }
+ if ok, err := b.Acquire(ctx, hall, 15*time.Second); err != nil || ok {
+ t.Errorf("b 取得 a 的租约 = %v, %v,期望 false", ok, err)
+ }
+ if ok, err := a.Acquire(ctx, hall, 15*time.Second); err != nil || !ok {
+ t.Errorf("a 再次取得自己的租约 = %v, %v,期望 true", ok, err)
+ }
+ if owners, err := b.Owners(ctx, []string{hall, party}); err != nil || !slices.Equal(owners, []string{"a", ""}) {
+ t.Errorf("Owners = %q, %v,期望 [a \"\"]", owners, err)
+ }
+
+ if ok, err := b.Renew(ctx, hall, 15*time.Second); err != nil || ok {
+ t.Errorf("b 续期 a 的租约 = %v, %v,期望 false", ok, err)
+ }
+ if ok, err := b.SetOnline(ctx, hall, 5); err != nil || ok {
+ t.Errorf("b 写入 a 的房间的在线人数 = %v, %v,期望 false", ok, err)
+ }
+ if counts, err := a.OnlineCounts(ctx, []string{hall, party}); err != nil || !slices.Equal(counts, []int{0, 0}) {
+ t.Errorf("取得租约之后的在线人数 = %v, %v,期望 [0 0]", counts, err)
+ }
+ if err := client.PExpire(ctx, ownerKey(hall), 10*time.Second).Err(); err != nil {
+ t.Fatal(err)
+ }
+ if ok, err := a.SetOnline(ctx, hall, 3); err != nil || !ok {
+ t.Fatalf("a 写入在线人数 = %v, %v", ok, err)
+ }
+ if counts, err := b.OnlineCounts(ctx, []string{hall, party}); err != nil || !slices.Equal(counts, []int{3, 0}) {
+ t.Errorf("写入之后的在线人数 = %v, %v,期望 [3 0]", counts, err)
+ }
+ if ttl, err := client.PTTL(ctx, onlineKey(hall)).Result(); err != nil || ttl <= 9*time.Second || ttl > 10*time.Second {
+ t.Errorf("在线人数的剩余时间 = %v, %v,期望与租约一样约 10 秒", ttl, err)
+ }
+ if ok, err := a.Renew(ctx, hall, 15*time.Second); err != nil || !ok {
+ t.Fatalf("a 续期自己的租约 = %v, %v", ok, err)
+ }
+ for _, key := range []string{ownerKey(hall), onlineKey(hall)} {
+ if ttl, err := client.PTTL(ctx, key).Result(); err != nil || ttl <= 14*time.Second || ttl > 15*time.Second {
+ t.Errorf("续期之后 %s 的剩余时间 = %v, %v,期望约 15 秒", key, ttl, err)
+ }
+ }
+ if counts, _ := b.OnlineCounts(ctx, []string{hall}); !slices.Equal(counts, []int{3}) {
+ t.Errorf("续期之后的在线人数 = %v,期望不变", counts)
+ }
+
+ if err := b.Release(ctx, hall); err != nil {
+ t.Fatal(err)
+ }
+ if owners, _ := a.Owners(ctx, []string{hall}); !slices.Equal(owners, []string{"a"}) {
+ t.Errorf("b 释放 a 的租约之后 owner = %q,期望不变", owners)
+ }
+ if err := a.Release(ctx, hall); err != nil {
+ t.Fatal(err)
+ }
+ if owners, _ := a.Owners(ctx, []string{hall}); !slices.Equal(owners, []string{""}) {
+ t.Errorf("释放之后 owner = %q,期望没有", owners)
+ }
+ if counts, _ := a.OnlineCounts(ctx, []string{hall}); !slices.Equal(counts, []int{0}) {
+ t.Errorf("释放之后在线人数 = %v,期望 0", counts)
+ }
+ if ok, err := a.Renew(ctx, hall, 15*time.Second); err != nil || ok {
+ t.Errorf("续期已释放的租约 = %v, %v,期望 false", ok, err)
+ }
+ if ok, err := b.Acquire(ctx, hall, 15*time.Second); err != nil || !ok {
+ t.Errorf("释放之后 b 取得租约 = %v, %v,期望 true", ok, err)
+ }
+}
+
+func TestLeaseExpires(t *testing.T) {
+ client := newClient(t)
+ a, b := NewLeases(client, "a"), NewLeases(client, "b")
+ if ok, err := a.Acquire(t.Context(), hall, 100*time.Millisecond); err != nil || !ok {
+ t.Fatalf("Acquire = %v, %v", ok, err)
+ }
+ if ok, err := a.SetOnline(t.Context(), hall, 2); err != nil || !ok {
+ t.Fatalf("SetOnline = %v, %v", ok, err)
+ }
+ time.Sleep(200 * time.Millisecond)
+ if owners, err := b.Owners(t.Context(), []string{hall}); err != nil || !slices.Equal(owners, []string{""}) {
+ t.Errorf("过期之后 owner = %q, %v,期望没有", owners, err)
+ }
+ if counts, err := b.OnlineCounts(t.Context(), []string{hall}); err != nil || !slices.Equal(counts, []int{0}) {
+ t.Errorf("过期之后在线人数 = %v, %v,期望 0", counts, err)
+ }
+ if ok, err := a.Renew(t.Context(), hall, 100*time.Millisecond); err != nil || ok {
+ t.Errorf("续期已过期的租约 = %v, %v,期望 false", ok, err)
+ }
+ if ok, err := b.Acquire(t.Context(), hall, 15*time.Second); err != nil || !ok {
+ t.Errorf("过期之后 b 取得租约 = %v, %v,期望 true", ok, err)
+ }
+}
+
+// 本实例重新取得自己的租约就是房间的一次新运行:它从空的在线列表开始,上一次运行写下的在线人数作废。
+func TestReacquiringOwnLeaseResetsOnlineCount(t *testing.T) {
+ a := NewLeases(newClient(t), "a")
+ if ok, err := a.Acquire(t.Context(), hall, time.Minute); err != nil || !ok {
+ t.Fatalf("Acquire = %v, %v", ok, err)
+ }
+ if ok, err := a.SetOnline(t.Context(), hall, 3); err != nil || !ok {
+ t.Fatalf("SetOnline = %v, %v", ok, err)
+ }
+ if ok, err := a.Acquire(t.Context(), hall, time.Minute); err != nil || !ok {
+ t.Fatalf("再次 Acquire = %v, %v", ok, err)
+ }
+ if counts, err := a.OnlineCounts(t.Context(), []string{hall}); err != nil || counts[0] != 0 {
+ t.Errorf("重新取得租约之后在线人数 = %v, %v,期望 0", counts, err)
+ }
+}
diff --git a/internal/adapter/out/redis/liveness_test.go b/internal/adapter/out/redis/liveness_test.go
new file mode 100644
index 0000000..e143065
--- /dev/null
+++ b/internal/adapter/out/redis/liveness_test.go
@@ -0,0 +1,37 @@
+package redis
+
+import (
+ "slices"
+ "testing"
+ "time"
+)
+
+func TestLiveness(t *testing.T) {
+ client := newClient(t)
+ a, b := NewLiveness(client, "a"), NewLiveness(client, "b")
+ if existed, err := a.Heartbeat(t.Context(), 15*time.Second); err != nil || existed {
+ t.Fatalf("第一次刷新 = %v, %v,期望存活键之前不存在", existed, err)
+ }
+ if existed, err := a.Heartbeat(t.Context(), 15*time.Second); err != nil || !existed {
+ t.Errorf("第二次刷新 = %v, %v,期望存活键已经存在", existed, err)
+ }
+ if _, err := b.Heartbeat(t.Context(), 100*time.Millisecond); err != nil {
+ t.Fatal(err)
+ }
+ if expired, err := a.Expired(t.Context(), []string{"a", "b", "c"}); err != nil || !slices.Equal(expired, []string{"c"}) {
+ t.Errorf("Expired = %q, %v,期望只有没有刷新过存活键的 c", expired, err)
+ }
+ time.Sleep(200 * time.Millisecond)
+ if expired, err := a.Expired(t.Context(), []string{"a", "b", "c"}); err != nil || !slices.Equal(expired, []string{"b", "c"}) {
+ t.Errorf("b 的存活键过期之后 Expired = %q, %v,期望 [b c]", expired, err)
+ }
+ if existed, err := b.Heartbeat(t.Context(), 15*time.Second); err != nil || existed {
+ t.Errorf("过期之后刷新 = %v, %v,期望存活键之前不存在", existed, err)
+ }
+ if ttl, err := client.PTTL(t.Context(), aliveKey("a")).Result(); err != nil || ttl <= 14*time.Second || ttl > 15*time.Second {
+ t.Errorf("存活键的剩余时间 = %v, %v,期望约 15 秒", ttl, err)
+ }
+ if expired, err := a.Expired(t.Context(), nil); err != nil || len(expired) != 0 {
+ t.Errorf("Expired(nil) = %q, %v", expired, err)
+ }
+}
diff --git a/internal/adapter/out/redis/main_test.go b/internal/adapter/out/redis/main_test.go
new file mode 100644
index 0000000..226338c
--- /dev/null
+++ b/internal/adapter/out/redis/main_test.go
@@ -0,0 +1,55 @@
+package redis
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "sync/atomic"
+ "testing"
+
+ goredis "github.com/redis/go-redis/v9"
+ "github.com/testcontainers/testcontainers-go"
+ tcvalkey "github.com/testcontainers/testcontainers-go/modules/valkey"
+)
+
+var (
+ server *goredis.Options
+ databases atomic.Int64
+)
+
+func TestMain(m *testing.M) {
+ os.Exit(runTests(m))
+}
+
+// 每个测试用一个单独的逻辑数据库,所以容器开启了足够多的数据库。
+func runTests(m *testing.M) int {
+ ctx := context.Background()
+ container, err := tcvalkey.Run(ctx, "valkey/valkey:8-alpine", testcontainers.WithCmdArgs("--databases", "1024"))
+ defer func() { _ = testcontainers.TerminateContainer(container) }()
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "启动 Valkey 容器:", err)
+ return 1
+ }
+ url, err := container.ConnectionString(ctx)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ return 1
+ }
+ if server, err = goredis.ParseURL(url); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ return 1
+ }
+ return m.Run()
+}
+
+func newClient(t *testing.T) *goredis.Client {
+ t.Helper()
+ opts := *server
+ opts.DB = int(databases.Add(1))
+ client := goredis.NewClient(&opts)
+ t.Cleanup(func() { _ = client.Close() })
+ if err := client.Ping(t.Context()).Err(); err != nil {
+ t.Fatalf("连接第 %d 个数据库:%v", opts.DB, err)
+ }
+ return client
+}
diff --git a/internal/adapter/out/redis/quota_test.go b/internal/adapter/out/redis/quota_test.go
new file mode 100644
index 0000000..de4616f
--- /dev/null
+++ b/internal/adapter/out/redis/quota_test.go
@@ -0,0 +1,25 @@
+package redis
+
+import (
+ "testing"
+ "time"
+)
+
+func TestTakeSearch(t *testing.T) {
+ quota := NewSearchQuota(newClient(t))
+ for i := range 2 {
+ if wait, err := quota.TakeSearch(t.Context(), "room-1", 2, time.Minute); err != nil || wait != 0 {
+ t.Fatalf("第 %d 次 = %v, %v,突发额度之内应直接取到", i+1, wait, err)
+ }
+ }
+ wait, err := quota.TakeSearch(t.Context(), "room-1", 2, time.Minute)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if wait < 59*time.Second || wait > time.Minute {
+ t.Errorf("额度用完后要等 %v,期望约 1 分钟", wait)
+ }
+ if wait, err := quota.TakeSearch(t.Context(), "room-2", 2, time.Minute); err != nil || wait != 0 {
+ t.Errorf("另一个房间 = %v, %v,额度按房间计算", wait, err)
+ }
+}
diff --git a/internal/adapter/out/storage/main_test.go b/internal/adapter/out/storage/main_test.go
new file mode 100644
index 0000000..5e27f3b
--- /dev/null
+++ b/internal/adapter/out/storage/main_test.go
@@ -0,0 +1,56 @@
+package storage
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "sync/atomic"
+ "testing"
+
+ "github.com/testcontainers/testcontainers-go"
+ "github.com/testcontainers/testcontainers-go/wait"
+)
+
+const (
+ rustfsImage = "rustfs/rustfs:1.0.3"
+ accessKey = "alisten-test"
+ secretKey = "alisten-test-secret"
+)
+
+var (
+ endpoint string
+ buckets atomic.Int64
+)
+
+func TestMain(m *testing.M) {
+ os.Exit(runTests(m))
+}
+
+func runTests(m *testing.M) int {
+ ctx := context.Background()
+ container, err := testcontainers.Run(ctx, rustfsImage,
+ testcontainers.WithExposedPorts("9000/tcp"),
+ testcontainers.WithEnv(map[string]string{"RUSTFS_ACCESS_KEY": accessKey, "RUSTFS_SECRET_KEY": secretKey}),
+ testcontainers.WithWaitStrategy(wait.ForHTTP("/health/ready").WithPort("9000/tcp")),
+ )
+ defer func() { _ = testcontainers.TerminateContainer(container) }()
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "启动 RustFS 容器:", err)
+ return 1
+ }
+ if endpoint, err = container.PortEndpoint(ctx, "9000/tcp", "http"); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ return 1
+ }
+ return m.Run()
+}
+
+func newConfig() Config {
+ return Config{
+ Endpoint: endpoint,
+ Region: "us-east-1",
+ Bucket: fmt.Sprintf("test-%d", buckets.Add(1)),
+ AccessKeyID: accessKey,
+ SecretAccessKey: secretKey,
+ }
+}
diff --git a/internal/adapter/out/storage/storage_test.go b/internal/adapter/out/storage/storage_test.go
new file mode 100644
index 0000000..f68df99
--- /dev/null
+++ b/internal/adapter/out/storage/storage_test.go
@@ -0,0 +1,220 @@
+package storage
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "slices"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/service/s3"
+)
+
+func put(t *testing.T, s *Storage, key, content, contentType string) {
+ t.Helper()
+ if err := s.Put(t.Context(), key, strings.NewReader(content), contentType); err != nil {
+ t.Fatal(err)
+ }
+}
+
+type anonymousResponse struct {
+ status int
+ body string
+ contentType string
+ contentRange string
+}
+
+func anonymousGet(t *testing.T, url, rangeHeader string) anonymousResponse {
+ t.Helper()
+ req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rangeHeader != "" {
+ req.Header.Set("Range", rangeHeader)
+ }
+ resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return anonymousResponse{status: resp.StatusCode, body: string(body), contentType: resp.Header.Get("Content-Type"), contentRange: resp.Header.Get("Content-Range")}
+}
+
+func TestEnsureBucketAllowsAnonymousReadsOfLinks(t *testing.T) {
+ cfg := newConfig()
+ s := New(cfg)
+ if created, err := s.EnsureBucket(t.Context()); err != nil || !created {
+ t.Fatalf("第一次检查 bucket = %v, %v,期望创建", created, err)
+ }
+ if created, err := s.EnsureBucket(t.Context()); err != nil || created {
+ t.Fatalf("第二次检查 bucket = %v, %v,期望保持不变", created, err)
+ }
+ put(t, s, "link/abc/audio.mp3", "0123456789abcdef", "audio/mpeg")
+ put(t, s, "other/secret.txt", "secret", "text/plain")
+ base := cfg.Endpoint + "/" + cfg.Bucket
+
+ got := anonymousGet(t, base+"/link/abc/audio.mp3", "")
+ if want := (anonymousResponse{status: http.StatusOK, body: "0123456789abcdef", contentType: "audio/mpeg"}); got != want {
+ t.Errorf("匿名读取 link/ 下的对象 = %+v,期望 %+v", got, want)
+ }
+ got = anonymousGet(t, base+"/link/abc/audio.mp3", "bytes=2-5")
+ if want := (anonymousResponse{status: http.StatusPartialContent, body: "2345", contentType: "audio/mpeg", contentRange: "bytes 2-5/16"}); got != want {
+ t.Errorf("带 Range 的匿名读取 = %+v,期望 %+v", got, want)
+ }
+ for _, path := range []string{"/other/secret.txt", "/", "/?list-type=2"} {
+ if got := anonymousGet(t, base+path, ""); got.status != http.StatusForbidden {
+ t.Errorf("匿名请求 %s = %d,期望 403:匿名只能读取 link/ 下的对象,不能列出对象", path, got.status)
+ }
+ }
+}
+
+// RustFS 对匿名请求也按查询参数 response-content-type 改写响应的 Content-Type(AWS 的 S3 不会)。链接曲目的音频和封面是
+// 用户提交的内容,对象存储与页面同源时,被改成 text/html 的响应会在页面的源上运行脚本,只能靠入口为 /storage/ 的响应
+// 添加 Content-Security-Policy: sandbox 和 X-Content-Type-Options: nosniff 来阻止。升级 RustFS 后这个测试如果失败,
+// 先核对入口的这项要求是否仍然需要。
+func TestAnonymousResponseContentTypeIsOverridable(t *testing.T) {
+ cfg := newConfig()
+ s := New(cfg)
+ if _, err := s.EnsureBucket(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ const body = ""
+ put(t, s, "link/abc/cover.jpg", body, "image/jpeg")
+ url := cfg.Endpoint + "/" + cfg.Bucket + "/link/abc/cover.jpg"
+
+ if got, want := anonymousGet(t, url, ""), (anonymousResponse{status: http.StatusOK, body: body, contentType: "image/jpeg"}); got != want {
+ t.Fatalf("匿名读取 = %+v,期望 %+v", got, want)
+ }
+ got := anonymousGet(t, url+"?response-content-type=text%2Fhtml", "")
+ if want := (anonymousResponse{status: http.StatusOK, body: body, contentType: "text/html"}); got != want {
+ t.Errorf("带 response-content-type 的匿名读取 = %+v,期望 %+v", got, want)
+ }
+}
+
+func TestEnsureBucketLeavesExistingBucketUnchanged(t *testing.T) {
+ cfg := newConfig()
+ s := New(cfg)
+ if _, err := s.client.CreateBucket(t.Context(), &s3.CreateBucketInput{Bucket: aws.String(cfg.Bucket)}); err != nil {
+ t.Fatal(err)
+ }
+ if created, err := s.EnsureBucket(t.Context()); err != nil || created {
+ t.Fatalf("检查已存在的 bucket = %v, %v,期望保持不变", created, err)
+ }
+ put(t, s, "link/abc/audio.mp3", "0123456789abcdef", "audio/mpeg")
+ if got := anonymousGet(t, cfg.Endpoint+"/"+cfg.Bucket+"/link/abc/audio.mp3", ""); got.status != http.StatusForbidden {
+ t.Errorf("匿名读取 = %d,期望 403:已存在的 bucket 不设置访问策略", got.status)
+ }
+}
+
+func TestExistsReportsErrorsOtherThanNotFound(t *testing.T) {
+ cfg := newConfig()
+ s := New(cfg)
+ if _, err := s.EnsureBucket(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ const key = "link/abc/audio.mp3"
+ put(t, s, key, "mp3", "audio/mpeg")
+
+ cfg.SecretAccessKey = "wrong-secret"
+ if ok, err := New(cfg).Exists(t.Context(), key); err == nil || ok {
+ t.Errorf("凭证不对时 Exists = %v, %v,期望返回错误", ok, err)
+ }
+}
+
+func TestExistsAndDelete(t *testing.T) {
+ s := New(newConfig())
+ if _, err := s.EnsureBucket(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ const key = "link/abc/cover.jpg"
+ if ok, err := s.Exists(t.Context(), key); err != nil || ok {
+ t.Fatalf("上传之前 Exists = %v, %v", ok, err)
+ }
+ put(t, s, key, "jpeg", "image/jpeg")
+ if ok, err := s.Exists(t.Context(), key); err != nil || !ok {
+ t.Fatalf("上传之后 Exists = %v, %v", ok, err)
+ }
+ for range 2 {
+ if err := s.Delete(t.Context(), key); err != nil {
+ t.Fatalf("删除 = %v,期望对象不存在时同样成功", err)
+ }
+ }
+ if ok, err := s.Exists(t.Context(), key); err != nil || ok {
+ t.Errorf("删除之后 Exists = %v, %v", ok, err)
+ }
+}
+
+// RustFS 上无法只让设置访问策略失败,所以用一个按方法应答的 HTTP 服务代替对象存储。
+func fakeBucketServer(t *testing.T, policy http.HandlerFunc) (endpoint string, requests func() []string) {
+ t.Helper()
+ var mu sync.Mutex
+ var received []string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, isPolicy := r.URL.Query()["policy"]
+ mu.Lock()
+ received = append(received, r.Method+" "+r.URL.Path+map[bool]string{true: "?policy"}[isPolicy])
+ mu.Unlock()
+ switch {
+ case r.Method == http.MethodHead:
+ w.WriteHeader(http.StatusNotFound)
+ case r.Method == http.MethodPut && isPolicy:
+ policy(w, r)
+ case r.Method == http.MethodDelete:
+ w.WriteHeader(http.StatusNoContent)
+ }
+ }))
+ t.Cleanup(server.Close)
+ return server.URL, func() []string {
+ mu.Lock()
+ defer mu.Unlock()
+ return slices.Clone(received)
+ }
+}
+
+func TestEnsureBucketRemovesBucketWithoutPolicy(t *testing.T) {
+ endpoint, requests := fakeBucketServer(t, func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/xml")
+ w.WriteHeader(http.StatusForbidden)
+ _, _ = io.WriteString(w, `AccessDeniedAccess Denied`)
+ })
+
+ s := New(Config{Endpoint: endpoint, Region: "us-east-1", Bucket: "alisten", AccessKeyID: accessKey, SecretAccessKey: secretKey})
+ created, err := s.EnsureBucket(t.Context())
+ if created || err == nil {
+ t.Errorf("设置访问策略失败时 = %v, %v", created, err)
+ }
+ if want := []string{"HEAD /alisten", "PUT /alisten", "PUT /alisten?policy", "DELETE /alisten"}; !slices.Equal(requests(), want) {
+ t.Errorf("请求依次为 %q,期望 %q:刚创建的 bucket 要删除", requests(), want)
+ }
+}
+
+func TestEnsureBucketRemovesBucketWhenCancelled(t *testing.T) {
+ ctx, cancel := context.WithCancel(t.Context())
+ defer cancel()
+ endpoint, requests := fakeBucketServer(t, func(_ http.ResponseWriter, r *http.Request) {
+ // net/http 的服务端读完请求体之后才监视连接,客户端断开时 r.Context() 才会结束。
+ _, _ = io.Copy(io.Discard, r.Body)
+ cancel()
+ <-r.Context().Done()
+ })
+
+ s := New(Config{Endpoint: endpoint, Region: "us-east-1", Bucket: "alisten", AccessKeyID: accessKey, SecretAccessKey: secretKey})
+ created, err := s.EnsureBucket(ctx)
+ if created || !errors.Is(err, context.Canceled) {
+ t.Errorf("设置访问策略时被取消 = %v, %v", created, err)
+ }
+ if want := []string{"HEAD /alisten", "PUT /alisten", "PUT /alisten?policy", "DELETE /alisten"}; !slices.Equal(requests(), want) {
+ t.Errorf("请求依次为 %q,期望 %q:取消之后刚创建的 bucket 仍要删除", requests(), want)
+ }
+}
diff --git a/internal/adapter/out/ytdlp/guard_test.go b/internal/adapter/out/ytdlp/guard_test.go
new file mode 100644
index 0000000..3e9617b
--- /dev/null
+++ b/internal/adapter/out/ytdlp/guard_test.go
@@ -0,0 +1,195 @@
+package ytdlp
+
+import (
+ "context"
+ "encoding/binary"
+ "errors"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "net/netip"
+ "slices"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+func TestPublic(t *testing.T) {
+ refused := []string{
+ "127.0.0.1", "::1", "10.1.2.3", "172.16.0.1", "192.168.1.1", "169.254.169.254", "fe80::1", "fc00::1", "fd12:3456::1",
+ "100.64.0.1", "100.127.255.254", "0.0.0.0", "0.1.2.3", "::", "224.0.0.1", "ff02::1", "255.255.255.255",
+ "64:ff9b::a00:1", "::ffff:100.64.0.1",
+ "198.18.0.1", "198.19.255.254", "240.0.0.1", "255.255.255.254", "192.0.0.1", "192.0.0.255",
+ "64:ff9b:1::a00:1", "::7f00:1", "2002:7f00:1::1", "2001:0:4136:e378:8000:63bf:3fff:fdd2", "::ffff:0:a00:1",
+ "fec0::1", "feff::1",
+ "192.0.2.1", "192.0.2.255", "198.51.100.1", "203.0.113.254",
+ "2001:db8::1", "2001:db8:ffff::1", "3fff::1", "3fff:fff:ffff::1",
+ }
+ for _, s := range refused {
+ if public(netip.MustParseAddr(s)) {
+ t.Errorf("%s 不应算作公网地址", s)
+ }
+ }
+ // 紧挨着被拒绝的地址段的公网地址。
+ for _, s := range []string{
+ "8.8.8.8", "1.1.1.1", "100.128.0.1", "2606:4700:4700::1111", "::ffff:8.8.8.8",
+ "198.17.255.255", "198.20.0.1", "223.255.255.254", "192.0.1.1", "2001:1::1", "2003::1", "64:ff9c::1",
+ "192.0.3.1", "198.51.99.255", "198.51.101.1", "203.0.112.255", "203.0.114.1", "2001:db7:ffff::1", "2001:db9::1", "3ffe:ffff::1", "3fff:1000::1",
+ } {
+ if !public(netip.MustParseAddr(s)) {
+ t.Errorf("%s 应算作公网地址", s)
+ }
+ }
+}
+
+func TestDialPublicRefusesPrivateTargets(t *testing.T) {
+ server := httptest.NewServer(http.NotFoundHandler())
+ defer server.Close()
+ _, port, err := net.SplitHostPort(server.Listener.Addr().String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, host := range []string{"127.0.0.1", "localhost"} {
+ conn, err := dialPublic(net.DefaultResolver)(t.Context(), "tcp", net.JoinHostPort(host, port))
+ var refused *refusedError
+ if !errors.As(err, &refused) {
+ if conn != nil {
+ _ = conn.Close()
+ }
+ t.Errorf("连接 %s = %v,期望拒绝", host, err)
+ }
+ }
+}
+
+// A 和 AAAA 的查询是同时发出的,所以一次解析就是各一次查询。
+type dnsServer struct {
+ addr string
+ answers func(qtype uint16, n int) []netip.Addr
+
+ mu sync.Mutex
+ queries map[uint16]int
+}
+
+const (
+ typeA = 1
+ typeAAAA = 28
+)
+
+func startDNS(t *testing.T, answers func(qtype uint16, n int) []netip.Addr) *dnsServer {
+ t.Helper()
+ conn, err := net.ListenPacket("udp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = conn.Close() })
+ s := &dnsServer{addr: conn.LocalAddr().String(), answers: answers, queries: map[uint16]int{}}
+ go func() {
+ buf := make([]byte, 512)
+ for {
+ n, from, err := conn.ReadFrom(buf)
+ if err != nil {
+ return
+ }
+ if reply := s.reply(buf[:n]); reply != nil {
+ _, _ = conn.WriteTo(reply, from)
+ }
+ }
+ }()
+ return s
+}
+
+func (s *dnsServer) resolver() *net.Resolver {
+ return &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, _, _ string) (net.Conn, error) {
+ var d net.Dialer
+ return d.DialContext(ctx, "udp", s.addr)
+ }}
+}
+
+// 回应只有一个问题的查询:问题原样放回,答案是 A 或 AAAA 记录,没有答案时是空的成功回应。
+func (s *dnsServer) reply(query []byte) []byte {
+ // 问题从第 12 个字节开始:域名是「长度加标签」的序列,以 0 结尾,之后是 2 字节的类型和 2 字节的类。
+ end := 12
+ for end < len(query) && query[end] != 0 {
+ end += int(query[end]) + 1
+ }
+ end += 1 + 4
+ if len(query) < 12 || end > len(query) {
+ return nil
+ }
+ qtype := binary.BigEndian.Uint16(query[end-4:])
+ s.mu.Lock()
+ s.queries[qtype]++
+ addrs := s.answers(qtype, s.queries[qtype])
+ s.mu.Unlock()
+
+ reply := append(slices.Clone(query[:2]), 0x81, 0x80)
+ reply = binary.BigEndian.AppendUint16(reply, 1)
+ reply = binary.BigEndian.AppendUint16(reply, uint16(len(addrs)))
+ reply = append(reply, 0, 0, 0, 0)
+ reply = append(reply, query[12:end]...)
+ for _, a := range addrs {
+ typ := uint16(typeA)
+ if a.Is6() {
+ typ = typeAAAA
+ }
+ reply = append(reply, 0xC0, 0x0C)
+ reply = binary.BigEndian.AppendUint16(reply, typ)
+ reply = binary.BigEndian.AppendUint16(reply, 1)
+ reply = binary.BigEndian.AppendUint32(reply, 0)
+ reply = binary.BigEndian.AppendUint16(reply, uint16(len(a.AsSlice())))
+ reply = append(reply, a.AsSlice()...)
+ }
+ return reply
+}
+
+// DNS 重绑定:域名第一次解析给出公网地址,之后的解析给出回环地址。
+func TestDialPublicDoesNotResolveAgain(t *testing.T) {
+ // Cloudflare 的公共 DNS,一直是公网地址。连接成功或失败都能看出连接的是哪个地址。
+ public := netip.MustParseAddr("2606:4700:4700::1111")
+ internal, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = internal.Close() })
+ go func() {
+ for {
+ c, err := internal.Accept()
+ if err != nil {
+ return
+ }
+ _ = c.Close()
+ }
+ }()
+ _, port, err := net.SplitHostPort(internal.Addr().String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ dns := startDNS(t, func(qtype uint16, n int) []netip.Addr {
+ switch {
+ case qtype == typeAAAA && n == 1:
+ return []netip.Addr{public}
+ case qtype == typeA && n > 1:
+ return []netip.Addr{netip.MustParseAddr("127.0.0.1")}
+ }
+ return nil
+ })
+
+ ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second)
+ defer cancel()
+ conn, err := dialPublic(dns.resolver())(ctx, "tcp", net.JoinHostPort("rebind.test.", port))
+ switch {
+ case conn != nil:
+ defer func() { _ = conn.Close() }()
+ if got := conn.RemoteAddr().(*net.TCPAddr).AddrPort().Addr(); got != public {
+ t.Errorf("连上了 %v,期望连接第一次解析出的 %v", got, public)
+ }
+ case !strings.Contains(err.Error(), "["+public.String()+"]"):
+ t.Errorf("连接失败的错误 %q 没有提到第一次解析出的 %v,期望连接的是它", err, public)
+ }
+ dns.mu.Lock()
+ defer dns.mu.Unlock()
+ if dns.queries[typeA] != 1 || dns.queries[typeAAAA] != 1 {
+ t.Errorf("域名被查询了 %v,期望 A 和 AAAA 各一次", dns.queries)
+ }
+}
diff --git a/internal/adapter/out/ytdlp/hardening_test.go b/internal/adapter/out/ytdlp/hardening_test.go
new file mode 100644
index 0000000..ec8dd3b
--- /dev/null
+++ b/internal/adapter/out/ytdlp/hardening_test.go
@@ -0,0 +1,372 @@
+package ytdlp
+
+import (
+ "bytes"
+ "encoding/binary"
+ "encoding/json"
+ "errors"
+ "io"
+ "log/slog"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "slices"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/app/port/porttest"
+)
+
+// 地址用 media.test,只有测试的代理能把它解析到本机。
+func startPaths(t *testing.T, routes map[string]http.Handler) (link func(path string) string, hits func(path string) int) {
+ t.Helper()
+ var mu sync.Mutex
+ counts := map[string]int{}
+ mux := http.NewServeMux()
+ for pattern, handler := range routes {
+ mux.Handle(pattern, handler)
+ }
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mu.Lock()
+ counts[r.URL.Path]++
+ mu.Unlock()
+ mux.ServeHTTP(w, r)
+ }))
+ t.Cleanup(server.Close)
+ _, port, err := net.SplitHostPort(server.Listener.Addr().String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ base := "http://media.test:" + port
+ return func(path string) string { return base + path }, func(path string) int {
+ mu.Lock()
+ defer mu.Unlock()
+ return counts[path]
+ }
+}
+
+func encodeAAC(t *testing.T, path string, extra ...string) {
+ t.Helper()
+ args := slices.Concat([]string{"-y", "-loglevel", "error", "-f", "lavfi", "-i", "sine=frequency=440:duration=1", "-c:a", "aac"}, extra, []string{path})
+ if out, err := exec.CommandContext(t.Context(), "ffmpeg", args...).CombinedOutput(); err != nil {
+ t.Fatalf("ffmpeg 生成 %s:%v\n%s", filepath.Base(path), err, out)
+ }
+}
+
+func hlsSite(t *testing.T) (link func(path string) string, hits func(path string) int) {
+ t.Helper()
+ dir := t.TempDir()
+ encodeAAC(t, filepath.Join(dir, "index.m3u8"), "-f", "hls", "-hls_time", "4", "-hls_playlist_type", "vod", "-hls_segment_filename", filepath.Join(dir, "seg%d.ts"))
+ files := http.FileServer(http.Dir(dir))
+ return startPaths(t, map[string]http.Handler{"/": http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasSuffix(r.URL.Path, ".m3u8") {
+ w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
+ }
+ files.ServeHTTP(w, r)
+ })})
+}
+
+// 写出一份 yt-dlp 的 info.json,让它直接下载 format 描述的这一个格式,不经过 extractor。
+func writeInfo(t *testing.T, format map[string]any) string {
+ t.Helper()
+ info := map[string]any{
+ "_type": "video", "id": "x", "title": "x", "extractor": "generic", "extractor_key": "Generic",
+ "vcodec": "none", "acodec": "aac", "format_id": "x", "http_headers": map[string]string{"User-Agent": "x"},
+ }
+ for k, v := range format {
+ info[k] = v
+ }
+ b, err := json.Marshal(info)
+ if err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(t.TempDir(), "info.json")
+ if err := os.WriteFile(path, b, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return path
+}
+
+func verbose(t *testing.T, link string, extra ...string) (stderr []byte, err error) {
+ t.Helper()
+ p := siteProxy(t)
+ dir := t.TempDir()
+ _, stderr, err = run(t.Context(), dir, slices.Concat([]string{"--verbose"}, extra, args(link, dir, p.url, "")))
+ return stderr, err
+}
+
+// 不包括 ffprobe:yt-dlp 不给它传后处理器的参数。
+func ffmpegCommands(stderr []byte) [][]string {
+ const marker = "[debug] ffmpeg command line: "
+ var commands [][]string
+ rest := string(stderr)
+ for {
+ _, after, ok := strings.Cut(rest, marker)
+ if !ok {
+ return commands
+ }
+ words, n := shellWords(after)
+ rest = after[n:]
+ if len(words) > 0 && words[0] == "ffmpeg" {
+ commands = append(commands, words)
+ }
+ }
+}
+
+// 读到第一个不在引号内的换行为止,按空白拆成参数;单引号内的内容(可以跨行)属于同一个参数。返回参数和消耗的字节数。
+func shellWords(s string) ([]string, int) {
+ var words []string
+ var word strings.Builder
+ inWord, quoted := false, false
+ flush := func() {
+ if inWord {
+ words = append(words, word.String())
+ word.Reset()
+ inWord = false
+ }
+ }
+ for i := 0; i < len(s); i++ {
+ switch c := s[i]; {
+ case c == '\'':
+ quoted, inWord = !quoted, true
+ case quoted:
+ word.WriteByte(c)
+ case c == '\n':
+ flush()
+ return words, i + 1
+ case c == ' ':
+ flush()
+ default:
+ word.WriteByte(c)
+ inWord = true
+ }
+ }
+ flush()
+ return words, len(s)
+}
+
+func checkLocalOnly(t *testing.T, commands [][]string) {
+ t.Helper()
+ for _, argv := range commands {
+ input, whitelist := slices.Index(argv, "-i"), slices.Index(argv, "-protocol_whitelist")
+ if input < 0 || whitelist < 0 || whitelist > input || argv[whitelist+1] != "file" {
+ t.Errorf("ffmpeg 的命令行没有在输入之前把协议限制为 file:%q", argv)
+ }
+ }
+}
+
+func commandWith(commands [][]string, words ...string) []string {
+ for _, argv := range commands {
+ if !slices.ContainsFunc(words, func(w string) bool { return !slices.Contains(argv, w) }) {
+ return argv
+ }
+ }
+ return nil
+}
+
+func TestPostprocessorsReadLocalFilesOnly(t *testing.T) {
+ t.Run("extract_audio", func(t *testing.T) {
+ _, _, link := startSite(t)
+ stderr, err := verbose(t, link("/page"))
+ if err != nil {
+ t.Fatalf("yt-dlp 失败:%v\n%s", err, stderr)
+ }
+ commands := ffmpegCommands(stderr)
+ if commandWith(commands, "libmp3lame") == nil {
+ t.Fatalf("没有转 mp3 的 ffmpeg 命令行:\n%s", stderr)
+ }
+ checkLocalOnly(t, commands)
+ })
+
+ t.Run("fixup_m3u8", func(t *testing.T) {
+ link, _ := hlsSite(t)
+ stderr, err := verbose(t, link("/index.m3u8"))
+ if err != nil {
+ t.Fatalf("yt-dlp 失败:%v\n%s", err, stderr)
+ }
+ commands := ffmpegCommands(stderr)
+ if commandWith(commands, "aac_adtstoasc") == nil || commandWith(commands, "libmp3lame") == nil {
+ t.Fatalf("期望 FixupM3u8 和转 mp3 各有一条 ffmpeg 命令行:\n%s", stderr)
+ }
+ checkLocalOnly(t, commands)
+ })
+
+ t.Run("fixup_m4a", func(t *testing.T) {
+ dir := t.TempDir()
+ encodeAAC(t, filepath.Join(dir, "song.m4a"))
+ link, _ := startPaths(t, map[string]http.Handler{"/": http.FileServer(http.Dir(dir))})
+ info := writeInfo(t, map[string]any{"url": link("/song.m4a"), "webpage_url": link("/page"), "ext": "m4a", "protocol": "http", "container": "m4a_dash"})
+ stderr, err := verbose(t, link("/page"), "--load-info-json", info)
+ if err != nil {
+ t.Fatalf("yt-dlp 失败:%v\n%s", err, stderr)
+ }
+ commands := ffmpegCommands(stderr)
+ if commandWith(commands, "copy", "mp4") == nil || commandWith(commands, "libmp3lame") == nil {
+ t.Fatalf("期望 FixupM4a 和转 mp3 各有一条 ffmpeg 命令行:\n%s", stderr)
+ }
+ checkLocalOnly(t, commands)
+ })
+}
+
+// 原生下载器处理不了 SAMPLE-AES 加密的 HLS,yt-dlp 会交给 ffmpeg 下载。
+func TestFFmpegDownloaderReadsLocalFilesOnly(t *testing.T) {
+ playlist := "#EXTM3U\n#EXT-X-VERSION:5\n#EXT-X-TARGETDURATION:2\n#EXT-X-MEDIA-SEQUENCE:0\n" +
+ "#EXT-X-KEY:METHOD=SAMPLE-AES,URI=\"key.bin\"\n#EXTINF:2.0,\nseg.ts\n#EXT-X-ENDLIST\n"
+ link, hits := startPaths(t, map[string]http.Handler{
+ "/enc.m3u8": http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
+ _, _ = io.WriteString(w, playlist)
+ }),
+ "/key.bin": http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(make([]byte, 16)) }),
+ "/seg.ts": http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(make([]byte, 188*4)) }),
+ })
+ stderr, err := verbose(t, link("/enc.m3u8"))
+ if err == nil {
+ t.Errorf("ffmpeg 读不了 http 的播放列表,yt-dlp 应当失败\n%s", stderr)
+ }
+ commands := ffmpegCommands(stderr)
+ if len(commands) == 0 {
+ t.Fatalf("yt-dlp 没有把这个流交给 ffmpeg 下载,需要换一个原生下载器处理不了的流:\n%s", stderr)
+ }
+ checkLocalOnly(t, commands)
+ for _, path := range []string{"/seg.ts", "/key.bin"} {
+ if n := hits(path); n != 0 {
+ t.Errorf("%s 收到 %d 个请求,期望 ffmpeg 没有读它", path, n)
+ }
+ }
+}
+
+// 在 WAV 的 fmt 块和 data 块之间插入 LIST/INFO 块,INAM 是标题:ffmpeg 转码时会把它复制成 mp3 的 ID3 标签。
+func taggedWAV(title string) []byte {
+ wav := porttest.SilentWAV(time.Second)
+ const dataChunk = 36
+ info := append([]byte("INFOINAM"), binary.LittleEndian.AppendUint32(nil, uint32(len(title)+1))...)
+ info = append(append(info, title...), 0)
+ if len(info)%2 == 1 {
+ info = append(info, 0)
+ }
+ list := append([]byte("LIST"), binary.LittleEndian.AppendUint32(nil, uint32(len(info)))...)
+ out := slices.Concat(wav[:dataChunk], list, info, wav[dataChunk:])
+ binary.LittleEndian.PutUint32(out[4:], uint32(len(out)-8))
+ return out
+}
+
+func TestAudioDropsSourceTags(t *testing.T) {
+ const markup = ""
+ source := taggedWAV(markup)
+ path := filepath.Join(t.TempDir(), "tagged.wav")
+ if err := os.WriteFile(path, source, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if out, err := exec.CommandContext(t.Context(), "ffprobe", "-v", "error", "-show_entries", "format_tags=title", "-of", "default=noprint_wrappers=1:nokey=1", path).Output(); err != nil || !strings.Contains(string(out), markup) {
+ t.Fatalf("测试用的 WAV 没有被 ffprobe 读出标题:%q, %v", out, err)
+ }
+
+ link, _ := startPaths(t, map[string]http.Handler{
+ "/page": http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = io.WriteString(w, `标签`)
+ }),
+ "/tagged.wav": http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "audio/wav")
+ http.ServeContent(w, r, "tagged.wav", time.Time{}, bytes.NewReader(source))
+ }),
+ })
+ p := siteProxy(t)
+ dir := t.TempDir()
+ stdout, stderr, err := run(t.Context(), dir, args(link("/page"), dir, p.url, ""))
+ if err != nil {
+ t.Fatalf("yt-dlp 失败:%v\n%s", err, stderr)
+ }
+ d, err := collect(dir, stdout)
+ if err != nil {
+ t.Fatal(err)
+ }
+ audio := readFile(t, d.Audio)
+ if !isMP3(audio) {
+ t.Fatalf("audio.mp3 的开头 %x 不是 MP3", audio[:min(len(audio), 4)])
+ }
+ if bytes.Contains(audio, []byte("alert(2)")) {
+ t.Errorf("mp3 中还带着来源文件的标题标签")
+ }
+}
+
+func TestConfigFilesAreIgnored(t *testing.T) {
+ executed := filepath.Join(t.TempDir(), "executed")
+ config := t.TempDir()
+ conf := "--proxy \"\"\n--exec \"touch " + executed + "\"\n"
+ if err := os.WriteFile(filepath.Join(config, "yt-dlp.conf"), []byte(conf), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("XDG_CONFIG_HOME", config)
+
+ _, _, link := startSite(t)
+ p := siteProxy(t)
+ dir := t.TempDir()
+ // media.test 只有测试的代理能解析:下载成功说明请求经过了命令行指定的代理。
+ if _, stderr, err := run(t.Context(), dir, args(link("/page"), dir, p.url, "")); err != nil {
+ t.Fatalf("yt-dlp 失败:%v\n%s", err, stderr)
+ }
+ if _, err := os.Stat(executed); err == nil {
+ t.Error("配置文件里的 --exec 被执行了")
+ }
+
+ server, requests, _ := startSite(t)
+ o := &observed{}
+ _, _, err := New(t.TempDir(), net.DefaultResolver, noCookies{}, o.observe, slog.New(slog.DiscardHandler)).Download(t.Context(), server.URL+"/page")
+ var rejected *port.RejectedError
+ if !errors.As(err, &rejected) || requests.Load() != 0 {
+ t.Errorf("配置文件里有 --proxy \"\" 时下载本机的网站 = %v,网站收到 %d 个请求,期望仍被拒绝", err, requests.Load())
+ }
+}
+
+// 页面里有多个媒体时,generic extractor 把它们当作播放列表。
+func TestOnlyFirstPlaylistItemIsDownloaded(t *testing.T) {
+ wav := func(d time.Duration) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "audio/wav")
+ http.ServeContent(w, r, "x.wav", time.Time{}, bytes.NewReader(porttest.SilentWAV(d)))
+ })
+ }
+ link, hits := startPaths(t, map[string]http.Handler{
+ "/list": http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = io.WriteString(w, `列表`)
+ }),
+ "/one.wav": wav(time.Second),
+ "/two.wav": wav(2 * time.Second),
+ })
+ p := siteProxy(t)
+ dir := t.TempDir()
+ stdout, stderr, err := run(t.Context(), dir, args(link("/list"), dir, p.url, ""))
+ if err != nil {
+ t.Fatalf("yt-dlp 失败:%v\n%s", err, stderr)
+ }
+ if _, err := collect(dir, stdout); err != nil {
+ t.Errorf("播放列表的输出 %q 应当只有第一项的元数据:%v", stdout, err)
+ }
+ if n := hits("/two.wav"); n != 0 {
+ t.Errorf("播放列表的第二项收到 %d 个请求,期望没有下载", n)
+ }
+}
+
+func TestHLSFormatsAreDownloadedNatively(t *testing.T) {
+ link, hits := hlsSite(t)
+ info := writeInfo(t, map[string]any{"url": link("/index.m3u8"), "webpage_url": link("/page"), "ext": "mp4", "protocol": "m3u8"})
+ stderr, err := verbose(t, link("/page"), "--load-info-json", info)
+ if err != nil {
+ t.Fatalf("yt-dlp 失败:%v\n%s", err, stderr)
+ }
+ if !bytes.Contains(stderr, []byte("Invoking hlsnative downloader")) {
+ t.Errorf("期望原生下载器下载 m3u8:\n%s", stderr)
+ }
+ if n := hits("/seg0.ts"); n == 0 {
+ t.Error("分片没有被下载")
+ }
+}
diff --git a/internal/adapter/out/ytdlp/main_test.go b/internal/adapter/out/ytdlp/main_test.go
new file mode 100644
index 0000000..13287e6
--- /dev/null
+++ b/internal/adapter/out/ytdlp/main_test.go
@@ -0,0 +1,147 @@
+package ytdlp
+
+import (
+ "archive/zip"
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "testing"
+ "time"
+)
+
+const ytdlpVersion = "2026.10.21"
+
+var ytdlpArchives = map[string]struct{ name, executable, sha256 string }{
+ "amd64": {"yt-dlp_linux.zip", "yt-dlp_linux", "dcddc44b6151ccd855a9a3c8fcdb00e897bb872ec9f4908b1b708b16eaaaa114"},
+ "arm64": {"yt-dlp_linux_aarch64.zip", "yt-dlp_linux_aarch64", "d98a0aeb26b670aac94ee892a03a196f3f2cf0a81c3872186199c0c67ab36bde"},
+}
+
+func TestMain(m *testing.M) {
+ os.Exit(runTests(m))
+}
+
+func runTests(m *testing.M) int {
+ dir, err := installYtdlp()
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "准备 yt-dlp:", err)
+ return 1
+ }
+ for _, tool := range []string{"ffmpeg", "ffprobe"} {
+ if _, err := exec.LookPath(tool); err != nil {
+ fmt.Fprintf(os.Stderr, "测试需要 PATH 中有 %s:%v\n", tool, err)
+ return 1
+ }
+ }
+ if err := os.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ return 1
+ }
+ return m.Run()
+}
+
+func installYtdlp() (string, error) {
+ archive, ok := ytdlpArchives[runtime.GOARCH]
+ if runtime.GOOS != "linux" || !ok {
+ return "", fmt.Errorf("没有 %s/%s 的 yt-dlp", runtime.GOOS, runtime.GOARCH)
+ }
+ dir := filepath.Join(os.TempDir(), "alisten-yt-dlp-"+ytdlpVersion+"-"+runtime.GOARCH)
+ if _, err := os.Stat(filepath.Join(dir, executable)); err == nil {
+ return dir, nil
+ }
+ data, err := fetch("https://github.com/yt-dlp/yt-dlp/releases/download/" + ytdlpVersion + "/" + archive.name)
+ if err != nil {
+ return "", err
+ }
+ if sum := sha256.Sum256(data); hex.EncodeToString(sum[:]) != archive.sha256 {
+ return "", fmt.Errorf("%s 的 SHA-256 不符", archive.name)
+ }
+ // 解压到同一目录下的临时目录,完成之后再改名,中途失败不会留下不完整的 yt-dlp。
+ tmp, err := os.MkdirTemp(os.TempDir(), "alisten-yt-dlp-")
+ if err != nil {
+ return "", err
+ }
+ defer func() { _ = os.RemoveAll(tmp) }()
+ if err := unzip(data, tmp); err != nil {
+ return "", err
+ }
+ if err := os.Symlink(archive.executable, filepath.Join(tmp, executable)); err != nil {
+ return "", err
+ }
+ if err := os.Rename(tmp, dir); err != nil {
+ // 另一个测试进程先完成了安装。
+ if _, statErr := os.Stat(filepath.Join(dir, executable)); statErr == nil {
+ return dir, nil
+ }
+ return "", err
+ }
+ return dir, nil
+}
+
+func fetch(url string) ([]byte, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ defer cancel()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := (&http.Client{}).Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = resp.Body.Close() }()
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("下载 %s:%s", url, resp.Status)
+ }
+ return io.ReadAll(resp.Body)
+}
+
+func unzip(data []byte, dir string) error {
+ r, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
+ if err != nil {
+ return err
+ }
+ for _, f := range r.File {
+ if !filepath.IsLocal(f.Name) {
+ return fmt.Errorf("压缩包中的路径 %q 不合法", f.Name)
+ }
+ path := filepath.Join(dir, f.Name)
+ if f.FileInfo().IsDir() {
+ if err := os.MkdirAll(path, 0o755); err != nil {
+ return err
+ }
+ continue
+ }
+ if err := extract(f, path); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func extract(f *zip.File, path string) error {
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return err
+ }
+ src, err := f.Open()
+ if err != nil {
+ return err
+ }
+ defer func() { _ = src.Close() }()
+ dst, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, f.Mode().Perm())
+ if err != nil {
+ return err
+ }
+ if _, err := io.Copy(dst, src); err != nil {
+ _ = dst.Close()
+ return err
+ }
+ return dst.Close()
+}
diff --git a/internal/adapter/out/ytdlp/proxy_test.go b/internal/adapter/out/ytdlp/proxy_test.go
new file mode 100644
index 0000000..1b9ad52
--- /dev/null
+++ b/internal/adapter/out/ytdlp/proxy_test.go
@@ -0,0 +1,169 @@
+package ytdlp
+
+import (
+ "context"
+ "crypto/tls"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+func direct(ctx context.Context, network, address string) (net.Conn, error) {
+ var d net.Dialer
+ return d.DialContext(ctx, network, address)
+}
+
+func startTestProxy(t *testing.T, dial func(context.Context, string, string) (net.Conn, error)) *proxy {
+ t.Helper()
+ p, err := startProxy(dial)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(p.Close)
+ return p
+}
+
+func viaProxy(t *testing.T, p *proxy, server *httptest.Server) *http.Client {
+ t.Helper()
+ proxyURL, err := url.Parse(p.url)
+ if err != nil {
+ t.Fatal(err)
+ }
+ transport := &http.Transport{Proxy: http.ProxyURL(proxyURL)}
+ if server.TLS != nil {
+ transport.TLSClientConfig = &tls.Config{RootCAs: server.Client().Transport.(*http.Transport).TLSClientConfig.RootCAs}
+ }
+ t.Cleanup(transport.CloseIdleConnections)
+ return &http.Client{Transport: transport, Timeout: 10 * time.Second}
+}
+
+func get(t *testing.T, client *http.Client, url string) (int, string, error) {
+ t.Helper()
+ req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ return 0, "", err
+ }
+ defer func() { _ = resp.Body.Close() }()
+ body, err := io.ReadAll(resp.Body)
+ return resp.StatusCode, string(body), err
+}
+
+func TestProxyRefusesPrivateTargets(t *testing.T) {
+ var requests atomic.Int32
+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ requests.Add(1)
+ _, _ = io.WriteString(w, "内网")
+ })
+ plain, secure := httptest.NewServer(handler), httptest.NewTLSServer(handler)
+ defer plain.Close()
+ defer secure.Close()
+
+ p := startTestProxy(t, dialPublic(net.DefaultResolver))
+ if status, _, err := get(t, viaProxy(t, p, plain), plain.URL+"/a.mp3"); err != nil || status != http.StatusForbidden {
+ t.Errorf("经代理请求 http 的内网地址 = %d, %v,期望 403", status, err)
+ }
+ if _, _, err := get(t, viaProxy(t, p, secure), secure.URL+"/a.mp3"); err == nil || !strings.Contains(err.Error(), "Forbidden") {
+ t.Errorf("经代理请求 https 的内网地址 = %v,期望隧道被拒绝", err)
+ }
+ if n := requests.Load(); n != 0 {
+ t.Errorf("内网的服务收到了 %d 个请求", n)
+ }
+ if !p.wasRefused() {
+ t.Error("代理应记下拒绝过的目标")
+ }
+}
+
+func TestProxyForwardsRequests(t *testing.T) {
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("X-Forwarded-For") != "" {
+ t.Errorf("转发的请求带有 X-Forwarded-For:%q", r.Header.Get("X-Forwarded-For"))
+ }
+ _, _ = io.WriteString(w, "音频 "+r.URL.Path)
+ })
+ plain, secure := httptest.NewServer(handler), httptest.NewTLSServer(handler)
+ defer plain.Close()
+ defer secure.Close()
+
+ p := startTestProxy(t, direct)
+ for _, server := range []*httptest.Server{plain, secure} {
+ if status, body, err := get(t, viaProxy(t, p, server), server.URL+"/a.mp3"); err != nil || status != http.StatusOK || body != "音频 /a.mp3" {
+ t.Errorf("经代理请求 %s = %d %q %v", server.URL, status, body, err)
+ }
+ }
+ if p.wasRefused() {
+ t.Error("没有拒绝过目标")
+ }
+
+ req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, p.url+"/a.mp3", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = resp.Body.Close()
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Errorf("直接请求代理 = %d,期望 400:代理只接受代理请求", resp.StatusCode)
+ }
+}
+
+func TestProxyCloseEndsTunnels(t *testing.T) {
+ upstream, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = upstream.Close() }()
+ accepted := make(chan net.Conn, 1)
+ go func() {
+ if c, err := upstream.Accept(); err == nil {
+ accepted <- c
+ }
+ }()
+
+ p, err := startProxy(direct)
+ if err != nil {
+ t.Fatal(err)
+ }
+ client, err := net.Dial("tcp", strings.TrimPrefix(p.url, "http://"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = client.Close() }()
+ if _, err := io.WriteString(client, "CONNECT "+upstream.Addr().String()+" HTTP/1.1\r\nHost: "+upstream.Addr().String()+"\r\n\r\n"); err != nil {
+ t.Fatal(err)
+ }
+ reply := make([]byte, len("HTTP/1.1 200 Connection established\r\n\r\n"))
+ if _, err := io.ReadFull(client, reply); err != nil || !strings.HasPrefix(string(reply), "HTTP/1.1 200") {
+ t.Fatalf("CONNECT 的回复 = %q, %v", reply, err)
+ }
+ server := <-accepted
+ defer func() { _ = server.Close() }()
+
+ closed := make(chan struct{})
+ go func() {
+ p.Close()
+ close(closed)
+ }()
+ select {
+ case <-closed:
+ case <-time.After(5 * time.Second):
+ t.Fatal("有隧道时代理 5 秒内没有关闭")
+ }
+ if err := server.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := server.Read(make([]byte, 1)); err != io.EOF {
+ t.Errorf("代理关闭之后目标一端的读取 = %v,期望连接已关闭", err)
+ }
+}
diff --git a/internal/adapter/out/ytdlp/ytdlp_test.go b/internal/adapter/out/ytdlp/ytdlp_test.go
new file mode 100644
index 0000000..f4bf918
--- /dev/null
+++ b/internal/adapter/out/ytdlp/ytdlp_test.go
@@ -0,0 +1,505 @@
+package ytdlp
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "slices"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/app/port/porttest"
+)
+
+// 网页中的地址都用 media.test,只有测试的代理能把它解析到本机:请求如果绕过代理,就无法连接。
+func startSite(t *testing.T) (server *httptest.Server, requests *atomic.Int32, link func(path string) string) {
+ t.Helper()
+ requests = &atomic.Int32{}
+ var base string
+ mux := http.NewServeMux()
+ mux.HandleFunc("/song.wav", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "audio/wav")
+ http.ServeContent(w, r, "song.wav", time.Time{}, bytes.NewReader(porttest.SilentWAV(2*time.Second)))
+ })
+ mux.HandleFunc("/cover.jpg", func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "image/jpeg")
+ _, _ = io.WriteString(w, "jpeg")
+ })
+ mux.HandleFunc("/page", func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = fmt.Fprintf(w, `晴天`+
+ ``, base)
+ })
+ mux.HandleFunc("/empty", func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = io.WriteString(w, "没有媒体你好")
+ })
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requests.Add(1)
+ mux.ServeHTTP(w, r)
+ }))
+ t.Cleanup(server.Close)
+ _, port, err := net.SplitHostPort(server.Listener.Addr().String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ base = "http://media.test:" + port
+ return server, requests, func(path string) string { return base + path }
+}
+
+func siteProxy(t *testing.T) *proxy {
+ t.Helper()
+ return startTestProxy(t, func(ctx context.Context, network, address string) (net.Conn, error) {
+ if host, port, err := net.SplitHostPort(address); err == nil && host == "media.test" {
+ address = net.JoinHostPort("127.0.0.1", port)
+ }
+ return direct(ctx, network, address)
+ })
+}
+
+func TestYtdlpDownloadsAudioAndCover(t *testing.T) {
+ _, requests, link := startSite(t)
+ p := siteProxy(t)
+ dir := t.TempDir()
+ stdout, stderr, err := run(t.Context(), dir, args(link("/page"), dir, p.url, ""))
+ if err != nil {
+ t.Fatalf("yt-dlp 失败:%v\n%s", err, stderr)
+ }
+ d, err := collect(dir, stdout)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if d.Audio != file(filepath.Join(dir, "audio.mp3")) || d.Cover != file(filepath.Join(dir, "cover.jpg")) || d.CoverExt != "jpg" {
+ t.Errorf("下载结果 = %+v", d)
+ }
+ if !strings.Contains(d.Meta.Title, "晴天") {
+ t.Errorf("标题 = %q,期望取自网页", d.Meta.Title)
+ }
+ audio := readFile(t, d.Audio)
+ if !isMP3(audio) {
+ t.Errorf("audio.mp3 的开头 %x 不是 MP3", audio[:min(len(audio), 4)])
+ }
+ if requests.Load() < 3 {
+ t.Errorf("网站收到 %d 个请求,期望网页、音频和封面都经代理请求", requests.Load())
+ }
+ if entries, _ := os.ReadDir(dir); len(entries) != 2 {
+ t.Errorf("目录中有 %d 个文件,期望只有音频和封面", len(entries))
+ }
+}
+
+// MP3 文件以 ID3 标签或 MPEG 帧同步字开头。
+func isMP3(b []byte) bool {
+ return bytes.HasPrefix(b, []byte("ID3")) || len(b) > 1 && b[0] == 0xFF && b[1]&0xE0 == 0xE0
+}
+
+func readFile(t *testing.T, f port.LinkFile) []byte {
+ t.Helper()
+ r, err := f.Open()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = r.Close() }()
+ b, err := io.ReadAll(r)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return b
+}
+
+func TestYtdlpRejectsUnsupportedLink(t *testing.T) {
+ _, _, link := startSite(t)
+ p := siteProxy(t)
+ dir := t.TempDir()
+ _, stderr, err := run(t.Context(), dir, args(link("/empty"), dir, p.url, ""))
+ failure, err := classify(t.Context(), err, stderr, p.wasRefused())
+ var rejected *port.RejectedError
+ if failure != failureUnsupported || !errors.As(err, &rejected) {
+ t.Errorf("没有媒体的网页 = %q, %v,期望不支持\n%s", failure, err, stderr)
+ }
+}
+
+type observed struct {
+ mu sync.Mutex
+ failures []string
+}
+
+func (o *observed) observe(elapsed time.Duration, failure string) {
+ o.mu.Lock()
+ defer o.mu.Unlock()
+ if elapsed <= 0 {
+ failure += "(没有耗时)"
+ }
+ o.failures = append(o.failures, failure)
+}
+
+func TestDownloadRefusesPrivateAddresses(t *testing.T) {
+ server, requests, _ := startSite(t)
+ o := &observed{}
+ root := t.TempDir()
+ _, _, err := New(root, net.DefaultResolver, noCookies{}, o.observe, slog.New(slog.DiscardHandler)).Download(t.Context(), server.URL+"/page")
+ var rejected *port.RejectedError
+ if !errors.As(err, &rejected) || !strings.Contains(err.Error(), "不是公网地址") {
+ t.Errorf("下载本机的地址 = %v,期望拒绝", err)
+ }
+ if n := requests.Load(); n != 0 {
+ t.Errorf("网站收到了 %d 个请求", n)
+ }
+ if !slices.Equal(o.failures, []string{failureBlocked}) {
+ t.Errorf("记下的失败 = %q", o.failures)
+ }
+ if left, _ := os.ReadDir(root); len(left) != 0 {
+ t.Errorf("下载失败之后 %s 中还有 %v", root, left)
+ }
+}
+
+func fakeYtdlp(t *testing.T, script string) {
+ t.Helper()
+ bin := t.TempDir()
+ if err := os.WriteFile(filepath.Join(bin, executable), []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
+}
+
+func TestDownloadKeepsFilesUntilReleased(t *testing.T) {
+ fakeYtdlp(t, "#!/bin/sh\nprintf mp3 > audio.mp3\necho '{}'\n")
+ root := t.TempDir()
+ d, release, err := New(root, net.DefaultResolver, noCookies{}, func(time.Duration, string) {}, slog.New(slog.DiscardHandler)).Download(t.Context(), "https://example.com/page")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if jobs, _ := os.ReadDir(root); len(jobs) != 1 || !jobs[0].IsDir() {
+ t.Errorf("%s 中有 %v,期望只有这次任务的目录", root, jobs)
+ }
+ if audio := readFile(t, d.Audio); string(audio) != "mp3" {
+ t.Errorf("音频 = %q", audio)
+ }
+ release()
+ if left, _ := os.ReadDir(root); len(left) != 0 {
+ t.Errorf("release 之后 %s 中还有 %v", root, left)
+ }
+}
+
+func TestCancelledDownloadIsNotObserved(t *testing.T) {
+ var once sync.Once
+ entered := make(chan struct{})
+ resolver := &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, _, _ string) (net.Conn, error) {
+ once.Do(func() { close(entered) })
+ <-ctx.Done()
+ return nil, ctx.Err()
+ }}
+ o := &observed{}
+ var logs bytes.Buffer
+ ctx, cancel := context.WithCancel(t.Context())
+ defer cancel()
+ done := make(chan error, 1)
+ go func() {
+ _, _, err := New(t.TempDir(), resolver, noCookies{}, o.observe, slog.New(slog.NewJSONHandler(&logs, nil))).Download(ctx, "http://hang.test/page")
+ done <- err
+ }()
+ select {
+ case <-entered:
+ case <-time.After(30 * time.Second):
+ t.Fatal("yt-dlp 的请求没有到达代理")
+ }
+ cancel()
+ if err := <-done; !errors.Is(err, context.Canceled) {
+ t.Errorf("取消的下载返回 %v,期望 context.Canceled", err)
+ }
+ o.mu.Lock()
+ defer o.mu.Unlock()
+ if len(o.failures) != 0 || logs.Len() != 0 {
+ t.Errorf("取消的下载记下了 %q,日志:%s", o.failures, logs.String())
+ }
+}
+
+func TestImpersonatedRequestsUseProxy(t *testing.T) {
+ t.Setenv("no_proxy", "*")
+ t.Setenv("NO_PROXY", "*")
+ server, requests, _ := startSite(t)
+ p := startTestProxy(t, dialPublic(net.DefaultResolver))
+ dir := t.TempDir()
+ _, stderr, err := run(t.Context(), dir, append([]string{"--impersonate", "chrome"}, args(server.URL+"/page", dir, p.url, "")...))
+ if err == nil || requests.Load() != 0 || !p.wasRefused() {
+ t.Errorf("模拟浏览器请求本机的网站 = %v,网站收到 %d 个请求,期望被代理拒绝\n%s", err, requests.Load(), stderr)
+ }
+}
+
+func processesIn(t *testing.T, dir string) []string {
+ t.Helper()
+ entries, err := os.ReadDir("/proc")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var pids []string
+ for _, e := range entries {
+ if _, err := strconv.Atoi(e.Name()); err != nil {
+ continue
+ }
+ if cwd, err := os.Readlink(filepath.Join("/proc", e.Name(), "cwd")); err == nil && cwd == dir {
+ pids = append(pids, e.Name())
+ }
+ }
+ return pids
+}
+
+func TestRunKillsProcessGroup(t *testing.T) {
+ fakeYtdlp(t, "#!/bin/sh\nsleep 300 &\nsleep 300\n")
+ dir := t.TempDir()
+ ctx, cancel := context.WithTimeout(t.Context(), time.Second)
+ defer cancel()
+ start := time.Now()
+ _, _, err := run(ctx, dir, nil)
+ if err == nil || time.Since(start) > waitDelay {
+ t.Errorf("run = %v,用了 %v,期望超时后立即结束", err, time.Since(start))
+ }
+ if failure, _ := classify(ctx, err, nil, false); failure != failureTimeout {
+ t.Errorf("超时的失败原因 = %q", failure)
+ }
+ for deadline := time.Now().Add(5 * time.Second); len(processesIn(t, dir)) > 0; time.Sleep(50 * time.Millisecond) {
+ if time.Now().After(deadline) {
+ t.Fatalf("超时之后仍有进程 %v", processesIn(t, dir))
+ }
+ }
+}
+
+func TestArgsEndWithLink(t *testing.T) {
+ got := args("-x", "/tmp/d", "http://127.0.0.1:1", "")
+ if !slices.Equal(got[len(got)-2:], []string{"--", "-x"}) {
+ t.Errorf("参数的末尾 = %q,期望链接在 -- 之后", got[len(got)-2:])
+ }
+}
+
+func TestClassify(t *testing.T) {
+ exited := errors.New("exit status 1")
+ expired, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
+ defer cancel()
+ long := strings.Repeat("长", reasonLimit+10)
+ tests := []struct {
+ ctx context.Context
+ err error
+ stderr string
+ refused bool
+ failure string
+ rejected bool
+ message string
+ }{
+ {t.Context(), nil, "WARNING: x", false, "", false, ""},
+ {expired, exited, "", false, failureTimeout, false, "yt-dlp 下载超时:context deadline exceeded"},
+ {t.Context(), exited, "ERROR: [generic] a: HTTP Error 403: Forbidden\n", true, failureBlocked, true, "链接指向的地址不是公网地址,不能下载"},
+ {t.Context(), exited, "WARNING: x\nERROR: Unsupported URL: http://example.com/\n", false, failureUnsupported, true, "yt-dlp 不支持这个链接"},
+ {t.Context(), errNoAudio, "", false, failureUnsupported, true, "yt-dlp 不支持这个链接"},
+ {t.Context(), exited, "ERROR: 第一条\nWARNING: x\nERROR: [youtube] x: Video unavailable\n", false, failureError, false, "yt-dlp 下载失败:[youtube] x: Video unavailable"},
+ {t.Context(), exited, "ERROR: " + long, false, failureError, false, "yt-dlp 下载失败:" + strings.Repeat("长", reasonLimit) + "…"},
+ {t.Context(), exited, "", false, failureError, false, "yt-dlp 下载失败:exit status 1"},
+ }
+ for _, tt := range tests {
+ failure, err := classify(tt.ctx, tt.err, []byte(tt.stderr), tt.refused)
+ var rejected *port.RejectedError
+ if failure != tt.failure || errors.As(err, &rejected) != tt.rejected || (err == nil) != (tt.message == "") || (err != nil && err.Error() != tt.message) {
+ t.Errorf("classify(%v, %q) = %q, %v,期望 %q, %q", tt.err, tt.stderr, failure, err, tt.failure, tt.message)
+ }
+ }
+}
+
+func TestCollect(t *testing.T) {
+ dir := t.TempDir()
+ if _, err := collect(dir, []byte(`{"title": "晴天"}`)); !errors.Is(err, errNoAudio) {
+ t.Errorf("没有音频时 = %v", err)
+ }
+ for _, name := range []string{"audio.mp3", "cover.WEBP"} {
+ if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ d, err := collect(dir, []byte("{\"title\": \"晴天\", \"uploader\": \"周杰伦\", \"album\": \"叶惠美\", \"duration\": 269.5}\n"))
+ want := port.LinkDownload{Audio: file(filepath.Join(dir, "audio.mp3")), Cover: file(filepath.Join(dir, "cover.WEBP")), CoverExt: "webp"}
+ want.Meta.Title, want.Meta.Artist, want.Meta.Album, want.Meta.Duration = "晴天", "周杰伦", "叶惠美", 269500*time.Millisecond
+ if err != nil || d != want {
+ t.Errorf("collect = %+v, %v\n期望 %+v", d, err, want)
+ }
+ if d, _ := collect(dir, []byte(`{"title": "晴天", "artist": "周杰伦、方文山", "uploader": "JVR Music"}`)); d.Meta.Artist != "周杰伦、方文山" {
+ t.Errorf("有歌手时 Artist = %q,期望取歌手而不是上传者", d.Meta.Artist)
+ }
+}
+
+func TestTail(t *testing.T) {
+ out := &tail{limit: 5}
+ for _, s := range []string{"ab\nc", "def\n", "g"} {
+ if _, err := io.WriteString(out, s); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if string(out.buf) != "def\ng" {
+ t.Errorf("保留的输出 = %q,期望末尾的 5 个字节", out.buf)
+ }
+ if string(out.lines()) != "g" {
+ t.Errorf("lines() = %q,期望去掉开头的半行", out.lines())
+ }
+ whole := &tail{limit: 6}
+ if _, err := io.WriteString(whole, "ab\ncd"); err != nil {
+ t.Fatal(err)
+ }
+ if string(whole.lines()) != "ab\ncd" {
+ t.Errorf("没有截断时 lines() = %q", whole.lines())
+ }
+}
+
+func TestRedactCookies(t *testing.T) {
+ stderr := "WARNING: skipping cookie file entry due to invalid length 1: 'SID=fictional\\n'\n" +
+ "ERROR: '/tmp/a/cookies.txt' does not look like a Netscape format cookies file\n" +
+ "WARNING: [youtube] The provided YouTube account cookies are no longer valid.\n" +
+ "ValueError: could not convert string to float: 'fictional'\n" +
+ "ERROR: invalid Netscape format cookies file '/tmp/a/cookies.txt': 'fictional'"
+ want := "WARNING: skipping cookie file entry [REDACTED]\n" +
+ "ERROR: '/tmp/a/cookies.txt' does not look like a Netscape format cookies file\n" +
+ "WARNING: [youtube] The provided YouTube account cookies are no longer valid.\n" +
+ "ValueError: could not convert string to float [REDACTED]\n" +
+ "ERROR: invalid Netscape format cookies file [REDACTED]"
+ if got := string(redactCookies([]byte(stderr))); got != want {
+ t.Errorf("redactCookies =\n%s\n期望\n%s", got, want)
+ }
+}
+
+type noCookies struct{}
+
+func (noCookies) CookieFile(context.Context, string) (string, func(), bool, error) {
+ return "", nil, false, nil
+}
+
+type fakeCookies struct {
+ path string
+ ok bool
+ err error
+ released int
+ asked int
+}
+
+func (f *fakeCookies) CookieFile(_ context.Context, _ string) (string, func(), bool, error) {
+ f.asked++
+ if f.err != nil {
+ return "", nil, false, f.err
+ }
+ if !f.ok {
+ return "", nil, false, nil
+ }
+ return f.path, func() { f.released++ }, true, nil
+}
+
+// fakeYtdlp 把收到的参数写进 args.txt,供测试核对 --cookies。
+func recordedArgs(t *testing.T, root string) []string {
+ t.Helper()
+ jobs, err := os.ReadDir(root)
+ if err != nil || len(jobs) != 1 {
+ t.Fatalf("读取任务目录 %s:%v %v", root, jobs, err)
+ }
+ raw, err := os.ReadFile(filepath.Join(root, jobs[0].Name(), "args.txt"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return strings.Split(strings.TrimRight(string(raw), "\n"), "\n")
+}
+
+func TestDownloadPassesCookiesFile(t *testing.T) {
+ fakeYtdlp(t, "#!/bin/sh\nprintf '%s\\n' \"$@\" > args.txt\nprintf mp3 > audio.mp3\necho '{}'\n")
+ root := t.TempDir()
+ cookies := &fakeCookies{path: filepath.Join(t.TempDir(), "cookies.txt"), ok: true}
+ _, release, err := New(root, net.DefaultResolver, cookies, func(time.Duration, string) {}, slog.New(slog.DiscardHandler)).Download(t.Context(), "https://www.youtube.com/watch?v=dQw4w9WgXcQ")
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := recordedArgs(t, root)
+ if i := slices.Index(got, "--cookies"); i < 0 || i+1 >= len(got) || got[i+1] != cookies.path {
+ t.Errorf("参数中没有 --cookies %s:%q", cookies.path, got)
+ }
+ if cookies.released != 1 {
+ t.Errorf("下载结束调用 release %d 次,期望 1 次", cookies.released)
+ }
+ release()
+ if cookies.released != 1 {
+ t.Errorf("任务目录的 release 又调用了 cookies 的 release")
+ }
+}
+
+func TestDownloadOmitsCookiesWhenNotOffered(t *testing.T) {
+ fakeYtdlp(t, "#!/bin/sh\nprintf '%s\\n' \"$@\" > args.txt\nprintf mp3 > audio.mp3\necho '{}'\n")
+ root := t.TempDir()
+ cookies := &fakeCookies{}
+ _, release, err := New(root, net.DefaultResolver, cookies, func(time.Duration, string) {}, slog.New(slog.DiscardHandler)).Download(t.Context(), "https://example.com/page")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer release()
+ if cookies.asked != 1 {
+ t.Errorf("CookieFile 被调用 %d 次", cookies.asked)
+ }
+ if got := recordedArgs(t, root); slices.Contains(got, "--cookies") {
+ t.Errorf("不需要 cookies 时传了 --cookies:%q", got)
+ }
+ if cookies.released != 0 {
+ t.Errorf("没有 cookies 时调用了 release %d 次", cookies.released)
+ }
+}
+
+func TestDownloadReleasesCookiesOnFailure(t *testing.T) {
+ fakeYtdlp(t, "#!/bin/sh\necho 'ERROR: unavailable' >&2\nexit 1\n")
+ cookies := &fakeCookies{path: filepath.Join(t.TempDir(), "cookies.txt"), ok: true}
+ _, _, err := New(t.TempDir(), net.DefaultResolver, cookies, func(time.Duration, string) {}, slog.New(slog.DiscardHandler)).Download(t.Context(), "https://www.youtube.com/watch?v=dQw4w9WgXcQ")
+ if err == nil {
+ t.Fatal("下载失败应返回错误")
+ }
+ if cookies.released != 1 {
+ t.Errorf("失败后调用 release %d 次,期望 1 次", cookies.released)
+ }
+}
+
+func TestDownloadFailsWhenCookiesUnavailable(t *testing.T) {
+ fakeYtdlp(t, "#!/bin/sh\nprintf '%s\\n' \"$@\" > args.txt\nprintf mp3 > audio.mp3\necho '{}'\n")
+ root := t.TempDir()
+ cookies := &fakeCookies{err: errors.New("数据库不可用")}
+ _, _, err := New(root, net.DefaultResolver, cookies, func(time.Duration, string) {}, slog.New(slog.DiscardHandler)).Download(t.Context(), "https://www.youtube.com/watch?v=dQw4w9WgXcQ")
+ if err == nil || !strings.Contains(err.Error(), "数据库不可用") {
+ t.Fatalf("读取 cookies 失败应返回错误,得到 %v", err)
+ }
+ if jobs, _ := os.ReadDir(root); len(jobs) != 0 {
+ t.Errorf("失败后 %s 中还有 %v", root, jobs)
+ }
+}
+
+func TestDownloadRedactsCookiesEchoedByYtdlp(t *testing.T) {
+ // 第 2 到 4 行格式不对,yt-dlp 跳过并打印出来,第 3 行长到截断之后的输出从它的半行开始;
+ // 最后一行有两层 #HttpOnly_ 前缀,读取失败,报错和 traceback 里都带着它的内容。
+ path := filepath.Join(t.TempDir(), "cookies.txt")
+ content := "# Netscape HTTP Cookie File\n" +
+ "SID=fictional-sid; PREF=f4=4000000\n" +
+ "SID=" + strings.Repeat("x", 70000) + "fictional-tail\n" +
+ ".youtube.com\tTRUE\t/\tTRUE\tfictional-expiry\tHSID\tfictional-hsid\n" +
+ "#HttpOnly_#HttpOnly_.youtube.com\tTRUE\t/\tTRUE\tfictional-float\tSSID\tfictional-ssid\n"
+ if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ offline := &net.Resolver{PreferGo: true, Dial: func(context.Context, string, string) (net.Conn, error) {
+ return nil, errors.New("测试不连网")
+ }}
+ var logs bytes.Buffer
+ _, _, err := New(t.TempDir(), offline, &fakeCookies{path: path, ok: true}, func(time.Duration, string) {}, slog.New(slog.NewJSONHandler(&logs, nil))).Download(t.Context(), "http://cookies.test/page")
+ if want := "yt-dlp 下载失败:invalid Netscape format cookies file [REDACTED]"; err == nil || err.Error() != want {
+ t.Errorf("Download 返回 %v,期望 %q", err, want)
+ }
+ if strings.Contains(logs.String(), "fictional") || !strings.Contains(logs.String(), "skipping cookie file entry [REDACTED]") {
+ t.Errorf("日志没有去掉 cookies 文件的内容:%s", logs.String())
+ }
+}
diff --git a/internal/app/admin/admin_test.go b/internal/app/admin/admin_test.go
new file mode 100644
index 0000000..d054a69
--- /dev/null
+++ b/internal/app/admin/admin_test.go
@@ -0,0 +1,506 @@
+package admin
+
+import (
+ "context"
+ "crypto/sha256"
+ "errors"
+ "log/slog"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/lobby"
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/app/port/porttest"
+ "github.com/dihemoe/alisten/internal/app/realtime"
+ "github.com/dihemoe/alisten/internal/domain/room"
+)
+
+type fixture struct {
+ admin *Service
+ store *porttest.Store
+ youtube *porttest.YouTubeCookies
+ netease *porttest.NetEaseQRLogin
+ qq *porttest.QQQRLogin
+ bilibili *porttest.BilibiliQRLogin
+ node *porttest.Node
+ host *realtime.Host
+ gateway *realtime.Gateway
+}
+
+func newService(t *testing.T, token string) fixture {
+ t.Helper()
+ store := porttest.New()
+ node := porttest.NewRedis().Node("a")
+ host, gateway := realtime.New(store, store, nil, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
+ ctx, stop := context.WithCancel(context.Background())
+ wait, err := realtime.Start(ctx, host, gateway)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ if err := host.Shutdown(context.Background()); err != nil {
+ t.Error(err)
+ }
+ if err := gateway.Shutdown(context.Background()); err != nil {
+ t.Error(err)
+ }
+ stop()
+ wait()
+ })
+ l := lobby.New(store, node)
+ netease, qq, bilibili, youtube := porttest.NewNetEaseQRLogin(), porttest.NewQQQRLogin(), porttest.NewBilibiliQRLogin(), porttest.NewYouTubeCookies()
+ return fixture{admin: New(token, l, store, store, store, netease, qq, bilibili, youtube, gateway), store: store, youtube: youtube, netease: netease, qq: qq, bilibili: bilibili, node: node, host: host, gateway: gateway}
+}
+
+func TestSetYouTubeCookies(t *testing.T) {
+ f := newService(t, "secret")
+
+ // Netscape 格式原样保存,换行统一成 \n;#HttpOnly_ 开头的是有效 cookie 行,不是注释。
+ input := "# Netscape HTTP Cookie File\r\n#HttpOnly_.youtube.com\tTRUE\t/\tTRUE\t1798765432\tSID\tfictional\r\n.youtube.com\tTRUE\t/\tFALSE\t1798765432\tHSID\tother\r\n"
+ want := "# Netscape HTTP Cookie File\n#HttpOnly_.youtube.com\tTRUE\t/\tTRUE\t1798765432\tSID\tfictional\n.youtube.com\tTRUE\t/\tFALSE\t1798765432\tHSID\tother"
+ if err := f.admin.SetYouTubeCookies(t.Context(), input); err != nil {
+ t.Fatalf("保存 Netscape cookies:%v", err)
+ }
+ if got := f.youtube.Cookies(); got != want {
+ t.Errorf("保存的 cookies = %q,期望 %q", got, want)
+ }
+
+ // 请求头格式转成 Netscape:带和不带 Cookie: 前缀;cookie 名字保留原始大小写。
+ for _, tc := range []struct{ header, want string }{
+ {"SID=fictional; HSID=other", "\tSID\tfictional"},
+ {"Cookie: SID=fictional; HSID=other", "\tSID\tfictional"},
+ {"cookie:sid=fictional", "\tsid\tfictional"},
+ } {
+ if err := f.admin.SetYouTubeCookies(t.Context(), tc.header); err != nil {
+ t.Fatalf("保存请求头格式的 cookies:%v", err)
+ }
+ saved := f.youtube.Cookies()
+ lines := strings.Split(saved, "\n")
+ if lines[0] != "# Netscape HTTP Cookie File" {
+ t.Errorf("转换后的第一行 = %q,期望 Netscape 头", lines[0])
+ }
+ for _, line := range lines[1:] {
+ fields := strings.Split(line, "\t")
+ if len(fields) != 7 || fields[0] != ".youtube.com" || fields[1] != "TRUE" || fields[2] != "/" || fields[3] != "TRUE" {
+ t.Errorf("转换后的行 = %q,期望 .youtube.com、Secure 的 Netscape 行", line)
+ continue
+ }
+ expiry, err := strconv.ParseInt(fields[4], 10, 64)
+ if err != nil || expiry <= time.Now().Unix() {
+ t.Errorf("过期时间 = %q,期望晚于现在", fields[4])
+ }
+ }
+ if !strings.Contains(saved, tc.want) {
+ t.Errorf("转换后的内容缺少 %q 行:%q", tc.want, saved)
+ }
+ }
+
+ // 无法识别的内容返回固定错误,错误文本不包含输入的值。
+ unrecognized := []string{"fictional", " \n ", "name\twith\ttabs", "a=1\nb=2", "# Netscape HTTP Cookie File"}
+ for _, input := range unrecognized {
+ err := f.admin.SetYouTubeCookies(t.Context(), input)
+ if !errors.Is(err, ErrUnrecognizedCookies) {
+ t.Errorf("保存 %q 的错误 = %v,期望 ErrUnrecognizedCookies", input, err)
+ }
+ if strings.Contains(err.Error(), input) && strings.TrimSpace(input) != "" {
+ t.Errorf("错误文本包含输入的值:%v", err)
+ }
+ }
+
+ failure := errors.New("保存失败")
+ f.youtube.Fail(failure)
+ if err := f.admin.SetYouTubeCookies(t.Context(), "session=fictional"); !errors.Is(err, failure) {
+ t.Errorf("保存失败时的错误 = %v,期望透传 %v", err, failure)
+ }
+}
+
+func TestClearYouTubeCookies(t *testing.T) {
+ f := newService(t, "secret")
+ if err := f.admin.SetYouTubeCookies(t.Context(), "session=fictional"); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.admin.ClearYouTubeCookies(t.Context()); err != nil {
+ t.Fatalf("清除 cookies:%v", err)
+ }
+ if got := f.youtube.Cookies(); got != "" {
+ t.Errorf("清除之后的 cookies = %q,期望空字符串", got)
+ }
+
+ failure := errors.New("删除失败")
+ f.youtube.Fail(failure)
+ if err := f.admin.ClearYouTubeCookies(t.Context()); !errors.Is(err, failure) {
+ t.Errorf("删除失败时的错误 = %v,期望透传 %v", err, failure)
+ }
+}
+
+func (f fixture) enter(t *testing.T, roomID, name string) ([]byte, *realtime.Session) {
+ t.Helper()
+ hash := sha256.Sum256([]byte(name))
+ m, err := f.store.CreateMember(t.Context(), hash[:], name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ r, err := f.store.FindRoom(t.Context(), roomID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := f.store.CreateTicket(t.Context(), port.NewTicket{TokenHash: hash[:], RoomID: roomID, MemberID: m.ID, PasswordHash: r.PasswordHash}); err != nil {
+ t.Fatal(err)
+ }
+ session, err := f.gateway.Join(t.Context(), roomID, m)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { f.gateway.Leave(session) })
+ return hash[:], session
+}
+
+// 会话没有关闭时返回 0。
+func closeReason(t *testing.T, s *realtime.Session) realtime.CloseReason {
+ t.Helper()
+ ctx, cancel := context.WithCancel(t.Context())
+ cancel()
+ for {
+ m, reason, _ := s.Next(ctx)
+ if m == nil {
+ return reason
+ }
+ }
+}
+
+// 票据作废的通知经由房间频道送达,等会话关闭之后再看原因。
+func waitClosed(t *testing.T, s *realtime.Session) realtime.CloseReason {
+ t.Helper()
+ select {
+ case <-s.Context().Done():
+ case <-time.After(5 * time.Second):
+ t.Fatal("会话没有关闭")
+ }
+ return closeReason(t, s)
+}
+
+func ptr(s string) *string { return &s }
+
+func TestAuthorize(t *testing.T) {
+ s := newService(t, "secret").admin
+ if err := s.Authorize("secret"); err != nil {
+ t.Errorf("正确的管理 token 被拒绝:%v", err)
+ }
+ for _, token := range []string{"", "secre", "secret ", "Secret"} {
+ if err := s.Authorize(token); !errors.Is(err, ErrInvalidToken) {
+ t.Errorf("Authorize(%q) = %v,期望 ErrInvalidToken", token, err)
+ }
+ }
+}
+
+func TestUpdateRoom(t *testing.T) {
+ f := newService(t, "secret")
+ id, err := f.admin.CreateRoom(t.Context(), NewRoom{Name: "大厅", Description: "常驻", Slug: "hall"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.admin.CreateRoom(t.Context(), NewRoom{Name: "二厅", Slug: "lounge"}); err != nil {
+ t.Fatal(err)
+ }
+ ticket, session := f.enter(t, id, "alice")
+
+ if err := f.admin.UpdateRoom(t.Context(), id, RoomChange{Name: ptr("新大厅")}); err != nil {
+ t.Fatal(err)
+ }
+ if r, _ := f.store.FindRoom(t.Context(), id); r.Info.Name != "新大厅" || r.Info.Description != "常驻" || r.Slug != "hall" {
+ t.Errorf("只改名称之后 = %+v,期望描述和别名不变", r)
+ }
+ if err := f.admin.UpdateRoom(t.Context(), id, RoomChange{Name: ptr("改不了"), Slug: ptr("lounge")}); !errors.Is(err, port.ErrSlugTaken) {
+ t.Errorf("别名被占用时的错误 = %v,期望 ErrSlugTaken", err)
+ }
+ if r, _ := f.store.FindRoom(t.Context(), id); r.Info.Name != "新大厅" || r.Slug != "hall" {
+ t.Errorf("别名被占用之后 = %+v,期望什么都没有改", r)
+ }
+
+ if err := f.admin.UpdateRoom(t.Context(), id, RoomChange{Password: ptr("芝麻开门"), Description: ptr("上锁了")}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.store.TicketHolder(t.Context(), ticket); !errors.Is(err, port.ErrTicketNotFound) {
+ t.Errorf("设置密码之后票据的错误 = %v,期望 ErrTicketNotFound", err)
+ }
+ if reason := waitClosed(t, session); reason != realtime.CloseRevoked {
+ t.Errorf("设置密码之后会话的关闭原因 = %d,期望 CloseRevoked", reason)
+ }
+ if r, _ := f.store.FindRoom(t.Context(), id); !strings.HasPrefix(r.PasswordHash, "$argon2id$") || r.Info.Description != "上锁了" {
+ t.Errorf("设置密码和描述之后 = %+v", r)
+ }
+}
+
+func TestUpdateRoomKeepsTicketsWhenRoomUnavailable(t *testing.T) {
+ f := newService(t, "secret")
+ id, err := f.admin.CreateRoom(t.Context(), NewRoom{Name: "大厅"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ ticket, session := f.enter(t, id, "alice")
+ if err := f.host.Shutdown(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := f.admin.UpdateRoom(t.Context(), id, RoomChange{Name: ptr("新大厅"), Password: ptr("芝麻开门")}); !errors.Is(err, realtime.ErrUnavailable) {
+ t.Fatalf("房间不可用时的错误 = %v,期望 ErrUnavailable", err)
+ }
+ if _, err := f.store.TicketHolder(t.Context(), ticket); err != nil {
+ t.Errorf("修改失败之后票据的错误 = %v,期望票据仍然有效", err)
+ }
+ if reason := closeReason(t, session); reason != 0 {
+ t.Errorf("修改失败之后会话以 %d 关闭,期望不受影响", reason)
+ }
+ if r, _ := f.store.FindRoom(t.Context(), id); r.PasswordHash != "" || r.Info.Name != "大厅" {
+ t.Errorf("修改失败之后 = %+v,期望密码和名称都没有改", r)
+ }
+}
+
+func TestCancelPasswordKeepsTickets(t *testing.T) {
+ f := newService(t, "secret")
+ id, err := f.admin.CreateRoom(t.Context(), NewRoom{Name: "大厅", Password: "芝麻开门"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ ticket, session := f.enter(t, id, "alice")
+
+ if err := f.admin.UpdateRoom(t.Context(), id, RoomChange{Password: ptr("")}); err != nil {
+ t.Fatal(err)
+ }
+ if holder, err := f.store.TicketHolder(t.Context(), ticket); err != nil || holder.RoomID != id {
+ t.Errorf("取消密码之后 TicketHolder = %+v, %v,期望票据仍然有效", holder, err)
+ }
+ if reason := closeReason(t, session); reason != 0 {
+ t.Errorf("取消密码之后会话以 %d 关闭,期望不受影响", reason)
+ }
+ if r, _ := f.store.FindRoom(t.Context(), id); r.PasswordHash != "" {
+ t.Errorf("取消密码之后的密码哈希 = %q", r.PasswordHash)
+ }
+}
+
+func TestRoomAPIKey(t *testing.T) {
+ f := newService(t, "secret")
+ id, err := f.admin.CreateRoom(t.Context(), NewRoom{Name: "大厅"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ holder := func(key string) (port.TicketHolder, error) {
+ hash := sha256.Sum256([]byte(key))
+ return f.store.TicketHolder(t.Context(), hash[:])
+ }
+ status := func() port.APIKeyStatus {
+ t.Helper()
+ s, err := f.admin.RoomAPIKeyStatus(t.Context(), id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return s
+ }
+
+ if got := status(); got.Set || !got.CreatedAt.IsZero() {
+ t.Errorf("还没有 Key 时的状态 = %+v,期望未生成", got)
+ }
+ before := time.Now()
+ key, err := f.admin.ResetRoomAPIKey(t.Context(), id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ bot, err := holder(key)
+ if err != nil || bot.RoomID != id || !bot.Bot {
+ t.Fatalf("Key 的持有者 = %+v, %v,期望这个房间的 bot 成员", bot, err)
+ }
+ if got := status(); !got.Set || got.CreatedAt.Before(before) || got.CreatedAt.After(time.Now()) {
+ t.Errorf("生成之后的状态 = %+v,期望已生成,时间是这次生成的时间", got)
+ }
+
+ reset, err := f.admin.ResetRoomAPIKey(t.Context(), id)
+ if err != nil || reset == key {
+ t.Fatalf("重置 = %q, %v,期望换成新的 Key", reset, err)
+ }
+ if _, err := holder(key); !errors.Is(err, port.ErrTicketNotFound) {
+ t.Errorf("重置之后旧 Key 的错误 = %v,期望 ErrTicketNotFound", err)
+ }
+ if got, err := holder(reset); err != nil || got != bot {
+ t.Errorf("重置之后新 Key 的持有者 = %+v, %v,期望 bot 成员不变", got, err)
+ }
+
+ ticket, _ := f.enter(t, id, "alice")
+ for _, password := range []string{"芝麻开门", "新密码", ""} {
+ if err := f.admin.UpdateRoom(t.Context(), id, RoomChange{Password: ptr(password)}); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := holder(reset); err != nil || got != bot {
+ t.Errorf("密码改为 %q 之后 Key 的持有者 = %+v, %v,期望 Key 仍然有效", password, got, err)
+ }
+ }
+ if _, err := f.store.TicketHolder(t.Context(), ticket); !errors.Is(err, port.ErrTicketNotFound) {
+ t.Errorf("设置密码之后成员票据的错误 = %v,期望 ErrTicketNotFound", err)
+ }
+
+ ephemeral, err := f.store.CreateRoom(t.Context(), port.NewRoom{Kind: room.Ephemeral, Name: "临时"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for name, call := range map[string]func() error{
+ "查看": func() error { _, err := f.admin.RoomAPIKeyStatus(t.Context(), ephemeral); return err },
+ "生成": func() error { _, err := f.admin.ResetRoomAPIKey(t.Context(), ephemeral); return err },
+ } {
+ if err := call(); !errors.Is(err, port.ErrRoomNotFound) {
+ t.Errorf("%s临时房间的 Key 的错误 = %v,期望 ErrRoomNotFound", name, err)
+ }
+ }
+}
+
+func TestCredentialStatus(t *testing.T) {
+ f := newService(t, "secret")
+ name, vip := "测试账号", true
+ for _, source := range []struct {
+ name string
+ save func(context.Context, string) error
+ status func(context.Context) (port.CredentialStatus, error)
+ setAccount func(*port.SourceAccount)
+ failAccount func(error)
+ account *port.SourceAccount
+ }{
+ {"网易云", f.store.SetNeteaseCookie, f.admin.NeteaseCredentialStatus, f.netease.SetAccount, f.netease.FailAccount, &port.SourceAccount{ID: "123", Name: &name, VIP: &vip, Verified: true}},
+ {"QQ", f.store.SetQQCookie, f.admin.QQCredentialStatus, f.qq.SetAccount, f.qq.FailAccount, &port.SourceAccount{ID: "456"}},
+ } {
+ t.Run(source.name, func(t *testing.T) {
+ source.failAccount(errors.New("无凭据时不允许查询账号"))
+ got, err := source.status(t.Context())
+ if err != nil || got.Set || got.Account != nil {
+ t.Fatalf("没有凭据时的状态 = %+v, %v,期望未设置且不查询账号", got, err)
+ }
+ if err := source.save(t.Context(), "敏感凭据"); err != nil {
+ t.Fatal(err)
+ }
+ source.setAccount(source.account)
+ got, err = source.status(t.Context())
+ if err != nil || !got.Set || got.Account != source.account {
+ t.Fatalf("保存凭据后的状态 = %+v, %v,期望 %+v", got, err, source.account)
+ }
+ upstream := errors.New("上游失败")
+ source.failAccount(upstream)
+ if _, err := source.status(t.Context()); !errors.Is(err, port.ErrProviderFailed) || !errors.Is(err, upstream) {
+ t.Fatalf("账号查询错误 = %v,期望包含上游错误和 ErrProviderFailed", err)
+ }
+ })
+ }
+}
+
+func TestQRLogin(t *testing.T) {
+ f := newService(t, "secret")
+ netease, err := f.admin.CreateNeteaseQRLogin(t.Context())
+ if err != nil || netease.Key == "" || netease.LoginURL == "" {
+ t.Fatalf("网易云的二维码 = %+v, %v", netease, err)
+ }
+ scanned := port.QRLoginResult{Status: port.QRLoginScanned, Message: "请在手机上确认"}
+ f.netease.SetResult(netease.Key, scanned)
+ if got, err := f.admin.CheckNeteaseQRLogin(t.Context(), netease.Key); err != nil || got != scanned {
+ t.Errorf("网易云的扫码结果 = %+v, %v,期望 %+v", got, err, scanned)
+ }
+
+ wx, err := f.admin.CreateQQQRLogin(t.Context(), port.QQLoginWeChat)
+ if err != nil || wx.QRImage != porttest.WeChatQRImage {
+ t.Fatalf("微信扫码的二维码 = %+v, %v,期望音源给出的微信二维码", wx, err)
+ }
+ for typ, want := range map[port.QQLoginType]port.QRLoginStatus{port.QQLoginWeChat: port.QRLoginWaiting, port.QQLoginQQ: port.QRLoginExpired} {
+ if got, err := f.admin.CheckQQQRLogin(t.Context(), typ, wx.Key); err != nil || got.Status != want {
+ t.Errorf("按 %s 查询微信扫码的结果 = %+v, %v,期望 %s", typ, got, err, want)
+ }
+ }
+
+ f.netease.Fail(errors.New("连接被重置"))
+ f.qq.Fail(errors.New("连接被重置"))
+ for name, call := range map[string]func() error{
+ "生成网易云的二维码": func() error { _, err := f.admin.CreateNeteaseQRLogin(t.Context()); return err },
+ "查询网易云的扫码结果": func() error { _, err := f.admin.CheckNeteaseQRLogin(t.Context(), netease.Key); return err },
+ "生成 QQ 的二维码": func() error { _, err := f.admin.CreateQQQRLogin(t.Context(), port.QQLoginQQ); return err },
+ "查询 QQ 的扫码结果": func() error { _, err := f.admin.CheckQQQRLogin(t.Context(), port.QQLoginWeChat, wx.Key); return err },
+ } {
+ if err := call(); !errors.Is(err, port.ErrProviderFailed) {
+ t.Errorf("音源出错时%s = %v,期望 ErrProviderFailed", name, err)
+ }
+ }
+}
+
+func TestBilibiliCredentialStatus(t *testing.T) {
+ f := newService(t, "secret")
+ if got, err := f.admin.BilibiliCredentialStatus(t.Context()); err != nil || got.Set || got.Account != nil {
+ t.Fatalf("没有登录时 = %+v, %v,期望未设置", got, err)
+ }
+ name, vip := "测试账号", true
+ account := &port.SourceAccount{ID: "789", Name: &name, VIP: &vip, Verified: true}
+ f.bilibili.SetCredential(account)
+ if got, err := f.admin.BilibiliCredentialStatus(t.Context()); err != nil || !got.Set || got.Account != account {
+ t.Fatalf("登录之后 = %+v, %v,期望 %+v", got, err, account)
+ }
+ upstream := errors.New("上游失败")
+ f.bilibili.FailStatus(upstream)
+ if _, err := f.admin.BilibiliCredentialStatus(t.Context()); !errors.Is(err, port.ErrProviderFailed) || !errors.Is(err, upstream) {
+ t.Errorf("查询登录状态失败时 = %v,期望包含音源的错误和 ErrProviderFailed", err)
+ }
+}
+
+func TestBilibiliQRLogin(t *testing.T) {
+ f := newService(t, "secret")
+ session, err := f.admin.CreateBilibiliQRLogin(t.Context())
+ if err != nil || session.Key == "" || session.LoginURL == "" || session.QRImage != "" {
+ t.Fatalf("B 站的二维码 = %+v, %v,期望只有登录链接", session, err)
+ }
+ scanned := port.QRLoginResult{Status: port.QRLoginScanned}
+ f.bilibili.SetResult(session.Key, scanned)
+ if got, err := f.admin.CheckBilibiliQRLogin(t.Context(), session.Key); err != nil || got != scanned {
+ t.Errorf("B 站的扫码结果 = %+v, %v,期望 %+v", got, err, scanned)
+ }
+ f.bilibili.Fail(errors.New("连接被重置"))
+ for name, call := range map[string]func() error{
+ "生成 B 站的二维码": func() error { _, err := f.admin.CreateBilibiliQRLogin(t.Context()); return err },
+ "查询 B 站的扫码结果": func() error { _, err := f.admin.CheckBilibiliQRLogin(t.Context(), session.Key); return err },
+ } {
+ if err := call(); !errors.Is(err, port.ErrProviderFailed) {
+ t.Errorf("音源出错时%s = %v,期望 ErrProviderFailed", name, err)
+ }
+ }
+}
+
+func TestUpdateRoomReportsFailedRevoke(t *testing.T) {
+ f := newService(t, "secret")
+ id, err := f.admin.CreateRoom(t.Context(), NewRoom{Name: "大厅"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.node.SetDown(true)
+ err = f.admin.UpdateRoom(t.Context(), id, RoomChange{Password: ptr("芝麻开门")})
+ f.node.SetDown(false)
+ if !errors.Is(err, realtime.ErrUnavailable) {
+ t.Errorf("通知发不出去时的错误 = %v,期望 ErrUnavailable", err)
+ }
+ if r, err := f.store.FindRoom(t.Context(), id); err != nil || r.PasswordHash == "" {
+ t.Errorf("通知之前密码已经设置好,房间 = %#v, %v", r, err)
+ }
+}
+
+func TestResetRoomAPIKeyReportsFailedRevoke(t *testing.T) {
+ f := newService(t, "secret")
+ id, err := f.admin.CreateRoom(t.Context(), NewRoom{Name: "大厅"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ key, err := f.admin.ResetRoomAPIKey(t.Context(), id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.node.SetDown(true)
+ renewed, err := f.admin.ResetRoomAPIKey(t.Context(), id)
+ f.node.SetDown(false)
+ if renewed != "" || !errors.Is(err, realtime.ErrUnavailable) {
+ t.Errorf("通知发不出去时 = %q, %v,期望不返回 Key,错误为 ErrUnavailable", renewed, err)
+ }
+ hash := sha256.Sum256([]byte(key))
+ if _, err := f.store.TicketHolder(t.Context(), hash[:]); !errors.Is(err, port.ErrTicketNotFound) {
+ t.Errorf("通知之前旧 Key 已经失效,它的错误 = %v,期望 ErrTicketNotFound", err)
+ }
+}
diff --git a/internal/app/catalog/catalog_test.go b/internal/app/catalog/catalog_test.go
new file mode 100644
index 0000000..0084218
--- /dev/null
+++ b/internal/app/catalog/catalog_test.go
@@ -0,0 +1,362 @@
+package catalog
+
+import (
+ "context"
+ "errors"
+ "reflect"
+ "slices"
+ "sync"
+ "testing"
+ "testing/synctest"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/app/port/porttest"
+ "github.com/dihemoe/alisten/internal/domain/room"
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+type tracksTable map[track.Track]port.PreparedTrack
+
+func (t tracksTable) PreparedTrack(_ context.Context, tr track.Track) (port.PreparedTrack, bool, error) {
+ p, ok := t[tr]
+ return p, ok, nil
+}
+
+func (t tracksTable) SavePreparedTrack(_ context.Context, tr track.Track, p port.PreparedTrack) error {
+ t[tr] = p
+ return nil
+}
+
+type history struct {
+ seeds, queued, excluded []track.Track
+}
+
+func (h history) AutoDJSeeds(_ context.Context, _ string, includeQueue bool) ([]track.Track, error) {
+ if includeQueue {
+ return append(slices.Clone(h.seeds), h.queued...), nil
+ }
+ return h.seeds, nil
+}
+
+func (h history) AutoDJExclusions(context.Context, string) ([]track.Track, error) {
+ return h.excluded, nil
+}
+
+type quota struct {
+ mu sync.Mutex
+ waits []time.Duration
+ calls int
+}
+
+func (q *quota) TakeSearch(_ context.Context, _ string, burst int, refill time.Duration) (time.Duration, error) {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+ if burst != 10 || refill != time.Minute {
+ return 0, errors.New("临时房间的额度是突发 10 次,之后每分钟恢复 1 次")
+ }
+ q.calls++
+ if len(q.waits) == 0 {
+ return 0, nil
+ }
+ wait := q.waits[0]
+ q.waits = q.waits[1:]
+ return wait, nil
+}
+
+type fixture struct {
+ svc *Service
+ netease *porttest.Source
+ qq *porttest.Source
+ bilibili *porttest.Bilibili
+ tracks tracksTable
+ quota *quota
+ ephemeral string
+ persistent string
+}
+
+func song(source track.Source, id, title string) port.Song {
+ return port.Song{Track: track.Track{Source: source, ID: id}, Meta: track.Meta{Title: title, Artist: "周杰伦", Duration: 4 * time.Minute}}
+}
+
+func newFixture(t *testing.T, h history) *fixture {
+ t.Helper()
+ f := &fixture{netease: porttest.NewSource(track.NetEase), qq: porttest.NewSource(track.QQ), bilibili: porttest.NewBilibili(), tracks: tracksTable{}, quota: "a{}}
+ rooms := porttest.New()
+ var err error
+ if f.ephemeral, err = rooms.CreateRoom(t.Context(), port.NewRoom{Kind: room.Ephemeral, Name: "临时"}); err != nil {
+ t.Fatal(err)
+ }
+ if f.persistent, err = rooms.CreateRoom(t.Context(), port.NewRoom{Kind: room.Persistent, Name: "常驻"}); err != nil {
+ t.Fatal(err)
+ }
+ f.svc = New(f.netease, f.qq, f.bilibili, f.bilibili, f.tracks, h, rooms, f.quota)
+ return f
+}
+
+func (f *fixture) add(s *porttest.Source, songs ...port.Song) {
+ for _, song := range songs {
+ s.Add(song.Track.ID, song.Meta, "", nil)
+ }
+}
+
+func TestSearchTracksPaginates(t *testing.T) {
+ f := newFixture(t, history{})
+ songs := []port.Song{song(track.NetEase, "1", "晴天"), song(track.NetEase, "2", "晴天 (Live)"), song(track.NetEase, "3", "晴天 (伴奏)")}
+ f.add(f.netease, songs...)
+ f.add(f.qq, song(track.QQ, "0039MnYb0qxYhV", "晴天"))
+
+ cases := []struct {
+ page, size int64
+ want []port.Song
+ }{
+ {1, 2, songs[:2]},
+ {2, 2, songs[2:]},
+ {3, 2, nil},
+ {1 << 62, 100, nil},
+ }
+ for _, c := range cases {
+ got, total, err := f.svc.SearchTracks(t.Context(), f.persistent, track.NetEase, "晴天", c.page, c.size)
+ if err != nil || total != 3 || !reflect.DeepEqual(got, c.want) {
+ t.Errorf("第 %d 页(每页 %d 条)= %v, %d, %v,期望 %v 和总数 3", c.page, c.size, got, total, err, c.want)
+ }
+ }
+ if got, total, err := f.svc.SearchTracks(t.Context(), f.persistent, track.QQ, "晴天", 1, 10); err != nil || total != 1 || got[0].Track.ID != "0039MnYb0qxYhV" {
+ t.Errorf("QQ 的搜索结果 = %v, %d, %v", got, total, err)
+ }
+ if _, _, err := f.svc.SearchTracks(t.Context(), f.persistent, track.Link, "晴天", 1, 10); !errors.Is(err, port.ErrProviderFailed) {
+ t.Errorf("搜索链接的错误 = %v,期望 ErrProviderFailed", err)
+ }
+ f.netease.Fail(errors.New("网易云不可用"))
+ if _, _, err := f.svc.SearchTracks(t.Context(), f.persistent, track.NetEase, "晴天", 1, 10); !errors.Is(err, port.ErrProviderFailed) {
+ t.Errorf("上游出错时的错误 = %v,期望 ErrProviderFailed", err)
+ }
+}
+
+func TestSearchQuota(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ f := newFixture(t, history{})
+ f.add(f.netease, song(track.NetEase, "1", "晴天"))
+
+ f.quota.waits = []time.Duration{20 * time.Second}
+ start := time.Now()
+ if _, _, err := f.svc.SearchTracks(t.Context(), f.ephemeral, track.NetEase, "晴天", 1, 10); err != nil {
+ t.Fatal(err)
+ }
+ if waited := time.Since(start); waited != 20*time.Second {
+ t.Errorf("等了 %v,期望等到有额度为止", waited)
+ }
+
+ f.quota.waits = []time.Duration{30 * time.Second}
+ start = time.Now()
+ if _, err := f.svc.RecommendList(t.Context(), f.ephemeral); err != nil {
+ t.Errorf("预计等待恰好 30 秒时的错误 = %v,期望等到有额度为止", err)
+ }
+ if waited := time.Since(start); waited != 30*time.Second {
+ t.Errorf("等了 %v,期望 30 秒", waited)
+ }
+
+ f.quota.waits = []time.Duration{31 * time.Second}
+ if _, err := f.svc.RecommendList(t.Context(), f.ephemeral); !errors.Is(err, ErrRateLimited) {
+ t.Errorf("预计等待超过 30 秒时的错误 = %v,期望 ErrRateLimited", err)
+ }
+
+ calls := f.quota.calls
+ if _, _, err := f.svc.SearchPlaylists(t.Context(), f.persistent, track.NetEase, "晴天", 1, 10); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := f.svc.FirstMatch(t.Context(), track.NetEase, "晴天"); err != nil {
+ t.Fatal(err)
+ }
+ if f.quota.calls != calls {
+ t.Error("常驻房间的搜索和按关键词点歌都不消耗额度")
+ }
+ })
+}
+
+func TestTrackMetaPrefersTracksTable(t *testing.T) {
+ f := newFixture(t, history{})
+ sunny := song(track.NetEase, "1", "晴天")
+ f.add(f.netease, sunny)
+ prepared := track.Meta{Title: "晴天", Artist: "周杰伦", PageURL: "https://music.163.com/#/song?id=1", Duration: 269500 * time.Millisecond}
+ f.tracks[sunny.Track] = port.PreparedTrack{Meta: prepared}
+
+ f.netease.Fail(errors.New("不应请求上游"))
+ if got, err := f.svc.TrackMeta(t.Context(), sunny.Track); err != nil || got != prepared {
+ t.Errorf("曲目表中已有的歌 = %v, %v,期望 %v", got, err, prepared)
+ }
+ f.netease.Fail(nil)
+ delete(f.tracks, sunny.Track)
+ if got, err := f.svc.TrackMeta(t.Context(), sunny.Track); err != nil || got != sunny.Meta {
+ t.Errorf("曲目表中没有的歌 = %v, %v,期望上游的元数据 %v", got, err, sunny.Meta)
+ }
+ if _, err := f.svc.TrackMeta(t.Context(), track.Track{Source: track.NetEase, ID: "404"}); !errors.Is(err, port.ErrProviderFailed) {
+ t.Errorf("上游没有的歌的错误 = %v,期望 ErrProviderFailed", err)
+ }
+}
+
+func TestFirstMatch(t *testing.T) {
+ f := newFixture(t, history{})
+ f.add(f.qq, song(track.QQ, "a", "晴天"), song(track.QQ, "b", "晴天 (Live)"))
+ if got, m, err := f.svc.FirstMatch(t.Context(), track.QQ, "晴天"); err != nil || got.ID != "a" || m.Title != "晴天" {
+ t.Errorf("按关键词点歌 = %v, %v, %v,期望第一条搜索结果", got, m, err)
+ }
+ if _, _, err := f.svc.FirstMatch(t.Context(), track.QQ, "七里香"); !errors.Is(err, port.ErrProviderFailed) {
+ t.Errorf("没有搜索结果时的错误 = %v,期望 ErrProviderFailed", err)
+ }
+}
+
+func TestTrackMetaOfBilibili(t *testing.T) {
+ f := newFixture(t, history{})
+ video := track.Track{Source: track.Bilibili, ID: "BV1xx411c7mD/2"}
+ f.bilibili.AddVideo(video.ID, track.Meta{Title: "晴天(钢琴版)", Artist: "某 UP 主", Duration: 2 * time.Minute}, nil, nil, "")
+ want := track.Meta{Title: "晴天(钢琴版)", Artist: "某 UP 主", PageURL: "https://www.bilibili.com/video/BV1xx411c7mD?p=2", Duration: 2 * time.Minute}
+ if got, err := f.svc.TrackMeta(t.Context(), video); err != nil || got != want {
+ t.Errorf("B 站的元数据 = %+v, %v,期望 %+v:页面地址按 ID 拼出", got, err, want)
+ }
+ prepared := track.Meta{Title: "晴天(钢琴版)", CoverKey: "link/k/cover.jpg", PageURL: want.PageURL, Duration: 2 * time.Minute}
+ f.tracks[video] = port.PreparedTrack{Meta: prepared}
+ f.bilibili.Fail(errors.New("不应请求上游"))
+ if got, err := f.svc.TrackMeta(t.Context(), video); err != nil || got != prepared {
+ t.Errorf("曲目表中已有的 B 站的歌 = %+v, %v,期望 %+v", got, err, prepared)
+ }
+}
+
+func TestResolveLink(t *testing.T) {
+ f := newFixture(t, history{})
+ const (
+ video = "https://www.bilibili.com/video/BV1xx411c7mD?p=2"
+ short = "https://b23.tv/qingtian"
+ bangumi = "https://www.bilibili.com/bangumi/play/ep1"
+ youtube = "https://www.youtube.com/watch?v=x"
+ )
+ f.bilibili.AddVideo("BV1xx411c7mD/2", track.Meta{Title: "晴天(钢琴版)", Artist: "某 UP 主"}, nil, nil, "")
+ f.bilibili.AddLink(video, "BV1xx411c7mD/2")
+ f.bilibili.AddLink(short, "BV1xx411c7mD/2")
+ f.bilibili.RejectLink(bangumi, "哔哩哔哩的番剧不能点播")
+ want := track.Track{Source: track.Bilibili, ID: "BV1xx411c7mD/2"}
+ for _, link := range []string{video, short} {
+ got, m, err := f.svc.ResolveLink(t.Context(), link)
+ if err != nil || got != want || m.Title != "晴天(钢琴版)" || m.PageURL != "https://www.bilibili.com/video/BV1xx411c7mD?p=2" {
+ t.Errorf("ResolveLink(%s) = %v, %+v, %v,期望 B 站的 %v", link, got, m, err, want)
+ }
+ }
+ got, m, err := f.svc.ResolveLink(t.Context(), youtube)
+ if err != nil || got != (track.Track{Source: track.Link, ID: youtube}) || m != (track.Meta{Title: youtube, PageURL: youtube}) {
+ t.Errorf("其他链接 = %v, %+v, %v,期望仍是链接曲目,标题和页面都是链接本身", got, m, err)
+ }
+ var rejected *RejectedLinkError
+ if _, _, err := f.svc.ResolveLink(t.Context(), bangumi); !errors.As(err, &rejected) || err.Error() != "哔哩哔哩的番剧不能点播" {
+ t.Errorf("番剧的链接 = %v,期望 RejectedLinkError,文本是音源给出的原因", err)
+ }
+}
+
+func TestResolveLinkFailures(t *testing.T) {
+ f := newFixture(t, history{})
+ const link = "https://www.bilibili.com/video/BV1xx411c7mD"
+ // 音源里也登记这个 ID:不检查格式时,它会被当作 B 站的曲目点进队列。
+ f.bilibili.AddVideo("BV1xx411c7mD/1", track.Meta{Title: "第 1 P"}, nil, nil, "")
+ f.bilibili.AddLink(link, "BV1xx411c7mD/1")
+ if _, _, err := f.svc.ResolveLink(t.Context(), link); !errors.Is(err, port.ErrProviderFailed) {
+ t.Errorf("音源给出的曲目 ID 格式不对时 = %v,期望 ErrProviderFailed", err)
+ }
+ f.bilibili.AddLink(link, "BV1xx411c7mD")
+ if _, _, err := f.svc.ResolveLink(t.Context(), link); !errors.Is(err, port.ErrProviderFailed) {
+ t.Errorf("音源中查不到这个视频时 = %v,期望 ErrProviderFailed", err)
+ }
+ f.bilibili.Fail(errors.New("连接被重置"))
+ var rejected *RejectedLinkError
+ if _, _, err := f.svc.ResolveLink(t.Context(), link); !errors.Is(err, port.ErrProviderFailed) || errors.As(err, &rejected) {
+ t.Errorf("识别链接出错时 = %v,期望 ErrProviderFailed", err)
+ }
+}
+
+func netease(id string) track.Track { return track.Track{Source: track.NetEase, ID: id} }
+
+func TestRecommend(t *testing.T) {
+ h := history{seeds: []track.Track{netease("a"), netease("b")}, queued: []track.Track{netease("q")}, excluded: []track.Track{netease("x")}}
+ f := newFixture(t, h)
+ for _, id := range []string{"a", "b", "q", "x", "c", "d", "e", "f"} {
+ f.add(f.netease, song(track.NetEase, id, "歌"+id))
+ }
+ f.netease.SetSimilar("a", "x", "c", "b", "d")
+ f.netease.SetSimilar("b", "c", "e")
+ f.netease.SetSimilar("q", "f")
+
+ want := []track.Track{netease("c"), netease("d"), netease("e")}
+ got, m, ok, err := f.svc.Recommend(t.Context(), f.persistent)
+ if err != nil || !ok || !slices.Contains(want, got) || m.Title != "歌"+got.ID {
+ t.Errorf("推荐 = %v, %v, %v, %v,期望从 %v 中选一首", got, m, ok, err, want)
+ }
+
+ list, err := f.svc.RecommendList(t.Context(), f.persistent)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var ids []track.Track
+ for _, s := range list {
+ ids = append(ids, s.Track)
+ }
+ if want := append(want, netease("f")); !slices.Equal(ids, want) {
+ t.Errorf("推荐列表 = %v,期望 %v:种子还包括队列中的歌,去掉排除项和种子本身", ids, want)
+ }
+}
+
+type similarCalls struct {
+ port.NetEaseSource
+ seeds []string
+}
+
+func (s *similarCalls) SimilarTracks(ctx context.Context, id string) ([]port.Song, error) {
+ s.seeds = append(s.seeds, id)
+ return s.NetEaseSource.SimilarTracks(ctx, id)
+}
+
+func TestRecommendLimits(t *testing.T) {
+ for _, tt := range []struct {
+ candidates int
+ seeds []string
+ }{
+ {26, []string{"s1"}},
+ } {
+ f := newFixture(t, history{seeds: []track.Track{netease("s1"), netease("s2")}})
+ var similar []string
+ for i := range tt.candidates {
+ id := string(rune('A' + i))
+ f.add(f.netease, song(track.NetEase, id, id))
+ similar = append(similar, id)
+ }
+ f.add(f.netease, song(track.NetEase, "late", "late"))
+ f.netease.SetSimilar("s1", similar...)
+ f.netease.SetSimilar("s2", "late")
+ calls := &similarCalls{NetEaseSource: f.netease}
+ f.svc.netease = calls
+
+ list, err := f.svc.RecommendList(t.Context(), f.persistent)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(list) != 10 || list[0].Track.ID != "A" || list[9].Track.ID != "J" {
+ t.Errorf("%d 首候选的推荐列表 = %v,期望前 10 首候选", tt.candidates, list)
+ }
+ if !slices.Equal(calls.seeds, tt.seeds) {
+ t.Errorf("%d 首候选之后获取了 %v 的相似歌曲,期望 %v", tt.candidates, calls.seeds, tt.seeds)
+ }
+ }
+}
+
+func TestNoRecommendation(t *testing.T) {
+ f := newFixture(t, history{})
+ if _, _, ok, err := f.svc.Recommend(t.Context(), f.persistent); ok || err != nil {
+ t.Errorf("没有种子时 = %v, %v,期望没有推荐结果", ok, err)
+ }
+
+ f = newFixture(t, history{seeds: []track.Track{netease("a")}})
+ f.netease.Fail(errors.New("网易云不可用"))
+ if _, _, ok, err := f.svc.Recommend(t.Context(), f.persistent); ok || err == nil {
+ t.Errorf("接口出错时 = %v, %v,期望没有推荐结果,错误用于记录日志", ok, err)
+ }
+ if list, err := f.svc.RecommendList(t.Context(), f.persistent); err != nil || len(list) != 0 {
+ t.Errorf("接口出错时的推荐列表 = %v, %v,期望为空", list, err)
+ }
+}
diff --git a/internal/app/identity/identity_test.go b/internal/app/identity/identity_test.go
new file mode 100644
index 0000000..beb543b
--- /dev/null
+++ b/internal/app/identity/identity_test.go
@@ -0,0 +1,195 @@
+package identity
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/json"
+ "errors"
+ "log/slog"
+ "regexp"
+ "testing"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/app/port/porttest"
+ "github.com/dihemoe/alisten/internal/app/realtime"
+ "github.com/dihemoe/alisten/internal/domain/member"
+ "github.com/dihemoe/alisten/internal/domain/room"
+ "github.com/dihemoe/alisten/internal/lib/logctx"
+)
+
+func newService(t *testing.T) (*Service, *porttest.Store) {
+ t.Helper()
+ store := porttest.New()
+ node := porttest.NewRedis().Node("a")
+ return New(store, store, store, newGateway(store, node, node)), store
+}
+
+// 没有调用 realtime.Start:这些测试不运行房间。
+func newGateway(store *porttest.Store, node *porttest.Node, bus port.Bus) *realtime.Gateway {
+ _, gateway := realtime.New(store, store, nil, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: bus}, slog.New(slog.DiscardHandler))
+ return gateway
+}
+
+func createMember(t *testing.T, s *Service) Session {
+ t.Helper()
+ session, err := s.CreateMember(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ return session
+}
+
+func createRoom(t *testing.T, store *porttest.Store, password string) string {
+ t.Helper()
+ id, err := store.CreateRoom(t.Context(), port.NewRoom{Kind: room.Persistent, Name: "大厅", PasswordHash: hashPassword(t, password)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return id
+}
+
+func ptr(s string) *string { return &s }
+
+func TestCreateMember(t *testing.T) {
+ s, store := newService(t)
+ session := createMember(t, s)
+
+ if !regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`).MatchString(session.MemberToken) {
+ t.Errorf("成员 token %q 应是 256 位随机值的 base64url 编码", session.MemberToken)
+ }
+ if !regexp.MustCompile(`^游客-[0-9a-f]{4}$`).MatchString(session.Member.Name) || session.Member.AvatarHash != "" {
+ t.Errorf("新成员 = %#v,期望默认昵称和空头像", session.Member)
+ }
+ hash := sha256.Sum256([]byte(session.MemberToken))
+ if m, err := store.MemberByToken(t.Context(), hash[:]); err != nil || m != session.Member {
+ t.Errorf("存储中应按 token 的 SHA-256 找到成员:%#v, %v", m, err)
+ }
+ if other := createMember(t, s); other.MemberToken == session.MemberToken || other.Member.ID == session.Member.ID {
+ t.Error("每次都应创建新的成员和 token")
+ }
+}
+
+func TestUpdateProfile(t *testing.T) {
+ s, _ := newService(t)
+ id := createMember(t, s).Member.ID
+
+ m, err := s.UpdateProfile(t.Context(), id, Profile{Name: ptr("甲"), Email: ptr(" Alice@Example.COM ")})
+ if err != nil {
+ t.Fatal(err)
+ }
+ // md5("alice@example.com")
+ if want := (member.Member{ID: id, Name: "甲", AvatarHash: "c160f8cc69a4f0bf2b0362752353d060"}); m != want {
+ t.Errorf("修改资料后 = %#v,期望 %#v", m, want)
+ }
+ if m, _ := s.UpdateProfile(t.Context(), id, Profile{}); m.Name != "甲" || m.AvatarHash == "" {
+ t.Errorf("没有传入的字段应保持不变:%#v", m)
+ }
+ for _, blank := range []string{"", " \t "} {
+ if _, err := s.UpdateProfile(t.Context(), id, Profile{Email: ptr("alice@example.com")}); err != nil {
+ t.Fatal(err)
+ }
+ if m, _ := s.UpdateProfile(t.Context(), id, Profile{Email: ptr(blank)}); m.Name != "甲" || m.AvatarHash != "" {
+ t.Errorf("邮箱为 %q 时应清除头像:%#v", blank, m)
+ }
+ }
+}
+
+type noMemberBroadcast struct {
+ port.Bus
+}
+
+func (noMemberBroadcast) PublishMember(context.Context, member.Member) error {
+ return porttest.ErrRedisDown
+}
+
+func TestUpdateProfileFailsWhenBroadcastFails(t *testing.T) {
+ store := porttest.New()
+ node := porttest.NewRedis().Node("a")
+ s := New(store, store, store, newGateway(store, node, noMemberBroadcast{node}))
+ session := createMember(t, s)
+
+ if _, err := s.UpdateProfile(t.Context(), session.Member.ID, Profile{Name: ptr("甲")}); !errors.Is(err, realtime.ErrUnavailable) {
+ t.Errorf("广播资料失败时错误 = %v,期望 realtime.ErrUnavailable", err)
+ }
+ hash := sha256.Sum256([]byte(session.MemberToken))
+ if m, err := store.MemberByToken(t.Context(), hash[:]); err != nil || m != (member.Member{ID: session.Member.ID, Name: "甲"}) {
+ t.Errorf("广播失败之后存储中的成员 = %#v, %v,期望已经保存新资料", m, err)
+ }
+}
+
+type passwordChangedStore struct {
+ *porttest.Store
+ newHash string
+}
+
+func (s passwordChangedStore) CreateTicket(ctx context.Context, t port.NewTicket) error {
+ if err := s.UpdateRoomAccess(ctx, t.RoomID, port.AccessChange{PasswordHash: &s.newHash}); err != nil {
+ return err
+ }
+ return s.Store.CreateTicket(ctx, t)
+}
+
+func TestIssueTicketFailsWhenPasswordChangesMeanwhile(t *testing.T) {
+ store := porttest.New()
+ changed := passwordChangedStore{Store: store, newHash: hashPassword(t, "新密码")}
+ node := porttest.NewRedis().Node("a")
+ s := New(store, store, changed, newGateway(store, node, node))
+ alice := createMember(t, s).Member
+ id := createRoom(t, store, "旧密码")
+
+ if _, err := s.IssueTicket(t.Context(), alice.ID, id, "旧密码"); !errors.Is(err, ErrWrongPassword) {
+ t.Errorf("密码在校验之后被修改,错误 = %v,期望 ErrWrongPassword", err)
+ }
+}
+
+type loggedSubscriptions struct {
+ port.Bus
+ logger *slog.Logger
+}
+
+func (b loggedSubscriptions) SubscribeRoom(ctx context.Context, roomID string) error {
+ b.logger.InfoContext(ctx, "订阅房间频道", "subscribed", roomID)
+ return b.Bus.SubscribeRoom(ctx, roomID)
+}
+
+// 进入时传入的可能是别名或大小写不同的房间 ID,网关必须用存储中的 ID 加入,否则同一个房间会被当成两个。
+// 这里用别名,因为测试中的房间 ID 只有数字。
+func TestEnterRoomJoinsTheStoredRoom(t *testing.T) {
+ store := porttest.New()
+ node := porttest.NewRedis().Node("a")
+ var logs bytes.Buffer
+ s := New(store, store, store, newGateway(store, node, loggedSubscriptions{node, slog.New(logctx.NewHandler(slog.NewJSONHandler(&logs, nil)))}))
+ alice := createMember(t, s).Member
+ id, err := store.CreateRoom(t.Context(), port.NewRoom{Kind: room.Persistent, Name: "大厅", Slug: "hall"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ ticket, err := s.IssueTicket(t.Context(), alice.ID, id, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // 测试中的网关不运行房间,加入在订阅房间频道之后失败,这里只检查订阅时的日志。
+ _, _ = s.EnterRoom(t.Context(), "hall", ticket)
+ var entry map[string]any
+ if err := json.Unmarshal(logs.Bytes(), &entry); err != nil {
+ t.Fatalf("日志应只有一行 JSON:%v\n%s", err, logs.String())
+ }
+ if entry["subscribed"] != id || entry["room_id"] != id || entry["member_id"] != alice.ID {
+ t.Errorf("进入房间途中的日志 = %v,期望订阅房间 %s,并带着房间 %s 和成员 %s", entry, id, id, alice.ID)
+ }
+}
+
+func TestEnterRoomRejectsAPIKey(t *testing.T) {
+ s, store := newService(t)
+ id := createRoom(t, store, "")
+ key, k := NewAPIKey()
+ if err := store.ResetRoomAPIKey(t.Context(), id, k); err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := s.EnterRoom(t.Context(), id, key); !errors.Is(err, ErrBotSession) {
+ t.Errorf("用 API Key 建立会话的错误 = %v,期望 ErrBotSession", err)
+ }
+}
diff --git a/internal/app/identity/password_test.go b/internal/app/identity/password_test.go
new file mode 100644
index 0000000..15ba1c2
--- /dev/null
+++ b/internal/app/identity/password_test.go
@@ -0,0 +1,89 @@
+package identity
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+)
+
+func hashPassword(t *testing.T, password string) string {
+ t.Helper()
+ hash, err := HashPassword(t.Context(), password)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return hash
+}
+
+func TestPasswordHash(t *testing.T) {
+ if got := hashPassword(t, ""); got != "" {
+ t.Errorf("空密码的哈希 = %q,期望空字符串", got)
+ }
+
+ hash := hashPassword(t, "芝麻开门")
+ if !strings.HasPrefix(hash, "$argon2id$v=19$m=19456,t=2,p=1$") {
+ t.Errorf("哈希 %q 应是 argon2id 的 PHC 字符串", hash)
+ }
+ if hashPassword(t, "芝麻开门") == hash {
+ t.Error("每次哈希都应使用新的盐")
+ }
+ for password, want := range map[string]bool{"芝麻开门": true, "芝麻关门": false, "": false} {
+ if ok, err := checkPassword(t.Context(), hash, password); err != nil || ok != want {
+ t.Errorf("checkPassword(%q) = %v, %v,期望 %v", password, ok, err, want)
+ }
+ }
+}
+
+func TestCheckPasswordRejectsMalformedHash(t *testing.T) {
+ for _, malformed := range []string{
+ "芝麻开门",
+ } {
+ if _, err := checkPassword(t.Context(), malformed, "芝麻开门"); !errors.Is(err, errMalformedPasswordHash) {
+ t.Errorf("checkPassword(%q) 的错误 = %v,期望 errMalformedPasswordHash", malformed, err)
+ }
+ }
+}
+
+func occupyArgonSlots(t *testing.T) {
+ t.Helper()
+ for range cap(argonSlots) {
+ argonSlots <- struct{}{}
+ }
+ // 归还名额时不阻塞:实现在 ctx 结束之后仍去计算时,会在结束时替这里归还一个名额;阻塞地归还会让测试一直挂到包超时,
+ // 而不是报出错误。
+ t.Cleanup(func() {
+ for range cap(argonSlots) {
+ select {
+ case <-argonSlots:
+ default:
+ }
+ }
+ })
+}
+
+func TestQueuedPasswordWorkGivesUpWithContext(t *testing.T) {
+ hash := hashPassword(t, "芝麻开门")
+ occupyArgonSlots(t)
+ ctx, cancel := context.WithCancel(t.Context())
+ cancel()
+
+ if _, err := HashPassword(ctx, "芝麻开门"); !errors.Is(err, context.Canceled) {
+ t.Errorf("名额占满时 HashPassword 的错误 = %v,期望 context.Canceled", err)
+ }
+ if _, err := checkPassword(ctx, hash, "芝麻开门"); !errors.Is(err, context.Canceled) {
+ t.Errorf("名额占满时 checkPassword 的错误 = %v,期望 context.Canceled", err)
+ }
+}
+
+func TestPasswordWorkReturnsItsSlot(t *testing.T) {
+ // 名额没有归还时,第 cap+1 次计算会一直等到期限。
+ ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
+ defer cancel()
+ for range cap(argonSlots) + 1 {
+ if _, err := HashPassword(ctx, "芝麻开门"); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
diff --git a/internal/app/lobby/lobby_test.go b/internal/app/lobby/lobby_test.go
new file mode 100644
index 0000000..f2140a7
--- /dev/null
+++ b/internal/app/lobby/lobby_test.go
@@ -0,0 +1,91 @@
+package lobby
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "testing"
+
+ "github.com/dihemoe/alisten/internal/app/port/porttest"
+ "github.com/dihemoe/alisten/internal/app/realtime"
+ "github.com/dihemoe/alisten/internal/domain/member"
+)
+
+func newService(t *testing.T, store *porttest.Store) (*Service, *realtime.Gateway) {
+ t.Helper()
+ node := porttest.NewRedis().Node("a")
+ host, gateway := realtime.New(store, store, nil, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
+ ctx, stop := context.WithCancel(context.Background())
+ wait, err := realtime.Start(ctx, host, gateway)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ if err := host.Shutdown(context.Background()); err != nil {
+ t.Error(err)
+ }
+ if err := gateway.Shutdown(context.Background()); err != nil {
+ t.Error(err)
+ }
+ stop()
+ wait()
+ })
+ return New(store, node), gateway
+}
+
+func TestOnlineCount(t *testing.T) {
+ store := porttest.New()
+ s, gateway := newService(t, store)
+ busy, err := s.CreateRoom(t.Context(), NewRoom{Name: "热闹"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ quiet, err := s.CreateRoom(t.Context(), NewRoom{Name: "冷清"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ // 同一个成员的两个会话算两个在线。
+ alice := member.Member{ID: "00000000-0000-7000-8000-00000000000a", Name: "alice"}
+ for range 2 {
+ session, err := gateway.Join(t.Context(), busy, alice)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { gateway.Leave(session) })
+ }
+
+ rooms, err := s.ListRooms(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ online := map[string]int{}
+ for _, r := range rooms {
+ online[r.Info.ID] = r.Online
+ }
+ if online[busy] != 2 || online[quiet] != 0 {
+ t.Errorf("在线人数 = %v,期望 %s 为 2,%s 为 0", online, busy, quiet)
+ }
+ if r, err := s.FindRoom(t.Context(), busy); err != nil || r.Online != 2 {
+ t.Errorf("FindRoom = %#v, %v,期望在线人数 2", r, err)
+ }
+}
+
+type unreadableCounts struct{}
+
+func (unreadableCounts) OnlineCounts(context.Context, []string) ([]int, error) {
+ return nil, porttest.ErrRedisDown
+}
+
+func TestUnreadableOnlineCountsAreUnavailable(t *testing.T) {
+ s := New(porttest.New(), unreadableCounts{})
+ id, err := s.CreateRoom(t.Context(), NewRoom{Name: "大厅"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.ListRooms(t.Context()); !errors.Is(err, realtime.ErrUnavailable) {
+ t.Errorf("ListRooms 的错误 = %v,期望 ErrUnavailable", err)
+ }
+ if _, err := s.FindRoom(t.Context(), id); !errors.Is(err, realtime.ErrUnavailable) {
+ t.Errorf("FindRoom 的错误 = %v,期望 ErrUnavailable", err)
+ }
+}
diff --git a/internal/app/media/library_test.go b/internal/app/media/library_test.go
new file mode 100644
index 0000000..c140243
--- /dev/null
+++ b/internal/app/media/library_test.go
@@ -0,0 +1,98 @@
+package media
+
+import (
+ "context"
+ "errors"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/app/port/porttest"
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+type library struct {
+ media map[string]port.StoredMedia
+ used map[track.Track]bool
+}
+
+func (l *library) ListStoredMedia(context.Context) ([]port.StoredMedia, error) {
+ var list []port.StoredMedia
+ for _, m := range l.media {
+ list = append(list, m)
+ }
+ return list, nil
+}
+
+func (l *library) StoredMedia(_ context.Context, key string) (port.StoredMedia, bool, error) {
+ m, ok := l.media[key]
+ return m, ok, nil
+}
+
+func (l *library) DeleteStoredMedia(_ context.Context, key string) error {
+ delete(l.media, key)
+ return nil
+}
+
+func (l *library) TrackInUse(_ context.Context, t track.Track) (bool, error) {
+ return l.used[t], nil
+}
+
+func newLibrary(t *testing.T) (*Library, *library, *porttest.MediaStore) {
+ t.Helper()
+ key := Key(link.ID)
+ lib := &library{
+ media: map[string]port.StoredMedia{key: {Key: key, Source: track.Link, Link: link.ID, Title: "晴天", Size: 3, CoverKey: track.ObjectKey(CoverObject(key, "jpg")), StoredAt: time.Now()}},
+ used: map[track.Track]bool{},
+ }
+ store := porttest.NewMediaStore()
+ for _, object := range []string{AudioObject(key), CoverObject(key, "jpg"), AudioObject("other")} {
+ if err := store.Put(t.Context(), object, strings.NewReader("data"), "application/octet-stream"); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return NewLibrary(lib, lib, store), lib, store
+}
+
+func TestDeleteRemovesObjectsAndResult(t *testing.T) {
+ l, lib, store := newLibrary(t)
+ if err := l.Delete(t.Context(), Key(link.ID)); err != nil {
+ t.Fatal(err)
+ }
+ if keys := store.Keys(); !slices.Equal(keys, []string{AudioObject("other")}) {
+ t.Errorf("删除之后的对象 = %q,期望音频和封面都已删除", keys)
+ }
+ if len(lib.media) != 0 {
+ t.Error("删除之后准备结果应已清除")
+ }
+ if err := l.Delete(t.Context(), Key(link.ID)); !errors.Is(err, ErrNotFound) {
+ t.Errorf("再次删除 = %v,期望 ErrNotFound", err)
+ }
+}
+
+func TestDeleteRefusesMediaInUse(t *testing.T) {
+ l, lib, store := newLibrary(t)
+ stored := lib.media[Key(link.ID)]
+ stored.Source = track.Bilibili
+ lib.media[Key(link.ID)] = stored
+ lib.used[track.Track{Source: track.Bilibili, ID: link.ID}] = true
+ if err := l.Delete(t.Context(), Key(link.ID)); !errors.Is(err, ErrInUse) {
+ t.Errorf("删除仍被房间引用的歌 = %v,期望 ErrInUse", err)
+ }
+ if len(store.Keys()) != 3 || len(lib.media) != 1 {
+ t.Error("仍被房间引用的歌不应删除任何东西")
+ }
+}
+
+func TestDeleteKeepsResultWhenObjectsCannotBeDeleted(t *testing.T) {
+ l, lib, store := newLibrary(t)
+ store.Fail(errors.New("连接被拒绝"))
+ if err := l.Delete(t.Context(), Key(link.ID)); err == nil {
+ t.Fatal("对象删除失败时应返回错误")
+ }
+ if len(lib.media) != 1 {
+ t.Error("对象删除失败时准备结果应保留,后台还能看到这首歌并重试")
+ }
+}
diff --git a/internal/app/media/playback_test.go b/internal/app/media/playback_test.go
new file mode 100644
index 0000000..912fc47
--- /dev/null
+++ b/internal/app/media/playback_test.go
@@ -0,0 +1,282 @@
+package media
+
+import (
+ "context"
+ "errors"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+ "testing/synctest"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+var t0 = time.Date(2026, 10, 26, 12, 0, 0, 0, time.UTC)
+
+type playURLs struct {
+ mu sync.Mutex
+ urls map[string]string
+ errs map[string]error
+ calls int
+ hold chan struct{}
+}
+
+func (p *playURLs) PlayURL(ctx context.Context, id string) (string, error) {
+ p.mu.Lock()
+ p.calls++
+ hold := p.hold
+ p.mu.Unlock()
+ if hold != nil {
+ <-hold
+ }
+ if _, ok := ctx.Deadline(); !ok {
+ return "", errors.New("解析没有期限")
+ }
+ // 像真实的音源那样:请求的 ctx 结束了,解析就失败。
+ if err := ctx.Err(); err != nil {
+ return "", err
+ }
+ return p.urls[id], p.errs[id]
+}
+
+func (p *playURLs) count() int {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ return p.calls
+}
+
+type inRooms map[track.Track]bool
+
+func (r inRooms) TrackInUse(_ context.Context, t track.Track) (bool, error) {
+ return r[t], nil
+}
+
+func queued(ids ...string) inRooms {
+ r := inRooms{}
+ for _, id := range ids {
+ r[track.Track{Source: track.NetEase, ID: id}] = true
+ }
+ return r
+}
+
+var errDown = errors.New("连接中断")
+
+type brokenRooms struct{}
+
+func (brokenRooms) TrackInUse(context.Context, track.Track) (bool, error) {
+ return false, errDown
+}
+
+func TestPlaybackURL(t *testing.T) {
+ urls := &playURLs{
+ urls: map[string]string{"1": "http://m701.music.126.net/a.mp3?vuutv=x%2By", "2": "https://m701.music.126.net/b.mp3", "4": "ftp://m701.music.126.net/d.mp3", "7": "https://:8080/e.mp3"},
+ errs: map[string]error{"5": &port.RejectedError{Err: errors.New("网易云没有提供这首歌的播放地址")}},
+ }
+ p := NewPlayback(map[track.Source]port.PlayURLs{track.NetEase: urls}, queued("1", "2", "4", "5", "7"))
+ for id, want := range map[string]string{"1": "https://m701.music.126.net/a.mp3?vuutv=x%2By", "2": "https://m701.music.126.net/b.mp3"} {
+ if got, err := p.URL(t.Context(), track.Track{Source: track.NetEase, ID: id}, t0); err != nil || got != want {
+ t.Errorf("第 %s 首的地址 = %q, %v,期望 %q:只把 http 换成 https,其余原样保留", id, got, err, want)
+ }
+ }
+ for _, id := range []string{"4", "5", "7"} {
+ if _, err := p.URL(t.Context(), track.Track{Source: track.NetEase, ID: id}, t0); !errors.Is(err, port.ErrProviderFailed) {
+ t.Errorf("第 %s 首 = %v,期望 ErrProviderFailed", id, err)
+ }
+ }
+ if _, err := p.URL(t.Context(), track.Track{Source: track.QQ, ID: "1"}, t0); !errors.Is(err, ErrNoPlayback) {
+ t.Errorf("没有登记的来源 = %v,期望 ErrNoPlayback", err)
+ }
+}
+
+func TestPlaybackOnlyForTracksInRooms(t *testing.T) {
+ urls := &playURLs{urls: map[string]string{"1": "https://m701.music.126.net/a.mp3", "2": "https://m701.music.126.net/b.mp3"}}
+ rooms := queued("1")
+ sources := map[track.Source]port.PlayURLs{track.NetEase: urls}
+ p := NewPlayback(sources, rooms)
+ sunny, rain := track.Track{Source: track.NetEase, ID: "1"}, track.Track{Source: track.NetEase, ID: "2"}
+
+ if _, err := p.URL(t.Context(), rain, t0); !errors.Is(err, ErrNotInRoom) || urls.count() != 0 {
+ t.Errorf("不在房间里的歌 = %v,解析了 %d 次,期望 ErrNotInRoom,不请求音源", err, urls.count())
+ }
+ if got, err := p.URL(t.Context(), sunny, t0); err != nil || got != "https://m701.music.126.net/a.mp3" {
+ t.Errorf("房间里的歌 = %q, %v", got, err)
+ }
+ delete(rooms, sunny)
+ if _, err := p.URL(t.Context(), sunny, t0); !errors.Is(err, ErrNotInRoom) {
+ t.Errorf("移出房间之后 = %v,期望 ErrNotInRoom:复用的地址也只给房间里的歌", err)
+ }
+ if _, err := NewPlayback(sources, brokenRooms{}).URL(t.Context(), sunny, t0); !errors.Is(err, errDown) || errors.Is(err, port.ErrProviderFailed) {
+ t.Errorf("查不了房间时 = %v,期望原样返回存储的错误", err)
+ }
+ if n := urls.count(); n != 1 {
+ t.Errorf("解析了 %d 次,期望只有房间里的那一次", n)
+ }
+}
+
+func TestPlaybackReusesURLForAMinute(t *testing.T) {
+ urls := &playURLs{
+ urls: map[string]string{"1": "http://m701.music.126.net/a.mp3", "3": "/relative.mp3"},
+ errs: map[string]error{"6": errors.New("连接被重置")},
+ }
+ p := NewPlayback(map[track.Source]port.PlayURLs{track.NetEase: urls}, queued("1", "3", "6"))
+ sunny := track.Track{Source: track.NetEase, ID: "1"}
+ for i, step := range []struct {
+ at time.Duration
+ calls int
+ }{{0, 1}, {59 * time.Second, 1}, {time.Minute, 2}, {time.Minute + 59*time.Second, 2}, {2 * time.Minute, 3}} {
+ got, err := p.URL(t.Context(), sunny, t0.Add(step.at))
+ if err != nil || got != "https://m701.music.126.net/a.mp3" || urls.count() != step.calls {
+ t.Errorf("第 %d 次请求(%v 之后)= %q, %v,共解析 %d 次,期望 %d 次", i+1, step.at, got, err, urls.count(), step.calls)
+ }
+ }
+
+ for _, id := range []string{"3", "6"} {
+ before := urls.count()
+ for range 2 {
+ if _, err := p.URL(t.Context(), track.Track{Source: track.NetEase, ID: id}, t0); !errors.Is(err, port.ErrProviderFailed) {
+ t.Errorf("第 %s 首 = %v,期望 ErrProviderFailed", id, err)
+ }
+ }
+ if n := urls.count() - before; n != 2 {
+ t.Errorf("第 %s 首请求两次解析了 %d 次,期望每次都重新解析:失败不复用", id, n)
+ }
+ }
+}
+
+func TestPlaybackRemembersAtMost4096Tracks(t *testing.T) {
+ urls := &playURLs{urls: map[string]string{}}
+ ids := make([]string, reuseLimit+1)
+ for i := range ids {
+ ids[i] = strconv.Itoa(i)
+ urls.urls[ids[i]] = "https://m701.music.126.net/" + ids[i] + ".mp3"
+ }
+ p := NewPlayback(map[track.Source]port.PlayURLs{track.NetEase: urls}, queued(ids...))
+ resolve := func(id string, at time.Duration) {
+ t.Helper()
+ if _, err := p.URL(t.Context(), track.Track{Source: track.NetEase, ID: id}, t0.Add(at)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for _, id := range ids {
+ resolve(id, 0)
+ }
+ resolve(ids[0], time.Second)
+ resolve(ids[reuseLimit], time.Second)
+ if n := urls.count(); n != reuseLimit+2 {
+ t.Errorf("解析了 %d 次,期望 %d 次:前 %d 首复用,满了之后的第 %d 首没有记下", n, reuseLimit+2, reuseLimit, reuseLimit+1)
+ }
+ resolve(ids[reuseLimit], time.Minute)
+ resolve(ids[reuseLimit], time.Minute+time.Second)
+ if n := urls.count(); n != reuseLimit+3 {
+ t.Errorf("解析了 %d 次,期望 %d 次:写入时清掉过期的条目,之后能记下新的歌", n, reuseLimit+3)
+ }
+}
+
+func TestPlaybackCoalescesConcurrentRequests(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ urls := &playURLs{urls: map[string]string{"1": "https://m701.music.126.net/a.mp3"}, hold: make(chan struct{})}
+ release := sync.OnceFunc(func() { close(urls.hold) })
+ defer release()
+ p := NewPlayback(map[track.Source]port.PlayURLs{track.NetEase: urls}, queued("1"))
+ sunny := track.Track{Source: track.NetEase, ID: "1"}
+ first, cancelFirst := context.WithCancel(t.Context())
+ firstDone := make(chan error, 1)
+ go func() {
+ _, err := p.URL(first, sunny, t0)
+ firstDone <- err
+ }()
+ synctest.Wait()
+ var wg sync.WaitGroup
+ results := make([]string, 2)
+ for i := range results {
+ wg.Go(func() { results[i], _ = p.URL(t.Context(), sunny, t0) })
+ synctest.Wait()
+ }
+
+ cancelFirst()
+ synctest.Wait()
+ select {
+ case err := <-firstDone:
+ if !errors.Is(err, context.Canceled) {
+ t.Errorf("取消的请求返回 %v,期望 context.Canceled", err)
+ }
+ default:
+ t.Error("发起解析的请求取消之后没有立即返回")
+ }
+ release()
+ wg.Wait()
+ for i, got := range results {
+ if got != "https://m701.music.126.net/a.mp3" {
+ t.Errorf("第 %d 个等待的请求得到 %q", i+1, got)
+ }
+ }
+ if n := urls.count(); n != 1 {
+ t.Errorf("同时到达的 3 个请求解析了 %d 次,期望 1 次", n)
+ }
+ if _, err := p.URL(t.Context(), sunny, t0.Add(reuseWindow)); err != nil || urls.count() != 2 {
+ t.Errorf("1 分钟之后的请求 = %v,解析了 %d 次,期望重新解析", err, urls.count())
+ }
+ })
+}
+
+func TestPlaybackWaiterLeavesWithoutStoppingResolution(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ urls := &playURLs{urls: map[string]string{"1": "https://m701.music.126.net/a.mp3"}, hold: make(chan struct{})}
+ release := sync.OnceFunc(func() { close(urls.hold) })
+ defer release()
+ p := NewPlayback(map[track.Source]port.PlayURLs{track.NetEase: urls}, queued("1"))
+ sunny := track.Track{Source: track.NetEase, ID: "1"}
+ stays := make(chan string, 1)
+ go func() {
+ got, _ := p.URL(t.Context(), sunny, t0)
+ stays <- got
+ }()
+ synctest.Wait()
+ leaving, leave := context.WithCancel(t.Context())
+ leaves := make(chan error, 1)
+ go func() {
+ _, err := p.URL(leaving, sunny, t0)
+ leaves <- err
+ }()
+ synctest.Wait()
+
+ leave()
+ synctest.Wait()
+ select {
+ case err := <-leaves:
+ if !errors.Is(err, context.Canceled) {
+ t.Errorf("离开的请求返回 %v,期望 context.Canceled", err)
+ }
+ default:
+ t.Error("客户端离开之后请求没有立即返回")
+ }
+ select {
+ case got := <-stays:
+ t.Errorf("共用的解析还没有结束,另一个请求就返回了 %q", got)
+ default:
+ }
+ release()
+ synctest.Wait()
+ if got := <-stays; got != "https://m701.music.126.net/a.mp3" {
+ t.Errorf("留下的请求得到 %q", got)
+ }
+ if n := urls.count(); n != 1 {
+ t.Errorf("解析了 %d 次,期望 1 次", n)
+ }
+ })
+}
+
+type panicking struct{}
+
+func (panicking) PlayURL(context.Context, string) (string, error) { panic("音源出错了") }
+
+func TestPlaybackTurnsPanicIntoError(t *testing.T) {
+ p := NewPlayback(map[track.Source]port.PlayURLs{track.NetEase: panicking{}}, queued("1"))
+ _, err := p.URL(t.Context(), track.Track{Source: track.NetEase, ID: "1"}, t0)
+ if err == nil || !strings.Contains(err.Error(), "音源出错了") || errors.Is(err, port.ErrProviderFailed) {
+ t.Errorf("音源 panic 时 = %v,期望带着 panic 内容、不属于音源出错的错误", err)
+ }
+}
diff --git a/internal/app/media/ready_test.go b/internal/app/media/ready_test.go
new file mode 100644
index 0000000..17ba4cc
--- /dev/null
+++ b/internal/app/media/ready_test.go
@@ -0,0 +1,180 @@
+package media
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "log/slog"
+ "strings"
+ "testing"
+ "testing/synctest"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/app/port/porttest"
+ "github.com/dihemoe/alisten/internal/domain/room"
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+var (
+ link = track.Track{Source: track.Link, ID: "https://example.com/a.mp3"}
+ netease = track.Track{Source: track.NetEase, ID: "186016"}
+ bilibili = track.Track{Source: track.Bilibili, ID: "BV1xx411c7mD"}
+ ready = track.Prepared{Duration: time.Minute, Gain: 1}
+)
+
+type tracksTable map[track.Track]port.PreparedTrack
+
+func (t tracksTable) PreparedTrack(_ context.Context, tr track.Track) (port.PreparedTrack, bool, error) {
+ p, ok := t[tr]
+ return p, ok, nil
+}
+
+func (tracksTable) SavePreparedTrack(context.Context, track.Track, port.PreparedTrack) error {
+ return errors.New("不支持写入")
+}
+
+func putAudio(t *testing.T, store *porttest.MediaStore, tr track.Track) {
+ t.Helper()
+ if err := store.Put(t.Context(), AudioObject(Key(tr.ID)), strings.NewReader("mp3"), "audio/mpeg"); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestTracksRequireLinkAudio(t *testing.T) {
+ store := porttest.NewMediaStore()
+ linkResult := port.PreparedTrack{Meta: track.Meta{Title: "晴天"}, Result: ready, MediaKey: Key(link.ID), MediaSize: 3}
+ tracks := NewTracks(tracksTable{link: linkResult, netease: {Result: ready}}, store)
+
+ if _, ok, err := tracks.PreparedTrack(t.Context(), link); err != nil || ok {
+ t.Errorf("音频不在对象存储中时 = %v, %v,期望没有结果", ok, err)
+ }
+ putAudio(t, store, link)
+ if got, ok, err := tracks.PreparedTrack(t.Context(), link); err != nil || !ok || got != linkResult {
+ t.Errorf("音频在对象存储中时 = %+v, %v, %v", got, ok, err)
+ }
+ store.Fail(errors.New("连接被拒绝"))
+ if _, ok, err := tracks.PreparedTrack(t.Context(), link); err == nil || ok {
+ t.Errorf("对象存储出错时 = %v, %v,期望返回错误,由调用方重试", ok, err)
+ }
+ if _, ok, err := tracks.PreparedTrack(t.Context(), netease); err != nil || !ok {
+ t.Errorf("网易云的结果 = %v, %v,期望不检查对象存储", ok, err)
+ }
+}
+
+func TestTracksRequireBilibiliAudio(t *testing.T) {
+ store := porttest.NewMediaStore()
+ result := port.PreparedTrack{Meta: track.Meta{Title: "晴天(钢琴版)"}, Result: ready, MediaKey: Key(bilibili.ID), MediaSize: 3}
+ tracks := NewTracks(tracksTable{bilibili: result}, store)
+ if _, ok, err := tracks.PreparedTrack(t.Context(), bilibili); err != nil || ok {
+ t.Errorf("音频不在对象存储中时 = %v, %v,期望没有结果", ok, err)
+ }
+ putAudio(t, store, bilibili)
+ if got, ok, err := tracks.PreparedTrack(t.Context(), bilibili); err != nil || !ok || got != result {
+ t.Errorf("音频在对象存储中时 = %+v, %v, %v", got, ok, err)
+ }
+}
+
+type snapshots struct {
+ port.RoomSnapshots
+ snap room.Snapshot
+}
+
+func (s snapshots) TakeRoom(context.Context, string) (uint32, room.Snapshot, error) {
+ return 3, s.snap, nil
+}
+
+func readyEntry(id room.EntryID, tr track.Track) room.Entry {
+ return room.Entry{ID: id, Track: tr, Meta: track.Meta{Title: tr.ID}, Ready: true, Prep: ready}
+}
+
+func restoredSnapshot() room.Snapshot {
+ other := track.Track{Source: track.Link, ID: "https://example.com/b.mp3"}
+ pending := readyEntry(2, link)
+ return room.Snapshot{
+ Current: &room.NowPlaying{Entry: readyEntry(1, other)},
+ Pending: &pending,
+ Waiting: []room.Entry{readyEntry(3, other), readyEntry(4, netease), {ID: 5, Track: link}, readyEntry(6, bilibili)},
+ }
+}
+
+func TestSnapshotsUnreadyStoredTracksWithoutAudio(t *testing.T) {
+ store := porttest.NewMediaStore()
+ putAudio(t, store, track.Track{Source: track.Link, ID: "https://example.com/b.mp3"})
+ term, snap, err := NewSnapshots(snapshots{snap: restoredSnapshot()}, store, slog.New(slog.DiscardHandler)).TakeRoom(t.Context(), "room")
+ if err != nil || term != 3 {
+ t.Fatal(term, err)
+ }
+ if !snap.Current.Entry.Ready {
+ t.Error("正在播放的歌不应改回准备中")
+ }
+ if snap.Pending.Ready || snap.Pending.Prep != (track.Prepared{}) {
+ t.Errorf("音频已不在对象存储中的待播 = %+v,期望改回准备中", *snap.Pending)
+ }
+ for i, want := range []bool{true, true, false, false} {
+ if got := snap.Waiting[i].Ready; got != want {
+ t.Errorf("等待队列第 %d 首 Ready = %v,期望 %v", i+1, got, want)
+ }
+ }
+}
+
+func TestSnapshotsKeepEntriesWhenStorageFails(t *testing.T) {
+ store := porttest.NewMediaStore()
+ store.Fail(errors.New("连接被拒绝"))
+ var logs bytes.Buffer
+ _, snap, err := NewSnapshots(snapshots{snap: restoredSnapshot()}, store, slog.New(slog.NewJSONHandler(&logs, nil))).TakeRoom(t.Context(), "room")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !snap.Pending.Ready || !snap.Waiting[0].Ready {
+ t.Error("对象存储出错时,条目应按曲目表恢复")
+ }
+ if n := strings.Count(logs.String(), "检查曲目的音频失败"); n != 1 {
+ t.Errorf("日志中记录了 %d 次失败,期望出错后不再检查其余条目:\n%s", n, logs.String())
+ }
+}
+
+type stalledStore struct{ port.MediaStore }
+
+func (stalledStore) Exists(ctx context.Context, _ string) (bool, error) {
+ <-ctx.Done()
+ return false, ctx.Err()
+}
+
+func TestSnapshotsStopCheckingAfterBudget(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ ctx, cancel := context.WithCancel(t.Context())
+ defer cancel()
+ var logs bytes.Buffer
+ type taken struct {
+ snap room.Snapshot
+ err error
+ }
+ done := make(chan taken, 1)
+ go func() {
+ _, snap, err := NewSnapshots(snapshots{snap: restoredSnapshot()}, stalledStore{}, slog.New(slog.NewJSONHandler(&logs, nil))).TakeRoom(ctx, "room")
+ done <- taken{snap, err}
+ }()
+
+ time.Sleep(restoreCheckTimeout - time.Nanosecond)
+ synctest.Wait()
+ select {
+ case <-done:
+ t.Fatal("检查在预算用完之前就结束了")
+ default:
+ }
+ time.Sleep(time.Nanosecond)
+ synctest.Wait()
+ select {
+ case got := <-done:
+ if got.err != nil || !got.snap.Pending.Ready || !got.snap.Waiting[0].Ready {
+ t.Errorf("预算用完之后 = %+v, %v,期望其余条目按曲目表恢复", got.snap, got.err)
+ }
+ if !strings.Contains(logs.String(), "检查曲目的音频失败") {
+ t.Errorf("日志中没有检查失败的 WARN:%s", logs.String())
+ }
+ default:
+ t.Fatalf("预算 %v 用完之后检查没有结束", restoreCheckTimeout)
+ }
+ })
+}
diff --git a/internal/app/port/porttest/bilibili.go b/internal/app/port/porttest/bilibili.go
new file mode 100644
index 0000000..cd54173
--- /dev/null
+++ b/internal/app/port/porttest/bilibili.go
@@ -0,0 +1,294 @@
+package porttest
+
+import (
+ "context"
+ "errors"
+ "io"
+ "slices"
+ "sync"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+var (
+ _ port.BilibiliSource = (*Bilibili)(nil)
+ _ port.BilibiliLinks = (*Bilibili)(nil)
+ _ port.BilibiliQRLoginSource = (*BilibiliQRLogin)(nil)
+ _ port.Transcoder = (*Transcoder)(nil)
+ _ port.YouTubeCookies = (*YouTubeCookies)(nil)
+)
+
+var ErrUnknownVideo = errors.New("音源中没有这个视频")
+
+type Bilibili struct {
+ mu sync.Mutex
+ videos map[string]bilibiliVideo
+ links map[string]string
+ rejects map[string]string
+ failure error
+ released int
+}
+
+type bilibiliVideo struct {
+ meta track.Meta
+ audio []byte
+ cover []byte
+ coverExt string
+ reject string
+}
+
+func NewBilibili() *Bilibili {
+ return &Bilibili{videos: map[string]bilibiliVideo{}, links: map[string]string{}, rejects: map[string]string{}}
+}
+
+// cover 为 nil 表示这个视频没有封面。
+func (b *Bilibili) AddVideo(id string, meta track.Meta, audio, cover []byte, coverExt string) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ b.videos[id] = bilibiliVideo{meta: meta, audio: audio, cover: cover, coverExt: coverExt}
+}
+
+// 元数据和下载都拒绝这个视频,比如视频不存在或需要付费。
+func (b *Bilibili) RejectVideo(id, reason string) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ v := b.videos[id]
+ v.reject = reason
+ b.videos[id] = v
+}
+
+func (b *Bilibili) AddLink(link, id string) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ b.links[link] = id
+}
+
+// 是 B 站的链接但不是视频,比如番剧、直播和个人空间。
+func (b *Bilibili) RejectLink(link, reason string) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ b.rejects[link] = reason
+}
+
+func (b *Bilibili) Fail(err error) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ b.failure = err
+}
+
+// Released 是已经调用的 release 次数。
+func (b *Bilibili) Released() int {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.released
+}
+
+func (b *Bilibili) Recognize(_ context.Context, link string) (string, bool, error) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ if b.failure != nil {
+ return "", false, b.failure
+ }
+ if reason, ok := b.rejects[link]; ok {
+ return "", false, &port.RejectedError{Err: errors.New(reason)}
+ }
+ id, ok := b.links[link]
+ return id, ok, nil
+}
+
+func (b *Bilibili) TrackMeta(_ context.Context, id string) (track.Meta, error) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ if b.failure != nil {
+ return track.Meta{}, b.failure
+ }
+ v, ok := b.videos[id]
+ switch {
+ case !ok:
+ return track.Meta{}, &port.RejectedError{Err: ErrUnknownVideo}
+ case v.reject != "":
+ return track.Meta{}, &port.RejectedError{Err: errors.New(v.reject)}
+ }
+ return v.meta, nil
+}
+
+func (b *Bilibili) Download(_ context.Context, id string) (port.BilibiliDownload, func(), error) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ if b.failure != nil {
+ return port.BilibiliDownload{}, nil, b.failure
+ }
+ v, ok := b.videos[id]
+ switch {
+ case !ok:
+ return port.BilibiliDownload{}, nil, &port.RejectedError{Err: ErrUnknownVideo}
+ case v.reject != "":
+ return port.BilibiliDownload{}, nil, &port.RejectedError{Err: errors.New(v.reject)}
+ }
+ d := port.BilibiliDownload{Meta: v.meta, Audio: LinkFile(v.audio)}
+ if v.cover != nil {
+ d.Cover, d.CoverExt = LinkFile(v.cover), v.coverExt
+ }
+ return d, b.markReleased, nil
+}
+
+func (b *Bilibili) markReleased() {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ b.released++
+}
+
+type BilibiliQRLogin struct {
+ qrLogins
+
+ statusMu sync.Mutex
+ status port.CredentialStatus
+ statusFailure error
+}
+
+func NewBilibiliQRLogin() *BilibiliQRLogin {
+ return &BilibiliQRLogin{qrLogins: qrLogins{logins: map[string]qrLogin{}}}
+}
+
+func (l *BilibiliQRLogin) CreateQRLogin(context.Context) (port.QRLoginSession, error) {
+ key, expires, err := l.create("bilibili")
+ if err != nil {
+ return port.QRLoginSession{}, err
+ }
+ return port.QRLoginSession{Key: key, LoginURL: "https://bilibili.example/login?key=" + key, ExpiresAt: expires}, nil
+}
+
+func (l *BilibiliQRLogin) CheckQRLogin(_ context.Context, key string) (port.QRLoginResult, error) {
+ return l.check("bilibili", key)
+}
+
+// account 为 nil 表示已保存登录信息但未识别到账号。
+func (l *BilibiliQRLogin) SetCredential(account *port.SourceAccount) {
+ l.statusMu.Lock()
+ defer l.statusMu.Unlock()
+ l.status, l.statusFailure = port.CredentialStatus{Set: true, Account: account}, nil
+}
+
+func (l *BilibiliQRLogin) FailStatus(err error) {
+ l.statusMu.Lock()
+ defer l.statusMu.Unlock()
+ l.statusFailure = err
+}
+
+func (l *BilibiliQRLogin) CredentialStatus(context.Context) (port.CredentialStatus, error) {
+ l.statusMu.Lock()
+ defer l.statusMu.Unlock()
+ if l.statusFailure != nil {
+ return port.CredentialStatus{}, l.statusFailure
+ }
+ return l.status, nil
+}
+
+// 每次转码都返回构造时给的 mp3。
+type Transcoder struct {
+ mu sync.Mutex
+ mp3 []byte
+ inputs [][]byte
+ failure error
+ released int
+}
+
+func NewTranscoder(mp3 []byte) *Transcoder {
+ return &Transcoder{mp3: mp3}
+}
+
+func (t *Transcoder) Fail(err error) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ t.failure = err
+}
+
+// Released 是已经调用的 release 次数。
+func (t *Transcoder) Released() int {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return t.released
+}
+
+// Inputs 是每次转码读到的输入,按调用的顺序,包括失败的调用。
+func (t *Transcoder) Inputs() [][]byte {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return slices.Clone(t.inputs)
+}
+
+func (t *Transcoder) ToMP3(_ context.Context, audio port.LinkFile) (port.LinkFile, func(), error) {
+ f, err := audio.Open()
+ if err != nil {
+ return nil, nil, err
+ }
+ input, err := io.ReadAll(f)
+ _ = f.Close()
+ if err != nil {
+ return nil, nil, err
+ }
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ t.inputs = append(t.inputs, input)
+ if t.failure != nil {
+ return nil, nil, t.failure
+ }
+ return LinkFile(t.mp3), t.markReleased, nil
+}
+
+func (t *Transcoder) markReleased() {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ t.released++
+}
+
+type YouTubeCookies struct {
+ mu sync.Mutex
+ cookies string
+ status port.YouTubeCookiesStatus
+ failure error
+}
+
+func NewYouTubeCookies() *YouTubeCookies {
+ return &YouTubeCookies{}
+}
+
+// Set 把 cookies 标记为已填写,updatedAt 是最近一次填写的时间。
+func (y *YouTubeCookies) Set(updatedAt time.Time) {
+ y.mu.Lock()
+ defer y.mu.Unlock()
+ y.status, y.failure = port.YouTubeCookiesStatus{Set: true, UpdatedAt: updatedAt}, nil
+}
+
+func (y *YouTubeCookies) Fail(err error) {
+ y.mu.Lock()
+ defer y.mu.Unlock()
+ y.failure = err
+}
+
+func (y *YouTubeCookies) SetCookies(_ context.Context, cookies string) error {
+ y.mu.Lock()
+ defer y.mu.Unlock()
+ if y.failure != nil {
+ return y.failure
+ }
+ y.cookies = cookies
+ return nil
+}
+
+func (y *YouTubeCookies) Cookies() string {
+ y.mu.Lock()
+ defer y.mu.Unlock()
+ return y.cookies
+}
+
+func (y *YouTubeCookies) Status(context.Context) (port.YouTubeCookiesStatus, error) {
+ y.mu.Lock()
+ defer y.mu.Unlock()
+ if y.failure != nil {
+ return port.YouTubeCookiesStatus{}, y.failure
+ }
+ return y.status, nil
+}
diff --git a/internal/app/port/porttest/bilibili_test.go b/internal/app/port/porttest/bilibili_test.go
new file mode 100644
index 0000000..bffc458
--- /dev/null
+++ b/internal/app/port/porttest/bilibili_test.go
@@ -0,0 +1,268 @@
+package porttest
+
+import (
+ "errors"
+ "io"
+ "testing"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+func TestBilibiliRecognize(t *testing.T) {
+ b := NewBilibili()
+ b.AddLink("https://www.bilibili.com/video/BV1xx411c7mD", "BV1xx411c7mD")
+ b.AddLink("https://www.bilibili.com/video/BV1xx411c7mD?p=2", "BV1xx411c7mD/2")
+ b.RejectLink("https://live.bilibili.com/6", "直播不是曲目")
+
+ id, ok, err := b.Recognize(t.Context(), "https://www.bilibili.com/video/BV1xx411c7mD")
+ if err != nil || !ok || id != "BV1xx411c7mD" {
+ t.Fatalf("完整链接 = %q, %v, %v", id, ok, err)
+ }
+ id, ok, err = b.Recognize(t.Context(), "https://www.bilibili.com/video/BV1xx411c7mD?p=2")
+ if err != nil || !ok || id != "BV1xx411c7mD/2" {
+ t.Fatalf("分 P 链接 = %q, %v, %v", id, ok, err)
+ }
+
+ id, ok, err = b.Recognize(t.Context(), "https://example.com/video")
+ if err != nil || ok || id != "" {
+ t.Fatalf("不是 B 站的链接 = %q, %v, %v", id, ok, err)
+ }
+
+ _, _, err = b.Recognize(t.Context(), "https://live.bilibili.com/6")
+ var rejected *port.RejectedError
+ if !errors.As(err, &rejected) || rejected.Error() != "直播不是曲目" {
+ t.Fatalf("直播链接 = %v", err)
+ }
+
+ b.Fail(errors.New("请求失败"))
+ if _, _, err = b.Recognize(t.Context(), "https://www.bilibili.com/video/BV1xx411c7mD"); err == nil {
+ t.Fatal("识别失败时没有返回错误")
+ }
+}
+
+func TestBilibiliTrackMeta(t *testing.T) {
+ meta := track.Meta{Title: "一首歌", Artist: "UP 主", Duration: 3 * time.Minute}
+ b := NewBilibili()
+ b.AddVideo("BV1xx411c7mD", meta, []byte("aac"), nil, "")
+ b.RejectVideo("BV1xx411c7mE", "视频需要付费")
+
+ got, err := b.TrackMeta(t.Context(), "BV1xx411c7mD")
+ if err != nil || got != meta {
+ t.Fatalf("元数据 = %#v, %v", got, err)
+ }
+
+ var rejected *port.RejectedError
+ if _, err = b.TrackMeta(t.Context(), "BV1xx411c7mE"); !errors.As(err, &rejected) || rejected.Error() != "视频需要付费" {
+ t.Fatalf("付费视频 = %v", err)
+ }
+ if _, err = b.TrackMeta(t.Context(), "BV1xx411c7mF"); !errors.As(err, &rejected) {
+ t.Fatalf("未知视频 = %v", err)
+ }
+}
+
+func TestBilibiliDownload(t *testing.T) {
+ meta := track.Meta{Title: "一首歌", Artist: "UP 主"}
+ b := NewBilibili()
+ b.AddVideo("BV1xx411c7mD", meta, []byte("aac"), []byte("jpeg"), "jpg")
+ b.AddVideo("BV1xx411c7mE", meta, []byte("flv"), nil, "")
+
+ d, release, err := b.Download(t.Context(), "BV1xx411c7mD")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if d.Meta != meta {
+ t.Fatalf("下载的元数据 = %#v", d.Meta)
+ }
+ if got := readFile(t, d.Audio); got != "aac" {
+ t.Fatalf("音频内容 = %q", got)
+ }
+ if got := readFile(t, d.Cover); got != "jpeg" || d.CoverExt != "jpg" {
+ t.Fatalf("封面 = %q, %q", got, d.CoverExt)
+ }
+ if b.Released() != 0 {
+ t.Fatalf("release 之前 Released = %d", b.Released())
+ }
+ release()
+ if b.Released() != 1 {
+ t.Fatalf("release 之后 Released = %d", b.Released())
+ }
+
+ d, release, err = b.Download(t.Context(), "BV1xx411c7mE")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if d.Cover != nil || d.CoverExt != "" {
+ t.Fatalf("没有封面 = %#v, %q", d.Cover, d.CoverExt)
+ }
+ release()
+ if b.Released() != 2 {
+ t.Fatalf("两次 release 之后 Released = %d", b.Released())
+ }
+
+ var rejected *port.RejectedError
+ if _, release, err = b.Download(t.Context(), "BV1xx411c7mF"); !errors.As(err, &rejected) || release != nil {
+ t.Fatalf("未知视频 = %v, release 非空 %v", err, release != nil)
+ }
+
+ b.Fail(errors.New("下载失败"))
+ if _, release, err = b.Download(t.Context(), "BV1xx411c7mD"); err == nil || release != nil {
+ t.Fatalf("下载失败 = %v, release 非空 %v", err, release != nil)
+ }
+}
+
+func TestBilibiliQRLogin(t *testing.T) {
+ l := NewBilibiliQRLogin()
+ s, err := l.CreateQRLogin(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if s.Key == "" || s.LoginURL == "" {
+ t.Fatalf("会话 = %#v", s)
+ }
+ if !s.ExpiresAt.After(time.Now()) {
+ t.Fatalf("过期时间 = %v", s.ExpiresAt)
+ }
+
+ r, err := l.CheckQRLogin(t.Context(), s.Key)
+ if err != nil || r.Status != port.QRLoginWaiting {
+ t.Fatalf("刚创建的会话 = %#v, %v", r, err)
+ }
+
+ l.SetResult(s.Key, port.QRLoginResult{Status: port.QRLoginScanned})
+ if r, _ = l.CheckQRLogin(t.Context(), s.Key); r.Status != port.QRLoginScanned {
+ t.Fatalf("扫码后 = %#v", r)
+ }
+
+ l.SetResult(s.Key, port.QRLoginResult{Status: port.QRLoginSuccess, Message: "已登录"})
+ if r, _ = l.CheckQRLogin(t.Context(), s.Key); r.Status != port.QRLoginSuccess || r.Message != "已登录" {
+ t.Fatalf("登录成功 = %#v", r)
+ }
+
+ if r, err = l.CheckQRLogin(t.Context(), "bilibili-999"); err != nil || r.Status != port.QRLoginExpired {
+ t.Fatalf("未知会话 = %#v, %v", r, err)
+ }
+
+ l.Fail(errors.New("请求失败"))
+ if _, err = l.CreateQRLogin(t.Context()); err == nil {
+ t.Fatal("创建失败时没有返回错误")
+ }
+ if _, err = l.CheckQRLogin(t.Context(), s.Key); err == nil {
+ t.Fatal("查询失败时没有返回错误")
+ }
+}
+
+func TestBilibiliCredentialStatus(t *testing.T) {
+ l := NewBilibiliQRLogin()
+ s, err := l.CredentialStatus(t.Context())
+ if err != nil || s.Set || s.Account != nil {
+ t.Fatalf("未保存凭据 = %#v, %v", s, err)
+ }
+
+ name, vip := "UP 主", true
+ l.SetCredential(&port.SourceAccount{ID: "322892", Name: &name, VIP: &vip, Verified: true})
+ s, err = l.CredentialStatus(t.Context())
+ if err != nil || !s.Set || s.Account == nil || s.Account.ID != "322892" || !s.Account.Verified {
+ t.Fatalf("已登录 = %#v, %v", s, err)
+ }
+
+ l.SetCredential(nil)
+ s, err = l.CredentialStatus(t.Context())
+ if err != nil || !s.Set || s.Account != nil {
+ t.Fatalf("已保存但未识别到账号 = %#v, %v", s, err)
+ }
+
+ l.FailStatus(errors.New("读取失败"))
+ if _, err = l.CredentialStatus(t.Context()); err == nil {
+ t.Fatal("读取失败时没有返回错误")
+ }
+}
+
+func TestTranscoderToMP3(t *testing.T) {
+ c := NewTranscoder([]byte("mp3"))
+ mp3, release, err := c.ToMP3(t.Context(), LinkFile("aac"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := readFile(t, mp3); got != "mp3" {
+ t.Fatalf("转码输出 = %q", got)
+ }
+ if c.Released() != 0 {
+ t.Fatalf("release 之前 Released = %d", c.Released())
+ }
+ release()
+ if c.Released() != 1 {
+ t.Fatalf("release 之后 Released = %d", c.Released())
+ }
+
+ c.Fail(errors.New("转码失败"))
+ if _, release, err = c.ToMP3(t.Context(), LinkFile("aac")); err == nil || release != nil {
+ t.Fatalf("转码失败 = %v, release 非空 %v", err, release != nil)
+ }
+}
+
+func TestTranscoderRecordsInputs(t *testing.T) {
+ c := NewTranscoder([]byte("mp3"))
+ _, release, err := c.ToMP3(t.Context(), LinkFile("aac"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ release()
+ c.Fail(errors.New("转码失败"))
+ if _, _, err := c.ToMP3(t.Context(), LinkFile("flv")); err == nil {
+ t.Fatal("转码失败时没有返回错误")
+ }
+ if got := c.Inputs(); len(got) != 2 || string(got[0]) != "aac" || string(got[1]) != "flv" {
+ t.Errorf("记下的输入 = %q,期望两次调用的输入,包括失败的那次", got)
+ }
+}
+
+func TestStoreBilibiliCredential(t *testing.T) {
+ s := New()
+ if got, err := s.BilibiliCredential(t.Context()); err != nil || got != "" {
+ t.Fatalf("初始 Bilibili 凭据 = %q, %v", got, err)
+ }
+ for _, value := range []string{`{"cookie":"SESSDATA=x","refresh_token":"y"}`, ""} {
+ if err := s.SetBilibiliCredential(t.Context(), value); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := s.BilibiliCredential(t.Context()); err != nil || got != value {
+ t.Errorf("Bilibili 凭据 = %q, %v,期望 %q", got, err, value)
+ }
+ }
+}
+
+func TestYouTubeCookiesStatus(t *testing.T) {
+ y := NewYouTubeCookies()
+ s, err := y.Status(t.Context())
+ if err != nil || s.Set || !s.UpdatedAt.IsZero() {
+ t.Fatalf("未填写 = %#v, %v", s, err)
+ }
+
+ at := time.Date(2026, 10, 28, 12, 0, 0, 0, time.UTC)
+ y.Set(at)
+ s, err = y.Status(t.Context())
+ if err != nil || !s.Set || !s.UpdatedAt.Equal(at) {
+ t.Fatalf("已填写 = %#v, %v", s, err)
+ }
+
+ y.Fail(errors.New("读取失败"))
+ if _, err = y.Status(t.Context()); err == nil {
+ t.Fatal("读取失败时没有返回错误")
+ }
+}
+
+func readFile(t *testing.T, f port.LinkFile) string {
+ t.Helper()
+ r, err := f.Open()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = r.Close() }()
+ b, err := io.ReadAll(r)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(b)
+}
diff --git a/internal/app/port/porttest/link.go b/internal/app/port/porttest/link.go
new file mode 100644
index 0000000..b34aef9
--- /dev/null
+++ b/internal/app/port/porttest/link.go
@@ -0,0 +1,20 @@
+package porttest
+
+import (
+ "bytes"
+ "io"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+)
+
+var _ port.LinkFile = LinkFile(nil)
+
+type LinkFile []byte
+
+func (f LinkFile) Open() (io.ReadSeekCloser, error) {
+ return linkReader{bytes.NewReader(f)}, nil
+}
+
+type linkReader struct{ *bytes.Reader }
+
+func (linkReader) Close() error { return nil }
diff --git a/internal/app/port/porttest/login.go b/internal/app/port/porttest/login.go
new file mode 100644
index 0000000..8e8e781
--- /dev/null
+++ b/internal/app/port/porttest/login.go
@@ -0,0 +1,136 @@
+package porttest
+
+import (
+ "context"
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+)
+
+var (
+ _ port.NetEaseQRLoginSource = (*NetEaseQRLogin)(nil)
+ _ port.QQQRLoginSource = (*QQQRLogin)(nil)
+)
+
+const (
+ QQQRImage = "data:image/png;base64,iVBORw0KGgo="
+ WeChatQRImage = "https://wx.example/qr.png"
+)
+
+// 查询时不检查会话的过期时间;登录成功时也不像真实的音源那样保存登录状态。
+type qrLogins struct {
+ mu sync.Mutex
+ created int
+ logins map[string]qrLogin
+ failure error
+ account *port.SourceAccount
+ accountFailure error
+}
+
+type qrLogin struct {
+ kind string
+ result port.QRLoginResult
+}
+
+func (l *qrLogins) create(kind string) (string, time.Time, error) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ if l.failure != nil {
+ return "", time.Time{}, l.failure
+ }
+ l.created++
+ key := fmt.Sprintf("%s-%d", kind, l.created)
+ l.logins[key] = qrLogin{kind: kind, result: port.QRLoginResult{Status: port.QRLoginWaiting}}
+ return key, time.Now().Add(2 * time.Minute), nil
+}
+
+func (l *qrLogins) check(kind, key string) (port.QRLoginResult, error) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ if l.failure != nil {
+ return port.QRLoginResult{}, l.failure
+ }
+ if login, ok := l.logins[key]; ok && login.kind == kind {
+ return login.result, nil
+ }
+ return port.QRLoginResult{Status: port.QRLoginExpired}, nil
+}
+
+// key 必须已经生成过。
+func (l *qrLogins) SetResult(key string, result port.QRLoginResult) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ login := l.logins[key]
+ login.result = result
+ l.logins[key] = login
+}
+
+func (l *qrLogins) Fail(err error) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ l.failure = err
+}
+
+func (l *qrLogins) CurrentAccount(context.Context) (*port.SourceAccount, error) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ if l.accountFailure != nil {
+ return nil, l.accountFailure
+ }
+ return l.account, nil
+}
+
+func (l *qrLogins) SetAccount(account *port.SourceAccount) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ l.account, l.accountFailure = account, nil
+}
+
+func (l *qrLogins) FailAccount(err error) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ l.accountFailure = err
+}
+
+// 像网易云那样只给登录链接。
+type NetEaseQRLogin struct{ qrLogins }
+
+func NewNetEaseQRLogin() *NetEaseQRLogin {
+ return &NetEaseQRLogin{qrLogins{logins: map[string]qrLogin{}}}
+}
+
+func (l *NetEaseQRLogin) CreateQRLogin(context.Context) (port.QRLoginSession, error) {
+ key, expires, err := l.create("netease")
+ if err != nil {
+ return port.QRLoginSession{}, err
+ }
+ return port.QRLoginSession{Key: key, LoginURL: "https://netease.example/login?key=" + key, ExpiresAt: expires}, nil
+}
+
+func (l *NetEaseQRLogin) CheckQRLogin(_ context.Context, key string) (port.QRLoginResult, error) {
+ return l.check("netease", key)
+}
+
+// QQ 扫码只给 data: 的图片,微信扫码给登录链接和 https 的图片。
+type QQQRLogin struct{ qrLogins }
+
+func NewQQQRLogin() *QQQRLogin {
+ return &QQQRLogin{qrLogins{logins: map[string]qrLogin{}}}
+}
+
+func (l *QQQRLogin) CreateQRLogin(_ context.Context, typ port.QQLoginType) (port.QRLoginSession, error) {
+ key, expires, err := l.create(string(typ))
+ if err != nil {
+ return port.QRLoginSession{}, err
+ }
+ if typ == port.QQLoginWeChat {
+ return port.QRLoginSession{Key: key, LoginURL: "https://wx.example/login?key=" + key, QRImage: WeChatQRImage, ExpiresAt: expires}, nil
+ }
+ return port.QRLoginSession{Key: key, QRImage: QQQRImage, ExpiresAt: expires}, nil
+}
+
+func (l *QQQRLogin) CheckQRLogin(_ context.Context, typ port.QQLoginType, key string) (port.QRLoginResult, error) {
+ return l.check(string(typ), key)
+}
diff --git a/internal/app/port/porttest/media.go b/internal/app/port/porttest/media.go
new file mode 100644
index 0000000..2e25e49
--- /dev/null
+++ b/internal/app/port/porttest/media.go
@@ -0,0 +1,81 @@
+package porttest
+
+import (
+ "context"
+ "io"
+ "maps"
+ "slices"
+ "sync"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+)
+
+var _ port.MediaStore = (*MediaStore)(nil)
+
+type MediaStore struct {
+ mu sync.Mutex
+ objects map[string]Object
+ failure error
+}
+
+type Object struct {
+ Data []byte
+ ContentType string
+}
+
+func NewMediaStore() *MediaStore {
+ return &MediaStore{objects: map[string]Object{}}
+}
+
+func (s *MediaStore) Put(_ context.Context, key string, body io.ReadSeeker, contentType string) error {
+ data, err := io.ReadAll(body)
+ if err != nil {
+ return err
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.failure != nil {
+ return s.failure
+ }
+ s.objects[key] = Object{Data: data, ContentType: contentType}
+ return nil
+}
+
+func (s *MediaStore) Exists(_ context.Context, key string) (bool, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.failure != nil {
+ return false, s.failure
+ }
+ _, ok := s.objects[key]
+ return ok, nil
+}
+
+func (s *MediaStore) Delete(_ context.Context, key string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.failure != nil {
+ return s.failure
+ }
+ delete(s.objects, key)
+ return nil
+}
+
+func (s *MediaStore) Object(key string) (o Object, ok bool) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ o, ok = s.objects[key]
+ return o, ok
+}
+
+func (s *MediaStore) Keys() []string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return slices.Sorted(maps.Keys(s.objects))
+}
+
+func (s *MediaStore) Fail(err error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.failure = err
+}
diff --git a/internal/app/port/porttest/preparer.go b/internal/app/port/porttest/preparer.go
new file mode 100644
index 0000000..8974e33
--- /dev/null
+++ b/internal/app/port/porttest/preparer.go
@@ -0,0 +1,48 @@
+package porttest
+
+import (
+ "context"
+ "errors"
+ "slices"
+ "sync"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+type Preparer struct {
+ mu sync.Mutex
+ songs []song
+}
+
+type song struct {
+ track track.Track
+ meta track.Meta
+ prepared track.Prepared
+}
+
+var _ port.Preparer = (*Preparer)(nil)
+
+var ErrUnknownTrack = errors.New("音源中没有这首歌")
+
+func NewPreparer() *Preparer {
+ return &Preparer{}
+}
+
+func (p *Preparer) Add(t track.Track, m track.Meta, result track.Prepared) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.songs = append(p.songs, song{track: t, meta: m, prepared: result})
+}
+
+func (p *Preparer) Prepare(_ context.Context, t track.Track) (track.Meta, track.Prepared, error) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ i := slices.IndexFunc(p.songs, func(s song) bool { return s.track == t })
+ if i < 0 {
+ return track.Meta{}, track.Prepared{}, ErrUnknownTrack
+ }
+ return p.songs[i].meta, p.songs[i].prepared, nil
+}
+
+func (p *Preparer) Finished(port.PrepareOutcome) {}
diff --git a/internal/app/port/porttest/redis.go b/internal/app/port/porttest/redis.go
new file mode 100644
index 0000000..4569239
--- /dev/null
+++ b/internal/app/port/porttest/redis.go
@@ -0,0 +1,326 @@
+package porttest
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/domain/member"
+)
+
+var ErrRedisDown = errors.New("Redis 不可达")
+
+// 时间取自 time.Now,可以在 synctest 中使用。
+// 同一个频道上的消息按发布的顺序送达每个订阅方,包括发布方自己。
+type Redis struct {
+ mu sync.Mutex
+ leases map[string]lease
+ alive map[string]time.Time
+ subs map[string]map[*Node]struct{}
+}
+
+type lease struct {
+ owner string
+ online int
+ expires time.Time
+}
+
+func NewRedis() *Redis {
+ return &Redis{leases: map[string]lease{}, alive: map[string]time.Time{}, subs: map[string]map[*Node]struct{}{}}
+}
+
+// Node 是一个实例所见的 Redis。
+type Node struct {
+ redis *Redis
+ instance string
+ wake chan struct{}
+
+ // 以下字段由 redis.mu 保护。
+ down bool
+ queue []port.Delivery
+}
+
+var (
+ _ port.Leases = (*Node)(nil)
+ _ port.OnlineCounts = (*Node)(nil)
+ _ port.Liveness = (*Node)(nil)
+ _ port.Bus = (*Node)(nil)
+)
+
+func (r *Redis) Node(instanceID string) *Node {
+ return &Node{redis: r, instance: instanceID, wake: make(chan struct{}, 1)}
+}
+
+// Flush 模拟 Redis 被清空:租约、在线人数和存活键都丢失,订阅不受影响。
+func (r *Redis) Flush() {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ clear(r.leases)
+ clear(r.alive)
+}
+
+// SetDown 模拟本实例连不上 Redis:之后的操作都返回 ErrRedisDown;订阅连接随之断开,下一条收到的消息是
+// SubscriptionLost。恢复之后仍然订阅着原来的频道,断开期间发布的消息不会补回。
+func (n *Node) SetDown(down bool) {
+ r := n.redis
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if down && !n.down {
+ n.lose()
+ }
+ n.down = down
+}
+
+// Disconnect 模拟订阅连接短暂断开:还没有收到的消息丢失,下一条收到的消息是 SubscriptionLost。
+func (n *Node) Disconnect() {
+ n.redis.mu.Lock()
+ defer n.redis.mu.Unlock()
+ n.lose()
+}
+
+// 调用方持有 redis.mu。
+func (n *Node) lose() {
+ n.queue = []port.Delivery{port.SubscriptionLost{}}
+ n.signal()
+}
+
+func (n *Node) signal() {
+ select {
+ case n.wake <- struct{}{}:
+ default:
+ }
+}
+
+// 调用方持有 redis.mu。
+func (n *Node) check() error {
+ if n.down {
+ return ErrRedisDown
+ }
+ return nil
+}
+
+func valid(l lease) bool {
+ return l.owner != "" && time.Now().Before(l.expires)
+}
+
+func (n *Node) Acquire(_ context.Context, roomID string, ttl time.Duration) (bool, error) {
+ r := n.redis
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if err := n.check(); err != nil {
+ return false, err
+ }
+ if l := r.leases[roomID]; valid(l) && l.owner != n.instance {
+ return false, nil
+ }
+ r.leases[roomID] = lease{owner: n.instance, expires: time.Now().Add(ttl)}
+ return true, nil
+}
+
+func (n *Node) Renew(_ context.Context, roomID string, ttl time.Duration) (bool, error) {
+ r := n.redis
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if err := n.check(); err != nil {
+ return false, err
+ }
+ l := r.leases[roomID]
+ if !valid(l) || l.owner != n.instance {
+ return false, nil
+ }
+ l.expires = time.Now().Add(ttl)
+ r.leases[roomID] = l
+ return true, nil
+}
+
+func (n *Node) SetOnline(_ context.Context, roomID string, online int) (bool, error) {
+ r := n.redis
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if err := n.check(); err != nil {
+ return false, err
+ }
+ l := r.leases[roomID]
+ if !valid(l) || l.owner != n.instance {
+ return false, nil
+ }
+ l.online = online
+ r.leases[roomID] = l
+ return true, nil
+}
+
+func (n *Node) Release(_ context.Context, roomID string) error {
+ r := n.redis
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if err := n.check(); err != nil {
+ return err
+ }
+ if l := r.leases[roomID]; valid(l) && l.owner == n.instance {
+ delete(r.leases, roomID)
+ }
+ return nil
+}
+
+func (n *Node) Owners(_ context.Context, roomIDs []string) ([]string, error) {
+ r := n.redis
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if err := n.check(); err != nil {
+ return nil, err
+ }
+ owners := make([]string, len(roomIDs))
+ for i, id := range roomIDs {
+ if l := r.leases[id]; valid(l) {
+ owners[i] = l.owner
+ }
+ }
+ return owners, nil
+}
+
+func (n *Node) OnlineCounts(_ context.Context, roomIDs []string) ([]int, error) {
+ r := n.redis
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if err := n.check(); err != nil {
+ return nil, err
+ }
+ counts := make([]int, len(roomIDs))
+ for i, id := range roomIDs {
+ if l := r.leases[id]; valid(l) {
+ counts[i] = l.online
+ }
+ }
+ return counts, nil
+}
+
+func (n *Node) Heartbeat(_ context.Context, ttl time.Duration) (bool, error) {
+ r := n.redis
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if err := n.check(); err != nil {
+ return false, err
+ }
+ existed := time.Now().Before(r.alive[n.instance])
+ r.alive[n.instance] = time.Now().Add(ttl)
+ return existed, nil
+}
+
+func (n *Node) Expired(_ context.Context, instanceIDs []string) ([]string, error) {
+ r := n.redis
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if err := n.check(); err != nil {
+ return nil, err
+ }
+ var expired []string
+ for _, id := range instanceIDs {
+ if !time.Now().Before(r.alive[id]) {
+ expired = append(expired, id)
+ }
+ }
+ return expired, nil
+}
+
+func (n *Node) SubscribeInstance(context.Context) error {
+ return n.subscribe("inst:"+n.instance, "members", "tracks.prepared")
+}
+
+func (n *Node) SubscribeRoom(_ context.Context, roomID string) error {
+ return n.subscribe("room:" + roomID)
+}
+
+func (n *Node) subscribe(channels ...string) error {
+ r := n.redis
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if err := n.check(); err != nil {
+ return err
+ }
+ for _, ch := range channels {
+ if r.subs[ch] == nil {
+ r.subs[ch] = map[*Node]struct{}{}
+ }
+ r.subs[ch][n] = struct{}{}
+ }
+ return nil
+}
+
+func (n *Node) UnsubscribeRoom(_ context.Context, roomID string) error {
+ r := n.redis
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if err := n.check(); err != nil {
+ return err
+ }
+ delete(r.subs["room:"+roomID], n)
+ return nil
+}
+
+func (n *Node) Subscribed(roomID string) bool {
+ r := n.redis
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ _, ok := r.subs["room:"+roomID][n]
+ return ok
+}
+
+func (n *Node) PublishRoom(_ context.Context, roomID string, m port.RoomMessage) error {
+ _, err := n.publish("room:"+roomID, port.RoomDelivery{Room: roomID, Message: m})
+ return err
+}
+
+func (n *Node) SendInput(_ context.Context, instanceID string, in port.RoomInput) (bool, error) {
+ receivers, err := n.publish("inst:"+instanceID, port.InputDelivery{Context: context.Background(), Input: in})
+ return receivers > 0, err
+}
+
+func (n *Node) PublishMember(_ context.Context, m member.Member) error {
+ _, err := n.publish("members", port.MemberDelivery{Member: m})
+ return err
+}
+
+func (n *Node) PublishPrepared(_ context.Context, o port.PrepareOutcome) error {
+ _, err := n.publish("tracks.prepared", port.PreparedDelivery{Outcome: o})
+ return err
+}
+
+// 连不上 Redis 的实例没有订阅连接,收不到消息。
+func (n *Node) publish(channel string, d port.Delivery) (int, error) {
+ r := n.redis
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if err := n.check(); err != nil {
+ return 0, err
+ }
+ receivers := 0
+ for s := range r.subs[channel] {
+ if !s.down {
+ s.queue = append(s.queue, d)
+ s.signal()
+ receivers++
+ }
+ }
+ return receivers, nil
+}
+
+func (n *Node) Receive(ctx context.Context) (port.Delivery, error) {
+ r := n.redis
+ for {
+ r.mu.Lock()
+ if len(n.queue) > 0 {
+ d := n.queue[0]
+ n.queue = n.queue[1:]
+ r.mu.Unlock()
+ return d, nil
+ }
+ r.mu.Unlock()
+ select {
+ case <-n.wake:
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+ }
+}
diff --git a/internal/app/port/porttest/snapshot.go b/internal/app/port/porttest/snapshot.go
new file mode 100644
index 0000000..ee23c5b
--- /dev/null
+++ b/internal/app/port/porttest/snapshot.go
@@ -0,0 +1,131 @@
+package porttest
+
+import (
+ "context"
+ "fmt"
+ "maps"
+ "slices"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/domain/room"
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+var _ port.RoomSnapshots = (*Store)(nil)
+
+type roomState struct {
+ term uint32
+ mode room.PlayMode
+ entrySeq room.EntryID
+ waiting []room.Entry
+ pending *room.Entry
+ current *room.NowPlaying
+ plays []room.RecordPlay
+}
+
+func (s *Store) TakeRoom(_ context.Context, id string) (uint32, room.Snapshot, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ r := s.room(id)
+ if !uuidPattern.MatchString(id) || r == nil {
+ return 0, room.Snapshot{}, fmt.Errorf("接管房间 %s:%w", id, port.ErrRoomNotFound)
+ }
+ st := s.states[r.Info.ID]
+ if st == nil {
+ st = &roomState{mode: room.Sequential}
+ s.states[r.Info.ID] = st
+ }
+ st.term++
+ snap := room.Snapshot{Info: r.Info, Mode: st.mode, EntrySeq: st.entrySeq, Waiting: make([]room.Entry, len(st.waiting))}
+ for i, e := range st.waiting {
+ snap.Waiting[i] = restored(e)
+ }
+ if st.pending != nil {
+ e := restored(*st.pending)
+ snap.Pending = &e
+ }
+ if st.current != nil {
+ snap.Current = &room.NowPlaying{Entry: restored(st.current.Entry), StartedAt: st.current.StartedAt}
+ }
+ return st.term, snap, nil
+}
+
+// postgres 适配器按曲目表判断条目是否就绪,这里没有曲目表,恢复出的条目都在准备中。
+func restored(e room.Entry) room.Entry {
+ e.Ready = false
+ e.Prep = track.Prepared{}
+ return e
+}
+
+func (s *Store) SaveRoom(_ context.Context, term uint32, snap room.Snapshot, plays []room.RecordPlay) error {
+ if err := checkText(snapshotTexts(snap)...); err != nil {
+ return err
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ r := s.room(snap.Info.ID)
+ st := s.states[snap.Info.ID]
+ if r == nil || st == nil || st.term != term {
+ return fmt.Errorf("落盘房间 %s:%w", snap.Info.ID, port.ErrTermExpired)
+ }
+ r.Info.Name, r.Info.Description = snap.Info.Name, snap.Info.Description
+ st.mode, st.entrySeq = snap.Mode, snap.EntrySeq
+ st.waiting = slices.Clone(snap.Waiting)
+ st.pending, st.current = nil, nil
+ if snap.Pending != nil {
+ e := *snap.Pending
+ st.pending = &e
+ }
+ if snap.Current != nil {
+ c := *snap.Current
+ st.current = &c
+ }
+ st.plays = append(st.plays, plays...)
+ return nil
+}
+
+func (s *Store) DeleteRoom(_ context.Context, id string, term uint32) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ st := s.states[id]
+ if s.room(id) == nil || st == nil || st.term != term {
+ return fmt.Errorf("删除房间 %s:%w", id, port.ErrTermExpired)
+ }
+ s.rooms = slices.DeleteFunc(s.rooms, func(r port.RoomRecord) bool { return r.Info.ID == id })
+ delete(s.states, id)
+ maps.DeleteFunc(s.tickets, func(_ string, t storedTicket) bool { return t.roomID == id })
+ return nil
+}
+
+func (s *Store) Plays(roomID string) []room.RecordPlay {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if st := s.states[roomID]; st != nil {
+ return slices.Clone(st.plays)
+ }
+ return nil
+}
+
+func (s *Store) Term(roomID string) uint32 {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if st := s.states[roomID]; st != nil {
+ return st.term
+ }
+ return 0
+}
+
+func snapshotTexts(s room.Snapshot) []string {
+ texts := []string{s.Info.Name, s.Info.Description}
+ entries := slices.Clone(s.Waiting)
+ if s.Pending != nil {
+ entries = append(entries, *s.Pending)
+ }
+ if s.Current != nil {
+ entries = append(entries, s.Current.Entry)
+ }
+ for _, e := range entries {
+ texts = append(texts, e.Track.ID, e.Meta.Title, e.Meta.Artist, e.Meta.Album, e.Meta.CoverURL, string(e.Meta.CoverKey), e.Meta.PageURL, e.By.Name)
+ }
+ return texts
+}
diff --git a/internal/app/port/porttest/snapshot_test.go b/internal/app/port/porttest/snapshot_test.go
new file mode 100644
index 0000000..0e9e858
--- /dev/null
+++ b/internal/app/port/porttest/snapshot_test.go
@@ -0,0 +1,39 @@
+package porttest
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/domain/room"
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+func TestSnapshotKeepsRounds(t *testing.T) {
+ s := New()
+ id, err := s.CreateRoom(t.Context(), port.NewRoom{Kind: room.Persistent, Name: "大厅"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ term, snap, err := s.TakeRoom(t.Context(), id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ entry := func(id room.EntryID, round int) room.Entry {
+ return room.Entry{ID: id, Track: track.Track{Source: track.NetEase, ID: fmt.Sprint(id)}, Round: round}
+ }
+ pending := entry(2, 2)
+ snap.Mode, snap.EntrySeq = room.Rotation, 3
+ snap.Current, snap.Pending, snap.Waiting = &room.NowPlaying{Entry: entry(1, 1)}, &pending, []room.Entry{entry(3, 3)}
+ if err := s.SaveRoom(t.Context(), term, snap, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ _, restored, err := s.TakeRoom(t.Context(), id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if restored.Mode != room.Rotation || restored.Current.Entry.Round != 1 || restored.Pending.Round != 2 || restored.Waiting[0].Round != 3 {
+ t.Fatalf("恢复出的快照没有保留播放模式和轮次:%#v", restored)
+ }
+}
diff --git a/internal/app/port/porttest/source.go b/internal/app/port/porttest/source.go
new file mode 100644
index 0000000..3cd2467
--- /dev/null
+++ b/internal/app/port/porttest/source.go
@@ -0,0 +1,254 @@
+package porttest
+
+import (
+ "bytes"
+ "context"
+ "encoding/binary"
+ "errors"
+ "io"
+ "math"
+ "slices"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+type Source struct {
+ source track.Source
+
+ mu sync.Mutex
+ songs []sourceSong
+ playlists []sourcePlaylist
+ similar map[string][]string
+ failure error
+}
+
+type sourceSong struct {
+ id string
+ meta track.Meta
+ lyric string
+ audio []byte
+ reject string
+}
+
+type sourcePlaylist struct {
+ playlist port.Playlist
+ ids []string
+}
+
+var (
+ _ port.NetEaseSource = (*Source)(nil)
+ _ port.PlayURLs = (*Source)(nil)
+)
+
+var ErrUnknownSong = errors.New("音源中没有这首歌")
+
+func NewSource(source track.Source) *Source {
+ return &Source{source: source, similar: map[string][]string{}}
+}
+
+func (s *Source) Add(id string, m track.Meta, lyric string, audio []byte) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.songs = append(s.songs, sourceSong{id: id, meta: m, lyric: lyric, audio: audio})
+}
+
+func (s *Source) Reject(id, reason string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for i := range s.songs {
+ if s.songs[i].id == id {
+ s.songs[i].reject = reason
+ }
+ }
+}
+
+// ids 是歌单中的歌,必须已经登记过。
+func (s *Source) AddPlaylist(p port.Playlist, ids ...string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.playlists = append(s.playlists, sourcePlaylist{playlist: p, ids: ids})
+}
+
+// ids 是 id 的相似歌曲,必须已经登记过。
+func (s *Source) SetSimilar(id string, ids ...string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.similar[id] = ids
+}
+
+func (s *Source) Fail(err error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.failure = err
+}
+
+func (s *Source) SearchTracks(_ context.Context, keyword string) ([]port.Song, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.failure != nil {
+ return nil, s.failure
+ }
+ var songs []port.Song
+ for _, song := range s.songs {
+ if strings.Contains(song.meta.Title, keyword) || strings.Contains(song.meta.Artist, keyword) {
+ songs = append(songs, s.song(song))
+ }
+ }
+ return songs, nil
+}
+
+func (s *Source) SearchPlaylists(_ context.Context, keyword string) ([]port.Playlist, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.failure != nil {
+ return nil, s.failure
+ }
+ var playlists []port.Playlist
+ for _, p := range s.playlists {
+ if strings.Contains(p.playlist.Name, keyword) {
+ playlists = append(playlists, p.playlist)
+ }
+ }
+ return playlists, nil
+}
+
+func (s *Source) PlaylistTracks(_ context.Context, playlistID string) ([]port.Song, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.failure != nil {
+ return nil, s.failure
+ }
+ i := slices.IndexFunc(s.playlists, func(p sourcePlaylist) bool { return p.playlist.ID == playlistID })
+ if i < 0 {
+ return nil, errors.New("音源中没有这个歌单")
+ }
+ return s.lookup(s.playlists[i].ids), nil
+}
+
+func (s *Source) SimilarTracks(_ context.Context, id string) ([]port.Song, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.failure != nil {
+ return nil, s.failure
+ }
+ return s.lookup(s.similar[id]), nil
+}
+
+func (s *Source) TrackMeta(_ context.Context, id string) (track.Meta, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.failure != nil {
+ return track.Meta{}, s.failure
+ }
+ song, ok := s.find(id)
+ if !ok {
+ return track.Meta{}, &port.RejectedError{Err: ErrUnknownSong}
+ }
+ return song.meta, nil
+}
+
+func (s *Source) Resolve(_ context.Context, id string) (port.Resolved, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.failure != nil {
+ return port.Resolved{}, s.failure
+ }
+ song, ok := s.find(id)
+ switch {
+ case !ok:
+ return port.Resolved{}, &port.RejectedError{Err: ErrUnknownSong}
+ case song.reject != "":
+ return port.Resolved{}, &port.RejectedError{Err: errors.New(song.reject)}
+ }
+ return port.Resolved{Meta: song.meta, Lyric: song.lyric, Audio: io.NopCloser(bytes.NewReader(song.audio))}, nil
+}
+
+func (s *Source) PlayURL(_ context.Context, id string) (string, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.failure != nil {
+ return "", s.failure
+ }
+ song, ok := s.find(id)
+ switch {
+ case !ok:
+ return "", &port.RejectedError{Err: ErrUnknownSong}
+ case song.reject != "":
+ return "", &port.RejectedError{Err: errors.New(song.reject)}
+ }
+ return "http://" + string(s.source) + ".example/" + id + ".mp3", nil
+}
+
+// 调用方持有 s.mu。
+func (s *Source) find(id string) (sourceSong, bool) {
+ i := slices.IndexFunc(s.songs, func(song sourceSong) bool { return song.id == id })
+ if i < 0 {
+ return sourceSong{}, false
+ }
+ return s.songs[i], true
+}
+
+// 调用方持有 s.mu。
+func (s *Source) lookup(ids []string) []port.Song {
+ songs := make([]port.Song, 0, len(ids))
+ for _, id := range ids {
+ if song, ok := s.find(id); ok {
+ songs = append(songs, s.song(song))
+ }
+ }
+ return songs
+}
+
+func (s *Source) song(song sourceSong) port.Song {
+ return port.Song{Track: track.Track{Source: s.source, ID: song.id}, Meta: song.meta}
+}
+
+// 静音的响度分析得到的增益正好是 1。
+func SilentWAV(d time.Duration) []byte {
+ const rate = 8000
+ frames := int(d * rate / time.Second)
+ b := []byte("RIFF")
+ b = binary.LittleEndian.AppendUint32(b, uint32(36+frames))
+ b = append(b, "WAVEfmt "...)
+ b = binary.LittleEndian.AppendUint32(b, 16) // fmt 块的长度
+ b = binary.LittleEndian.AppendUint16(b, 1) // PCM
+ b = binary.LittleEndian.AppendUint16(b, 1) // 声道数
+ b = binary.LittleEndian.AppendUint32(b, rate)
+ b = binary.LittleEndian.AppendUint32(b, rate) // 每秒的字节数
+ b = binary.LittleEndian.AppendUint16(b, 1) // 每帧的字节数
+ b = binary.LittleEndian.AppendUint16(b, 8) // 位深
+ b = append(b, "data"...)
+ b = binary.LittleEndian.AppendUint32(b, uint32(frames))
+ // 8 位 PCM 是无符号的,静音是 128。
+ return append(b, bytes.Repeat([]byte{128}, frames)...)
+}
+
+// 1 kHz 的正弦波,峰值是满幅的 amplitude 倍:响度分析得到的增益不是 1。
+func ToneWAV(d time.Duration, amplitude float64) []byte {
+ const rate, channels = 44100, 2
+ frames := int(d * rate / time.Second)
+ size := frames * channels * 2
+ b := []byte("RIFF")
+ b = binary.LittleEndian.AppendUint32(b, uint32(36+size))
+ b = append(b, "WAVEfmt "...)
+ b = binary.LittleEndian.AppendUint32(b, 16) // fmt 块的长度
+ b = binary.LittleEndian.AppendUint16(b, 1) // PCM
+ b = binary.LittleEndian.AppendUint16(b, channels)
+ b = binary.LittleEndian.AppendUint32(b, rate)
+ b = binary.LittleEndian.AppendUint32(b, rate*channels*2) // 每秒的字节数
+ b = binary.LittleEndian.AppendUint16(b, channels*2) // 每帧的字节数
+ b = binary.LittleEndian.AppendUint16(b, 16) // 位深
+ b = append(b, "data"...)
+ b = binary.LittleEndian.AppendUint32(b, uint32(size))
+ for i := range frames {
+ v := int16(math.Round(amplitude * math.MaxInt16 * math.Sin(2*math.Pi*1000*float64(i)/rate)))
+ for range channels {
+ b = binary.LittleEndian.AppendUint16(b, uint16(v))
+ }
+ }
+ return b
+}
diff --git a/internal/app/port/porttest/store.go b/internal/app/port/porttest/store.go
new file mode 100644
index 0000000..7ab212e
--- /dev/null
+++ b/internal/app/port/porttest/store.go
@@ -0,0 +1,364 @@
+// Package porttest 在内存中实现 internal/app/port 的端口,只供测试使用。
+package porttest
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "maps"
+ "regexp"
+ "slices"
+ "strings"
+ "sync"
+ "time"
+ "unicode/utf8"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/domain/member"
+ "github.com/dihemoe/alisten/internal/domain/room"
+)
+
+// Store 的行为与 postgres 适配器一致,包括它会拒绝的输入。
+type Store struct {
+ mu sync.Mutex
+ ids int
+ members []storedMember
+ rooms []port.RoomRecord
+ states map[string]*roomState
+ tickets map[string]storedTicket
+ // 房间 ID 到 bot 成员 ID。
+ bots map[string]string
+ cookies map[string]string
+ brandName string
+}
+
+type storedMember struct {
+ tokenHash string
+ member member.Member
+}
+
+type storedTicket struct {
+ roomID string
+ memberID string
+ createdAt time.Time
+}
+
+var (
+ _ port.Members = (*Store)(nil)
+ _ port.Tickets = (*Store)(nil)
+ _ port.RoomDirectory = (*Store)(nil)
+ _ port.Credentials = (*Store)(nil)
+ _ port.APIKeys = (*Store)(nil)
+ _ port.Site = (*Store)(nil)
+)
+
+func New() *Store {
+ return &Store{
+ states: map[string]*roomState{},
+ tickets: map[string]storedTicket{},
+ bots: map[string]string{},
+ cookies: map[string]string{},
+ }
+}
+
+func (s *Store) newID() string {
+ s.ids++
+ return fmt.Sprintf("00000000-0000-7000-8000-%012d", s.ids)
+}
+
+var uuidPattern = regexp.MustCompile(`^(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
+
+// PostgreSQL 的 text 无法存储 U+0000,也不接受不合法的 UTF-8,查询参数中出现时直接报错。
+func checkText(values ...string) error {
+ for _, v := range values {
+ if strings.ContainsRune(v, 0) || !utf8.ValidString(v) {
+ return fmt.Errorf("PostgreSQL 无法处理文本 %q", v)
+ }
+ }
+ return nil
+}
+
+func (s *Store) CreateMember(_ context.Context, tokenHash []byte, name string) (member.Member, error) {
+ if err := checkText(name); err != nil {
+ return member.Member{}, err
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for _, m := range s.members {
+ if m.tokenHash == string(tokenHash) {
+ return member.Member{}, errors.New("token 哈希重复")
+ }
+ }
+ m := member.Member{ID: s.newID(), Name: name}
+ s.members = append(s.members, storedMember{tokenHash: string(tokenHash), member: m})
+ return m, nil
+}
+
+func (s *Store) MemberByToken(_ context.Context, tokenHash []byte) (member.Member, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for _, m := range s.members {
+ if m.tokenHash == string(tokenHash) {
+ return m.member, nil
+ }
+ }
+ return member.Member{}, port.ErrMemberNotFound
+}
+
+func (s *Store) UpdateMember(_ context.Context, id string, name, avatarHash *string) (member.Member, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for i := range s.members {
+ m := &s.members[i].member
+ if m.ID != id {
+ continue
+ }
+ if name != nil {
+ if err := checkText(*name); err != nil {
+ return member.Member{}, err
+ }
+ m.Name = *name
+ }
+ if avatarHash != nil {
+ m.AvatarHash = *avatarHash
+ }
+ return *m, nil
+ }
+ return member.Member{}, port.ErrMemberNotFound
+}
+
+func (s *Store) CreateTicket(_ context.Context, t port.NewTicket) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ r := s.room(t.RoomID)
+ if r == nil {
+ return port.ErrRoomNotFound
+ }
+ if r.PasswordHash != t.PasswordHash {
+ return port.ErrPasswordChanged
+ }
+ if !slices.ContainsFunc(s.members, func(m storedMember) bool { return m.member.ID == t.MemberID }) {
+ return errors.New("成员不存在")
+ }
+ if _, ok := s.tickets[string(t.TokenHash)]; ok {
+ return errors.New("票据哈希重复")
+ }
+ s.tickets[string(t.TokenHash)] = storedTicket{roomID: r.Info.ID, memberID: t.MemberID, createdAt: time.Now()}
+ return nil
+}
+
+func (s *Store) TicketHolder(_ context.Context, tokenHash []byte) (port.TicketHolder, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ t, ok := s.tickets[string(tokenHash)]
+ if !ok {
+ return port.TicketHolder{}, port.ErrTicketNotFound
+ }
+ for _, m := range s.members {
+ if m.member.ID == t.memberID {
+ return port.TicketHolder{RoomID: t.roomID, Member: m.member, Bot: s.bots[t.roomID] == t.memberID}, nil
+ }
+ }
+ return port.TicketHolder{}, port.ErrTicketNotFound
+}
+
+func (s *Store) RoomAPIKeyStatus(_ context.Context, roomID string) (port.APIKeyStatus, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ r := s.room(roomID)
+ if r == nil {
+ return port.APIKeyStatus{}, nil
+ }
+ if bot, ok := s.bots[r.Info.ID]; ok {
+ for _, t := range s.tickets {
+ if t.roomID == r.Info.ID && t.memberID == bot {
+ return port.APIKeyStatus{Set: true, CreatedAt: t.createdAt}, nil
+ }
+ }
+ }
+ return port.APIKeyStatus{}, nil
+}
+
+func (s *Store) ResetRoomAPIKey(_ context.Context, roomID string, k port.NewAPIKey) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ r := s.room(roomID)
+ if r == nil {
+ return port.ErrRoomNotFound
+ }
+ if r.Info.Kind != room.Persistent {
+ return errors.New("只有常驻房间可以有 bot 成员")
+ }
+ bot, ok := s.bots[r.Info.ID]
+ if ok {
+ maps.DeleteFunc(s.tickets, func(_ string, t storedTicket) bool { return t.roomID == r.Info.ID && t.memberID == bot })
+ } else {
+ bot = s.newID()
+ s.members = append(s.members, storedMember{tokenHash: string(k.BotTokenHash), member: member.Member{ID: bot, Name: "bot"}})
+ s.bots[r.Info.ID] = bot
+ }
+ s.tickets[string(k.TokenHash)] = storedTicket{roomID: r.Info.ID, memberID: bot, createdAt: time.Now()}
+ return nil
+}
+
+func (s *Store) CreateRoom(_ context.Context, r port.NewRoom) (string, error) {
+ if err := checkText(r.Name, r.Description, r.Slug, r.PasswordHash); err != nil {
+ return "", err
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if r.Slug != "" && r.Kind != room.Persistent {
+ return "", errors.New("只有常驻房间可以有别名")
+ }
+ if r.Slug != "" && s.slugOwner(r.Slug) != nil {
+ return "", port.ErrSlugTaken
+ }
+ id := s.newID()
+ s.rooms = append(s.rooms, port.RoomRecord{
+ Info: room.Info{ID: id, Name: r.Name, Description: r.Description, Kind: r.Kind},
+ Slug: r.Slug,
+ PasswordHash: r.PasswordHash,
+ })
+ return id, nil
+}
+
+func (s *Store) ListRooms(context.Context) ([]port.RoomRecord, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return slices.Clone(s.rooms), nil
+}
+
+func (s *Store) FindRoom(_ context.Context, ref string) (port.RoomRecord, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ var r *port.RoomRecord
+ if uuidPattern.MatchString(ref) {
+ r = s.room(ref)
+ } else {
+ if err := checkText(ref); err != nil {
+ return port.RoomRecord{}, err
+ }
+ r = s.slugOwner(ref)
+ }
+ if r == nil {
+ return port.RoomRecord{}, fmt.Errorf("查找房间 %s:%w", ref, port.ErrRoomNotFound)
+ }
+ return *r, nil
+}
+
+func (s *Store) UpdateRoomAccess(_ context.Context, id string, c port.AccessChange) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ r := s.room(id)
+ if r == nil {
+ return port.ErrRoomNotFound
+ }
+ updated := *r
+ if c.Slug != nil {
+ if err := checkText(*c.Slug); err != nil {
+ return err
+ }
+ if owner := s.slugOwner(*c.Slug); *c.Slug != "" && owner != nil && owner.Info.ID != r.Info.ID {
+ return port.ErrSlugTaken
+ }
+ if *c.Slug != "" && r.Info.Kind != room.Persistent {
+ return errors.New("只有常驻房间可以有别名")
+ }
+ updated.Slug = *c.Slug
+ }
+ if c.PasswordHash != nil {
+ if err := checkText(*c.PasswordHash); err != nil {
+ return err
+ }
+ updated.PasswordHash = *c.PasswordHash
+ // 取消密码时,已经签发的票据保持有效。
+ if *c.PasswordHash != "" {
+ bot := s.bots[r.Info.ID]
+ maps.DeleteFunc(s.tickets, func(_ string, t storedTicket) bool { return t.roomID == r.Info.ID && t.memberID != bot })
+ }
+ }
+ *r = updated
+ return nil
+}
+
+func (s *Store) NeteaseCookie(context.Context) (string, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.cookies["netease"], nil
+}
+
+func (s *Store) SetNeteaseCookie(_ context.Context, cookie string) error {
+ if err := checkText(cookie); err != nil {
+ return err
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.cookies["netease"] = cookie
+ return nil
+}
+
+func (s *Store) QQCookie(context.Context) (string, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.cookies["qq"], nil
+}
+
+func (s *Store) SetQQCookie(_ context.Context, cookie string) error {
+ if err := checkText(cookie); err != nil {
+ return err
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.cookies["qq"] = cookie
+ return nil
+}
+
+func (s *Store) BilibiliCredential(context.Context) (string, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.cookies["bilibili"], nil
+}
+
+func (s *Store) SetBilibiliCredential(_ context.Context, credential string) error {
+ if err := checkText(credential); err != nil {
+ return err
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.cookies["bilibili"] = credential
+ return nil
+}
+
+func (s *Store) BrandName(context.Context) (string, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.brandName, nil
+}
+
+func (s *Store) SetBrandName(_ context.Context, name string) error {
+ if err := checkText(name); err != nil {
+ return err
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.brandName = name
+ return nil
+}
+
+func (s *Store) room(id string) *port.RoomRecord {
+ for i := range s.rooms {
+ if strings.EqualFold(s.rooms[i].Info.ID, id) {
+ return &s.rooms[i]
+ }
+ }
+ return nil
+}
+
+func (s *Store) slugOwner(slug string) *port.RoomRecord {
+ for i := range s.rooms {
+ if s.rooms[i].Slug != "" && s.rooms[i].Slug == slug {
+ return &s.rooms[i]
+ }
+ }
+ return nil
+}
diff --git a/internal/app/prepare/bilibili_test.go b/internal/app/prepare/bilibili_test.go
new file mode 100644
index 0000000..24ffd76
--- /dev/null
+++ b/internal/app/prepare/bilibili_test.go
@@ -0,0 +1,123 @@
+package prepare
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/media"
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/app/port/porttest"
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+var video = track.Track{Source: track.Bilibili, ID: "BV1xx411c7mD/2"}
+
+type bilibiliFixture struct {
+ source *porttest.Bilibili
+ transcoder *porttest.Transcoder
+ store *porttest.MediaStore
+ tracks *tracksTable
+}
+
+// 转出的 mp3 总是一段 2 秒的静音。
+func newBilibiliFixture() bilibiliFixture {
+ return bilibiliFixture{source: porttest.NewBilibili(), transcoder: porttest.NewTranscoder(porttest.SilentWAV(2 * time.Second)), store: porttest.NewMediaStore(), tracks: newTracks()}
+}
+
+func (f bilibiliFixture) run(t *testing.T, tr track.Track) error {
+ t.Helper()
+ return NewMediaRunner(&downloader{}, f.source, f.transcoder, f.store, f.tracks, discard()).Run(t.Context(), tr)
+}
+
+func TestMediaRunnerStoresBilibili(t *testing.T) {
+ f := newBilibiliFixture()
+ original := []byte("平台给的原始音频")
+ f.source.AddVideo(video.ID, track.Meta{Title: "晴天(钢琴版)", Artist: "某 UP 主", Duration: 3 * time.Second}, original, []byte("jpeg"), "jpg")
+ if err := f.run(t, video); err != nil {
+ t.Fatal(err)
+ }
+ if inputs := f.transcoder.Inputs(); len(inputs) != 1 || string(inputs[0]) != string(original) {
+ t.Errorf("转码的输入 = %q,期望平台给的原始音频", inputs)
+ }
+ key := media.Key(video.ID)
+ mp3 := porttest.SilentWAV(2 * time.Second)
+ if got, ok := f.store.Object(media.AudioObject(key)); !ok || string(got.Data) != string(mp3) || got.ContentType != "audio/mpeg" {
+ t.Errorf("上传的音频 = %v, %v,期望转出的 mp3", got.ContentType, ok)
+ }
+ if got, ok := f.store.Object(media.CoverObject(key, "jpg")); !ok || string(got.Data) != "jpeg" || got.ContentType != "image/jpeg" {
+ t.Errorf("上传的封面 = %+v, %v", got, ok)
+ }
+ want := port.PreparedTrack{
+ Meta: track.Meta{Title: "晴天(钢琴版)", Artist: "某 UP 主", CoverKey: track.ObjectKey(media.CoverObject(key, "jpg")), PageURL: "https://www.bilibili.com/video/BV1xx411c7mD?p=2", Duration: 3 * time.Second},
+ Result: track.Prepared{Duration: 2 * time.Second, Gain: 1},
+ MediaKey: key,
+ MediaSize: int64(len(mp3)),
+ }
+ if got, ok := f.tracks.row(video); !ok || got != want {
+ t.Errorf("曲目表中的结果 = %+v\n期望 %+v:时长取解码转出的 mp3 得到的时长", got, want)
+ }
+ if f.source.Released() != 1 || f.transcoder.Released() != 1 {
+ t.Errorf("释放了 %d 次下载、%d 次转码,期望各 1 次", f.source.Released(), f.transcoder.Released())
+ }
+}
+
+func TestMediaRunnerRejectsBadBilibiliTasks(t *testing.T) {
+ f := newBilibiliFixture()
+ f.source.AddVideo("BV1xx411c7mD", track.Meta{Title: "付费视频"}, []byte("平台给的原始音频"), nil, "")
+ f.source.RejectVideo("BV1xx411c7mD", "这个视频需要付费")
+ var rejected *port.RejectedError
+ if err := f.run(t, track.Track{Source: track.Bilibili, ID: "BV1xx411c7mD"}); !errors.As(err, &rejected) || err.Error() != "这个视频需要付费" {
+ t.Errorf("音源拒绝下载时 = %v,期望不能准备,原因是音源给出的", err)
+ }
+ // 音源里也登记这些 ID:交给音源就会下载成功。
+ for _, id := range []string{"BV1xx411c7mD/1", "../BV1xx411c7mD", ""} {
+ f.source.AddVideo(id, track.Meta{Title: "格式不对的 ID"}, []byte("平台给的原始音频"), nil, "")
+ if err := f.run(t, track.Track{Source: track.Bilibili, ID: id}); !errors.As(err, &rejected) {
+ t.Errorf("任务参数中的 ID %q 的错误 = %v,期望不能准备", id, err)
+ }
+ }
+ if f.source.Released() != 0 || len(f.transcoder.Inputs()) != 0 || len(f.store.Keys()) != 0 {
+ t.Errorf("释放了 %d 次下载、转码了 %d 次、上传了 %q,期望不能准备的歌都不下载、不转码、不上传", f.source.Released(), len(f.transcoder.Inputs()), f.store.Keys())
+ }
+}
+
+func TestMediaRunnerReleasesDownloadWhenTranscodeFails(t *testing.T) {
+ f := newBilibiliFixture()
+ f.source.AddVideo(video.ID, track.Meta{Title: "晴天(钢琴版)"}, []byte("平台给的原始音频"), nil, "")
+ f.transcoder.Fail(errors.New("ffmpeg 转码失败:Invalid data found when processing input"))
+ err := f.run(t, video)
+ var rejected *port.RejectedError
+ if err == nil || errors.As(err, &rejected) {
+ t.Errorf("转码失败时 = %v,期望可以重试的错误", err)
+ }
+ if f.source.Released() != 1 {
+ t.Errorf("转码失败之后释放了 %d 次下载,期望 1 次", f.source.Released())
+ }
+ if len(f.store.Keys()) != 0 {
+ t.Error("转码失败时不应上传")
+ }
+ if _, ok := f.tracks.row(video); ok {
+ t.Error("转码失败时不应写入曲目表")
+ }
+}
+
+type panickingTranscoder struct{}
+
+func (panickingTranscoder) ToMP3(context.Context, port.LinkFile) (port.LinkFile, func(), error) {
+ panic("转码出了程序缺陷")
+}
+
+func TestMediaRunnerReleasesDownloadWhenTranscoderPanics(t *testing.T) {
+ source := porttest.NewBilibili()
+ source.AddVideo(video.ID, track.Meta{Title: "晴天(钢琴版)"}, []byte("平台给的原始音频"), nil, "")
+ runner := NewMediaRunner(&downloader{}, source, panickingTranscoder{}, porttest.NewMediaStore(), newTracks(), discard())
+ func() {
+ defer func() { _ = recover() }()
+ _ = runner.Run(t.Context(), video)
+ }()
+ if source.Released() != 1 {
+ t.Errorf("转码 panic 之后释放了 %d 次下载,期望 1 次:River 恢复 panic 之后 worker 继续运行,文件不能留下", source.Released())
+ }
+}
diff --git a/internal/app/prepare/media_test.go b/internal/app/prepare/media_test.go
new file mode 100644
index 0000000..497e607
--- /dev/null
+++ b/internal/app/prepare/media_test.go
@@ -0,0 +1,412 @@
+package prepare
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "log/slog"
+ "slices"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/media"
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/app/port/porttest"
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+var link = track.Track{Source: track.Link, ID: "https://www.youtube.com/watch?v=x"}
+
+type downloader struct {
+ meta track.Meta
+ audio []byte
+ cover []byte
+ coverExt string
+ err error
+ links []string
+ released int
+ finished func()
+}
+
+func (d *downloader) Download(_ context.Context, link string) (port.LinkDownload, func(), error) {
+ d.links = append(d.links, link)
+ if d.err != nil {
+ return port.LinkDownload{}, nil, d.err
+ }
+ released := &atomic.Bool{}
+ result := port.LinkDownload{Meta: d.meta, Audio: downloadedFile{released, d.audio}}
+ if d.cover != nil {
+ result.Cover, result.CoverExt = downloadedFile{released, d.cover}, d.coverExt
+ }
+ if d.finished != nil {
+ d.finished()
+ }
+ return result, func() {
+ released.Store(true)
+ d.released++
+ }, nil
+}
+
+// 和下载适配器一样,release 之后文件就读不到了。
+type downloadedFile struct {
+ released *atomic.Bool
+ data []byte
+}
+
+func (f downloadedFile) Open() (io.ReadSeekCloser, error) {
+ if f.released.Load() {
+ return nil, errors.New("文件已经删除")
+ }
+ return porttest.LinkFile(f.data).Open()
+}
+
+// 只准备链接的测试用不到 B 站的音源和转码。
+func newLinkRunner(d port.LinkDownloader, store port.MediaStore, tracks port.Tracks, logger *slog.Logger) *MediaRunner {
+ return NewMediaRunner(d, nil, nil, store, tracks, logger)
+}
+
+func runLink(t *testing.T, d *downloader, store *porttest.MediaStore, tr track.Track) (*tracksTable, error) {
+ t.Helper()
+ tracks := newTracks()
+ err := newLinkRunner(d, store, tracks, discard()).Run(t.Context(), tr)
+ if d.err == nil && d.released != len(d.links) {
+ t.Errorf("下载成功 %d 次,release 调用了 %d 次,期望每次下载成功之后正好调用一次", len(d.links), d.released)
+ }
+ return tracks, err
+}
+
+func TestLinkRunnerStoresMediaAndResult(t *testing.T) {
+ audio := porttest.SilentWAV(2 * time.Second)
+ d := &downloader{meta: track.Meta{Title: "晴天", Artist: "周杰伦", Album: "叶惠美", Duration: 3 * time.Second}, audio: audio, cover: []byte("webp"), coverExt: "webp"}
+ store := porttest.NewMediaStore()
+ tracks, err := runLink(t, d, store, link)
+ if err != nil {
+ t.Fatal(err)
+ }
+ key := media.Key(link.ID)
+ if got, ok := store.Object(media.AudioObject(key)); !ok || string(got.Data) != string(audio) || got.ContentType != "audio/mpeg" {
+ t.Errorf("音频 = %v, %v", got.ContentType, ok)
+ }
+ if got, ok := store.Object(media.CoverObject(key, "webp")); !ok || string(got.Data) != "webp" || got.ContentType != "image/webp" {
+ t.Errorf("封面 = %+v, %v", got, ok)
+ }
+ want := port.PreparedTrack{
+ Meta: track.Meta{Title: "晴天", Artist: "周杰伦", Album: "叶惠美", CoverKey: track.ObjectKey(media.CoverObject(key, "webp")), PageURL: link.ID, Duration: 3 * time.Second},
+ Result: track.Prepared{Duration: 2 * time.Second, Gain: 1},
+ MediaKey: key,
+ MediaSize: int64(len(audio)),
+ }
+ if got, ok := tracks.row(link); !ok || got != want {
+ t.Errorf("曲目表中的结果 = %+v\n期望 %+v:时长取解码得到的时长", got, want)
+ }
+ if !slices.Equal(d.links, []string{link.ID}) {
+ t.Errorf("下载的链接 = %q", d.links)
+ }
+}
+
+func TestLinkRunnerSkipsCoverOfOtherFormats(t *testing.T) {
+ d := &downloader{meta: track.Meta{Title: "晴天"}, audio: porttest.SilentWAV(time.Second), cover: []byte(""), coverExt: "svg"}
+ store := porttest.NewMediaStore()
+ tracks, err := runLink(t, d, store, link)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if keys := store.Keys(); !slices.Equal(keys, []string{media.AudioObject(media.Key(link.ID))}) {
+ t.Errorf("上传的对象 = %q,期望只有音频", keys)
+ }
+ if got, _ := tracks.row(link); got.Meta.CoverKey != "" {
+ t.Errorf("封面 = %q,期望没有封面", got.Meta.CoverKey)
+ }
+}
+
+func TestLinkRunnerFallsBackWhenAudioCannotBeAnalyzed(t *testing.T) {
+ d := &downloader{meta: track.Meta{Title: "晴天", Duration: 3 * time.Second}, audio: []byte("不是音频")}
+ tracks, err := runLink(t, d, porttest.NewMediaStore(), link)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, _ := tracks.row(link); got.Result != (track.Prepared{Duration: 3 * time.Second, Gain: 1}) {
+ t.Errorf("无法解码时的结果 = %+v,期望增益为 1、时长取元数据中的时长", got.Result)
+ }
+
+ d = &downloader{meta: track.Meta{Title: "晴天"}, audio: []byte("不是音频")}
+ var rejected *port.RejectedError
+ if tracks, err := runLink(t, d, porttest.NewMediaStore(), link); !errors.As(err, &rejected) {
+ t.Errorf("元数据中也没有时长时的错误 = %v,期望不能准备", err)
+ } else if _, ok := tracks.row(link); ok {
+ t.Error("不能准备的歌不应写入曲目表")
+ }
+}
+
+func TestLinkRunnerLogsAudioThatCannotBeDecoded(t *testing.T) {
+ decodeErr := truncatedMP3Error(t)
+ runWith := func(m track.Meta) (*bytes.Buffer, error) {
+ logs := &bytes.Buffer{}
+ d := &downloader{meta: m, audio: truncatedMP3}
+ err := newLinkRunner(d, porttest.NewMediaStore(), newTracks(), slog.New(slog.NewJSONHandler(logs, nil))).Run(t.Context(), link)
+ return logs, err
+ }
+
+ t.Run("回退", func(t *testing.T) {
+ logs, err := runWith(track.Meta{Title: "晴天", Duration: 3 * time.Second})
+ if err != nil {
+ t.Fatal(err)
+ }
+ checkDecodeWarning(t, logs, link, decodeErr)
+ })
+ t.Run("元数据中也没有时长", func(t *testing.T) {
+ _, err := runWith(track.Meta{Title: "晴天"})
+ var rejected *port.RejectedError
+ if !errors.As(err, &rejected) || err.Error() != "这首歌的音频无法播放" {
+ t.Errorf("错误 = %v,期望不能准备,原因带着解码的错误 %q", err, decodeErr)
+ }
+ })
+}
+
+func TestLinkRunnerFailures(t *testing.T) {
+ var rejected *port.RejectedError
+ refused := &port.RejectedError{Err: errors.New("yt-dlp 不支持这个链接")}
+ d := &downloader{err: refused}
+ store := porttest.NewMediaStore()
+ if _, err := runLink(t, d, store, link); !errors.As(err, &rejected) || err.Error() != "yt-dlp 不支持这个链接" {
+ t.Errorf("下载被拒绝时的错误 = %v", err)
+ }
+
+ for _, tr := range []track.Track{{Source: track.Link, ID: "ftp://example.com/a.mp3"}, {Source: track.Link, ID: "https://:8080/a.mp3"}, {Source: track.NetEase, ID: "186016"}} {
+ d := &downloader{audio: porttest.SilentWAV(time.Second)}
+ if _, err := runLink(t, d, store, tr); !errors.As(err, &rejected) || len(d.links) != 0 {
+ t.Errorf("任务参数 %v 的错误 = %v,下载了 %q,期望不能准备", tr, err, d.links)
+ }
+ }
+
+ store.Fail(errors.New("连接被拒绝"))
+ d = &downloader{meta: track.Meta{Title: "晴天"}, audio: porttest.SilentWAV(time.Second)}
+ tracks, err := runLink(t, d, store, link)
+ if err == nil || errors.As(err, &rejected) {
+ t.Errorf("上传失败时的错误 = %v,期望可以重试的错误", err)
+ }
+ if _, ok := tracks.row(link); ok {
+ t.Error("上传失败时不应写入曲目表")
+ }
+}
+
+func TestLinkRunnerAnalysisStopsWhenCancelled(t *testing.T) {
+ ctx, cancel := context.WithCancel(t.Context())
+ defer cancel()
+ d := &downloader{meta: track.Meta{Title: "晴天", Duration: time.Second}, audio: porttest.SilentWAV(2 * time.Second), finished: cancel}
+ tracks := newTracks()
+ err := newLinkRunner(d, porttest.NewMediaStore(), tracks, discard()).Run(ctx, link)
+ var rejected *port.RejectedError
+ if !errors.Is(err, context.Canceled) || errors.As(err, &rejected) {
+ t.Errorf("分析时 ctx 已经结束,Run = %v,期望可以重试的 context.Canceled", err)
+ }
+ if _, ok := tracks.row(link); ok {
+ t.Error("没有分析完的歌不应写入曲目表")
+ }
+}
+
+type checkedStore struct {
+ *porttest.MediaStore
+ putErr func(key string) error
+ deleteErr error
+
+ mu sync.Mutex
+ uploaded bool
+ deadlines []bool
+}
+
+func (s *checkedStore) Put(ctx context.Context, key string, body io.ReadSeeker, contentType string) error {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ if s.putErr != nil {
+ if err := s.putErr(key); err != nil {
+ return err
+ }
+ }
+ s.mu.Lock()
+ s.uploaded = true
+ s.mu.Unlock()
+ return s.MediaStore.Put(ctx, key, body, contentType)
+}
+
+func (s *checkedStore) Delete(ctx context.Context, key string) error {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ _, hasDeadline := ctx.Deadline()
+ s.mu.Lock()
+ s.deadlines = append(s.deadlines, hasDeadline)
+ failing := s.uploaded && s.deleteErr != nil
+ s.mu.Unlock()
+ if failing {
+ return s.deleteErr
+ }
+ return s.MediaStore.Delete(ctx, key)
+}
+
+type failingSave struct{ *tracksTable }
+
+func (failingSave) SavePreparedTrack(context.Context, track.Track, port.PreparedTrack) error {
+ return errors.New("数据库不可用")
+}
+
+func storeWithOtherObject(t *testing.T) *checkedStore {
+ t.Helper()
+ store := &checkedStore{MediaStore: porttest.NewMediaStore()}
+ if err := store.MediaStore.Put(t.Context(), media.AudioObject("other"), strings.NewReader("x"), "audio/mpeg"); err != nil {
+ t.Fatal(err)
+ }
+ return store
+}
+
+func TestLinkRunnerClearsStaleObjects(t *testing.T) {
+ key := media.Key(link.ID)
+ for name, tt := range map[string]struct {
+ cover []byte
+ coverExt string
+ want []string
+ }{
+ "cover_format_changed": {[]byte("jpeg"), "jpg", []string{media.AudioObject("other"), media.AudioObject(key), media.CoverObject(key, "jpg")}},
+ "no_cover_this_time": {nil, "", []string{media.AudioObject("other"), media.AudioObject(key)}},
+ } {
+ t.Run(name, func(t *testing.T) {
+ store := storeWithOtherObject(t)
+ for _, object := range []string{media.AudioObject(key), media.CoverObject(key, "png"), media.CoverObject(key, "webp"), media.CoverObject(key, "jpeg")} {
+ if err := store.MediaStore.Put(t.Context(), object, strings.NewReader("old"), "application/octet-stream"); err != nil {
+ t.Fatal(err)
+ }
+ }
+ d := &downloader{meta: track.Meta{Title: "晴天"}, audio: porttest.SilentWAV(time.Second), cover: tt.cover, coverExt: tt.coverExt}
+ if err := newLinkRunner(d, store, newTracks(), discard()).Run(t.Context(), link); err != nil {
+ t.Fatal(err)
+ }
+ if got := store.Keys(); !slices.Equal(got, slices.Sorted(slices.Values(tt.want))) {
+ t.Errorf("对象 = %q,期望 %q", got, tt.want)
+ }
+ if got, _ := store.Object(media.AudioObject(key)); string(got.Data) == "old" {
+ t.Error("音频还是上一次的")
+ }
+ })
+ }
+}
+
+func TestLinkRunnerRemovesUploadedObjectsOnFailure(t *testing.T) {
+ key := media.Key(link.ID)
+ cover := media.CoverObject(key, "jpg")
+ tests := map[string]struct {
+ d *downloader
+ store func(cancel context.CancelFunc) *checkedStore
+ tracks func() port.Tracks
+ check func(t *testing.T, err error)
+ }{
+ "audio_cannot_be_analyzed": {
+ d: &downloader{audio: []byte("不是音频"), cover: []byte("jpeg"), coverExt: "jpg"},
+ check: func(t *testing.T, err error) {
+ var rejected *port.RejectedError
+ if !errors.As(err, &rejected) {
+ t.Errorf("错误 = %v,期望不能准备", err)
+ }
+ },
+ },
+ "result_cannot_be_saved": {
+ d: &downloader{meta: track.Meta{Title: "晴天"}, audio: porttest.SilentWAV(time.Second), cover: []byte("jpeg"), coverExt: "jpg"},
+ tracks: func() port.Tracks { return failingSave{newTracks()} },
+ check: func(t *testing.T, err error) {
+ if err == nil || !strings.Contains(err.Error(), "数据库不可用") {
+ t.Errorf("错误 = %v,期望写入曲目表的错误", err)
+ }
+ },
+ },
+ "cover_cannot_be_uploaded": {
+ d: &downloader{meta: track.Meta{Title: "晴天"}, audio: porttest.SilentWAV(time.Second), cover: []byte("jpeg"), coverExt: "jpg"},
+ store: func(context.CancelFunc) *checkedStore {
+ s := storeWithOtherObject(t)
+ s.putErr = func(key string) error {
+ if key == cover {
+ return errors.New("磁盘已满")
+ }
+ return nil
+ }
+ return s
+ },
+ check: func(t *testing.T, err error) {
+ if err == nil || !strings.Contains(err.Error(), "磁盘已满") {
+ t.Errorf("错误 = %v,期望封面上传的错误", err)
+ }
+ },
+ },
+ "cancelled_while_uploading": {
+ d: &downloader{meta: track.Meta{Title: "晴天"}, audio: porttest.SilentWAV(time.Second), cover: []byte("jpeg"), coverExt: "jpg"},
+ store: func(cancel context.CancelFunc) *checkedStore {
+ s := storeWithOtherObject(t)
+ s.putErr = func(key string) error {
+ if key == cover {
+ cancel()
+ return context.Canceled
+ }
+ return nil
+ }
+ return s
+ },
+ check: func(t *testing.T, err error) {
+ if !errors.Is(err, context.Canceled) {
+ t.Errorf("错误 = %v,期望 context.Canceled", err)
+ }
+ },
+ },
+ }
+ for name, tt := range tests {
+ t.Run(name, func(t *testing.T) {
+ ctx, cancel := context.WithCancel(t.Context())
+ defer cancel()
+ store := storeWithOtherObject(t)
+ if tt.store != nil {
+ store = tt.store(cancel)
+ }
+ var tracks port.Tracks = newTracks()
+ if tt.tracks != nil {
+ tracks = tt.tracks()
+ }
+ err := newLinkRunner(tt.d, store, tracks, discard()).Run(ctx, link)
+ tt.check(t, err)
+ if got := store.Keys(); !slices.Equal(got, []string{media.AudioObject("other")}) {
+ t.Errorf("失败之后的对象 = %q,期望已上传的音频和封面都删掉", got)
+ }
+ store.mu.Lock()
+ defer store.mu.Unlock()
+ // 上传之前的清理会删除音频和每种格式的封面各 1 个对象,之后的每次清理都应带有期限。
+ if before := len(coverTypes) + 1; len(store.deadlines) <= before || slices.Contains(store.deadlines[before:], false) {
+ t.Errorf("每次删除时 ctx 是否带期限 = %v,期望上传之后有清理,并且都带着期限", store.deadlines)
+ }
+ })
+ }
+}
+
+func TestLinkRunnerCleanupFailureIsLogged(t *testing.T) {
+ var logs bytes.Buffer
+ store := &checkedStore{MediaStore: porttest.NewMediaStore(), deleteErr: errors.New("连接被拒绝")}
+ d := &downloader{audio: []byte("不是音频")}
+ err := newLinkRunner(d, store, newTracks(), slog.New(slog.NewJSONHandler(&logs, nil))).Run(t.Context(), link)
+ var rejected *port.RejectedError
+ if !errors.As(err, &rejected) {
+ t.Errorf("错误 = %v,期望仍是不能准备", err)
+ }
+ if !strings.Contains(logs.String(), `"level":"WARN"`) || !strings.Contains(logs.String(), "连接被拒绝") || !strings.Contains(logs.String(), link.ID) {
+ t.Errorf("日志 = %s,期望一条带着错误和链接的 WARN", logs.String())
+ }
+}
+
+func TestLinkRunnerUnanalyzableAudioWinsOverUploadFailure(t *testing.T) {
+ store := &checkedStore{MediaStore: porttest.NewMediaStore(), putErr: func(string) error { return errors.New("连接被拒绝") }}
+ d := &downloader{audio: []byte("不是音频")}
+ err := newLinkRunner(d, store, newTracks(), discard()).Run(t.Context(), link)
+ var rejected *port.RejectedError
+ if !errors.As(err, &rejected) {
+ t.Errorf("上传失败且无法分析时 = %v,期望不能准备", err)
+ }
+}
diff --git a/internal/app/prepare/prepare_test.go b/internal/app/prepare/prepare_test.go
new file mode 100644
index 0000000..1be8c6b
--- /dev/null
+++ b/internal/app/prepare/prepare_test.go
@@ -0,0 +1,347 @@
+package prepare
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "sync"
+ "testing"
+ "testing/synctest"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+type tracksTable struct {
+ mu sync.Mutex
+ rows map[track.Track]port.PreparedTrack
+ failures int
+}
+
+func newTracks() *tracksTable { return &tracksTable{rows: map[track.Track]port.PreparedTrack{}} }
+
+func (t *tracksTable) PreparedTrack(_ context.Context, tr track.Track) (port.PreparedTrack, bool, error) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if t.failures > 0 {
+ t.failures--
+ return port.PreparedTrack{}, false, errors.New("数据库不可用")
+ }
+ p, ok := t.rows[tr]
+ return p, ok, nil
+}
+
+func (t *tracksTable) SavePreparedTrack(_ context.Context, tr track.Track, p port.PreparedTrack) error {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ t.rows[tr] = p
+ return nil
+}
+
+func (t *tracksTable) row(tr track.Track) (port.PreparedTrack, bool) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ p, ok := t.rows[tr]
+ return p, ok
+}
+
+type jobs struct {
+ mu sync.Mutex
+ next int64
+ byTrack map[track.Track]int64
+ states map[int64]port.JobState
+ reasons map[int64]string
+ submitted []track.Track
+ failures int
+}
+
+func newJobs() *jobs {
+ return &jobs{byTrack: map[track.Track]int64{}, states: map[int64]port.JobState{}, reasons: map[int64]string{}}
+}
+
+func (j *jobs) Submit(_ context.Context, t track.Track) (int64, error) {
+ j.mu.Lock()
+ defer j.mu.Unlock()
+ if j.failures > 0 {
+ j.failures--
+ return 0, errors.New("数据库不可用")
+ }
+ if id, ok := j.byTrack[t]; ok && j.states[id] == port.JobPending {
+ return id, nil
+ }
+ j.next++
+ j.byTrack[t], j.states[j.next] = j.next, port.JobPending
+ j.submitted = append(j.submitted, t)
+ return j.next, nil
+}
+
+func (j *jobs) Status(_ context.Context, id int64) (port.JobState, string, error) {
+ j.mu.Lock()
+ defer j.mu.Unlock()
+ return j.states[id], j.reasons[id], nil
+}
+
+func (j *jobs) finish(id int64, state port.JobState, reason string) {
+ j.mu.Lock()
+ defer j.mu.Unlock()
+ j.states[id], j.reasons[id] = state, reason
+}
+
+func (j *jobs) count() int {
+ j.mu.Lock()
+ defer j.mu.Unlock()
+ return len(j.submitted)
+}
+
+var (
+ sunny = track.Track{Source: track.NetEase, ID: "186016"}
+ result = port.PreparedTrack{
+ Meta: track.Meta{Title: "晴天", Artist: "周杰伦", Duration: 269500 * time.Millisecond},
+ Result: track.Prepared{Duration: 269500 * time.Millisecond, Gain: 0.75, Lyric: "[00:01.00]故事的小黄花"},
+ }
+)
+
+type prepared struct {
+ meta track.Meta
+ result track.Prepared
+ err error
+}
+
+func start(p *Preparer, ctx context.Context, t track.Track) <-chan prepared {
+ done := make(chan prepared, 1)
+ go func() {
+ m, r, err := p.Prepare(ctx, t)
+ done <- prepared{m, r, err}
+ }()
+ return done
+}
+
+func discard() *slog.Logger { return slog.New(slog.DiscardHandler) }
+
+// synctest 的时钟随核对的定时器不断推进,Prepare 不返回也不会被判定为死锁:必须设期限,否则出错时要等到包超时才失败。
+func await(t *testing.T, done <-chan prepared) prepared {
+ t.Helper()
+ select {
+ case got := <-done:
+ return got
+ case <-time.After(5 * time.Minute):
+ t.Fatal("五分钟之后 Prepare 仍在等待")
+ return prepared{}
+ }
+}
+
+func TestPrepareReusesTracksTable(t *testing.T) {
+ tracks, j := newTracks(), newJobs()
+ tracks.rows[sunny] = result
+ m, r, err := New(tracks, j, discard()).Prepare(t.Context(), sunny)
+ if err != nil || m != result.Meta || r != result.Result {
+ t.Fatalf("曲目表中已有的结果 = %v, %v, %v", m, r, err)
+ }
+ if j.count() != 0 {
+ t.Error("曲目表中已有结果时不应提交任务")
+ }
+}
+
+func TestPrepareWaitsForJob(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ tracks, j := newTracks(), newJobs()
+ p := New(tracks, j, discard())
+ first, second := start(p, t.Context(), sunny), start(p, t.Context(), sunny)
+ synctest.Wait()
+ if j.count() != 1 {
+ t.Fatalf("提交了 %d 个任务,期望同一首歌只有一个", j.count())
+ }
+ tracks.rows[sunny] = result
+ p.Finished(port.PrepareOutcome{Track: sunny, JobID: 1})
+ for _, done := range []<-chan prepared{first, second} {
+ if got := await(t, done); got.err != nil || got.meta != result.Meta || got.result != result.Result {
+ t.Errorf("准备的结果 = %+v", got)
+ }
+ }
+ })
+}
+
+type holdingJobs struct {
+ *jobs
+ release chan struct{}
+}
+
+func (h holdingJobs) Submit(ctx context.Context, t track.Track) (int64, error) {
+ <-h.release
+ return h.jobs.Submit(ctx, t)
+}
+
+func TestPrepareAdoptsLatestOutcome(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ tracks := newTracks()
+ j := holdingJobs{jobs: newJobs(), release: make(chan struct{})}
+ p := New(tracks, j, discard())
+ done := start(p, t.Context(), sunny)
+ synctest.Wait()
+ if err := tracks.SavePreparedTrack(t.Context(), sunny, result); err != nil {
+ t.Fatal(err)
+ }
+ p.Finished(port.PrepareOutcome{Track: sunny, JobID: 1, Reason: "下载音频:HTTP 403"})
+ p.Finished(port.PrepareOutcome{Track: sunny, JobID: 2})
+ close(j.release)
+ if got := await(t, done); got.err != nil || got.result != result.Result {
+ t.Errorf("较早的失败之后到达成功,准备的结果 = %+v,期望采用成功", got)
+ }
+ })
+}
+
+func TestPrepareFails(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ p := New(newTracks(), newJobs(), discard())
+ done := start(p, t.Context(), sunny)
+ synctest.Wait()
+ p.Finished(port.PrepareOutcome{Track: sunny, JobID: 7, Reason: "旧任务失败"})
+ synctest.Wait()
+ select {
+ case got := <-done:
+ t.Fatalf("其他任务的失败结束了等待:%+v", got)
+ default:
+ }
+ p.Finished(port.PrepareOutcome{Track: sunny, JobID: 1, Reason: "网易云没有提供这首歌的播放地址"})
+ if got := await(t, done); got.err == nil || got.err.Error() != "网易云没有提供这首歌的播放地址" {
+ t.Errorf("失败的原因 = %v", got.err)
+ }
+ })
+}
+
+func TestPrepareChecksJobEvery30Seconds(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ tracks, j := newTracks(), newJobs()
+ p := New(tracks, j, discard())
+ begin := time.Now()
+ done := start(p, t.Context(), sunny)
+ synctest.Wait()
+ j.finish(1, port.JobFailed, "下载音频:HTTP 403")
+ if got := await(t, done); got.err == nil || got.err.Error() != "下载音频:HTTP 403" || time.Since(begin) != 30*time.Second {
+ t.Errorf("通知丢失时 %v 后的结果 = %v,期望 30 秒后按任务的状态失败", time.Since(begin), got.err)
+ }
+
+ begin = time.Now()
+ done = start(p, t.Context(), sunny)
+ synctest.Wait()
+ j.finish(2, port.JobFinished, "")
+ time.Sleep(30 * time.Second)
+ synctest.Wait()
+ if j.count() != 3 {
+ t.Fatalf("提交了 %d 个任务,期望任务完成却没有结果时再提交一次", j.count())
+ }
+ if err := tracks.SavePreparedTrack(t.Context(), sunny, result); err != nil {
+ t.Fatal(err)
+ }
+ j.finish(3, port.JobFinished, "")
+ if got := await(t, done); got.err != nil || got.result != result.Result || time.Since(begin) != 60*time.Second {
+ t.Errorf("%v 后的结果 = %+v", time.Since(begin), got)
+ }
+ })
+}
+
+func TestPrepareFindsResultOfStuckJob(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ tracks, j := newTracks(), newJobs()
+ p := New(tracks, j, discard())
+ begin := time.Now()
+ done := start(p, t.Context(), sunny)
+ synctest.Wait()
+ if err := tracks.SavePreparedTrack(t.Context(), sunny, result); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case got := <-done:
+ if got.err != nil || got.result != result.Result || time.Since(begin) != 30*time.Second {
+ t.Errorf("%v 后的结果 = %+v,期望 30 秒后取得曲目表中的结果", time.Since(begin), got)
+ }
+ case <-time.After(time.Minute):
+ t.Fatal("任务停在 running、结果已经在曲目表中,一分钟之后仍在等待")
+ }
+ if j.count() != 1 {
+ t.Errorf("提交了 %d 个任务,期望只有最初的一个", j.count())
+ }
+ })
+}
+
+type finishingTracks struct {
+ *tracksTable
+ t *testing.T
+ jobs *jobs
+ finishOn int
+ reads int
+}
+
+func (f *finishingTracks) PreparedTrack(ctx context.Context, tr track.Track) (port.PreparedTrack, bool, error) {
+ p, ok, err := f.tracksTable.PreparedTrack(ctx, tr)
+ if f.reads++; f.reads == f.finishOn {
+ if err := f.SavePreparedTrack(ctx, tr, result); err != nil {
+ f.t.Error(err)
+ }
+ f.jobs.finish(1, port.JobFinished, "")
+ }
+ return p, ok, err
+}
+
+func TestPrepareDoesNotResubmitJobFinishedDuringCheck(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ j := newJobs()
+ tracks := &finishingTracks{tracksTable: newTracks(), t: t, jobs: j, finishOn: 2}
+ begin := time.Now()
+ done := start(New(tracks, j, discard()), t.Context(), sunny)
+ if got := await(t, done); got.err != nil || got.result != result.Result || time.Since(begin) != 30*time.Second {
+ t.Errorf("%v 后的结果 = %+v,期望 30 秒后取得刚写入的结果", time.Since(begin), got)
+ }
+ if j.count() != 1 {
+ t.Errorf("提交了 %d 个任务,期望只有最初的一个", j.count())
+ }
+ })
+}
+
+func TestPrepareRetriesStoreFailures(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ tracks, j := newTracks(), newJobs()
+ tracks.failures, j.failures = 1, 1
+ p := New(tracks, j, discard())
+ done := start(p, t.Context(), sunny)
+ time.Sleep(30 * time.Second)
+ synctest.Wait()
+ if j.count() != 0 {
+ t.Fatal("提交任务失败时不应有任务")
+ }
+ time.Sleep(30 * time.Second)
+ synctest.Wait()
+ if j.count() != 1 {
+ t.Fatalf("读写失败之后的第二次核对提交了 %d 个任务,期望 1 个", j.count())
+ }
+ tracks.rows[sunny] = result
+ p.Finished(port.PrepareOutcome{Track: sunny, JobID: 1})
+ if got := await(t, done); got.err != nil {
+ t.Errorf("读写失败重试之后的结果 = %v", got.err)
+ }
+ })
+}
+
+func TestPrepareStopsWithContext(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ p := New(newTracks(), newJobs(), discard())
+ ctx, cancel := context.WithCancel(t.Context())
+ done := start(p, ctx, sunny)
+ synctest.Wait()
+ cancel()
+ select {
+ case got := <-done:
+ if !errors.Is(got.err, context.Canceled) {
+ t.Errorf("ctx 结束后的错误 = %v", got.err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("ctx 结束一秒之后 Prepare 仍在等待")
+ }
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if len(p.waiting) != 0 {
+ t.Error("放弃等待之后不应再登记这首歌")
+ }
+ })
+}
diff --git a/internal/app/prepare/run_test.go b/internal/app/prepare/run_test.go
new file mode 100644
index 0000000..2ed6a75
--- /dev/null
+++ b/internal/app/prepare/run_test.go
@@ -0,0 +1,159 @@
+package prepare
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "log/slog"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/app/port/porttest"
+ "github.com/dihemoe/alisten/internal/domain/track"
+ "github.com/dihemoe/alisten/internal/lib/loudness"
+)
+
+var meta = track.Meta{Title: "晴天", Artist: "周杰伦", PageURL: "https://music.163.com/#/song?id=186016", Duration: 269 * time.Second}
+
+func run(t *testing.T, source port.Source, tr track.Track) (*tracksTable, error) {
+ t.Helper()
+ tracks, _, err := runLogged(t, source, tr)
+ return tracks, err
+}
+
+func runLogged(t *testing.T, source port.Source, tr track.Track) (*tracksTable, *bytes.Buffer, error) {
+ t.Helper()
+ tracks, logs := newTracks(), &bytes.Buffer{}
+ err := NewRunner(map[track.Source]port.Source{track.NetEase: source}, tracks, slog.New(slog.NewJSONHandler(logs, nil))).Run(t.Context(), tr)
+ return tracks, logs, err
+}
+
+// 128 kbps、44.1 kHz 的静音帧:417 字节,解码出 1152 个采样帧。
+var silentFrame = append([]byte{0xFF, 0xFB, 0x90, 0x00}, make([]byte, 413)...)
+
+var truncatedMP3 = append(bytes.Repeat(silentFrame, 3), silentFrame[:200]...)
+
+func truncatedMP3Error(t *testing.T) error {
+ t.Helper()
+ _, err := loudness.AnalyzeReader(bytes.NewReader(truncatedMP3))
+ if err == nil {
+ t.Fatal("被截断的 MP3 应该无法解码")
+ }
+ return err
+}
+
+func checkDecodeWarning(t *testing.T, logs *bytes.Buffer, tr track.Track, decodeErr error) {
+ t.Helper()
+ var warnings []map[string]any
+ for dec := json.NewDecoder(bytes.NewReader(logs.Bytes())); dec.More(); {
+ var entry map[string]any
+ if err := dec.Decode(&entry); err != nil {
+ t.Fatal(err)
+ }
+ if entry["level"] == "WARN" {
+ warnings = append(warnings, entry)
+ }
+ }
+ if len(warnings) != 1 {
+ t.Fatalf("日志中有 %d 条 WARN,期望 1 条:\n%s", len(warnings), logs)
+ }
+ want := map[string]any{"source": string(tr.Source), "track_id": tr.ID, "error": decodeErr.Error()}
+ for key, value := range want {
+ if warnings[0][key] != value {
+ t.Errorf("WARN 的 %s = %v,期望 %v", key, warnings[0][key], value)
+ }
+ }
+}
+
+func TestRunFallsBackWhenAudioCannotBeAnalyzed(t *testing.T) {
+ source := porttest.NewSource(track.NetEase)
+ source.Add("186016", meta, "", []byte("不是音频"))
+ tracks, err := run(t, source, sunny)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, _ := tracks.row(sunny); got.Result != (track.Prepared{Duration: meta.Duration, Gain: 1}) {
+ t.Errorf("无法解码时的结果 = %+v,期望增益为 1、时长取元数据中的时长", got.Result)
+ }
+
+ source = porttest.NewSource(track.NetEase)
+ source.Add("186016", track.Meta{Title: "晴天"}, "", []byte("不是音频"))
+ var rejected *port.RejectedError
+ if _, err := run(t, source, sunny); !errors.As(err, &rejected) {
+ t.Errorf("元数据中也没有时长时的错误 = %v,期望不能准备", err)
+ }
+}
+
+func TestRunLogsAudioThatCannotBeDecoded(t *testing.T) {
+ decodeErr := truncatedMP3Error(t)
+ sourceWith := func(m track.Meta, audio []byte) port.Source {
+ source := porttest.NewSource(track.NetEase)
+ source.Add("186016", m, "", audio)
+ return source
+ }
+
+ t.Run("回退", func(t *testing.T) {
+ _, logs, err := runLogged(t, sourceWith(meta, truncatedMP3), sunny)
+ if err != nil {
+ t.Fatal(err)
+ }
+ checkDecodeWarning(t, logs, sunny, decodeErr)
+ })
+ t.Run("元数据中也没有时长", func(t *testing.T) {
+ _, _, err := runLogged(t, sourceWith(track.Meta{Title: "晴天"}, truncatedMP3), sunny)
+ var rejected *port.RejectedError
+ if !errors.As(err, &rejected) || err.Error() != "这首歌的音频无法播放" {
+ t.Errorf("错误 = %v,期望不能准备,原因带着解码的错误 %q", err, decodeErr)
+ }
+ })
+ t.Run("下载中断", func(t *testing.T) {
+ if _, logs, _ := runLogged(t, brokenSource{}, sunny); logs.Len() != 0 {
+ t.Errorf("日志 = %q,期望没有日志:这不是音频无法解码", logs)
+ }
+ })
+}
+
+type brokenSource struct{ port.Source }
+
+type brokenAudio struct{ io.Reader }
+
+func (brokenAudio) Close() error { return nil }
+
+func (brokenSource) Resolve(context.Context, string) (port.Resolved, error) {
+ audio := io.MultiReader(strings.NewReader("RIFF"), failingReader{})
+ return port.Resolved{Meta: meta, Audio: brokenAudio{audio}}, nil
+}
+
+type failingReader struct{}
+
+func (failingReader) Read([]byte) (int, error) { return 0, errors.New("连接被重置") }
+
+func TestRunRetriesInterruptedDownload(t *testing.T) {
+ tracks, err := run(t, brokenSource{}, sunny)
+ var rejected *port.RejectedError
+ if err == nil || errors.As(err, &rejected) {
+ t.Errorf("下载中断时的错误 = %v,期望可以重试的错误", err)
+ }
+ if _, ok := tracks.row(sunny); ok {
+ t.Error("下载中断时不应写入曲目表")
+ }
+}
+
+func TestRunRejects(t *testing.T) {
+ source := porttest.NewSource(track.NetEase)
+ source.Add("186016", meta, "", porttest.SilentWAV(time.Second))
+ source.Reject("186016", "网易云没有提供这首歌的播放地址")
+ var rejected *port.RejectedError
+ if _, err := run(t, source, sunny); !errors.As(err, &rejected) || err.Error() != "网易云没有提供这首歌的播放地址" {
+ t.Errorf("音源拒绝时的错误 = %v", err)
+ }
+ for _, tr := range []track.Track{{Source: track.Link, ID: "https://www.youtube.com/watch?v=x"}, {Source: track.NetEase}} {
+ if _, err := run(t, source, tr); !errors.As(err, &rejected) {
+ t.Errorf("任务参数 %v 的错误 = %v,期望不能准备", tr, err)
+ }
+ }
+}
diff --git a/internal/app/realtime/cluster_test.go b/internal/app/realtime/cluster_test.go
new file mode 100644
index 0000000..a011402
--- /dev/null
+++ b/internal/app/realtime/cluster_test.go
@@ -0,0 +1,805 @@
+package realtime
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "slices"
+ "sync/atomic"
+ "testing"
+ "testing/synctest"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/app/port/porttest"
+ "github.com/dihemoe/alisten/internal/domain/member"
+ "github.com/dihemoe/alisten/internal/domain/room"
+)
+
+type clusterFixture struct {
+ store *porttest.Store
+ redis *porttest.Redis
+ a, b *instance
+ room string
+}
+
+func newClusterFixture(t *testing.T, snapshotsOfA port.RoomSnapshots, store *porttest.Store) *clusterFixture {
+ t.Helper()
+ songs := newSongs()
+ songs.add(sunny, sunnyM, sunnyP)
+ redis := porttest.NewRedis()
+ f := &clusterFixture{store: store, redis: redis}
+ f.a = startInstance(t, redis, "a", snapshotsOfA, store, songs)
+ f.b = startInstance(t, redis, "b", store, store, songs)
+ synctest.Wait()
+ f.room = createRoom(t, store, room.Persistent)
+ if _, err := f.a.gateway.Do(t.Context(), f.room, alice, room.Sync{}); err != nil {
+ t.Fatal(err)
+ }
+ if owner := f.a.owner(t, f.room); owner != "a" {
+ t.Fatalf("房间的 owner 是 %q,期望 a", owner)
+ }
+ return f
+}
+
+func TestCommandsReachOwnerOnAnotherInstance(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newClusterFixture(t, store, store)
+
+ onB := f.b.join(t, f.room, bob)
+ checkReceived(t, onB, "hello bob 1/1 [bob]")
+ onA := f.a.join(t, f.room, alice)
+ checkReceived(t, onA, "hello alice 1/2 [bob alice]")
+ checkReceived(t, onB, "1/2 room.MembersChanged")
+ if n := f.b.online(t, f.room); n != 2 {
+ t.Errorf("在线人数 = %d,期望 2", n)
+ }
+
+ onB.Begin()
+ if err := f.b.gateway.Command(t.Context(), onB, "c1", room.Enqueue{Track: sunny, Meta: sunnyM}); err != nil {
+ t.Fatal(err)
+ }
+ checkReceived(t, onB,
+ "1/3 room.Activity",
+ "1/4 room.QueueChanged",
+ "reply c1 room.EnqueueResult",
+ "1/5 room.QueueChanged",
+ "1/6 room.PlaybackChanged",
+ )
+ checkReceived(t, onA, "1/3 room.Activity", "1/4 room.QueueChanged", "1/5 room.QueueChanged", "1/6 room.PlaybackChanged")
+ if owner := f.b.owner(t, f.room); owner != "a" {
+ t.Errorf("房间的 owner 是 %q,期望一直是 a", owner)
+ }
+ if n := f.b.host.RoomsOwned(); n != 0 {
+ t.Errorf("b 的 RoomsOwned = %d,期望 0", n)
+ }
+ time.Sleep(2 * aliveTTL)
+ res, err := f.a.gateway.Do(t.Context(), f.room, onA.Member(), room.Sync{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if names := sessionNames(res.(room.SyncResult).View); !slices.Equal(names, []string{"bob", "alice"}) {
+ t.Errorf("30 秒之后的在线列表 = %q,期望两个会话都在", names)
+ }
+ })
+}
+
+func TestOwnerOutOfReachIsTakenOver(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newClusterFixture(t, store, store)
+ onB := f.b.join(t, f.room, bob)
+ received(t, onB)
+
+ f.a.node.SetDown(true)
+ start := time.Now()
+ synctest.Wait()
+ if _, err := f.b.gateway.Do(t.Context(), f.room, bob, room.Sync{}); !errors.Is(err, ErrUnavailable) {
+ t.Errorf("owner 不可达时的错误 = %v,期望 ErrUnavailable", err)
+ }
+ if time.Since(start) != 0 {
+ t.Error("owner 没有订阅它的频道时应立即失败")
+ }
+ for f.b.owner(t, f.room) != "b" {
+ if time.Since(start) > leaseTTL+reconcileInterval+reconcileJitter {
+ t.Fatalf("%v 之后 b 仍没有接管房间", time.Since(start))
+ }
+ time.Sleep(100 * time.Millisecond)
+ synctest.Wait()
+ }
+ if elapsed := time.Since(start); elapsed < leaseTTL-renewInterval {
+ t.Errorf("%v 之后就接管了房间,租约还没有过期", elapsed)
+ }
+ if got := received(t, onB); !slices.Contains(got, "2/1 room.MembersChanged") {
+ t.Errorf("b 的会话收到 %q,期望新任期的在线列表", got)
+ }
+ res, err := f.b.gateway.Do(t.Context(), f.room, bob, room.Sync{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if view := res.(room.SyncResult).View; view.Term != 2 || len(view.Sessions) != 1 {
+ t.Errorf("接管之后的快照:任期 %d,在线 %d,期望任期 2、只有 b 的会话", view.Term, len(view.Sessions))
+ }
+ f.a.node.SetDown(false)
+ })
+}
+
+func TestOwnerSubscriptionLossHandsOver(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newClusterFixture(t, store, store)
+ onA := f.a.join(t, f.room, alice)
+ onB := f.b.join(t, f.room, bob)
+ received(t, onA)
+ received(t, onB)
+
+ f.a.node.Disconnect()
+ synctest.Wait()
+ checkReceived(t, onA, fmt.Sprintf("closed %d", CloseRestart))
+ if owner := f.b.owner(t, f.room); owner != "b" {
+ t.Errorf("a 的订阅断开之后 owner 是 %q,期望 b", owner)
+ }
+ got := received(t, onB)
+ if !slices.Contains(got, "2/1 room.MembersChanged") {
+ t.Errorf("b 的会话收到 %q,期望新任期的在线列表", got)
+ }
+ other := createRoom(t, store, room.Persistent)
+ if _, err := f.a.gateway.Do(t.Context(), other, onA.Member(), room.Sync{}); err != nil {
+ t.Errorf("a 重新订阅之后接管新房间失败:%v", err)
+ }
+ })
+}
+
+type flakyBus struct {
+ port.Bus
+ failing atomic.Bool
+}
+
+func (b *flakyBus) SubscribeInstance(ctx context.Context) error {
+ if b.failing.Load() {
+ return porttest.ErrRedisDown
+ }
+ return b.Bus.SubscribeInstance(ctx)
+}
+
+func newFlakyBusInstance(t *testing.T, store *porttest.Store, logger *slog.Logger) (*Host, *Gateway, *porttest.Node, *flakyBus) {
+ t.Helper()
+ songs := newSongs()
+ node := porttest.NewRedis().Node("a")
+ bus := &flakyBus{Bus: node}
+ h, g := New(store, store, songs.music, songs.preparer, Cluster{Instance: "a", Leases: node, Liveness: node, Bus: bus}, logger)
+ return h, g, node, bus
+}
+
+// 本实例的频道订阅生效之前不接管房间:此时收不到其他实例转发来的输入,接管之后房间的回复也可能漏掉。
+func TestNoTakeoverBeforeInstanceChannelIsSubscribed(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ h, g, node, bus := newFlakyBusInstance(t, store, slog.New(slog.DiscardHandler))
+ bus.failing.Store(true)
+ ctx, stop := context.WithCancel(context.Background())
+ var wait func()
+ var startErr error
+ started := make(chan struct{})
+ go func() {
+ defer close(started)
+ wait, startErr = Start(ctx, h, g)
+ }()
+ t.Cleanup(func() {
+ _ = h.Shutdown(context.Background())
+ _ = g.Shutdown(context.Background())
+ stop()
+ <-started
+ if wait != nil {
+ wait()
+ }
+ })
+ id := createRoom(t, store, room.Persistent)
+ synctest.Wait()
+
+ if _, err := g.Do(t.Context(), id, alice, room.Sync{}); !errors.Is(err, ErrUnavailable) {
+ t.Errorf("订阅之前接管房间的请求 = %v,期望 ErrUnavailable", err)
+ }
+ owners, err := node.Owners(t.Context(), []string{id})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if term := store.Term(id); owners[0] != "" || term != 0 {
+ t.Errorf("订阅之前房间的 owner = %q、任期 = %d,期望没有接管", owners[0], term)
+ }
+
+ bus.failing.Store(false)
+ time.Sleep(retryInterval)
+ <-started
+ if startErr != nil {
+ t.Fatal(startErr)
+ }
+ if _, err := g.Do(t.Context(), id, alice, room.Sync{}); err != nil {
+ t.Errorf("订阅生效之后的请求失败:%v", err)
+ }
+ })
+}
+
+func TestNoTakeoverAfterLossUntilInstanceChannelIsSubscribedAgain(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ h, g, node, bus := newFlakyBusInstance(t, store, slog.New(slog.DiscardHandler))
+ ctx, stop := context.WithCancel(context.Background())
+ wait, err := Start(ctx, h, g)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ _ = h.Shutdown(context.Background())
+ _ = g.Shutdown(context.Background())
+ stop()
+ wait()
+ })
+ synctest.Wait()
+ held, fresh := createRoom(t, store, room.Persistent), createRoom(t, store, room.Persistent)
+ if _, err := g.Do(t.Context(), held, alice, room.Sync{}); err != nil {
+ t.Fatal(err)
+ }
+
+ bus.failing.Store(true)
+ node.Disconnect()
+ synctest.Wait()
+ for range 3 {
+ for _, id := range []string{held, fresh} {
+ if _, err := g.Do(t.Context(), id, alice, room.Sync{}); !errors.Is(err, ErrUnavailable) {
+ t.Errorf("重新订阅之前接管房间的请求 = %v,期望 ErrUnavailable", err)
+ }
+ }
+ time.Sleep(retryInterval)
+ }
+ if n, termHeld, termFresh := h.RoomsOwned(), store.Term(held), store.Term(fresh); n != 0 || termHeld != 1 || termFresh != 0 {
+ t.Errorf("重新订阅之前运行 %d 个房间,任期 %d 和 %d,期望释放了全部房间、没有再接管", n, termHeld, termFresh)
+ }
+
+ bus.failing.Store(false)
+ time.Sleep(retryInterval)
+ synctest.Wait()
+ res, err := g.Do(t.Context(), held, alice, room.Sync{})
+ if err != nil {
+ t.Fatalf("重新订阅之后的请求失败:%v", err)
+ }
+ if view := res.(room.SyncResult).View; view.Term != 2 {
+ t.Errorf("重新订阅之后接管房间的任期 = %d,期望 2", view.Term)
+ }
+ })
+}
+
+// 释放全部房间、断开全部会话会影响整个实例,所以必须记录日志。
+func TestSubscriptionLossIsLogged(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ var logs logBuffer
+ h, g, node, bus := newFlakyBusInstance(t, store, slog.New(slog.NewJSONHandler(&logs, nil)))
+ ctx, stop := context.WithCancel(context.Background())
+ wait, err := Start(ctx, h, g)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ _ = h.Shutdown(context.Background())
+ _ = g.Shutdown(context.Background())
+ stop()
+ wait()
+ })
+ synctest.Wait()
+
+ bus.failing.Store(true)
+ node.Disconnect()
+ synctest.Wait()
+ lost := logs.entries(t, "订阅连接断开过,释放全部房间并断开会话")
+ if len(lost) != 1 || lost[0]["level"] != "WARN" {
+ t.Errorf("订阅断开的日志 = %v,期望一条 WARN", lost)
+ }
+ if back := logs.entries(t, "订阅已恢复,重新开始接管房间"); len(back) != 0 {
+ t.Errorf("还没有重新订阅就记了恢复的日志:%v", back)
+ }
+
+ bus.failing.Store(false)
+ time.Sleep(retryInterval)
+ synctest.Wait()
+ back := logs.entries(t, "订阅已恢复,重新开始接管房间")
+ if len(back) != 1 || back[0]["level"] != "INFO" {
+ t.Errorf("重新订阅之后的日志 = %v,期望一条 INFO", back)
+ }
+ })
+}
+
+func TestSessionsOfStoppedGatewayLeave(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newClusterFixture(t, store, store)
+ onA := f.a.join(t, f.room, alice)
+ f.b.join(t, f.room, bob)
+ received(t, onA)
+
+ f.b.node.SetDown(true)
+ time.Sleep(aliveTTL + reconcileInterval + reconcileJitter)
+ synctest.Wait()
+ res, err := f.a.gateway.Do(t.Context(), f.room, onA.Member(), room.Sync{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if sessions := res.(room.SyncResult).View.Sessions; len(sessions) != 1 || sessions[0].Member.ID != onA.Member().ID {
+ t.Errorf("在线列表 = %+v,期望只剩 a 的会话", sessions)
+ }
+ f.b.node.SetDown(false)
+ })
+}
+
+func TestMemberUpdateReachesOtherInstances(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newClusterFixture(t, store, store)
+ onA := f.a.join(t, f.room, alice)
+ onB := f.b.join(t, f.room, bob)
+ received(t, onA)
+ received(t, onB)
+
+ renamed := member.Member{ID: onB.Member().ID, Name: "乙"}
+ if err := f.a.gateway.MemberUpdated(t.Context(), renamed); err != nil {
+ t.Fatal(err)
+ }
+ synctest.Wait()
+ if onB.Member() != renamed {
+ t.Errorf("b 上会话记录的资料 = %+v,期望 %+v", onB.Member(), renamed)
+ }
+ checkReceived(t, onA, "1/3 room.MembersChanged")
+ res, err := f.a.gateway.Do(t.Context(), f.room, onA.Member(), room.Sync{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if names := sessionNames(res.(room.SyncResult).View); !slices.Equal(names, []string{"alice", "乙"}) {
+ t.Errorf("在线列表 = %q,期望新的昵称", names)
+ }
+ })
+}
+
+type flakyLiveness struct {
+ port.Liveness
+ fail atomic.Bool
+}
+
+func (l *flakyLiveness) Heartbeat(ctx context.Context, ttl time.Duration) (bool, error) {
+ if l.fail.Load() {
+ return false, porttest.ErrRedisDown
+ }
+ return l.Liveness.Heartbeat(ctx, ttl)
+}
+
+func TestGatewayRejoinsAfterItsAliveKeyExpired(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ songs := newSongs()
+ redis := porttest.NewRedis()
+ a := startInstance(t, redis, "a", store, store, songs)
+ node := redis.Node("b")
+ liveness := &flakyLiveness{Liveness: node}
+ h, g := New(store, store, songs.music, songs.preparer, Cluster{Instance: "b", Leases: node, Liveness: liveness, Bus: node}, slog.New(slog.DiscardHandler))
+ ctx, stop := context.WithCancel(context.Background())
+ wait, err := Start(ctx, h, g)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ _ = h.Shutdown(context.Background())
+ _ = g.Shutdown(context.Background())
+ stop()
+ wait()
+ })
+ synctest.Wait()
+ id := createRoom(t, store, room.Persistent)
+ if _, err := a.gateway.Do(t.Context(), id, alice, room.Sync{}); err != nil {
+ t.Fatal(err)
+ }
+ s, err := g.Join(t.Context(), id, bob)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { g.Leave(s) })
+ synctest.Wait()
+
+ online := func() []string {
+ res, err := a.gateway.Do(t.Context(), id, alice, room.Sync{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return sessionNames(res.(room.SyncResult).View)
+ }
+ liveness.fail.Store(true)
+ time.Sleep(aliveTTL + reconcileInterval + reconcileJitter)
+ synctest.Wait()
+ if names := online(); len(names) != 0 {
+ t.Errorf("b 的存活键过期之后在线列表 = %q,期望为空", names)
+ }
+ liveness.fail.Store(false)
+ time.Sleep(heartbeatInterval)
+ synctest.Wait()
+ if names := online(); !slices.Equal(names, []string{"bob"}) {
+ t.Errorf("b 恢复刷新之后在线列表 = %q,期望 b 的会话重新加入", names)
+ }
+ })
+}
+
+type flakyOwners struct {
+ port.Leases
+ failing atomic.Bool
+}
+
+func (l *flakyOwners) Owners(ctx context.Context, roomIDs []string) ([]string, error) {
+ if l.failing.Load() {
+ return nil, porttest.ErrRedisDown
+ }
+ return l.Leases.Owners(ctx, roomIDs)
+}
+
+type flakyForwards struct {
+ port.Bus
+ failing atomic.Bool
+ calls atomic.Int32
+}
+
+func (b *flakyForwards) SendInput(ctx context.Context, instanceID string, in port.RoomInput) (bool, error) {
+ b.calls.Add(1)
+ if b.failing.Load() {
+ return false, porttest.ErrRedisDown
+ }
+ return b.Bus.SendInput(ctx, instanceID, in)
+}
+
+func startFlakyInstance(t *testing.T, redis *porttest.Redis, id string, snapshots port.RoomSnapshots, store *porttest.Store, songs *songs) (*instance, *flakyOwners, *flakyForwards) {
+ t.Helper()
+ node := redis.Node(id)
+ leases, bus := &flakyOwners{Leases: node}, &flakyForwards{Bus: node}
+ h, g := New(snapshots, store, songs.music, songs.preparer, Cluster{Instance: id, Leases: leases, Liveness: node, Bus: bus}, slog.New(slog.DiscardHandler))
+ ctx, stop := context.WithCancel(context.Background())
+ wait, err := Start(ctx, h, g)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ _ = h.Shutdown(context.Background())
+ _ = g.Shutdown(context.Background())
+ stop()
+ wait()
+ })
+ return &instance{id: id, node: node, host: h, gateway: g}, leases, bus
+}
+
+func TestLostLeaveIsRetried(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ songs := newSongs()
+ redis := porttest.NewRedis()
+ a := startInstance(t, redis, "a", store, store, songs)
+ b, owners, _ := startFlakyInstance(t, redis, "b", store, store, songs)
+ synctest.Wait()
+ id := createRoom(t, store, room.Ephemeral)
+ if _, err := a.gateway.Do(t.Context(), id, alice, room.Sync{}); err != nil {
+ t.Fatal(err)
+ }
+ s := b.join(t, id, bob)
+ if n := a.online(t, id); n != 1 {
+ t.Fatalf("加入之后在线人数 = %d,期望 1", n)
+ }
+
+ owners.failing.Store(true)
+ b.gateway.Leave(s)
+ time.Sleep(3 * time.Second)
+ owners.failing.Store(false)
+ time.Sleep(time.Minute)
+ synctest.Wait()
+ if n := a.online(t, id); n != 0 {
+ t.Errorf("Leave 失败、Redis 恢复一分钟之后在线人数 = %d,期望会话已经离开", n)
+ }
+ time.Sleep(5 * time.Minute)
+ synctest.Wait()
+ if _, err := a.gateway.Do(t.Context(), id, alice, room.Sync{}); !errors.Is(err, port.ErrRoomNotFound) {
+ t.Errorf("无人的临时房间过了 5 分钟之后的请求 = %v,期望房间已经关闭(ErrRoomNotFound)", err)
+ }
+ })
+}
+
+func TestFailedRejoinIsRetried(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := &failingStore{Store: porttest.New()}
+ songs := newSongs()
+ redis := porttest.NewRedis()
+ a := startInstance(t, redis, "a", store, store.Store, songs)
+ b, owners, _ := startFlakyInstance(t, redis, "b", store.Store, store.Store, songs)
+ synctest.Wait()
+ id := createRoom(t, store.Store, room.Persistent)
+ a.join(t, id, alice)
+ b.join(t, id, bob)
+
+ owners.failing.Store(true)
+ store.fail.Store(true)
+ _, _ = a.gateway.Do(t.Context(), id, alice, room.SetPlayMode{Mode: room.Random})
+ store.fail.Store(false)
+ synctest.Wait()
+ if _, err := a.gateway.Do(t.Context(), id, alice, room.Sync{}); err != nil {
+ t.Fatal(err)
+ }
+ synctest.Wait()
+ time.Sleep(3 * time.Second)
+ owners.failing.Store(false)
+ time.Sleep(time.Minute)
+ synctest.Wait()
+
+ res, err := a.gateway.Do(t.Context(), id, alice, room.Sync{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ view := res.(room.SyncResult).View
+ if names := sessionNames(view); view.Term != 2 || !slices.Equal(names, []string{"alice", "bob"}) {
+ t.Errorf("Redis 恢复一分钟之后:任期 %d,在线 %q,期望任期 2、两个会话都在线", view.Term, names)
+ }
+ })
+}
+
+func TestRetriedJoinIsDroppedWhenSessionEnds(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := &failingStore{Store: porttest.New()}
+ songs := newSongs()
+ redis := porttest.NewRedis()
+ a := startInstance(t, redis, "a", store, store.Store, songs)
+ b, owners, _ := startFlakyInstance(t, redis, "b", store.Store, store.Store, songs)
+ synctest.Wait()
+ id := createRoom(t, store.Store, room.Persistent)
+ onA := a.join(t, id, alice)
+ onB := b.join(t, id, bob)
+ received(t, onA)
+ received(t, onB)
+
+ owners.failing.Store(true)
+ store.fail.Store(true)
+ _, _ = a.gateway.Do(t.Context(), id, alice, room.SetPlayMode{Mode: room.Random})
+ store.fail.Store(false)
+ synctest.Wait()
+ if _, err := a.gateway.Do(t.Context(), id, alice, room.Sync{}); err != nil {
+ t.Fatal(err)
+ }
+ synctest.Wait()
+ b.gateway.Leave(onB)
+ time.Sleep(3 * time.Second)
+ owners.failing.Store(false)
+ time.Sleep(time.Minute)
+ synctest.Wait()
+
+ checkReceived(t, onA, "2/1 room.MembersChanged")
+ })
+}
+
+func TestFailedMemberUpdateIsRetried(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ songs := newSongs()
+ redis := porttest.NewRedis()
+ a := startInstance(t, redis, "a", store, store, songs)
+ b, owners, _ := startFlakyInstance(t, redis, "b", store, store, songs)
+ synctest.Wait()
+ id := createRoom(t, store, room.Persistent)
+ b.join(t, id, bob)
+
+ owners.failing.Store(true)
+ if err := a.gateway.MemberUpdated(t.Context(), member.Member{ID: bob.ID, Name: "乙"}); err != nil {
+ t.Fatal(err)
+ }
+ synctest.Wait()
+ time.Sleep(3 * time.Second)
+ owners.failing.Store(false)
+ time.Sleep(time.Minute)
+ synctest.Wait()
+
+ res, err := a.gateway.Do(t.Context(), id, alice, room.Sync{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if names := sessionNames(res.(room.SyncResult).View); !slices.Equal(names, []string{"乙"}) {
+ t.Errorf("Redis 恢复一分钟之后在线列表 = %q,期望新的昵称", names)
+ }
+ })
+}
+
+func TestMemberUpdateRetryCarriesLatestProfile(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ songs := newSongs()
+ redis := porttest.NewRedis()
+ a := startInstance(t, redis, "a", store, store, songs)
+ b, owners, _ := startFlakyInstance(t, redis, "b", store, store, songs)
+ synctest.Wait()
+ id := createRoom(t, store, room.Persistent)
+ b.join(t, id, bob)
+
+ owners.failing.Store(true)
+ if err := a.gateway.MemberUpdated(t.Context(), member.Member{ID: bob.ID, Name: "乙"}); err != nil {
+ t.Fatal(err)
+ }
+ synctest.Wait()
+ owners.failing.Store(false)
+ if err := a.gateway.MemberUpdated(t.Context(), member.Member{ID: bob.ID, Name: "丙"}); err != nil {
+ t.Fatal(err)
+ }
+ time.Sleep(time.Minute)
+ synctest.Wait()
+
+ res, err := a.gateway.Do(t.Context(), id, alice, room.Sync{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if names := sessionNames(res.(room.SyncResult).View); !slices.Equal(names, []string{"丙"}) {
+ t.Errorf("在线列表 = %q,期望最新的昵称", names)
+ }
+ })
+}
+
+func TestPresenceRetriesStopAtShutdown(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ songs := newSongs()
+ redis := porttest.NewRedis()
+ a := startInstance(t, redis, "a", store, store, songs)
+ b, _, forwards := startFlakyInstance(t, redis, "b", store, store, songs)
+ synctest.Wait()
+ id := createRoom(t, store, room.Persistent)
+ if _, err := a.gateway.Do(t.Context(), id, alice, room.Sync{}); err != nil {
+ t.Fatal(err)
+ }
+ s := b.join(t, id, bob)
+
+ forwards.failing.Store(true)
+ forwards.calls.Store(0)
+ b.gateway.Leave(s)
+ time.Sleep(time.Minute)
+ synctest.Wait()
+ before := forwards.calls.Load()
+ if before < 3 {
+ t.Errorf("Leave 发不出去之后的一分钟内尝试了 %d 次,期望在后台不断重试", before)
+ }
+ if err := b.gateway.Shutdown(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ time.Sleep(time.Minute)
+ synctest.Wait()
+ if after := forwards.calls.Load(); after != before {
+ t.Errorf("网关停止之后又尝试了 %d 次,期望不再重试", after-before)
+ }
+ // 如果还有重试的 goroutine,让它发送成功后退出,以免 bubble 无法结束。
+ forwards.failing.Store(false)
+ })
+}
+
+func sessionNames(v room.View) []string {
+ names := make([]string, len(v.Sessions))
+ for i, s := range v.Sessions {
+ names[i] = s.Member.Name
+ }
+ return names
+}
+
+func TestRevokeAndDeleteReachOtherInstances(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newClusterFixture(t, store, store)
+ deleted := createRoom(t, store, room.Persistent)
+ revoked := f.b.join(t, f.room, bob)
+ gone := f.b.join(t, deleted, alice)
+ received(t, revoked)
+ received(t, gone)
+
+ if err := f.a.gateway.Revoke(t.Context(), f.room); err != nil {
+ t.Fatal(err)
+ }
+ checkReceived(t, revoked, fmt.Sprintf("closed %d", CloseRevoked))
+ if err := f.a.gateway.Delete(t.Context(), deleted); err != nil {
+ t.Fatal(err)
+ }
+ checkReceived(t, gone, fmt.Sprintf("closed %d", CloseRoomGone))
+ })
+}
+
+type staleLeases struct {
+ port.Leases
+ stale atomic.Bool
+}
+
+func (l *staleLeases) Owners(ctx context.Context, roomIDs []string) ([]string, error) {
+ if l.stale.CompareAndSwap(true, false) {
+ return make([]string, len(roomIDs)), nil
+ }
+ return l.Leases.Owners(ctx, roomIDs)
+}
+
+func TestCommandFollowsOwnerAfterLosingTakeover(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newClusterFixture(t, store, store)
+ node := f.redis.Node("c")
+ leases := &staleLeases{Leases: node}
+ songs := newSongs()
+ h, g := New(store, store, songs.music, songs.preparer, Cluster{Instance: "c", Leases: leases, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
+ ctx, stop := context.WithCancel(context.Background())
+ wait, err := Start(ctx, h, g)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ _ = h.Shutdown(context.Background())
+ _ = g.Shutdown(context.Background())
+ stop()
+ wait()
+ })
+ synctest.Wait()
+
+ leases.stale.Store(true)
+ if _, err := g.Do(t.Context(), f.room, bob, room.Chat{Text: "你好"}); err != nil {
+ t.Errorf("命令失败:%v", err)
+ }
+ if owner := f.a.owner(t, f.room); owner != "a" {
+ t.Errorf("房间的 owner 是 %q,期望仍是 a", owner)
+ }
+ })
+}
+
+func TestConcurrentTakeoverHasOneOwner(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newClusterFixture(t, store, store)
+ fresh := createRoom(t, store, room.Persistent)
+ errs := make(chan error, 2)
+ for _, in := range []*instance{f.a, f.b} {
+ go func() {
+ _, err := in.gateway.Do(context.Background(), fresh, bob, room.Chat{Text: in.id})
+ errs <- err
+ }()
+ }
+ for range 2 {
+ if err := <-errs; err != nil {
+ t.Errorf("命令失败:%v", err)
+ }
+ }
+ if term := store.Term(fresh); term != 1 {
+ t.Errorf("任期 = %d,期望只被接管一次", term)
+ }
+ if n := f.a.host.RoomsOwned() + f.b.host.RoomsOwned(); n != 2 {
+ t.Errorf("两个实例运行的房间共 %d 个,期望 2 个", n)
+ }
+ })
+}
+
+func TestRemoteNotFoundMeansRoomNotFound(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ songs := newSongs()
+ redis := porttest.NewRedis()
+ a := startInstance(t, redis, "a", store, store, songs)
+ b := startInstance(t, redis, "b", store, store, songs)
+ synctest.Wait()
+ // 房间的数据已经删除,租约却还属于 a:a 接管时找不到房间。
+ missing := "01a15778-1911-7a28-8b2e-e08c2a25f388"
+ if ok, err := a.node.Acquire(t.Context(), missing, leaseTTL); err != nil || !ok {
+ t.Fatalf("a 取得租约 = %v, %v", ok, err)
+ }
+ if _, err := b.gateway.Do(t.Context(), missing, bob, room.Sync{}); !errors.Is(err, port.ErrRoomNotFound) {
+ t.Errorf("经 b 发出的请求的错误 = %v,期望 ErrRoomNotFound", err)
+ }
+ })
+}
+
+func TestStartRefreshesAliveKeyAtOnce(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ redis := porttest.NewRedis()
+ store := porttest.New()
+ startInstance(t, redis, "a", store, store, newSongs())
+ if expired, err := redis.Node("b").Expired(t.Context(), []string{"a"}); err != nil || len(expired) != 0 {
+ t.Errorf("Start 之后 a 的存活键过期 = %v, %v,期望已经刷新", expired, err)
+ }
+ })
+}
diff --git a/internal/app/realtime/context_test.go b/internal/app/realtime/context_test.go
new file mode 100644
index 0000000..8a47df2
--- /dev/null
+++ b/internal/app/realtime/context_test.go
@@ -0,0 +1,136 @@
+package realtime
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "strings"
+ "testing"
+ "testing/synctest"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/app/port/porttest"
+ "github.com/dihemoe/alisten/internal/domain/room"
+ "github.com/dihemoe/alisten/internal/domain/track"
+ "github.com/dihemoe/alisten/internal/lib/logctx"
+)
+
+type requestValue struct{}
+
+type contextStore struct {
+ *porttest.Store
+ saving chan struct{}
+ hold chan struct{}
+ value any
+ err error
+}
+
+func (s *contextStore) SaveRoom(ctx context.Context, term uint32, snap room.Snapshot, plays []room.RecordPlay) error {
+ s.saving <- struct{}{}
+ <-s.hold
+ s.value, s.err = ctx.Value(requestValue{}), ctx.Err()
+ return s.Store.SaveRoom(ctx, term, snap, plays)
+}
+
+func TestEffectsKeepRequestValues(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := &contextStore{Store: porttest.New(), saving: make(chan struct{}), hold: make(chan struct{})}
+ f := newHostFixture(t, store, store.Store)
+ id := f.createRoom(t, room.Persistent)
+
+ ctx, cancel := context.WithCancel(context.WithValue(context.Background(), requestValue{}, "设置播放模式"))
+ sent := make(chan error, 1)
+ go func() {
+ sent <- f.host.send(ctx, id, envelope{input: command("r1", alice, room.SetPlayMode{Mode: room.Random})}, true)
+ }()
+ <-store.saving
+ cancel()
+ close(store.hold)
+ if err := <-sent; err != nil {
+ t.Fatal(err)
+ }
+ synctest.Wait()
+ if store.value != "设置播放模式" || store.err != nil {
+ t.Errorf("落盘时 ctx 的值 = %v、错误 = %v,期望带着请求的值,请求结束之后也没有结束", store.value, store.err)
+ }
+ checkMessages(t, f.spy.take(), "started 1", "1/1 room.ModeChanged", "reply r1 room.Done")
+ })
+}
+
+type contextPreparer struct {
+ started chan context.Context
+}
+
+func (p contextPreparer) Prepare(ctx context.Context, _ track.Track) (track.Meta, track.Prepared, error) {
+ p.started <- ctx
+ <-ctx.Done()
+ return track.Meta{}, track.Prepared{}, ctx.Err()
+}
+
+func (contextPreparer) Finished(port.PrepareOutcome) {}
+
+func TestPreparationKeepsRequestValues(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ redis := porttest.NewRedis()
+ node := redis.Node("a")
+ preparer := contextPreparer{started: make(chan context.Context, 1)}
+ f := startHost(t, &hostFixture{store: store, redis: redis, node: node, spy: spy{redis.Node("spy")}},
+ newHost(store, store, preparer, newSongs().music, cluster(node, "a"), slog.New(slog.DiscardHandler)))
+ id := f.createRoom(t, room.Persistent)
+
+ ctx, cancel := context.WithCancel(context.WithValue(context.Background(), requestValue{}, "点歌"))
+ if err := f.host.send(ctx, id, envelope{input: command("r1", alice, room.Enqueue{Track: sunny, Meta: sunnyM})}, true); err != nil {
+ t.Fatal(err)
+ }
+ preparing := <-preparer.started
+ cancel()
+ synctest.Wait()
+ if preparing.Value(requestValue{}) != "点歌" || preparing.Err() != nil {
+ t.Errorf("准备时 ctx 的值 = %v、错误 = %v,期望带着请求的值,请求结束之后仍在准备", preparing.Value(requestValue{}), preparing.Err())
+ }
+ if err := f.host.Shutdown(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ if preparing.Err() == nil {
+ t.Error("房间停止之后准备仍未停止")
+ }
+ })
+}
+
+func TestFailedRequestsCarryTheRequestIDOfTheOwnerLogs(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := &failingStore{Store: porttest.New()}
+ var logs logBuffer
+ f := startLoggingInstance(t, porttest.NewRedis(), "a", store, store.Store, newSongs(), slog.New(logctx.NewHandler(slog.NewJSONHandler(&logs, nil))))
+ id := createRoom(t, store.Store, room.Persistent)
+ a := f.join(t, id, alice)
+ store.fail.Store(true)
+
+ // 房间被放弃之后要等这次运行结束,下一个请求才会重新接管它并再次落盘;否则下一个请求可能遇到正在结束的运行,不经落盘就失败。
+ _, doErr := f.gateway.Do(t.Context(), id, bob, room.SetPlayMode{Mode: room.Random})
+ synctest.Wait()
+ if !a.Begin() {
+ t.Fatal("没有未完成的请求时应能占用名额")
+ }
+ commandErr := f.gateway.Command(t.Context(), a, "c1", room.SetPlayMode{Mode: room.Random})
+ synctest.Wait()
+ name := "新名字"
+ updateErr := f.gateway.UpdateInfo(t.Context(), id, &name, nil)
+ synctest.Wait()
+
+ entries := logs.entries(t, "落盘失败,放弃房间")
+ if len(entries) != 3 {
+ t.Fatalf("落盘失败的日志有 %d 条,期望 3 条", len(entries))
+ }
+ for i, err := range []error{doErr, commandErr, updateErr} {
+ var failed *RequestError
+ if !errors.Is(err, ErrUnavailable) || !errors.As(err, &failed) {
+ t.Fatalf("第 %d 个请求的错误 = %v,期望带着 RequestID 的 ErrUnavailable", i+1, err)
+ }
+ if string(failed.ID) != entries[i]["request_id"] || !strings.HasPrefix(string(failed.ID), "a/r") {
+ t.Errorf("第 %d 个请求的错误中的 RequestID = %q,owner 日志中的 request_id = %v", i+1, failed.ID, entries[i]["request_id"])
+ }
+ }
+ })
+}
diff --git a/internal/app/realtime/gateway_test.go b/internal/app/realtime/gateway_test.go
new file mode 100644
index 0000000..8b6e663
--- /dev/null
+++ b/internal/app/realtime/gateway_test.go
@@ -0,0 +1,651 @@
+package realtime
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "slices"
+ "strings"
+ "sync"
+ "testing"
+ "testing/synctest"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/app/port/porttest"
+ "github.com/dihemoe/alisten/internal/domain/member"
+ "github.com/dihemoe/alisten/internal/domain/room"
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+type instance struct {
+ id string
+ node *porttest.Node
+ host *Host
+ gateway *Gateway
+}
+
+// 只能在 synctest 的 bubble 中调用。
+func startInstance(t *testing.T, redis *porttest.Redis, id string, snapshots port.RoomSnapshots, store *porttest.Store, songs *songs) *instance {
+ t.Helper()
+ return startLoggingInstance(t, redis, id, snapshots, store, songs, slog.New(slog.DiscardHandler))
+}
+
+func startLoggingInstance(t *testing.T, redis *porttest.Redis, id string, snapshots port.RoomSnapshots, store *porttest.Store, songs *songs, logger *slog.Logger) *instance {
+ t.Helper()
+ node := redis.Node(id)
+ h, g := New(snapshots, store, songs.music, songs.preparer, cluster(node, id), logger)
+ ctx, stop := context.WithCancel(context.Background())
+ wait, err := Start(ctx, h, g)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ if err := h.Shutdown(context.Background()); err != nil {
+ t.Error(err)
+ }
+ if err := g.Shutdown(context.Background()); err != nil {
+ t.Error(err)
+ }
+ stop()
+ wait()
+ })
+ return &instance{id: id, node: node, host: h, gateway: g}
+}
+
+func (in *instance) join(t *testing.T, roomID string, m member.Member) *Session {
+ t.Helper()
+ s, err := in.gateway.Join(t.Context(), roomID, m)
+ if err != nil {
+ t.Fatalf("%s 加入房间:%v", m.Name, err)
+ }
+ t.Cleanup(func() { in.gateway.Leave(s) })
+ synctest.Wait()
+ return s
+}
+
+func (in *instance) online(t *testing.T, roomID string) int {
+ t.Helper()
+ counts, err := in.node.OnlineCounts(t.Context(), []string{roomID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return counts[0]
+}
+
+func (in *instance) owner(t *testing.T, roomID string) string {
+ t.Helper()
+ owners, err := in.node.Owners(t.Context(), []string{roomID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return owners[0]
+}
+
+type gatewayFixture struct {
+ *instance
+ store *porttest.Store
+ redis *porttest.Redis
+ songs *songs
+}
+
+func newGatewayFixture(t *testing.T, snapshots port.RoomSnapshots, store *porttest.Store) *gatewayFixture {
+ t.Helper()
+ songs := newSongs()
+ songs.add(sunny, sunnyM, sunnyP)
+ redis := porttest.NewRedis()
+ return &gatewayFixture{instance: startInstance(t, redis, "a", snapshots, store, songs), store: store, redis: redis, songs: songs}
+}
+
+func createRoom(t *testing.T, store *porttest.Store, kind room.Kind) string {
+ t.Helper()
+ id, err := store.CreateRoom(t.Context(), port.NewRoom{Kind: kind, Name: "房间"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return id
+}
+
+// received 返回会话或事件流的发送队列中已有的消息,不等待新消息。
+func received(t *testing.T, q interface {
+ Next(context.Context) (Message, CloseReason, error)
+}) []string {
+ t.Helper()
+ synctest.Wait()
+ done, cancel := context.WithCancel(t.Context())
+ cancel()
+ var got []string
+ for {
+ m, reason, err := q.Next(done)
+ switch {
+ case err != nil:
+ return got
+ case reason != 0:
+ return append(got, fmt.Sprintf("closed %d", reason))
+ }
+ got = append(got, describeMessage(m))
+ }
+}
+
+func describeMessage(m Message) string {
+ switch m := m.(type) {
+ case Hello:
+ names := make([]string, len(m.View.Sessions))
+ for i, s := range m.View.Sessions {
+ names[i] = s.Member.Name
+ }
+ return fmt.Sprintf("hello %s %d/%d [%s]", m.Member.Name, m.View.Term, m.View.Seq, strings.Join(names, " "))
+ case Snapshot:
+ return fmt.Sprintf("snapshot %d/%d [%s]", m.View.Term, m.View.Seq, strings.Join(sessionNames(m.View), " "))
+ case Event:
+ return fmt.Sprintf("%d/%d %T", m.Emit.Term, m.Emit.Seq, m.Emit.Event)
+ case Reply:
+ if m.Err != nil {
+ return fmt.Sprintf("reply %v %s", m.Tag, m.Err)
+ }
+ return fmt.Sprintf("reply %v %T", m.Tag, m.Result)
+ case Answer:
+ return fmt.Sprintf("answer %v %v", m.Tag, m.Data)
+ }
+ return fmt.Sprintf("%T", m)
+}
+
+func checkReceived(t *testing.T, s *Session, want ...string) {
+ t.Helper()
+ if got := received(t, s); !slices.Equal(got, want) {
+ t.Errorf("%s 收到:\n%s\n期望:\n%s", s.Member().Name, strings.Join(got, "\n"), strings.Join(want, "\n"))
+ }
+}
+
+func TestRequestLimit(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newGatewayFixture(t, store, store)
+ a := f.join(t, createRoom(t, store, room.Persistent), alice)
+ received(t, a)
+ for i := range requestLimit {
+ if !a.Begin() {
+ t.Fatalf("第 %d 个请求被拒绝", i+1)
+ }
+ }
+ if a.Begin() {
+ t.Fatal("第 17 个未完成的请求应被拒绝")
+ }
+ a.Fail("c1", ErrUnavailable)
+ if !a.Begin() {
+ t.Error("回复一个请求之后应能再占用名额")
+ }
+ a.Answer("q1", "搜索结果")
+ if !a.Begin() {
+ t.Error("回复不经过房间的请求之后应能再占用名额")
+ }
+ checkReceived(t, a, "reply c1 "+ErrUnavailable.Error(), "answer q1 搜索结果")
+ })
+}
+
+func TestSendQueueOverflowClosesSession(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newGatewayFixture(t, store, store)
+ id := createRoom(t, store, room.Persistent)
+ a := f.join(t, id, alice)
+ chat := func() {
+ if _, err := f.gateway.Do(t.Context(), id, bob, room.Chat{Text: "刷屏"}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ // 发送队列中已有握手的结果,再放 sendQueueLimit - 1 条正好占满。
+ for range sendQueueLimit - 1 {
+ chat()
+ }
+ synctest.Wait()
+ if a.Context().Err() != nil {
+ t.Fatal("队列正好满时不应关闭会话")
+ }
+ chat()
+ synctest.Wait()
+ if a.Context().Err() == nil {
+ t.Fatal("队列溢出时应关闭会话")
+ }
+ checkReceived(t, a, fmt.Sprintf("closed %d", CloseOverflow))
+ })
+}
+
+func TestOwnerReplyTimeout(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := &blockingStore{Store: porttest.New(), saving: make(chan struct{}, 1), release: make(chan struct{})}
+ f := newGatewayFixture(t, store, store.Store)
+ id := createRoom(t, store.Store, room.Persistent)
+ a := f.join(t, id, alice)
+ received(t, a)
+
+ start := time.Now()
+ a.Begin()
+ if err := f.gateway.Command(t.Context(), a, "c1", room.SetPlayMode{Mode: room.Random}); !errors.Is(err, ErrUnavailable) {
+ t.Errorf("Command 返回 %v,期望 ErrUnavailable", err)
+ }
+ if elapsed := time.Since(start); elapsed != ownerReplyTimeout {
+ t.Errorf("等了 %v,期望 %v", elapsed, ownerReplyTimeout)
+ }
+ close(store.release)
+ // 超时之后到达的回复不再送达,事件照常广播。
+ checkReceived(t, a, "reply c1 "+ErrUnavailable.Error(), "1/2 room.ModeChanged")
+
+ ctx, cancel := context.WithTimeout(t.Context(), time.Second)
+ defer cancel()
+ if _, err := f.gateway.Do(ctx, id, alice, room.Sync{}); err != nil {
+ t.Errorf("房间恢复之后请求应成功:%v", err)
+ }
+ })
+}
+
+func TestAbandonedRoomFailsPendingRequestsAndRejoins(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := &failingStore{Store: porttest.New()}
+ f := newGatewayFixture(t, store, store.Store)
+ id := createRoom(t, store.Store, room.Persistent)
+ a := f.join(t, id, alice)
+ received(t, a)
+
+ store.fail.Store(true)
+ start := time.Now()
+ a.Begin()
+ if err := f.gateway.Command(t.Context(), a, "c1", room.SetPlayMode{Mode: room.Random}); !errors.Is(err, ErrUnavailable) {
+ t.Errorf("Command 返回 %v,期望 ErrUnavailable", err)
+ }
+ if time.Since(start) != 0 {
+ t.Error("房间放弃时应立即回复,而不是等到超时")
+ }
+ checkReceived(t, a, "reply c1 "+ErrUnavailable.Error())
+
+ store.fail.Store(false)
+ res, err := f.gateway.Do(t.Context(), id, bob, room.Sync{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if view := res.(room.SyncResult).View; view.Term != 2 {
+ t.Errorf("重新接管后任期 = %d,期望 2", view.Term)
+ }
+ synctest.Wait()
+ if got := received(t, a); !slices.Contains(got, "2/1 room.MembersChanged") {
+ t.Errorf("重新接管后应收到新任期的在线列表,实际收到 %q", got)
+ }
+ if n := f.online(t, id); n != 1 {
+ t.Errorf("在线人数 = %d,期望重新加入的 1 个会话", n)
+ }
+ })
+}
+
+func TestSessionLeavingDuringRejoinIsNotKeptOnline(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := &gateStore{Store: porttest.New()}
+ f := newGatewayFixture(t, store, store.Store)
+ id := createRoom(t, store.Store, room.Persistent)
+ a := f.join(t, id, alice)
+ b := f.join(t, id, bob)
+ if _, err := f.gateway.Do(t.Context(), id, alice, room.Enqueue{Track: sunny, Meta: sunnyM}); err != nil {
+ t.Fatal(err)
+ }
+ synctest.Wait()
+ store.fail.Store(true)
+ _, _ = f.gateway.Do(t.Context(), id, alice, room.SetPlayMode{Mode: room.Random})
+ store.fail.Store(false)
+ synctest.Wait()
+ // porttest 恢复出的正在播放的歌没有时长:开播时间过去之后再接管,恢复时这首歌已经放完,第一个 effect 是 Persist。
+ time.Sleep(time.Second)
+
+ // 恢复时的 Persist 卡住,网关重新加入的第一个 Join 等待房间收下;两个会话在此期间先后离开。
+ hold := make(chan struct{})
+ store.setHold(hold)
+ go func() { _, _ = f.gateway.Do(context.Background(), id, alice, room.Sync{}) }()
+ synctest.Wait()
+ go f.gateway.Leave(a)
+ synctest.Wait()
+ go f.gateway.Leave(b)
+ synctest.Wait()
+ store.setHold(nil)
+ close(hold)
+ synctest.Wait()
+
+ res, err := f.gateway.Do(t.Context(), id, alice, room.Sync{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if sessions := res.(room.SyncResult).View.Sessions; len(sessions) != 0 {
+ t.Errorf("两个会话都离开之后的在线列表 = %+v,期望为空", sessions)
+ }
+ if n := f.online(t, id); n != 0 {
+ t.Errorf("在线人数 = %d,期望 0", n)
+ }
+ })
+}
+
+func TestDeleteFailureAbandonsRoom(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := failingDeleteStore{Store: porttest.New()}
+ f := newGatewayFixture(t, store, store.Store)
+ id := createRoom(t, store.Store, room.Persistent)
+ a := f.join(t, id, alice)
+ received(t, a)
+
+ if err := f.gateway.Delete(t.Context(), id); !errors.Is(err, ErrUnavailable) {
+ t.Errorf("删除失败时的错误 = %v,期望 ErrUnavailable", err)
+ }
+ synctest.Wait()
+ if a.Context().Err() != nil {
+ t.Error("删除失败时会话不应断开")
+ }
+ if _, err := store.FindRoom(t.Context(), id); err != nil {
+ t.Errorf("删除失败之后房间应仍在:%v", err)
+ }
+ })
+}
+
+func TestShutdownClosesSessions(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newGatewayFixture(t, store, store)
+ id := createRoom(t, store, room.Persistent)
+ a, err := f.gateway.Join(t.Context(), id, alice)
+ if err != nil {
+ t.Fatal(err)
+ }
+ received(t, a)
+
+ done := make(chan error, 1)
+ go func() { done <- f.gateway.Shutdown(context.Background()) }()
+ checkReceived(t, a, fmt.Sprintf("closed %d", CloseRestart))
+ select {
+ case err := <-done:
+ t.Fatalf("Shutdown 应等到会话调用 Leave,实际已返回 %v", err)
+ default:
+ }
+ f.gateway.Leave(a)
+ if err := <-done; err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.gateway.Join(t.Context(), id, bob); !errors.Is(err, ErrUnavailable) {
+ t.Errorf("Shutdown 之后加入的错误 = %v,期望 ErrUnavailable", err)
+ }
+ })
+}
+
+func TestResolvePick(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newGatewayFixture(t, store, store)
+ link := track.Track{Source: track.Link, ID: "https://www.youtube.com/watch?v=x"}
+ video := track.Track{Source: track.Bilibili, ID: "BV1xx411c7mD"}
+ videoM := track.Meta{Title: "晴天(钢琴版)", PageURL: "https://www.bilibili.com/video/BV1xx411c7mD"}
+ f.songs.bilibili.AddVideo(video.ID, track.Meta{Title: "晴天(钢琴版)"}, nil, nil, "")
+ f.songs.bilibili.AddLink("https://www.bilibili.com/video/BV1xx411c7mD", video.ID)
+ tests := []struct {
+ pick Pick
+ want room.Enqueue
+ err error
+ }{
+ {Pick{Track: sunny}, room.Enqueue{Track: sunny, Meta: sunnyM}, nil},
+ {Pick{Track: link}, room.Enqueue{Track: link, Meta: track.Meta{Title: link.ID, PageURL: link.ID}}, nil},
+ {Pick{Track: track.Track{Source: track.Link, ID: "https://www.bilibili.com/video/BV1xx411c7mD"}}, room.Enqueue{Track: video, Meta: videoM}, nil},
+ {Pick{Track: video}, room.Enqueue{Track: video, Meta: videoM}, nil},
+ {Pick{Source: track.NetEase, Keyword: "晴"}, room.Enqueue{Track: sunny, Meta: sunnyM}, nil},
+ }
+ for _, tt := range tests {
+ got, _, err := f.gateway.Resolve(t.Context(), alice, false, tt.pick)
+ if got != tt.want || !errors.Is(err, tt.err) {
+ t.Errorf("Resolve(%+v) = %+v, %v,期望 %+v, %v", tt.pick, got, err, tt.want, tt.err)
+ }
+ }
+ })
+}
+
+func TestOnlyBotsFillInRequester(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newGatewayFixture(t, store, store)
+ bot := member.Member{ID: "00000000-0000-7000-8000-0000000000b0", Name: "bot"}
+ for requester, want := range map[string]member.Member{"张三": {ID: bot.ID, Name: "张三(bot)"}, "": bot} {
+ got, by, err := f.gateway.Resolve(t.Context(), bot, true, Pick{Track: sunny, Requester: requester})
+ if err != nil || got.Track != sunny || by != want {
+ t.Errorf("bot 点歌时代填 %q = %+v, %+v, %v,期望点歌人 %+v", requester, got, by, err, want)
+ }
+ }
+ if _, _, err := f.gateway.Resolve(t.Context(), alice, false, Pick{Track: sunny, Requester: "张三"}); !errors.Is(err, ErrRequesterNotAllowed) {
+ t.Errorf("普通成员代填点歌人的错误 = %v,期望 ErrRequesterNotAllowed", err)
+ }
+ })
+}
+
+func TestUnsubscribeAfterLastUse(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newGatewayFixture(t, store, store)
+ sessionOnly := createRoom(t, store, room.Persistent)
+ withHTTP := createRoom(t, store, room.Persistent)
+
+ s, err := f.gateway.Join(t.Context(), sessionOnly, alice)
+ if err != nil {
+ t.Fatal(err)
+ }
+ synctest.Wait()
+ if !f.node.Subscribed(sessionOnly) {
+ t.Fatal("会话加入之后应订阅房间频道")
+ }
+ f.gateway.Leave(s)
+ synctest.Wait()
+ if f.node.Subscribed(sessionOnly) {
+ t.Error("最后一个会话离开、没有 HTTP 请求时应立即退订")
+ }
+
+ if _, err := f.gateway.Do(t.Context(), withHTTP, bob, room.Sync{}); err != nil {
+ t.Fatal(err)
+ }
+ time.Sleep(30 * time.Second)
+ s, err = f.gateway.Join(t.Context(), withHTTP, alice)
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.gateway.Leave(s)
+ time.Sleep(30*time.Second - time.Nanosecond)
+ synctest.Wait()
+ if !f.node.Subscribed(withHTTP) {
+ t.Error("最后一次 HTTP 请求之后不到 60 秒,不应退订")
+ }
+ time.Sleep(time.Nanosecond)
+ synctest.Wait()
+ if f.node.Subscribed(withHTTP) {
+ t.Error("最后一次 HTTP 请求 60 秒之后应退订")
+ }
+ if _, err := f.gateway.Do(t.Context(), withHTTP, bob, room.Sync{}); err != nil {
+ t.Errorf("退订之后的请求失败:%v", err)
+ }
+ })
+}
+
+func TestSubscriptionLossRestartsInstance(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := &blockingStore{Store: porttest.New(), saving: make(chan struct{}, 1), release: make(chan struct{})}
+ f := newGatewayFixture(t, store, store.Store)
+ id := createRoom(t, store.Store, room.Persistent)
+ a := f.join(t, id, alice)
+ received(t, a)
+
+ failed := make(chan error, 1)
+ go func() {
+ _, err := f.gateway.Do(context.Background(), id, bob, room.SetPlayMode{Mode: room.Random})
+ failed <- err
+ }()
+ <-store.saving
+ lost := time.Now()
+ f.node.Disconnect()
+ synctest.Wait()
+ if err := <-failed; !errors.Is(err, ErrUnavailable) {
+ t.Errorf("订阅断开时等待中的请求 = %v,期望 ErrUnavailable", err)
+ }
+ if time.Since(lost) != 0 {
+ t.Error("订阅断开时等待中的请求应立即失败,而不是等到超时")
+ }
+ checkReceived(t, a, fmt.Sprintf("closed %d", CloseRestart))
+ close(store.release)
+ synctest.Wait()
+ if n := f.host.RoomsOwned(); n != 0 {
+ t.Errorf("RoomsOwned = %d,期望房间已经释放", n)
+ }
+ res, err := f.gateway.Do(t.Context(), id, bob, room.Sync{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if view := res.(room.SyncResult).View; view.Term != 2 || view.Snapshot.Mode != room.Random {
+ t.Errorf("重新接管之后任期 = %d、播放模式 = %d,期望 2 和处理完的随机模式", view.Term, view.Snapshot.Mode)
+ }
+ })
+}
+
+type countingBus struct {
+ *porttest.Node
+
+ mu sync.Mutex
+ subscribes map[string]int
+ stalled bool
+ gate chan struct{}
+}
+
+func (b *countingBus) SubscribeRoom(ctx context.Context, roomID string) error {
+ b.mu.Lock()
+ b.subscribes[roomID]++
+ stalled, gate := b.stalled, b.gate
+ b.mu.Unlock()
+ if stalled {
+ <-ctx.Done()
+ return ctx.Err()
+ }
+ if gate != nil {
+ select {
+ case <-gate:
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ }
+ return b.Node.SubscribeRoom(ctx, roomID)
+}
+
+func (b *countingBus) stall(stalled bool) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ b.stalled = stalled
+}
+
+func (b *countingBus) delay() (resume func()) {
+ gate := make(chan struct{})
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ b.gate = gate
+ return sync.OnceFunc(func() { close(gate) })
+}
+
+func (b *countingBus) count(roomID string) int {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.subscribes[roomID]
+}
+
+func startCountingGateway(t *testing.T, store *porttest.Store) (*Gateway, *porttest.Node, *countingBus) {
+ t.Helper()
+ node := porttest.NewRedis().Node("a")
+ bus := &countingBus{Node: node, subscribes: map[string]int{}}
+ songs := newSongs()
+ c := cluster(node, "a")
+ c.Bus = bus
+ h, g := New(store, store, songs.music, songs.preparer, c, slog.New(slog.DiscardHandler))
+ ctx, stop := context.WithCancel(context.Background())
+ wait, err := Start(ctx, h, g)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ if err := h.Shutdown(context.Background()); err != nil {
+ t.Error(err)
+ }
+ if err := g.Shutdown(context.Background()); err != nil {
+ t.Error(err)
+ }
+ stop()
+ wait()
+ })
+ return g, node, bus
+}
+
+func TestConfirmedSubscriptionIsNotRepeated(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ g, node, bus := startCountingGateway(t, store)
+ id := createRoom(t, store, room.Persistent)
+ s, err := g.Join(t.Context(), id, alice)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { g.Leave(s) })
+ for range 3 {
+ if _, err := g.Do(t.Context(), id, bob, room.Sync{}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if n := bus.count(id); n != 1 {
+ t.Errorf("会话加入和之后的三个请求一共订阅了 %d 次,期望只在加入时订阅一次", n)
+ }
+
+ bus.stall(true)
+ begin := time.Now()
+ if _, err := g.Do(t.Context(), id, bob, room.Sync{}); err != nil || time.Since(begin) != 0 {
+ t.Errorf("订阅等不到确认时,确认过订阅的房间的请求 = %v,用了 %v,期望不等订阅,立即完成", err, time.Since(begin))
+ }
+ bus.stall(false)
+
+ node.Disconnect()
+ synctest.Wait()
+ if _, err := g.Do(t.Context(), id, bob, room.Sync{}); err != nil {
+ t.Fatal(err)
+ }
+ if n := bus.count(id); n != 2 {
+ t.Errorf("订阅连接断开过之后一共订阅了 %d 次,期望断开之后的第一个请求重新订阅", n)
+ }
+ })
+}
+
+func TestConfirmationAcrossDisconnectIsNotReused(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ g, node, bus := startCountingGateway(t, store)
+ id := createRoom(t, store, room.Persistent)
+ s, err := g.Join(t.Context(), id, alice)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { g.Leave(s) })
+
+ node.Disconnect()
+ synctest.Wait()
+ resume := bus.delay()
+ interrupted := make(chan struct{})
+ go func() {
+ defer close(interrupted)
+ _, _ = g.Do(t.Context(), id, bob, room.Sync{})
+ }()
+ synctest.Wait()
+ if n := bus.count(id); n != 2 {
+ t.Fatalf("第一次断开之后一共订阅了 %d 次,期望请求正在等第二次订阅的确认", n)
+ }
+
+ node.Disconnect()
+ synctest.Wait()
+ resume()
+ <-interrupted
+ if _, err := g.Do(t.Context(), id, bob, room.Sync{}); err != nil {
+ t.Fatal(err)
+ }
+ if n := bus.count(id); n != 3 {
+ t.Errorf("第二次断开之后一共订阅了 %d 次,期望之后的请求重新订阅,等待确认时断开的那次不算", n)
+ }
+ })
+}
diff --git a/internal/app/realtime/host_test.go b/internal/app/realtime/host_test.go
new file mode 100644
index 0000000..7256800
--- /dev/null
+++ b/internal/app/realtime/host_test.go
@@ -0,0 +1,1134 @@
+package realtime
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+ "slices"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "testing/synctest"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/app/catalog"
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/app/port/porttest"
+ "github.com/dihemoe/alisten/internal/domain/member"
+ "github.com/dihemoe/alisten/internal/domain/room"
+ "github.com/dihemoe/alisten/internal/domain/track"
+ "github.com/dihemoe/alisten/internal/lib/logctx"
+)
+
+var (
+ alice = member.Member{ID: "00000000-0000-7000-8000-00000000000a", Name: "alice"}
+ bob = member.Member{ID: "00000000-0000-7000-8000-00000000000b", Name: "bob"}
+ sunny = track.Track{Source: track.NetEase, ID: "186016"}
+ sunnyM = track.Meta{Title: "晴天", Artist: "周杰伦", Duration: 269 * time.Second}
+ sunnyP = track.Prepared{Duration: 3 * time.Minute, Gain: 0.8, Lyric: "[00:01.00]故事的小黄花"}
+ rain = track.Track{Source: track.NetEase, ID: "185811"}
+ rainM = track.Meta{Title: "七里香", Artist: "周杰伦"}
+ rainP = track.Prepared{Duration: 4 * time.Minute, Gain: 1}
+)
+
+func cluster(node *porttest.Node, instance string) Cluster {
+ return Cluster{Instance: instance, Leases: node, Liveness: node, Bus: node}
+}
+
+type noTracks struct{}
+
+func (noTracks) PreparedTrack(context.Context, track.Track) (port.PreparedTrack, bool, error) {
+ return port.PreparedTrack{}, false, nil
+}
+
+func (noTracks) SavePreparedTrack(context.Context, track.Track, port.PreparedTrack) error {
+ return nil
+}
+
+type noHistory struct{}
+
+func (noHistory) AutoDJSeeds(context.Context, string, bool) ([]track.Track, error) {
+ return nil, nil
+}
+
+func (noHistory) AutoDJExclusions(context.Context, string) ([]track.Track, error) {
+ return nil, nil
+}
+
+type songs struct {
+ netease *porttest.Source
+ bilibili *porttest.Bilibili
+ music *catalog.Service
+ preparer *porttest.Preparer
+}
+
+func newSongs() *songs {
+ netease, bilibili := porttest.NewSource(track.NetEase), porttest.NewBilibili()
+ // 网关和房间宿主只用到 TrackMeta、ResolveLink、FirstMatch 和 Recommend,用不到房间目录和搜索额度。
+ music := catalog.New(netease, porttest.NewSource(track.QQ), bilibili, bilibili, noTracks{}, noHistory{}, nil, nil)
+ return &songs{netease: netease, bilibili: bilibili, music: music, preparer: porttest.NewPreparer()}
+}
+
+func (s *songs) add(t track.Track, m track.Meta, p track.Prepared) {
+ s.netease.Add(t.ID, m, "", nil)
+ s.preparer.Add(t, m, p)
+}
+
+type spy struct {
+ node *porttest.Node
+}
+
+func (s spy) watch(t *testing.T, roomID string) {
+ t.Helper()
+ if err := s.node.SubscribeRoom(t.Context(), roomID); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func (s spy) take() []port.RoomMessage {
+ done, cancel := context.WithCancel(context.Background())
+ cancel()
+ var msgs []port.RoomMessage
+ for {
+ d, err := s.node.Receive(done)
+ if err != nil {
+ return msgs
+ }
+ if d, ok := d.(port.RoomDelivery); ok {
+ msgs = append(msgs, d.Message)
+ }
+ }
+}
+
+func describe(m port.RoomMessage) string {
+ switch m := m.(type) {
+ case port.OwnerChanged:
+ return fmt.Sprintf("started %d", m.Term)
+ case port.Emitted:
+ return fmt.Sprintf("%d/%d %T", m.Emit.Term, m.Emit.Seq, m.Emit.Event)
+ case port.Replied:
+ if m.Reply.Err != nil {
+ return fmt.Sprintf("reply %s %s", m.Reply.RequestID, m.Reply.Err.Message)
+ }
+ return fmt.Sprintf("reply %s %T", m.Reply.RequestID, m.Reply.Result)
+ case port.Rejected:
+ if m.NotFound {
+ return fmt.Sprintf("rejected %s not found", m.RequestID)
+ }
+ return fmt.Sprintf("rejected %s", m.RequestID)
+ case port.RoomClosed:
+ return "closed"
+ case port.OwnerReleased:
+ return "released"
+ case port.TicketsRevoked:
+ return "revoked"
+ }
+ return fmt.Sprintf("%T", m)
+}
+
+func checkMessages(t *testing.T, msgs []port.RoomMessage, want ...string) {
+ t.Helper()
+ got := make([]string, len(msgs))
+ for i, m := range msgs {
+ got[i] = describe(m)
+ }
+ if !slices.Equal(got, want) {
+ t.Errorf("房间消息:\n%s\n期望:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
+ }
+}
+
+type hostFixture struct {
+ store *porttest.Store
+ redis *porttest.Redis
+ node *porttest.Node
+ host *Host
+ spy spy
+}
+
+func newHostFixture(t *testing.T, snapshots port.RoomSnapshots, store *porttest.Store) *hostFixture {
+ t.Helper()
+ songs := newSongs()
+ songs.add(sunny, sunnyM, sunnyP)
+ songs.add(rain, rainM, rainP)
+ redis := porttest.NewRedis()
+ node := redis.Node("a")
+ return startHost(t, &hostFixture{store: store, redis: redis, node: node, spy: spy{redis.Node("spy")}},
+ newHost(snapshots, store, songs.preparer, songs.music, cluster(node, "a"), slog.New(slog.DiscardHandler)))
+}
+
+func startHost(t *testing.T, f *hostFixture, h *Host) *hostFixture {
+ t.Helper()
+ f.host = h
+ h.Start()
+ t.Cleanup(func() {
+ if err := h.Shutdown(context.Background()); err != nil {
+ t.Error(err)
+ }
+ })
+ return f
+}
+
+func (f *hostFixture) createRoom(t *testing.T, kind room.Kind) string {
+ t.Helper()
+ id, err := f.store.CreateRoom(t.Context(), port.NewRoom{Kind: kind, Name: "房间"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.spy.watch(t, id)
+ return id
+}
+
+func (f *hostFixture) send(t *testing.T, roomID string, in room.Input) {
+ t.Helper()
+ if err := f.host.send(t.Context(), roomID, envelope{input: in}, true); err != nil {
+ t.Fatalf("发送 %T:%v", in, err)
+ }
+ synctest.Wait()
+}
+
+func (f *hostFixture) owner(t *testing.T, roomID string) string {
+ t.Helper()
+ owners, err := f.node.Owners(t.Context(), []string{roomID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return owners[0]
+}
+
+func (f *hostFixture) online(t *testing.T, roomID string) int {
+ t.Helper()
+ counts, err := f.node.OnlineCounts(t.Context(), []string{roomID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return counts[0]
+}
+
+func command(id string, by member.Member, a room.Action) room.Command {
+ return room.Command{RequestID: room.RequestID(id), Member: by, Action: a}
+}
+
+func TestEffectsRunInOrder(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newHostFixture(t, store, store)
+ id := f.createRoom(t, room.Persistent)
+ start := time.Now()
+
+ f.send(t, id, room.Join{Session: "s1", Member: alice})
+ f.send(t, id, command("r1", alice, room.Enqueue{Track: sunny, Meta: sunnyM}))
+ // 准备的结果在下一批处理:点歌的回复先到,开播在后。
+ checkMessages(t, f.spy.take(),
+ "started 1",
+ "1/1 room.MembersChanged",
+ "1/2 room.Activity",
+ "1/3 room.QueueChanged",
+ "reply r1 room.EnqueueResult",
+ "1/4 room.QueueChanged",
+ "1/5 room.PlaybackChanged",
+ )
+
+ time.Sleep(sunnyP.Duration + 200*time.Millisecond)
+ synctest.Wait()
+ checkMessages(t, f.spy.take(), "1/6 room.PlaybackChanged")
+ plays := store.Plays(id)
+ startedAt := start.Add(200 * time.Millisecond)
+ if len(plays) != 1 || !plays[0].StartedAt.Equal(startedAt) || !plays[0].EndedAt.Equal(startedAt.Add(sunnyP.Duration)) {
+ t.Errorf("播放记录 = %+v,期望一首从 %v 播到 %v", plays, startedAt, startedAt.Add(sunnyP.Duration))
+ }
+ })
+}
+
+type failingStore struct {
+ *porttest.Store
+ fail atomic.Bool
+}
+
+func (s *failingStore) SaveRoom(ctx context.Context, term uint32, snap room.Snapshot, plays []room.RecordPlay) error {
+ if s.fail.Load() {
+ return errors.New("连接中断")
+ }
+ return s.Store.SaveRoom(ctx, term, snap, plays)
+}
+
+func TestPersistFailureAbandonsRoom(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := &failingStore{Store: porttest.New()}
+ f := newHostFixture(t, store, store.Store)
+ id := f.createRoom(t, room.Persistent)
+ f.send(t, id, room.Join{Session: "s1", Member: alice})
+ f.spy.take()
+
+ store.fail.Store(true)
+ f.send(t, id, command("r1", alice, room.SetPlayMode{Mode: room.Random}))
+ checkMessages(t, f.spy.take(), "rejected r1")
+ if owner := f.owner(t, id); owner != "" {
+ t.Errorf("放弃房间之后租约属于 %q,期望已经释放", owner)
+ }
+ if n := f.host.RoomsOwned(); n != 0 {
+ t.Errorf("RoomsOwned = %d,期望 0", n)
+ }
+
+ store.fail.Store(false)
+ f.send(t, id, command("r2", alice, room.Sync{}))
+ checkMessages(t, f.spy.take(), "started 2", "reply r2 room.SyncResult")
+ if term := store.Term(id); term != 2 {
+ t.Errorf("重新接管后任期 = %d,期望 2", term)
+ }
+ if n := f.host.LeasesLost(); n != 0 {
+ t.Errorf("LeasesLost = %d,落盘失败不算失去租约", n)
+ }
+ })
+}
+
+// 每次 Restore 的 token 都从 1 开始:放弃房间时如果不停下计时器,它会提前关闭重新接管后的房间。
+func TestTimersOfAbandonedRoomAreDiscarded(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := &failingStore{Store: porttest.New()}
+ f := newHostFixture(t, store, store.Store)
+ id := f.createRoom(t, room.Ephemeral)
+ start := time.Now()
+
+ // 第一次运行在 start 时恢复,token 1 是 start+5m 的关闭计时器。
+ f.send(t, id, command("r1", alice, room.Sync{}))
+ time.Sleep(time.Minute)
+ store.fail.Store(true)
+ f.send(t, id, command("r2", alice, room.SetPlayMode{Mode: room.Random}))
+ store.fail.Store(false)
+ time.Sleep(time.Minute)
+ // 第二次运行在 start+2m 时恢复,token 1 是 start+7m 的关闭计时器。
+ f.send(t, id, command("r3", alice, room.Sync{}))
+ f.spy.take()
+
+ time.Sleep(time.Until(start.Add(5*time.Minute + time.Second)))
+ synctest.Wait()
+ checkMessages(t, f.spy.take())
+ time.Sleep(time.Until(start.Add(7 * time.Minute)))
+ synctest.Wait()
+ checkMessages(t, f.spy.take(), "closed")
+ })
+}
+
+// Prepare 不理会 ctx:检验的是宿主自己丢弃旧运行的结果,而不是靠端口的实现按 ctx 提前返回。
+type gatedPreparer struct {
+ calls atomic.Int32
+ stale, fresh chan struct{}
+}
+
+func (*gatedPreparer) Finished(port.PrepareOutcome) {}
+
+func (p *gatedPreparer) Prepare(context.Context, track.Track) (track.Meta, track.Prepared, error) {
+ if p.calls.Add(1) == 1 {
+ <-p.stale
+ return track.Meta{}, track.Prepared{}, errors.New("旧运行的准备失败")
+ }
+ <-p.fresh
+ return sunnyM, sunnyP, nil
+}
+
+func TestPrepareOfAbandonedRunIsDiscarded(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := &failingStore{Store: porttest.New()}
+ preparer := &gatedPreparer{stale: make(chan struct{}), fresh: make(chan struct{})}
+ redis := porttest.NewRedis()
+ node := redis.Node("a")
+ f := startHost(t, &hostFixture{store: store.Store, redis: redis, node: node, spy: spy{redis.Node("spy")}},
+ newHost(store, store.Store, preparer, newSongs().music, cluster(node, "a"), slog.New(slog.DiscardHandler)))
+ id := f.createRoom(t, room.Persistent)
+ f.send(t, id, command("r1", alice, room.Enqueue{Track: sunny, Meta: sunnyM}))
+ f.spy.take()
+
+ store.fail.Store(true)
+ f.send(t, id, command("r2", alice, room.SetPlayMode{Mode: room.Random}))
+ store.fail.Store(false)
+ checkMessages(t, f.spy.take(), "rejected r2")
+ // 第二次运行恢复出准备中的待播,重新发出 Prepare。
+ f.send(t, id, command("r3", alice, room.Sync{}))
+ checkMessages(t, f.spy.take(), "started 2", "reply r3 room.SyncResult")
+
+ close(preparer.stale)
+ synctest.Wait()
+ checkMessages(t, f.spy.take())
+ close(preparer.fresh)
+ synctest.Wait()
+ checkMessages(t, f.spy.take(), "2/1 room.QueueChanged", "2/2 room.PlaybackChanged")
+ })
+}
+
+type failingDeleteStore struct {
+ *porttest.Store
+}
+
+func (failingDeleteStore) DeleteRoom(context.Context, string, uint32) error {
+ return errors.New("连接中断")
+}
+
+func TestEphemeralRoomClosesFiveMinutesAfterLastLeave(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newHostFixture(t, store, store)
+ id := f.createRoom(t, room.Ephemeral)
+ f.send(t, id, room.Join{Session: "s1", Member: alice})
+ f.send(t, id, room.Join{Session: "s2", Member: bob})
+ if n := f.online(t, id); n != 2 {
+ t.Errorf("在线人数 = %d,期望 2", n)
+ }
+ f.send(t, id, room.Leave{Session: "s1"})
+ f.send(t, id, room.Leave{Session: "s2"})
+ f.spy.take()
+
+ time.Sleep(5*time.Minute - time.Second)
+ synctest.Wait()
+ checkMessages(t, f.spy.take())
+ time.Sleep(time.Second)
+ synctest.Wait()
+ // 关闭之后租约随之删除,不再发出释放通知。
+ checkMessages(t, f.spy.take(), "closed")
+ if owner := f.owner(t, id); owner != "" {
+ t.Errorf("关闭之后租约属于 %q,期望已经删除", owner)
+ }
+ if _, err := store.FindRoom(t.Context(), id); !errors.Is(err, port.ErrRoomNotFound) {
+ t.Errorf("关闭后查找房间的错误 = %v,期望 ErrRoomNotFound", err)
+ }
+ if err := f.host.send(t.Context(), id, envelope{input: room.Join{Session: "s3", Member: alice}}, true); !errors.Is(err, port.ErrRoomNotFound) {
+ t.Errorf("关闭后加入房间的错误 = %v,期望 ErrRoomNotFound", err)
+ }
+ })
+}
+
+func TestReconcileTakesOverRoomsWithoutOwner(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newHostFixture(t, store, store)
+ persistent := f.createRoom(t, room.Persistent)
+ ephemeral := f.createRoom(t, room.Ephemeral)
+ elsewhere := f.createRoom(t, room.Persistent)
+ other := f.redis.Node("b")
+ if ok, err := other.Acquire(t.Context(), elsewhere, leaseTTL); err != nil || !ok {
+ t.Fatalf("b 取得租约 = %v, %v", ok, err)
+ }
+ ctx, stop := context.WithCancel(t.Context())
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ f.host.Reconcile(ctx)
+ }()
+ t.Cleanup(func() {
+ stop()
+ <-done
+ })
+
+ synctest.Wait()
+ for _, id := range []string{persistent, ephemeral} {
+ if term := store.Term(id); term != 1 {
+ t.Errorf("对账之后房间 %s 的任期 = %d,期望 1", id, term)
+ }
+ }
+ if term := store.Term(elsewhere); term != 0 {
+ t.Errorf("由 b 运行的房间的任期 = %d,期望没有被接管", term)
+ }
+ time.Sleep(5 * time.Minute)
+ synctest.Wait()
+ if _, err := store.FindRoom(t.Context(), ephemeral); !errors.Is(err, port.ErrRoomNotFound) {
+ t.Errorf("无人的临时房间应已关闭:%v", err)
+ }
+ if term := store.Term(persistent); term != 1 {
+ t.Errorf("常驻房间的任期 = %d,期望一直是 1", term)
+ }
+ if term := store.Term(elsewhere); term != 1 {
+ t.Errorf("b 的租约过期之后房间的任期 = %d,期望 1", term)
+ }
+ later := f.createRoom(t, room.Persistent)
+ time.Sleep(reconcileInterval + reconcileJitter)
+ synctest.Wait()
+ if term := store.Term(later); term != 1 {
+ t.Errorf("新建的房间在对账之后的任期 = %d,期望 1", term)
+ }
+ })
+}
+
+func TestHostAcceptsRoomsOnlyBetweenStartAndShutdown(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ songs := newSongs()
+ redis := porttest.NewRedis()
+ watcher := spy{redis.Node("spy")}
+ h := newHost(store, store, songs.preparer, songs.music, cluster(redis.Node("a"), "a"), slog.New(slog.DiscardHandler))
+ id, err := store.CreateRoom(t.Context(), port.NewRoom{Kind: room.Persistent, Name: "房间"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ watcher.watch(t, id)
+ join := envelope{input: room.Join{Session: "s1", Member: alice}}
+ if err := h.send(t.Context(), id, join, true); !errors.Is(err, ErrUnavailable) {
+ t.Errorf("Start 之前的错误 = %v,期望 ErrUnavailable", err)
+ }
+
+ h.Start()
+ if err := h.send(t.Context(), id, join, true); err != nil {
+ t.Fatal(err)
+ }
+ synctest.Wait()
+ if err := h.Shutdown(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ checkMessages(t, watcher.take(), "started 1", "1/1 room.MembersChanged", "released")
+ if err := h.send(t.Context(), id, join, true); !errors.Is(err, ErrUnavailable) {
+ t.Errorf("Shutdown 之后的错误 = %v,期望 ErrUnavailable", err)
+ }
+ h.Start()
+ if err := h.send(t.Context(), id, join, true); !errors.Is(err, ErrUnavailable) {
+ t.Errorf("Shutdown 之后再 Start,错误 = %v,期望 ErrUnavailable", err)
+ }
+ })
+}
+
+type blockingStore struct {
+ *porttest.Store
+ saving chan struct{}
+ release chan struct{}
+}
+
+func (s *blockingStore) SaveRoom(ctx context.Context, term uint32, snap room.Snapshot, plays []room.RecordPlay) error {
+ s.saving <- struct{}{}
+ <-s.release
+ return s.Store.SaveRoom(ctx, term, snap, plays)
+}
+
+type gateStore struct {
+ *porttest.Store
+ fail atomic.Bool
+ mu sync.Mutex
+ hold chan struct{}
+}
+
+func (s *gateStore) setHold(hold chan struct{}) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.hold = hold
+}
+
+func (s *gateStore) SaveRoom(ctx context.Context, term uint32, snap room.Snapshot, plays []room.RecordPlay) error {
+ s.mu.Lock()
+ hold := s.hold
+ s.mu.Unlock()
+ if hold != nil {
+ <-hold
+ }
+ if s.fail.Load() {
+ return errors.New("连接中断")
+ }
+ return s.Store.SaveRoom(ctx, term, snap, plays)
+}
+
+func TestShutdownFinishesCurrentInput(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := &blockingStore{Store: porttest.New(), saving: make(chan struct{}), release: make(chan struct{})}
+ f := newHostFixture(t, store, store.Store)
+ id := f.createRoom(t, room.Persistent)
+ f.send(t, id, room.Join{Session: "s1", Member: alice})
+ f.spy.take()
+
+ go func() {
+ _ = f.host.send(context.Background(), id, envelope{input: command("r1", alice, room.SetPlayMode{Mode: room.Random})}, true)
+ }()
+ <-store.saving
+ queued := make(chan error, 1)
+ go func() {
+ queued <- f.host.send(context.Background(), id, envelope{input: command("r2", alice, room.Sync{})}, true)
+ }()
+ shutdown := make(chan error, 1)
+ go func() { shutdown <- f.host.Shutdown(context.Background()) }()
+ synctest.Wait()
+ close(store.release)
+
+ if err := <-shutdown; err != nil {
+ t.Fatal(err)
+ }
+ if err := <-queued; !errors.Is(err, ErrUnavailable) {
+ t.Errorf("释放房间时排队的输入,错误 = %v,期望 ErrUnavailable", err)
+ }
+ checkMessages(t, f.spy.take(), "1/2 room.ModeChanged", "reply r1 room.Done", "released")
+ if owner := f.owner(t, id); owner != "" {
+ t.Errorf("释放之后租约属于 %q,期望已经删除", owner)
+ }
+ })
+}
+
+func TestUnknownRoom(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newHostFixture(t, store, store)
+ for _, id := range []string{"01a15778-1911-7a28-8b2e-e08c2a25f388"} {
+ if err := f.host.send(t.Context(), id, envelope{input: room.Join{Session: "s1", Member: alice}}, true); !errors.Is(err, port.ErrRoomNotFound) {
+ t.Errorf("房间 %q 的错误 = %v,期望 ErrRoomNotFound", id, err)
+ }
+ if owner := f.owner(t, id); owner != "" {
+ t.Errorf("房间 %q 不存在,租约应已释放,实际属于 %q", id, owner)
+ }
+ }
+ if err := f.host.send(t.Context(), "01a15778-1911-7a28-8b2e-e08c2a25f388", envelope{input: room.Leave{Session: "s1"}}, false); err != nil {
+ t.Errorf("丢弃输入时的错误 = %v", err)
+ }
+ })
+}
+
+func TestRenewalKeepsLeaseAndOnlineCount(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newHostFixture(t, store, store)
+ id := f.createRoom(t, room.Persistent)
+ f.send(t, id, room.Join{Session: "s1", Member: alice})
+ f.send(t, id, room.Join{Session: "s2", Member: bob})
+ if n := f.online(t, id); n != 2 {
+ t.Errorf("在线人数 = %d,期望 2", n)
+ }
+ time.Sleep(time.Minute)
+ synctest.Wait()
+ if owner := f.owner(t, id); owner != "a" {
+ t.Errorf("一分钟之后租约属于 %q,期望一直续期", owner)
+ }
+ if ok, err := f.redis.Node("b").Acquire(t.Context(), id, leaseTTL); err != nil || ok {
+ t.Errorf("b 取得 a 的租约 = %v, %v,期望 false", ok, err)
+ }
+ f.send(t, id, room.Leave{Session: "s1"})
+ if n := f.online(t, id); n != 1 {
+ t.Errorf("离开之后在线人数 = %d,期望 1", n)
+ }
+ if n := f.host.RoomsOwned(); n != 1 {
+ t.Errorf("RoomsOwned = %d,期望 1", n)
+ }
+ })
+}
+
+func TestLostLeaseStopsRoomImmediately(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := &gateStore{Store: porttest.New()}
+ f := newHostFixture(t, store, store.Store)
+ id := f.createRoom(t, room.Persistent)
+ f.send(t, id, room.Join{Session: "s1", Member: alice})
+ f.spy.take()
+
+ // 落盘卡住时租约丢失:落盘返回之后,本批剩下的事件和回复都不再发出。
+ hold := make(chan struct{})
+ store.setHold(hold)
+ go func() {
+ _ = f.host.send(context.Background(), id, envelope{input: command("r1", alice, room.SetPlayMode{Mode: room.Random})}, true)
+ }()
+ synctest.Wait()
+ f.redis.Flush()
+ if ok, err := f.redis.Node("b").Acquire(t.Context(), id, leaseTTL); err != nil || !ok {
+ t.Fatalf("清空之后 b 取得租约 = %v, %v", ok, err)
+ }
+ time.Sleep(renewInterval)
+ synctest.Wait()
+ store.setHold(nil)
+ close(hold)
+ synctest.Wait()
+
+ checkMessages(t, f.spy.take(), "rejected r1")
+ if owner := f.owner(t, id); owner != "b" {
+ t.Errorf("租约属于 %q,期望仍属于 b", owner)
+ }
+ if n := f.host.LeasesLost(); n != 1 {
+ t.Errorf("LeasesLost = %d,期望 1", n)
+ }
+ if err := f.host.send(t.Context(), id, envelope{input: command("r2", alice, room.Sync{})}, true); !errors.Is(err, errOwnedElsewhere) {
+ t.Errorf("租约属于 b 时接管房间的错误 = %v,期望 errOwnedElsewhere", err)
+ }
+ })
+}
+
+type flakyLeases struct {
+ port.Leases
+ fail atomic.Bool
+ hang atomic.Bool
+ resume chan struct{}
+}
+
+func (l *flakyLeases) Renew(ctx context.Context, roomID string, ttl time.Duration) (bool, error) {
+ if l.hang.Load() {
+ <-l.resume
+ return false, porttest.ErrRedisDown
+ }
+ if l.fail.Load() {
+ return false, porttest.ErrRedisDown
+ }
+ return l.Leases.Renew(ctx, roomID, ttl)
+}
+
+func newFlakyFixture(t *testing.T) (*hostFixture, *flakyLeases) {
+ t.Helper()
+ store := porttest.New()
+ songs := newSongs()
+ redis := porttest.NewRedis()
+ node := redis.Node("a")
+ leases := &flakyLeases{Leases: node, resume: make(chan struct{})}
+ f := startHost(t, &hostFixture{store: store, redis: redis, node: node, spy: spy{redis.Node("spy")}},
+ newHost(store, store, songs.preparer, songs.music, Cluster{Instance: "a", Leases: leases, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler)))
+ return f, leases
+}
+
+func TestStopsTenSecondsAfterLastRenewal(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ f, leases := newFlakyFixture(t)
+ id := f.createRoom(t, room.Persistent)
+ f.send(t, id, room.Join{Session: "s1", Member: alice})
+ f.spy.take()
+ leases.fail.Store(true)
+
+ time.Sleep(ownerDeadline - time.Second)
+ f.send(t, id, command("r1", alice, room.Sync{}))
+ checkMessages(t, f.spy.take(), "reply r1 room.SyncResult")
+ time.Sleep(time.Second)
+ synctest.Wait()
+ checkMessages(t, f.spy.take())
+ if n := f.host.LeasesLost(); n != 1 {
+ t.Errorf("LeasesLost = %d,期望 1", n)
+ }
+ if n := f.host.RoomsOwned(); n != 0 {
+ t.Errorf("RoomsOwned = %d,期望 0", n)
+ }
+ other := f.redis.Node("b")
+ if ok, err := other.Acquire(t.Context(), id, leaseTTL); err != nil || ok {
+ t.Errorf("停止处理输入时 b 取得租约 = %v, %v,期望租约还没有过期", ok, err)
+ }
+ time.Sleep(leaseTTL - ownerDeadline)
+ if ok, err := other.Acquire(t.Context(), id, leaseTTL); err != nil || !ok {
+ t.Errorf("租约过期之后 b 取得租约 = %v, %v,期望 true", ok, err)
+ }
+ })
+}
+
+func TestStopsAtDeadlineWhileRenewalHangs(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ f, leases := newFlakyFixture(t)
+ id := f.createRoom(t, room.Persistent)
+ f.send(t, id, room.Join{Session: "s1", Member: alice})
+ f.spy.take()
+ leases.hang.Store(true)
+
+ time.Sleep(ownerDeadline + time.Second)
+ go func() {
+ _ = f.host.send(context.Background(), id, envelope{input: command("r1", alice, room.Sync{})}, true)
+ }()
+ synctest.Wait()
+ checkMessages(t, f.spy.take(), "rejected r1")
+ if n := f.host.LeasesLost(); n != 1 {
+ t.Errorf("LeasesLost = %d,期望 1", n)
+ }
+ close(leases.resume)
+ })
+}
+
+type slowTakeStore struct {
+ *porttest.Store
+ delay time.Duration
+}
+
+func (s *slowTakeStore) TakeRoom(ctx context.Context, id string) (uint32, room.Snapshot, error) {
+ time.Sleep(s.delay)
+ return s.Store.TakeRoom(ctx, id)
+}
+
+func TestSlowTakeRoomKeepsHealthyLease(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newHostFixture(t, &slowTakeStore{Store: store, delay: renewInterval + time.Second}, store)
+ id := f.createRoom(t, room.Persistent)
+ start := time.Now()
+ f.send(t, id, room.Join{Session: "s1", Member: alice})
+ time.Sleep(ownerDeadline + 500*time.Millisecond - time.Since(start))
+ f.send(t, id, command("r1", alice, room.Sync{}))
+ checkMessages(t, f.spy.take(), "started 1", "1/1 room.MembersChanged", "reply r1 room.SyncResult")
+ if n := f.host.LeasesLost(); n != 0 {
+ t.Errorf("LeasesLost = %d,Redis 正常时不应失去租约", n)
+ }
+ })
+}
+
+func TestLeaseLostWhileTakingRoomPublishesNothing(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newHostFixture(t, &slowTakeStore{Store: store, delay: 2 * renewInterval}, store)
+ id := f.createRoom(t, room.Persistent)
+ sent := make(chan error, 1)
+ go func() {
+ sent <- f.host.send(t.Context(), id, envelope{input: room.Join{Session: "s1", Member: alice}}, true)
+ }()
+ time.Sleep(time.Second)
+ f.redis.Flush()
+ if ok, err := f.redis.Node("b").Acquire(t.Context(), id, leaseTTL); err != nil || !ok {
+ t.Fatalf("清空之后 b 取得租约 = %v, %v", ok, err)
+ }
+ time.Sleep(2 * renewInterval)
+ synctest.Wait()
+
+ if err := <-sent; !errors.Is(err, ErrUnavailable) {
+ t.Errorf("接管时租约被取走,输入的错误 = %v,期望 ErrUnavailable", err)
+ }
+ checkMessages(t, f.spy.take())
+ if n := f.host.LeasesLost(); n != 1 {
+ t.Errorf("LeasesLost = %d,期望 1", n)
+ }
+ if n := f.host.RoomsOwned(); n != 0 {
+ t.Errorf("RoomsOwned = %d,期望 0", n)
+ }
+ if owner := f.owner(t, id); owner != "b" {
+ t.Errorf("租约属于 %q,期望仍属于 b", owner)
+ }
+ })
+}
+
+func TestMembershipChangeDetectsLostLease(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newHostFixture(t, store, store)
+ id := f.createRoom(t, room.Persistent)
+ f.send(t, id, room.Join{Session: "s1", Member: alice})
+ f.spy.take()
+ f.redis.Flush()
+ if ok, err := f.redis.Node("b").Acquire(t.Context(), id, leaseTTL); err != nil || !ok {
+ t.Fatalf("清空之后 b 取得租约 = %v, %v", ok, err)
+ }
+
+ f.send(t, id, room.Join{Session: "s2", Member: bob})
+ checkMessages(t, f.spy.take())
+ if n := f.host.LeasesLost(); n != 1 {
+ t.Errorf("LeasesLost = %d,期望 1", n)
+ }
+ })
+}
+
+func TestTermExpiredCountsAsLostLease(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newHostFixture(t, store, store)
+ id := f.createRoom(t, room.Persistent)
+ f.send(t, id, room.Join{Session: "s1", Member: alice})
+ f.spy.take()
+ if _, _, err := store.TakeRoom(t.Context(), id); err != nil {
+ t.Fatal(err)
+ }
+
+ f.send(t, id, command("r1", alice, room.SetPlayMode{Mode: room.Random}))
+ checkMessages(t, f.spy.take(), "rejected r1")
+ if n := f.host.LeasesLost(); n != 1 {
+ t.Errorf("LeasesLost = %d,期望 1", n)
+ }
+ })
+}
+
+type logBuffer struct {
+ mu sync.Mutex
+ buf bytes.Buffer
+}
+
+func (b *logBuffer) Write(p []byte) (int, error) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.buf.Write(p)
+}
+
+func (b *logBuffer) entries(t *testing.T, msg string) []map[string]any {
+ t.Helper()
+ b.mu.Lock()
+ text := b.buf.String()
+ b.mu.Unlock()
+ var found []map[string]any
+ for line := range strings.Lines(strings.TrimSpace(text)) {
+ var entry map[string]any
+ if err := json.Unmarshal([]byte(line), &entry); err != nil {
+ t.Fatalf("日志不是 JSON:%v\n%s", err, line)
+ }
+ if entry["msg"] == msg {
+ found = append(found, entry)
+ }
+ }
+ return found
+}
+
+// 房间的释放通知发不出去时,其他网关要等下一次对账才接管房间,所以要留下日志。
+type noReleaseNotice struct {
+ port.Bus
+}
+
+func (b noReleaseNotice) PublishRoom(ctx context.Context, roomID string, m port.RoomMessage) error {
+ if _, ok := m.(port.OwnerReleased); ok {
+ return porttest.ErrRedisDown
+ }
+ return b.Bus.PublishRoom(ctx, roomID, m)
+}
+
+func TestFailedReleaseNoticeIsLogged(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ songs := newSongs()
+ redis := porttest.NewRedis()
+ node := redis.Node("a")
+ var logs logBuffer
+ f := startHost(t, &hostFixture{store: store, redis: redis, node: node, spy: spy{redis.Node("spy")}},
+ newHost(store, store, songs.preparer, songs.music, Cluster{Instance: "a", Leases: node, Liveness: node, Bus: noReleaseNotice{node}}, slog.New(logctx.NewHandler(slog.NewJSONHandler(&logs, nil)))))
+ id := f.createRoom(t, room.Persistent)
+ f.send(t, id, room.Join{Session: "s1", Member: alice})
+ if err := f.host.Shutdown(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+
+ entries := logs.entries(t, "发布房间的释放通知失败")
+ if len(entries) != 1 {
+ t.Fatalf("发布释放通知失败的日志有 %d 条,期望 1 条", len(entries))
+ }
+ if e := entries[0]; e["level"] != "ERROR" || e["room_id"] != id || e["error"] == nil {
+ t.Errorf("日志 = %v,期望 ERROR 级别、带房间 ID 和错误", e)
+ }
+ if owner := f.owner(t, id); owner != "" {
+ t.Errorf("释放之后租约属于 %q,期望已经删除", owner)
+ }
+ })
+}
+
+func TestRemoteInputsKeepTheirOrder(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newHostFixture(t, store, store)
+ id := f.createRoom(t, room.Persistent)
+ f.host.deliver(context.Background(), port.RoomInput{Room: id, Gateway: "b", Input: room.Join{Session: "b/s1", Member: bob}, Takeover: true})
+ f.host.deliver(context.Background(), port.RoomInput{Room: id, Gateway: "b", Input: command("b/r1", bob, room.Sync{}), Takeover: true})
+ synctest.Wait()
+ msgs := f.spy.take()
+ checkMessages(t, msgs, "started 1", "1/1 room.MembersChanged", "reply b/r1 room.SyncResult")
+ if sessions := msgs[2].(port.Replied).Reply.Result.(room.SyncResult).View.Sessions; len(sessions) != 1 || sessions[0].ID != "b/s1" {
+ t.Errorf("快照中的在线列表 = %+v,期望有刚加入的会话", sessions)
+ }
+ })
+}
+
+// 过了期限才轮到的请求不再交给房间,否则网关已经告诉客户端失败的请求,又会被房间执行一遍。
+func TestForwardedRequestsExpireWhileQueued(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := &blockingStore{Store: porttest.New(), saving: make(chan struct{}, 1), release: make(chan struct{})}
+ f := newHostFixture(t, store, store.Store)
+ id := f.createRoom(t, room.Persistent)
+ for _, in := range []room.Input{
+ command("b/r1", bob, room.SetPlayMode{Mode: room.Random}),
+ command("b/r2", bob, room.Sync{}),
+ command("b/r3", bob, room.Chat{Text: "你好"}),
+ } {
+ f.host.deliver(context.Background(), port.RoomInput{Room: id, Gateway: "b", Input: in, Takeover: true})
+ }
+
+ // r1 卡在落盘。r2 在期限到达时被拒绝,r3 也一样:它同样是 10 秒之前到达的,轮到它时期限已经过了。
+ time.Sleep(ownerReplyTimeout)
+ synctest.Wait()
+ checkMessages(t, f.spy.take(), "started 1", "rejected b/r2", "rejected b/r3")
+
+ close(store.release)
+ synctest.Wait()
+ checkMessages(t, f.spy.take(), "1/1 room.ModeChanged", "reply b/r1 room.Done")
+ })
+}
+
+// send 的两个 select 在多个分支同时就绪时随机选择一个,所以要多试几次:漏掉 ctx 检查时,几乎必然有一次会把输入交出去。
+func TestSendIgnoresInputOfFinishedCaller(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newHostFixture(t, store, store)
+ id := f.createRoom(t, room.Persistent)
+ f.send(t, id, room.Join{Session: "s1", Member: alice})
+ f.spy.take()
+
+ ctx, cancel := context.WithCancel(t.Context())
+ cancel()
+ for range 64 {
+ if err := f.host.send(ctx, id, envelope{input: command("r1", alice, room.Chat{Text: "你好"})}, true); !errors.Is(err, context.Canceled) {
+ t.Fatalf("ctx 结束之后发送的错误 = %v,期望 context.Canceled", err)
+ }
+ }
+ synctest.Wait()
+ checkMessages(t, f.spy.take())
+ })
+}
+
+func TestRemoteRequestsAreRejected(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newHostFixture(t, store, store)
+ elsewhere := f.createRoom(t, room.Persistent)
+ if ok, err := f.redis.Node("c").Acquire(t.Context(), elsewhere, leaseTTL); err != nil || !ok {
+ t.Fatalf("c 取得租约 = %v, %v", ok, err)
+ }
+ missing := "01a15778-1911-7a28-8b2e-e08c2a25f388"
+ f.spy.watch(t, missing)
+ f.host.deliver(context.Background(), port.RoomInput{Room: elsewhere, Gateway: "b", Input: command("b/r1", bob, room.Sync{}), Takeover: true})
+ f.host.deliver(context.Background(), port.RoomInput{Room: missing, Gateway: "b", Patch: &port.InfoPatch{RequestID: "b/r2"}, Takeover: true})
+ f.host.deliver(context.Background(), port.RoomInput{Room: missing, Gateway: "b", Input: room.Leave{Session: "b/s1"}})
+ synctest.Wait()
+ // 两个房间的输入各自排队,彼此之间没有先后。
+ msgs := f.spy.take()
+ slices.SortFunc(msgs, func(a, b port.RoomMessage) int { return strings.Compare(describe(a), describe(b)) })
+ checkMessages(t, msgs, "rejected b/r1", "rejected b/r2 not found")
+ })
+}
+
+type downStore struct {
+ *porttest.Store
+ down atomic.Bool
+}
+
+func (s *downStore) TakeRoom(ctx context.Context, id string) (uint32, room.Snapshot, error) {
+ if s.down.Load() {
+ return 0, room.Snapshot{}, errors.New("连接被拒绝")
+ }
+ return s.Store.TakeRoom(ctx, id)
+}
+
+func TestTakeoverFailuresAreLoggedWhenTheyStart(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := &downStore{Store: porttest.New()}
+ store.down.Store(true)
+ songs := newSongs()
+ redis := porttest.NewRedis()
+ node := redis.Node("a")
+ var logs logBuffer
+ f := startHost(t, &hostFixture{store: store.Store, redis: redis, node: node, spy: spy{redis.Node("spy")}},
+ newHost(store, store, songs.preparer, songs.music, cluster(node, "a"), slog.New(slog.NewJSONHandler(&logs, nil))))
+ for range 3 {
+ f.createRoom(t, room.Persistent)
+ }
+ ctx, stop := context.WithCancel(t.Context())
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ f.host.Reconcile(ctx)
+ }()
+ t.Cleanup(func() {
+ stop()
+ <-done
+ })
+
+ time.Sleep(3 * (reconcileInterval + reconcileJitter))
+ synctest.Wait()
+ if n := len(logs.entries(t, "接管房间失败")); n != 1 {
+ t.Fatalf("3 个房间在几次对账中都接管失败,记了 %d 条日志,期望 1 条", n)
+ }
+ store.down.Store(false)
+ time.Sleep(reconcileInterval + reconcileJitter)
+ synctest.Wait()
+ if n := f.host.RoomsOwned(); n != 3 {
+ t.Fatalf("存储恢复之后运行着 %d 个房间,期望 3 个", n)
+ }
+ store.down.Store(true)
+ stuck := f.createRoom(t, room.Persistent)
+ time.Sleep(reconcileInterval + reconcileJitter)
+ synctest.Wait()
+ if n := len(logs.entries(t, "接管房间失败")); n != 2 {
+ t.Errorf("接管恢复正常之后又失败,日志共 %d 条,期望 2 条", n)
+ }
+
+ if ok, err := f.redis.Node("b").Acquire(t.Context(), stuck, 24*time.Hour); err != nil || !ok {
+ t.Fatalf("b 取得租约 = %v, %v", ok, err)
+ }
+ store.down.Store(false)
+ time.Sleep(2 * time.Minute)
+ synctest.Wait()
+ store.down.Store(true)
+ f.createRoom(t, room.Persistent)
+ time.Sleep(reconcileInterval + reconcileJitter)
+ synctest.Wait()
+ if n := len(logs.entries(t, "接管房间失败")); n != 3 {
+ t.Errorf("存储恢复期间本实例没有接管成功过,两分钟之后又失败,日志共 %d 条,期望 3 条", n)
+ }
+
+ time.Sleep(time.Minute + reconcileInterval + reconcileJitter)
+ synctest.Wait()
+ if n := len(logs.entries(t, "接管房间失败")); n != 4 {
+ t.Errorf("同一次故障持续了一分钟以上,日志共 %d 条,期望 4 条", n)
+ }
+ })
+}
+
+type recoveringLiveness struct {
+ port.Liveness
+ gateway *porttest.Node
+ unreachable string
+ recovered atomic.Bool
+ failed atomic.Bool
+}
+
+func (l *recoveringLiveness) Expired(ctx context.Context, ids []string) ([]string, error) {
+ // 复查一次只查一个网关。
+ if len(ids) == 1 && ids[0] == l.unreachable {
+ l.failed.Store(true)
+ return nil, errors.New("连接被拒绝")
+ }
+ expired, err := l.Liveness.Expired(ctx, ids)
+ if err == nil && len(expired) > 0 && l.recovered.CompareAndSwap(false, true) {
+ _, err = l.gateway.Heartbeat(ctx, aliveTTL)
+ }
+ return expired, err
+}
+
+// 复查读取失败时不知道网关是否还活着,不为它的会话发 Leave。
+func TestRecoveredGatewayKeepsItsSessions(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ songs := newSongs()
+ redis := porttest.NewRedis()
+ node := redis.Node("a")
+ gateway, unreachable := redis.Node("b"), redis.Node("c")
+ liveness := &recoveringLiveness{Liveness: node, gateway: gateway, unreachable: "c"}
+ f := startHost(t, &hostFixture{store: store, redis: redis, node: node, spy: spy{redis.Node("spy")}},
+ newHost(store, store, songs.preparer, songs.music, Cluster{Instance: "a", Leases: node, Liveness: liveness, Bus: node}, slog.New(slog.DiscardHandler)))
+ id := f.createRoom(t, room.Persistent)
+ for _, g := range []*porttest.Node{gateway, unreachable} {
+ if _, err := g.Heartbeat(t.Context(), aliveTTL); err != nil {
+ t.Fatal(err)
+ }
+ }
+ f.send(t, id, room.Join{Session: "a/s1", Member: alice})
+ f.host.deliver(context.Background(), port.RoomInput{Room: id, Gateway: "b", Input: room.Join{Session: "b/s1", Member: bob}})
+ f.host.deliver(context.Background(), port.RoomInput{Room: id, Gateway: "c", Input: room.Join{Session: "c/s1", Member: bob}})
+ synctest.Wait()
+ ctx, stop := context.WithCancel(t.Context())
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ f.host.Reconcile(ctx)
+ }()
+ t.Cleanup(func() {
+ stop()
+ <-done
+ })
+ synctest.Wait()
+ f.spy.take()
+
+ time.Sleep(aliveTTL + reconcileInterval + reconcileJitter)
+ synctest.Wait()
+ if !liveness.recovered.Load() {
+ t.Fatal("对账没有读到 b 的存活键过期")
+ }
+ if !liveness.failed.Load() {
+ t.Fatal("对账没有复查 c 的存活键")
+ }
+ checkMessages(t, f.spy.take())
+ })
+}
+
+func TestStoppedHostRejectsForwardedRequests(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newHostFixture(t, store, store)
+ id := f.createRoom(t, room.Persistent)
+ if err := f.host.Shutdown(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ f.host.deliver(context.Background(), port.RoomInput{Room: id, Gateway: "b", Input: command("b/r1", bob, room.Sync{}), Takeover: true})
+ synctest.Wait()
+ checkMessages(t, f.spy.take(), "rejected b/r1")
+ })
+}
diff --git a/internal/app/realtime/observer_test.go b/internal/app/realtime/observer_test.go
new file mode 100644
index 0000000..39b6cc6
--- /dev/null
+++ b/internal/app/realtime/observer_test.go
@@ -0,0 +1,281 @@
+package realtime
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "slices"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "testing/synctest"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/app/port/porttest"
+ "github.com/dihemoe/alisten/internal/domain/member"
+ "github.com/dihemoe/alisten/internal/domain/room"
+)
+
+var (
+ carol = member.Member{ID: "00000000-0000-7000-8000-00000000000c", Name: "carol"}
+ robot = member.Member{ID: "00000000-0000-7000-8000-0000000000b0", Name: "bot"}
+)
+
+// 只能在 synctest 的 bubble 中调用。
+func (in *instance) observe(t *testing.T, roomID string, m member.Member, bot bool, credential string) *Observer {
+ t.Helper()
+ o, err := in.gateway.Observe(t.Context(), roomID, m, bot, credential)
+ if err != nil {
+ t.Fatalf("%s 打开事件流:%v", m.Name, err)
+ }
+ t.Cleanup(func() { in.gateway.Unobserve(o) })
+ synctest.Wait()
+ return o
+}
+
+func checkObserved(t *testing.T, o *Observer, want ...string) {
+ t.Helper()
+ if got := received(t, o); !slices.Equal(got, want) {
+ t.Errorf("%s 的事件流收到:\n%s\n期望:\n%s", o.member.Name, strings.Join(got, "\n"), strings.Join(want, "\n"))
+ }
+}
+
+func TestObserverGetsSnapshotThenEvents(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newGatewayFixture(t, store, store)
+ id := createRoom(t, store, room.Persistent)
+ f.join(t, id, alice)
+ o := f.observe(t, id, robot, true, "key")
+ if _, err := f.gateway.Do(t.Context(), id, robot, room.Chat{Text: "你好"}); err != nil {
+ t.Fatal(err)
+ }
+ checkObserved(t, o, "snapshot 1/1 [alice]", "1/2 room.ChatPosted")
+ })
+}
+
+func TestObserverIsNotASession(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newGatewayFixture(t, store, store)
+ id := createRoom(t, store, room.Persistent)
+ o := f.observe(t, id, robot, true, "key")
+ for _, m := range []member.Member{alice, bob, carol} {
+ f.join(t, id, m)
+ }
+ if got := received(t, o); len(got) == 0 || got[0] != "snapshot 1/0 []" {
+ t.Errorf("事件流收到 %q,期望以空的在线列表开始", got)
+ }
+ if n := f.online(t, id); n != 3 {
+ t.Errorf("在线人数 = %d,期望只有 3 个会话", n)
+ }
+ if _, err := f.gateway.Do(t.Context(), id, alice, room.Enqueue{Track: sunny, Meta: sunnyM}); err != nil {
+ t.Fatal(err)
+ }
+ synctest.Wait()
+ // 3 个会话时 1 票就切歌,4 个时要 2 票。
+ res, err := f.gateway.Do(t.Context(), id, alice, room.VoteSkip{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if vote := res.(room.VoteResult); vote.Required != 1 || !vote.Skipped {
+ t.Errorf("3 个会话和 1 个事件流时投票的结果 = %+v,期望只要 1 票就切歌", vote)
+ }
+ })
+}
+
+func TestRoomNoticesCloseObservers(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newGatewayFixture(t, store, store)
+ locked, deleted, other := createRoom(t, store, room.Persistent), createRoom(t, store, room.Persistent), createRoom(t, store, room.Persistent)
+ member, bot := f.observe(t, locked, alice, false, "alice"), f.observe(t, locked, robot, true, "key")
+ gone, restarted := f.observe(t, deleted, alice, false, "alice"), f.observe(t, other, robot, true, "key")
+ received(t, bot)
+
+ if err := f.gateway.Revoke(t.Context(), locked); err != nil {
+ t.Fatal(err)
+ }
+ checkObserved(t, member, fmt.Sprintf("closed %d", CloseRevoked))
+ checkObserved(t, bot)
+ if err := f.gateway.Delete(t.Context(), deleted); err != nil {
+ t.Fatal(err)
+ }
+ checkObserved(t, gone, fmt.Sprintf("closed %d", CloseRoomGone))
+ f.node.Disconnect()
+ checkObserved(t, restarted, fmt.Sprintf("closed %d", CloseRestart))
+ })
+}
+
+func TestAPIKeyResetClosesOnlyBotObservers(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newClusterFixture(t, store, store)
+ session := f.b.join(t, f.room, alice)
+ member, bot := f.b.observe(t, f.room, bob, false, "bob"), f.b.observe(t, f.room, robot, true, "key")
+ received(t, session)
+ received(t, member)
+
+ if err := f.a.gateway.RevokeAPIKey(t.Context(), f.room); err != nil {
+ t.Fatal(err)
+ }
+ checkObserved(t, bot, fmt.Sprintf("closed %d", CloseRevoked))
+ checkReceived(t, session)
+ checkObserved(t, member)
+ })
+}
+
+func TestObserverGetsNewSnapshotWhenRunChanges(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := &failingStore{Store: porttest.New()}
+ f := newGatewayFixture(t, store, store.Store)
+ id := createRoom(t, store.Store, room.Persistent)
+ o := f.observe(t, id, robot, true, "key")
+ if _, err := f.gateway.Do(t.Context(), id, alice, room.Chat{Text: "你好"}); err != nil {
+ t.Fatal(err)
+ }
+ checkObserved(t, o, "snapshot 1/0 []", "1/1 room.ChatPosted")
+
+ store.fail.Store(true)
+ if _, err := f.gateway.Do(t.Context(), id, alice, room.SetPlayMode{Mode: room.Random}); !errors.Is(err, ErrUnavailable) {
+ t.Fatalf("落盘失败时的错误 = %v,期望 ErrUnavailable", err)
+ }
+ store.fail.Store(false)
+ // 放弃房间的那次运行在自己的 goroutine 里收尾,收尾完之前到达的请求会得到 ErrUnavailable;等它收尾完,Sync 才会重新接管房间。
+ synctest.Wait()
+ if _, err := f.gateway.Do(t.Context(), id, alice, room.Sync{}); err != nil {
+ t.Fatal(err)
+ }
+ synctest.Wait()
+ if _, err := f.gateway.Do(t.Context(), id, alice, room.Chat{Text: "又来了"}); err != nil {
+ t.Fatal(err)
+ }
+ checkObserved(t, o, "snapshot 2/0 []", "2/1 room.ChatPosted")
+ })
+}
+
+// 房间的事件发布卡住时,房间停在这一批 effect 上,之后的输入都在排队。
+type heldEmits struct {
+ port.Bus
+ holding atomic.Bool
+ release chan struct{}
+}
+
+func (b *heldEmits) PublishRoom(ctx context.Context, roomID string, m port.RoomMessage) error {
+ if _, ok := m.(port.Emitted); ok && b.holding.Load() {
+ <-b.release
+ }
+ return b.Bus.PublishRoom(ctx, roomID, m)
+}
+
+// a 的租约已经丢失,但它还没有发现:排在它那里的快照请求在 b 接管、发出变更通知之后才得到回复,这份快照已经过时。
+func TestObserverSkipsSnapshotOfEarlierRun(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ songs := newSongs()
+ redis := porttest.NewRedis()
+ node := redis.Node("a")
+ bus := &heldEmits{Bus: node, release: make(chan struct{})}
+ h, g := New(store, store, songs.music, songs.preparer, Cluster{Instance: "a", Leases: node, Liveness: node, Bus: bus}, slog.New(slog.DiscardHandler))
+ ctx, stop := context.WithCancel(context.Background())
+ wait, err := Start(ctx, h, g)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ _ = h.Shutdown(context.Background())
+ _ = g.Shutdown(context.Background())
+ stop()
+ wait()
+ })
+ b := startInstance(t, redis, "b", store, store, songs)
+ synctest.Wait()
+ id := createRoom(t, store, room.Persistent)
+ if _, err := g.Do(t.Context(), id, alice, room.Sync{}); err != nil {
+ t.Fatal(err)
+ }
+
+ bus.holding.Store(true)
+ go func() { _, _ = g.Do(context.Background(), id, alice, room.Chat{Text: "你好"}) }()
+ synctest.Wait()
+ var o *Observer
+ opened := make(chan error, 1)
+ go func() {
+ var err error
+ o, err = b.gateway.Observe(context.Background(), id, robot, true, "key")
+ opened <- err
+ }()
+ synctest.Wait()
+ redis.Flush()
+ if _, err := b.gateway.Do(t.Context(), id, bob, room.Sync{}); err != nil {
+ t.Fatal(err)
+ }
+ close(bus.release)
+ if err := <-opened; err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { b.gateway.Unobserve(o) })
+ checkObserved(t, o, "snapshot 2/0 []")
+ })
+}
+
+func TestObserverLimitPerRoom(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newGatewayFixture(t, store, store)
+ full, other := createRoom(t, store, room.Persistent), createRoom(t, store, room.Persistent)
+ first, err := f.gateway.Observe(t.Context(), full, robot, true, "key")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for range observerLimit - 1 {
+ f.observe(t, full, robot, true, "key")
+ }
+ if _, err := f.gateway.Observe(t.Context(), full, alice, false, "alice"); !errors.Is(err, ErrTooManyObservers) {
+ t.Errorf("第 %d 个事件流的错误 = %v,期望 ErrTooManyObservers", observerLimit+1, err)
+ }
+ f.observe(t, other, alice, false, "alice")
+ f.gateway.Unobserve(first)
+ f.observe(t, full, alice, false, "alice")
+ })
+}
+
+func TestFullRoomReplacesOldestObserverOfSameCredential(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newGatewayFixture(t, store, store)
+ id := createRoom(t, store, room.Persistent)
+ alices := f.observe(t, id, alice, false, "alice")
+ oldest := f.observe(t, id, robot, true, "key")
+ bobs := f.observe(t, id, bob, false, "bob")
+ newer := f.observe(t, id, robot, true, "key")
+ carols := f.observe(t, id, carol, false, "carol")
+ replacing := f.observe(t, id, robot, true, "key")
+ checkObserved(t, oldest, fmt.Sprintf("closed %d", CloseReplaced))
+ open := []*Observer{alices, bobs, newer, carols, replacing}
+ for _, o := range open {
+ checkObserved(t, o, "snapshot 1/0 []")
+ }
+
+ if _, err := f.gateway.Observe(t.Context(), id, bob, false, "bob 的另一张票据"); !errors.Is(err, ErrTooManyObservers) {
+ t.Errorf("名额用完、没有同一凭证的事件流时的错误 = %v,期望 ErrTooManyObservers", err)
+ }
+ for _, o := range open {
+ checkObserved(t, o)
+ }
+ })
+}
+
+func TestFailedObserveFreesItsSlot(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ store := porttest.New()
+ f := newGatewayFixture(t, store, store)
+ missing := "01a15778-1911-7a28-8b2e-e08c2a25f388"
+ for i := range observerLimit + 1 {
+ if _, err := f.gateway.Observe(t.Context(), missing, robot, true, fmt.Sprint(i)); !errors.Is(err, port.ErrRoomNotFound) {
+ t.Fatalf("第 %d 次打开不存在的房间的事件流 = %v,期望 ErrRoomNotFound", i+1, err)
+ }
+ }
+ })
+}
diff --git a/internal/app/site/site_test.go b/internal/app/site/site_test.go
new file mode 100644
index 0000000..b84c39a
--- /dev/null
+++ b/internal/app/site/site_test.go
@@ -0,0 +1,74 @@
+package site
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/app/port/porttest"
+)
+
+func TestBrandName(t *testing.T) {
+ s := New(porttest.New())
+ if got, err := s.BrandName(t.Context()); err != nil || got != "Alisten" {
+ t.Fatalf("没有设置过时 = %q, %v,期望默认的 Alisten", got, err)
+ }
+ if saved, err := s.SetBrandName(t.Context(), " 周末听歌会\n"); err != nil || saved != "周末听歌会" {
+ t.Fatalf("SetBrandName = %q, %v,期望返回去掉首尾空白的 %q", saved, err, "周末听歌会")
+ }
+ if got, err := s.BrandName(t.Context()); err != nil || got != "周末听歌会" {
+ t.Errorf("设置之后 = %q, %v,期望去掉首尾空白的 %q", got, err, "周末听歌会")
+ }
+}
+
+func TestBrandNameCountsCharacters(t *testing.T) {
+ s := New(porttest.New())
+ for _, name := range []string{strings.Repeat("歌", 20), strings.Repeat("🎵", 20), " " + strings.Repeat("a", 20) + " "} {
+ if _, err := s.SetBrandName(t.Context(), name); err != nil {
+ t.Errorf("SetBrandName(%q) = %v,期望 20 个字符可以保存", name, err)
+ }
+ }
+}
+
+func TestInvalidBrandName(t *testing.T) {
+ s := New(porttest.New())
+ if _, err := s.SetBrandName(t.Context(), "周末听歌会"); err != nil {
+ t.Fatal(err)
+ }
+ for _, name := range []string{"", " \t ", strings.Repeat("歌", 21), "周末\n听歌会", "周末\u0085听歌会"} {
+ if _, err := s.SetBrandName(t.Context(), name); !errors.Is(err, ErrInvalidBrandName) {
+ t.Errorf("SetBrandName(%q) = %v,期望 ErrInvalidBrandName", name, err)
+ }
+ }
+ if got, err := s.BrandName(t.Context()); err != nil || got != "周末听歌会" {
+ t.Errorf("被拒绝之后 = %q, %v,期望仍是 %q", got, err, "周末听歌会")
+ }
+}
+
+func TestBrandNameNeedsVisibleCharacter(t *testing.T) {
+ s := New(porttest.New())
+ for _, name := range []string{"\u200b", "\u200b \u200b", "\u200b\u3000\u200b", "\u3000 \u3000"} {
+ if _, err := s.SetBrandName(t.Context(), name); !errors.Is(err, ErrInvalidBrandName) {
+ t.Errorf("SetBrandName(%q) = %v,期望 ErrInvalidBrandName", name, err)
+ }
+ }
+ if _, err := s.SetBrandName(t.Context(), "\u200b周末听歌会"); err != nil {
+ t.Errorf("SetBrandName(%q) = %v,期望有看得见的字符就可以保存", "\u200b周末听歌会", err)
+ }
+}
+
+type brokenSite struct{ port.Site }
+
+var errBroken = errors.New("连接中断")
+
+func (brokenSite) BrandName(context.Context) (string, error) {
+ return "", errBroken
+}
+
+func TestBrandNameReadFailure(t *testing.T) {
+ if got, err := New(brokenSite{}).BrandName(t.Context()); !errors.Is(err, errBroken) {
+ t.Errorf("读取失败时 = %q, %v,期望返回错误而不是默认的品牌名", got, err)
+ }
+}
diff --git a/internal/bootstrap/cluster_test.go b/internal/bootstrap/cluster_test.go
new file mode 100644
index 0000000..adb5c0f
--- /dev/null
+++ b/internal/bootstrap/cluster_test.go
@@ -0,0 +1,299 @@
+package bootstrap
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/coder/websocket"
+ goredis "github.com/redis/go-redis/v9"
+
+ "github.com/dihemoe/alisten/internal/adapter/out/redis"
+)
+
+// cut 之后断开全部连接,也不再接受新连接,模拟实例与 Redis 之间的网络中断。
+type redisProxy struct {
+ listener net.Listener
+ mu sync.Mutex
+ conns []net.Conn
+ cut bool
+}
+
+func startRedisProxy(t *testing.T, target string) (*redisProxy, string) {
+ t.Helper()
+ u, err := url.Parse(target)
+ if err != nil {
+ t.Fatal(err)
+ }
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ p := &redisProxy{listener: listener}
+ t.Cleanup(p.Cut)
+ upstream := u.Host
+ go func() {
+ for {
+ client, err := listener.Accept()
+ if err != nil {
+ return
+ }
+ server, err := net.Dial("tcp", upstream)
+ if err != nil {
+ _ = client.Close()
+ continue
+ }
+ p.mu.Lock()
+ if p.cut {
+ _ = client.Close()
+ _ = server.Close()
+ } else {
+ p.conns = append(p.conns, client, server)
+ go func() { _, _ = io.Copy(server, client); _ = server.Close() }()
+ go func() { _, _ = io.Copy(client, server); _ = client.Close() }()
+ }
+ p.mu.Unlock()
+ }
+ }()
+ u.Host = listener.Addr().String()
+ return p, u.String()
+}
+
+func (p *redisProxy) Cut() {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.cut = true
+ _ = p.listener.Close()
+ for _, c := range p.conns {
+ _ = c.Close()
+ }
+}
+
+func enterRoom(t *testing.T, svc service, roomID string) (string, string) {
+ t.Helper()
+ var session struct{ MemberToken string }
+ _, _, _, body := send(t, svc.server, "POST /api/members\n\n{}")
+ if err := json.Unmarshal(body, &session); err != nil {
+ t.Fatal(err)
+ }
+ line, status, _, body := send(t, svc.server, "POST /api/rooms/"+roomID+"/tickets\nAuthorization: Bearer "+session.MemberToken+"\n\n{}")
+ var ticket struct{ Ticket string }
+ if err := json.Unmarshal(body, &ticket); err != nil || status != http.StatusCreated {
+ t.Fatalf("%s = %d %s", line, status, body)
+ }
+ return session.MemberToken, ticket.Ticket
+}
+
+func createPersistentRoom(t *testing.T, svc service, name string) string {
+ t.Helper()
+ line, status, _, body := send(t, svc.server, "POST /api/admin/rooms\nAuthorization: Bearer admin-secret\n\n{\"name\": \""+name+"\", \"description\": \"\"}")
+ var created struct{ ID string }
+ if err := json.Unmarshal(body, &created); err != nil || status != http.StatusCreated {
+ t.Fatalf("%s = %d %s", line, status, body)
+ }
+ return created.ID
+}
+
+// 房间没有 owner 时由 svc 接管。
+func roomRequest(t *testing.T, svc service, ticket, request, data string) {
+ t.Helper()
+ line, status, _, body := send(t, svc.server, request+"\nAuthorization: Bearer "+ticket+"\n\n"+data)
+ if status/100 != 2 {
+ t.Fatalf("%s = %d %s", line, status, body)
+ }
+}
+
+type wsClient struct {
+ conn *websocket.Conn
+}
+
+func connect(t *testing.T, svc service, roomID, ticket string) (wsClient, map[string]any) {
+ t.Helper()
+ c := wsClient{conn: dial(t, svc.server, "")}
+ c.write(t, `{"kind":"request","type":"hello","id":"h1","data":{"roomId":"`+roomID+`","ticket":"`+ticket+`","protocolVersion":"`+protocolVersion(t)+`"}}`)
+ hello := c.read(t)
+ if hello["error"] != nil {
+ t.Fatalf("握手失败:%v", hello)
+ }
+ return c, hello["data"].(map[string]any)
+}
+
+func (c wsClient) write(t *testing.T, frame string) {
+ t.Helper()
+ if err := c.conn.Write(t.Context(), websocket.MessageText, []byte(frame)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func (c wsClient) read(t *testing.T) map[string]any {
+ t.Helper()
+ ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
+ defer cancel()
+ _, frame, err := c.conn.Read(ctx)
+ if err != nil {
+ t.Fatalf("读消息:%v", err)
+ }
+ var m map[string]any
+ if err := json.Unmarshal(frame, &m); err != nil {
+ t.Fatal(err)
+ }
+ return m
+}
+
+func (c wsClient) awaitTerm(t *testing.T, term int) {
+ t.Helper()
+ for {
+ if m := c.read(t); m["type"] == "members.updated" && m["term"] == float64(term) {
+ return
+ }
+ }
+}
+
+func newObserver(t *testing.T, redisURL string) *redis.Leases {
+ t.Helper()
+ opts, err := goredis.ParseURL(redisURL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rdb := goredis.NewClient(opts)
+ t.Cleanup(func() { _ = rdb.Close() })
+ return redis.NewLeases(rdb, "observer")
+}
+
+func owner(t *testing.T, observer *redis.Leases, roomID string) string {
+ t.Helper()
+ owners, err := observer.Owners(t.Context(), []string{roomID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return owners[0]
+}
+
+// 各实例的对账可能先于按需接管取得新房间,所以测试按实际的 owner 安排其余的实例。
+func TestInstancesShareRooms(t *testing.T) {
+ databaseURL, redisURL := startPostgres(t)(t), startValkey(t)(t)
+ contractEnv(t, databaseURL, redisURL)
+ var services []service
+ proxies := map[string]*redisProxy{}
+ for range 3 {
+ proxy, proxied := startRedisProxy(t, redisURL)
+ connectionEnv(t, proxied)
+ svc := startService(t)
+ services = append(services, svc)
+ proxies[svc.instance] = proxy
+ }
+ observer := newObserver(t, redisURL)
+ byInstance := func(id string) (service, []service) {
+ var owner service
+ var others []service
+ for _, svc := range services {
+ if svc.instance == id {
+ owner = svc
+ } else {
+ others = append(others, svc)
+ }
+ }
+ if owner.server == nil {
+ t.Fatalf("实例 %q 不在这次测试中", id)
+ }
+ return owner, others
+ }
+
+ hall := createPersistentRoom(t, services[0], "大厅")
+ _, botTicket := enterRoom(t, services[0], hall)
+ roomRequest(t, services[0], botTicket, "GET /api/room", "")
+ first, others := byInstance(owner(t, observer, hall))
+ _, ticket := enterRoom(t, others[0], hall)
+ client, hello := connect(t, others[0], hall, ticket)
+ if room := hello["room"].(map[string]any); room["term"] != float64(1) || room["members"].(map[string]any)["onlineCount"] != float64(1) {
+ t.Errorf("握手的快照 = %v,期望任期 1、在线 1 人", room)
+ }
+ client.write(t, `{"kind":"request","type":"chat.send","id":"c1","data":{"content":"你好"}}`)
+ if m := client.read(t); m["type"] != "chat.message" || m["term"] != float64(1) {
+ t.Errorf("收到 %v,期望任期 1 的聊天消息", m)
+ }
+ if m := client.read(t); m["type"] != "chat.send" || m["error"] != nil {
+ t.Errorf("收到 %v,期望聊天的成功回复", m)
+ }
+ if first.public.host.RoomsOwned() != 1 || others[0].public.host.RoomsOwned() != 0 {
+ t.Errorf("owner 运行 %d 个房间、客户端所在的实例运行 %d 个,期望房间一直在 owner", first.public.host.RoomsOwned(), others[0].public.host.RoomsOwned())
+ }
+
+ cut := time.Now()
+ proxies[first.instance].Cut()
+ for got := owner(t, observer, hall); got == "" || got == first.instance; got = owner(t, observer, hall) {
+ if time.Since(cut) > 25*time.Second {
+ t.Fatalf("owner 断开 %v 之后仍没有实例接管大厅", time.Since(cut))
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+ if takeover := time.Since(cut); takeover < 5*time.Second || takeover > 21*time.Second {
+ t.Errorf("owner 断开 %v 之后被接管,期望在租约过期之后、对账的间隔之内", takeover)
+ }
+ client.awaitTerm(t, 2)
+
+ lounge := createPersistentRoom(t, others[0], "二厅")
+ _, loungeBot := enterRoom(t, others[0], lounge)
+ roomRequest(t, others[0], loungeBot, "GET /api/room", "")
+ var guests []wsClient
+ for _, svc := range others {
+ _, ticket := enterRoom(t, svc, lounge)
+ guest, _ := connect(t, svc, lounge, ticket)
+ guests = append(guests, guest)
+ }
+ leaving, _ := byInstance(owner(t, observer, lounge))
+ released := time.Now()
+ if err := leaving.public.host.Shutdown(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ for _, guest := range guests {
+ guest.awaitTerm(t, 2)
+ }
+ if elapsed := time.Since(released); elapsed > time.Second {
+ t.Errorf("二厅释放 %v 之后才被接管,期望随即接管", elapsed)
+ }
+ if got := owner(t, observer, lounge); got == "" || got == leaving.instance || got == first.instance {
+ t.Errorf("二厅的 owner = %q,期望另一个有会话的实例", got)
+ }
+}
+
+func TestPreparedOutcomeReachesOwnerOnAnotherInstance(t *testing.T) {
+ databaseURL, redisURL := startPostgres(t)(t), startValkey(t)(t)
+ contractEnv(t, databaseURL, redisURL)
+ services := []service{startService(t), startService(t)}
+ observer := newObserver(t, redisURL)
+ hall := createPersistentRoom(t, services[0], "大厅")
+ _, ticket := enterRoom(t, services[0], hall)
+ client, _ := connect(t, services[0], hall, ticket)
+ id := owner(t, observer, hall)
+ for _, svc := range services {
+ if svc.instance == id {
+ if err := svc.public.jobs.Stop(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+
+ client.write(t, `{"kind":"request","type":"queue.add","id":"a1","data":{"track":{"source":"netease","id":"186016"}}}`)
+ added := time.Now()
+ for {
+ m := client.read(t)
+ if m["type"] != "playback.updated" {
+ continue
+ }
+ playing := m["data"].(map[string]any)["nowPlaying"].(map[string]any)
+ if playing["durationMs"] != float64(269500) {
+ t.Fatalf("开始播放的歌 = %v,期望准备好的晴天", playing)
+ }
+ break
+ }
+ if elapsed := time.Since(added); elapsed > messageWait {
+ t.Errorf("点歌 %v 之后才开始播放,期望准备结果经 tracks.prepared 送达", elapsed)
+ }
+}
diff --git a/internal/bootstrap/config_test.go b/internal/bootstrap/config_test.go
new file mode 100644
index 0000000..0fc9390
--- /dev/null
+++ b/internal/bootstrap/config_test.go
@@ -0,0 +1,85 @@
+package bootstrap
+
+import (
+ "log/slog"
+ "os"
+ "testing"
+)
+
+// 先调用 t.Setenv,测试结束后才会恢复原值。
+func unsetenv(t *testing.T, name string) {
+ t.Helper()
+ t.Setenv(name, "")
+ if err := os.Unsetenv(name); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestListenAddr(t *testing.T) {
+ cases := []struct {
+ name string
+ value *string
+ want string
+ wantErr bool
+ }{
+ {name: "未设置时使用默认值", value: nil, want: ":8080"},
+ {name: "指定主机和端口", value: ptr("127.0.0.1:8081"), want: "127.0.0.1:8081"},
+ {name: "空字符串不回退到默认值", value: ptr(""), wantErr: true},
+ {name: "缺少端口", value: ptr("8080"), wantErr: true},
+ {name: "端口为 0", value: ptr(":0"), wantErr: true},
+ {name: "端口超出范围", value: ptr(":70000"), wantErr: true},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if tc.value == nil {
+ unsetenv(t, "ALISTEN_LISTEN")
+ } else {
+ t.Setenv("ALISTEN_LISTEN", *tc.value)
+ }
+ got, err := listenAddr("ALISTEN_LISTEN", ":8080")
+ if tc.wantErr {
+ if err == nil {
+ t.Fatalf("期望报错,实际得到 %q", got)
+ }
+ return
+ }
+ if err != nil || got != tc.want {
+ t.Fatalf("listenAddr() = %q, %v,期望 %q", got, err, tc.want)
+ }
+ })
+ }
+}
+
+func TestLoadLogLevel(t *testing.T) {
+ cases := []struct {
+ name string
+ value *string
+ want slog.Level
+ wantErr bool
+ }{
+ {name: "未设置时为 info", value: nil, want: slog.LevelInfo},
+ {name: "debug", value: ptr("debug"), want: slog.LevelDebug},
+ {name: "未知级别", value: ptr("verbose"), wantErr: true},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if tc.value == nil {
+ unsetenv(t, "ALISTEN_LOG_LEVEL")
+ } else {
+ t.Setenv("ALISTEN_LOG_LEVEL", *tc.value)
+ }
+ got, err := loadLogLevel()
+ if tc.wantErr {
+ if err == nil {
+ t.Fatalf("期望报错,实际得到 %v", got)
+ }
+ return
+ }
+ if err != nil || got != tc.want {
+ t.Fatalf("loadLogLevel() = %v, %v,期望 %v", got, err, tc.want)
+ }
+ })
+ }
+}
+
+func ptr(s string) *string { return &s }
diff --git a/internal/bootstrap/contract_test.go b/internal/bootstrap/contract_test.go
new file mode 100644
index 0000000..436e614
--- /dev/null
+++ b/internal/bootstrap/contract_test.go
@@ -0,0 +1,746 @@
+package bootstrap
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "crypto/rand"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "maps"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "reflect"
+ "regexp"
+ "slices"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/coder/websocket"
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+ "github.com/prometheus/client_golang/prometheus"
+ goredis "github.com/redis/go-redis/v9"
+ "github.com/testcontainers/testcontainers-go"
+ tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
+ tcvalkey "github.com/testcontainers/testcontainers-go/modules/valkey"
+ sdktrace "go.opentelemetry.io/otel/sdk/trace"
+ "go.opentelemetry.io/otel/sdk/trace/tracetest"
+ "golang.org/x/tools/txtar"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/app/port/porttest"
+ "github.com/dihemoe/alisten/internal/app/realtime"
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+func TestContract(t *testing.T) {
+ var files []string
+ for _, dir := range []string{"http", "ws"} {
+ matches, err := filepath.Glob(filepath.Join("testdata", dir, "*.txtar"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(matches) == 0 {
+ t.Fatalf("testdata/%s 中没有用例", dir)
+ }
+ files = append(files, matches...)
+ }
+ newDatabase, newRedis := startPostgres(t), startValkey(t)
+ for _, file := range files {
+ name, _ := filepath.Rel("testdata", strings.TrimSuffix(file, ".txtar"))
+ t.Run(filepath.ToSlash(name), func(t *testing.T) {
+ archive, err := txtar.ParseFile(file)
+ if err != nil {
+ t.Fatal(err)
+ }
+ runContract(t, archive.Files, newDatabase(t), newRedis(t))
+ })
+ }
+}
+
+// 整个包共用一个 PostgreSQL 和一个 Valkey 容器:如果每个测试各起一组容器,一次 go test 要在宿主机上创建、删除几十块虚拟网卡,
+// 宿主机上的浏览器每次都会当作网络变化而断开连接。
+var shared struct {
+ postgresOnce sync.Once
+ postgres *tcpostgres.PostgresContainer
+ postgresURL string
+ postgresErr error
+ databases int
+ valkeyOnce sync.Once
+ valkey *tcvalkey.ValkeyContainer
+ valkeyURL string
+ valkeyErr error
+ valkeyDBs int
+}
+
+func startPostgres(t *testing.T) func(*testing.T) string {
+ t.Helper()
+ shared.postgresOnce.Do(func() {
+ ctx := context.Background()
+ shared.postgres, shared.postgresErr = tcpostgres.Run(ctx, "postgres:18-alpine", tcpostgres.BasicWaitStrategies())
+ if shared.postgresErr == nil {
+ shared.postgresURL, shared.postgresErr = shared.postgres.ConnectionString(ctx, "sslmode=disable")
+ }
+ })
+ if shared.postgresErr != nil {
+ t.Fatalf("启动 PostgreSQL 容器:%v", shared.postgresErr)
+ }
+ return func(t *testing.T) string {
+ conn, err := pgx.Connect(t.Context(), shared.postgresURL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = conn.Close(context.Background()) }()
+ shared.databases++
+ name := fmt.Sprintf("test_%d", shared.databases)
+ if _, err := conn.Exec(t.Context(), "CREATE DATABASE "+name); err != nil {
+ t.Fatal(err)
+ }
+ u, err := url.Parse(shared.postgresURL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ u.Path = "/" + name
+ return u.String()
+ }
+}
+
+func startValkey(t *testing.T) func(*testing.T) string {
+ t.Helper()
+ shared.valkeyOnce.Do(func() {
+ ctx := context.Background()
+ shared.valkey, shared.valkeyErr = tcvalkey.Run(ctx, "valkey/valkey:8-alpine", testcontainers.WithCmdArgs("--databases", "1024"))
+ if shared.valkeyErr == nil {
+ shared.valkeyURL, shared.valkeyErr = shared.valkey.ConnectionString(ctx)
+ }
+ })
+ if shared.valkeyErr != nil {
+ t.Fatalf("启动 Valkey 容器:%v", shared.valkeyErr)
+ }
+ return func(t *testing.T) string {
+ shared.valkeyDBs++
+ if shared.valkeyDBs >= 1024 {
+ t.Fatal("共用的 Valkey 的 1024 个逻辑数据库已经用完")
+ }
+ return fmt.Sprintf("%s/%d", shared.valkeyURL, shared.valkeyDBs)
+ }
+}
+
+// B 站的视频:完整链接和 b23.tv 短链都识别成第 2 P;番剧的链接认得出但不能点。下载到的是平台的原始音频和一张封面。
+// 付费视频的下载在 worker 的测试中设为被拒绝。
+func contractBilibili() *porttest.Bilibili {
+ b := porttest.NewBilibili()
+ b.AddVideo("BV1xx411c7mD/2", track.Meta{Title: "晴天(钢琴版)", Artist: "某 UP 主", Duration: 2 * time.Second}, []byte("平台给的原始音频"), []byte("jpeg"), "jpg")
+ b.AddLink("https://www.bilibili.com/video/BV1xx411c7mD?p=2", "BV1xx411c7mD/2")
+ b.AddLink("https://b23.tv/qingtian", "BV1xx411c7mD/2")
+ b.RejectLink("https://www.bilibili.com/bangumi/play/ep1", "哔哩哔哩的番剧不能点播")
+ b.AddVideo("BV1GJ411x7h7", track.Meta{Title: "付费视频", Artist: "某 UP 主", Duration: time.Minute}, []byte("平台给的原始音频"), nil, "")
+ b.AddLink("https://www.bilibili.com/video/BV1GJ411x7h7", "BV1GJ411x7h7")
+ return b
+}
+
+// 准备时下载到的是一段静音,增益正好是 1,时长是静音的时长。
+func contractSources() sources {
+ netease := porttest.NewSource(track.NetEase)
+ netease.Add("186016", track.Meta{Title: "晴天", Artist: "周杰伦", Album: "叶惠美", CoverURL: "https://p1.music.126.net/example.jpg", PageURL: "https://music.163.com/#/song?id=186016", Duration: 269 * time.Second},
+ "[00:01.00]故事的小黄花", porttest.SilentWAV(269500*time.Millisecond))
+ netease.Add("185811", track.Meta{Title: "七里香", Artist: "周杰伦", Album: "七里香", CoverURL: "https://p1.music.126.net/qilixiang.jpg", PageURL: "https://music.163.com/#/song?id=185811", Duration: 299 * time.Second},
+ "", porttest.SilentWAV(299*time.Second))
+ netease.Add("1100", track.Meta{Title: "片头", Artist: "无名", PageURL: "https://music.163.com/#/song?id=1100", Duration: 15 * time.Second},
+ "", porttest.SilentWAV(15*time.Second))
+ netease.Add("404", track.Meta{Title: "不能播放的歌", Artist: "无名", PageURL: "https://music.163.com/#/song?id=404", Duration: time.Minute}, "", nil)
+ netease.Reject("404", "网易云没有提供这首歌的播放地址")
+ netease.SetSimilar("186016", "185811")
+ netease.AddPlaylist(port.Playlist{ID: "3778678", Name: "周杰伦精选", CoverURL: "https://p1.music.126.net/jay.jpg", Creator: "alice", Description: "", TrackCount: 2, PlayCount: 100}, "186016", "185811")
+ bilibili := contractBilibili()
+ return sources{
+ netease: netease,
+ qq: porttest.NewSource(track.QQ),
+ bilibili: bilibili,
+ bilibiliLinks: bilibili,
+ playback: map[track.Source]port.PlayURLs{track.NetEase: netease},
+ neteaseLogin: porttest.NewNetEaseQRLogin(),
+ qqLogin: porttest.NewQQQRLogin(),
+ bilibiliLogin: porttest.NewBilibiliQRLogin(),
+ }
+}
+
+type service struct {
+ server *httptest.Server
+ public public
+ instance string
+ metrics *prometheus.Registry
+ logs *logBuffer
+ spans *tracetest.SpanRecorder
+ tracer *sdktrace.TracerProvider
+}
+
+type logBuffer struct {
+ mu sync.Mutex
+ buf bytes.Buffer
+}
+
+func (b *logBuffer) Write(p []byte) (int, error) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.buf.Write(p)
+}
+
+func (b *logBuffer) Len() int {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.buf.Len()
+}
+
+func (b *logBuffer) String() string {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.buf.String()
+}
+
+// 各场景共用一个 Valkey,而 Pub/Sub 不按逻辑库隔离:实例频道靠随机的实例 ID 区分;
+// members 和 tracks.prepared 两个频道靠场景依次运行、每个场景结束时停止服务来隔开。
+func startService(t *testing.T) service {
+ t.Helper()
+ if err := runMigrate(newLogger(io.Discard, slog.LevelInfo)); err != nil {
+ t.Fatal(err)
+ }
+ cfg, err := loadServeConfig()
+ if err != nil {
+ t.Fatal(err)
+ }
+ spans := tracetest.NewSpanRecorder()
+ tracer := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spans))
+ cfg.tracing = true
+ traceDatabase(cfg.database, tracer)
+ pool, err := pgxpool.NewWithConfig(t.Context(), cfg.database)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(pool.Close)
+ rdb := goredis.NewClient(cfg.redis)
+ t.Cleanup(func() { _ = rdb.Close() })
+ traceRedis(rdb, tracer)
+ logs := &logBuffer{}
+ instance := rand.Text()
+ metrics := prometheus.NewRegistry()
+ p, err := newPublic(cfg, pool, rdb, instance, contractSources(), metrics, func(track.Source, string, time.Duration) {}, tracer, newLogger(logs, slog.LevelInfo))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := p.start(); err != nil {
+ t.Fatal(err)
+ }
+ ctx, stop := context.WithCancel(context.Background())
+ wait, err := realtime.Start(ctx, p.host, p.gateway)
+ if err != nil {
+ t.Fatal(err)
+ }
+ server := httptest.NewServer(p.handler)
+ t.Cleanup(func() {
+ if err := p.host.Shutdown(context.Background()); err != nil {
+ t.Error(err)
+ }
+ if err := p.gateway.Shutdown(context.Background()); err != nil {
+ t.Error(err)
+ }
+ server.Close()
+ if err := p.jobs.StopAndCancel(context.Background()); err != nil {
+ t.Error(err)
+ }
+ stop()
+ wait()
+ })
+ return service{server: server, public: p, instance: instance, metrics: metrics, logs: logs, spans: spans, tracer: tracer}
+}
+
+// 场景依次由以下几节组成:
+// - 可选的 env 放在最前面,每行写成 名称=值;
+// - request 和 response 成对出现,是一次 HTTP 请求和完整的响应:response 依次是状态码、若干行 名称: 值 的响应头和响应体,204 没有响应体;
+// - connect 名称:打开一个 WebSocket 连接,内容是请求头;紧跟 response 时,期望握手被拒绝;
+// - send 名称:发送一帧;receive 名称:读下一条消息,与期望的报文比较;
+// - closed 名称:连接应以给出的关闭码断开;disconnect 名称:客户端断开连接;
+// - stream 名称:打开房间的事件流,内容是请求头;event 名称:读下一个事件,与期望的事件比较,close 之后事件流应结束。
+func runContract(t *testing.T, sections []txtar.File, databaseURL, redisURL string) {
+ contractEnv(t, databaseURL, redisURL)
+ if len(sections) > 0 && sections[0].Name == "env" {
+ for line := range strings.Lines(strings.TrimSpace(string(sections[0].Data))) {
+ name, value, ok := strings.Cut(strings.TrimSpace(line), "=")
+ if !ok {
+ t.Fatalf("env 中的 %q 应写成 名称=值", line)
+ }
+ t.Setenv(name, value)
+ }
+ sections = sections[1:]
+ }
+ svc := startService(t)
+
+ vars := map[string]string{"protocolVersion": protocolVersion(t)}
+ conns := map[string]*websocket.Conn{}
+ streams := map[string]*eventStream{}
+ for i := 0; i < len(sections); i++ {
+ kind, name, _ := strings.Cut(sections[i].Name, " ")
+ data := string(sections[i].Data)
+ switch {
+ case kind == "request":
+ if i+1 == len(sections) || sections[i+1].Name != "response" {
+ t.Fatalf("第 %d 节的 request 之后应是 response", i+1)
+ }
+ i++
+ line, status, header, body := send(t, svc.server, expand(t, data, vars))
+ checkResponse(t, line, status, header, body, string(sections[i].Data), vars)
+ case kind == "connect" && i+1 < len(sections) && sections[i+1].Name == "response":
+ i++
+ status, header, body := dialRejected(t, svc.server, expand(t, data, vars))
+ checkResponse(t, "GET /ws", status, header, body, string(sections[i].Data), vars)
+ case kind == "connect":
+ conns[name] = dial(t, svc.server, expand(t, data, vars))
+ case kind == "send":
+ frame := []byte(strings.TrimSpace(expand(t, data, vars)))
+ if err := connection(t, conns, name).Write(t.Context(), websocket.MessageText, frame); err != nil {
+ t.Fatalf("%s 发送:%v", name, err)
+ }
+ case kind == "receive":
+ receive(t, connection(t, conns, name), name, data, vars)
+ case kind == "closed":
+ checkClosed(t, connection(t, conns, name), name, data)
+ case kind == "disconnect":
+ _ = connection(t, conns, name).CloseNow()
+ delete(conns, name)
+ case kind == "stream":
+ streams[name] = openStream(t, svc.server, expand(t, data, vars))
+ case kind == "event":
+ stream, ok := streams[name]
+ if !ok {
+ t.Fatalf("事件流 %s 还没有打开", name)
+ }
+ checkEvent(t, stream, name, data, vars)
+ default:
+ t.Fatalf("第 %d 节 %q 不是认识的格式", i+1, sections[i].Name)
+ }
+ }
+ if svc.logs.Len() != 0 {
+ t.Errorf("这些场景都不应产生日志:\n%s", svc.logs.String())
+ }
+}
+
+func contractEnv(t *testing.T, databaseURL, redisURL string) {
+ unsetenv(t, "ALISTEN_LISTEN")
+ unsetenv(t, "ALISTEN_OPS_LISTEN")
+ storageEnv(t)
+ connectionEnv(t, databaseURL)
+ connectionEnv(t, redisURL)
+ t.Setenv("ALISTEN_PUBLIC_URL", "https://alisten.example")
+ t.Setenv("ALISTEN_ALLOWED_ORIGINS", "https://alisten.example,http://localhost:9876")
+ t.Setenv("ALISTEN_ADMIN_TOKEN", "admin-secret")
+ t.Setenv("STORAGE_PUBLIC_URL", "https://alisten.example/storage/alisten")
+}
+
+func connectionEnv(t *testing.T, connString string) {
+ t.Helper()
+ u, err := url.Parse(connString)
+ if err != nil {
+ t.Fatal(err)
+ }
+ password, _ := u.User.Password()
+ name := strings.TrimPrefix(u.Path, "/")
+ switch u.Scheme {
+ case "postgres":
+ t.Setenv("DATABASE_HOST", u.Hostname())
+ t.Setenv("DATABASE_PORT", u.Port())
+ t.Setenv("DATABASE_USERNAME", u.User.Username())
+ t.Setenv("DATABASE_PASSWORD", password)
+ t.Setenv("DATABASE_NAME", name)
+ t.Setenv("DATABASE_SSLMODE", u.Query().Get("sslmode"))
+ case "redis":
+ t.Setenv("REDIS_HOST", u.Hostname())
+ t.Setenv("REDIS_PORT", u.Port())
+ t.Setenv("REDIS_PASSWORD", password)
+ t.Setenv("REDIS_DB", name)
+ default:
+ t.Fatalf("%q 不是 PostgreSQL 或 Redis 的连接串", connString)
+ }
+}
+
+// bootstrap 不能 import protocol,所以 hello 中的协议版本从生成的 Go 代码中读出。
+func protocolVersion(t *testing.T) string {
+ t.Helper()
+ b, err := os.ReadFile(filepath.Join("..", "..", "protocol", "types.gen.go"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ m := regexp.MustCompile(`const Version = "([0-9a-f]{16})"`).FindSubmatch(b)
+ if m == nil {
+ t.Fatal("protocol/types.gen.go 中没有协议版本")
+ }
+ return string(m[1])
+}
+
+func connection(t *testing.T, conns map[string]*websocket.Conn, name string) *websocket.Conn {
+ t.Helper()
+ conn, ok := conns[name]
+ if !ok {
+ t.Fatalf("连接 %s 还没有打开", name)
+ }
+ return conn
+}
+
+func wsURL(server *httptest.Server) string {
+ return "ws" + strings.TrimPrefix(server.URL, "http") + "/ws"
+}
+
+func headers(t *testing.T, text string) http.Header {
+ t.Helper()
+ header := http.Header{}
+ for line := range strings.Lines(strings.TrimSpace(text)) {
+ name, value, ok := strings.Cut(line, ":")
+ if !ok {
+ t.Fatalf("请求头 %q 应写成 名称: 值", line)
+ }
+ header.Set(strings.TrimSpace(name), strings.TrimSpace(value))
+ }
+ return header
+}
+
+func dial(t *testing.T, server *httptest.Server, header string) *websocket.Conn {
+ t.Helper()
+ conn, _, err := websocket.Dial(t.Context(), wsURL(server), &websocket.DialOptions{HTTPHeader: headers(t, header)})
+ if err != nil {
+ t.Fatalf("WebSocket 握手:%v", err)
+ }
+ t.Cleanup(func() { _ = conn.CloseNow() })
+ return conn
+}
+
+func dialRejected(t *testing.T, server *httptest.Server, header string) (int, http.Header, []byte) {
+ t.Helper()
+ conn, resp, err := websocket.Dial(t.Context(), wsURL(server), &websocket.DialOptions{HTTPHeader: headers(t, header)})
+ if err == nil {
+ _ = conn.CloseNow()
+ t.Fatal("WebSocket 握手应被拒绝")
+ }
+ if resp == nil {
+ t.Fatalf("握手被拒绝时应有 HTTP 响应:%v", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return resp.StatusCode, resp.Header, body
+}
+
+// 消息总会到达,期限只是为了出错时不一直等下去:最长的一次要等《片头》放完,15 秒。
+const messageWait = 25 * time.Second
+
+func receive(t *testing.T, conn *websocket.Conn, name, want string, vars map[string]string) {
+ t.Helper()
+ ctx, cancel := context.WithTimeout(t.Context(), messageWait)
+ defer cancel()
+ _, frame, err := conn.Read(ctx)
+ if err != nil {
+ t.Fatalf("%s 读消息:%v", name, err)
+ }
+ var wantJSON, gotJSON any
+ if err := json.Unmarshal([]byte(want), &wantJSON); err != nil {
+ t.Fatalf("期望的消息不是 JSON:%v\n%s", err, want)
+ }
+ if err := json.Unmarshal(frame, &gotJSON); err != nil {
+ t.Fatalf("%s 收到的消息不是 JSON:%v\n%s", name, err, frame)
+ }
+ if err := match(wantJSON, timestamps(gotJSON), vars, "消息"); err != nil {
+ t.Fatalf("%s:%v\n期望:%s\n实际:%s", name, err, strings.TrimSpace(want), frame)
+ }
+}
+
+func checkClosed(t *testing.T, conn *websocket.Conn, name, want string) {
+ t.Helper()
+ code, err := strconv.Atoi(strings.TrimSpace(want))
+ if err != nil {
+ t.Fatalf("closed 的内容应是关闭码:%q", want)
+ }
+ ctx, cancel := context.WithTimeout(t.Context(), messageWait)
+ defer cancel()
+ _, frame, err := conn.Read(ctx)
+ if got := websocket.CloseStatus(err); got != websocket.StatusCode(code) {
+ t.Fatalf("%s 的关闭码 = %d(%v %s),期望 %d", name, got, err, frame, code)
+ }
+}
+
+type eventStream struct {
+ body io.Closer
+ reader *bufio.Reader
+}
+
+func openStream(t *testing.T, server *httptest.Server, header string) *eventStream {
+ t.Helper()
+ req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, server.URL+"/api/room/events", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header = headers(t, header)
+ resp, err := server.Client().Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = resp.Body.Close() })
+ if resp.StatusCode != http.StatusOK || resp.Header.Get("Content-Type") != "text/event-stream" {
+ body, _ := io.ReadAll(resp.Body)
+ t.Fatalf("打开事件流 = %d %s:%s", resp.StatusCode, resp.Header.Get("Content-Type"), body)
+ }
+ return &eventStream{body: resp.Body, reader: bufio.NewReader(resp.Body)}
+}
+
+// 返回下一个事件的各行,跳过心跳之类只有注释的事件;事件流结束时返回 nil。
+func (s *eventStream) next(t *testing.T, name string) []string {
+ t.Helper()
+ timeout := time.AfterFunc(messageWait, func() { _ = s.body.Close() })
+ defer timeout.Stop()
+ var lines []string
+ for {
+ line, err := s.reader.ReadString('\n')
+ if errors.Is(err, io.EOF) && line == "" && len(lines) == 0 {
+ return nil
+ }
+ if err != nil {
+ t.Fatalf("%s 读事件:%v", name, err)
+ }
+ switch line = strings.TrimSuffix(line, "\n"); {
+ case line == "" && len(lines) > 0:
+ return lines
+ case line != "" && !strings.HasPrefix(line, ":"):
+ lines = append(lines, line)
+ }
+ }
+}
+
+// data 之前的各行原样比较,data 按 JSON 比较。
+func checkEvent(t *testing.T, s *eventStream, name, want string, vars map[string]string) {
+ t.Helper()
+ wantHead, wantData, ok := strings.Cut(strings.TrimSpace(want), "\ndata: ")
+ if !ok {
+ t.Fatalf("期望的事件应以 data 结尾:%q", want)
+ }
+ got := s.next(t, name)
+ if got == nil {
+ t.Fatalf("%s 的事件流已经结束,期望:%s", name, strings.TrimSpace(want))
+ }
+ gotHead, gotData := strings.Join(got[:len(got)-1], "\n"), strings.TrimPrefix(got[len(got)-1], "data: ")
+ if gotHead != wantHead {
+ t.Fatalf("%s 收到\n%s\n期望\n%s", name, gotHead, wantHead)
+ }
+ var wantJSON, gotJSON any
+ if err := json.Unmarshal([]byte(wantData), &wantJSON); err != nil {
+ t.Fatalf("期望的 data 不是 JSON:%v\n%s", err, wantData)
+ }
+ if err := json.Unmarshal([]byte(gotData), &gotJSON); err != nil {
+ t.Fatalf("%s 收到的 data 不是 JSON:%v\n%s", name, err, gotData)
+ }
+ if err := match(wantJSON, timestamps(gotJSON), vars, "事件"); err != nil {
+ t.Fatalf("%s:%v\n期望:%s\n实际:%s", name, err, wantData, gotData)
+ }
+ if gotHead == "event: close" {
+ if after := s.next(t, name); after != nil {
+ t.Fatalf("%s 的事件流在 close 之后又收到 %q", name, after)
+ }
+ }
+}
+
+// 服务端生成的正数时间戳在期望中统一写为 1:字段名以 AtMs 结尾,或者是 serverTimeMs。
+func timestamps(v any) any {
+ switch v := v.(type) {
+ case map[string]any:
+ for key, value := range v {
+ if n, ok := value.(float64); ok && n > 0 && (strings.HasSuffix(key, "AtMs") || key == "serverTimeMs") {
+ v[key] = float64(1)
+ } else {
+ v[key] = timestamps(value)
+ }
+ }
+ case []any:
+ for i := range v {
+ v[i] = timestamps(v[i])
+ }
+ }
+ return v
+}
+
+// $ 加小写字母开头的名字是变量:在响应中第一次出现时记下实际的值,之后出现时必须相同;在请求中替换为记下的值。
+var variable = regexp.MustCompile(`\$[a-z][A-Za-z0-9]*`)
+
+func expand(t *testing.T, text string, vars map[string]string) string {
+ t.Helper()
+ return variable.ReplaceAllStringFunc(text, func(v string) string {
+ value, ok := vars[v[1:]]
+ if !ok {
+ t.Fatalf("变量 %s 在使用之前还没有出现在响应中", v)
+ }
+ return value
+ })
+}
+
+func send(t *testing.T, server *httptest.Server, request string) (string, int, http.Header, []byte) {
+ t.Helper()
+ head, body, _ := strings.Cut(strings.TrimSpace(request), "\n\n")
+ lines := strings.Split(head, "\n")
+ method, path, ok := strings.Cut(lines[0], " ")
+ if !ok {
+ t.Fatalf("请求的第一行 %q 应为方法和路径", lines[0])
+ }
+ req, err := http.NewRequestWithContext(t.Context(), method, server.URL+path, strings.NewReader(strings.TrimSpace(body)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, line := range lines[1:] {
+ name, value, ok := strings.Cut(line, ":")
+ if !ok {
+ t.Fatalf("请求头 %q 应写成 名称: 值", line)
+ }
+ req.Header.Set(strings.TrimSpace(name), strings.TrimSpace(value))
+ }
+ resp, err := server.Client().Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ got, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return lines[0], resp.StatusCode, resp.Header, got
+}
+
+var headerLine = regexp.MustCompile(`^([A-Za-z][A-Za-z0-9-]*): (.*)$`)
+
+// 响应头在响应体之后比较,可以使用响应体中第一次出现的变量。没有列出 Location 时,响应也不能带 Location。
+func checkResponse(t *testing.T, line string, status int, header http.Header, body []byte, want string, vars map[string]string) {
+ t.Helper()
+ lines := strings.Split(strings.TrimSpace(want), "\n")
+ wantStatus, err := strconv.Atoi(strings.TrimSpace(lines[0]))
+ if err != nil {
+ t.Fatalf("期望的响应应以状态码开头:%q", lines[0])
+ }
+ if status != wantStatus {
+ t.Fatalf("%s 的状态码 = %d,期望 %d:\n%s", line, status, wantStatus, body)
+ }
+ wantHeader := http.Header{}
+ rest := lines[1:]
+ for ; len(rest) > 0; rest = rest[1:] {
+ m := headerLine.FindStringSubmatch(rest[0])
+ if m == nil {
+ break
+ }
+ wantHeader.Set(m[1], m[2])
+ }
+ for name := range header {
+ if strings.HasPrefix(name, "Access-Control-") {
+ t.Errorf("%s 返回了 CORS 头 %s", line, name)
+ }
+ }
+ wantBody := strings.Join(rest, "\n")
+ if status == http.StatusNoContent {
+ if wantBody != "" || len(body) > 0 {
+ t.Fatalf("%s:204 没有响应体,期望:%q,实际:%q", line, wantBody, body)
+ }
+ if got := header.Get("Content-Type"); got != "" {
+ t.Errorf("%s 的 Content-Type = %q,204 不带 Content-Type", line, got)
+ }
+ } else {
+ if got := header.Get("Content-Type"); got != "application/json" {
+ t.Errorf("%s 的 Content-Type = %q", line, got)
+ }
+ var wantJSON, gotJSON any
+ if err := json.Unmarshal([]byte(wantBody), &wantJSON); err != nil {
+ t.Fatalf("期望的响应体不是 JSON:%v\n%s", err, wantBody)
+ }
+ if err := json.Unmarshal(body, &gotJSON); err != nil {
+ t.Fatalf("%s 的响应体不是 JSON:%v\n%s", line, err, body)
+ }
+ if err := match(wantJSON, timestamps(gotJSON), vars, "响应"); err != nil {
+ t.Fatalf("%s:%v\n期望:%s\n实际:%s", line, err, wantBody, body)
+ }
+ }
+ for _, name := range slices.Sorted(maps.Keys(wantHeader)) {
+ if got, want := header.Get(name), expand(t, wantHeader.Get(name), vars); got != want {
+ t.Fatalf("%s 的 %s = %q,期望 %q", line, name, got, want)
+ }
+ }
+ if got := header.Get("Location"); wantHeader.Get("Location") == "" && got != "" {
+ t.Fatalf("%s 带了没有列出的 Location:%q", line, got)
+ }
+}
+
+func match(want, got any, vars map[string]string, path string) error {
+ switch w := want.(type) {
+ case map[string]any:
+ g, ok := got.(map[string]any)
+ if !ok {
+ return fmt.Errorf("%s 应为对象,实际为 %v", path, got)
+ }
+ for _, key := range slices.Sorted(maps.Keys(g)) {
+ if _, ok := w[key]; !ok {
+ return fmt.Errorf("%s 多出字段 %s", path, key)
+ }
+ }
+ for _, key := range slices.Sorted(maps.Keys(w)) {
+ value, ok := g[key]
+ if !ok {
+ return fmt.Errorf("%s 缺少字段 %s", path, key)
+ }
+ if err := match(w[key], value, vars, path+"."+key); err != nil {
+ return err
+ }
+ }
+ return nil
+ case []any:
+ g, ok := got.([]any)
+ if !ok || len(g) != len(w) {
+ return fmt.Errorf("%s 应为 %d 项的数组,实际为 %v", path, len(w), got)
+ }
+ for i := range w {
+ if err := match(w[i], g[i], vars, fmt.Sprintf("%s[%d]", path, i)); err != nil {
+ return err
+ }
+ }
+ return nil
+ case string:
+ if strings.HasPrefix(w, "$") && variable.FindString(w) == w {
+ return bind(w[1:], got, vars, path)
+ }
+ }
+ if !reflect.DeepEqual(want, got) {
+ return fmt.Errorf("%s = %v,期望 %v", path, got, want)
+ }
+ return nil
+}
+
+// 不同的变量必须取不同的值:两次签发的 token 或两个房间的 ID 相同时,测试会失败。
+func bind(name string, got any, vars map[string]string, path string) error {
+ value, ok := got.(string)
+ if !ok || value == "" {
+ return fmt.Errorf("%s 应为非空字符串,实际为 %v", path, got)
+ }
+ if bound, ok := vars[name]; ok {
+ if value != bound {
+ return fmt.Errorf("%s = %q,期望与 $%s 相同:%q", path, value, name, bound)
+ }
+ return nil
+ }
+ for other, bound := range vars {
+ if bound == value {
+ return fmt.Errorf("%s 与 $%s 的值相同:%q", path, other, value)
+ }
+ }
+ vars[name] = value
+ return nil
+}
diff --git a/internal/bootstrap/database_test.go b/internal/bootstrap/database_test.go
new file mode 100644
index 0000000..58fdb30
--- /dev/null
+++ b/internal/bootstrap/database_test.go
@@ -0,0 +1,73 @@
+package bootstrap
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestLoadDatabaseConfig(t *testing.T) {
+ t.Setenv("DATABASE_HOST", "db.internal")
+ t.Setenv("DATABASE_USERNAME", "alisten")
+ t.Setenv("DATABASE_NAME", "alisten")
+ unsetenv(t, "DATABASE_PORT")
+ unsetenv(t, "DATABASE_PASSWORD")
+ unsetenv(t, "DATABASE_SSLMODE")
+ cfg, err := loadDatabaseConfig()
+ if err != nil {
+ t.Fatal(err)
+ }
+ c := cfg.ConnConfig
+ if c.Host != "db.internal" || c.Port != 5432 || c.User != "alisten" || c.Database != "alisten" || c.Password != "" {
+ t.Errorf("连接配置 = %s:%d、用户 %q、数据库 %q、密码 %q,期望默认端口 5432、没有密码", c.Host, c.Port, c.User, c.Database, c.Password)
+ }
+ if c.TLSConfig == nil || len(c.Fallbacks) != 1 || c.Fallbacks[0].TLSConfig != nil {
+ t.Error("默认的 sslmode 应为 prefer:先用 TLS 连接,失败时改用明文")
+ }
+ if cfg.MaxConns < 2 {
+ t.Errorf("连接池最多 %d 个连接,迁移锁要一直占用一个,至少要有两个", cfg.MaxConns)
+ }
+
+ t.Setenv("DATABASE_HOST", "/var/run/postgresql")
+ t.Setenv("DATABASE_PORT", "6432")
+ t.Setenv("DATABASE_USERNAME", `o'brien \x`)
+ t.Setenv("DATABASE_PASSWORD", `Zq9x K7mP' @/\`)
+ t.Setenv("DATABASE_NAME", "alisten db")
+ t.Setenv("DATABASE_SSLMODE", "disable")
+ cfg, err = loadDatabaseConfig()
+ if err != nil {
+ t.Fatal(err)
+ }
+ c = cfg.ConnConfig
+ if c.Host != "/var/run/postgresql" || c.Port != 6432 || c.User != `o'brien \x` || c.Database != "alisten db" || c.Password != `Zq9x K7mP' @/\` {
+ t.Errorf("连接配置 = %s:%d、用户 %q、数据库 %q、密码 %q,期望与环境变量逐字相同", c.Host, c.Port, c.User, c.Database, c.Password)
+ }
+ if strings.Contains(cfg.ConnString(), "Zq9x") {
+ t.Error("密码不能出现在连接串中")
+ }
+ t.Setenv("DATABASE_HOST", "db.internal")
+ cfg, err = loadDatabaseConfig()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c := cfg.ConnConfig; c.TLSConfig != nil || len(c.Fallbacks) != 0 {
+ t.Error("sslmode=disable 时不应使用 TLS")
+ }
+
+ for _, tc := range []struct{ name, value string }{
+ {"DATABASE_HOST", ""},
+ {"DATABASE_PORT", "0"},
+ {"DATABASE_PORT", "65536"},
+ {"DATABASE_SSLMODE", "on"},
+ } {
+ t.Run(tc.name+"="+tc.value, func(t *testing.T) {
+ t.Setenv(tc.name, tc.value)
+ _, err := loadDatabaseConfig()
+ if err == nil || !strings.Contains(err.Error(), tc.name) {
+ t.Fatalf("错误 = %v,期望报错并写明 %s", err, tc.name)
+ }
+ if strings.Contains(err.Error(), "Zq9x") || strings.Contains(err.Error(), "K7mP") {
+ t.Errorf("错误信息中不能出现密码:%v", err)
+ }
+ })
+ }
+}
diff --git a/internal/bootstrap/healthcheck_test.go b/internal/bootstrap/healthcheck_test.go
new file mode 100644
index 0000000..b94f9b9
--- /dev/null
+++ b/internal/bootstrap/healthcheck_test.go
@@ -0,0 +1,40 @@
+package bootstrap
+
+import (
+ "net/http/httptest"
+ "sync/atomic"
+ "testing"
+
+ "github.com/prometheus/client_golang/prometheus"
+
+ "github.com/dihemoe/alisten/internal/adapter/in/ops"
+)
+
+func TestHealthcheckFollowsReadiness(t *testing.T) {
+ var ready atomic.Bool
+ server := httptest.NewServer(ops.NewHandler(ready.Load, prometheus.NewRegistry()))
+ defer server.Close()
+ t.Setenv("ALISTEN_OPS_LISTEN", server.Listener.Addr().String())
+
+ if err := runHealthcheck(nil); err == nil {
+ t.Error("未就绪时应失败")
+ }
+ ready.Store(true)
+ if err := runHealthcheck(nil); err != nil {
+ t.Errorf("就绪时应成功:%v", err)
+ }
+}
+
+func TestLoopbackAddr(t *testing.T) {
+ cases := map[string]string{
+ ":9090": "127.0.0.1:9090",
+ "0.0.0.0:9090": "127.0.0.1:9090",
+ "[::]:9090": "[::1]:9090",
+ "10.0.0.5:9090": "10.0.0.5:9090",
+ }
+ for listen, want := range cases {
+ if got := loopbackAddr(listen); got != want {
+ t.Errorf("loopbackAddr(%q) = %q,期望 %q", listen, got, want)
+ }
+ }
+}
diff --git a/internal/bootstrap/httpserver_test.go b/internal/bootstrap/httpserver_test.go
new file mode 100644
index 0000000..3242dec
--- /dev/null
+++ b/internal/bootstrap/httpserver_test.go
@@ -0,0 +1,129 @@
+package bootstrap
+
+import (
+ "bytes"
+ "errors"
+ "io"
+ "log/slog"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "go.opentelemetry.io/otel/codes"
+)
+
+func TestRecoverPanicsLogsStackInOneField(t *testing.T) {
+ var buf bytes.Buffer
+ logger := newLogger(&buf, slog.LevelInfo).With("component", "test")
+ handler := recoverPanics(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
+ panic("boom")
+ }), logger)
+
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/x", nil))
+
+ if rec.Code != http.StatusInternalServerError {
+ t.Errorf("状态码 = %d,期望 500", rec.Code)
+ }
+ entry := decodeLine(t, buf.Bytes())
+ if entry["panic"] != "boom" {
+ t.Errorf("panic = %v,期望 boom", entry["panic"])
+ }
+ if stack, _ := entry["stack"].(string); !strings.Contains(stack, "goroutine") {
+ t.Errorf("stack 字段中没有调用栈:%v", entry["stack"])
+ }
+}
+
+func TestRecoverPanicsLeavesAbortHandlerToNetHTTP(t *testing.T) {
+ var buf bytes.Buffer
+ logger := newLogger(&buf, slog.LevelInfo).With("component", "test")
+ handler := recoverPanics(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
+ panic(http.ErrAbortHandler)
+ }), logger)
+
+ defer func() {
+ value := recover()
+ if err, ok := value.(error); !ok || !errors.Is(err, http.ErrAbortHandler) {
+ t.Errorf("recover() = %v,期望 http.ErrAbortHandler", value)
+ }
+ if buf.Len() != 0 {
+ t.Errorf("不应记录日志:\n%s", buf.String())
+ }
+ }()
+ handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/x", nil))
+}
+
+func TestPanickingRequestIsAnErrorSpan(t *testing.T) {
+ tracer, spans := recordingTracer(t)
+ var buf bytes.Buffer
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/boom", func(http.ResponseWriter, *http.Request) { panic("boom") })
+ handler := publicHandler(mux, true, tracer, newLogger(&buf, slog.LevelInfo).With("component", "http"))
+
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/boom", nil))
+
+ if rec.Code != http.StatusInternalServerError {
+ t.Errorf("状态码 = %d,期望 500", rec.Code)
+ }
+ ended := spans.Ended()
+ if len(ended) != 1 {
+ t.Fatalf("记了 %d 个 span,期望 1 个", len(ended))
+ }
+ span := ended[0]
+ if got := attributes(span)["http.response.status_code"]; span.Name() != "GET /api/boom" || span.Status().Code != codes.Error || got != "500" {
+ t.Errorf("span = %s、状态 = %v、状态码 = %s,期望以路由命名、出错、500", span.Name(), span.Status(), got)
+ }
+ entry := decodeLine(t, buf.Bytes())
+ if entry["panic"] != "boom" || entry["trace_id"] != span.SpanContext().TraceID().String() || entry["span_id"] != span.SpanContext().SpanID().String() {
+ t.Errorf("panic 的日志 = %v,期望带着 span %v", entry, span.SpanContext())
+ }
+}
+
+func TestPublicHandlerWithoutTracing(t *testing.T) {
+ tracer, spans := recordingTracer(t)
+ var buf bytes.Buffer
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/boom", func(http.ResponseWriter, *http.Request) { panic("boom") })
+ handler := publicHandler(mux, false, tracer, newLogger(&buf, slog.LevelInfo).With("component", "http"))
+
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/boom", nil))
+
+ if n := len(spans.Started()); n != 0 {
+ t.Errorf("不导出追踪时开始了 %d 个 span", n)
+ }
+ if entry := decodeLine(t, buf.Bytes()); rec.Code != http.StatusInternalServerError || entry["panic"] != "boom" {
+ t.Errorf("状态码 = %d、日志 = %v,期望 panic 照样恢复", rec.Code, entry)
+ }
+}
+
+func TestHTTPServerLimitsHeaderSize(t *testing.T) {
+ srv := newHTTPServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), newLogger(io.Discard, slog.LevelInfo))
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ go func() { _ = srv.Serve(ln) }()
+ t.Cleanup(func() { _ = srv.Close() })
+ client := &http.Client{Timeout: 5 * time.Second}
+ for _, tt := range []struct {
+ size int
+ want int
+ }{
+ {1 << 10, http.StatusOK},
+ {80 << 10, http.StatusRequestHeaderFieldsTooLarge},
+ } {
+ resp, err := client.Get("http://" + ln.Addr().String() + "/?q=" + strings.Repeat("a", tt.size))
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = resp.Body.Close()
+ if resp.StatusCode != tt.want {
+ t.Errorf("%d 字节的查询串 = %d,期望 %d", tt.size, resp.StatusCode, tt.want)
+ }
+ }
+}
diff --git a/internal/bootstrap/logging_test.go b/internal/bootstrap/logging_test.go
new file mode 100644
index 0000000..a0a9ac2
--- /dev/null
+++ b/internal/bootstrap/logging_test.go
@@ -0,0 +1,121 @@
+package bootstrap
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "log/slog"
+ "testing"
+
+ "go.opentelemetry.io/otel/trace"
+
+ "github.com/dihemoe/alisten/internal/lib/logctx"
+)
+
+func TestLoggerWritesFixedFields(t *testing.T) {
+ var buf bytes.Buffer
+ newLogger(&buf, slog.LevelInfo).With("component", "test").Info("消息")
+
+ entry := decodeLine(t, buf.Bytes())
+ want := map[string]any{"msg": "消息", "level": "INFO", "service": "alisten", "component": "test", "version": version}
+ for key, value := range want {
+ if entry[key] != value {
+ t.Errorf("%s = %v,期望 %v", key, entry[key], value)
+ }
+ }
+ if _, ok := entry["time"]; !ok {
+ t.Error("缺少 time 字段")
+ }
+}
+
+func TestLoggerRedactsCredentials(t *testing.T) {
+ var buf bytes.Buffer
+ newLogger(&buf, slog.LevelInfo).With("component", "test").Info("消息",
+ "member_token", "t",
+ "Cookie", "c",
+ "roomPassword", "p",
+ "tickets", []string{"a"},
+ "S3_SECRET_ACCESS_KEY", "s",
+ "Authorization", "Bearer x",
+ slog.Group("request", "ticket", "k"),
+ "room_id", "r1",
+ )
+
+ entry := decodeLine(t, buf.Bytes())
+ for _, key := range []string{"member_token", "Cookie", "roomPassword", "tickets", "S3_SECRET_ACCESS_KEY", "Authorization"} {
+ if entry[key] != redacted {
+ t.Errorf("%s = %v,期望已脱敏", key, entry[key])
+ }
+ }
+ if group, _ := entry["request"].(map[string]any); group["ticket"] != redacted {
+ t.Errorf("request.ticket = %v,期望已脱敏", group["ticket"])
+ }
+ if entry["room_id"] != "r1" {
+ t.Errorf("room_id = %v,不应脱敏", entry["room_id"])
+ }
+}
+
+func exampleSpan(t *testing.T, flags trace.TraceFlags) trace.SpanContext {
+ t.Helper()
+ traceID, err := trace.TraceIDFromHex("4bf92f3577b34da6a3ce929d0e0e4736")
+ if err != nil {
+ t.Fatal(err)
+ }
+ spanID, err := trace.SpanIDFromHex("00f067aa0ba902b7")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return trace.NewSpanContext(trace.SpanContextConfig{TraceID: traceID, SpanID: spanID, TraceFlags: flags})
+}
+
+// 打开了 group 的 logger(River 的)也要把关联字段写在顶层。
+func TestLoggerAddsCorrelationFields(t *testing.T) {
+ sc := exampleSpan(t, trace.FlagsSampled)
+ ctx := trace.ContextWithSpanContext(context.Background(), sc)
+ ctx = logctx.WithMember(logctx.WithRoom(logctx.WithRequest(ctx, "a/r1"), "room-1"), "member-1")
+ want := map[string]any{
+ "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
+ "span_id": "00f067aa0ba902b7",
+ "request_id": "a/r1",
+ "room_id": "room-1",
+ "member_id": "member-1",
+ "component": "test",
+ }
+ for _, group := range []string{"", "river"} {
+ var buf bytes.Buffer
+ newLogger(&buf, slog.LevelInfo).With("component", "test").WithGroup(group).InfoContext(ctx, "消息", "attempt", 1)
+
+ entry := decodeLine(t, buf.Bytes())
+ for key, value := range want {
+ if entry[key] != value {
+ t.Errorf("group %q:%s = %v,期望 %v", group, key, entry[key], value)
+ }
+ }
+ }
+}
+
+func TestLoggerSkipsUnsampledSpans(t *testing.T) {
+ ctx := trace.ContextWithSpanContext(context.Background(), exampleSpan(t, 0))
+ var buf bytes.Buffer
+ newLogger(&buf, slog.LevelInfo).InfoContext(ctx, "消息")
+
+ entry := decodeLine(t, buf.Bytes())
+ for _, key := range []string{"trace_id", "span_id"} {
+ if value, ok := entry[key]; ok {
+ t.Errorf("%s = %v,期望没有这个字段", key, value)
+ }
+ }
+}
+
+func decodeLine(t *testing.T, out []byte) map[string]any {
+ t.Helper()
+ lines := bytes.Split(bytes.TrimSuffix(out, []byte("\n")), []byte("\n"))
+ if len(lines) != 1 {
+ t.Fatalf("期望一行日志,实际 %d 行:\n%s", len(lines), out)
+ }
+ var entry map[string]any
+ if err := json.Unmarshal(lines[0], &entry); err != nil {
+ t.Fatalf("日志不是 JSON:%v\n%s", err, out)
+ }
+ return entry
+}
diff --git a/internal/bootstrap/main_test.go b/internal/bootstrap/main_test.go
new file mode 100644
index 0000000..2cf4653
--- /dev/null
+++ b/internal/bootstrap/main_test.go
@@ -0,0 +1,24 @@
+package bootstrap
+
+import "testing"
+
+func TestMainExitCodes(t *testing.T) {
+ cases := []struct {
+ name string
+ args []string
+ want int
+ }{
+ {name: "缺少子命令", args: nil, want: 2},
+ {name: "未知的子命令", args: []string{"bogus"}, want: 2},
+ {name: "子命令出错", args: []string{"worker"}, want: 1},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ unsetenv(t, "DATABASE_HOST")
+ unsetenv(t, "ALISTEN_LOG_LEVEL")
+ if got := Main(tc.args); got != tc.want {
+ t.Errorf("Main(%q) = %d,期望 %d", tc.args, got, tc.want)
+ }
+ })
+ }
+}
diff --git a/internal/bootstrap/media_test.go b/internal/bootstrap/media_test.go
new file mode 100644
index 0000000..9cafd65
--- /dev/null
+++ b/internal/bootstrap/media_test.go
@@ -0,0 +1,157 @@
+package bootstrap
+
+import (
+ "io"
+ "log/slog"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "github.com/dihemoe/alisten/internal/adapter/out/postgres"
+ "github.com/dihemoe/alisten/internal/adapter/out/storage"
+ "github.com/dihemoe/alisten/internal/app/media"
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/domain/room"
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+func TestMediaRedirect(t *testing.T) {
+ contractEnv(t, startPostgres(t)(t), startValkey(t)(t))
+ svc := startService(t)
+ _, ticket := enterRoom(t, svc, createPersistentRoom(t, svc, "大厅"))
+ roomRequest(t, svc, ticket, "POST /api/room/queue", `{"track": {"source": "netease", "id": "186016"}}`)
+ client := &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
+ tests := []struct {
+ path string
+ status int
+ location string
+ }{
+ {"/media/netease/186016", http.StatusFound, "https://netease.example/186016.mp3"},
+ {"/media/netease/185811", http.StatusNotFound, ""},
+ {"/media/qq/0039MnYb0qxYhV", http.StatusBadGateway, ""},
+ }
+ for _, tt := range tests {
+ req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, svc.server.URL+tt.path, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = resp.Body.Close()
+ if resp.StatusCode != tt.status || resp.Header.Get("Location") != tt.location {
+ t.Errorf("%s = %d %q,期望 %d %q", tt.path, resp.StatusCode, resp.Header.Get("Location"), tt.status, tt.location)
+ }
+ }
+ for _, c := range []struct {
+ source, result string
+ want float64
+ }{{"netease", "ok", 1}, {"netease", "NOT_FOUND", 1}, {"qq", "PROVIDER_ERROR", 1}} {
+ if got := metricValue(t, svc.metrics, "alisten_media_redirects_total", "source", c.source, "result", c.result); got != c.want {
+ t.Errorf("alisten_media_redirects_total{%s,%s} = %v,期望 %v", c.source, c.result, got, c.want)
+ }
+ }
+}
+
+func entryStatuses(snapshot map[string]any) map[string]string {
+ statuses := map[string]string{}
+ add := func(entry any) {
+ if e, ok := entry.(map[string]any); ok {
+ statuses[e["track"].(map[string]any)["id"].(string)] = e["status"].(string)
+ }
+ }
+ add(snapshot["pending"])
+ if playing, ok := snapshot["nowPlaying"].(map[string]any); ok {
+ add(playing["entry"])
+ }
+ for _, e := range snapshot["items"].([]any) {
+ add(e)
+ }
+ return statuses
+}
+
+func queuedLinkJobs(t *testing.T, pool *pgxpool.Pool, link string) int {
+ t.Helper()
+ var n int
+ row := pool.QueryRow(t.Context(), "SELECT count(*) FROM river_job WHERE kind = 'prepare' AND queue = 'ytdlp' AND args->>'id' = $1", link)
+ if err := row.Scan(&n); err != nil {
+ t.Fatal(err)
+ }
+ return n
+}
+
+func TestRestoredLinksNeedTheirAudio(t *testing.T) {
+ contractEnv(t, startPostgres(t)(t), startValkey(t)(t))
+ // 服务运行时,对账会接管没有 owner 的房间,可能发生在快照落盘之前,所以先落盘再启动服务。
+ if err := runMigrate(newLogger(io.Discard, slog.LevelInfo)); err != nil {
+ t.Fatal(err)
+ }
+ objects, err := loadStorageConfig()
+ if err != nil {
+ t.Fatal(err)
+ }
+ dbConfig, err := loadDatabaseConfig()
+ if err != nil {
+ t.Fatal(err)
+ }
+ pool, err := pgxpool.NewWithConfig(t.Context(), dbConfig)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(pool.Close)
+ rooms := postgres.New(pool)
+
+ // 上一个 owner 已经把房间落盘:等待队列中有两个已就绪的链接曲目,只有第二个的音频还在对象存储中。
+ hall, err := rooms.CreateRoom(t.Context(), port.NewRoom{Kind: room.Persistent, Name: "大厅"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ term, snapshot, err := rooms.TakeRoom(t.Context(), hall)
+ if err != nil {
+ t.Fatal(err)
+ }
+ const missing, present = "https://example.com/missing.mp3", "https://example.com/present.mp3"
+ for i, link := range []string{missing, present} {
+ prepared := port.PreparedTrack{
+ Meta: track.Meta{Title: link, PageURL: link},
+ Result: track.Prepared{Duration: 2 * time.Second, Gain: 1},
+ MediaKey: media.Key(link),
+ MediaSize: 3,
+ }
+ linkTrack := track.Track{Source: track.Link, ID: link}
+ if err := rooms.SavePreparedTrack(t.Context(), linkTrack, prepared); err != nil {
+ t.Fatal(err)
+ }
+ snapshot.Waiting = append(snapshot.Waiting, room.Entry{ID: room.EntryID(i + 1), Track: linkTrack, Meta: prepared.Meta, AddedAt: time.Now()})
+ }
+ snapshot.EntrySeq = 2
+ if err := storage.New(objects).Put(t.Context(), media.AudioObject(media.Key(present)), strings.NewReader("mp3"), "audio/mpeg"); err != nil {
+ t.Fatal(err)
+ }
+ if err := rooms.SaveRoom(t.Context(), term, snapshot, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ svc := startService(t)
+ _, ticket := enterRoom(t, svc, hall)
+ _, hello := connect(t, svc, hall, ticket)
+ want := map[string]string{missing: "preparing", present: "ready"}
+ if got := entryStatuses(hello["room"].(map[string]any)); len(got) != len(want) || got[missing] != want[missing] || got[present] != want[present] {
+ t.Errorf("接管之后条目的状态 = %v,期望 %v", got, want)
+ }
+ for deadline := time.Now().Add(messageWait); queuedLinkJobs(t, pool, missing) == 0; time.Sleep(100 * time.Millisecond) {
+ if time.Now().After(deadline) {
+ t.Fatal("音频已不在对象存储中的链接没有重新提交准备任务")
+ }
+ }
+ if n := queuedLinkJobs(t, pool, missing); n != 1 {
+ t.Errorf("音频不在的链接有 %d 个准备任务,期望 1 个", n)
+ }
+ if n := queuedLinkJobs(t, pool, present); n != 0 {
+ t.Errorf("音频还在的链接有 %d 个准备任务,期望没有", n)
+ }
+}
diff --git a/internal/bootstrap/metrics_test.go b/internal/bootstrap/metrics_test.go
new file mode 100644
index 0000000..78ebb38
--- /dev/null
+++ b/internal/bootstrap/metrics_test.go
@@ -0,0 +1,159 @@
+package bootstrap
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "testing"
+ "testing/synctest"
+ "time"
+
+ "github.com/prometheus/client_golang/prometheus"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/domain/room"
+)
+
+// 返回指标的值,直方图返回样本数;labels 依次是标签名和值。没有这个指标时测试失败。
+func metricValue(t *testing.T, metrics prometheus.Gatherer, name string, labels ...string) float64 {
+ t.Helper()
+ families, err := metrics.Gather()
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, f := range families {
+ if f.GetName() != name {
+ continue
+ }
+ next:
+ for _, m := range f.GetMetric() {
+ for i := 0; i+1 < len(labels); i += 2 {
+ found := false
+ for _, l := range m.GetLabel() {
+ if l.GetName() == labels[i] && l.GetValue() == labels[i+1] {
+ found = true
+ }
+ }
+ if !found {
+ continue next
+ }
+ }
+ switch {
+ case m.GetCounter() != nil:
+ return m.GetCounter().GetValue()
+ case m.GetGauge() != nil:
+ return m.GetGauge().GetValue()
+ case m.GetHistogram() != nil:
+ return float64(m.GetHistogram().GetSampleCount())
+ }
+ }
+ }
+ t.Fatalf("没有指标 %s%q", name, labels)
+ return 0
+}
+
+func TestRuntimeMetrics(t *testing.T) {
+ contractEnv(t, startPostgres(t)(t), startValkey(t)(t))
+ svc := startService(t)
+ roomID := createPersistentRoom(t, svc, "大厅")
+ _, ticket := enterRoom(t, svc, roomID)
+ client, _ := connect(t, svc, roomID, ticket)
+ client.write(t, `{"kind":"request","type":"settings.setPlayMode","id":"m1","data":{"playMode":"random"}}`)
+ client.read(t)
+ if m := client.read(t); m["type"] != "settings.setPlayMode" || m["error"] != nil {
+ t.Fatalf("切换播放模式的回复 = %v", m)
+ }
+ client.write(t, `{"kind":"request","type":"search.tracks","id":"q1","data":{"source":"netease","keyword":"晴天","page":1,"pageSize":10}}`)
+ if m := client.read(t); m["type"] != "search.tracks" || m["error"] != nil {
+ t.Fatalf("搜索的回复 = %v", m)
+ }
+ line, status, _, body := send(t, svc.server, "POST /api/room/queue/9/likes\nAuthorization: Bearer "+ticket)
+ if status != http.StatusNotFound {
+ t.Fatalf("%s = %d %s,期望 404", line, status, body)
+ }
+
+ checks := []struct {
+ name string
+ labels []string
+ want float64
+ }{
+ {"alisten_rooms_owned", nil, 1},
+ {"alisten_sessions", nil, 1},
+ {"alisten_room_commands_total", []string{"type", "settings.setPlayMode", "result", "ok"}, 1},
+ {"alisten_room_commands_total", []string{"type", "search.tracks", "result", "ok"}, 1},
+ {"alisten_room_commands_total", []string{"type", "queue.like", "result", "NOT_FOUND"}, 1},
+ {"alisten_persist_duration_seconds", nil, 1},
+ {"alisten_persist_failures_total", nil, 0},
+ {"alisten_lease_lost_total", nil, 0},
+ {"alisten_bus_dropped_total", []string{"reason", "version"}, 0},
+ }
+ for _, c := range checks {
+ if got := metricValue(t, svc.metrics, c.name, c.labels...); got != c.want {
+ t.Errorf("%s%q = %v,期望 %v", c.name, c.labels, got, c.want)
+ }
+ }
+}
+
+func TestWatchClockKeepsLastOffset(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ offset := prometheus.NewGauge(prometheus.GaugeOpts{Name: "alisten_clock_offset_seconds", Help: "测试用指标。"})
+ metrics := prometheus.NewRegistry()
+ metrics.MustRegister(offset)
+ results := []struct {
+ offset time.Duration
+ err error
+ }{{20 * time.Millisecond, nil}, {0, errors.New("连不上 Redis")}, {-5 * time.Millisecond, nil}}
+ calls := 0
+ measure := func(ctx context.Context) (time.Duration, error) {
+ if _, ok := ctx.Deadline(); !ok {
+ t.Error("每次估算都应有期限")
+ }
+ r := results[min(calls, len(results)-1)]
+ calls++
+ return r.offset, r.err
+ }
+ ctx, stop := context.WithCancel(t.Context())
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ watchClock(ctx, measure, offset)
+ }()
+ for _, want := range []float64{0.02, 0.02, -0.005} {
+ synctest.Wait()
+ if got := metricValue(t, metrics, "alisten_clock_offset_seconds"); got != want {
+ t.Errorf("第 %d 次估算之后时钟偏差 = %v,期望 %v", calls, got, want)
+ }
+ time.Sleep(clockInterval)
+ }
+ stop()
+ <-done
+ })
+}
+
+type failingSaves struct {
+ port.RoomSnapshots
+ err error
+}
+
+func (s *failingSaves) SaveRoom(context.Context, uint32, room.Snapshot, []room.RecordPlay) error {
+ return s.err
+}
+
+func TestExpiredTermIsNotAPersistFailure(t *testing.T) {
+ metrics := prometheus.NewRegistry()
+ store := &failingSaves{}
+ snapshots := measure(store, metrics)
+ for _, err := range []error{fmt.Errorf("保存房间:%w", port.ErrTermExpired), errors.New("连接断开")} {
+ store.err = err
+ if got := snapshots.SaveRoom(t.Context(), 1, room.Snapshot{}, nil); !errors.Is(got, err) {
+ t.Fatalf("SaveRoom 的错误 = %v,期望原样返回 %v", got, err)
+ }
+ }
+ if got := metricValue(t, metrics, "alisten_persist_failures_total"); got != 1 {
+ t.Errorf("alisten_persist_failures_total = %v,期望只计连接断开的那一次", got)
+ }
+ if got := metricValue(t, metrics, "alisten_persist_duration_seconds"); got != 2 {
+ t.Errorf("alisten_persist_duration_seconds 的样本数 = %v,期望两次落盘都计时", got)
+ }
+}
diff --git a/internal/bootstrap/migrate_test.go b/internal/bootstrap/migrate_test.go
new file mode 100644
index 0000000..8039f15
--- /dev/null
+++ b/internal/bootstrap/migrate_test.go
@@ -0,0 +1,66 @@
+package bootstrap
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "github.com/dihemoe/alisten/internal/adapter/out/postgres"
+ "github.com/dihemoe/alisten/internal/adapter/out/storage"
+)
+
+func TestMigrateIsRepeatable(t *testing.T) {
+ url := startPostgres(t)(t)
+ unsetenv(t, "ALISTEN_LOG_LEVEL")
+ connectionEnv(t, url)
+ bucket := storageEnv(t)
+ pool, err := pgxpool.New(t.Context(), url)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(pool.Close)
+
+ done := make(chan int, 1)
+ err = postgres.WithMigrationLock(t.Context(), pool, func(ctx context.Context) error {
+ go func() { done <- Main([]string{"migrate"}) }()
+ for {
+ var waiting int
+ if err := pool.QueryRow(ctx, "SELECT count(*) FROM pg_locks WHERE locktype = 'advisory' AND NOT granted").Scan(&waiting); err != nil {
+ return err
+ }
+ if waiting > 0 {
+ return nil
+ }
+ select {
+ case code := <-done:
+ return fmt.Errorf("迁移没有等待锁就结束了:%d", code)
+ case <-time.After(20 * time.Millisecond):
+ }
+ }
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := <-done; got != 0 {
+ t.Fatalf("Main(migrate) = %d,期望 0", got)
+ }
+
+ if got := Main([]string{"migrate"}); got != 0 {
+ t.Fatalf("第二次 Main(migrate) = %d,期望 0", got)
+ }
+ for _, table := range []string{"rooms", "river_job"} {
+ var exists bool
+ if err := pool.QueryRow(t.Context(), "SELECT to_regclass($1) IS NOT NULL", table).Scan(&exists); err != nil {
+ t.Fatal(err)
+ }
+ if !exists {
+ t.Errorf("迁移后缺少表 %s", table)
+ }
+ }
+ if created, err := storage.New(bucket).EnsureBucket(t.Context()); err != nil || created {
+ t.Errorf("迁移之后检查 bucket = %v, %v,期望迁移已经创建了它", created, err)
+ }
+}
diff --git a/internal/bootstrap/propagation_test.go b/internal/bootstrap/propagation_test.go
new file mode 100644
index 0000000..913d39d
--- /dev/null
+++ b/internal/bootstrap/propagation_test.go
@@ -0,0 +1,156 @@
+package bootstrap
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/jackc/pgx/v5"
+ "go.opentelemetry.io/otel/codes"
+ "go.opentelemetry.io/otel/trace"
+
+ "github.com/dihemoe/alisten/internal/adapter/out/queue"
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+// 模拟房间已由另一个实例接管:本实例下一次落盘会因任期过期而失败。
+func bumpTerm(t *testing.T, databaseURL, roomID string) {
+ t.Helper()
+ conn, err := pgx.Connect(t.Context(), databaseURL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = conn.Close(context.Background()) }()
+ if _, err := conn.Exec(t.Context(), "UPDATE rooms SET term = term + 1 WHERE id = $1", roomID); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func logEntries(t *testing.T, logs *logBuffer, msg string) []map[string]any {
+ t.Helper()
+ var found []map[string]any
+ for line := range strings.Lines(strings.TrimSpace(logs.String())) {
+ var entry map[string]any
+ if err := json.Unmarshal([]byte(line), &entry); err != nil {
+ t.Fatalf("日志不是 JSON:%v\n%s", err, line)
+ }
+ if entry["msg"] == msg {
+ found = append(found, entry)
+ }
+ }
+ return found
+}
+
+func TestRoomCommandIsOneTraceAcrossInstances(t *testing.T) {
+ databaseURL, redisURL := startPostgres(t)(t), startValkey(t)(t)
+ contractEnv(t, databaseURL, redisURL)
+ services := []service{startService(t), startService(t)}
+ observer := newObserver(t, redisURL)
+ hall := createPersistentRoom(t, services[0], "大厅")
+ _, botTicket := enterRoom(t, services[0], hall)
+ roomRequest(t, services[0], botTicket, "GET /api/room", "")
+ var owning, gateway service
+ for _, svc := range services {
+ if svc.instance == owner(t, observer, hall) {
+ owning = svc
+ } else {
+ gateway = svc
+ }
+ }
+ _, ticket := enterRoom(t, gateway, hall)
+ client, hello := connect(t, gateway, hall, ticket)
+ memberID := hello["member"].(map[string]any)["id"].(string)
+
+ bumpTerm(t, databaseURL, hall)
+ client.write(t, `{"kind":"request","type":"queue.add","id":"a1","data":{"track":{"source":"netease","id":"186016"}}}`)
+ if reply := client.readUntil(t, func(m map[string]any) bool { return m["type"] == "queue.add" }); reply["error"].(map[string]any)["code"] != "UNAVAILABLE" {
+ t.Fatalf("点歌的回复 = %v,期望 owner 落盘失败之后的 UNAVAILABLE", reply)
+ }
+
+ command := awaitSpan(t, gateway.spans, "WS queue.add", trace.TraceID{})
+ traceID := command.SpanContext().TraceID()
+ if command.Status().Code != codes.Error {
+ t.Errorf("命令的 span 的状态 = %v,期望 UNAVAILABLE 标为出错", command.Status())
+ }
+ received := awaitSpan(t, owning.spans, "receive room input", traceID)
+ if received.Parent().SpanID() != command.SpanContext().SpanID() || !received.Parent().IsRemote() {
+ t.Errorf("owner 收到输入的 span 的父 span = %v,期望是网关的请求 %v", received.Parent(), command.SpanContext())
+ }
+ saved := awaitSpan(t, owning.spans, "SaveRoomState", traceID)
+ if saved.Parent().SpanID() != received.SpanContext().SpanID() {
+ t.Errorf("落盘的查询的父 span = %v,期望是 owner 收到输入的 span", saved.Parent())
+ }
+
+ entries := logEntries(t, owning.logs, "落盘失败,放弃房间")
+ if len(entries) != 1 {
+ t.Fatalf("owner 落盘失败的日志有 %d 条,期望 1 条:\n%s", len(entries), owning.logs.String())
+ }
+ entry := entries[0]
+ requestID, _ := entry["request_id"].(string)
+ if entry["trace_id"] != traceID.String() || entry["span_id"] != received.SpanContext().SpanID().String() ||
+ entry["room_id"] != hall || entry["member_id"] != memberID || !strings.HasPrefix(requestID, gateway.instance+"/r") {
+ t.Errorf("日志 = %v,期望 trace %s、span %s、房间 %s、成员 %s、网关 %s 发出的请求", entry, traceID, received.SpanContext().SpanID(), hall, memberID, gateway.instance)
+ }
+}
+
+func TestPrepareJobLinksToTheRoomCommand(t *testing.T) {
+ contractEnv(t, startPostgres(t)(t), startValkey(t)(t))
+ objects, err := loadStorageConfig()
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("STORAGE_PUBLIC_URL", objects.Endpoint+"/"+objects.Bucket)
+ svc := startService(t)
+ w := startWorker(t, &links{})
+ w.waitReady(t)
+ hall := createPersistentRoom(t, svc, "大厅")
+ _, ticket := enterRoom(t, svc, hall)
+ client, _ := connect(t, svc, hall, ticket)
+ client.write(t, `{"kind":"request","type":"queue.add","id":"a1","data":{"track":{"source":"link","id":"https://example.com/qingtian.mp3"}}}`)
+ client.readUntil(t, playing)
+
+ command := awaitSpan(t, svc.spans, "WS queue.add", trace.TraceID{})
+ insert := awaitSpan(t, svc.spans, "river.insert_many", command.SpanContext().TraceID())
+ work := awaitSpan(t, w.spans, "river.work", trace.TraceID{})
+ links := work.Links()
+ if work.Parent().IsValid() || len(links) != 1 || links[0].SpanContext.SpanID() != insert.SpanContext().SpanID() || links[0].SpanContext.TraceID() != command.SpanContext().TraceID() {
+ t.Errorf("执行任务的 span 的父 span = %v、链接 = %v,期望另开 trace,链接到 serve 提交任务的 span %v", work.Parent(), links, insert.SpanContext())
+ }
+ for _, name := range []string{"yt-dlp", "S3 PutObject", "SavePreparedTrack"} {
+ awaitSpan(t, w.spans, name, work.SpanContext().TraceID())
+ }
+}
+
+func TestJobInsertsStartNoTraces(t *testing.T) {
+ contractEnv(t, startPostgres(t)(t), startValkey(t)(t))
+ svc := startService(t)
+ jobs := queue.New(svc.public.jobs)
+
+ // serve 的 River client 不处理 ytdlp 队列,链接曲目的任务会一直停在队列中。
+ id, err := jobs.Submit(t.Context(), track.Track{Source: track.Link, ID: "https://example.com/qingtian.mp3"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ job, err := svc.public.jobs.JobGet(t.Context(), id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n := len(svc.spans.Started()); n != 0 || strings.Contains(string(job.Metadata), "traceparent") {
+ t.Errorf("没有请求发起的提交开始了 %d 个 span,任务的元数据 = %s,期望没有 span,也没有追踪上下文", n, job.Metadata)
+ }
+
+ ctx, request := svc.tracer.Tracer("test").Start(t.Context(), "WS queue.add")
+ if _, err := jobs.Submit(ctx, track.Track{Source: track.NetEase, ID: "1100"}); err != nil {
+ t.Fatal(err)
+ }
+ request.End()
+ insert := awaitSpan(t, svc.spans, "river.insert_many", request.SpanContext().TraceID())
+ if insert.SpanKind() != trace.SpanKindProducer || insert.Parent().SpanID() != request.SpanContext().SpanID() {
+ t.Errorf("提交任务的 span 的类型 = %v、父 span = %v,期望是请求 %v 之下的 producer span", insert.SpanKind(), insert.Parent(), request.SpanContext())
+ }
+ work := awaitSpan(t, svc.spans, "river.work", trace.TraceID{})
+ if links := work.Links(); work.Parent().IsValid() || len(links) != 1 || links[0].SpanContext.SpanID() != insert.SpanContext().SpanID() {
+ t.Errorf("执行任务的 span 的父 span = %v、链接 = %v,期望另开 trace,链接到提交它的 span %v", work.Parent(), links, insert.SpanContext())
+ }
+}
diff --git a/internal/bootstrap/readiness_test.go b/internal/bootstrap/readiness_test.go
new file mode 100644
index 0000000..4b762e1
--- /dev/null
+++ b/internal/bootstrap/readiness_test.go
@@ -0,0 +1,98 @@
+package bootstrap
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net"
+ "os"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+func dnsTimeout(port int) error {
+ read := &net.OpError{Op: "read", Net: "udp", Source: &net.UDPAddr{IP: net.IPv4(172, 18, 0, 5), Port: port}, Addr: &net.UDPAddr{IP: net.IPv4(127, 0, 0, 11), Port: 53}, Err: os.ErrDeadlineExceeded}
+ lookup := &net.DNSError{Err: read.Error(), Name: "redis", Server: "127.0.0.11:53", IsTimeout: true}
+ return fmt.Errorf("连接 Redis:%w", &net.OpError{Op: "dial", Net: "tcp", Err: lookup})
+}
+
+func readTimeout(port int) error {
+ read := &net.OpError{Op: "read", Net: "tcp", Source: &net.TCPAddr{IP: net.ParseIP("fd00::5"), Port: port}, Addr: &net.TCPAddr{IP: net.ParseIP("fd00::3"), Port: 6379}, Err: os.ErrDeadlineExceeded}
+ return fmt.Errorf("连接 Redis:%w", read)
+}
+
+func TestWatchReadinessFollowsChecks(t *testing.T) {
+ ctx, cancel := context.WithCancel(t.Context())
+ var ready, stopping atomic.Bool
+ // 每次检查开始时,ready 是上一次检查的结果;检查返回之前它不会变。
+ previous := make(chan bool)
+ results := make(chan error)
+ check := func(checkCtx context.Context) error {
+ if _, ok := checkCtx.Deadline(); !ok {
+ t.Error("每次检查都应有期限")
+ }
+ // 检查自己的期限只有 1ms,所以等待测试一方时用 ctx 而不是 checkCtx。
+ select {
+ case previous <- ready.Load():
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ select {
+ case err := <-results:
+ return err
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ }
+ var logs bytes.Buffer
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ watchReadiness(ctx, time.Millisecond, check, &ready, &stopping, newLogger(&logs, slog.LevelInfo))
+ }()
+
+ unmigrated := errors.New("数据库迁移的版本为 0,低于要求的 1")
+ unreachable := errors.New("连接 Redis:connection refused")
+ steps := []struct {
+ result error
+ ready bool
+ }{
+ {unmigrated, false}, {unmigrated, false},
+ {dnsTimeout(43521), false}, {dnsTimeout(51234), false},
+ {readTimeout(40000), false}, {readTimeout(40002), false},
+ {unreachable, false}, {nil, true}, {nil, true}, {unmigrated, false},
+ }
+ for i, step := range steps {
+ got := <-previous
+ if i > 0 && got != steps[i-1].ready {
+ t.Errorf("第 %d 次检查之后 ready = %v,期望 %v", i, got, steps[i-1].ready)
+ }
+ results <- step.result
+ }
+ if got := <-previous; got != steps[len(steps)-1].ready {
+ t.Errorf("最后一次检查之后 ready = %v,期望 %v", got, steps[len(steps)-1].ready)
+ }
+ stopping.Store(true)
+ results <- unreachable
+ if got := <-previous; got {
+ t.Error("退出开始之后检查失败,ready 应为 false")
+ }
+ results <- nil
+ if got := <-previous; !got {
+ t.Error("退出开始之后检查通过,ready 应为 true")
+ }
+ cancel()
+ <-done
+
+ var levels []string
+ for line := range strings.Lines(strings.TrimSpace(logs.String())) {
+ levels = append(levels, decodeLine(t, []byte(line))["level"].(string))
+ }
+ if got := strings.Join(levels, ","); got != "WARN,WARN,WARN,WARN,INFO,WARN" {
+ t.Errorf("日志级别依次为 %s,期望只在状态或者不可用的原因变化时记录(只有本机临时端口不同的错误是同一个原因),退出开始之后不记录:WARN,WARN,WARN,WARN,INFO,WARN", got)
+ }
+}
diff --git a/internal/bootstrap/redis_test.go b/internal/bootstrap/redis_test.go
new file mode 100644
index 0000000..069949d
--- /dev/null
+++ b/internal/bootstrap/redis_test.go
@@ -0,0 +1,87 @@
+package bootstrap
+
+import (
+ "bytes"
+ "log/slog"
+ "slices"
+ "strings"
+ "testing"
+ "testing/synctest"
+ "time"
+)
+
+func TestLoadRedisConfig(t *testing.T) {
+ t.Setenv("REDIS_HOST", "cache.internal")
+ unsetenv(t, "REDIS_PORT")
+ unsetenv(t, "REDIS_PASSWORD")
+ unsetenv(t, "REDIS_DB")
+ opts, err := loadRedisConfig()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if opts.Addr != "cache.internal:6379" || opts.Password != "" || opts.DB != 0 || !opts.ContextTimeoutEnabled {
+ t.Errorf("地址 %s、密码 %q、库 %d、遵守 ctx 的期限 %v,期望默认端口 6379、不认证、0 号库,并遵守 ctx 的期限", opts.Addr, opts.Password, opts.DB, opts.ContextTimeoutEnabled)
+ }
+
+ t.Setenv("REDIS_HOST", "::1")
+ t.Setenv("REDIS_PORT", "6380")
+ t.Setenv("REDIS_PASSWORD", "Zq9x@K7mP")
+ t.Setenv("REDIS_DB", "2")
+ opts, err = loadRedisConfig()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if opts.Addr != "[::1]:6380" || opts.Password != "Zq9x@K7mP" || opts.DB != 2 {
+ t.Errorf("地址 %s、密码 %q、库 %d,期望 [::1]:6380、环境变量中的密码、2 号库", opts.Addr, opts.Password, opts.DB)
+ }
+
+ for _, tc := range []struct{ name, value string }{
+ {"REDIS_HOST", ""},
+ {"REDIS_PORT", "0"},
+ {"REDIS_DB", "-1"},
+ {"REDIS_DB", "db"},
+ } {
+ t.Run(tc.name+"="+tc.value, func(t *testing.T) {
+ t.Setenv(tc.name, tc.value)
+ _, err := loadRedisConfig()
+ if err == nil || !strings.Contains(err.Error(), tc.name) {
+ t.Fatalf("错误 = %v,期望报错并写明 %s", err, tc.name)
+ }
+ if strings.Contains(err.Error(), "Zq9x") || strings.Contains(err.Error(), "K7mP") {
+ t.Errorf("错误信息中不能出现密码:%v", err)
+ }
+ })
+ }
+}
+
+func TestRedisLoggerSkipsRepeatsWithinAMinute(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ var logs bytes.Buffer
+ l := newRedisLogger(newLogger(&logs, slog.LevelInfo))
+ const dial = "redis: connection pool: failed to dial after %d attempts: %v"
+ for range 10 {
+ l.Printf(t.Context(), dial, 5, "connection refused")
+ l.Printf(t.Context(), "redis: discarding bad PubSub connection: %v", "EOF")
+ time.Sleep(time.Second)
+ }
+ time.Sleep(redisLogRepeat)
+ l.Printf(t.Context(), dial, 5, "connection refused")
+
+ var got []string
+ for line := range strings.Lines(strings.TrimSpace(logs.String())) {
+ entry := decodeLine(t, []byte(line))
+ if entry["level"] != "WARN" {
+ t.Errorf("go-redis 的日志级别 = %v,期望 WARN", entry["level"])
+ }
+ got = append(got, entry["msg"].(string))
+ }
+ want := []string{
+ "redis: connection pool: failed to dial after 5 attempts: connection refused",
+ "redis: discarding bad PubSub connection: EOF",
+ "redis: connection pool: failed to dial after 5 attempts: connection refused",
+ }
+ if !slices.Equal(got, want) {
+ t.Errorf("记下的日志 = %q,期望同样的内容一分钟内只记一次", got)
+ }
+ })
+}
diff --git a/internal/bootstrap/serve_test.go b/internal/bootstrap/serve_test.go
new file mode 100644
index 0000000..073de83
--- /dev/null
+++ b/internal/bootstrap/serve_test.go
@@ -0,0 +1,160 @@
+package bootstrap
+
+import (
+ "slices"
+ "testing"
+)
+
+func TestLoadServeConfig(t *testing.T) {
+ unsetenv(t, "ALISTEN_LISTEN")
+ unsetenv(t, "ALISTEN_OPS_LISTEN")
+ unsetenv(t, "ALISTEN_ADMIN_TOKEN")
+ unsetenv(t, "ALISTEN_ALLOWED_ORIGINS")
+ t.Setenv("ALISTEN_PUBLIC_URL", "https://alisten.example")
+ t.Setenv("DATABASE_HOST", "db.internal")
+ t.Setenv("DATABASE_USERNAME", "alisten")
+ t.Setenv("DATABASE_NAME", "alisten")
+ t.Setenv("REDIS_HOST", "cache.internal")
+ t.Setenv("STORAGE_PUBLIC_URL", "https://alisten.example/storage/alisten/")
+ t.Setenv("STORAGE_ENDPOINT_URL", "http://rustfs:9000")
+ t.Setenv("STORAGE_ACCESS_KEY_ID", "alisten")
+ t.Setenv("STORAGE_SECRET_ACCESS_KEY", "secret")
+ unsetenv(t, "STORAGE_REGION")
+ unsetenv(t, "STORAGE_BUCKET_NAME")
+
+ cfg, err := loadServeConfig()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.listen != ":8080" || cfg.opsListen != ":9090" || cfg.publicOrigin != "https://alisten.example" || cfg.database.ConnConfig.Host != "db.internal" || cfg.redis.Addr != "cache.internal:6379" {
+ t.Errorf("配置 = %s、%s、%s、%s、%s", cfg.listen, cfg.opsListen, cfg.publicOrigin, cfg.database.ConnConfig.Host, cfg.redis.Addr)
+ }
+ if cfg.adminToken != "" {
+ t.Error("未设置 ALISTEN_ADMIN_TOKEN 时管理接口不可用")
+ }
+ if cfg.storageURL != "https://alisten.example/storage/alisten" {
+ t.Errorf("对象存储的地址前缀 = %q,期望去掉结尾的 /", cfg.storageURL)
+ }
+ if cfg.storage.Endpoint != "http://rustfs:9000" || cfg.storage.Bucket != "alisten" {
+ t.Errorf("对象存储 = %+v", cfg.storage)
+ }
+ if !slices.Equal(cfg.allowedOrigins, []string{"https://alisten.example"}) {
+ t.Errorf("默认允许来源 = %v,期望只有公开地址", cfg.allowedOrigins)
+ }
+ t.Setenv("ALISTEN_ALLOWED_ORIGINS", "https://a.example, http://localhost:9876,https://a.example")
+ cfg, err = loadServeConfig()
+ if err != nil || !slices.Equal(cfg.allowedOrigins, []string{"https://a.example", "http://localhost:9876"}) {
+ t.Errorf("显式允许来源 = %v, %v,期望两个去重后的地址", cfg.allowedOrigins, err)
+ }
+ for _, value := range []string{"https://a.example,,https://b.example", "ftp://a.example", "not-a-url"} {
+ t.Setenv("ALISTEN_ALLOWED_ORIGINS", value)
+ if _, err := loadServeConfig(); err == nil {
+ t.Errorf("ALISTEN_ALLOWED_ORIGINS=%q 时应报错", value)
+ }
+ }
+ t.Setenv("ALISTEN_ALLOWED_ORIGINS", "")
+
+ t.Setenv("ALISTEN_ADMIN_TOKEN", "secret")
+ if cfg, err := loadServeConfig(); err != nil || cfg.adminToken != "secret" {
+ t.Errorf("管理 token = %q, %v,期望来自 ALISTEN_ADMIN_TOKEN", cfg.adminToken, err)
+ }
+ for _, name := range []string{"DATABASE_HOST", "REDIS_HOST", "STORAGE_PUBLIC_URL", "STORAGE_ENDPOINT_URL"} {
+ t.Run(name, func(t *testing.T) {
+ unsetenv(t, name)
+ if _, err := loadServeConfig(); err == nil {
+ t.Errorf("缺少 %s 时应报错", name)
+ }
+ })
+ }
+}
+
+func TestLoadServeConfigPublicURLDefaults(t *testing.T) {
+ t.Setenv("ALISTEN_ALLOWED_ORIGINS", "")
+ t.Setenv("DATABASE_HOST", "db.internal")
+ t.Setenv("DATABASE_USERNAME", "alisten")
+ t.Setenv("DATABASE_NAME", "alisten")
+ t.Setenv("REDIS_HOST", "cache.internal")
+ t.Setenv("STORAGE_PUBLIC_URL", "")
+ t.Setenv("STORAGE_ENDPOINT_URL", "http://rustfs:9000")
+ t.Setenv("STORAGE_REGION", "us-east-1")
+ t.Setenv("STORAGE_ACCESS_KEY_ID", "alisten")
+ t.Setenv("STORAGE_SECRET_ACCESS_KEY", "secret")
+ unsetenv(t, "STORAGE_BUCKET_NAME")
+
+ for _, tt := range []struct {
+ name string
+ publicURL string
+ wantOrigin string
+ wantStorage string
+ }{
+ {name: "公开地址为空", publicURL: "", wantOrigin: "http://localhost:9876", wantStorage: "http://localhost:9876/storage/alisten"},
+ {name: "空域名占位", publicURL: "https://", wantOrigin: "http://localhost:9876", wantStorage: "http://localhost:9876/storage/alisten"},
+ {name: "局域网 HTTP", publicURL: "http://192.168.1.20:9876", wantOrigin: "http://192.168.1.20:9876", wantStorage: "http://192.168.1.20:9876/storage/alisten"},
+ {name: "公开地址带路径", publicURL: "http://localhost:9876/r/hall", wantOrigin: "http://localhost:9876", wantStorage: "http://localhost:9876/storage/alisten"},
+ {name: "公开地址带查询和片段", publicURL: "http://localhost:9876/?x=1#y", wantOrigin: "http://localhost:9876", wantStorage: "http://localhost:9876/storage/alisten"},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Setenv("ALISTEN_PUBLIC_URL", tt.publicURL)
+ cfg, err := loadServeConfig()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.publicOrigin != tt.wantOrigin || cfg.storageURL != tt.wantStorage {
+ t.Errorf("公开地址 = %q,对象存储地址 = %q,期望 %q、%q", cfg.publicOrigin, cfg.storageURL, tt.wantOrigin, tt.wantStorage)
+ }
+ })
+ }
+}
+
+func TestPublicOrigin(t *testing.T) {
+ valid := map[string]string{
+ "https://alisten.example": "https://alisten.example",
+ "https://Alisten.Example/": "https://alisten.example",
+ "HTTPS://alisten.example:443/r/hall": "https://alisten.example",
+ "http://localhost:80": "http://localhost",
+ "http://localhost:8080": "http://localhost:8080",
+ "https://[::1]:443": "https://[::1]",
+ "https://[2001:db8::1]:8443": "https://[2001:db8::1]:8443",
+ "https://[2001:DB8::1]": "https://[2001:db8::1]",
+ "http://127.0.0.1:8080": "http://127.0.0.1:8080",
+ "https://alisten.example.": "https://alisten.example.",
+ "https://alisten.example:8443/?x=1#y": "https://alisten.example:8443",
+ "https://xn--fsqu00a.example": "https://xn--fsqu00a.example",
+ }
+ for value, want := range valid {
+ if got, err := publicOrigin(value); err != nil || got != want {
+ t.Errorf("publicOrigin(%q) = %q, %v,期望 %q", value, got, err, want)
+ }
+ }
+ invalid := []string{
+ "", "alisten.example", "ftp://alisten.example", "https://", "https://:8080", "http://:80/r/hall", "/r/hall", "https://alisten.example:port",
+ "https://alisten.example:", "https://alisten.example:0443", "https://alisten.example:0", "https://alisten.example:65536",
+ "https://例子.example",
+ "https://[2001:db8:0:0:0:0:0:1]", "https://[2001:0db8::1]", "https://[::ffff:192.0.2.1]", "https://[fe80::1%25eth0]",
+ "http://127.1:8080", "http://0x7f000001", "http://010.0.0.1", "http://127.0.0.1.:8080", "http://example.123",
+ }
+ for _, value := range invalid {
+ if got, err := publicOrigin(value); err == nil {
+ t.Errorf("publicOrigin(%q) = %q,期望报错", value, got)
+ }
+ }
+}
+
+func TestParseStorageURL(t *testing.T) {
+ valid := map[string]string{
+ "https://alisten.example/storage/alisten": "https://alisten.example/storage/alisten",
+ "https://alisten.example/storage/alisten/": "https://alisten.example/storage/alisten",
+ "http://localhost:9000/alisten": "http://localhost:9000/alisten",
+ "https://cdn.example": "https://cdn.example",
+ }
+ for value, want := range valid {
+ if got, err := parseStorageURL(value); err != nil || got != want {
+ t.Errorf("parseStorageURL(%q) = %q, %v,期望 %q", value, got, err, want)
+ }
+ }
+ for _, value := range []string{"", "cdn.example", "ftp://cdn.example", "https://", "https://:9000/alisten", "/storage", "https://cdn.example/a?x=1", "https://cdn.example/a#b", "https://cdn.example/a?", "https://cdn.example/a#", "https://key:secret@cdn.example/a"} {
+ if got, err := parseStorageURL(value); err == nil {
+ t.Errorf("parseStorageURL(%q) = %q,期望报错", value, got)
+ }
+ }
+}
diff --git a/internal/bootstrap/shutdown_test.go b/internal/bootstrap/shutdown_test.go
new file mode 100644
index 0000000..7948093
--- /dev/null
+++ b/internal/bootstrap/shutdown_test.go
@@ -0,0 +1,421 @@
+package bootstrap
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "log/slog"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "slices"
+ "strings"
+ "sync"
+ "syscall"
+ "testing"
+ "time"
+
+ "github.com/coder/websocket"
+)
+
+// alice 不回应关闭握手,网关要等她最多 5 秒;
+// 这段时间里 bob 已经收到 1012,如果会话先于房间关闭,房间此时还在处理命令。
+func TestShutdownReleasesRoomsBeforeClosingSessions(t *testing.T) {
+ contractEnv(t, startPostgres(t)(t), startValkey(t)(t))
+ svc := startService(t)
+ request := func(text string) map[string]string {
+ t.Helper()
+ line, status, _, body := send(t, svc.server, text)
+ if status != http.StatusCreated {
+ t.Fatalf("%s 的状态码 = %d:%s", line, status, body)
+ }
+ var v map[string]string
+ if err := json.Unmarshal(body, &v); err != nil {
+ t.Fatal(err)
+ }
+ return v
+ }
+ roomID := request("POST /api/rooms\n\n{\"name\": \"临时\", \"description\": \"\"}")["id"]
+ join := func() (*websocket.Conn, string) {
+ t.Helper()
+ var session struct{ MemberToken string }
+ _, _, _, body := send(t, svc.server, "POST /api/members\n\n{}")
+ if err := json.Unmarshal(body, &session); err != nil {
+ t.Fatal(err)
+ }
+ ticket := request("POST /api/rooms/" + roomID + "/tickets\nAuthorization: Bearer " + session.MemberToken + "\n\n{}")["ticket"]
+ conn := dial(t, svc.server, "")
+ hello := `{"kind":"request","type":"hello","id":"h1","data":{"roomId":"` + roomID + `","ticket":"` + ticket + `","protocolVersion":"` + protocolVersion(t) + `"}}`
+ if err := conn.Write(t.Context(), websocket.MessageText, []byte(hello)); err != nil {
+ t.Fatal(err)
+ }
+ if _, frame, err := conn.Read(t.Context()); err != nil || !strings.Contains(string(frame), `"type":"hello"`) || strings.Contains(string(frame), `"error"`) {
+ t.Fatalf("握手:%s, %v", frame, err)
+ }
+ return conn, ticket
+ }
+ _, _ = join()
+ bob, bobTicket := join()
+ // bob 加入时 alice 收到的在线列表留在她的连接中不读:她既不读取消息,也不回应关闭握手。
+
+ var logs bytes.Buffer
+ done := make(chan error, 1)
+ go func() {
+ done <- svc.public.shutdown(context.Background(), svc.server.Config, newLogger(&logs, slog.LevelInfo))
+ }()
+ ctx, cancel := context.WithTimeout(t.Context(), messageWait)
+ defer cancel()
+ if _, frame, err := bob.Read(ctx); websocket.CloseStatus(err) != websocket.StatusServiceRestart {
+ t.Fatalf("bob 应收到 1012,实际收到 %s, %v", frame, err)
+ }
+ line, status, _, body := send(t, svc.server, "POST /api/room/messages\nAuthorization: Bearer "+bobTicket+"\n\n{\"content\": \"还在吗\"}")
+ if status != http.StatusServiceUnavailable {
+ t.Errorf("会话关闭时 %s = %d %s,期望房间已释放、返回 503", line, status, body)
+ }
+
+ if err := <-done; err != nil {
+ t.Fatal(err)
+ }
+ var steps []string
+ for line := range strings.Lines(strings.TrimSpace(logs.String())) {
+ steps = append(steps, decodeLine(t, []byte(line))["msg"].(string))
+ }
+ if got := strings.Join(steps, ","); got != "已释放本实例的全部房间,已关闭全部 WebSocket 会话,已停止执行准备任务" {
+ t.Errorf("退出的日志依次为 %s", got)
+ }
+}
+
+func TestShutdownStopsJobsWhenPublicPortCannotDrain(t *testing.T) {
+ contractEnv(t, startPostgres(t)(t), startValkey(t)(t))
+ svc := startService(t)
+
+ entered, release := make(chan struct{}), make(chan struct{})
+ stalled := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
+ close(entered)
+ <-release
+ }))
+ t.Cleanup(stalled.Close)
+ t.Cleanup(func() { close(release) })
+ req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, stalled.URL, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ go func() {
+ if resp, err := stalled.Client().Do(req); err == nil {
+ _ = resp.Body.Close()
+ }
+ }()
+ <-entered
+
+ ctx, cancel := context.WithTimeout(t.Context(), time.Second)
+ defer cancel()
+ var logs bytes.Buffer
+ err = svc.public.shutdown(ctx, stalled.Config, newLogger(&logs, slog.LevelInfo))
+ if !errors.Is(err, context.DeadlineExceeded) || !strings.Contains(err.Error(), "关闭公开端口") {
+ t.Errorf("公开端口排不空时的错误 = %v,期望关闭公开端口超过期限", err)
+ }
+ select {
+ case <-svc.public.jobs.Stopped():
+ default:
+ t.Error("公开端口排不空时 River client 没有停止")
+ }
+ if !strings.Contains(logs.String(), "已停止执行准备任务") {
+ t.Errorf("共用的期限用完之后,停止 River client 仍应在自己的期限内完成:\n%s", logs.String())
+ }
+}
+
+// Helm chart 和 compose 设置的退出宽限期都是 30 秒,与 Kubernetes 的默认值相同。
+func TestShutdownFitsGracePeriod(t *testing.T) {
+ if total := drainDelay + shutdownTimeout + jobsStopTimeout; total > 30*time.Second {
+ t.Errorf("退出最长要 %v,超过了 30 秒的退出宽限期", total)
+ }
+}
+
+type lockedBuffer struct {
+ mu sync.Mutex
+ buf bytes.Buffer
+}
+
+func (b *lockedBuffer) Write(p []byte) (int, error) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.buf.Write(p)
+}
+
+func (b *lockedBuffer) messages(t *testing.T) []string {
+ t.Helper()
+ b.mu.Lock()
+ text := b.buf.String()
+ b.mu.Unlock()
+ var msgs []string
+ for line := range strings.Lines(strings.TrimSpace(text)) {
+ msgs = append(msgs, decodeLine(t, []byte(line))["msg"].(string))
+ }
+ return msgs
+}
+
+// 接受连接但从不应答,span 要到退出时才导出:这样导出剩下的 span 会超时,而不是很快失败。
+func unresponsiveCollector(t *testing.T) {
+ t.Helper()
+ release := make(chan struct{})
+ collector := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { <-release }))
+ // Cleanup 按注册的逆序执行:先放行,Close 才不会等待没有返回的请求。
+ t.Cleanup(collector.Close)
+ t.Cleanup(func() { close(release) })
+ t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", collector.URL)
+ t.Setenv("OTEL_BSP_SCHEDULE_DELAY", "3600000")
+}
+
+// 监听地址的端口不能写 0,所以先让系统挑一个空闲的端口。
+func freeAddr(t *testing.T) string {
+ t.Helper()
+ l, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = l.Close() }()
+ return l.Addr().String()
+}
+
+func eventually(t *testing.T, what string, finished <-chan struct{}, cond func() bool) {
+ t.Helper()
+ deadline := time.After(60 * time.Second)
+ for !cond() {
+ select {
+ case <-finished:
+ if !cond() {
+ t.Fatalf("等待%s时 runServe 已经返回", what)
+ }
+ case <-deadline:
+ t.Fatalf("60 秒内没有等到%s", what)
+ case <-time.After(50 * time.Millisecond):
+ }
+ }
+}
+
+func readyStatus(opsAddr string) int {
+ client := &http.Client{Timeout: 2 * time.Second}
+ resp, err := client.Get("http://" + opsAddr + "/readyz")
+ if err != nil {
+ return 0
+ }
+ _ = resp.Body.Close()
+ return resp.StatusCode
+}
+
+func TestServeExitsGracefullyOnSIGTERM(t *testing.T) {
+ contractEnv(t, startPostgres(t)(t), startValkey(t)(t))
+ listen, opsListen := freeAddr(t), freeAddr(t)
+ t.Setenv("ALISTEN_LISTEN", listen)
+ t.Setenv("ALISTEN_OPS_LISTEN", opsListen)
+ unresponsiveCollector(t)
+ if err := runMigrate(newLogger(io.Discard, slog.LevelInfo)); err != nil {
+ t.Fatal(err)
+ }
+
+ var logs lockedBuffer
+ var runErr error
+ var returned time.Time
+ finished := make(chan struct{})
+ go func() {
+ defer close(finished)
+ runErr = runServe(newLogger(&logs, slog.LevelInfo))
+ returned = time.Now()
+ }()
+
+ eventually(t, "/readyz 返回 200", finished, func() bool { return readyStatus(opsListen) == http.StatusOK })
+ // 这个 HTTP 请求记下一个 span,它到退出时才导出。
+ if resp, err := (&http.Client{Timeout: 10 * time.Second}).Get("http://" + listen + "/api/rooms"); err != nil {
+ t.Fatal(err)
+ } else {
+ _ = resp.Body.Close()
+ }
+ signalled := time.Now()
+ if err := syscall.Kill(syscall.Getpid(), syscall.SIGTERM); err != nil {
+ t.Fatal(err)
+ }
+ // 退出开始的日志写出时,就绪标志已经清除;入口摘除之前(5 秒)运维端口仍然可以访问。
+ eventually(t, "退出开始的日志", finished, func() bool {
+ return slices.Contains(logs.messages(t), "收到退出信号,等待入口摘除本实例")
+ })
+ if status := readyStatus(opsListen); status == http.StatusOK {
+ t.Error("收到退出信号之后 /readyz 仍然返回 200")
+ }
+
+ select {
+ case <-finished:
+ case <-time.After(60 * time.Second):
+ t.Fatal("收到 SIGTERM 60 秒之后 runServe 仍没有返回")
+ }
+ if runErr != nil {
+ t.Errorf("收到 SIGTERM 之后 runServe 返回 %v,期望 nil", runErr)
+ }
+ if elapsed := returned.Sub(signalled); elapsed < drainDelay {
+ t.Errorf("收到 SIGTERM %v 之后就退出了,期望先等 %v 让入口摘除本实例", elapsed, drainDelay)
+ }
+ want := []string{
+ "服务已启动",
+ "收到退出信号,等待入口摘除本实例",
+ "已释放本实例的全部房间",
+ "已关闭全部 WebSocket 会话",
+ "已停止执行准备任务",
+ "导出剩下的追踪失败",
+ "服务已退出",
+ }
+ var got []string
+ for _, msg := range logs.messages(t) {
+ if slices.Contains(want, msg) {
+ got = append(got, msg)
+ }
+ }
+ if !slices.Equal(got, want) {
+ t.Errorf("退出的日志依次为\n%s\n期望\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
+ }
+}
+
+type tcpProxy struct {
+ listener net.Listener
+ target string
+
+ mu sync.Mutex
+ cut bool
+ conns []net.Conn
+}
+
+func startProxy(t *testing.T, target string) *tcpProxy {
+ t.Helper()
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ p := &tcpProxy{listener: listener, target: target}
+ t.Cleanup(p.disconnect)
+ go func() {
+ for {
+ client, err := listener.Accept()
+ if err != nil {
+ return
+ }
+ go p.forward(client)
+ }
+ }()
+ return p
+}
+
+func (p *tcpProxy) forward(client net.Conn) {
+ server, err := net.Dial("tcp", p.target)
+ if err != nil {
+ _ = client.Close()
+ return
+ }
+ p.mu.Lock()
+ if p.cut {
+ p.mu.Unlock()
+ _ = client.Close()
+ _ = server.Close()
+ return
+ }
+ p.conns = append(p.conns, client, server)
+ p.mu.Unlock()
+ go func() {
+ _, _ = io.Copy(server, client)
+ _ = server.Close()
+ }()
+ _, _ = io.Copy(client, server)
+ _ = client.Close()
+}
+
+func (p *tcpProxy) disconnect() {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.cut = true
+ _ = p.listener.Close()
+ for _, c := range p.conns {
+ _ = c.Close()
+ }
+}
+
+// 会话不回应关闭握手,关闭它要等最多 5 秒:PostgreSQL 断开之后、依赖检查停止之前,至少完成一次检查。
+func TestServeLogsNoReadinessDuringExit(t *testing.T) {
+ database := startPostgres(t)(t)
+ contractEnv(t, database, startValkey(t)(t))
+ if err := runMigrate(newLogger(io.Discard, slog.LevelInfo)); err != nil {
+ t.Fatal(err)
+ }
+ u, err := url.Parse(database)
+ if err != nil {
+ t.Fatal(err)
+ }
+ proxy := startProxy(t, u.Host)
+ u.Host = proxy.listener.Addr().String()
+ connectionEnv(t, u.String())
+ listen, opsListen := freeAddr(t), freeAddr(t)
+ t.Setenv("ALISTEN_LISTEN", listen)
+ t.Setenv("ALISTEN_OPS_LISTEN", opsListen)
+
+ var logs lockedBuffer
+ finished := make(chan struct{})
+ go func() {
+ defer close(finished)
+ _ = runServe(newLogger(&logs, slog.LevelInfo))
+ }()
+ eventually(t, "/readyz 返回 200", finished, func() bool { return readyStatus(opsListen) == http.StatusOK })
+
+ call := func(method, path, token, body string) map[string]any {
+ t.Helper()
+ req, err := http.NewRequestWithContext(t.Context(), method, "http://"+listen+path, strings.NewReader(body))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ var v map[string]any
+ if err := json.NewDecoder(resp.Body).Decode(&v); err != nil || resp.StatusCode != http.StatusCreated {
+ t.Fatalf("%s %s = %d, %v", method, path, resp.StatusCode, err)
+ }
+ return v
+ }
+ token := call(http.MethodPost, "/api/members", "", "{}")["memberToken"].(string)
+ roomID := call(http.MethodPost, "/api/rooms", "", `{"name": "临时", "description": ""}`)["id"].(string)
+ ticket := call(http.MethodPost, "/api/rooms/"+roomID+"/tickets", token, "{}")["ticket"].(string)
+ conn, _, err := websocket.Dial(t.Context(), "ws://"+listen+"/ws", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = conn.CloseNow() })
+ hello := `{"kind":"request","type":"hello","id":"h1","data":{"roomId":"` + roomID + `","ticket":"` + ticket + `","protocolVersion":"` + protocolVersion(t) + `"}}`
+ if err := conn.Write(t.Context(), websocket.MessageText, []byte(hello)); err != nil {
+ t.Fatal(err)
+ }
+ if _, frame, err := conn.Read(t.Context()); err != nil || strings.Contains(string(frame), `"error"`) {
+ t.Fatalf("握手:%s, %v", frame, err)
+ }
+ // 之后不再读取这个连接,也就不会回应关闭握手。
+
+ if err := syscall.Kill(syscall.Getpid(), syscall.SIGTERM); err != nil {
+ t.Fatal(err)
+ }
+ const exiting = "收到退出信号,等待入口摘除本实例"
+ eventually(t, "退出开始的日志", finished, func() bool { return slices.Contains(logs.messages(t), exiting) })
+ proxy.disconnect()
+ select {
+ case <-finished:
+ case <-time.After(60 * time.Second):
+ t.Fatal("收到 SIGTERM 60 秒之后 runServe 仍没有返回")
+ }
+ msgs := logs.messages(t)
+ for _, msg := range msgs[slices.Index(msgs, exiting):] {
+ if msg == "依赖检查未通过,实例未就绪" || msg == "依赖检查通过,实例已就绪" {
+ t.Errorf("退出开始之后记了就绪检查的日志 %q:\n%s", msg, strings.Join(msgs, "\n"))
+ }
+ }
+}
diff --git a/internal/bootstrap/spans_test.go b/internal/bootstrap/spans_test.go
new file mode 100644
index 0000000..b1cc876
--- /dev/null
+++ b/internal/bootstrap/spans_test.go
@@ -0,0 +1,301 @@
+package bootstrap
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "slices"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+ goredis "github.com/redis/go-redis/v9"
+ "go.opentelemetry.io/otel/codes"
+ sdktrace "go.opentelemetry.io/otel/sdk/trace"
+ "go.opentelemetry.io/otel/sdk/trace/tracetest"
+ "go.opentelemetry.io/otel/trace"
+
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/app/port/porttest"
+)
+
+func recordingTracer(t *testing.T) (*sdktrace.TracerProvider, *tracetest.SpanRecorder) {
+ t.Helper()
+ spans := tracetest.NewSpanRecorder()
+ tracer := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spans))
+ t.Cleanup(func() { _ = tracer.Shutdown(context.Background()) })
+ return tracer, spans
+}
+
+func childSpans(t *testing.T, spans *tracetest.SpanRecorder, parent trace.Span) []string {
+ t.Helper()
+ var got []string
+ for _, span := range spans.Ended() {
+ if span.SpanContext().SpanID() == parent.SpanContext().SpanID() {
+ continue
+ }
+ if span.Parent().SpanID() != parent.SpanContext().SpanID() || span.SpanKind() != trace.SpanKindClient {
+ t.Errorf("%s 的父 span = %v、类型 = %v,期望是请求的 span 之下的 client span", span.Name(), span.Parent(), span.SpanKind())
+ }
+ got = append(got, span.Name()+" "+span.Status().Code.String())
+ }
+ return got
+}
+
+func attributes(span sdktrace.ReadOnlySpan) map[string]string {
+ attrs := map[string]string{}
+ for _, kv := range span.Attributes() {
+ attrs[string(kv.Key)] = kv.Value.String()
+ }
+ return attrs
+}
+
+func TestOutboundHTTPSpans(t *testing.T) {
+ var mu sync.Mutex
+ var traceparents []string
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mu.Lock()
+ traceparents = append(traceparents, r.Header.Get("Traceparent"))
+ mu.Unlock()
+ if r.URL.Path == "/missing" {
+ http.NotFound(w, r)
+ return
+ }
+ _, _ = io.WriteString(w, "音频")
+ }))
+ t.Cleanup(upstream.Close)
+ tracer, spans := recordingTracer(t)
+ client := &http.Client{Transport: traceHTTP(http.DefaultTransport, tracer)}
+ get := func(ctx context.Context, path string) *http.Response {
+ t.Helper()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, upstream.URL+path+"?vkey=secret", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return resp
+ }
+
+ _ = get(t.Context(), "/audio").Body.Close()
+ if n := len(spans.Ended()); n != 0 {
+ t.Fatalf("后台的请求记了 %d 个 span", n)
+ }
+ ctx, parent := tracer.Tracer("test").Start(t.Context(), "请求")
+ resp := get(ctx, "/audio")
+ if n := len(spans.Ended()); n != 0 {
+ t.Error("响应体读完之前 span 就结束了")
+ }
+ if _, err := io.ReadAll(resp.Body); err != nil {
+ t.Fatal(err)
+ }
+ _ = resp.Body.Close()
+ _ = get(ctx, "/missing").Body.Close()
+ parent.End()
+
+ if got, want := childSpans(t, spans, parent), []string{"HTTP GET Unset", "HTTP GET Error"}; !slices.Equal(got, want) {
+ t.Errorf("span = %q,期望 %q", got, want)
+ }
+ for i, span := range spans.Ended()[:2] {
+ attrs := attributes(span)
+ if attrs["http.request.method"] != "GET" || attrs["server.address"] != "127.0.0.1" || attrs["http.response.status_code"] != []string{"200", "404"}[i] {
+ t.Errorf("%s 的属性 = %v", span.Name(), attrs)
+ }
+ for key, value := range attrs {
+ if strings.Contains(value, "vkey") || strings.Contains(value, "/audio") {
+ t.Errorf("属性 %s = %q 带出了请求的地址", key, value)
+ }
+ }
+ }
+ mu.Lock()
+ defer mu.Unlock()
+ for _, header := range traceparents {
+ if header != "" {
+ t.Errorf("音源收到了 traceparent:%q", header)
+ }
+ }
+}
+
+func TestObjectStoreSpans(t *testing.T) {
+ tracer, spans := recordingTracer(t)
+ store := porttest.NewMediaStore()
+ objects := traceObjects(store, tracer)
+ if _, err := objects.Exists(t.Context(), "link/a/audio.mp3"); err != nil {
+ t.Fatal(err)
+ }
+ if n := len(spans.Ended()); n != 0 {
+ t.Fatalf("后台的调用记了 %d 个 span", n)
+ }
+
+ ctx, parent := tracer.Tracer("test").Start(t.Context(), "任务")
+ if err := objects.Put(ctx, "link/a/audio.mp3", strings.NewReader("mp3"), "audio/mpeg"); err != nil {
+ t.Fatal(err)
+ }
+ if ok, err := objects.Exists(ctx, "link/a/audio.mp3"); !ok || err != nil {
+ t.Fatalf("Exists = %v, %v", ok, err)
+ }
+ parent.End()
+ store.Fail(errors.New("连接中断"))
+ if err := objects.Delete(ctx, "link/a/audio.mp3"); err == nil {
+ t.Fatal("对象存储出错时 Delete 应返回错误")
+ }
+
+ if got, want := childSpans(t, spans, parent), []string{"S3 PutObject Unset", "S3 HeadObject Unset", "S3 DeleteObject Error"}; !slices.Equal(got, want) {
+ t.Errorf("span = %q,期望 %q", got, want)
+ }
+}
+
+func TestDatabaseAndRedisSpans(t *testing.T) {
+ contractEnv(t, startPostgres(t)(t), startValkey(t)(t))
+ tracer, spans := recordingTracer(t)
+ cfg, err := loadDatabaseConfig()
+ if err != nil {
+ t.Fatal(err)
+ }
+ traceDatabase(cfg, tracer)
+ pool, err := pgxpool.NewWithConfig(t.Context(), cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(pool.Close)
+ opts, err := loadRedisConfig()
+ if err != nil {
+ t.Fatal(err)
+ }
+ rdb := goredis.NewClient(opts)
+ t.Cleanup(func() { _ = rdb.Close() })
+ traceRedis(rdb, tracer)
+
+ if _, err := pool.Exec(t.Context(), "SELECT 1"); err != nil {
+ t.Fatal(err)
+ }
+ if err := rdb.Ping(t.Context()).Err(); err != nil {
+ t.Fatal(err)
+ }
+ if n := len(spans.Ended()); n != 0 {
+ t.Fatalf("后台的调用记了 %d 个 span", n)
+ }
+
+ ctx, parent := tracer.Tracer("test").Start(t.Context(), "请求")
+ if _, err := pool.Exec(ctx, "-- name: Probe :exec\nSELECT 1"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, "SELECT 2"); err != nil {
+ t.Fatal(err)
+ }
+ if err := rdb.Get(ctx, "不存在的键").Err(); !errors.Is(err, goredis.Nil) {
+ t.Fatalf("GET = %v,期望 redis.Nil", err)
+ }
+ parent.End()
+ if err := rdb.Ping(ctx).Err(); err != nil {
+ t.Fatal(err)
+ }
+ // Script.Run 先发 EVALSHA,脚本不在 Redis 的缓存中时收到 NOSCRIPT,再改用 EVAL。
+ if err := goredis.NewScript("return 1").Run(ctx, rdb, nil).Err(); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, "SELECT * FROM 不存在的表"); err == nil {
+ t.Fatal("查询不存在的表应报错")
+ }
+ if err := rdb.Do(ctx, "不存在的命令").Err(); err == nil {
+ t.Fatal("不存在的命令应报错")
+ }
+
+ if got, want := childSpans(t, spans, parent), []string{"Probe Unset", "SELECT Unset", "get Unset", "ping Unset", "evalsha Unset", "eval Unset", "SELECT Error", "不存在的命令 Error"}; !slices.Equal(got, want) {
+ t.Errorf("span = %q,期望 %q", got, want)
+ }
+ for _, span := range spans.Ended() {
+ if description := span.Status().Description; description != "" {
+ t.Errorf("%s 的状态描述 = %q,期望不带错误的文本", span.Name(), description)
+ }
+ }
+}
+
+func TestQueryName(t *testing.T) {
+ cases := map[string]string{
+ "-- name: JobGetAvailable :many\nWITH locked_jobs AS (SELECT 1)": "JobGetAvailable",
+ "select 1": "SELECT",
+ "\n\t begin": "BEGIN",
+ "commit\nignored": "COMMIT",
+ "": "QUERY",
+ " \n": "QUERY",
+ "-- name: ": "QUERY",
+ "-- name: \n\t": "QUERY",
+ }
+ for sql, want := range cases {
+ if got := queryName(sql); got != want {
+ t.Errorf("queryName(%q) = %q,期望 %q", sql, got, want)
+ }
+ }
+ long := "-- name: JobGetAvailable :many\n" + strings.Repeat("SELECT id FROM river_job WHERE state = 'available';\n", 40)
+ if n := testing.AllocsPerRun(100, func() { _ = queryName(long) }); n != 0 {
+ t.Errorf("queryName 处理 %d 字节的语句分配了 %v 次内存,期望 0 次", len(long), n)
+ }
+}
+
+// 使用记录 span 的 provider:这样没有记录 span 是因为 ctx 中没有已采样的 span,而不是因为 provider 什么都不做。
+func TestCallsOutsideSampledTracesAllocateNothing(t *testing.T) {
+ tracer, _ := recordingTracer(t)
+ ctx := t.Context()
+ queries := databaseSpans{tracer: tracer.Tracer("test")}
+ query := pgx.TraceQueryStartData{SQL: "-- name: JobGetAvailable :many\nSELECT 1"}
+ command := redisSpans{tracer: tracer.Tracer("test")}.ProcessHook(func(context.Context, goredis.Cmder) error { return nil })
+ cmd := goredis.NewCmd(ctx, "get", "key")
+ for name, call := range map[string]func(){
+ "数据库查询": func() { queries.TraceQueryStart(ctx, nil, query) },
+ "Redis 命令": func() { _ = command(ctx, cmd) },
+ } {
+ if n := testing.AllocsPerRun(100, call); n != 0 {
+ t.Errorf("%s分配了 %v 次内存,期望 0 次", name, n)
+ }
+ }
+}
+
+type failedDownloads struct{ err error }
+
+func (d failedDownloads) Download(context.Context, string) (port.LinkDownload, func(), error) {
+ return port.LinkDownload{}, nil, d.err
+}
+
+type failedTransport struct{ err error }
+
+func (t failedTransport) RoundTrip(*http.Request) (*http.Response, error) { return nil, t.err }
+
+func TestSpanStatusDoesNotCarryErrorText(t *testing.T) {
+ tracer, spans := recordingTracer(t)
+ ctx, parent := tracer.Tracer("test").Start(t.Context(), "任务")
+ defer parent.End()
+ failure := errors.New("下载 https://music.example/audio?vkey=SECRET 失败")
+
+ store := porttest.NewMediaStore()
+ store.Fail(failure)
+ objects := traceObjects(store, tracer)
+ _, _ = objects.Exists(ctx, "link/a/audio.mp3")
+ _ = objects.Delete(ctx, "link/a/audio.mp3")
+ _, _, _ = traceDownloads(failedDownloads{failure}, tracer).Download(ctx, "https://example.com/a")
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://music.example/audio", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, _ = (&http.Client{Transport: traceHTTP(failedTransport{failure}, tracer)}).Do(req)
+
+ failed := 0
+ for _, span := range spans.Ended() {
+ if span.Status().Code != codes.Error {
+ continue
+ }
+ failed++
+ if description := span.Status().Description; description != "" {
+ t.Errorf("%s 的状态描述 = %q,期望不带错误的文本", span.Name(), description)
+ }
+ }
+ if failed != 4 {
+ t.Errorf("出错的 span 有 %d 个,期望 4 个", failed)
+ }
+}
diff --git a/internal/bootstrap/storage_test.go b/internal/bootstrap/storage_test.go
new file mode 100644
index 0000000..8ff120e
--- /dev/null
+++ b/internal/bootstrap/storage_test.go
@@ -0,0 +1,108 @@
+package bootstrap
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/testcontainers/testcontainers-go"
+ "github.com/testcontainers/testcontainers-go/wait"
+
+ "github.com/dihemoe/alisten/internal/adapter/out/storage"
+)
+
+const (
+ rustfsImage = "rustfs/rustfs:1.0.3"
+ rustfsAccessKey = "alisten-test"
+ rustfsSecretKey = "alisten-test-secret"
+)
+
+var rustfs struct {
+ once sync.Once
+ container *testcontainers.DockerContainer
+ endpoint string
+ err error
+ buckets int
+}
+
+func TestMain(m *testing.M) {
+ code := m.Run()
+ _ = testcontainers.TerminateContainer(rustfs.container)
+ _ = testcontainers.TerminateContainer(shared.postgres)
+ _ = testcontainers.TerminateContainer(shared.valkey)
+ os.Exit(code)
+}
+
+// 每次都是一个还不存在的 bucket,由迁移创建。
+func storageEnv(t *testing.T) storage.Config {
+ t.Helper()
+ rustfs.once.Do(func() {
+ ctx := context.Background()
+ rustfs.container, rustfs.err = testcontainers.Run(ctx, rustfsImage,
+ testcontainers.WithExposedPorts("9000/tcp"),
+ testcontainers.WithEnv(map[string]string{"RUSTFS_ACCESS_KEY": rustfsAccessKey, "RUSTFS_SECRET_KEY": rustfsSecretKey}),
+ testcontainers.WithWaitStrategy(wait.ForHTTP("/health/ready").WithPort("9000/tcp")),
+ )
+ if rustfs.err == nil {
+ rustfs.endpoint, rustfs.err = rustfs.container.PortEndpoint(ctx, "9000/tcp", "http")
+ }
+ })
+ if rustfs.err != nil {
+ t.Fatalf("启动 RustFS 容器:%v", rustfs.err)
+ }
+ rustfs.buckets++
+ cfg := storage.Config{
+ Endpoint: rustfs.endpoint,
+ Region: "us-east-1",
+ Bucket: fmt.Sprintf("test-%d", rustfs.buckets),
+ AccessKeyID: rustfsAccessKey,
+ SecretAccessKey: rustfsSecretKey,
+ }
+ t.Setenv("STORAGE_ENDPOINT_URL", cfg.Endpoint)
+ t.Setenv("STORAGE_REGION", cfg.Region)
+ t.Setenv("STORAGE_BUCKET_NAME", cfg.Bucket)
+ t.Setenv("STORAGE_ACCESS_KEY_ID", cfg.AccessKeyID)
+ t.Setenv("STORAGE_SECRET_ACCESS_KEY", cfg.SecretAccessKey)
+ return cfg
+}
+
+func TestLoadStorageConfig(t *testing.T) {
+ t.Setenv("STORAGE_ENDPOINT_URL", "http://rustfs:9000")
+ t.Setenv("STORAGE_ACCESS_KEY_ID", "alisten")
+ t.Setenv("STORAGE_SECRET_ACCESS_KEY", "secret")
+ unsetenv(t, "STORAGE_REGION")
+ unsetenv(t, "STORAGE_BUCKET_NAME")
+ cfg, err := loadStorageConfig()
+ want := storage.Config{Endpoint: "http://rustfs:9000", Region: "us-east-1", Bucket: "alisten", AccessKeyID: "alisten", SecretAccessKey: "secret"}
+ if err != nil || cfg != want {
+ t.Fatalf("loadStorageConfig() = %+v, %v,期望 %+v", cfg, err, want)
+ }
+
+ invalid := map[string]string{
+ "STORAGE_ENDPOINT_URL": "https://key:secret@s3.example",
+ "STORAGE_REGION": "",
+ "STORAGE_BUCKET_NAME": `a"b`,
+ "STORAGE_SECRET_ACCESS_KEY": "",
+ }
+ for name, value := range invalid {
+ t.Run(name, func(t *testing.T) {
+ t.Setenv(name, value)
+ _, err := loadStorageConfig()
+ if err == nil || !strings.Contains(err.Error(), name) {
+ t.Fatalf("%s=%q 时应报错并写明变量名,实际为 %v", name, value, err)
+ }
+ if strings.Contains(err.Error(), "secret") {
+ t.Errorf("错误 %q 中不应出现凭证", err)
+ }
+ })
+ }
+ for _, bucket := range []string{"ab", "Alisten", "-alisten", "alisten-", "a/b", strings.Repeat("a", 64)} {
+ t.Setenv("STORAGE_BUCKET_NAME", bucket)
+ if _, err := loadStorageConfig(); err == nil {
+ t.Errorf("STORAGE_BUCKET_NAME=%q 时应报错", bucket)
+ }
+ }
+}
diff --git a/internal/bootstrap/testdata/http/admin-disabled.txtar b/internal/bootstrap/testdata/http/admin-disabled.txtar
new file mode 100644
index 0000000..8b70877
--- /dev/null
+++ b/internal/bootstrap/testdata/http/admin-disabled.txtar
@@ -0,0 +1,98 @@
+没有配置管理 token 时,管理接口一律返回 FORBIDDEN,不看凭证和请求体。
+
+-- env --
+ALISTEN_ADMIN_TOKEN=
+
+-- request --
+GET /api/admin/rooms
+Authorization: Bearer admin-secret
+-- response --
+403
+{"error": {"code": "FORBIDDEN", "message": "后台没有启用:服务端没有配置管理 token。"}}
+
+-- request --
+POST /api/admin/rooms
+
+不是 JSON
+-- response --
+403
+{"error": {"code": "FORBIDDEN", "message": "后台没有启用:服务端没有配置管理 token。"}}
+
+-- request --
+GET /api/admin/rooms/01a15778-1911-7a28-8b2e-e08c2a25f388/api-key
+Authorization: Bearer admin-secret
+-- response --
+403
+{"error": {"code": "FORBIDDEN", "message": "后台没有启用:服务端没有配置管理 token。"}}
+
+-- request --
+POST /api/admin/rooms/01a15778-1911-7a28-8b2e-e08c2a25f388/api-key
+Authorization: Bearer admin-secret
+
+不是 JSON
+-- response --
+403
+{"error": {"code": "FORBIDDEN", "message": "后台没有启用:服务端没有配置管理 token。"}}
+
+-- request --
+GET /api/admin/credentials/netease
+-- response --
+403
+{"error": {"code": "FORBIDDEN", "message": "后台没有启用:服务端没有配置管理 token。"}}
+
+-- request --
+GET /api/admin/credentials/qq
+Authorization: Bearer admin-secret
+-- response --
+403
+{"error": {"code": "FORBIDDEN", "message": "后台没有启用:服务端没有配置管理 token。"}}
+
+-- request --
+POST /api/admin/credentials/netease/qr-logins
+Authorization: Bearer admin-secret
+
+{}
+-- response --
+403
+{"error": {"code": "FORBIDDEN", "message": "后台没有启用:服务端没有配置管理 token。"}}
+
+-- request --
+POST /api/admin/credentials/netease/qr-logins/status
+Authorization: Bearer admin-secret
+
+不是 JSON
+-- response --
+403
+{"error": {"code": "FORBIDDEN", "message": "后台没有启用:服务端没有配置管理 token。"}}
+
+-- request --
+POST /api/admin/credentials/qq/qr-logins
+Authorization: Bearer
+
+{"type": "qq"}
+-- response --
+403
+{"error": {"code": "FORBIDDEN", "message": "后台没有启用:服务端没有配置管理 token。"}}
+
+-- request --
+POST /api/admin/credentials/qq/qr-logins/status
+Authorization: Bearer admin-secret
+
+{"type": "wx", "key": "wx-1"}
+-- response --
+403
+{"error": {"code": "FORBIDDEN", "message": "后台没有启用:服务端没有配置管理 token。"}}
+
+-- request --
+GET /api/admin/media
+Authorization: Bearer admin-secret
+-- response --
+403
+{"error": {"code": "FORBIDDEN", "message": "后台没有启用:服务端没有配置管理 token。"}}
+
+-- request --
+DELETE /api/admin/media/66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391
+Authorization: Bearer admin-secret
+-- response --
+403
+{"error": {"code": "FORBIDDEN", "message": "后台没有启用:服务端没有配置管理 token。"}}
diff --git a/internal/bootstrap/testdata/http/admin.txtar b/internal/bootstrap/testdata/http/admin.txtar
new file mode 100644
index 0000000..c311299
--- /dev/null
+++ b/internal/bootstrap/testdata/http/admin.txtar
@@ -0,0 +1,350 @@
+后台:常驻房间的列表和新建、按别名查找房间、音源账号的登录状态和扫码登录(包括 B 站)、YouTube cookies 的填写、清除和状态(不返回内容,空的 cookies 不能填写)、已存储的媒体。
+
+-- request --
+GET /api/admin/rooms
+-- response --
+401
+{"error": {"code": "UNAUTHORIZED", "message": "管理 token 无效"}}
+
+-- request --
+GET /api/admin/rooms
+Authorization: Bearer wrong-token
+-- response --
+401
+{"error": {"code": "UNAUTHORIZED", "message": "管理 token 无效"}}
+
+-- request --
+GET /api/admin/rooms
+Authorization: Bearer admin-secret
+-- response --
+200
+{"items": []}
+
+-- request --
+POST /api/admin/rooms
+Authorization: Bearer admin-secret
+
+{"name": "大厅", "description": "常驻", "slug": "hall", "password": "芝麻开门"}
+-- response --
+201
+Location: /api/rooms/$hall
+{"id": "$hall"}
+
+-- request --
+POST /api/admin/rooms
+Authorization: Bearer admin-secret
+
+{"name": "二厅", "description": ""}
+-- response --
+201
+Location: /api/rooms/$second
+{"id": "$second"}
+
+-- request --
+POST /api/admin/rooms
+Authorization: Bearer admin-secret
+
+{"name": "三厅", "description": "", "slug": "hall"}
+-- response --
+409
+{"error": {"code": "CONFLICT", "message": "别名已被占用"}}
+
+-- request --
+POST /api/admin/rooms
+Authorization: Bearer admin-secret
+
+{"name": "三厅", "description": "", "slug": "Hall"}
+-- response --
+400
+{"error": {"code": "INVALID_ARGUMENT", "message": "slug:格式不正确"}}
+
+-- request --
+POST /api/rooms
+
+{"name": "临时", "description": ""}
+-- response --
+201
+Location: /api/rooms/$open
+{"id": "$open"}
+
+-- request --
+GET /api/admin/rooms
+Authorization: Bearer admin-secret
+-- response --
+200
+{"items": [
+ {"id": "$hall", "slug": "hall", "name": "大厅", "description": "常驻", "kind": "persistent", "hasPassword": true, "onlineCount": 0},
+ {"id": "$second", "slug": null, "name": "二厅", "description": "", "kind": "persistent", "hasPassword": false, "onlineCount": 0}
+]}
+
+-- request --
+GET /api/rooms
+-- response --
+200
+{"items": [
+ {"id": "$hall", "slug": "hall", "name": "大厅", "description": "常驻", "kind": "persistent", "hasPassword": true, "onlineCount": 0},
+ {"id": "$second", "slug": null, "name": "二厅", "description": "", "kind": "persistent", "hasPassword": false, "onlineCount": 0},
+ {"id": "$open", "slug": null, "name": "临时", "description": "", "kind": "ephemeral", "hasPassword": false, "onlineCount": 0}
+]}
+
+-- request --
+GET /api/rooms/hall
+-- response --
+200
+{"id": "$hall", "slug": "hall", "name": "大厅", "description": "常驻", "kind": "persistent", "hasPassword": true, "onlineCount": 0}
+
+-- request --
+GET /api/rooms/HALL
+-- response --
+404
+{"error": {"code": "NOT_FOUND", "message": "房间不存在"}}
+
+-- request --
+POST /api/members
+
+{}
+-- response --
+201
+{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$guestName", "avatarHash": ""}}
+
+-- request --
+POST /api/rooms/hall/tickets
+Authorization: Bearer $alice
+
+{"password": "芝麻开门"}
+-- response --
+404
+{"error": {"code": "NOT_FOUND", "message": "房间不存在"}}
+
+-- request --
+POST /api/rooms/$hall/tickets
+Authorization: Bearer $alice
+
+{"password": "芝麻开门"}
+-- response --
+201
+{"ticket": "$ticket"}
+
+-- request --
+GET /api/admin/credentials/netease
+Authorization: Bearer admin-secret
+-- response --
+200
+{"set": false, "account": null}
+
+-- request --
+GET /api/admin/credentials/qq
+Authorization: Bearer admin-secret
+-- response --
+200
+{"set": false, "account": null}
+
+-- request --
+PUT /api/admin/credentials/netease
+Authorization: Bearer admin-secret
+
+{}
+-- response --
+404
+{"error": {"code": "NOT_FOUND", "message": "接口不存在"}}
+
+-- request --
+POST /api/admin/credentials/netease/qr-logins
+Authorization: Bearer admin-secret
+
+{}
+-- response --
+201
+{"key": "$neteaseKey", "qrImage": "$neteaseImage", "expiresAtMs": 1}
+
+-- request --
+POST /api/admin/credentials/netease/qr-logins/status
+Authorization: Bearer admin-secret
+
+{"key": "$neteaseKey"}
+-- response --
+200
+{"status": "waiting", "message": ""}
+
+-- request --
+POST /api/admin/credentials/netease/qr-logins/status
+Authorization: Bearer admin-secret
+
+{"key": ""}
+-- response --
+400
+{"error": {"code": "INVALID_ARGUMENT", "message": "key:长度不能少于 1 个字符"}}
+
+-- request --
+POST /api/admin/credentials/qq/qr-logins
+Authorization: Bearer admin-secret
+
+{"type": "qq"}
+-- response --
+201
+{"key": "$qqKey", "qrImage": "data:image/png;base64,iVBORw0KGgo=", "expiresAtMs": 1}
+
+-- request --
+POST /api/admin/credentials/qq/qr-logins
+Authorization: Bearer admin-secret
+
+{"type": "wx"}
+-- response --
+201
+{"key": "$wxKey", "qrImage": "https://wx.example/qr.png", "expiresAtMs": 1}
+
+-- request --
+POST /api/admin/credentials/qq/qr-logins
+Authorization: Bearer admin-secret
+
+{"type": "wechat"}
+-- response --
+400
+{"error": {"code": "INVALID_ARGUMENT", "message": "type:取值应为 qq、wx 之一"}}
+
+-- request --
+POST /api/admin/credentials/qq/qr-logins/status
+Authorization: Bearer admin-secret
+
+{"type": "wx", "key": "$wxKey"}
+-- response --
+200
+{"status": "waiting", "message": ""}
+
+-- request --
+POST /api/admin/credentials/qq/qr-logins/status
+Authorization: Bearer admin-secret
+
+{"type": "qq", "key": "$wxKey"}
+-- response --
+200
+{"status": "expired", "message": ""}
+
+-- request --
+POST /api/admin/credentials/qq/qr-logins
+Authorization: Bearer $alice
+
+{"type": "qq"}
+-- response --
+401
+{"error": {"code": "UNAUTHORIZED", "message": "管理 token 无效"}}
+
+-- request --
+GET /api/admin/credentials/bilibili
+Authorization: Bearer admin-secret
+-- response --
+200
+{"set": false, "account": null}
+
+-- request --
+POST /api/admin/credentials/bilibili/qr-logins
+Authorization: Bearer admin-secret
+
+{}
+-- response --
+201
+{"key": "$bilibiliKey", "qrImage": "$bilibiliImage", "expiresAtMs": 1}
+
+-- request --
+POST /api/admin/credentials/bilibili/qr-logins/status
+Authorization: Bearer admin-secret
+
+{"key": "$bilibiliKey"}
+-- response --
+200
+{"status": "waiting", "message": ""}
+
+-- request --
+GET /api/admin/credentials/youtube
+Authorization: Bearer admin-secret
+-- response --
+200
+{"set": false, "updatedAtMs": 0}
+
+-- request --
+PUT /api/admin/credentials/youtube
+Authorization: Bearer admin-secret
+
+{"cookies": "session=fictional"}
+-- response --
+204
+
+-- request --
+GET /api/admin/credentials/youtube
+Authorization: Bearer admin-secret
+-- response --
+200
+{"set": true, "updatedAtMs": 1}
+
+-- request --
+PUT /api/admin/credentials/youtube
+Authorization: Bearer admin-secret
+
+{"cookies": "fictional"}
+-- response --
+400
+{"error": {"code": "INVALID_ARGUMENT", "message": "不是能识别的 cookies:请粘贴扩展导出的 cookies.txt,或开发者工具里请求头 cookie 的值。"}}
+
+-- request --
+GET /api/admin/credentials/youtube
+Authorization: Bearer admin-secret
+-- response --
+200
+{"set": true, "updatedAtMs": 1}
+
+-- request --
+PUT /api/admin/credentials/youtube
+Authorization: Bearer admin-secret
+
+{"cookies": ""}
+-- response --
+400
+{"error": {"code": "INVALID_ARGUMENT", "message": "cookies:长度不能少于 1 个字符"}}
+
+-- request --
+GET /api/admin/credentials/youtube
+Authorization: Bearer admin-secret
+-- response --
+200
+{"set": true, "updatedAtMs": 1}
+
+-- request --
+DELETE /api/admin/credentials/youtube
+Authorization: Bearer admin-secret
+-- response --
+204
+
+-- request --
+GET /api/admin/credentials/youtube
+Authorization: Bearer admin-secret
+-- response --
+200
+{"set": false, "updatedAtMs": 0}
+
+-- request --
+GET /api/admin/media
+Authorization: Bearer admin-secret
+-- response --
+200
+{"items": []}
+
+-- request --
+DELETE /api/admin/media/66cf7fbb71ea804452be64db6a2002116ba136db17c97dbb7ed4a74c8d052391
+Authorization: Bearer admin-secret
+-- response --
+404
+{"error": {"code": "NOT_FOUND", "message": "媒体不存在"}}
+
+-- request --
+DELETE /api/admin/media/not-a-key
+Authorization: Bearer admin-secret
+-- response --
+404
+{"error": {"code": "NOT_FOUND", "message": "媒体不存在"}}
+
+-- request --
+GET /api/admin/media
+Authorization: Bearer $alice
+-- response --
+401
+{"error": {"code": "UNAUTHORIZED", "message": "管理 token 无效"}}
diff --git a/internal/bootstrap/testdata/http/events.txtar b/internal/bootstrap/testdata/http/events.txtar
new file mode 100644
index 0000000..b9c60d1
--- /dev/null
+++ b/internal/bootstrap/testdata/http/events.txtar
@@ -0,0 +1,136 @@
+房间的事件流:bot 用常驻房间的 API Key 打开事件流,先收到快照,点歌之后收到与 WebSocket 相同的房间事件;事件流不加入房间,
+快照中的在线列表一直为空。管理员重置 Key 之后,bot 的事件流收到 4401 的 close 并结束;成员用票据打开的事件流不受影响。
+房间的 5 条事件流都用了这张票据之后,再用它打开时顶替最早的一条:被顶替的事件流收到 4409 的 close 并结束。
+
+-- request --
+POST /api/members
+
+{}
+-- response --
+201
+{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}}
+
+-- request --
+POST /api/admin/rooms
+Authorization: Bearer admin-secret
+
+{"name": "大厅", "description": ""}
+-- response --
+201
+Location: /api/rooms/$hall
+{"id": "$hall"}
+
+-- request --
+POST /api/rooms/$hall/tickets
+Authorization: Bearer $alice
+
+{}
+-- response --
+201
+{"ticket": "$ticket"}
+
+-- request --
+POST /api/admin/rooms/$hall/api-key
+Authorization: Bearer admin-secret
+
+{}
+-- response --
+201
+Location: /api/admin/rooms/$hall/api-key
+{"key": "$key"}
+
+-- stream bot --
+Authorization: Bearer $key
+-- event bot --
+event: snapshot
+data: {"room": {"term": 1, "seq": 0, "room": {"id": "$hall", "name": "大厅", "description": "", "kind": "persistent"},
+ "playMode": "sequential", "nowPlaying": null, "pending": null, "items": [],
+ "members": {"sessions": [], "onlineCount": 0}}}
+
+-- request --
+POST /api/room/queue
+Authorization: Bearer $key
+
+{"source": "netease", "keyword": "晴天", "requester": "张三"}
+-- response --
+201
+Location: /api/room/queue/1
+{"entry": {"id": "1", "track": {"source": "netease", "id": "186016"},
+ "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000},
+ "by": {"id": "$botId", "name": "张三(bot)", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"}}
+-- event bot --
+event: room.activity
+id: 1
+data: {"kind": "event", "type": "room.activity", "term": 1, "seq": 1, "data": {"kind": "added", "member": {"id": "$botId", "name": "张三(bot)", "avatarHash": ""},
+ "entry": {"id": "1", "track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000},
+ "by": {"id": "$botId", "name": "张三(bot)", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"},
+ "message": "点了《晴天》"}}
+-- event bot --
+event: queue.updated
+id: 2
+data: {"kind": "event", "type": "queue.updated", "term": 1, "seq": 2, "data": {"items": [],
+ "pending": {"id": "1", "track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000},
+ "by": {"id": "$botId", "name": "张三(bot)", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"}}}
+-- event bot --
+event: queue.updated
+id: 3
+data: {"kind": "event", "type": "queue.updated", "term": 1, "seq": 3, "data": {"pending": null, "items": []}}
+-- event bot --
+event: playback.updated
+id: 4
+data: {"kind": "event", "type": "playback.updated", "term": 1, "seq": 4, "data": {"nowPlaying": {
+ "entry": {"id": "1", "track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269500},
+ "by": {"id": "$botId", "name": "张三(bot)", "avatarHash": ""}, "auto": false, "likes": 0, "status": "ready"},
+ "mediaUrl": "/media/netease/186016", "startedAtMs": 1, "durationMs": 269500, "gain": 1, "lyric": "[00:01.00]故事的小黄花"}}}
+
+-- stream alice --
+Authorization: Bearer $ticket
+-- event alice --
+event: snapshot
+data: {"room": {"term": 1, "seq": 4, "room": {"id": "$hall", "name": "大厅", "description": "", "kind": "persistent"},
+ "playMode": "sequential",
+ "nowPlaying": {
+ "entry": {"id": "1", "track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269500},
+ "by": {"id": "$botId", "name": "张三(bot)", "avatarHash": ""}, "auto": false, "likes": 0, "status": "ready"},
+ "mediaUrl": "/media/netease/186016", "startedAtMs": 1, "durationMs": 269500, "gain": 1, "lyric": "[00:01.00]故事的小黄花"},
+ "pending": null, "items": [],
+ "members": {"sessions": [], "onlineCount": 0}}}
+
+-- request --
+POST /api/admin/rooms/$hall/api-key
+Authorization: Bearer admin-secret
+
+{}
+-- response --
+201
+Location: /api/admin/rooms/$hall/api-key
+{"key": "$resetKey"}
+-- event bot --
+event: close
+data: {"code": 4401, "message": "凭证已失效:房间设置或修改了密码,或者 API Key 已重置"}
+
+-- request --
+POST /api/room/messages
+Authorization: Bearer $ticket
+
+{"content": "还在"}
+-- response --
+204
+-- event alice --
+event: chat.message
+id: 5
+data: {"kind": "event", "type": "chat.message", "term": 1, "seq": 5, "data": {"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "content": "还在", "sentAtMs": 1}}
+
+-- stream alice2 --
+Authorization: Bearer $ticket
+-- stream alice3 --
+Authorization: Bearer $ticket
+-- stream alice4 --
+Authorization: Bearer $ticket
+-- stream alice5 --
+Authorization: Bearer $ticket
+-- stream alice6 --
+Authorization: Bearer $ticket
+-- event alice --
+event: close
+data: {"code": 4409, "message": "同一凭证打开了新的事件流"}
diff --git a/internal/bootstrap/testdata/http/origin.txtar b/internal/bootstrap/testdata/http/origin.txtar
new file mode 100644
index 0000000..807bcd5
--- /dev/null
+++ b/internal/bootstrap/testdata/http/origin.txtar
@@ -0,0 +1,57 @@
+带 Origin 的请求只接受显式允许列表中的来源;不带 Origin 的请求不检查。
+
+-- request --
+POST /api/rooms
+Origin: https://evil.example
+
+{"name": "钓鱼", "description": ""}
+-- response --
+403
+{"error": {"code": "FORBIDDEN", "message": "服务器不接受从这个网址发出的操作,请用网站的正式地址打开。"}}
+
+-- request --
+GET /api/rooms
+Origin: http://alisten.example
+-- response --
+403
+{"error": {"code": "FORBIDDEN", "message": "服务器不接受从这个网址发出的操作,请用网站的正式地址打开。"}}
+
+-- request --
+POST /api/rooms
+Origin: https://alisten.example
+
+{"name": "同源", "description": ""}
+-- response --
+201
+Location: /api/rooms/$sameOrigin
+{"id": "$sameOrigin"}
+
+-- request --
+POST /api/rooms
+Origin: http://localhost:9876
+
+{"name": "本机", "description": ""}
+-- response --
+201
+Location: /api/rooms/$local
+{"id": "$local"}
+
+-- request --
+POST /api/rooms
+
+{"name": "bot", "description": ""}
+-- response --
+201
+Location: /api/rooms/$bot
+{"id": "$bot"}
+
+-- request --
+GET /api/rooms
+Origin: https://alisten.example
+-- response --
+200
+{"items": [
+ {"id": "$sameOrigin", "slug": null, "name": "同源", "description": "", "kind": "ephemeral", "hasPassword": false, "onlineCount": 0},
+ {"id": "$local", "slug": null, "name": "本机", "description": "", "kind": "ephemeral", "hasPassword": false, "onlineCount": 0},
+ {"id": "$bot", "slug": null, "name": "bot", "description": "", "kind": "ephemeral", "hasPassword": false, "onlineCount": 0}
+]}
diff --git a/internal/bootstrap/testdata/http/room-requests.txtar b/internal/bootstrap/testdata/http/room-requests.txtar
new file mode 100644
index 0000000..cbf7040
--- /dev/null
+++ b/internal/bootstrap/testdata/http/room-requests.txtar
@@ -0,0 +1,258 @@
+bot 用进房票据调用 /api/room 下的接口,响应与 WebSocket 上的相同:读操作用 GET,参数放在路径和查询串中
+(keyword=%E5%91%A8%E6%9D%B0%E4%BC%A6 是“周杰伦”);点歌返回 201 和新条目的地址,删歌、切换播放模式和聊天返回 204,
+没有字段的请求可以不带请求体。bot 没有会话,不出现在在线列表中;它用事件流等第一首歌开播,再投票切歌。
+
+-- request --
+POST /api/members
+
+{}
+-- response --
+201
+{"memberToken": "$bot", "member": {"id": "$botId", "name": "$botName", "avatarHash": ""}}
+
+-- request --
+POST /api/rooms
+
+{"name": "临时", "description": ""}
+-- response --
+201
+Location: /api/rooms/$room
+{"id": "$room"}
+
+-- request --
+POST /api/rooms/$room/tickets
+Authorization: Bearer $bot
+
+{}
+-- response --
+201
+{"ticket": "$ticket"}
+
+-- request --
+GET /api/room
+Authorization: Bearer $ticket
+-- response --
+200
+{"room": {"term": 1, "seq": 0, "room": {"id": "$room", "name": "临时", "description": "", "kind": "ephemeral"},
+ "playMode": "sequential", "nowPlaying": null, "pending": null, "items": [],
+ "members": {"sessions": [], "onlineCount": 0}}}
+
+-- request --
+GET /api/room?verbose=1
+Authorization: Bearer $ticket
+-- response --
+400
+{"error": {"code": "INVALID_ARGUMENT", "message": "verbose:未知参数"}}
+
+-- request --
+POST /api/room/skip-votes
+Authorization: Bearer $ticket
+-- response --
+409
+{"error": {"code": "NOTHING_PLAYING", "message": "现在没有在放的歌。"}}
+
+-- stream bot --
+Authorization: Bearer $ticket
+-- event bot --
+event: snapshot
+data: {"room": {"term": 1, "seq": 0, "room": {"id": "$room", "name": "临时", "description": "", "kind": "ephemeral"},
+ "playMode": "sequential", "nowPlaying": null, "pending": null, "items": [],
+ "members": {"sessions": [], "onlineCount": 0}}}
+
+-- request --
+POST /api/room/messages
+Authorization: Bearer $ticket
+
+{"content": "机器人报到"}
+-- response --
+204
+-- event bot --
+event: chat.message
+id: 1
+data: {"kind": "event", "type": "chat.message", "term": 1, "seq": 1, "data": {"member": {"id": "$botId", "name": "$botName", "avatarHash": ""}, "content": "机器人报到", "sentAtMs": 1}}
+
+-- request --
+PATCH /api/room/settings
+Authorization: Bearer $ticket
+
+{"playMode": "shuffle"}
+-- response --
+400
+{"error": {"code": "INVALID_ARGUMENT", "message": "playMode:取值应为 sequential、random、rotation 之一"}}
+
+-- request --
+PATCH /api/room/settings
+Authorization: Bearer $ticket
+
+{"playMode": "rotation"}
+-- response --
+204
+-- event bot --
+event: settings.updated
+id: 2
+data: {"kind": "event", "type": "settings.updated", "term": 1, "seq": 2, "data": {"playMode": "rotation"}}
+
+-- request --
+POST /api/room/queue
+Authorization: Bearer $ticket
+
+{"track": {"source": "netease", "id": "186016"}}
+-- response --
+201
+Location: /api/room/queue/1
+{"entry": {"id": "1", "track": {"source": "netease", "id": "186016"},
+ "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000},
+ "by": {"id": "$botId", "name": "$botName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"}}
+-- event bot --
+event: room.activity
+id: 3
+data: {"kind": "event", "type": "room.activity", "term": 1, "seq": 3, "data": {"kind": "added", "member": {"id": "$botId", "name": "$botName", "avatarHash": ""},
+ "entry": {"id": "1", "track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000},
+ "by": {"id": "$botId", "name": "$botName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"},
+ "message": "点了《晴天》"}}
+-- event bot --
+event: queue.updated
+id: 4
+data: {"kind": "event", "type": "queue.updated", "term": 1, "seq": 4, "data": {"items": [],
+ "pending": {"id": "1", "track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000},
+ "by": {"id": "$botId", "name": "$botName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"}}}
+-- event bot --
+event: queue.updated
+id: 5
+data: {"kind": "event", "type": "queue.updated", "term": 1, "seq": 5, "data": {"pending": null, "items": []}}
+-- event bot --
+event: playback.updated
+id: 6
+data: {"kind": "event", "type": "playback.updated", "term": 1, "seq": 6, "data": {"nowPlaying": {
+ "entry": {"id": "1", "track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269500},
+ "by": {"id": "$botId", "name": "$botName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "ready"},
+ "mediaUrl": "/media/netease/186016", "startedAtMs": 1, "durationMs": 269500, "gain": 1, "lyric": "[00:01.00]故事的小黄花"}}}
+
+-- request --
+POST /api/room/queue
+Authorization: Bearer $ticket
+
+{"track": {"source": "netease", "id": "185811"}}
+-- response --
+201
+Location: /api/room/queue/2
+{"entry": {"id": "2", "track": {"source": "netease", "id": "185811"},
+ "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000},
+ "by": {"id": "$botId", "name": "$botName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"}}
+
+-- request --
+POST /api/room/queue/2/likes
+Authorization: Bearer $ticket
+-- response --
+200
+{"likes": 1}
+
+-- request --
+POST /api/room/queue/2/likes
+Authorization: Bearer $ticket
+
+{"entryId": "2"}
+-- response --
+400
+{"error": {"code": "INVALID_ARGUMENT", "message": "entryId:未知字段"}}
+
+-- request --
+POST /api/room/queue/99/likes
+Authorization: Bearer $ticket
+
+{}
+-- response --
+404
+{"error": {"code": "NOT_FOUND", "message": "这首歌已经不在播放列表里了。"}}
+
+-- request --
+DELETE /api/room/queue/2
+Authorization: Bearer $ticket
+-- response --
+204
+
+-- request --
+DELETE /api/room/queue/99
+Authorization: Bearer $ticket
+-- response --
+404
+{"error": {"code": "NOT_FOUND", "message": "这首歌已经不在播放列表里了。"}}
+
+-- request --
+POST /api/room/skip-votes
+Authorization: Bearer $ticket
+
+{}
+-- response --
+200
+{"votes": 1, "required": 1, "skipped": true}
+
+-- request --
+GET /api/room/search/tracks?source=netease&keyword=%E5%91%A8%E6%9D%B0%E4%BC%A6&page=1&pageSize=1
+Authorization: Bearer $ticket
+-- response --
+200
+{"total": 2, "items": [
+ {"track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000}}]}
+
+-- request --
+GET /api/room/search/tracks?source=netease&keyword=%E5%91%A8%E6%9D%B0%E4%BC%A6&page=one&pageSize=1
+Authorization: Bearer $ticket
+-- response --
+400
+{"error": {"code": "INVALID_ARGUMENT", "message": "page:应为整数"}}
+
+-- request --
+GET /api/room/search/tracks?source=netease&keyword=%E5%91%A8%E6%9D%B0%E4%BC%A6&page=1&pageSize=1&sort=hot
+Authorization: Bearer $ticket
+-- response --
+400
+{"error": {"code": "INVALID_ARGUMENT", "message": "sort:未知参数"}}
+
+-- request --
+GET /api/room/search/playlists?source=netease&keyword=%E5%91%A8%E6%9D%B0%E4%BC%A6&page=1&pageSize=10
+Authorization: Bearer $ticket
+-- response --
+200
+{"total": 1, "items": [{"source": "netease", "id": "3778678", "name": "周杰伦精选", "coverUrl": "https://p1.music.126.net/jay.jpg",
+ "creator": "alice", "description": "", "trackCount": 2, "playCount": 100}]}
+
+-- request --
+GET /api/room/playlists/netease/3778678/tracks
+Authorization: Bearer $ticket
+-- response --
+200
+{"total": 2, "items": [
+ {"track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000}},
+ {"track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000}}]}
+
+-- request --
+POST /api/room/hello
+Authorization: Bearer $ticket
+
+{}
+-- response --
+404
+{"error": {"code": "NOT_FOUND", "message": "接口不存在"}}
+
+-- request --
+POST /api/room/queue
+Authorization: Bearer $ticket
+
+{"keyword": "晴天"}
+-- response --
+400
+{"error": {"code": "INVALID_ARGUMENT", "message": "只能提供以下字段组合之一:track;source、keyword"}}
+
+-- request --
+GET /api/room
+-- response --
+401
+{"error": {"code": "UNAUTHORIZED", "message": "票据无效"}}
+
+-- request --
+GET /api/room
+Authorization: Bearer $bot
+-- response --
+401
+{"error": {"code": "UNAUTHORIZED", "message": "票据无效"}}
diff --git a/internal/bootstrap/testdata/http/rooms.txtar b/internal/bootstrap/testdata/http/rooms.txtar
new file mode 100644
index 0000000..22bd67b
--- /dev/null
+++ b/internal/bootstrap/testdata/http/rooms.txtar
@@ -0,0 +1,88 @@
+大厅:房间列表、按 UUID 查找房间、创建临时房间。
+
+-- request --
+GET /api/rooms
+-- response --
+200
+{"items": []}
+
+-- request --
+POST /api/rooms
+
+{"name": "临时", "description": "随便听听"}
+-- response --
+201
+Location: /api/rooms/$open
+{"id": "$open"}
+
+-- request --
+POST /api/rooms
+
+{"name": "上锁", "description": "", "password": "芝麻开门"}
+-- response --
+201
+Location: /api/rooms/$locked
+{"id": "$locked"}
+
+-- request --
+GET /api/rooms
+-- response --
+200
+{"items": [
+ {"id": "$open", "slug": null, "name": "临时", "description": "随便听听", "kind": "ephemeral", "hasPassword": false, "onlineCount": 0},
+ {"id": "$locked", "slug": null, "name": "上锁", "description": "", "kind": "ephemeral", "hasPassword": true, "onlineCount": 0}
+]}
+
+-- request --
+GET /api/rooms/$locked
+-- response --
+200
+{"id": "$locked", "slug": null, "name": "上锁", "description": "", "kind": "ephemeral", "hasPassword": true, "onlineCount": 0}
+
+-- request --
+GET /api/rooms/01a15778-1911-7a28-8b2e-e08c2a25f388
+-- response --
+404
+{"error": {"code": "NOT_FOUND", "message": "房间不存在"}}
+
+-- request --
+GET /api/rooms/no-such-room
+-- response --
+404
+{"error": {"code": "NOT_FOUND", "message": "房间不存在"}}
+
+-- request --
+GET /api/rooms/%00
+-- response --
+404
+{"error": {"code": "NOT_FOUND", "message": "房间不存在"}}
+
+-- request --
+GET /api/rooms/hall%00
+-- response --
+404
+{"error": {"code": "NOT_FOUND", "message": "房间不存在"}}
+
+-- request --
+POST /api/rooms
+
+{"name": "", "description": ""}
+-- response --
+400
+{"error": {"code": "INVALID_ARGUMENT", "message": "name:长度不能少于 1 个字符"}}
+
+-- request --
+POST /api/rooms
+
+{"name": "临时", "description": "\u0000"}
+-- response --
+400
+{"error": {"code": "INVALID_ARGUMENT", "message": "description:不能包含 U+0000"}}
+
+-- request --
+POST /api/rooms
+
+{"name": "临时", "description": "", "slug": "lobby"}
+-- response --
+400
+{"error": {"code": "INVALID_ARGUMENT", "message": "slug:未知字段"}}
diff --git a/internal/bootstrap/testdata/http/search-quota.txtar b/internal/bootstrap/testdata/http/search-quota.txtar
new file mode 100644
index 0000000..1d6c940
--- /dev/null
+++ b/internal/bootstrap/testdata/http/search-quota.txtar
@@ -0,0 +1,116 @@
+临时房间的搜索与推荐列表共用一个额度:突发 10 次,之后每分钟恢复 1 次。额度用完、预计要等 30 秒以上时直接返回 RATE_LIMITED。
+
+-- request --
+POST /api/members
+
+{}
+-- response --
+201
+{"memberToken": "$bot", "member": {"id": "$botId", "name": "$botName", "avatarHash": ""}}
+
+-- request --
+POST /api/rooms
+
+{"name": "临时", "description": ""}
+-- response --
+201
+Location: /api/rooms/$room
+{"id": "$room"}
+
+-- request --
+POST /api/rooms/$room/tickets
+Authorization: Bearer $bot
+
+{}
+-- response --
+201
+{"ticket": "$ticket"}
+
+-- request --
+GET /api/room/search/playlists?source=netease&keyword=no-such-playlist&page=1&pageSize=10
+Authorization: Bearer $ticket
+-- response --
+200
+{"items": [], "total": 0}
+
+-- request --
+GET /api/room/search/playlists?source=netease&keyword=no-such-playlist&page=1&pageSize=10
+Authorization: Bearer $ticket
+-- response --
+200
+{"items": [], "total": 0}
+
+-- request --
+GET /api/room/search/playlists?source=netease&keyword=no-such-playlist&page=1&pageSize=10
+Authorization: Bearer $ticket
+-- response --
+200
+{"items": [], "total": 0}
+
+-- request --
+GET /api/room/search/playlists?source=netease&keyword=no-such-playlist&page=1&pageSize=10
+Authorization: Bearer $ticket
+-- response --
+200
+{"items": [], "total": 0}
+
+-- request --
+GET /api/room/search/playlists?source=netease&keyword=no-such-playlist&page=1&pageSize=10
+Authorization: Bearer $ticket
+-- response --
+200
+{"items": [], "total": 0}
+
+-- request --
+GET /api/room/search/playlists?source=netease&keyword=no-such-playlist&page=1&pageSize=10
+Authorization: Bearer $ticket
+-- response --
+200
+{"items": [], "total": 0}
+
+-- request --
+GET /api/room/search/playlists?source=netease&keyword=no-such-playlist&page=1&pageSize=10
+Authorization: Bearer $ticket
+-- response --
+200
+{"items": [], "total": 0}
+
+-- request --
+GET /api/room/search/playlists?source=netease&keyword=no-such-playlist&page=1&pageSize=10
+Authorization: Bearer $ticket
+-- response --
+200
+{"items": [], "total": 0}
+
+-- request --
+GET /api/room/search/playlists?source=netease&keyword=no-such-playlist&page=1&pageSize=10
+Authorization: Bearer $ticket
+-- response --
+200
+{"items": [], "total": 0}
+
+-- request --
+GET /api/room/recommendations
+Authorization: Bearer $ticket
+-- response --
+200
+{"items": []}
+
+-- request --
+GET /api/room/search/playlists?source=netease&keyword=no-such-playlist&page=1&pageSize=10
+Authorization: Bearer $ticket
+-- response --
+429
+{"error": {"code": "RATE_LIMITED", "message": "搜索过于频繁,请稍后再试。"}}
+
+-- request --
+POST /api/room/queue
+Authorization: Bearer $ticket
+
+{"source": "netease", "keyword": "晴天"}
+-- response --
+201
+Location: /api/room/queue/1
+{"entry": {"id": "1", "track": {"source": "netease", "id": "186016"},
+ "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000},
+ "by": {"id": "$botId", "name": "$botName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"}}
diff --git a/internal/bootstrap/testdata/http/session-errors.txtar b/internal/bootstrap/testdata/http/session-errors.txtar
new file mode 100644
index 0000000..542354b
--- /dev/null
+++ b/internal/bootstrap/testdata/http/session-errors.txtar
@@ -0,0 +1,73 @@
+成员 token 无效时返回 UNAUTHORIZED;请求体不合法时返回 INVALID_ARGUMENT。
+
+-- request --
+POST /api/members
+
+{"name": "甲"}
+-- response --
+400
+{"error": {"code": "INVALID_ARGUMENT", "message": "name:未知字段"}}
+
+-- request --
+POST /api/members
+
+null
+-- response --
+400
+{"error": {"code": "INVALID_ARGUMENT", "message": "应为 JSON 对象"}}
+
+-- request --
+POST /api/members
+
+{}
+-- response --
+201
+{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$guestName", "avatarHash": ""}}
+
+-- request --
+GET /api/me
+-- response --
+401
+{"error": {"code": "UNAUTHORIZED", "message": "成员 token 无效"}}
+
+-- request --
+GET /api/me
+Authorization: Bearer not-a-member-token
+-- response --
+401
+{"error": {"code": "UNAUTHORIZED", "message": "成员 token 无效"}}
+
+-- request --
+PATCH /api/me
+
+{"name": "乙"}
+-- response --
+401
+{"error": {"code": "UNAUTHORIZED", "message": "成员 token 无效"}}
+
+-- request --
+PATCH /api/me
+Authorization: Bearer not-a-member-token
+
+{"name": "乙"}
+-- response --
+401
+{"error": {"code": "UNAUTHORIZED", "message": "成员 token 无效"}}
+
+-- request --
+PATCH /api/me
+Authorization: Bearer $alice
+
+{"name": ""}
+-- response --
+400
+{"error": {"code": "INVALID_ARGUMENT", "message": "name:长度不能少于 1 个字符"}}
+
+-- request --
+PATCH /api/me
+Authorization: Bearer $alice
+
+{"name": "甲\u0000"}
+-- response --
+400
+{"error": {"code": "INVALID_ARGUMENT", "message": "name:不能包含 U+0000"}}
diff --git a/internal/bootstrap/testdata/http/session.txtar b/internal/bootstrap/testdata/http/session.txtar
new file mode 100644
index 0000000..cfe8f21
--- /dev/null
+++ b/internal/bootstrap/testdata/http/session.txtar
@@ -0,0 +1,74 @@
+获取成员身份,读取和修改昵称、头像。
+
+-- request --
+POST /api/members
+
+{}
+-- response --
+201
+{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$guestName", "avatarHash": ""}}
+
+-- request --
+POST /api/members
+
+{}
+-- response --
+201
+{"memberToken": "$bob", "member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}}
+
+-- request --
+GET /api/me
+Authorization: Bearer $alice
+-- response --
+200
+{"member": {"id": "$aliceId", "name": "$guestName", "avatarHash": ""}}
+
+-- request --
+PATCH /api/me
+Authorization: Bearer $alice
+
+{"name": "甲", "email": " Alice@Example.COM "}
+-- response --
+200
+{"member": {"id": "$aliceId", "name": "甲", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}}
+
+-- request --
+PATCH /api/me
+Authorization: Bearer $alice
+
+{}
+-- response --
+200
+{"member": {"id": "$aliceId", "name": "甲", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}}
+
+-- request --
+PATCH /api/me
+Authorization: Bearer $alice
+
+{"email": ""}
+-- response --
+200
+{"member": {"id": "$aliceId", "name": "甲", "avatarHash": ""}}
+
+-- request --
+PATCH /api/me
+Authorization: Bearer $bob
+
+{"name": "乙"}
+-- response --
+200
+{"member": {"id": "$bobId", "name": "乙", "avatarHash": ""}}
+
+-- request --
+GET /api/me
+Authorization: Bearer $alice
+-- response --
+200
+{"member": {"id": "$aliceId", "name": "甲", "avatarHash": ""}}
+
+-- request --
+GET /api/me
+Authorization: Bearer $bob
+-- response --
+200
+{"member": {"id": "$bobId", "name": "乙", "avatarHash": ""}}
diff --git a/internal/bootstrap/testdata/http/site.txtar b/internal/bootstrap/testdata/http/site.txtar
new file mode 100644
index 0000000..2ca6248
--- /dev/null
+++ b/internal/bootstrap/testdata/http/site.txtar
@@ -0,0 +1,63 @@
+站点:品牌名默认是 Alisten;后台修改时返回存下的名字,首尾的空白已经去掉,之后读到的也是它;空的、超过 20 个字符的和看不见的名字不能保存;修改品牌名要管理 token。
+
+-- request --
+GET /api/site
+-- response --
+200
+{"brandName": "Alisten"}
+
+-- request --
+PATCH /api/admin/site
+Authorization: Bearer admin-secret
+
+{"brandName": " 周末听歌会 "}
+-- response --
+200
+{"brandName": "周末听歌会"}
+
+-- request --
+GET /api/site
+-- response --
+200
+{"brandName": "周末听歌会"}
+
+-- request --
+PATCH /api/admin/site
+Authorization: Bearer admin-secret
+
+{"brandName": ""}
+-- response --
+400
+{"error": {"code": "INVALID_ARGUMENT", "message": "brandName:去掉首尾空白后应有 1 到 20 个字符,其中至少一个看得见,不能包含控制字符"}}
+
+-- request --
+PATCH /api/admin/site
+Authorization: Bearer admin-secret
+
+{"brandName": "一二三四五六七八九十一二三四五六七八九十一"}
+-- response --
+400
+{"error": {"code": "INVALID_ARGUMENT", "message": "brandName:去掉首尾空白后应有 1 到 20 个字符,其中至少一个看得见,不能包含控制字符"}}
+
+-- request --
+PATCH /api/admin/site
+Authorization: Bearer admin-secret
+
+{"brandName": "\u200b\u3000\u200b"}
+-- response --
+400
+{"error": {"code": "INVALID_ARGUMENT", "message": "brandName:去掉首尾空白后应有 1 到 20 个字符,其中至少一个看得见,不能包含控制字符"}}
+
+-- request --
+PATCH /api/admin/site
+
+{"brandName": "别人的名字"}
+-- response --
+401
+{"error": {"code": "UNAUTHORIZED", "message": "管理 token 无效"}}
+
+-- request --
+GET /api/site
+-- response --
+200
+{"brandName": "周末听歌会"}
diff --git a/internal/bootstrap/testdata/http/tickets.txtar b/internal/bootstrap/testdata/http/tickets.txtar
new file mode 100644
index 0000000..894a718
--- /dev/null
+++ b/internal/bootstrap/testdata/http/tickets.txtar
@@ -0,0 +1,107 @@
+成员用成员 token 换取进房票据;有密码的房间先校验密码。
+
+-- request --
+POST /api/members
+
+{}
+-- response --
+201
+{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$guestName", "avatarHash": ""}}
+
+-- request --
+POST /api/rooms
+
+{"name": "临时", "description": ""}
+-- response --
+201
+Location: /api/rooms/$open
+{"id": "$open"}
+
+-- request --
+POST /api/rooms
+
+{"name": "上锁", "description": "", "password": "芝麻开门"}
+-- response --
+201
+Location: /api/rooms/$locked
+{"id": "$locked"}
+
+-- request --
+POST /api/rooms/$open/tickets
+Authorization: Bearer $alice
+
+{}
+-- response --
+201
+{"ticket": "$openTicket"}
+
+-- request --
+POST /api/rooms/$open/tickets
+Authorization: Bearer $alice
+
+{"password": "用不到"}
+-- response --
+201
+{"ticket": "$secondTicket"}
+
+-- request --
+POST /api/rooms/$locked/tickets
+Authorization: Bearer $alice
+
+{}
+-- response --
+401
+{"error": {"code": "UNAUTHORIZED", "message": "房间密码错误"}}
+
+-- request --
+POST /api/rooms/$locked/tickets
+Authorization: Bearer $alice
+
+{"password": "芝麻关门"}
+-- response --
+401
+{"error": {"code": "UNAUTHORIZED", "message": "房间密码错误"}}
+
+-- request --
+POST /api/rooms/$locked/tickets
+Authorization: Bearer $alice
+
+{"password": "芝麻开门"}
+-- response --
+201
+{"ticket": "$lockedTicket"}
+
+-- request --
+POST /api/rooms/$locked/tickets
+
+{"password": "芝麻开门"}
+-- response --
+401
+{"error": {"code": "UNAUTHORIZED", "message": "成员 token 无效"}}
+
+-- request --
+POST /api/rooms/01a15778-1911-7a28-8b2e-e08c2a25f388/tickets
+Authorization: Bearer $alice
+
+{}
+-- response --
+404
+{"error": {"code": "NOT_FOUND", "message": "房间不存在"}}
+
+-- request --
+POST /api/rooms/%00/tickets
+Authorization: Bearer $alice
+
+{}
+-- response --
+404
+{"error": {"code": "NOT_FOUND", "message": "房间不存在"}}
+
+-- request --
+POST /api/rooms/$locked/tickets
+Authorization: Bearer $alice
+
+{"password": 1}
+-- response --
+400
+{"error": {"code": "INVALID_ARGUMENT", "message": "password:应为字符串"}}
diff --git a/internal/bootstrap/testdata/ws/admin.txtar b/internal/bootstrap/testdata/ws/admin.txtar
new file mode 100644
index 0000000..3131bbd
--- /dev/null
+++ b/internal/bootstrap/testdata/ws/admin.txtar
@@ -0,0 +1,200 @@
+后台修改和删除常驻房间。修改名称和描述后,房间广播新的房间信息;设置密码之后旧票据作废,会话以 4401 断开;
+删除房间之后会话以 4404 断开,房间和它的票据一起删除。临时房间不归后台管理。
+
+-- request --
+POST /api/members
+
+{}
+-- response --
+201
+{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}}
+
+-- request --
+POST /api/admin/rooms
+Authorization: Bearer admin-secret
+
+{"name": "大厅", "description": "常驻", "slug": "hall"}
+-- response --
+201
+Location: /api/rooms/$hall
+{"id": "$hall"}
+
+-- request --
+POST /api/admin/rooms
+Authorization: Bearer admin-secret
+
+{"name": "二厅", "description": ""}
+-- response --
+201
+Location: /api/rooms/$lounge
+{"id": "$lounge"}
+
+-- request --
+POST /api/rooms
+
+{"name": "临时", "description": ""}
+-- response --
+201
+Location: /api/rooms/$temp
+{"id": "$temp"}
+
+-- request --
+POST /api/rooms/$hall/tickets
+Authorization: Bearer $alice
+
+{}
+-- response --
+201
+{"ticket": "$hallTicket"}
+
+-- request --
+POST /api/rooms/$lounge/tickets
+Authorization: Bearer $alice
+
+{}
+-- response --
+201
+{"ticket": "$loungeTicket"}
+
+-- connect alice --
+-- send alice --
+{"kind": "request", "type": "hello", "id": "h1", "data": {"roomId": "$hall", "ticket": "$hallTicket", "protocolVersion": "$protocolVersion"}}
+-- receive alice --
+{"kind": "response", "type": "hello", "id": "h1", "data": {
+ "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""},
+ "serverTimeMs": 1,
+ "room": {"term": 1, "seq": 1, "room": {"id": "$hall", "name": "大厅", "description": "常驻", "kind": "persistent"},
+ "playMode": "sequential", "nowPlaying": null, "pending": null, "items": [],
+ "members": {"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}], "onlineCount": 1}}}}
+
+-- request --
+PATCH /api/admin/rooms/$hall
+Authorization: Bearer admin-secret
+
+{"name": "新大厅"}
+-- response --
+204
+-- receive alice --
+{"kind": "event", "type": "room.updated", "term": 1, "seq": 2, "data": {"name": "新大厅", "description": "常驻"}}
+
+-- request --
+PATCH /api/admin/rooms/$hall
+Authorization: Bearer admin-secret
+
+{"description": "换了描述", "slug": ""}
+-- response --
+204
+-- receive alice --
+{"kind": "event", "type": "room.updated", "term": 1, "seq": 3, "data": {"name": "新大厅", "description": "换了描述"}}
+
+-- request --
+GET /api/rooms/hall
+-- response --
+404
+{"error": {"code": "NOT_FOUND", "message": "房间不存在"}}
+
+-- request --
+GET /api/rooms/$hall
+-- response --
+200
+{"id": "$hall", "slug": null, "name": "新大厅", "description": "换了描述", "kind": "persistent", "hasPassword": false, "onlineCount": 1}
+
+-- request --
+PATCH /api/admin/rooms/$hall
+Authorization: Bearer admin-secret
+
+{"slug": "Hall"}
+-- response --
+400
+{"error": {"code": "INVALID_ARGUMENT", "message": "slug:格式不正确"}}
+
+-- request --
+PATCH /api/admin/rooms/$hall
+
+{"name": "无权修改"}
+-- response --
+401
+{"error": {"code": "UNAUTHORIZED", "message": "管理 token 无效"}}
+
+-- request --
+PATCH /api/admin/rooms/hall
+Authorization: Bearer admin-secret
+
+{"name": "别名不是房间 ID"}
+-- response --
+404
+{"error": {"code": "NOT_FOUND", "message": "房间不存在"}}
+
+-- request --
+PATCH /api/admin/rooms/$temp
+Authorization: Bearer admin-secret
+
+{"name": "改名"}
+-- response --
+404
+{"error": {"code": "NOT_FOUND", "message": "房间不存在"}}
+
+-- request --
+DELETE /api/admin/rooms/$temp
+Authorization: Bearer admin-secret
+-- response --
+404
+{"error": {"code": "NOT_FOUND", "message": "房间不存在"}}
+
+-- request --
+PATCH /api/admin/rooms/$hall
+Authorization: Bearer admin-secret
+
+{"password": "芝麻开门"}
+-- response --
+204
+-- closed alice --
+4401
+
+-- connect again --
+-- send again --
+{"kind": "request", "type": "hello", "id": "h1", "data": {"roomId": "$hall", "ticket": "$hallTicket", "protocolVersion": "$protocolVersion"}}
+-- receive again --
+{"kind": "response", "type": "hello", "id": "h1", "data": {}, "error": {"code": "UNAUTHORIZED", "message": "票据无效"}}
+-- closed again --
+4401
+
+-- connect lounge --
+-- send lounge --
+{"kind": "request", "type": "hello", "id": "h1", "data": {"roomId": "$lounge", "ticket": "$loungeTicket", "protocolVersion": "$protocolVersion"}}
+-- receive lounge --
+{"kind": "response", "type": "hello", "id": "h1", "data": {
+ "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""},
+ "serverTimeMs": 1,
+ "room": {"term": 1, "seq": 1, "room": {"id": "$lounge", "name": "二厅", "description": "", "kind": "persistent"},
+ "playMode": "sequential", "nowPlaying": null, "pending": null, "items": [],
+ "members": {"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}], "onlineCount": 1}}}}
+
+-- request --
+DELETE /api/admin/rooms/$lounge
+Authorization: Bearer admin-secret
+-- response --
+204
+-- closed lounge --
+4404
+
+-- request --
+GET /api/rooms/$lounge
+-- response --
+404
+{"error": {"code": "NOT_FOUND", "message": "房间不存在"}}
+
+-- connect gone --
+-- send gone --
+{"kind": "request", "type": "hello", "id": "h1", "data": {"roomId": "$lounge", "ticket": "$loungeTicket", "protocolVersion": "$protocolVersion"}}
+-- receive gone --
+{"kind": "response", "type": "hello", "id": "h1", "data": {}, "error": {"code": "NOT_FOUND", "message": "房间不存在"}}
+-- closed gone --
+4404
+
+-- request --
+DELETE /api/admin/rooms/$lounge
+Authorization: Bearer admin-secret
+-- response --
+404
+{"error": {"code": "NOT_FOUND", "message": "房间不存在"}}
diff --git a/internal/bootstrap/testdata/ws/api-key.txtar b/internal/bootstrap/testdata/ws/api-key.txtar
new file mode 100644
index 0000000..5168867
--- /dev/null
+++ b/internal/bootstrap/testdata/ws/api-key.txtar
@@ -0,0 +1,192 @@
+常驻房间的 API Key:管理员先看到还没有 Key,生成时拿到 Key,之后只看到已经生成;bot 用它通过 HTTP 点歌并代填点歌人,
+条目的点歌人是“张三(bot)”;普通成员不能代填;Key 不能建立 WebSocket 会话。重置之后旧 Key 返回 401;
+设置密码之后成员的会话以 4401 断开,Key 照常可用。临时房间没有 Key。
+
+-- request --
+POST /api/members
+
+{}
+-- response --
+201
+{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}}
+
+-- request --
+POST /api/admin/rooms
+Authorization: Bearer admin-secret
+
+{"name": "大厅", "description": ""}
+-- response --
+201
+Location: /api/rooms/$hall
+{"id": "$hall"}
+
+-- request --
+POST /api/rooms/$hall/tickets
+Authorization: Bearer $alice
+
+{}
+-- response --
+201
+{"ticket": "$ticket"}
+
+-- connect alice --
+-- send alice --
+{"kind": "request", "type": "hello", "id": "h1", "data": {"roomId": "$hall", "ticket": "$ticket", "protocolVersion": "$protocolVersion"}}
+-- receive alice --
+{"kind": "response", "type": "hello", "id": "h1", "data": {
+ "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""},
+ "serverTimeMs": 1,
+ "room": {"term": 1, "seq": 1, "room": {"id": "$hall", "name": "大厅", "description": "", "kind": "persistent"},
+ "playMode": "sequential", "nowPlaying": null, "pending": null, "items": [],
+ "members": {"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}], "onlineCount": 1}}}}
+
+-- request --
+GET /api/admin/rooms/$hall/api-key
+Authorization: Bearer admin-secret
+-- response --
+200
+{"set": false, "createdAtMs": 0}
+
+-- request --
+POST /api/admin/rooms/$hall/api-key
+Authorization: Bearer admin-secret
+
+{}
+-- response --
+201
+Location: /api/admin/rooms/$hall/api-key
+{"key": "$key"}
+
+-- request --
+GET /api/admin/rooms/$hall/api-key
+Authorization: Bearer admin-secret
+-- response --
+200
+{"set": true, "createdAtMs": 1}
+
+-- request --
+POST /api/room/queue
+Authorization: Bearer $key
+
+{"source": "netease", "keyword": "晴天", "requester": "张三"}
+-- response --
+201
+Location: /api/room/queue/1
+{"entry": {"id": "1", "track": {"source": "netease", "id": "186016"},
+ "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000},
+ "by": {"id": "$botId", "name": "张三(bot)", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"}}
+-- receive alice --
+{"kind": "event", "type": "room.activity", "term": 1, "seq": 2, "data": {"kind": "added", "member": {"id": "$botId", "name": "张三(bot)", "avatarHash": ""},
+ "entry": {"id": "1", "track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000},
+ "by": {"id": "$botId", "name": "张三(bot)", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"},
+ "message": "点了《晴天》"}}
+-- receive alice --
+{"kind": "event", "type": "queue.updated", "term": 1, "seq": 3, "data": {"items": [],
+ "pending": {"id": "1", "track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000},
+ "by": {"id": "$botId", "name": "张三(bot)", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"}}}
+-- receive alice --
+{"kind": "event", "type": "queue.updated", "term": 1, "seq": 4, "data": {"pending": null, "items": []}}
+-- receive alice --
+{"kind": "event", "type": "playback.updated", "term": 1, "seq": 5, "data": {"nowPlaying": {
+ "entry": {"id": "1", "track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269500},
+ "by": {"id": "$botId", "name": "张三(bot)", "avatarHash": ""}, "auto": false, "likes": 0, "status": "ready"},
+ "mediaUrl": "/media/netease/186016", "startedAtMs": 1, "durationMs": 269500, "gain": 1, "lyric": "[00:01.00]故事的小黄花"}}}
+
+-- request --
+GET /api/room
+Authorization: Bearer $key
+-- response --
+200
+{"room": {"term": 1, "seq": 5, "room": {"id": "$hall", "name": "大厅", "description": "", "kind": "persistent"},
+ "playMode": "sequential",
+ "nowPlaying": {
+ "entry": {"id": "1", "track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269500},
+ "by": {"id": "$botId", "name": "张三(bot)", "avatarHash": ""}, "auto": false, "likes": 0, "status": "ready"},
+ "mediaUrl": "/media/netease/186016", "startedAtMs": 1, "durationMs": 269500, "gain": 1, "lyric": "[00:01.00]故事的小黄花"},
+ "pending": null, "items": [],
+ "members": {"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}], "onlineCount": 1}}}
+
+-- send alice --
+{"kind": "request", "type": "queue.add", "id": "a1", "data": {"track": {"source": "netease", "id": "185811"}, "requester": "李四"}}
+-- receive alice --
+{"kind": "response", "type": "queue.add", "id": "a1", "data": {}, "error": {"code": "INVALID_ARGUMENT", "message": "requester:只有用常驻房间的 API Key 点歌时才能填写"}}
+
+-- connect bot --
+-- send bot --
+{"kind": "request", "type": "hello", "id": "h1", "data": {"roomId": "$hall", "ticket": "$key", "protocolVersion": "$protocolVersion"}}
+-- receive bot --
+{"kind": "response", "type": "hello", "id": "h1", "data": {}, "error": {"code": "UNAUTHORIZED", "message": "API Key 只能用于 HTTP 接口"}}
+-- closed bot --
+4401
+
+-- request --
+POST /api/admin/rooms/$hall/api-key
+Authorization: Bearer admin-secret
+
+{}
+-- response --
+201
+Location: /api/admin/rooms/$hall/api-key
+{"key": "$resetKey"}
+
+-- request --
+POST /api/room/messages
+Authorization: Bearer $key
+
+{"content": "旧 Key"}
+-- response --
+401
+{"error": {"code": "UNAUTHORIZED", "message": "票据无效"}}
+
+-- request --
+POST /api/room/messages
+Authorization: Bearer $resetKey
+
+{"content": "新 Key"}
+-- response --
+204
+-- receive alice --
+{"kind": "event", "type": "chat.message", "term": 1, "seq": 6, "data": {"member": {"id": "$botId", "name": "bot", "avatarHash": ""}, "content": "新 Key", "sentAtMs": 1}}
+
+-- request --
+PATCH /api/admin/rooms/$hall
+Authorization: Bearer admin-secret
+
+{"password": "芝麻开门"}
+-- response --
+204
+-- closed alice --
+4401
+
+-- request --
+POST /api/room/messages
+Authorization: Bearer $resetKey
+
+{"content": "改密码之后"}
+-- response --
+204
+
+-- request --
+POST /api/rooms
+
+{"name": "临时", "description": ""}
+-- response --
+201
+Location: /api/rooms/$temp
+{"id": "$temp"}
+
+-- request --
+GET /api/admin/rooms/$temp/api-key
+Authorization: Bearer admin-secret
+-- response --
+404
+{"error": {"code": "NOT_FOUND", "message": "房间不存在"}}
+
+-- request --
+POST /api/admin/rooms/$temp/api-key
+Authorization: Bearer admin-secret
+
+{}
+-- response --
+404
+{"error": {"code": "NOT_FOUND", "message": "房间不存在"}}
diff --git a/internal/bootstrap/testdata/ws/autodj.txtar b/internal/bootstrap/testdata/ws/autodj.txtar
new file mode 100644
index 0000000..728eb90
--- /dev/null
+++ b/internal/bootstrap/testdata/ws/autodj.txtar
@@ -0,0 +1,103 @@
+AutoDJ 自动补歌:网易云的歌开播时,等待队列为空、有人在听、10 秒内没有人点歌,就按房间的播放记录推荐一首相似的歌,
+以 AutoDJ 的名义入队并准备。《片头》只有 15 秒,它放完、晴天开播时,晴天已经是 15 秒前点的了:比 10 秒多出的 5 秒,
+是点晴天的消息从发出到房间收到可以耽搁的时间。
+
+-- request --
+POST /api/members
+
+{}
+-- response --
+201
+{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}}
+
+-- request --
+POST /api/admin/rooms
+Authorization: Bearer admin-secret
+
+{"name": "大厅", "description": ""}
+-- response --
+201
+Location: /api/rooms/$room
+{"id": "$room"}
+
+-- request --
+POST /api/rooms/$room/tickets
+Authorization: Bearer $alice
+
+{}
+-- response --
+201
+{"ticket": "$ticket"}
+
+-- connect alice --
+-- send alice --
+{"kind": "request", "type": "hello", "id": "h1", "data": {"roomId": "$room", "ticket": "$ticket", "protocolVersion": "$protocolVersion"}}
+-- receive alice --
+{"kind": "response", "type": "hello", "id": "h1", "data": {
+ "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""},
+ "serverTimeMs": 1,
+ "room": {"term": 1, "seq": 1, "room": {"id": "$room", "name": "大厅", "description": "", "kind": "persistent"},
+ "playMode": "sequential", "nowPlaying": null, "pending": null, "items": [],
+ "members": {"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}], "onlineCount": 1}}}}
+
+-- send alice --
+{"kind": "request", "type": "queue.add", "id": "a1", "data": {"track": {"source": "netease", "id": "1100"}}}
+-- receive alice --
+{"kind": "event", "type": "room.activity", "term": 1, "seq": 2, "data": {"kind": "added", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""},
+ "entry": {"id": "1", "track": {"source": "netease", "id": "1100"}, "meta": {"title": "片头", "artist": "无名", "album": "", "coverUrl": "", "pageUrl": "https://music.163.com/#/song?id=1100", "durationMs": 15000},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"},
+ "message": "点了《片头》"}}
+-- receive alice --
+{"kind": "event", "type": "queue.updated", "term": 1, "seq": 3, "data": {"items": [],
+ "pending": {"id": "1", "track": {"source": "netease", "id": "1100"}, "meta": {"title": "片头", "artist": "无名", "album": "", "coverUrl": "", "pageUrl": "https://music.163.com/#/song?id=1100", "durationMs": 15000},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"}}}
+-- receive alice --
+{"kind": "response", "type": "queue.add", "id": "a1", "data": {"entry": {"id": "1", "track": {"source": "netease", "id": "1100"}, "meta": {"title": "片头", "artist": "无名", "album": "", "coverUrl": "", "pageUrl": "https://music.163.com/#/song?id=1100", "durationMs": 15000},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"}}}
+-- receive alice --
+{"kind": "event", "type": "queue.updated", "term": 1, "seq": 4, "data": {"pending": null, "items": []}}
+-- receive alice --
+{"kind": "event", "type": "playback.updated", "term": 1, "seq": 5, "data": {"nowPlaying": {
+ "entry": {"id": "1", "track": {"source": "netease", "id": "1100"}, "meta": {"title": "片头", "artist": "无名", "album": "", "coverUrl": "", "pageUrl": "https://music.163.com/#/song?id=1100", "durationMs": 15000},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "ready"},
+ "mediaUrl": "/media/netease/1100", "startedAtMs": 1, "durationMs": 15000, "gain": 1, "lyric": ""}}}
+
+-- send alice --
+{"kind": "request", "type": "queue.add", "id": "a2", "data": {"track": {"source": "netease", "id": "186016"}}}
+-- receive alice --
+{"kind": "event", "type": "room.activity", "term": 1, "seq": 6, "data": {"kind": "added", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""},
+ "entry": {"id": "2", "track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"},
+ "message": "点了《晴天》"}}
+-- receive alice --
+{"kind": "event", "type": "queue.updated", "term": 1, "seq": 7, "data": {"pending": null,
+ "items": [{"id": "2", "track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"}]}}
+-- receive alice --
+{"kind": "response", "type": "queue.add", "id": "a2", "data": {"entry": {"id": "2", "track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"}}}
+-- receive alice --
+{"kind": "event", "type": "queue.updated", "term": 1, "seq": 8, "data": {"pending": null,
+ "items": [{"id": "2", "track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269500},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "ready"}]}}
+
+-- receive alice --
+{"kind": "event", "type": "queue.updated", "term": 1, "seq": 9, "data": {"pending": null, "items": []}}
+-- receive alice --
+{"kind": "event", "type": "playback.updated", "term": 1, "seq": 10, "data": {"nowPlaying": {
+ "entry": {"id": "2", "track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269500},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "ready"},
+ "mediaUrl": "/media/netease/186016", "startedAtMs": 1, "durationMs": 269500, "gain": 1, "lyric": "[00:01.00]故事的小黄花"}}}
+-- receive alice --
+{"kind": "event", "type": "room.activity", "term": 1, "seq": 11, "data": {"kind": "added",
+ "entry": {"id": "3", "track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000},
+ "by": null, "auto": true, "likes": 0, "status": "preparing"},
+ "message": "点了《七里香》"}}
+-- receive alice --
+{"kind": "event", "type": "queue.updated", "term": 1, "seq": 12, "data": {"pending": null,
+ "items": [{"id": "3", "track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000},
+ "by": null, "auto": true, "likes": 0, "status": "preparing"}]}}
+-- receive alice --
+{"kind": "event", "type": "queue.updated", "term": 1, "seq": 13, "data": {"pending": null,
+ "items": [{"id": "3", "track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000},
+ "by": null, "auto": true, "likes": 0, "status": "ready"}]}}
diff --git a/internal/bootstrap/testdata/ws/bilibili.txtar b/internal/bootstrap/testdata/ws/bilibili.txtar
new file mode 100644
index 0000000..67d6e40
--- /dev/null
+++ b/internal/bootstrap/testdata/ws/bilibili.txtar
@@ -0,0 +1,72 @@
+B 站:临时房间可以点 B 站的视频,完整链接和 b23.tv 短链识别成同一首;认得出但不能点的链接(番剧)返回音源给出的原因;
+直接给出的曲目 ID 格式不对时返回 INVALID_ARGUMENT。没有 worker,点到的歌一直在准备中。
+
+-- request --
+POST /api/members
+
+{}
+-- response --
+201
+{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}}
+
+-- request --
+POST /api/rooms
+
+{"name": "临时", "description": ""}
+-- response --
+201
+Location: /api/rooms/$room
+{"id": "$room"}
+
+-- request --
+POST /api/rooms/$room/tickets
+Authorization: Bearer $alice
+
+{}
+-- response --
+201
+{"ticket": "$ticket"}
+
+-- connect alice --
+-- send alice --
+{"kind": "request", "type": "hello", "id": "h1", "data": {"roomId": "$room", "ticket": "$ticket", "protocolVersion": "$protocolVersion"}}
+-- receive alice --
+{"kind": "response", "type": "hello", "id": "h1", "data": {
+ "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""},
+ "serverTimeMs": 1,
+ "room": {"term": 1, "seq": 1, "room": {"id": "$room", "name": "临时", "description": "", "kind": "ephemeral"},
+ "playMode": "sequential", "nowPlaying": null, "pending": null, "items": [],
+ "members": {"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}], "onlineCount": 1}}}}
+
+-- send alice --
+{"kind": "request", "type": "queue.add", "id": "a1", "data": {"track": {"source": "link", "id": "https://www.bilibili.com/video/BV1xx411c7mD?p=2"}}}
+-- receive alice --
+{"kind": "event", "type": "room.activity", "term": 1, "seq": 2, "data": {"kind": "added", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""},
+ "entry": {"id": "1", "track": {"source": "bilibili", "id": "BV1xx411c7mD/2"}, "meta": {"title": "晴天(钢琴版)", "artist": "某 UP 主", "album": "", "coverUrl": "", "pageUrl": "https://www.bilibili.com/video/BV1xx411c7mD?p=2", "durationMs": 2000},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"},
+ "message": "点了《晴天(钢琴版)》"}}
+-- receive alice --
+{"kind": "event", "type": "queue.updated", "term": 1, "seq": 3, "data": {"items": [],
+ "pending": {"id": "1", "track": {"source": "bilibili", "id": "BV1xx411c7mD/2"}, "meta": {"title": "晴天(钢琴版)", "artist": "某 UP 主", "album": "", "coverUrl": "", "pageUrl": "https://www.bilibili.com/video/BV1xx411c7mD?p=2", "durationMs": 2000},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"}}}
+-- receive alice --
+{"kind": "response", "type": "queue.add", "id": "a1", "data": {"entry": {"id": "1", "track": {"source": "bilibili", "id": "BV1xx411c7mD/2"},
+ "meta": {"title": "晴天(钢琴版)", "artist": "某 UP 主", "album": "", "coverUrl": "", "pageUrl": "https://www.bilibili.com/video/BV1xx411c7mD?p=2", "durationMs": 2000},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"}}}
+
+-- send alice --
+{"kind": "request", "type": "queue.add", "id": "a2", "data": {"track": {"source": "link", "id": "https://b23.tv/qingtian"}}}
+-- receive alice --
+{"kind": "response", "type": "queue.add", "id": "a2", "data": {}, "error": {"code": "CONFLICT", "message": "这首歌已经在播放列表里了。"}}
+-- send alice --
+{"kind": "request", "type": "queue.add", "id": "a3", "data": {"track": {"source": "link", "id": "https://www.bilibili.com/bangumi/play/ep1"}}}
+-- receive alice --
+{"kind": "response", "type": "queue.add", "id": "a3", "data": {}, "error": {"code": "INVALID_ARGUMENT", "message": "哔哩哔哩的番剧不能点播"}}
+-- send alice --
+{"kind": "request", "type": "queue.add", "id": "a4", "data": {"track": {"source": "bilibili", "id": "BV1xx411c7mD/1"}}}
+-- receive alice --
+{"kind": "response", "type": "queue.add", "id": "a4", "data": {}, "error": {"code": "INVALID_ARGUMENT", "message": "data.track.id:格式不正确"}}
+-- send alice --
+{"kind": "request", "type": "queue.add", "id": "a5", "data": {"track": {"source": "bilibili", "id": "BV1xx411c7mD/2"}}}
+-- receive alice --
+{"kind": "response", "type": "queue.add", "id": "a5", "data": {}, "error": {"code": "CONFLICT", "message": "这首歌已经在播放列表里了。"}}
diff --git a/internal/bootstrap/testdata/ws/catalog.txtar b/internal/bootstrap/testdata/ws/catalog.txtar
new file mode 100644
index 0000000..5a8a441
--- /dev/null
+++ b/internal/bootstrap/testdata/ws/catalog.txtar
@@ -0,0 +1,113 @@
+网关直接回复搜索、歌单和推荐列表的请求,它们不经过房间,房间的事件编号不变。搜索结果在上游一次取回的结果中分页;
+QQ 音乐不提供歌单内的歌曲,链接不支持搜索;推荐列表取正在播放的网易云歌曲的相似歌曲,去掉种子本身。常驻房间的搜索不限额度。
+
+-- request --
+POST /api/members
+
+{}
+-- response --
+201
+{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}}
+
+-- request --
+POST /api/admin/rooms
+Authorization: Bearer admin-secret
+
+{"name": "大厅", "description": ""}
+-- response --
+201
+Location: /api/rooms/$room
+{"id": "$room"}
+
+-- request --
+POST /api/rooms/$room/tickets
+Authorization: Bearer $alice
+
+{}
+-- response --
+201
+{"ticket": "$ticket"}
+
+-- connect alice --
+-- send alice --
+{"kind": "request", "type": "hello", "id": "h1", "data": {"roomId": "$room", "ticket": "$ticket", "protocolVersion": "$protocolVersion"}}
+-- receive alice --
+{"kind": "response", "type": "hello", "id": "h1", "data": {
+ "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""},
+ "serverTimeMs": 1,
+ "room": {"term": 1, "seq": 1, "room": {"id": "$room", "name": "大厅", "description": "", "kind": "persistent"},
+ "playMode": "sequential", "nowPlaying": null, "pending": null, "items": [],
+ "members": {"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}], "onlineCount": 1}}}}
+
+-- send alice --
+{"kind": "request", "type": "search.tracks", "id": "q1", "data": {"source": "netease", "keyword": "周杰伦", "page": 1, "pageSize": 1}}
+-- receive alice --
+{"kind": "response", "type": "search.tracks", "id": "q1", "data": {"total": 2, "items": [
+ {"track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000}}]}}
+-- send alice --
+{"kind": "request", "type": "search.tracks", "id": "q2", "data": {"source": "netease", "keyword": "周杰伦", "page": 2, "pageSize": 1}}
+-- receive alice --
+{"kind": "response", "type": "search.tracks", "id": "q2", "data": {"total": 2, "items": [
+ {"track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000}}]}}
+-- send alice --
+{"kind": "request", "type": "search.tracks", "id": "q3", "data": {"source": "link", "keyword": "周杰伦", "page": 1, "pageSize": 10}}
+-- receive alice --
+{"kind": "response", "type": "search.tracks", "id": "q3", "data": {}, "error": {"code": "PROVIDER_ERROR", "message": "音乐平台没有正常响应,请稍后再试。"}}
+-- send alice --
+{"kind": "request", "type": "search.playlists", "id": "q4", "data": {"source": "netease", "keyword": "周杰伦", "page": 1, "pageSize": 10}}
+-- receive alice --
+{"kind": "response", "type": "search.playlists", "id": "q4", "data": {"total": 1, "items": [{"source": "netease", "id": "3778678", "name": "周杰伦精选",
+ "coverUrl": "https://p1.music.126.net/jay.jpg", "creator": "alice", "description": "", "trackCount": 2, "playCount": 100}]}}
+-- send alice --
+{"kind": "request", "type": "playlist.tracks", "id": "q5", "data": {"source": "netease", "playlistId": "3778678"}}
+-- receive alice --
+{"kind": "response", "type": "playlist.tracks", "id": "q5", "data": {"total": 2, "items": [
+ {"track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000}},
+ {"track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000}}]}}
+-- send alice --
+{"kind": "request", "type": "playlist.tracks", "id": "q6", "data": {"source": "qq", "playlistId": "3778678"}}
+-- receive alice --
+{"kind": "response", "type": "playlist.tracks", "id": "q6", "data": {}, "error": {"code": "PROVIDER_ERROR", "message": "音乐平台没有正常响应,请稍后再试。"}}
+-- send alice --
+{"kind": "request", "type": "recommend.list", "id": "q7", "data": {}}
+-- receive alice --
+{"kind": "response", "type": "recommend.list", "id": "q7", "data": {"items": []}}
+
+-- send alice --
+{"kind": "request", "type": "queue.add", "id": "a1", "data": {"track": {"source": "netease", "id": "186016"}}}
+-- receive alice --
+{"kind": "event", "type": "room.activity", "term": 1, "seq": 2, "data": {"kind": "added", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""},
+ "entry": {"id": "1", "track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"},
+ "message": "点了《晴天》"}}
+-- receive alice --
+{"kind": "event", "type": "queue.updated", "term": 1, "seq": 3, "data": {"items": [],
+ "pending": {"id": "1", "track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"}}}
+-- receive alice --
+{"kind": "response", "type": "queue.add", "id": "a1", "data": {"entry": {"id": "1", "track": {"source": "netease", "id": "186016"},
+ "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"}}}
+-- receive alice --
+{"kind": "event", "type": "queue.updated", "term": 1, "seq": 4, "data": {"pending": null, "items": []}}
+-- receive alice --
+{"kind": "event", "type": "playback.updated", "term": 1, "seq": 5, "data": {"nowPlaying": {
+ "entry": {"id": "1", "track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269500},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "ready"},
+ "mediaUrl": "/media/netease/186016", "startedAtMs": 1, "durationMs": 269500, "gain": 1, "lyric": "[00:01.00]故事的小黄花"}}}
+
+-- send alice --
+{"kind": "request", "type": "recommend.list", "id": "q8", "data": {}}
+-- receive alice --
+{"kind": "response", "type": "recommend.list", "id": "q8", "data": {"items": [
+ {"track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000}}]}}
+-- send alice --
+{"kind": "request", "type": "room.sync", "id": "s1", "data": {}}
+-- receive alice --
+{"kind": "response", "type": "room.sync", "id": "s1", "data": {"room": {"term": 1, "seq": 5, "room": {"id": "$room", "name": "大厅", "description": "", "kind": "persistent"},
+ "playMode": "sequential", "pending": null, "items": [],
+ "nowPlaying": {
+ "entry": {"id": "1", "track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269500},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "ready"},
+ "mediaUrl": "/media/netease/186016", "startedAtMs": 1, "durationMs": 269500, "gain": 1, "lyric": "[00:01.00]故事的小黄花"},
+ "members": {"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}], "onlineCount": 1}}}}
diff --git a/internal/bootstrap/testdata/ws/handshake.txtar b/internal/bootstrap/testdata/ws/handshake.txtar
new file mode 100644
index 0000000..ee534bb
--- /dev/null
+++ b/internal/bootstrap/testdata/ws/handshake.txtar
@@ -0,0 +1,127 @@
+握手:第一帧必须是 hello。失败时先回复错误,再以对应的关闭码断开;先检查房间是否存在,再检查票据。
+列表外来源的握手以 HTTP 403 拒绝升级。
+
+-- request --
+POST /api/members
+
+{}
+-- response --
+201
+{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}}
+
+-- request --
+POST /api/rooms
+
+{"name": "临时", "description": ""}
+-- response --
+201
+Location: /api/rooms/$room
+{"id": "$room"}
+
+-- request --
+POST /api/rooms
+
+{"name": "隔壁", "description": ""}
+-- response --
+201
+Location: /api/rooms/$other
+{"id": "$other"}
+
+-- request --
+POST /api/rooms/$room/tickets
+Authorization: Bearer $alice
+
+{}
+-- response --
+201
+{"ticket": "$ticket"}
+
+-- connect evil --
+Origin: https://evil.example
+-- response --
+403
+{"error": {"code": "FORBIDDEN", "message": "服务器不接受从这个网址发出的操作,请用网站的正式地址打开。"}}
+
+-- connect early --
+-- send early --
+{"kind": "request", "type": "room.sync", "id": "r1", "data": {}}
+-- receive early --
+{"kind": "response", "type": "room.sync", "id": "r1", "data": {}, "error": {"code": "INVALID_ARGUMENT", "message": "第一帧必须是 hello"}}
+-- closed early --
+1008
+
+-- connect garbage --
+-- send garbage --
+hello
+-- receive garbage --
+{"kind": "event", "type": "error", "data": {"code": "INVALID_ARGUMENT", "message": "消息必须是 JSON 对象"}}
+-- closed garbage --
+1008
+
+-- connect outdated --
+-- send outdated --
+{"kind": "request", "type": "hello", "id": "h1", "data": {"roomId": "$room", "ticket": "$ticket", "protocolVersion": "0000000000000000"}}
+-- receive outdated --
+{"kind": "response", "type": "hello", "id": "h1", "data": {}, "error": {"code": "CLIENT_OUTDATED", "message": "前端与服务端的协议版本不一致,请刷新页面"}}
+-- closed outdated --
+4426
+
+-- connect slug --
+-- send slug --
+{"kind": "request", "type": "hello", "id": "h1", "data": {"roomId": "hall", "ticket": "$ticket", "protocolVersion": "$protocolVersion"}}
+-- receive slug --
+{"kind": "response", "type": "hello", "id": "h1", "data": {}, "error": {"code": "NOT_FOUND", "message": "房间不存在"}}
+-- closed slug --
+4404
+
+-- connect missing --
+-- send missing --
+{"kind": "request", "type": "hello", "id": "h1", "data": {"roomId": "01a15778-1911-7a28-8b2e-e08c2a25f388", "ticket": "不存在的票据", "protocolVersion": "$protocolVersion"}}
+-- receive missing --
+{"kind": "response", "type": "hello", "id": "h1", "data": {}, "error": {"code": "NOT_FOUND", "message": "房间不存在"}}
+-- closed missing --
+4404
+
+-- connect forged --
+-- send forged --
+{"kind": "request", "type": "hello", "id": "h1", "data": {"roomId": "$room", "ticket": "不存在的票据", "protocolVersion": "$protocolVersion"}}
+-- receive forged --
+{"kind": "response", "type": "hello", "id": "h1", "data": {}, "error": {"code": "UNAUTHORIZED", "message": "票据无效"}}
+-- closed forged --
+4401
+
+-- connect elsewhere --
+-- send elsewhere --
+{"kind": "request", "type": "hello", "id": "h1", "data": {"roomId": "$other", "ticket": "$ticket", "protocolVersion": "$protocolVersion"}}
+-- receive elsewhere --
+{"kind": "response", "type": "hello", "id": "h1", "data": {}, "error": {"code": "UNAUTHORIZED", "message": "票据无效"}}
+-- closed elsewhere --
+4401
+
+-- connect alice --
+Origin: https://alisten.example
+-- send alice --
+{"kind": "request", "type": "hello", "id": "h1", "data": {"roomId": "$room", "ticket": "$ticket", "protocolVersion": "$protocolVersion"}}
+-- receive alice --
+{"kind": "response", "type": "hello", "id": "h1", "data": {
+ "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""},
+ "serverTimeMs": 1,
+ "room": {"term": 1, "seq": 1, "room": {"id": "$room", "name": "临时", "description": "", "kind": "ephemeral"},
+ "playMode": "sequential", "nowPlaying": null, "pending": null, "items": [],
+ "members": {"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}], "onlineCount": 1}}}}
+-- send alice --
+{"kind": "request", "type": "hello", "id": "h2", "data": {"roomId": "$room", "ticket": "$ticket", "protocolVersion": "$protocolVersion"}}
+-- receive alice --
+{"kind": "response", "type": "hello", "id": "h2", "data": {}, "error": {"code": "INVALID_ARGUMENT", "message": "已经完成握手"}}
+-- send alice --
+{"kind": "request", "type": "queue.clear", "id": "x1", "data": {}}
+-- receive alice --
+{"kind": "response", "type": "queue.clear", "id": "x1", "data": {}, "error": {"code": "INVALID_ARGUMENT", "message": "type:未知的请求类型"}}
+-- send alice --
+{"kind": "request", "type": "chat.send", "data": {"content": "没有 id"}}
+-- receive alice --
+{"kind": "event", "type": "error", "data": {"code": "INVALID_ARGUMENT", "message": "id:应为 1–128 个字符的字符串"}}
+-- send alice --
+{"kind": "request", "type": "ping", "id": "p1", "data": {"clientTimeMs": 1}}
+-- receive alice --
+{"kind": "response", "type": "ping", "id": "p1", "data": {"clientTimeMs": 1, "serverTimeMs": 1}}
diff --git a/internal/bootstrap/testdata/ws/prepare.txtar b/internal/bootstrap/testdata/ws/prepare.txtar
new file mode 100644
index 0000000..8edaa97
--- /dev/null
+++ b/internal/bootstrap/testdata/ws/prepare.txtar
@@ -0,0 +1,62 @@
+音源拒绝的歌准备失败:条目移出队列,房间广播失败的原因,不再重试。
+
+-- request --
+POST /api/members
+
+{}
+-- response --
+201
+{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}}
+
+-- request --
+POST /api/admin/rooms
+Authorization: Bearer admin-secret
+
+{"name": "大厅", "description": ""}
+-- response --
+201
+Location: /api/rooms/$room
+{"id": "$room"}
+
+-- request --
+POST /api/rooms/$room/tickets
+Authorization: Bearer $alice
+
+{}
+-- response --
+201
+{"ticket": "$ticket"}
+
+-- connect alice --
+-- send alice --
+{"kind": "request", "type": "hello", "id": "h1", "data": {"roomId": "$room", "ticket": "$ticket", "protocolVersion": "$protocolVersion"}}
+-- receive alice --
+{"kind": "response", "type": "hello", "id": "h1", "data": {
+ "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""},
+ "serverTimeMs": 1,
+ "room": {"term": 1, "seq": 1, "room": {"id": "$room", "name": "大厅", "description": "", "kind": "persistent"},
+ "playMode": "sequential", "nowPlaying": null, "pending": null, "items": [],
+ "members": {"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}], "onlineCount": 1}}}}
+
+-- send alice --
+{"kind": "request", "type": "queue.add", "id": "a1", "data": {"track": {"source": "netease", "id": "404"}}}
+-- receive alice --
+{"kind": "event", "type": "room.activity", "term": 1, "seq": 2, "data": {"kind": "added", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""},
+ "entry": {"id": "1", "track": {"source": "netease", "id": "404"}, "meta": {"title": "不能播放的歌", "artist": "无名", "album": "", "coverUrl": "", "pageUrl": "https://music.163.com/#/song?id=404", "durationMs": 60000},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"},
+ "message": "点了《不能播放的歌》"}}
+-- receive alice --
+{"kind": "event", "type": "queue.updated", "term": 1, "seq": 3, "data": {"items": [],
+ "pending": {"id": "1", "track": {"source": "netease", "id": "404"}, "meta": {"title": "不能播放的歌", "artist": "无名", "album": "", "coverUrl": "", "pageUrl": "https://music.163.com/#/song?id=404", "durationMs": 60000},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"}}}
+-- receive alice --
+{"kind": "response", "type": "queue.add", "id": "a1", "data": {"entry": {"id": "1", "track": {"source": "netease", "id": "404"},
+ "meta": {"title": "不能播放的歌", "artist": "无名", "album": "", "coverUrl": "", "pageUrl": "https://music.163.com/#/song?id=404", "durationMs": 60000},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"}}}
+-- receive alice --
+{"kind": "event", "type": "room.activity", "term": 1, "seq": 4, "data": {"kind": "failed",
+ "entry": {"id": "1", "track": {"source": "netease", "id": "404"}, "meta": {"title": "不能播放的歌", "artist": "无名", "album": "", "coverUrl": "", "pageUrl": "https://music.163.com/#/song?id=404", "durationMs": 60000},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"},
+ "message": "《不能播放的歌》没能准备好:网易云没有提供这首歌的播放地址"}}
+-- receive alice --
+{"kind": "event", "type": "queue.updated", "term": 1, "seq": 5, "data": {"pending": null, "items": []}}
diff --git a/internal/bootstrap/testdata/ws/queue.txtar b/internal/bootstrap/testdata/ws/queue.txtar
new file mode 100644
index 0000000..295aa5d
--- /dev/null
+++ b/internal/bootstrap/testdata/ws/queue.txtar
@@ -0,0 +1,145 @@
+点歌、点赞、删歌、投票切歌和播放模式。网易云的歌先查元数据再入队;按关键词点歌取搜索结果的第一条;
+入队的歌先是准备中,准备好之后开播,正在播放的歌带有音频地址、时长、增益和歌词。回复排在同一批的事件之后。
+
+-- request --
+POST /api/members
+
+{}
+-- response --
+201
+{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}}
+
+-- request --
+POST /api/rooms
+
+{"name": "临时", "description": ""}
+-- response --
+201
+Location: /api/rooms/$room
+{"id": "$room"}
+
+-- request --
+POST /api/rooms/$room/tickets
+Authorization: Bearer $alice
+
+{}
+-- response --
+201
+{"ticket": "$ticket"}
+
+-- connect alice --
+-- send alice --
+{"kind": "request", "type": "hello", "id": "h1", "data": {"roomId": "$room", "ticket": "$ticket", "protocolVersion": "$protocolVersion"}}
+-- receive alice --
+{"kind": "response", "type": "hello", "id": "h1", "data": {
+ "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""},
+ "serverTimeMs": 1,
+ "room": {"term": 1, "seq": 1, "room": {"id": "$room", "name": "临时", "description": "", "kind": "ephemeral"},
+ "playMode": "sequential", "nowPlaying": null, "pending": null, "items": [],
+ "members": {"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}], "onlineCount": 1}}}}
+
+-- send alice --
+{"kind": "request", "type": "queue.add", "id": "a1", "data": {"track": {"source": "netease", "id": "186016"}}}
+-- receive alice --
+{"kind": "event", "type": "room.activity", "term": 1, "seq": 2, "data": {"kind": "added", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""},
+ "entry": {"id": "1", "track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"},
+ "message": "点了《晴天》"}}
+-- receive alice --
+{"kind": "event", "type": "queue.updated", "term": 1, "seq": 3, "data": {"items": [],
+ "pending": {"id": "1", "track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"}}}
+-- receive alice --
+{"kind": "response", "type": "queue.add", "id": "a1", "data": {"entry": {"id": "1", "track": {"source": "netease", "id": "186016"},
+ "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"}}}
+-- receive alice --
+{"kind": "event", "type": "queue.updated", "term": 1, "seq": 4, "data": {"pending": null, "items": []}}
+-- receive alice --
+{"kind": "event", "type": "playback.updated", "term": 1, "seq": 5, "data": {"nowPlaying": {
+ "entry": {"id": "1", "track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269500},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "ready"},
+ "mediaUrl": "/media/netease/186016", "startedAtMs": 1, "durationMs": 269500, "gain": 1, "lyric": "[00:01.00]故事的小黄花"}}}
+
+-- send alice --
+{"kind": "request", "type": "queue.add", "id": "a2", "data": {"source": "netease", "keyword": "七里"}}
+-- receive alice --
+{"kind": "event", "type": "room.activity", "term": 1, "seq": 6, "data": {"kind": "added", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""},
+ "entry": {"id": "2", "track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"},
+ "message": "点了《七里香》"}}
+-- receive alice --
+{"kind": "event", "type": "queue.updated", "term": 1, "seq": 7, "data": {"pending": null, "items": [
+ {"id": "2", "track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"}]}}
+-- receive alice --
+{"kind": "response", "type": "queue.add", "id": "a2", "data": {"entry": {"id": "2", "track": {"source": "netease", "id": "185811"},
+ "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "preparing"}}}
+-- receive alice --
+{"kind": "event", "type": "queue.updated", "term": 1, "seq": 8, "data": {"pending": null, "items": [
+ {"id": "2", "track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "ready"}]}}
+
+-- send alice --
+{"kind": "request", "type": "queue.like", "id": "l1", "data": {"entryId": "2"}}
+-- receive alice --
+{"kind": "event", "type": "room.activity", "term": 1, "seq": 9, "data": {"kind": "liked", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""},
+ "entry": {"id": "2", "track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 1, "status": "ready"},
+ "message": "给《七里香》点了赞,共 1 个赞"}}
+-- receive alice --
+{"kind": "event", "type": "queue.updated", "term": 1, "seq": 10, "data": {"pending": null, "items": [
+ {"id": "2", "track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 1, "status": "ready"}]}}
+-- receive alice --
+{"kind": "response", "type": "queue.like", "id": "l1", "data": {"likes": 1}}
+
+-- send alice --
+{"kind": "request", "type": "queue.remove", "id": "d1", "data": {"entryId": "1"}}
+-- receive alice --
+{"kind": "response", "type": "queue.remove", "id": "d1", "data": {}, "error": {"code": "NOT_FOUND", "message": "这首歌已经不在播放列表里了。"}}
+-- send alice --
+{"kind": "request", "type": "queue.add", "id": "a3", "data": {"track": {"source": "link", "id": "https://www.youtube.com/watch?v=x"}}}
+-- receive alice --
+{"kind": "response", "type": "queue.add", "id": "a3", "data": {}, "error": {"code": "FORBIDDEN", "message": "临时房间只能点哔哩哔哩的链接。"}}
+-- send alice --
+{"kind": "request", "type": "queue.add", "id": "a4", "data": {"track": {"source": "qq", "id": "0039MnYb0qxYhV"}}}
+-- receive alice --
+{"kind": "response", "type": "queue.add", "id": "a4", "data": {}, "error": {"code": "PROVIDER_ERROR", "message": "音乐平台没有正常响应,请稍后再试。"}}
+-- send alice --
+{"kind": "request", "type": "search.tracks", "id": "q1", "data": {"source": "netease", "keyword": "晴天", "page": 1, "pageSize": 10}}
+-- receive alice --
+{"kind": "response", "type": "search.tracks", "id": "q1", "data": {"total": 1, "items": [
+ {"track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000}}]}}
+
+-- send alice --
+{"kind": "request", "type": "playback.voteSkip", "id": "v1", "data": {}}
+-- receive alice --
+{"kind": "event", "type": "room.activity", "term": 1, "seq": 11, "data": {"kind": "skipped", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""},
+ "entry": {"id": "1", "track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269500},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 0, "status": "ready"},
+ "votes": 1, "required": 1, "message": "投票切歌成功,换下一首"}}
+-- receive alice --
+{"kind": "event", "type": "queue.updated", "term": 1, "seq": 12, "data": {"pending": null, "items": []}}
+-- receive alice --
+{"kind": "event", "type": "playback.updated", "term": 1, "seq": 13, "data": {"nowPlaying": {
+ "entry": {"id": "2", "track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000},
+ "by": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "auto": false, "likes": 1, "status": "ready"},
+ "mediaUrl": "/media/netease/185811", "startedAtMs": 1, "durationMs": 299000, "gain": 1, "lyric": ""}}}
+-- receive alice --
+{"kind": "response", "type": "playback.voteSkip", "id": "v1", "data": {"votes": 1, "required": 1, "skipped": true}}
+
+-- send alice --
+{"kind": "request", "type": "settings.setPlayMode", "id": "m1", "data": {"playMode": "random"}}
+-- receive alice --
+{"kind": "event", "type": "settings.updated", "term": 1, "seq": 14, "data": {"playMode": "random"}}
+-- receive alice --
+{"kind": "response", "type": "settings.setPlayMode", "id": "m1", "data": {}}
+
+-- send alice --
+{"kind": "request", "type": "settings.setPlayMode", "id": "m2", "data": {"playMode": "rotation"}}
+-- receive alice --
+{"kind": "event", "type": "settings.updated", "term": 1, "seq": 15, "data": {"playMode": "rotation"}}
+-- receive alice --
+{"kind": "response", "type": "settings.setPlayMode", "id": "m2", "data": {}}
diff --git a/internal/bootstrap/testdata/ws/session.txtar b/internal/bootstrap/testdata/ws/session.txtar
new file mode 100644
index 0000000..535aaaf
--- /dev/null
+++ b/internal/bootstrap/testdata/ws/session.txtar
@@ -0,0 +1,125 @@
+WebSocket 会话:握手、心跳、聊天、快照和在线列表。加入时的在线列表已包含在握手的快照中;
+断开的会话离开房间,房间列表的在线人数随之变化;修改资料后,所在房间广播新的在线列表。
+
+-- request --
+POST /api/members
+
+{}
+-- response --
+201
+{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}}
+
+-- request --
+POST /api/members
+
+{}
+-- response --
+201
+{"memberToken": "$bob", "member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}}
+
+-- request --
+POST /api/rooms
+
+{"name": "临时", "description": "随便听听"}
+-- response --
+201
+Location: /api/rooms/$room
+{"id": "$room"}
+
+-- request --
+POST /api/rooms/$room/tickets
+Authorization: Bearer $alice
+
+{}
+-- response --
+201
+{"ticket": "$aliceTicket"}
+
+-- request --
+POST /api/rooms/$room/tickets
+Authorization: Bearer $bob
+
+{}
+-- response --
+201
+{"ticket": "$bobTicket"}
+
+-- connect alice --
+-- send alice --
+{"kind": "request", "type": "hello", "id": "h1", "data": {"roomId": "$room", "ticket": "$aliceTicket", "protocolVersion": "$protocolVersion"}}
+-- receive alice --
+{"kind": "response", "type": "hello", "id": "h1", "data": {
+ "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""},
+ "serverTimeMs": 1,
+ "room": {"term": 1, "seq": 1, "room": {"id": "$room", "name": "临时", "description": "随便听听", "kind": "ephemeral"},
+ "playMode": "sequential", "nowPlaying": null, "pending": null, "items": [],
+ "members": {"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}], "onlineCount": 1}}}}
+
+-- send alice --
+{"kind": "request", "type": "ping", "id": "p1", "data": {"clientTimeMs": 1234}}
+-- receive alice --
+{"kind": "response", "type": "ping", "id": "p1", "data": {"clientTimeMs": 1234, "serverTimeMs": 1}}
+
+-- connect bob --
+-- send bob --
+{"kind": "request", "type": "hello", "id": "h1", "data": {"roomId": "$room", "ticket": "$bobTicket", "protocolVersion": "$protocolVersion"}}
+-- receive bob --
+{"kind": "response", "type": "hello", "id": "h1", "data": {
+ "member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""},
+ "serverTimeMs": 1,
+ "room": {"term": 1, "seq": 2, "room": {"id": "$room", "name": "临时", "description": "随便听听", "kind": "ephemeral"},
+ "playMode": "sequential", "nowPlaying": null, "pending": null, "items": [],
+ "members": {"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, {"id": "$bobId", "name": "$bobName", "avatarHash": ""}], "onlineCount": 2}}}}
+-- receive alice --
+{"kind": "event", "type": "members.updated", "term": 1, "seq": 2, "data": {
+ "sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, {"id": "$bobId", "name": "$bobName", "avatarHash": ""}], "onlineCount": 2}}
+
+-- request --
+GET /api/rooms/$room
+-- response --
+200
+{"id": "$room", "slug": null, "name": "临时", "description": "随便听听", "kind": "ephemeral", "hasPassword": false, "onlineCount": 2}
+
+-- send alice --
+{"kind": "request", "type": "chat.send", "id": "c1", "data": {"content": "大家好"}}
+-- receive alice --
+{"kind": "event", "type": "chat.message", "term": 1, "seq": 3, "data": {"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "content": "大家好", "sentAtMs": 1}}
+-- receive alice --
+{"kind": "response", "type": "chat.send", "id": "c1", "data": {}}
+-- receive bob --
+{"kind": "event", "type": "chat.message", "term": 1, "seq": 3, "data": {"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "content": "大家好", "sentAtMs": 1}}
+
+-- send bob --
+{"kind": "request", "type": "room.sync", "id": "s1", "data": {}}
+-- receive bob --
+{"kind": "response", "type": "room.sync", "id": "s1", "data": {"room": {"term": 1, "seq": 3, "room": {"id": "$room", "name": "临时", "description": "随便听听", "kind": "ephemeral"},
+ "playMode": "sequential", "nowPlaying": null, "pending": null, "items": [],
+ "members": {"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, {"id": "$bobId", "name": "$bobName", "avatarHash": ""}], "onlineCount": 2}}}}
+
+-- disconnect bob --
+-- receive alice --
+{"kind": "event", "type": "members.updated", "term": 1, "seq": 4, "data": {"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}], "onlineCount": 1}}
+
+-- request --
+PATCH /api/me
+Authorization: Bearer $alice
+
+{"name": "甲"}
+-- response --
+200
+{"member": {"id": "$aliceId", "name": "甲", "avatarHash": ""}}
+-- receive alice --
+{"kind": "event", "type": "members.updated", "term": 1, "seq": 5, "data": {"sessions": [{"id": "$aliceId", "name": "甲", "avatarHash": ""}], "onlineCount": 1}}
+
+-- send alice --
+{"kind": "request", "type": "chat.send", "id": "c2", "data": {"content": "改名了"}}
+-- receive alice --
+{"kind": "event", "type": "chat.message", "term": 1, "seq": 6, "data": {"member": {"id": "$aliceId", "name": "甲", "avatarHash": ""}, "content": "改名了", "sentAtMs": 1}}
+-- receive alice --
+{"kind": "response", "type": "chat.send", "id": "c2", "data": {}}
+
+-- request --
+GET /api/rooms
+-- response --
+200
+{"items": [{"id": "$room", "slug": null, "name": "临时", "description": "随便听听", "kind": "ephemeral", "hasPassword": false, "onlineCount": 1}]}
diff --git a/internal/bootstrap/tracing_test.go b/internal/bootstrap/tracing_test.go
new file mode 100644
index 0000000..cafd698
--- /dev/null
+++ b/internal/bootstrap/tracing_test.go
@@ -0,0 +1,330 @@
+package bootstrap
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "slices"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "go.opentelemetry.io/otel"
+ "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
+ sdktrace "go.opentelemetry.io/otel/sdk/trace"
+ "go.opentelemetry.io/otel/sdk/trace/tracetest"
+ "go.opentelemetry.io/otel/trace"
+)
+
+func TestLoadTracing(t *testing.T) {
+ cases := []struct {
+ name string
+ endpoint *string
+ traces *string
+ want bool
+ wantErr bool
+ }{
+ {name: "未设置时不导出"},
+ {name: "OTLP/HTTP 的基地址", endpoint: ptr("http://otel-collector:4318"), want: true},
+ {name: "基地址带路径前缀", endpoint: ptr("https://otlp.example/otel"), want: true},
+ {name: "只设置完整的追踪地址", traces: ptr("http://otel-collector:4318/v1/traces"), want: true},
+ {name: "完整的追踪地址可以不以 /v1/traces 结尾", traces: ptr("https://otlp.example/traces"), want: true},
+ {name: "两个都设置", endpoint: ptr("http://otel-collector:4318"), traces: ptr("http://tempo:4318/v1/traces"), want: true},
+ {name: "基地址带用户信息", endpoint: ptr("http://user:SECRET@otel-collector:4318"), wantErr: true},
+ {name: "基地址已经以 /v1/traces 结尾", endpoint: ptr("http://otel-collector:4318/v1/traces"), wantErr: true},
+ {name: "基地址以 /v1/traces/ 结尾", endpoint: ptr("http://otel-collector:4318/otel/v1/traces/"), wantErr: true},
+ {name: "追踪地址带用户信息", traces: ptr("http://user:SECRET@otel-collector:4318/v1/traces"), wantErr: true},
+ {name: "基地址有效而追踪地址无效", endpoint: ptr("http://otel-collector:4318"), traces: ptr("tempo:4318"), wantErr: true},
+ {name: "追踪地址有效而基地址无效", endpoint: ptr("otel-collector:4318"), traces: ptr("http://tempo:4318/v1/traces"), wantErr: true},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ for name, value := range map[string]*string{"OTEL_EXPORTER_OTLP_ENDPOINT": tc.endpoint, "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT": tc.traces} {
+ if value == nil {
+ unsetenv(t, name)
+ } else {
+ t.Setenv(name, *value)
+ }
+ }
+ got, err := loadTracing()
+ if tc.wantErr {
+ if err == nil {
+ t.Fatalf("期望报错,实际得到 %v", got)
+ }
+ for _, value := range []*string{tc.endpoint, tc.traces} {
+ if value != nil && (strings.Contains(err.Error(), *value) || strings.Contains(err.Error(), "SECRET")) {
+ t.Errorf("错误 %q 回显了地址", err)
+ }
+ }
+ return
+ }
+ if err != nil || got != tc.want {
+ t.Fatalf("loadTracing() = %v, %v,期望 %v", got, err, tc.want)
+ }
+ })
+ }
+}
+
+func TestTracesAreExportedOverOTLP(t *testing.T) {
+ var mu sync.Mutex
+ var paths []string
+ var bodies [][]byte
+ collector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ mu.Lock()
+ paths = append(paths, r.Method+" "+r.URL.Path+" "+r.Header.Get("Content-Type"))
+ bodies = append(bodies, body)
+ mu.Unlock()
+ }))
+ t.Cleanup(collector.Close)
+ t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", collector.URL+"/otel")
+
+ tracer, flush, err := newTracerProvider(t.Context(), true, "alisten-serve", "实例甲")
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, span := tracer.Tracer("test").Start(t.Context(), "导出的 span")
+ span.End()
+ if err := flush(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+
+ mu.Lock()
+ defer mu.Unlock()
+ if len(paths) != 1 || paths[0] != "POST /otel/v1/traces application/x-protobuf" {
+ t.Fatalf("收到的请求 = %q,期望一次 POST /otel/v1/traces", paths)
+ }
+ for _, want := range []string{"导出的 span", "service.name", "alisten-serve", "service.instance.id", "实例甲", "service.version", version} {
+ if !bytes.Contains(bodies[0], []byte(want)) {
+ t.Errorf("导出的内容中没有 %q", want)
+ }
+ }
+}
+
+func TestNoSpansWithoutExport(t *testing.T) {
+ tracer, flush, err := newTracerProvider(t.Context(), false, "alisten-serve", "实例甲")
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx, span := tracer.Tracer("test").Start(t.Context(), "不导出的 span")
+ span.End()
+ if span.IsRecording() || trace.SpanContextFromContext(ctx).IsValid() {
+ t.Error("不导出时 span 不应记录,也不应有 trace ID")
+ }
+ if err := flush(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestOpenTelemetryLogsAreJSON(t *testing.T) {
+ var buf bytes.Buffer
+ routeOpenTelemetryLogs(newLogger(&buf, slog.LevelInfo).With("component", "otel"))
+ t.Cleanup(func() { routeOpenTelemetryLogs(newLogger(io.Discard, slog.LevelInfo)) })
+
+ otel.Handle(errors.New("导出失败"))
+ // 格式错误的环境变量由导出器自己记一条错误日志。
+ t.Setenv("OTEL_EXPORTER_OTLP_TIMEOUT", "很久")
+ if _, err := otlptracehttp.New(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+
+ lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
+ if len(lines) != 2 {
+ t.Fatalf("期望两行日志,实际:\n%s", buf.String())
+ }
+ handled, logged := decodeLine(t, []byte(lines[0])), decodeLine(t, []byte(lines[1]))
+ if handled["level"] != "WARN" || handled["error"] != "导出失败" || handled["component"] != "otel" {
+ t.Errorf("报告的错误 = %v,期望 WARN 级别、带着错误", handled)
+ }
+ fields, _ := logged["otel"].(map[string]any)
+ if logged["level"] != "ERROR" || logged["component"] != "otel" || fields["input"] != redacted || fields["err"] == nil {
+ t.Errorf("导出器的日志 = %v,期望 ERROR 级别,otel 分组中有错误、变量的值已脱敏", logged)
+ }
+}
+
+// 逐个读取 token:解码成 map 时,后一个值会静默覆盖前一个,看不出重复。
+func duplicateKeys(t *testing.T, line string) []string {
+ t.Helper()
+ type object struct {
+ keys map[string]bool
+ wantKey bool
+ }
+ var stack []*object
+ var duplicates []string
+ valueDone := func() {
+ if n := len(stack); n > 0 && stack[n-1].keys != nil {
+ stack[n-1].wantKey = true
+ }
+ }
+ decoder := json.NewDecoder(strings.NewReader(line))
+ for {
+ token, err := decoder.Token()
+ if err == io.EOF {
+ return duplicates
+ }
+ if err != nil {
+ t.Fatalf("日志不是 JSON:%v\n%s", err, line)
+ }
+ if delim, ok := token.(json.Delim); ok {
+ switch delim {
+ case '{':
+ stack = append(stack, &object{keys: map[string]bool{}, wantKey: true})
+ case '[':
+ stack = append(stack, &object{})
+ default:
+ stack = stack[:len(stack)-1]
+ valueDone()
+ }
+ continue
+ }
+ if n := len(stack); n > 0 && stack[n-1].wantKey {
+ key := token.(string)
+ if stack[n-1].keys[key] {
+ duplicates = append(duplicates, key)
+ }
+ stack[n-1].keys[key] = true
+ stack[n-1].wantKey = false
+ continue
+ }
+ valueDone()
+ }
+}
+
+// debug 级别下 OpenTelemetry 在创建 tracer 时记录的日志带有自己的 version 字段,不能与日志的固定字段 version 重名。
+func TestOpenTelemetryDebugLogsHaveNoDuplicateKeys(t *testing.T) {
+ var buf bytes.Buffer
+ routeOpenTelemetryLogs(newLogger(&buf, slog.LevelDebug).With("component", "otel"))
+ t.Cleanup(func() { routeOpenTelemetryLogs(newLogger(io.Discard, slog.LevelInfo)) })
+ t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://otel-collector:4318")
+
+ tracer, flush, err := newTracerProvider(t.Context(), true, "alisten-serve", "实例甲")
+ if err != nil {
+ t.Fatal(err)
+ }
+ tracer.Tracer("test", trace.WithInstrumentationVersion("9.9.9"))
+ if err := flush(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+
+ var created bool
+ for line := range strings.Lines(strings.TrimSpace(buf.String())) {
+ if duplicates := duplicateKeys(t, line); len(duplicates) > 0 {
+ t.Errorf("日志中重复的键 %q:%s", duplicates, line)
+ }
+ entry := decodeLine(t, []byte(line))
+ if entry["version"] != version {
+ t.Errorf("version = %v,期望固定字段 %q:%s", entry["version"], version, line)
+ }
+ fields, _ := entry["otel"].(map[string]any)
+ if _, ok := fields["version"]; entry["msg"] == "Tracer created" && ok {
+ created = true
+ }
+ }
+ if !created {
+ t.Errorf("日志中没有创建 tracer 的记录,其 version 应在 otel 分组中:\n%s", buf.String())
+ }
+}
+
+// 导出器把写错的环境变量的原值记进自己的日志。把请求头写成 "Authorization: Bearer …" 这种常见的错误格式时,
+// 追踪后端的凭证会以 ERROR 级别进入日志。
+func TestOpenTelemetryLogsDoNotEchoMalformedHeaders(t *testing.T) {
+ var buf bytes.Buffer
+ routeOpenTelemetryLogs(newLogger(&buf, slog.LevelInfo).With("component", "otel"))
+ t.Cleanup(func() { routeOpenTelemetryLogs(newLogger(io.Discard, slog.LevelInfo)) })
+ t.Setenv("OTEL_EXPORTER_OTLP_HEADERS", "Authorization Bearer SUPERSECRETTOKEN,x-api-key=abc%zzSECRET2")
+
+ if _, err := otlptracehttp.New(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(buf.String(), "parse headers") || !strings.Contains(buf.String(), "escape header value") {
+ t.Fatalf("导出器应记录两条错误日志,实际:\n%s", buf.String())
+ }
+ for _, secret := range []string{"SUPERSECRETTOKEN", "SECRET2"} {
+ if strings.Contains(buf.String(), secret) {
+ t.Errorf("日志中有请求头的原值 %s:\n%s", secret, buf.String())
+ }
+ }
+}
+
+func TestExitLogFollowsTraceFlush(t *testing.T) {
+ failed := errors.New("子命令运行失败")
+ cases := []struct {
+ name string
+ flush error
+ err error
+ expect []string
+ }{
+ {name: "正常退出", expect: []string{"服务已退出"}},
+ {name: "导出失败的告警排在退出日志前面", flush: context.DeadlineExceeded, expect: []string{"导出剩下的追踪失败", "服务已退出"}},
+ {name: "出错退出没有退出日志", flush: context.DeadlineExceeded, err: failed, expect: []string{"导出剩下的追踪失败"}},
+ {name: "出错退出而导出成功", err: failed},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ var buf bytes.Buffer
+ exitAfterFlush(tc.err, func(context.Context) error { return tc.flush }, newLogger(&buf, slog.LevelInfo), "服务已退出")
+ var got []string
+ for line := range strings.Lines(strings.TrimSpace(buf.String())) {
+ got = append(got, decodeLine(t, []byte(line))["msg"].(string))
+ }
+ if !slices.Equal(got, tc.expect) {
+ t.Errorf("日志依次为 %q,期望 %q", got, tc.expect)
+ }
+ })
+ }
+}
+
+// HTTP 和 WebSocket 请求的 span 在回复发出之后才结束,所以要等。
+func awaitSpan(t *testing.T, spans *tracetest.SpanRecorder, name string, traceID trace.TraceID) sdktrace.ReadOnlySpan {
+ t.Helper()
+ for deadline := time.Now().Add(messageWait); ; time.Sleep(10 * time.Millisecond) {
+ for _, span := range spans.Ended() {
+ if span.Name() == name && (!traceID.IsValid() || span.SpanContext().TraceID() == traceID) {
+ return span
+ }
+ }
+ if time.Now().After(deadline) {
+ t.Fatalf("没有名为 %q 的 span", name)
+ }
+ }
+}
+
+func TestHTTPRequestsStartTraces(t *testing.T) {
+ contractEnv(t, startPostgres(t)(t), startValkey(t)(t))
+ svc := startService(t)
+ hall := createPersistentRoom(t, svc, "大厅")
+ _, ticket := enterRoom(t, svc, hall)
+ connect(t, svc, hall, ticket)
+ openStream(t, svc.server, "Authorization: Bearer "+ticket)
+ const clientTrace = "4bf92f3577b34da6a3ce929d0e0e4736"
+ if line, status, _, body := send(t, svc.server, "GET /api/rooms\ntraceparent: 00-"+clientTrace+"-00f067aa0ba902b7-01"); status != http.StatusOK {
+ t.Fatalf("%s = %d %s", line, status, body)
+ }
+
+ traces := map[trace.TraceID]string{}
+ for _, name := range []string{"POST /api/admin/rooms", "POST /api/members", "POST /api/rooms/{id}/tickets", "WS hello", "GET /api/rooms"} {
+ span := awaitSpan(t, svc.spans, name, trace.TraceID{})
+ if span.SpanKind() != trace.SpanKindServer || span.Parent().IsValid() {
+ t.Errorf("%s 的类型 = %v、父 span = %v,期望没有父 span 的 server span", name, span.SpanKind(), span.Parent())
+ }
+ if other, ok := traces[span.SpanContext().TraceID()]; ok {
+ t.Errorf("%s 与 %s 在同一条 trace 中", name, other)
+ }
+ traces[span.SpanContext().TraceID()] = name
+ }
+ if got := awaitSpan(t, svc.spans, "GET /api/rooms", trace.TraceID{}).SpanContext().TraceID().String(); got == clientTrace {
+ t.Error("GET /api/rooms 沿用了客户端带来的 trace")
+ }
+ // 升级请求和事件流的 span 要到连接断开才结束,所以看开始的 span,而不是结束的。
+ for _, span := range svc.spans.Started() {
+ if path := attributes(span)["url.path"]; path == "/ws" || path == "/api/room/events" {
+ t.Errorf("%s 的请求开始了 span %s", path, span.Name())
+ }
+ }
+}
diff --git a/internal/bootstrap/worker_test.go b/internal/bootstrap/worker_test.go
new file mode 100644
index 0000000..9a4640a
--- /dev/null
+++ b/internal/bootstrap/worker_test.go
@@ -0,0 +1,609 @@
+package bootstrap
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "encoding/json"
+ "errors"
+ "io"
+ "log/slog"
+ "net"
+ "net/http"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "sync"
+ "syscall"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+ "github.com/prometheus/client_golang/prometheus"
+ goredis "github.com/redis/go-redis/v9"
+ "github.com/riverqueue/river"
+ "github.com/riverqueue/river/riverdriver/riverpgxv5"
+ sdktrace "go.opentelemetry.io/otel/sdk/trace"
+ "go.opentelemetry.io/otel/sdk/trace/tracetest"
+
+ "github.com/dihemoe/alisten/internal/adapter/out/queue"
+ "github.com/dihemoe/alisten/internal/app/media"
+ "github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/app/port/porttest"
+ "github.com/dihemoe/alisten/internal/domain/track"
+ "github.com/dihemoe/alisten/internal/lib/loudness"
+)
+
+type links struct {
+ mu sync.Mutex
+ done []string
+}
+
+func (l *links) Download(_ context.Context, link string) (port.LinkDownload, func(), error) {
+ l.mu.Lock()
+ l.done = append(l.done, link)
+ l.mu.Unlock()
+ d := port.LinkDownload{
+ Meta: track.Meta{Title: "晴天", Artist: "周杰伦"},
+ Audio: porttest.LinkFile(porttest.SilentWAV(2 * time.Second)),
+ Cover: porttest.LinkFile("jpeg"),
+ CoverExt: "jpg",
+ }
+ return d, func() {}, nil
+}
+
+func (l *links) downloads() int {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return len(l.done)
+}
+
+type runningWorker struct {
+ worker mediaWorker
+ ops string
+ logs *bytes.Buffer
+ spans *tracetest.SpanRecorder
+ stop context.CancelFunc
+ result func() error
+}
+
+// 只准备链接的测试用不到 B 站的音源和转码。
+func startWorker(t *testing.T, download port.LinkDownloader) runningWorker {
+ t.Helper()
+ return startMediaWorker(t, workerSources{links: download})
+}
+
+func startMediaWorker(t *testing.T, src workerSources) runningWorker {
+ t.Helper()
+ cfg, err := loadWorkerConfig()
+ if err != nil {
+ t.Fatal(err)
+ }
+ spans := tracetest.NewSpanRecorder()
+ tracer := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spans))
+ traceDatabase(cfg.database, tracer)
+ pool, err := pgxpool.NewWithConfig(t.Context(), cfg.database)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(pool.Close)
+ rdb := goredis.NewClient(cfg.redis)
+ t.Cleanup(func() { _ = rdb.Close() })
+ traceRedis(rdb, tracer)
+ var logs bytes.Buffer
+ metrics := prometheus.NewRegistry()
+ w, err := newMediaWorker(cfg, pool, rdb, src, metrics, newWorkerMetrics(metrics), rand.Text(), tracer, newLogger(&logs, slog.LevelInfo))
+ if err != nil {
+ t.Fatal(err)
+ }
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx, stop := context.WithCancel(context.Background())
+ done := make(chan error, 1)
+ go func() { done <- w.run(ctx, listener) }()
+ running := runningWorker{worker: w, ops: "http://" + listener.Addr().String(), logs: &logs, spans: spans, stop: stop, result: sync.OnceValue(func() error { return <-done })}
+ t.Cleanup(func() {
+ stop()
+ _ = running.result()
+ })
+ return running
+}
+
+func (w runningWorker) readyz(t *testing.T) int {
+ t.Helper()
+ req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, w.ops+"/readyz", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = resp.Body.Close()
+ return resp.StatusCode
+}
+
+func (w runningWorker) waitReady(t *testing.T) {
+ t.Helper()
+ for deadline := time.Now().Add(2*readinessInterval + time.Second); w.readyz(t) != http.StatusOK; time.Sleep(100 * time.Millisecond) {
+ if time.Now().After(deadline) {
+ t.Fatalf("worker 没有就绪:\n%s", w.logs.String())
+ }
+ }
+}
+
+func (c wsClient) readUntil(t *testing.T, match func(map[string]any) bool) map[string]any {
+ t.Helper()
+ for {
+ if m := c.read(t); match(m) {
+ return m
+ }
+ }
+}
+
+func playing(m map[string]any) bool {
+ if m["type"] != "playback.updated" {
+ return false
+ }
+ return m["data"].(map[string]any)["nowPlaying"] != nil
+}
+
+func anonymousGet(t *testing.T, url string) (int, string) {
+ t.Helper()
+ req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return resp.StatusCode, string(body)
+}
+
+func TestWorkerPreparesLinks(t *testing.T) {
+ contractEnv(t, startPostgres(t)(t), startValkey(t)(t))
+ objects, err := loadStorageConfig()
+ if err != nil {
+ t.Fatal(err)
+ }
+ storageURL := objects.Endpoint + "/" + objects.Bucket
+ t.Setenv("STORAGE_PUBLIC_URL", storageURL)
+ svc := startService(t)
+ downloads := &links{}
+ w := startWorker(t, downloads)
+ w.waitReady(t)
+
+ hall := createPersistentRoom(t, svc, "大厅")
+ _, ticket := enterRoom(t, svc, hall)
+ client, _ := connect(t, svc, hall, ticket)
+ const link = "https://example.com/qingtian.mp3"
+ key := media.Key(link)
+ addLink := `{"kind":"request","type":"queue.add","id":"a1","data":{"track":{"source":"link","id":"` + link + `"}}}`
+ client.write(t, addLink)
+ now := client.readUntil(t, playing)["data"].(map[string]any)["nowPlaying"].(map[string]any)
+ entryMeta := now["entry"].(map[string]any)["meta"].(map[string]any)
+ if now["mediaUrl"] != storageURL+"/link/"+key+"/audio.mp3" || now["durationMs"] != float64(2000) ||
+ entryMeta["title"] != "晴天" || entryMeta["coverUrl"] != storageURL+"/link/"+key+"/cover.jpg" || entryMeta["pageUrl"] != link {
+ t.Fatalf("开始播放的链接 = %v", now)
+ }
+ if status, body := anonymousGet(t, now["mediaUrl"].(string)); status != http.StatusOK || body != string(porttest.SilentWAV(2*time.Second)) {
+ t.Errorf("匿名读取音频 = %d,期望读到上传的音频", status)
+ }
+ if status, body := anonymousGet(t, entryMeta["coverUrl"].(string)); status != http.StatusOK || body != "jpeg" {
+ t.Errorf("匿名读取封面 = %d %q", status, body)
+ }
+
+ admin := "Authorization: Bearer admin-secret"
+ if line, status, _, body := send(t, svc.server, "GET /api/admin/media\n"+admin); status != http.StatusOK || !strings.Contains(string(body), `"key":"`+key+`"`) {
+ t.Errorf("%s = %d %s,期望列出这首链接", line, status, body)
+ }
+ if line, status, _, body := send(t, svc.server, "DELETE /api/admin/media/"+key+"\n"+admin); status != http.StatusConflict {
+ t.Errorf("%s = %d %s,期望正在播放的链接不能删除", line, status, body)
+ }
+
+ client.write(t, `{"kind":"request","type":"playback.voteSkip","id":"v1","data":{}}`)
+ client.readUntil(t, func(m map[string]any) bool { return m["type"] == "playback.voteSkip" })
+ // 切歌之后要等落盘完成,链接曲目才不再被房间引用。
+ for deadline := time.Now().Add(messageWait); ; time.Sleep(100 * time.Millisecond) {
+ _, status, _, _ := send(t, svc.server, "DELETE /api/admin/media/"+key+"\n"+admin)
+ if status == http.StatusNoContent {
+ break
+ }
+ if time.Now().After(deadline) {
+ t.Fatalf("切歌之后删除媒体 = %d", status)
+ }
+ }
+ if status, _ := anonymousGet(t, now["mediaUrl"].(string)); status == http.StatusOK {
+ t.Error("删除之后仍能读到音频")
+ }
+ if _, status, _, body := send(t, svc.server, "GET /api/admin/media\n"+admin); status != http.StatusOK || strings.TrimSpace(string(body)) != `{"items":[]}` {
+ t.Errorf("删除之后的列表 = %d %s", status, body)
+ }
+
+ client.write(t, strings.Replace(addLink, `"a1"`, `"a2"`, 1))
+ client.readUntil(t, playing)
+ if n := downloads.downloads(); n != 2 {
+ t.Errorf("下载了 %d 次,期望删除之后再点会重新下载", n)
+ }
+ if got := metricValue(t, w.worker.metrics, "alisten_prepare_jobs_total", "source", "link", "result", "ok"); got != 2 {
+ t.Errorf("alisten_prepare_jobs_total{link,ok} = %v,期望 2", got)
+ }
+ for _, name := range []string{"alisten_ready", "alisten_ytdlp_jobs_waiting", "alisten_ytdlp_download_duration_seconds"} {
+ metricValue(t, w.worker.metrics, name)
+ }
+
+ w.stop()
+ if err := w.result(); err != nil || !strings.Contains(w.logs.String(), "已停止执行准备任务") {
+ t.Errorf("worker 退出 = %v,日志:\n%s", err, w.logs.String())
+ }
+}
+
+func TestWorkerPreparesBilibili(t *testing.T) {
+ contractEnv(t, startPostgres(t)(t), startValkey(t)(t))
+ objects, err := loadStorageConfig()
+ if err != nil {
+ t.Fatal(err)
+ }
+ storageURL := objects.Endpoint + "/" + objects.Bucket
+ t.Setenv("STORAGE_PUBLIC_URL", storageURL)
+ svc := startService(t)
+ // serve 和 worker 各自构造音源:serve 的那份照常给出付费视频的元数据,worker 的这份拒绝下载它,与真实的平台一样。
+ bilibili := contractBilibili()
+ bilibili.RejectVideo("BV1GJ411x7h7", "这个视频需要付费")
+ transcoder := porttest.NewTranscoder(porttest.SilentWAV(2 * time.Second))
+ w := startMediaWorker(t, workerSources{links: &links{}, bilibili: bilibili, transcoder: transcoder})
+ w.waitReady(t)
+
+ line, status, _, body := send(t, svc.server, "POST /api/rooms\n\n{\"name\": \"临时\", \"description\": \"\"}")
+ var created struct{ ID string }
+ if err := json.Unmarshal(body, &created); err != nil || status != http.StatusCreated {
+ t.Fatalf("%s = %d %s", line, status, body)
+ }
+ _, ticket := enterRoom(t, svc, created.ID)
+ client, _ := connect(t, svc, created.ID, ticket)
+ client.write(t, `{"kind":"request","type":"queue.add","id":"a1","data":{"track":{"source":"link","id":"https://b23.tv/qingtian"}}}`)
+ now := client.readUntil(t, playing)["data"].(map[string]any)["nowPlaying"].(map[string]any)
+ entry := now["entry"].(map[string]any)
+ meta := entry["meta"].(map[string]any)
+ const id = "BV1xx411c7mD/2"
+ key := media.Key(id)
+ if tr := entry["track"].(map[string]any); tr["source"] != "bilibili" || tr["id"] != id {
+ t.Fatalf("开始播放的曲目 = %v,期望 B 站的 %s", tr, id)
+ }
+ if now["mediaUrl"] != storageURL+"/link/"+key+"/audio.mp3" || now["durationMs"] != float64(2000) ||
+ meta["coverUrl"] != storageURL+"/link/"+key+"/cover.jpg" || meta["pageUrl"] != "https://www.bilibili.com/video/BV1xx411c7mD?p=2" || meta["title"] != "晴天(钢琴版)" {
+ t.Fatalf("开始播放的 B 站的歌 = %v", now)
+ }
+ if status, body := anonymousGet(t, now["mediaUrl"].(string)); status != http.StatusOK || body != string(porttest.SilentWAV(2*time.Second)) {
+ t.Errorf("匿名读取音频 = %d,期望读到转出的 mp3", status)
+ }
+ if status, body := anonymousGet(t, meta["coverUrl"].(string)); status != http.StatusOK || body != "jpeg" {
+ t.Errorf("匿名读取封面 = %d %q", status, body)
+ }
+ if inputs := transcoder.Inputs(); len(inputs) != 1 || string(inputs[0]) != "平台给的原始音频" {
+ t.Errorf("转码的输入 = %q,期望下载到的原始音频", inputs)
+ }
+ admin := "Authorization: Bearer admin-secret"
+ if line, status, _, body := send(t, svc.server, "GET /api/admin/media\n"+admin); status != http.StatusOK || !strings.Contains(string(body), `"key":"`+key+`"`) ||
+ !strings.Contains(string(body), `"source":"bilibili"`) || !strings.Contains(string(body), `"link":"https://www.bilibili.com/video/BV1xx411c7mD?p=2"`) {
+ t.Errorf("%s = %d %s,期望列出这首 B 站的歌", line, status, body)
+ }
+
+ client.write(t, `{"kind":"request","type":"queue.add","id":"a2","data":{"track":{"source":"link","id":"https://www.bilibili.com/video/BV1GJ411x7h7"}}}`)
+ failed := client.readUntil(t, func(m map[string]any) bool {
+ return m["type"] == "room.activity" && m["data"].(map[string]any)["kind"] == "failed"
+ })
+ if msg := failed["data"].(map[string]any)["message"]; msg != "《付费视频》没能准备好:这个视频需要付费" {
+ t.Errorf("音源拒绝下载时的动态 = %v,期望带着音源给出的原因", msg)
+ }
+ for result, want := range map[string]float64{"ok": 1, "rejected": 1} {
+ if got := metricValue(t, w.worker.metrics, "alisten_prepare_jobs_total", "source", "bilibili", "result", result); got != want {
+ t.Errorf("alisten_prepare_jobs_total{bilibili,%s} = %v,期望 %v", result, got, want)
+ }
+ }
+}
+
+func TestLinkCaseVariantsAreOneTrack(t *testing.T) {
+ contractEnv(t, startPostgres(t)(t), startValkey(t)(t))
+ svc := startService(t)
+ downloads := &links{}
+ w := startWorker(t, downloads)
+ w.waitReady(t)
+
+ hall := createPersistentRoom(t, svc, "大厅")
+ _, ticket := enterRoom(t, svc, hall)
+ client, _ := connect(t, svc, hall, ticket)
+ const link = "https://example.com/qingtian.mp3"
+ add := func(id, variant string) {
+ t.Helper()
+ client.write(t, `{"kind":"request","type":"queue.add","id":"`+id+`","data":{"track":{"source":"link","id":"`+variant+`"}}}`)
+ if reply := client.readUntil(t, func(m map[string]any) bool { return m["kind"] == "response" && m["id"] == id }); reply["error"] != nil {
+ t.Fatalf("点 %s 的响应 = %v", variant, reply)
+ }
+ }
+ add("a1", "HTTPS://Example.COM/qingtian.mp3")
+ now := client.readUntil(t, playing)["data"].(map[string]any)["nowPlaying"].(map[string]any)
+ if id := now["entry"].(map[string]any)["track"].(map[string]any)["id"]; id != link || now["mediaUrl"] != "https://alisten.example/storage/alisten/link/"+media.Key(link)+"/audio.mp3" {
+ t.Fatalf("开始播放的曲目 = %v,期望曲目 ID 是 %s", now, link)
+ }
+ add("a2", "hTTps://EXAMPLE.com/qingtian.mp3")
+ queued := client.readUntil(t, func(m map[string]any) bool {
+ if m["type"] != "queue.updated" {
+ return false
+ }
+ items := m["data"].(map[string]any)["items"].([]any)
+ return len(items) == 1 && items[0].(map[string]any)["status"] == "ready"
+ })
+ item := queued["data"].(map[string]any)["items"].([]any)[0].(map[string]any)
+ if id := item["track"].(map[string]any)["id"]; id != link {
+ t.Errorf("等待队列中的曲目 ID = %v,期望 %s", id, link)
+ }
+ if n := downloads.downloads(); n != 1 {
+ t.Errorf("下载了 %d 次,期望两种写法共用一次下载", n)
+ }
+ if line, status, _, body := send(t, svc.server, "GET /api/admin/media\nAuthorization: Bearer admin-secret"); status != http.StatusOK || strings.Count(string(body), `"key"`) != 1 || !strings.Contains(string(body), `"key":"`+media.Key(link)+`"`) {
+ t.Errorf("%s = %d %s,期望只存了一份", line, status, body)
+ }
+}
+
+type toneLinks struct{}
+
+func (toneLinks) Download(context.Context, string) (port.LinkDownload, func(), error) {
+ return port.LinkDownload{Meta: track.Meta{Title: "正弦波"}, Audio: porttest.LinkFile(porttest.ToneWAV(3*time.Second, 0.5))}, func() {}, nil
+}
+
+func TestPreparedGainIsStored(t *testing.T) {
+ contractEnv(t, startPostgres(t)(t), startValkey(t)(t))
+ svc := startService(t)
+ w := startWorker(t, toneLinks{})
+ w.waitReady(t)
+ analyzed, err := loudness.AnalyzeReader(bytes.NewReader(porttest.ToneWAV(3*time.Second, 0.5)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ gain := analyzed.Coefficient()
+ if gain > 0.5 {
+ t.Fatalf("正弦波的增益 = %v,期望明显小于 1", gain)
+ }
+
+ hall := createPersistentRoom(t, svc, "大厅")
+ _, ticket := enterRoom(t, svc, hall)
+ client, _ := connect(t, svc, hall, ticket)
+ const link = "https://example.com/tone.wav"
+ client.write(t, `{"kind":"request","type":"queue.add","id":"a1","data":{"track":{"source":"link","id":"`+link+`"}}}`)
+ now := client.readUntil(t, playing)["data"].(map[string]any)["nowPlaying"].(map[string]any)
+ if now["gain"] != gain {
+ t.Errorf("开始播放时的增益 = %v,期望 %v", now["gain"], gain)
+ }
+ cfg, err := loadDatabaseConfig()
+ if err != nil {
+ t.Fatal(err)
+ }
+ pool, err := pgxpool.NewWithConfig(t.Context(), cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(pool.Close)
+ var stored float64
+ if err := pool.QueryRow(t.Context(), "SELECT gain FROM tracks WHERE source = 'link' AND track_id = $1", link).Scan(&stored); err != nil {
+ t.Fatal(err)
+ }
+ if stored != gain {
+ t.Errorf("曲目表中的增益 = %v,期望 %v", stored, gain)
+ }
+}
+
+func submitLink(t *testing.T, link string) {
+ t.Helper()
+ cfg, err := loadDatabaseConfig()
+ if err != nil {
+ t.Fatal(err)
+ }
+ pool, err := pgxpool.NewWithConfig(t.Context(), cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(pool.Close)
+ inserter, err := river.NewClient(riverpgxv5.New(pool), &river.Config{Logger: slog.New(slog.DiscardHandler)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := queue.New(inserter).Submit(t.Context(), track.Track{Source: track.Link, ID: link}); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestWorkerWaitsForMigrations(t *testing.T) {
+ contractEnv(t, startPostgres(t)(t), startValkey(t)(t))
+ downloads := &links{}
+ w := startWorker(t, downloads)
+ time.Sleep(time.Second)
+ if status := w.readyz(t); status != http.StatusServiceUnavailable {
+ t.Fatalf("迁移之前 /readyz = %d,期望 503", status)
+ }
+ if err := runMigrate(newLogger(io.Discard, slog.LevelInfo)); err != nil {
+ t.Fatal(err)
+ }
+ w.waitReady(t)
+
+ submitLink(t, "https://example.com/a.mp3")
+ for deadline := time.Now().Add(messageWait); downloads.downloads() == 0; time.Sleep(100 * time.Millisecond) {
+ if time.Now().After(deadline) {
+ t.Fatalf("迁移完成之后 worker 没有执行任务:\n%s", w.logs.String())
+ }
+ }
+}
+
+type blockedLinks struct {
+ entered chan struct{}
+ release chan struct{}
+ once sync.Once
+}
+
+func (b *blockedLinks) Download(context.Context, string) (port.LinkDownload, func(), error) {
+ b.once.Do(func() { close(b.entered) })
+ <-b.release
+ return port.LinkDownload{}, nil, errors.New("测试结束")
+}
+
+// 运行中的任务停不下来时,运维端口在停止 River client 期间仍然可以访问,探针据此摘除 worker。
+func TestWorkerIsNotReadyWhileStopping(t *testing.T) {
+ contractEnv(t, startPostgres(t)(t), startValkey(t)(t))
+ if err := runMigrate(newLogger(io.Discard, slog.LevelInfo)); err != nil {
+ t.Fatal(err)
+ }
+ blocked := &blockedLinks{entered: make(chan struct{}), release: make(chan struct{})}
+ w := startWorker(t, blocked)
+ // Cleanup 按注册的逆序执行:先放行任务,startWorker 注册的清理才能等到 worker 退出。
+ release := sync.OnceFunc(func() { close(blocked.release) })
+ t.Cleanup(release)
+ w.waitReady(t)
+ submitLink(t, "https://example.com/a.mp3")
+ select {
+ case <-blocked.entered:
+ case <-time.After(messageWait):
+ t.Fatalf("worker 没有开始执行任务:\n%s", w.logs.String())
+ }
+
+ w.stop()
+ for deadline := time.Now().Add(messageWait); w.readyz(t) != http.StatusServiceUnavailable; time.Sleep(50 * time.Millisecond) {
+ if time.Now().After(deadline) {
+ t.Fatal("退出开始之后 /readyz 仍然返回 200")
+ }
+ }
+ if got := metricValue(t, w.worker.metrics, "alisten_ready"); got != 0 {
+ t.Errorf("alisten_ready = %v,期望退出开始之后为 0", got)
+ }
+ release()
+ if err := w.result(); err != nil {
+ t.Errorf("worker 退出 = %v", err)
+ }
+}
+
+func TestWorkerExitsWhenJobsStop(t *testing.T) {
+ contractEnv(t, startPostgres(t)(t), startValkey(t)(t))
+ if err := runMigrate(newLogger(io.Discard, slog.LevelInfo)); err != nil {
+ t.Fatal(err)
+ }
+ w := startWorker(t, &links{})
+ w.waitReady(t)
+ if err := w.worker.jobs.Stop(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ exited := make(chan error, 1)
+ go func() { exited <- w.result() }()
+ select {
+ case err := <-exited:
+ if err == nil || !strings.Contains(err.Error(), "River client 意外停止") {
+ t.Errorf("worker 退出 = %v,期望 River client 意外停止", err)
+ }
+ case <-time.After(messageWait):
+ t.Fatal("River client 停止之后 worker 没有退出")
+ }
+}
+
+func TestWorkerExitsAfterFlushingTraces(t *testing.T) {
+ if os.Geteuid() == 0 {
+ t.Skip("worker 子命令拒绝以 root 运行")
+ }
+ contractEnv(t, startPostgres(t)(t), startValkey(t)(t))
+ // worker 启动时会清理 TMPDIR 下自己的目录,不能影响本机上正在运行的 worker。
+ t.Setenv("TMPDIR", t.TempDir())
+ opsListen := freeAddr(t)
+ t.Setenv("ALISTEN_OPS_LISTEN", opsListen)
+ unresponsiveCollector(t)
+ if err := runMigrate(newLogger(io.Discard, slog.LevelInfo)); err != nil {
+ t.Fatal(err)
+ }
+
+ var logs lockedBuffer
+ var runErr error
+ finished := make(chan struct{})
+ go func() {
+ defer close(finished)
+ runErr = runWorker(newLogger(&logs, slog.LevelInfo))
+ }()
+ eventually(t, "/readyz 返回 200", finished, func() bool { return readyStatus(opsListen) == http.StatusOK })
+ // 执行任务的 span 是 worker 记下的唯一一个 span。任务执行一次就失败,span 随之结束:回环地址会被 yt-dlp 的代理拒绝;
+ // 本机没有 yt-dlp 的话,任务在启动 yt-dlp 这一步就失败。
+ cfg, err := loadDatabaseConfig()
+ if err != nil {
+ t.Fatal(err)
+ }
+ pool, err := pgxpool.NewWithConfig(t.Context(), cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(pool.Close)
+ submitLink(t, "http://127.0.0.1:1/qingtian.mp3")
+ eventually(t, "任务执行过一次", finished, func() bool {
+ var n int
+ if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM river_job WHERE queue = 'ytdlp' AND state IN ('retryable', 'discarded')").Scan(&n); err != nil {
+ t.Fatal(err)
+ }
+ return n > 0
+ })
+ if err := syscall.Kill(syscall.Getpid(), syscall.SIGTERM); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case <-finished:
+ case <-time.After(time.Minute):
+ t.Fatal("收到 SIGTERM 一分钟之后 runWorker 仍没有返回")
+ }
+ if runErr != nil {
+ t.Errorf("收到 SIGTERM 之后 runWorker 返回 %v,期望 nil", runErr)
+ }
+ want := []string{"已停止执行准备任务", "导出剩下的追踪失败", "worker 已退出"}
+ var got []string
+ for _, msg := range logs.messages(t) {
+ if slices.Contains(want, msg) {
+ got = append(got, msg)
+ }
+ }
+ if !slices.Equal(got, want) {
+ t.Errorf("退出的日志依次为 %q,期望 %q", got, want)
+ }
+}
+
+func TestWorkerSweepsOwnLeftoverTempDirsAtStart(t *testing.T) {
+ tmp := t.TempDir()
+ t.Setenv("TMPDIR", tmp)
+ host, err := os.Hostname()
+ if err != nil {
+ t.Fatal(err)
+ }
+ own := filepath.Join(tmp, "alisten-link-"+host, "job-1")
+ others := filepath.Join(tmp, "alisten-link-another-container", "job-2")
+ for _, dir := range []string{own, others} {
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "audio.mp3"), []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ if _, err := prepareWorkDir(); err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := os.Stat(own); !errors.Is(err, os.ErrNotExist) {
+ t.Errorf("worker 自己上一次运行留下的目录 %s 还在:%v", own, err)
+ }
+ if _, err := os.Stat(others); err != nil {
+ t.Errorf("其他容器的目录 %s 被动了:%v", others, err)
+ }
+ if info, err := os.Stat(filepath.Dir(own)); err != nil || !info.IsDir() {
+ t.Errorf("worker 自己的目录 %s 应当存在,任务在这里建临时目录:%v", filepath.Dir(own), err)
+ }
+}
diff --git a/internal/domain/room/autodj_test.go b/internal/domain/room/autodj_test.go
new file mode 100644
index 0000000..8b5f59d
--- /dev/null
+++ b/internal/domain/room/autodj_test.go
@@ -0,0 +1,146 @@
+package room
+
+import (
+ "slices"
+ "testing"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+func listenedRoom(t *testing.T) *Room {
+ t.Helper()
+ r := newRoom(t, Persistent)
+ r.Handle(at(0), Join{Session: "s1", Member: alice})
+ return r
+}
+
+func TestAutoDJAddIsNotAPick(t *testing.T) {
+ r := listenedRoom(t)
+ r.Handle(at(0), enqueue(alice, netease("a")))
+ rec := find[Recommend](r.Handle(at(20*time.Second), Prepared{Track: netease("a"), Result: ready(3 * time.Minute)}))
+ if len(rec) != 1 {
+ t.Fatal("没有请求推荐")
+ }
+ r.Handle(at(21*time.Second), Recommended{Token: rec[0].Token, Track: netease("b")})
+ r.Handle(at(22*time.Second), Prepared{Track: netease("b"), Result: ready(time.Minute)})
+
+ effects := r.Handle(at(25*time.Second), command(alice, VoteSkip{}))
+ if r.current == nil || r.current.Entry.Track != netease("b") || len(find[Recommend](effects)) != 1 {
+ t.Fatalf("AutoDJ 加入的歌不算有人点歌,开播时应继续请求推荐:%#v", effects)
+ }
+}
+
+func TestAutoDJIsOneRequester(t *testing.T) {
+ r := listenedRoom(t)
+ r.Handle(at(0), command(alice, SetPlayMode{Mode: Rotation}))
+ r.Handle(at(0), enqueue(alice, netease("a")))
+ rec := find[Recommend](r.Handle(at(20*time.Second), Prepared{Track: netease("a"), Result: ready(3 * time.Minute)}))
+ if len(rec) != 1 {
+ t.Fatal("没有请求推荐")
+ }
+ r.Handle(at(21*time.Second), Recommended{Token: rec[0].Token, Track: netease("b")})
+ r.Handle(at(22*time.Second), Prepared{Track: netease("b"), Result: ready(time.Minute)})
+ rec = find[Recommend](r.Handle(at(25*time.Second), command(alice, VoteSkip{})))
+ if len(rec) != 1 {
+ t.Fatal("AutoDJ 的歌开播时没有请求推荐")
+ }
+ r.Handle(at(26*time.Second), Recommended{Token: rec[0].Token, Track: netease("c")})
+ r.Handle(at(27*time.Second), enqueue(bob, netease("d")))
+ if got := ids(r.waiting); !slices.Equal(got, []EntryID{4, 3}) {
+ t.Fatalf("上一首推荐还在播放,下一首推荐应排在乙这一轮的歌后面:%v", got)
+ }
+}
+
+func TestAutoDJDoesNotRecommend(t *testing.T) {
+ qq := track.Track{Source: track.QQ, ID: "a"}
+ cases := []struct {
+ name string
+ start func(r *Room) []Effect
+ }{
+ {"10 秒内有人点歌", func(r *Room) []Effect {
+ r.Handle(at(0), enqueue(alice, netease("a")))
+ return r.Handle(at(5*time.Second), Prepared{Track: netease("a"), Result: ready(time.Minute)})
+ }},
+ {"没有会话", func(r *Room) []Effect {
+ r.Handle(at(0), Leave{Session: "s1"})
+ r.Handle(at(0), enqueue(alice, netease("a")))
+ return r.Handle(at(time.Minute), Prepared{Track: netease("a"), Result: ready(time.Minute)})
+ }},
+ {"不是网易云的歌", func(r *Room) []Effect {
+ r.Handle(at(0), enqueue(alice, qq))
+ return r.Handle(at(time.Minute), Prepared{Track: qq, Result: ready(time.Minute)})
+ }},
+ {"等待队列不为空", func(r *Room) []Effect {
+ r.Handle(at(0), enqueue(alice, netease("a")))
+ r.Handle(at(0), enqueue(alice, netease("b")))
+ return r.Handle(at(time.Minute), Prepared{Track: netease("a"), Result: ready(time.Minute)})
+ }},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ effects := tc.start(listenedRoom(t))
+ if len(events[PlaybackChanged](effects)) != 1 {
+ t.Fatalf("没有开播:%#v", effects)
+ }
+ if len(find[Recommend](effects)) != 0 {
+ t.Fatalf("不应请求推荐:%#v", effects)
+ }
+ })
+ }
+}
+
+func TestRecommendedIsCheckedAgain(t *testing.T) {
+ setup := func(t *testing.T) (*Room, Token) {
+ r := listenedRoom(t)
+ r.Handle(at(0), enqueue(alice, netease("a")))
+ rec := find[Recommend](r.Handle(at(20*time.Second), Prepared{Track: netease("a"), Result: ready(3 * time.Minute)}))
+ if len(rec) != 1 {
+ t.Fatal("没有请求推荐")
+ }
+ return r, rec[0].Token
+ }
+
+ t.Run("token 不匹配", func(t *testing.T) {
+ r, token := setup(t)
+ if effects := r.Handle(at(21*time.Second), Recommended{Token: token + 100, Track: netease("b")}); len(effects) != 0 {
+ t.Fatalf("应丢弃:%#v", effects)
+ }
+ })
+ t.Run("推荐的就是正在播放的歌", func(t *testing.T) {
+ r, token := setup(t)
+ if effects := r.Handle(at(21*time.Second), Recommended{Token: token, Track: netease("a")}); len(effects) != 0 {
+ t.Fatalf("应丢弃:%#v", effects)
+ }
+ })
+ t.Run("等待推荐期间有人点歌", func(t *testing.T) {
+ r, token := setup(t)
+ r.Handle(at(25*time.Second), enqueue(bob, netease("c")))
+ if effects := r.Handle(at(26*time.Second), Recommended{Token: token, Track: netease("b")}); len(effects) != 0 {
+ t.Fatalf("应丢弃:%#v", effects)
+ }
+ })
+ t.Run("同一个推荐结果再次送达", func(t *testing.T) {
+ r, token := setup(t)
+ r.Handle(at(21*time.Second), Recommended{Token: token, Track: netease("b")})
+ // 推荐的歌被删掉之后等待队列又空了,如果再次送达的结果没有被丢弃,就会再入队一次。
+ r.Handle(at(22*time.Second), command(alice, Remove{Entry: 2}))
+ if effects := r.Handle(at(23*time.Second), Recommended{Token: token, Track: netease("b")}); len(effects) != 0 {
+ t.Fatalf("应丢弃:%#v", effects)
+ }
+ })
+ t.Run("发出推荐的歌被切掉了", func(t *testing.T) {
+ r, token := setup(t)
+ r.Handle(at(22*time.Second), command(alice, VoteSkip{}))
+ if effects := r.Handle(at(23*time.Second), Recommended{Token: token, Track: netease("b")}); len(effects) != 0 {
+ t.Fatalf("应丢弃:%#v", effects)
+ }
+ })
+ t.Run("发出推荐的歌播完了", func(t *testing.T) {
+ r, token := setup(t)
+ r.Handle(at(3*time.Minute+21*time.Second), Timer{Token: r.endTimer})
+ if effects := r.Handle(at(3*time.Minute+22*time.Second), Recommended{Token: token, Track: netease("b")}); len(effects) != 0 {
+ t.Fatalf("应丢弃:%#v", effects)
+ }
+ })
+}
diff --git a/internal/domain/room/fuzz_test.go b/internal/domain/room/fuzz_test.go
new file mode 100644
index 0000000..55d442d
--- /dev/null
+++ b/internal/domain/room/fuzz_test.go
@@ -0,0 +1,236 @@
+package room
+
+import (
+ "fmt"
+ "maps"
+ "reflect"
+ "slices"
+ "testing"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/domain/member"
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+const (
+ // 房间换到其他实例运行:以下一个任期从存储中的状态恢复。
+ fuzzRestore = 17
+ // 准备结果写进了曲目表,却没有送到房间(房间没有 owner,或者通知丢失)。
+ fuzzStored = 18
+ // 曲目表中的结果不能再用:比如链接曲目的音频已经不在对象存储中。
+ fuzzDropped = 19
+ fuzzOps = 20
+)
+
+// ops 中每两个字节是一步:操作和它的参数。
+func FuzzInvariants(f *testing.F) {
+ f.Add(false, []byte{0, 0, 2, 1, 2, 2, 7, 1, 7, 2, 4, 2, 5, 0, 9, 3})
+ f.Add(true, []byte{6, 1, 2, 3, 2, 4, 2, 5, 7, 3, 8, 4, 3, 2, 9, 9, 11, 1})
+ f.Add(false, []byte{0, 0, 2, 4, 7, 10, 11, 1})
+ f.Add(true, []byte{2, 0, 7, 0, 2, 1, 10, 0})
+ f.Add(true, []byte{0, 0, 2, 1, 7, 1, 2, 2, 17, 0, 12, 1, 13, 0, 15, 0, 16, 0, 4, 2, 3, 2, 7, 2, 9, 0, 14, 0})
+ f.Add(false, []byte{0, 1, 2, 0, 2, 1, 2, 2, 7, 0, 17, 3, 9, 0, 4, 1, 3, 1, 1, 1, 17, 0, 10, 0})
+ // 正在播放的歌放完之后才恢复,等待队列中还有歌。
+ f.Add(true, []byte{0, 0, 2, 0, 7, 0, 2, 1, 7, 1, 16, 15, 16, 15, 16, 15, 16, 15, 17, 0})
+ // 恢复时待播和等待队列中准备中的歌重新准备。
+ f.Add(false, []byte{2, 0, 17, 0})
+ f.Add(true, []byte{2, 0, 7, 0, 2, 1, 17, 0})
+ // 待播准备好的结果在房间没有 owner 时写进了曲目表:恢复时待播已就绪,立即开播。
+ f.Add(true, []byte{2, 0, 18, 0, 17, 0})
+ // 等待队列中已就绪歌曲的结果不能再用:恢复时重新准备。
+ f.Add(true, []byte{2, 0, 7, 0, 2, 1, 7, 1, 19, 1, 17, 0})
+ f.Fuzz(func(t *testing.T, persistent bool, ops []byte) {
+ kind := Ephemeral
+ if persistent {
+ kind = Persistent
+ }
+ term := uint32(1)
+ r, _ := Restore(at(0), term, Snapshot{Info: Info{ID: "r1", Kind: kind}, Mode: Sequential})
+ table := map[track.Track]track.Prepared{}
+ var seq uint64
+ now := time.Duration(0)
+ for i := 0; i+1 < len(ops); i += 2 {
+ op, arg := ops[i], ops[i+1]
+ now += time.Duration(arg%16) * time.Second
+ env := Env{Now: t0.Add(now), Seed: uint64(i)}
+ before := r.snapshot()
+ var effects []Effect
+ switch op % fuzzOps {
+ case fuzzRestore:
+ // 每次状态变化都已落盘,所以落盘的状态就是 before;会话、投票和定时器不落盘,随旧的 Room 一起丢弃。
+ term++
+ seq = 0
+ before = stored(before, table)
+ r, effects = Restore(env, term, before)
+ checkRestorePrepares(t, r, effects)
+ case fuzzStored:
+ table[fuzzTrack(arg)] = ready(time.Duration(arg%5+1) * time.Minute)
+ continue
+ case fuzzDropped:
+ delete(table, fuzzTrack(arg))
+ continue
+ default:
+ in := fuzzInput(r, op, arg)
+ if p, ok := in.(Prepared); ok {
+ table[p.Track] = p.Result
+ }
+ effects = r.Handle(env, in)
+ }
+ // 房间关闭后就被丢弃,不会再收到输入;关闭时结束了正在播放的歌,但不再开播下一首。
+ closed := len(find[Close](effects)) != 0
+ checkInvariants(t, r, before, effects, term, &seq, closed)
+ if closed {
+ return
+ }
+ }
+ })
+}
+
+func fuzzTrack(arg byte) track.Track {
+ return netease(string(rune('a' + arg%6)))
+}
+
+// 存储接管房间时按曲目表重算待播和等待队列中的条目是否就绪;正在播放的歌已经开播,保持原样。
+func stored(s Snapshot, table map[track.Track]track.Prepared) Snapshot {
+ restore := func(e Entry) Entry {
+ e.Prep, e.Ready = table[e.Track]
+ return e
+ }
+ s.Waiting = slices.Clone(s.Waiting)
+ for i, e := range s.Waiting {
+ s.Waiting[i] = restore(e)
+ }
+ if s.Pending != nil {
+ e := restore(*s.Pending)
+ s.Pending = &e
+ }
+ return s
+}
+
+func checkRestorePrepares(t *testing.T, r *Room, effects []Effect) {
+ t.Helper()
+ want := map[track.Track]bool{}
+ if r.pending != nil && !r.pending.Ready {
+ want[r.pending.Track] = true
+ }
+ for _, e := range r.waiting {
+ if !e.Ready {
+ want[e.Track] = true
+ }
+ }
+ got := map[track.Track]bool{}
+ for _, p := range find[Prepare](effects) {
+ got[p.Track] = true
+ }
+ if !maps.Equal(got, want) {
+ t.Fatalf("恢复时发出 Prepare 的曲目 = %v,期望准备中的条目 %v", got, want)
+ }
+}
+
+// 不能用 sameSnapshot 判断持久状态是否变化:要检查的正是它。
+func persistentStateChanged(a, b Snapshot) bool {
+ if len(a.Waiting) == 0 && len(b.Waiting) == 0 {
+ a.Waiting, b.Waiting = nil, nil
+ }
+ return !reflect.DeepEqual(a, b)
+}
+
+func fuzzInput(r *Room, op, arg byte) Input {
+ members := []member.Member{alice, bob, carol}
+ m := members[int(arg)%len(members)]
+ tr := fuzzTrack(arg)
+ session := SessionID(fmt.Sprint("s", arg%4))
+ switch op % fuzzOps {
+ case 0:
+ return Join{Session: session, Member: m}
+ case 1:
+ return Leave{Session: session}
+ case 2:
+ return command(m, Enqueue{Track: tr})
+ case 3:
+ return command(m, Remove{Entry: fuzzEntry(r, arg)})
+ case 4:
+ return command(m, Like{Entry: fuzzEntry(r, arg)})
+ case 5:
+ return command(m, VoteSkip{})
+ case 6:
+ return command(m, SetPlayMode{Mode: PlayMode(arg%3) + 1})
+ case 7:
+ return Prepared{Track: tr, Result: ready(time.Duration(arg%5+1) * time.Minute)}
+ case 8:
+ return PrepareFailed{Track: tr, Reason: "失败"}
+ case 9:
+ return Timer{Token: r.endTimer}
+ case 10:
+ return Timer{Token: r.closeTimer}
+ case 11:
+ return Recommended{Token: r.recommendation, Track: tr}
+ case 12:
+ return UpdateInfo{RequestID: "q", Name: fmt.Sprint("房间", arg%3), Description: "介绍"}
+ case 13:
+ return MemberUpdated{Member: member.Member{ID: m.ID, Name: fmt.Sprint(m.Name, arg%2)}}
+ case 14:
+ return Delete{RequestID: "q"}
+ case 15:
+ return command(m, Chat{Text: "你好"})
+ default:
+ return command(m, Sync{})
+ }
+}
+
+// 多数时候选中等待队列中的条目,也会选中待播、正在播放和不存在的条目:删除和点赞只接受等待队列中的条目。
+func fuzzEntry(r *Room, arg byte) EntryID {
+ candidates := ids(r.waiting)
+ if r.pending != nil {
+ candidates = append(candidates, r.pending.ID)
+ }
+ if r.current != nil {
+ candidates = append(candidates, r.current.Entry.ID)
+ }
+ candidates = append(candidates, r.entrySeq+1)
+ return candidates[int(arg)%len(candidates)]
+}
+
+func checkInvariants(t *testing.T, r *Room, before Snapshot, effects []Effect, term uint32, seq *uint64, closed bool) {
+ t.Helper()
+ entries := slices.Clone(r.waiting)
+ if r.pending != nil {
+ entries = append(entries, *r.pending)
+ }
+ ids := map[EntryID]bool{}
+ tracks := map[track.Track]bool{}
+ for _, e := range entries {
+ if ids[e.ID] || tracks[e.Track] {
+ t.Fatalf("待播和等待队列中有重复的条目或曲目:%#v", entries)
+ }
+ ids[e.ID], tracks[e.Track] = true, true
+ }
+ if r.current != nil && ids[r.current.Entry.ID] {
+ t.Fatalf("正在播放的条目 ID 与队列中的重复:%d", r.current.Entry.ID)
+ }
+ if r.current != nil && r.pending != nil {
+ t.Fatal("有待播时不应有正在播放的歌")
+ }
+ if !closed && r.current == nil && r.pending == nil && len(r.waiting) != 0 {
+ t.Fatalf("空闲时等待队列不应有歌:%#v", r.waiting)
+ }
+ if r.pending != nil && r.pending.Ready {
+ t.Fatalf("已就绪的待播应立即开播:%#v", r.pending)
+ }
+ if !slices.IsSortedFunc(r.waiting, compareWaiting(r.mode)) {
+ t.Fatalf("等待队列没有按播放模式的排序规则排列:%#v", r.waiting)
+ }
+ if persistentStateChanged(before, r.snapshot()) {
+ if !persistsFirst(effects) {
+ t.Fatalf("持久状态变化时 Persist 应排在最前:%#v", effects)
+ }
+ } else if len(find[Persist](effects)) != 0 {
+ t.Fatalf("持久状态没变却落盘:%#v", effects)
+ }
+ for _, e := range find[Emit](effects) {
+ if e.Term != term || e.Seq != *seq+1 {
+ t.Fatalf("任期 %d 中 seq %d 之后是任期 %d 的 %d", term, *seq, e.Term, e.Seq)
+ }
+ *seq = e.Seq
+ }
+}
diff --git a/internal/domain/room/golden_test.go b/internal/domain/room/golden_test.go
new file mode 100644
index 0000000..2ebb3fa
--- /dev/null
+++ b/internal/domain/room/golden_test.go
@@ -0,0 +1,365 @@
+package room
+
+import (
+ "errors"
+ "flag"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "golang.org/x/tools/txtar"
+
+ "github.com/dihemoe/alisten/internal/domain/member"
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+var update = flag.Bool("update", false, "用实际输出重写 testdata 中的期望")
+
+var goldenMembers = map[string]member.Member{"m1": alice, "m2": bob, "m3": carol}
+
+func TestGolden(t *testing.T) {
+ files, err := filepath.Glob("testdata/*.txtar")
+ if err != nil || len(files) == 0 {
+ t.Fatalf("没有找到用例:%v", err)
+ }
+ for _, file := range files {
+ t.Run(filepath.Base(file), func(t *testing.T) {
+ archive, err := txtar.ParseFile(file)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var r *Room
+ changed := false
+ for i := range archive.Files {
+ f := &archive.Files[i]
+ input, want, _ := strings.Cut(string(f.Data), "\n")
+ var effects []Effect
+ r, effects, err = runStep(r, f.Name, strings.TrimPrefix(input, "> "))
+ if err != nil {
+ t.Fatalf("步骤 %s:%v", f.Name, err)
+ }
+ got := formatEffects(effects)
+ if got == want {
+ continue
+ }
+ if *update {
+ f.Data = []byte(input + "\n" + got)
+ changed = true
+ continue
+ }
+ t.Errorf("步骤 %s:%s\n实际:\n%s期望:\n%s", f.Name, input, got, want)
+ }
+ if changed {
+ if err := os.WriteFile(file, txtar.Format(archive), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ })
+ }
+}
+
+var roomKinds = map[string]Kind{"ephemeral": Ephemeral, "persistent": Persistent}
+
+func runStep(r *Room, step, line string) (*Room, []Effect, error) {
+ f := strings.Fields(line)
+ if len(f) == 0 {
+ return r, nil, errors.New("缺少输入")
+ }
+ if f[0] == "restore" {
+ if r != nil {
+ return r, nil, errors.New("restore 只能是第一步")
+ }
+ if len(f) != 2 {
+ return nil, nil, fmt.Errorf("restore 之后应是房间类型:%q", line)
+ }
+ kind, err := lookup(roomKinds, "房间类型", f[1])
+ if err != nil {
+ return nil, nil, err
+ }
+ r, effects := Restore(at(0), 1, Snapshot{Info: Info{ID: "r1", Name: "房间", Kind: kind}, Mode: Sequential})
+ return r, effects, nil
+ }
+ if r == nil {
+ return nil, nil, errors.New("第一步必须是 restore")
+ }
+ n, err := strconv.Atoi(step)
+ if err != nil {
+ return r, nil, fmt.Errorf("步骤名必须是数字:%q", step)
+ }
+ offset, err := time.ParseDuration(f[0])
+ if err != nil {
+ return r, nil, fmt.Errorf("时间不合法:%w", err)
+ }
+ in, err := parseInput(RequestID("q"+step), f[1:])
+ if err != nil {
+ return r, nil, err
+ }
+ return r, r.Handle(Env{Now: t0.Add(offset), Seed: uint64(n)}, in), nil
+}
+
+var inputArity = map[string]int{
+ "join": 2, "leave": 1, "enqueue": 2, "remove": 2, "like": 2, "vote": 1, "mode": 2,
+ "chat": 1, "sync": 1, "prepared": 2, "failed": 1, "recommended": 2, "timer": 1,
+}
+
+var playModes = map[string]PlayMode{"sequential": Sequential, "random": Random, "rotation": Rotation}
+
+func parseInput(id RequestID, f []string) (Input, error) {
+ if len(f) == 0 {
+ return nil, errors.New("缺少输入")
+ }
+ verb, args := f[0], f[1:]
+ n, ok := inputArity[verb]
+ switch {
+ case !ok:
+ return nil, fmt.Errorf("未知的输入:%q", verb)
+ case len(args) < n || len(args) > n && verb != "chat" && verb != "failed":
+ return nil, fmt.Errorf("%s 应有 %d 个参数:%q", verb, n, args)
+ }
+ cmd := func(a Action) (Input, error) {
+ m, err := lookup(goldenMembers, "成员", args[0])
+ return Command{RequestID: id, Member: m, Action: a}, err
+ }
+ switch verb {
+ case "join":
+ m, err := lookup(goldenMembers, "成员", args[1])
+ return Join{Session: SessionID(args[0]), Member: m}, err
+ case "leave":
+ return Leave{Session: SessionID(args[0])}, nil
+ case "enqueue":
+ tr, err := parseTrack(args[1])
+ if err != nil {
+ return nil, err
+ }
+ return cmd(Enqueue{Track: tr, Meta: track.Meta{Title: tr.ID}})
+ case "remove", "like":
+ e, err := parseNumber(args[1])
+ if err != nil {
+ return nil, err
+ }
+ if verb == "remove" {
+ return cmd(Remove{Entry: EntryID(e)})
+ }
+ return cmd(Like{Entry: EntryID(e)})
+ case "vote":
+ return cmd(VoteSkip{})
+ case "mode":
+ mode, err := lookup(playModes, "播放模式", args[1])
+ if err != nil {
+ return nil, err
+ }
+ return cmd(SetPlayMode{Mode: mode})
+ case "chat":
+ return cmd(Chat{Text: strings.Join(args[1:], " ")})
+ case "sync":
+ return cmd(Sync{})
+ case "prepared":
+ tr, err := parseTrack(args[0])
+ if err != nil {
+ return nil, err
+ }
+ d, err := time.ParseDuration(args[1])
+ if err != nil {
+ return nil, fmt.Errorf("时长不合法:%w", err)
+ }
+ return Prepared{Track: tr, Meta: track.Meta{Title: tr.ID}, Result: ready(d)}, nil
+ case "failed":
+ tr, err := parseTrack(args[0])
+ return PrepareFailed{Track: tr, Reason: strings.Join(args[1:], " ")}, err
+ case "recommended":
+ token, err := parseNumber(args[0])
+ if err != nil {
+ return nil, err
+ }
+ tr, err := parseTrack(args[1])
+ return Recommended{Token: Token(token), Track: tr, Meta: track.Meta{Title: tr.ID}}, err
+ default:
+ token, err := parseNumber(args[0])
+ return Timer{Token: Token(token)}, err
+ }
+}
+
+func lookup[V any](m map[string]V, what, key string) (V, error) {
+ v, ok := m[key]
+ if !ok {
+ return v, fmt.Errorf("未知的%s:%q", what, key)
+ }
+ return v, nil
+}
+
+func parseTrack(s string) (track.Track, error) {
+ source, id, ok := strings.Cut(s, ":")
+ if !ok || source == "" || id == "" {
+ return track.Track{}, fmt.Errorf("曲目应写成 来源:ID:%q", s)
+ }
+ return track.Track{Source: track.Source(source), ID: id}, nil
+}
+
+func parseNumber(s string) (uint64, error) {
+ n, err := strconv.ParseUint(s, 10, 64)
+ if err != nil {
+ return 0, fmt.Errorf("不是数字:%q", s)
+ }
+ return n, nil
+}
+
+func formatEffects(effects []Effect) string {
+ var b strings.Builder
+ for _, e := range effects {
+ b.WriteString(formatEffect(e))
+ b.WriteByte('\n')
+ }
+ return b.String()
+}
+
+func formatEffect(e Effect) string {
+ switch e := e.(type) {
+ case Persist:
+ s := e.Snapshot
+ return fmt.Sprintf("persist mode=%s entrySeq=%d current=%s pending=%s waiting=%s",
+ modeNames[s.Mode], s.EntrySeq, formatPlaying(s.Current), formatEntryPtr(s.Pending), formatEntries(s.Waiting))
+ case RecordPlay:
+ return fmt.Sprintf("record %s %s-%s", formatEntry(e.Entry), offset(e.StartedAt), offset(e.EndedAt))
+ case Emit:
+ return fmt.Sprintf("emit %d.%d %s", e.Term, e.Seq, formatEvent(e.Event))
+ case Close:
+ return "close"
+ case Reply:
+ return fmt.Sprintf("reply %s %s", e.RequestID, formatResult(e))
+ case Prepare:
+ return "prepare " + formatTrack(e.Track)
+ case Recommend:
+ return fmt.Sprintf("recommend %d", e.Token)
+ case Schedule:
+ return fmt.Sprintf("schedule %d %s", e.Token, offset(e.At))
+ }
+ panic(fmt.Sprintf("未知的 effect:%#v", e))
+}
+
+func formatEvent(e Event) string {
+ switch e := e.(type) {
+ case MembersChanged:
+ parts := make([]string, len(e.Sessions))
+ for i, s := range e.Sessions {
+ parts[i] = string(s.ID) + "=" + s.Member.ID
+ }
+ return "members [" + strings.Join(parts, " ") + "]"
+ case QueueChanged:
+ return fmt.Sprintf("queue pending=%s waiting=%s", formatEntryPtr(e.Pending), formatEntries(e.Waiting))
+ case PlaybackChanged:
+ return "playback " + formatPlaying(e.Current)
+ case ModeChanged:
+ return "mode " + modeNames[e.Mode]
+ case InfoChanged:
+ return fmt.Sprintf("info %s | %s", e.Name, e.Description)
+ case ChatPosted:
+ return fmt.Sprintf("chat %s %s", e.Member.ID, e.Text)
+ case Activity:
+ s := fmt.Sprintf("activity %s %s %s", activityNames[e.Kind], memberID(e.Member), formatEntry(e.Entry))
+ switch e.Kind {
+ case ActivityVoted, ActivitySkipped:
+ s += fmt.Sprintf(" %d/%d", e.Votes, e.Required)
+ case ActivityFailed:
+ s += " " + e.Reason
+ }
+ return s
+ }
+ panic(fmt.Sprintf("未知的事件:%#v", e))
+}
+
+func formatResult(r Reply) string {
+ if r.Err != nil {
+ return fmt.Sprintf("error %s %s", errorNames[r.Err.Kind], r.Err.Message)
+ }
+ switch res := r.Result.(type) {
+ case Done:
+ return "done"
+ case EnqueueResult:
+ return "enqueued " + formatEntry(res.Entry)
+ case LikeResult:
+ return fmt.Sprintf("liked %d", res.Likes)
+ case VoteResult:
+ s := fmt.Sprintf("voted %d/%d", res.Votes, res.Required)
+ if res.Skipped {
+ s += " skipped"
+ }
+ return s
+ case SyncResult:
+ return fmt.Sprintf("sync term=%d seq=%d sessions=%d", res.View.Term, res.View.Seq, len(res.View.Sessions))
+ }
+ panic(fmt.Sprintf("未知的回复:%#v", r))
+}
+
+func formatEntry(e Entry) string {
+ flags := []string{formatTrack(e.Track)}
+ if e.Ready {
+ flags = append(flags, "ready")
+ }
+ if e.Likes > 0 {
+ flags = append(flags, fmt.Sprintf("likes=%d", e.Likes))
+ }
+ if e.Round > 0 {
+ flags = append(flags, fmt.Sprintf("round=%d", e.Round))
+ }
+ if e.Auto {
+ flags = append(flags, "auto")
+ }
+ return fmt.Sprintf("#%d(%s)", e.ID, strings.Join(flags, ","))
+}
+
+func formatEntryPtr(e *Entry) string {
+ if e == nil {
+ return "-"
+ }
+ return formatEntry(*e)
+}
+
+func formatEntries(entries []Entry) string {
+ parts := make([]string, len(entries))
+ for i, e := range entries {
+ parts[i] = formatEntry(e)
+ }
+ return "[" + strings.Join(parts, " ") + "]"
+}
+
+func formatPlaying(p *NowPlaying) string {
+ if p == nil {
+ return "-"
+ }
+ return formatEntry(p.Entry) + "@" + offset(p.StartedAt)
+}
+
+func formatTrack(t track.Track) string { return string(t.Source) + ":" + t.ID }
+
+func offset(t time.Time) string { return t.Sub(t0).String() }
+
+func memberID(m member.Member) string {
+ if m.ID == "" {
+ return "-"
+ }
+ return m.ID
+}
+
+var modeNames = map[PlayMode]string{Sequential: "sequential", Random: "random", Rotation: "rotation"}
+
+var activityNames = map[ActivityKind]string{
+ ActivityAdded: "added",
+ ActivityRemoved: "removed",
+ ActivityLiked: "liked",
+ ActivityVoted: "voted",
+ ActivitySkipped: "skipped",
+ ActivityFailed: "failed",
+}
+
+var errorNames = map[ErrorKind]string{
+ NotFound: "not-found",
+ Forbidden: "forbidden",
+ Conflict: "conflict",
+ QueueFull: "queue-full",
+ RateLimited: "rate-limited",
+ NothingPlaying: "nothing-playing",
+}
diff --git a/internal/domain/room/helpers_test.go b/internal/domain/room/helpers_test.go
new file mode 100644
index 0000000..d3489fd
--- /dev/null
+++ b/internal/domain/room/helpers_test.go
@@ -0,0 +1,115 @@
+package room
+
+import (
+ "testing"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/domain/member"
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+var t0 = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+
+var (
+ alice = member.Member{ID: "m1", Name: "甲"}
+ bob = member.Member{ID: "m2", Name: "乙"}
+ carol = member.Member{ID: "m3", Name: "丙"}
+)
+
+func at(d time.Duration) Env { return Env{Now: t0.Add(d)} }
+
+func netease(id string) track.Track { return track.Track{Source: track.NetEase, ID: id} }
+
+func ready(d time.Duration) track.Prepared { return track.Prepared{Duration: d, Gain: 1} }
+
+func newRoom(t *testing.T, kind Kind) *Room {
+ t.Helper()
+ r, _ := Restore(at(0), 1, Snapshot{Info: Info{ID: "r1", Name: "房间", Kind: kind}, Mode: Sequential})
+ return r
+}
+
+func command(m member.Member, a Action) Command {
+ return Command{RequestID: "q", Member: m, Action: a}
+}
+
+func enqueue(m member.Member, t track.Track) Command {
+ return command(m, Enqueue{Track: t, Meta: track.Meta{Title: t.ID}})
+}
+
+func find[T Effect](effects []Effect) []T {
+ var out []T
+ for _, e := range effects {
+ if v, ok := e.(T); ok {
+ out = append(out, v)
+ }
+ }
+ return out
+}
+
+func events[T Event](effects []Effect) []T {
+ var out []T
+ for _, emit := range find[Emit](effects) {
+ if v, ok := emit.Event.(T); ok {
+ out = append(out, v)
+ }
+ }
+ return out
+}
+
+func persistsFirst(effects []Effect) bool {
+ if len(effects) == 0 {
+ return false
+ }
+ _, ok := effects[0].(Persist)
+ return ok
+}
+
+func reply(t *testing.T, effects []Effect) Reply {
+ t.Helper()
+ replies := find[Reply](effects)
+ if len(replies) != 1 {
+ t.Fatalf("期望一个回复,实际 %d 个:%#v", len(replies), effects)
+ }
+ return replies[0]
+}
+
+func wantError(t *testing.T, effects []Effect, kind ErrorKind) {
+ t.Helper()
+ if r := reply(t, effects); r.Err == nil || r.Err.Kind != kind || len(effects) != 1 {
+ t.Fatalf("期望只回复错误 %d,实际 %#v", kind, effects)
+ }
+}
+
+func ids(entries []Entry) []EntryID {
+ out := make([]EntryID, len(entries))
+ for i, e := range entries {
+ out[i] = e.ID
+ }
+ return out
+}
+
+func playing(t *testing.T, r *Room, now time.Duration, tr track.Track, d time.Duration) {
+ t.Helper()
+ r.Handle(at(now), enqueue(alice, tr))
+ r.Handle(at(now), Prepared{Track: tr, Meta: track.Meta{Title: tr.ID}, Result: ready(d)})
+ if r.current == nil || r.current.Entry.Track != tr {
+ t.Fatalf("%v 没有开始播放", tr)
+ }
+}
+
+// 点的歌立即准备好,每首一分钟。
+func pickReady(r *Room, now time.Duration, by member.Member, id string) {
+ r.Handle(at(now), enqueue(by, netease(id)))
+ r.Handle(at(now), Prepared{Track: netease(id), Meta: track.Meta{Title: id}, Result: ready(time.Minute)})
+}
+
+// 放完正在播放的歌,返回放完的时间和这首歌。
+func finish(t *testing.T, r *Room) (time.Duration, string) {
+ t.Helper()
+ if r.current == nil {
+ t.Fatal("没有正在播放的歌")
+ }
+ song, end := r.current.Entry.Track.ID, r.current.EndsAt().Sub(t0)
+ r.Handle(at(end), Timer{Token: r.endTimer})
+ return end, song
+}
diff --git a/internal/domain/room/limiter_test.go b/internal/domain/room/limiter_test.go
new file mode 100644
index 0000000..9daa904
--- /dev/null
+++ b/internal/domain/room/limiter_test.go
@@ -0,0 +1,35 @@
+package room
+
+import (
+ "testing"
+ "time"
+)
+
+func TestLimiterAllowsBurstThenRefills(t *testing.T) {
+ var l limiter
+ for i := range 5 {
+ if !l.allow(t0, 5, time.Minute) {
+ t.Fatalf("第 %d 次应在突发额度内", i+1)
+ }
+ }
+ if l.allow(t0, 5, time.Minute) {
+ t.Fatal("突发额度用完后应拒绝")
+ }
+ if l.allow(t0.Add(59*time.Second), 5, time.Minute) {
+ t.Fatal("不到一分钟不恢复")
+ }
+ if !l.allow(t0.Add(time.Minute), 5, time.Minute) {
+ t.Fatal("一分钟后恢复一次")
+ }
+ if l.allow(t0.Add(time.Minute), 5, time.Minute) {
+ t.Fatal("一分钟只恢复一次")
+ }
+ for i := range 5 {
+ if !l.allow(t0.Add(time.Hour), 5, time.Minute) {
+ t.Fatalf("长时间空闲后额度恢复满,第 %d 次失败", i+1)
+ }
+ }
+ if l.allow(t0.Add(time.Hour), 5, time.Minute) {
+ t.Fatal("额度不超过突发上限")
+ }
+}
diff --git a/internal/domain/room/playback_test.go b/internal/domain/room/playback_test.go
new file mode 100644
index 0000000..01d8719
--- /dev/null
+++ b/internal/domain/room/playback_test.go
@@ -0,0 +1,90 @@
+package room
+
+import (
+ "slices"
+ "testing"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+func TestCurrentSongCanBePickedAgain(t *testing.T) {
+ r := newRoom(t, Persistent)
+ playing(t, r, 0, netease("a"), 3*time.Minute)
+ if res := reply(t, r.Handle(at(time.Second), enqueue(bob, netease("a")))); res.Err != nil {
+ t.Fatalf("正在播放的歌可以再点:%#v", res)
+ }
+}
+
+func TestPreparedForWaitingEntryOnlyMarksReady(t *testing.T) {
+ r := newRoom(t, Persistent)
+ playing(t, r, 0, netease("a"), 3*time.Minute)
+ r.Handle(at(time.Second), enqueue(alice, netease("b")))
+
+ effects := r.Handle(at(2*time.Second), Prepared{Track: netease("b"), Result: ready(time.Minute)})
+ if len(events[PlaybackChanged](effects)) != 0 || !r.waiting[0].Ready {
+ t.Fatalf("等待队列中的歌只标记为已就绪:%#v", effects)
+ }
+ marked := r.waiting[0]
+
+ again := Prepared{Track: netease("b"), Meta: track.Meta{Title: "B2"}, Result: ready(2 * time.Minute)}
+ if effects := r.Handle(at(3*time.Second), again); len(effects) != 0 || r.waiting[0] != marked {
+ t.Fatalf("已就绪的条目忽略重复的结果:%#v", effects)
+ }
+ if effects := r.Handle(at(3*time.Second), PrepareFailed{Track: netease("b"), Reason: "x"}); len(effects) != 0 || len(r.waiting) != 1 || r.waiting[0] != marked {
+ t.Fatalf("已就绪的条目忽略准备失败的结果:%#v", effects)
+ }
+ if effects := r.Handle(at(3*time.Second), Prepared{Track: netease("x"), Result: ready(time.Minute)}); len(effects) != 0 {
+ t.Fatalf("不在队列中的曲目忽略结果:%#v", effects)
+ }
+}
+
+func TestSongEndRecordsPlayAndAdvances(t *testing.T) {
+ r := newRoom(t, Persistent)
+ playing(t, r, 0, netease("a"), 3*time.Minute)
+ r.Handle(at(time.Second), enqueue(alice, netease("b")))
+ r.Handle(at(time.Second), Prepared{Track: netease("b"), Result: ready(time.Minute)})
+ first := *r.current
+
+ effects := r.Handle(at(3*time.Minute+time.Second), Timer{Token: r.endTimer})
+ plays := find[RecordPlay](effects)
+ if len(plays) != 1 || plays[0].Entry.ID != first.Entry.ID || !plays[0].EndedAt.Equal(first.EndsAt()) {
+ t.Fatalf("播完应记录播放历史,结束时间为应播完的时间:%#v", effects)
+ }
+ got := events[PlaybackChanged](effects)
+ if len(got) != 1 || got[0].Current == nil || got[0].Current.Entry.Track != netease("b") ||
+ !got[0].Current.StartedAt.Equal(t0.Add(3*time.Minute+time.Second+startLead)) {
+ t.Fatalf("应接着播放下一首:%#v", effects)
+ }
+
+ effects = r.Handle(at(5*time.Minute), Timer{Token: r.endTimer})
+ if got := events[PlaybackChanged](effects); len(got) != 1 || got[0].Current != nil {
+ t.Fatalf("等待队列为空时应进入空闲:%#v", effects)
+ }
+}
+
+func TestPrepareFailedWaitingEntryIsRemoved(t *testing.T) {
+ r := newRoom(t, Persistent)
+ playing(t, r, 0, netease("a"), 3*time.Minute)
+ r.Handle(at(time.Second), enqueue(alice, netease("b")))
+ r.Handle(at(time.Second), enqueue(alice, netease("c")))
+ current := *r.current
+
+ effects := r.Handle(at(2*time.Second), PrepareFailed{Track: netease("b"), Reason: "歌曲已下架"})
+ if !persistsFirst(effects) {
+ t.Fatalf("状态变化时第一个 effect 应是 Persist:%#v", effects)
+ }
+ if got := ids(r.waiting); !slices.Equal(got, []EntryID{3}) {
+ t.Fatalf("准备失败的条目应移出等待队列:%v", got)
+ }
+ failed := events[Activity](effects)
+ if len(failed) != 1 || failed[0].Kind != ActivityFailed || failed[0].Entry.ID != 2 || failed[0].Reason != "歌曲已下架" {
+ t.Fatalf("应广播准备失败的原因:%#v", effects)
+ }
+ if queue := events[QueueChanged](effects); len(queue) != 1 || !slices.Equal(ids(queue[0].Waiting), []EntryID{3}) {
+ t.Fatalf("广播的等待队列不应再包含失败的条目:%#v", effects)
+ }
+ if len(events[PlaybackChanged](effects)) != 0 || *r.current != current {
+ t.Fatalf("准备失败不应影响正在播放的歌:%#v", effects)
+ }
+}
diff --git a/internal/domain/room/presence_test.go b/internal/domain/room/presence_test.go
new file mode 100644
index 0000000..521beb2
--- /dev/null
+++ b/internal/domain/room/presence_test.go
@@ -0,0 +1,89 @@
+package room
+
+import (
+ "slices"
+ "testing"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/domain/member"
+)
+
+func TestJoinAndLeaveBroadcastSessions(t *testing.T) {
+ r := newRoom(t, Persistent)
+
+ effects := r.Handle(at(time.Second), Join{Session: "s1", Member: alice})
+ if got := events[MembersChanged](effects); len(got) != 1 || !slices.Equal(got[0].Sessions, []Session{{"s1", alice}}) {
+ t.Fatalf("加入后的在线列表不对:%#v", effects)
+ }
+ if len(find[Persist](effects)) != 0 {
+ t.Fatal("在线会话不落盘")
+ }
+ if effects := r.Handle(at(2*time.Second), Join{Session: "s1", Member: alice}); len(effects) != 0 {
+ t.Fatalf("重复的 Join 不应产生输出:%#v", effects)
+ }
+
+ effects = r.Handle(at(3*time.Second), Leave{Session: "s1"})
+ if got := events[MembersChanged](effects); len(got) != 1 || len(got[0].Sessions) != 0 {
+ t.Fatalf("离开后的在线列表不对:%#v", effects)
+ }
+ if len(find[Schedule](effects)) != 0 {
+ t.Fatal("常驻房间无人时不关闭")
+ }
+ if effects := r.Handle(at(4*time.Second), Leave{Session: "s1"}); len(effects) != 0 {
+ t.Fatalf("不在线的会话 Leave 不应产生输出:%#v", effects)
+ }
+}
+
+func TestEphemeralRoomCloseEndsThePlayingSong(t *testing.T) {
+ r := newRoom(t, Ephemeral)
+ r.Handle(at(0), Join{Session: "s1", Member: alice})
+ playing(t, r, 0, netease("a"), 10*time.Minute)
+
+ closing := find[Schedule](r.Handle(at(time.Minute), Leave{Session: "s1"}))
+ if len(closing) != 1 {
+ t.Fatalf("最后一个会话离开后应开始无人计时:%#v", closing)
+ }
+ effects := r.Handle(at(6*time.Minute), Timer{Token: closing[0].Token})
+ plays := find[RecordPlay](effects)
+ if len(plays) != 1 || plays[0].Entry.Track != netease("a") || !plays[0].EndedAt.Equal(t0.Add(6*time.Minute)) {
+ t.Fatalf("关闭房间时应记下正在播放的歌:%#v", effects)
+ }
+ recordAt := slices.IndexFunc(effects, func(e Effect) bool { _, ok := e.(RecordPlay); return ok })
+ closeAt := slices.IndexFunc(effects, func(e Effect) bool { _, ok := e.(Close); return ok })
+ if closeAt < recordAt {
+ t.Fatalf("播放记录应在关闭房间之前写入:%#v", effects)
+ }
+}
+
+func TestEphemeralRoomCloseRecordsAnOverdueSongAtItsEnd(t *testing.T) {
+ r := newRoom(t, Ephemeral)
+ r.Handle(at(0), Join{Session: "s1", Member: alice})
+ playing(t, r, 0, netease("a"), 2*time.Minute)
+ ends := r.current.EndsAt()
+
+ closing := find[Schedule](r.Handle(at(time.Minute), Leave{Session: "s1"}))
+ if len(closing) != 1 {
+ t.Fatalf("最后一个会话离开后应开始无人计时:%#v", closing)
+ }
+ plays := find[RecordPlay](r.Handle(at(6*time.Minute), Timer{Token: closing[0].Token}))
+ if len(plays) != 1 || !plays[0].EndedAt.Equal(ends) {
+ t.Fatalf("已超过原定结束时间的歌应按原定结束时间记录:%#v", plays)
+ }
+}
+
+func TestMemberUpdatedRefreshesAllSessionsOfMember(t *testing.T) {
+ r := newRoom(t, Persistent)
+ r.Handle(at(0), Join{Session: "s1", Member: alice})
+ r.Handle(at(0), Join{Session: "s2", Member: alice})
+ r.Handle(at(0), Join{Session: "s3", Member: bob})
+
+ renamed := member.Member{ID: alice.ID, Name: "甲二"}
+ got := events[MembersChanged](r.Handle(at(time.Second), MemberUpdated{Member: renamed}))
+ want := []Session{{"s1", renamed}, {"s2", renamed}, {"s3", bob}}
+ if len(got) != 1 || !slices.Equal(got[0].Sessions, want) {
+ t.Fatalf("在线列表 = %#v,期望 %#v", got, want)
+ }
+ if effects := r.Handle(at(2*time.Second), MemberUpdated{Member: member.Member{ID: "m9"}}); len(effects) != 0 {
+ t.Fatalf("不在线的成员更新资料不应产生输出:%#v", effects)
+ }
+}
diff --git a/internal/domain/room/queue_test.go b/internal/domain/room/queue_test.go
new file mode 100644
index 0000000..8a42dba
--- /dev/null
+++ b/internal/domain/room/queue_test.go
@@ -0,0 +1,152 @@
+package room
+
+import (
+ "fmt"
+ "maps"
+ "slices"
+ "testing"
+ "time"
+
+ "github.com/dihemoe/alisten/internal/domain/member"
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+func TestDuplicatePick(t *testing.T) {
+ r := newRoom(t, Persistent)
+ r.Handle(at(0), enqueue(alice, netease("a")))
+ r.Handle(at(0), enqueue(alice, netease("b")))
+
+ wantError(t, r.Handle(at(time.Second), enqueue(bob, netease("a"))), Conflict)
+ wantError(t, r.Handle(at(time.Second), enqueue(bob, netease("b"))), Conflict)
+}
+
+func TestEphemeralRoomLimits(t *testing.T) {
+ r := newRoom(t, Ephemeral)
+ wantError(t, r.Handle(at(0), enqueue(alice, track.Track{Source: track.Link, ID: "https://example.com/a"})), Forbidden)
+
+ // 每分钟点一首,额度不会用完;第一首成为待播,之后 10 首进入等待队列。
+ for i := range 11 {
+ res := reply(t, r.Handle(at(time.Duration(i+1)*time.Minute), enqueue(alice, netease(string(rune('a'+i))))))
+ if res.Err != nil {
+ t.Fatalf("第 %d 首点歌失败:%#v", i+1, res)
+ }
+ }
+ wantError(t, r.Handle(at(13*time.Minute), enqueue(alice, netease("z"))), QueueFull)
+}
+
+func TestRateLimitsByRoomKind(t *testing.T) {
+ cases := []struct {
+ name string
+ kind Kind
+ burst int
+ }{
+ {"临时房间", Ephemeral, 5},
+ {"常驻房间", Persistent, 30},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ r := newRoom(t, tc.kind)
+ for i := range tc.burst {
+ if res := reply(t, r.Handle(at(0), enqueue(alice, netease(fmt.Sprint(i))))); res.Err != nil {
+ t.Fatalf("突发额度内的第 %d 次点歌失败:%#v", i+1, res)
+ }
+ }
+ if len(r.waiting) != tc.burst-1 {
+ t.Fatalf("等待队列有 %d 首,期望 %d 首:第一首成为待播,其余进入等待队列", len(r.waiting), tc.burst-1)
+ }
+ wantError(t, r.Handle(at(0), enqueue(alice, netease("x"))), RateLimited)
+ wantError(t, r.Handle(at(59*time.Second), enqueue(alice, netease("x"))), RateLimited)
+ wantError(t, r.Handle(at(59*time.Second), command(alice, Remove{Entry: 2})), RateLimited)
+
+ if res := reply(t, r.Handle(at(time.Minute), enqueue(alice, netease("x")))); res.Err != nil {
+ t.Fatalf("一分钟后恢复一次额度:%#v", res)
+ }
+ wantError(t, r.Handle(at(time.Minute), enqueue(alice, netease("y"))), RateLimited)
+ wantError(t, r.Handle(at(time.Minute), command(alice, Remove{Entry: 2})), RateLimited)
+
+ for i := range tc.burst {
+ if res := reply(t, r.Handle(at(time.Minute), command(bob, Like{Entry: 2}))); res.Err != nil {
+ t.Fatalf("点赞使用单独的额度,突发额度内的第 %d 次点赞失败:%#v", i+1, res)
+ }
+ }
+ wantError(t, r.Handle(at(time.Minute), command(bob, Like{Entry: 2})), RateLimited)
+ })
+ }
+}
+
+func TestRotationTellsRequestersApartByIDAndName(t *testing.T) {
+ cases := []struct {
+ name string
+ first, second member.Member
+ }{
+ {"bot 替不同的人点歌", member.Member{ID: "bot", Name: "张三(bot)"}, member.Member{ID: "bot", Name: "李四(bot)"}},
+ {"同名的不同成员", member.Member{ID: "m8", Name: "丁"}, member.Member{ID: "m9", Name: "丁"}},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ r := newRoom(t, Persistent)
+ r.Handle(at(0), command(alice, SetPlayMode{Mode: Rotation}))
+ playing(t, r, 0, netease("x"), time.Minute)
+ r.Handle(at(time.Second), enqueue(tc.first, netease("a")))
+ r.Handle(at(time.Second), enqueue(tc.first, netease("b")))
+ r.Handle(at(time.Second), enqueue(tc.second, netease("c")))
+ if got := ids(r.waiting); !slices.Equal(got, []EntryID{2, 4, 3}) {
+ t.Fatalf("应算作两个点歌人轮流播放:%v", got)
+ }
+ })
+ }
+}
+
+func TestRotationRemembersSongsThatHavePlayed(t *testing.T) {
+ r := newRoom(t, Persistent)
+ r.Handle(at(0), command(alice, SetPlayMode{Mode: Rotation}))
+ pickReady(r, 0, alice, "a1")
+ pickReady(r, 0, alice, "a2")
+ pickReady(r, 0, bob, "b1")
+ // 乙和丙轮流点歌,每次都等上一首放完才点:放过第 0 轮的人再点歌要排到第 1 轮,甲的第二首才不会一直被插队。
+ var played []string
+ for _, next := range []struct {
+ by member.Member
+ id string
+ }{{carol, "c1"}, {bob, "b2"}, {carol, "c2"}, {bob, "b3"}, {carol, "c3"}} {
+ now, song := finish(t, r)
+ played = append(played, song)
+ pickReady(r, now, next.by, next.id)
+ }
+ _, song := finish(t, r)
+ if played = append(played, song); !slices.Equal(played, []string{"a1", "b1", "c1", "a2", "b2", "c2"}) {
+ t.Fatalf("播放顺序 = %v,期望甲、乙、丙轮流", played)
+ }
+}
+
+func TestRotationForgetsFinishedRounds(t *testing.T) {
+ r := newRoom(t, Persistent)
+ r.Handle(at(0), command(alice, SetPlayMode{Mode: Rotation}))
+ pickReady(r, 0, alice, "a1")
+ pickReady(r, 0, bob, "b1")
+ pickReady(r, 0, alice, "a2")
+ finish(t, r)
+ now, _ := finish(t, r)
+ pickReady(r, now, carol, "c1")
+ if got := r.waiting[0].Round; got != 1 {
+ t.Fatalf("第 0 轮放完之后新来的丙应排进第 1 轮:%d", got)
+ }
+ if want := map[requester]int{requesterOf(alice): 1}; !maps.Equal(r.served, want) {
+ t.Fatalf("第 0 轮放完之后只需记着甲在第 1 轮开播:%v", r.served)
+ }
+}
+
+func TestRotationStartsOverInAnEmptyRoom(t *testing.T) {
+ r := newRoom(t, Persistent)
+ r.Handle(at(0), command(alice, SetPlayMode{Mode: Rotation}))
+ pickReady(r, 0, alice, "a1")
+ pickReady(r, 0, alice, "a2")
+ finish(t, r)
+ now, _ := finish(t, r)
+ pickReady(r, now, bob, "b1")
+ pickReady(r, now, alice, "a3")
+ pickReady(r, now, bob, "b2")
+ if got := ids(r.waiting); !slices.Equal(got, []EntryID{4, 5}) {
+ t.Fatalf("房间放空之后甲从第 0 轮重新开始,应排在乙的第二首前面:%v", got)
+ }
+}
diff --git a/internal/domain/room/restore_test.go b/internal/domain/room/restore_test.go
new file mode 100644
index 0000000..5c2ba8c
--- /dev/null
+++ b/internal/domain/room/restore_test.go
@@ -0,0 +1,94 @@
+package room
+
+import (
+ "cmp"
+ "slices"
+ "testing"
+ "time"
+)
+
+func TestRestore(t *testing.T) {
+ playingEntry := Entry{ID: 1, Track: netease("a"), Ready: true, Prep: ready(3 * time.Minute)}
+ snapshot := Snapshot{
+ Info: Info{ID: "r1", Kind: Persistent},
+ Mode: Sequential,
+ EntrySeq: 3,
+ Current: &NowPlaying{Entry: playingEntry, StartedAt: t0},
+ // 存储返回的顺序不保证符合排序规则。
+ Waiting: []Entry{
+ {ID: 2, Track: netease("b"), Ready: true, Prep: ready(time.Minute)},
+ {ID: 3, Track: netease("c"), Likes: 1},
+ },
+ }
+
+ t.Run("当前歌没放完", func(t *testing.T) {
+ r, effects := Restore(at(time.Minute), 2, snapshot)
+ if len(find[Persist](effects)) != 0 {
+ t.Fatalf("状态没变不落盘:%#v", effects)
+ }
+ if prep := find[Prepare](effects); len(prep) != 1 || prep[0].Track != netease("c") {
+ t.Fatalf("未就绪的条目重新准备:%#v", effects)
+ }
+ if s := find[Schedule](effects); len(s) != 1 || !s[0].At.Equal(t0.Add(3*time.Minute)) {
+ t.Fatalf("重新设定播完的定时器:%#v", effects)
+ }
+ if got := ids(r.waiting); !slices.Equal(got, []EntryID{3, 2}) {
+ t.Fatalf("等待队列应按点赞数排序:%v", got)
+ }
+ })
+
+ t.Run("当前歌已经放完", func(t *testing.T) {
+ r, effects := Restore(at(5*time.Minute), 2, snapshot)
+ if !persistsFirst(effects) {
+ t.Fatalf("状态变化时先落盘:%#v", effects)
+ }
+ if plays := find[RecordPlay](effects); len(plays) != 1 || !plays[0].EndedAt.Equal(t0.Add(3*time.Minute)) {
+ t.Fatalf("补记已经放完的歌:%#v", effects)
+ }
+ if r.pending == nil || r.pending.ID != 3 {
+ t.Fatalf("应选下一首(点赞数最多的未就绪条目):%#v", r.pending)
+ }
+ })
+
+ t.Run("重新设定的定时器能结束当前歌", func(t *testing.T) {
+ current := &NowPlaying{Entry: playingEntry, StartedAt: t0}
+ r, effects := Restore(at(time.Minute), 2, Snapshot{Info: snapshot.Info, Mode: Sequential, EntrySeq: 1, Current: current})
+ timers := find[Schedule](effects)
+ if len(timers) != 1 {
+ t.Fatalf("重新设定播完的定时器:%#v", effects)
+ }
+
+ effects = r.Handle(at(3*time.Minute), Timer{Token: timers[0].Token})
+ if plays := find[RecordPlay](effects); len(plays) != 1 || !plays[0].EndedAt.Equal(current.EndsAt()) {
+ t.Fatalf("到点应结束当前歌并记录:%#v", effects)
+ }
+ if got := events[PlaybackChanged](effects); len(got) != 1 || got[0].Current != nil {
+ t.Fatalf("等待队列为空时应进入空闲:%#v", effects)
+ }
+ })
+}
+
+func TestRestoreKeepsTheRotation(t *testing.T) {
+ r := newRoom(t, Persistent)
+ r.Handle(at(0), command(alice, SetPlayMode{Mode: Rotation}))
+ for _, id := range []string{"a1", "a2", "a3"} {
+ pickReady(r, 0, alice, id)
+ }
+ pickReady(r, 0, bob, "b1")
+ now, _ := finish(t, r)
+ pickReady(r, now, carol, "c1")
+ now, _ = finish(t, r)
+ // 乙的第一首已经放完,第二首按已开播的轮次排进第 1 轮。
+ pickReady(r, now, bob, "b2")
+ s := r.snapshot()
+ // 存储按条目 ID 返回等待队列。
+ slices.SortFunc(s.Waiting, func(a, b Entry) int { return cmp.Compare(a.ID, b.ID) })
+
+ restored, _ := Restore(at(now), 2, s)
+ if got, want := ids(restored.waiting), ids(r.waiting); !slices.Equal(got, want) {
+ t.Fatalf("恢复后的等待队列 = %v,期望与恢复前相同:%v", got, want)
+ }
+ if len(restored.served) != 0 {
+ t.Fatalf("已开播的轮次只在内存中,恢复后应为空:%v", restored.served)
+ }
+}
diff --git a/internal/domain/room/room_test.go b/internal/domain/room/room_test.go
new file mode 100644
index 0000000..7edae48
--- /dev/null
+++ b/internal/domain/room/room_test.go
@@ -0,0 +1,69 @@
+package room
+
+import (
+ "fmt"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+ "unicode/utf8"
+)
+
+func TestChatTruncatesTo500Characters(t *testing.T) {
+ r := newRoom(t, Persistent)
+ effects := r.Handle(at(time.Second), command(alice, Chat{Text: strings.Repeat("好", 600)}))
+
+ got := events[ChatPosted](effects)
+ if len(got) != 1 || utf8.RuneCountInString(got[0].Text) != 500 || got[0].Member != alice || !got[0].SentAt.Equal(t0.Add(time.Second)) {
+ t.Fatalf("聊天内容应截断为 500 个字符:%#v", effects)
+ }
+ if len(find[Persist](effects)) != 0 {
+ t.Fatal("聊天不落盘")
+ }
+ if got := events[ChatPosted](r.Handle(at(time.Second), command(alice, Chat{Text: " "}))); len(got) != 1 || got[0].Text != " " {
+ t.Fatal("空白内容照常广播")
+ }
+}
+
+func TestDeleteEndsThePlayingSong(t *testing.T) {
+ r := newRoom(t, Persistent)
+ playing(t, r, 0, netease("a"), 3*time.Minute)
+ started := r.current.StartedAt
+
+ effects := r.Handle(at(time.Minute), Delete{RequestID: "q"})
+ var kinds []string
+ for _, e := range effects {
+ kinds = append(kinds, fmt.Sprintf("%T", e))
+ }
+ // Close 会删除房间,播放记录必须先写入。
+ if want := []string{"room.Persist", "room.RecordPlay", "room.Emit", "room.Close", "room.Reply"}; !slices.Equal(kinds, want) {
+ t.Fatalf("删除时应依次落盘、记录播放、广播、关闭、回复:%v", kinds)
+ }
+ plays := find[RecordPlay](effects)
+ if plays[0].Entry.Track != netease("a") || !plays[0].StartedAt.Equal(started) || !plays[0].EndedAt.Equal(t0.Add(time.Minute)) {
+ t.Fatalf("删除房间时应记下正在播放的歌:%#v", plays)
+ }
+ if pb := events[PlaybackChanged](effects); len(pb) != 1 || pb[0].Current != nil {
+ t.Fatalf("删除房间时播放应停止:%#v", effects)
+ }
+}
+
+func TestDeleteRecordsAnOverdueSongAtItsEnd(t *testing.T) {
+ r := newRoom(t, Persistent)
+ playing(t, r, 0, netease("a"), 3*time.Minute)
+ ends := r.current.EndsAt()
+
+ plays := find[RecordPlay](r.Handle(at(10*time.Minute), Delete{RequestID: "q"}))
+ if len(plays) != 1 || !plays[0].EndedAt.Equal(ends) {
+ t.Fatalf("已超过原定结束时间的歌应按原定结束时间记录:%#v", plays)
+ }
+}
+
+func TestInputsWithTokenZeroAreDropped(t *testing.T) {
+ if effects := newRoom(t, Persistent).Handle(at(0), Timer{Token: 0}); len(effects) != 0 {
+ t.Fatalf("空闲的房间不应响应 token 为 0 的定时器:%#v", effects)
+ }
+ if effects := listenedRoom(t).Handle(at(time.Minute), Recommended{Token: 0, Track: netease("b")}); len(effects) != 0 {
+ t.Fatalf("没有待回的推荐时不应接受 token 为 0 的推荐:%#v", effects)
+ }
+}
diff --git a/internal/domain/room/testdata/autodj.txtar b/internal/domain/room/testdata/autodj.txtar
new file mode 100644
index 0000000..0b49738
--- /dev/null
+++ b/internal/domain/room/testdata/autodj.txtar
@@ -0,0 +1,39 @@
+AutoDJ:开播时等待队列为空、有人在听、10 秒内没人点歌,就补一首推荐的歌。
+-- 01 --
+> restore ephemeral
+schedule 1 5m0s
+-- 02 --
+> 0s join s1 m1
+emit 1.1 members [s1=m1]
+-- 03 --
+> 0s enqueue m1 netease:a
+persist mode=sequential entrySeq=1 current=- pending=#1(netease:a) waiting=[]
+emit 1.2 activity added m1 #1(netease:a)
+emit 1.3 queue pending=#1(netease:a) waiting=[]
+reply q03 enqueued #1(netease:a)
+prepare netease:a
+-- 04 --
+> 20s prepared netease:a 3m
+persist mode=sequential entrySeq=1 current=#1(netease:a,ready)@20.2s pending=- waiting=[]
+emit 1.4 queue pending=- waiting=[]
+emit 1.5 playback #1(netease:a,ready)@20.2s
+schedule 2 3m20.2s
+recommend 3
+-- 05 --
+> 21s recommended 3 netease:b
+persist mode=sequential entrySeq=2 current=#1(netease:a,ready)@20.2s pending=- waiting=[#2(netease:b,auto)]
+emit 1.6 activity added - #2(netease:b,auto)
+emit 1.7 queue pending=- waiting=[#2(netease:b,auto)]
+prepare netease:b
+-- 06 --
+> 22s prepared netease:b 2m
+persist mode=sequential entrySeq=2 current=#1(netease:a,ready)@20.2s pending=- waiting=[#2(netease:b,ready,auto)]
+emit 1.8 queue pending=- waiting=[#2(netease:b,ready,auto)]
+-- 07 --
+> 3m20.2s timer 2
+persist mode=sequential entrySeq=2 current=#2(netease:b,ready,auto)@3m20.4s pending=- waiting=[]
+record #1(netease:a,ready) 20.2s-3m20.2s
+emit 1.9 queue pending=- waiting=[]
+emit 1.10 playback #2(netease:b,ready,auto)@3m20.4s
+schedule 4 5m20.4s
+recommend 5
diff --git a/internal/domain/room/testdata/modes.txtar b/internal/domain/room/testdata/modes.txtar
new file mode 100644
index 0000000..c86a117
--- /dev/null
+++ b/internal/domain/room/testdata/modes.txtar
@@ -0,0 +1,54 @@
+播放模式与等待队列的顺序:顺序播放和随机播放按点赞数和入队顺序排列,轮流播放在点赞数相同时先比轮次。任何模式下入队都会定下轮次,切换模式时按新模式重排并广播。
+-- 01 --
+> restore persistent
+-- 02 --
+> 0s enqueue m1 netease:a1
+persist mode=sequential entrySeq=1 current=- pending=#1(netease:a1) waiting=[]
+emit 1.1 activity added m1 #1(netease:a1)
+emit 1.2 queue pending=#1(netease:a1) waiting=[]
+reply q02 enqueued #1(netease:a1)
+prepare netease:a1
+-- 03 --
+> 0s prepared netease:a1 1m
+persist mode=sequential entrySeq=1 current=#1(netease:a1,ready)@200ms pending=- waiting=[]
+emit 1.3 queue pending=- waiting=[]
+emit 1.4 playback #1(netease:a1,ready)@200ms
+schedule 1 1m0.2s
+-- 04 --
+> 1s enqueue m1 netease:a2
+persist mode=sequential entrySeq=2 current=#1(netease:a1,ready)@200ms pending=- waiting=[#2(netease:a2,round=1)]
+emit 1.5 activity added m1 #2(netease:a2,round=1)
+emit 1.6 queue pending=- waiting=[#2(netease:a2,round=1)]
+reply q04 enqueued #2(netease:a2,round=1)
+prepare netease:a2
+-- 05 --
+> 1s enqueue m1 netease:a3
+persist mode=sequential entrySeq=3 current=#1(netease:a1,ready)@200ms pending=- waiting=[#2(netease:a2,round=1) #3(netease:a3,round=2)]
+emit 1.7 activity added m1 #3(netease:a3,round=2)
+emit 1.8 queue pending=- waiting=[#2(netease:a2,round=1) #3(netease:a3,round=2)]
+reply q05 enqueued #3(netease:a3,round=2)
+prepare netease:a3
+-- 06 --
+> 2s enqueue m2 netease:b1
+persist mode=sequential entrySeq=4 current=#1(netease:a1,ready)@200ms pending=- waiting=[#2(netease:a2,round=1) #3(netease:a3,round=2) #4(netease:b1)]
+emit 1.9 activity added m2 #4(netease:b1)
+emit 1.10 queue pending=- waiting=[#2(netease:a2,round=1) #3(netease:a3,round=2) #4(netease:b1)]
+reply q06 enqueued #4(netease:b1)
+prepare netease:b1
+-- 07 --
+> 3s mode m2 rotation
+persist mode=rotation entrySeq=4 current=#1(netease:a1,ready)@200ms pending=- waiting=[#4(netease:b1) #2(netease:a2,round=1) #3(netease:a3,round=2)]
+emit 1.11 mode rotation
+emit 1.12 queue pending=- waiting=[#4(netease:b1) #2(netease:a2,round=1) #3(netease:a3,round=2)]
+reply q07 done
+-- 08 --
+> 4s mode m1 random
+persist mode=random entrySeq=4 current=#1(netease:a1,ready)@200ms pending=- waiting=[#2(netease:a2,round=1) #3(netease:a3,round=2) #4(netease:b1)]
+emit 1.13 mode random
+emit 1.14 queue pending=- waiting=[#2(netease:a2,round=1) #3(netease:a3,round=2) #4(netease:b1)]
+reply q08 done
+-- 09 --
+> 5s mode m1 sequential
+persist mode=sequential entrySeq=4 current=#1(netease:a1,ready)@200ms pending=- waiting=[#2(netease:a2,round=1) #3(netease:a3,round=2) #4(netease:b1)]
+emit 1.15 mode sequential
+reply q09 done
diff --git a/internal/domain/room/testdata/presence.txtar b/internal/domain/room/testdata/presence.txtar
new file mode 100644
index 0000000..246484e
--- /dev/null
+++ b/internal/domain/room/testdata/presence.txtar
@@ -0,0 +1,28 @@
+在线状态与聊天:会话加入、离开都广播在线列表;聊天照常广播,不落盘;临时房间最后一个会话离开 5 分钟后关闭,之前的计时作废。
+-- 01 --
+> restore ephemeral
+schedule 1 5m0s
+-- 02 --
+> 0s join s1 m1
+emit 1.1 members [s1=m1]
+-- 03 --
+> 0s join s2 m2
+emit 1.2 members [s1=m1 s2=m2]
+-- 04 --
+> 1s chat m1 你好 世界
+emit 1.3 chat m1 你好 世界
+reply q04 done
+-- 05 --
+> 2s leave s2
+emit 1.4 members [s1=m1]
+-- 06 --
+> 2s leave s2
+-- 07 --
+> 3s leave s1
+emit 1.5 members []
+schedule 2 5m3s
+-- 08 --
+> 5m0s timer 1
+-- 09 --
+> 5m3s timer 2
+close
diff --git a/internal/domain/room/testdata/queue.txtar b/internal/domain/room/testdata/queue.txtar
new file mode 100644
index 0000000..80a5e7e
--- /dev/null
+++ b/internal/domain/room/testdata/queue.txtar
@@ -0,0 +1,55 @@
+等待队列:点赞改变顺序,删除只针对等待队列;准备失败的条目移出队列,待播准备失败时选下一首。
+-- 01 --
+> restore persistent
+-- 02 --
+> 0s join s1 m1
+emit 1.1 members [s1=m1]
+-- 03 --
+> 0s enqueue m1 netease:a
+persist mode=sequential entrySeq=1 current=- pending=#1(netease:a) waiting=[]
+emit 1.2 activity added m1 #1(netease:a)
+emit 1.3 queue pending=#1(netease:a) waiting=[]
+reply q03 enqueued #1(netease:a)
+prepare netease:a
+-- 04 --
+> 0s enqueue m2 netease:b
+persist mode=sequential entrySeq=2 current=- pending=#1(netease:a) waiting=[#2(netease:b)]
+emit 1.4 activity added m2 #2(netease:b)
+emit 1.5 queue pending=#1(netease:a) waiting=[#2(netease:b)]
+reply q04 enqueued #2(netease:b)
+prepare netease:b
+-- 05 --
+> 0s enqueue m3 netease:c
+persist mode=sequential entrySeq=3 current=- pending=#1(netease:a) waiting=[#2(netease:b) #3(netease:c)]
+emit 1.6 activity added m3 #3(netease:c)
+emit 1.7 queue pending=#1(netease:a) waiting=[#2(netease:b) #3(netease:c)]
+reply q05 enqueued #3(netease:c)
+prepare netease:c
+-- 06 --
+> 1s like m2 3
+persist mode=sequential entrySeq=3 current=- pending=#1(netease:a) waiting=[#3(netease:c,likes=1) #2(netease:b)]
+emit 1.8 activity liked m2 #3(netease:c,likes=1)
+emit 1.9 queue pending=#1(netease:a) waiting=[#3(netease:c,likes=1) #2(netease:b)]
+reply q06 liked 1
+-- 07 --
+> 2s remove m3 1
+reply q07 error not-found 这首歌已经不在播放列表里了。
+-- 08 --
+> 2s remove m3 2
+persist mode=sequential entrySeq=3 current=- pending=#1(netease:a) waiting=[#3(netease:c,likes=1)]
+emit 1.10 activity removed m3 #2(netease:b)
+emit 1.11 queue pending=#1(netease:a) waiting=[#3(netease:c,likes=1)]
+reply q08 done
+-- 09 --
+> 3s failed netease:a 歌曲已下架
+persist mode=sequential entrySeq=3 current=- pending=#3(netease:c,likes=1) waiting=[]
+emit 1.12 activity failed - #1(netease:a) 歌曲已下架
+emit 1.13 queue pending=#3(netease:c,likes=1) waiting=[]
+-- 10 --
+> 4s failed netease:c 没有版权
+persist mode=sequential entrySeq=3 current=- pending=- waiting=[]
+emit 1.14 activity failed - #3(netease:c,likes=1) 没有版权
+emit 1.15 queue pending=- waiting=[]
+-- 11 --
+> 5s sync m1
+reply q11 sync term=1 seq=15 sessions=1
diff --git a/internal/domain/room/testdata/random.txtar b/internal/domain/room/testdata/random.txtar
new file mode 100644
index 0000000..bacec48
--- /dev/null
+++ b/internal/domain/room/testdata/random.txtar
@@ -0,0 +1,61 @@
+随机播放:切换到随机模式之后,下一首从等待队列中按本次处理的随机数选出。
+-- 01 --
+> restore persistent
+-- 02 --
+> 0s mode m1 random
+persist mode=random entrySeq=0 current=- pending=- waiting=[]
+emit 1.1 mode random
+reply q02 done
+-- 03 --
+> 0s enqueue m1 netease:a
+persist mode=random entrySeq=1 current=- pending=#1(netease:a) waiting=[]
+emit 1.2 activity added m1 #1(netease:a)
+emit 1.3 queue pending=#1(netease:a) waiting=[]
+reply q03 enqueued #1(netease:a)
+prepare netease:a
+-- 04 --
+> 0s enqueue m1 netease:b
+persist mode=random entrySeq=2 current=- pending=#1(netease:a) waiting=[#2(netease:b,round=1)]
+emit 1.4 activity added m1 #2(netease:b,round=1)
+emit 1.5 queue pending=#1(netease:a) waiting=[#2(netease:b,round=1)]
+reply q04 enqueued #2(netease:b,round=1)
+prepare netease:b
+-- 05 --
+> 0s enqueue m1 netease:c
+persist mode=random entrySeq=3 current=- pending=#1(netease:a) waiting=[#2(netease:b,round=1) #3(netease:c,round=2)]
+emit 1.6 activity added m1 #3(netease:c,round=2)
+emit 1.7 queue pending=#1(netease:a) waiting=[#2(netease:b,round=1) #3(netease:c,round=2)]
+reply q05 enqueued #3(netease:c,round=2)
+prepare netease:c
+-- 06 --
+> 0s enqueue m1 netease:d
+persist mode=random entrySeq=4 current=- pending=#1(netease:a) waiting=[#2(netease:b,round=1) #3(netease:c,round=2) #4(netease:d,round=3)]
+emit 1.8 activity added m1 #4(netease:d,round=3)
+emit 1.9 queue pending=#1(netease:a) waiting=[#2(netease:b,round=1) #3(netease:c,round=2) #4(netease:d,round=3)]
+reply q06 enqueued #4(netease:d,round=3)
+prepare netease:d
+-- 07 --
+> 1s prepared netease:a 1m
+persist mode=random entrySeq=4 current=#1(netease:a,ready)@1.2s pending=- waiting=[#2(netease:b,round=1) #3(netease:c,round=2) #4(netease:d,round=3)]
+emit 1.10 queue pending=- waiting=[#2(netease:b,round=1) #3(netease:c,round=2) #4(netease:d,round=3)]
+emit 1.11 playback #1(netease:a,ready)@1.2s
+schedule 1 1m1.2s
+-- 08 --
+> 1s prepared netease:b 1m
+persist mode=random entrySeq=4 current=#1(netease:a,ready)@1.2s pending=- waiting=[#2(netease:b,ready,round=1) #3(netease:c,round=2) #4(netease:d,round=3)]
+emit 1.12 queue pending=- waiting=[#2(netease:b,ready,round=1) #3(netease:c,round=2) #4(netease:d,round=3)]
+-- 09 --
+> 1s prepared netease:c 1m
+persist mode=random entrySeq=4 current=#1(netease:a,ready)@1.2s pending=- waiting=[#2(netease:b,ready,round=1) #3(netease:c,ready,round=2) #4(netease:d,round=3)]
+emit 1.13 queue pending=- waiting=[#2(netease:b,ready,round=1) #3(netease:c,ready,round=2) #4(netease:d,round=3)]
+-- 10 --
+> 1s prepared netease:d 1m
+persist mode=random entrySeq=4 current=#1(netease:a,ready)@1.2s pending=- waiting=[#2(netease:b,ready,round=1) #3(netease:c,ready,round=2) #4(netease:d,ready,round=3)]
+emit 1.14 queue pending=- waiting=[#2(netease:b,ready,round=1) #3(netease:c,ready,round=2) #4(netease:d,ready,round=3)]
+-- 11 --
+> 1m1.2s timer 1
+persist mode=random entrySeq=4 current=#3(netease:c,ready,round=2)@1m1.4s pending=- waiting=[#2(netease:b,ready,round=1) #4(netease:d,ready,round=3)]
+record #1(netease:a,ready) 1.2s-1m1.2s
+emit 1.15 queue pending=- waiting=[#2(netease:b,ready,round=1) #4(netease:d,ready,round=3)]
+emit 1.16 playback #3(netease:c,ready,round=2)@1m1.4s
+schedule 2 2m1.4s
diff --git a/internal/domain/room/testdata/rotation.txtar b/internal/domain/room/testdata/rotation.txtar
new file mode 100644
index 0000000..ec3b6bd
--- /dev/null
+++ b/internal/domain/room/testdata/rotation.txtar
@@ -0,0 +1,103 @@
+轮流播放:点赞数相同时先比轮次,各点歌人轮流。轮次在入队时定下:新歌排进最早还没放完的一轮,点歌人已有歌时排到他最后一首的下一轮,之后删除、出队都不改变其余歌的顺序。甲靠后的一首被点赞先放时,新来的丙仍排进第 0 轮;第 0 轮都放完后,甲再点歌从第 1 轮开始。
+-- 01 --
+> restore persistent
+-- 02 --
+> 0s mode m1 rotation
+persist mode=rotation entrySeq=0 current=- pending=- waiting=[]
+emit 1.1 mode rotation
+reply q02 done
+-- 03 --
+> 0s enqueue m1 netease:a1
+persist mode=rotation entrySeq=1 current=- pending=#1(netease:a1) waiting=[]
+emit 1.2 activity added m1 #1(netease:a1)
+emit 1.3 queue pending=#1(netease:a1) waiting=[]
+reply q03 enqueued #1(netease:a1)
+prepare netease:a1
+-- 04 --
+> 0s enqueue m1 netease:a2
+persist mode=rotation entrySeq=2 current=- pending=#1(netease:a1) waiting=[#2(netease:a2,round=1)]
+emit 1.4 activity added m1 #2(netease:a2,round=1)
+emit 1.5 queue pending=#1(netease:a1) waiting=[#2(netease:a2,round=1)]
+reply q04 enqueued #2(netease:a2,round=1)
+prepare netease:a2
+-- 05 --
+> 0s enqueue m2 netease:b1
+persist mode=rotation entrySeq=3 current=- pending=#1(netease:a1) waiting=[#3(netease:b1) #2(netease:a2,round=1)]
+emit 1.6 activity added m2 #3(netease:b1)
+emit 1.7 queue pending=#1(netease:a1) waiting=[#3(netease:b1) #2(netease:a2,round=1)]
+reply q05 enqueued #3(netease:b1)
+prepare netease:b1
+-- 06 --
+> 0s enqueue m2 netease:b2
+persist mode=rotation entrySeq=4 current=- pending=#1(netease:a1) waiting=[#3(netease:b1) #2(netease:a2,round=1) #4(netease:b2,round=1)]
+emit 1.8 activity added m2 #4(netease:b2,round=1)
+emit 1.9 queue pending=#1(netease:a1) waiting=[#3(netease:b1) #2(netease:a2,round=1) #4(netease:b2,round=1)]
+reply q06 enqueued #4(netease:b2,round=1)
+prepare netease:b2
+-- 07 --
+> 1s prepared netease:a1 1m
+persist mode=rotation entrySeq=4 current=#1(netease:a1,ready)@1.2s pending=- waiting=[#3(netease:b1) #2(netease:a2,round=1) #4(netease:b2,round=1)]
+emit 1.10 queue pending=- waiting=[#3(netease:b1) #2(netease:a2,round=1) #4(netease:b2,round=1)]
+emit 1.11 playback #1(netease:a1,ready)@1.2s
+schedule 1 1m1.2s
+-- 08 --
+> 2s enqueue m1 netease:a3
+persist mode=rotation entrySeq=5 current=#1(netease:a1,ready)@1.2s pending=- waiting=[#3(netease:b1) #2(netease:a2,round=1) #4(netease:b2,round=1) #5(netease:a3,round=2)]
+emit 1.12 activity added m1 #5(netease:a3,round=2)
+emit 1.13 queue pending=- waiting=[#3(netease:b1) #2(netease:a2,round=1) #4(netease:b2,round=1) #5(netease:a3,round=2)]
+reply q08 enqueued #5(netease:a3,round=2)
+prepare netease:a3
+-- 09 --
+> 3s like m3 5
+persist mode=rotation entrySeq=5 current=#1(netease:a1,ready)@1.2s pending=- waiting=[#5(netease:a3,likes=1,round=2) #3(netease:b1) #2(netease:a2,round=1) #4(netease:b2,round=1)]
+emit 1.14 activity liked m3 #5(netease:a3,likes=1,round=2)
+emit 1.15 queue pending=- waiting=[#5(netease:a3,likes=1,round=2) #3(netease:b1) #2(netease:a2,round=1) #4(netease:b2,round=1)]
+reply q09 liked 1
+-- 10 --
+> 1m1.2s timer 1
+persist mode=rotation entrySeq=5 current=- pending=#5(netease:a3,likes=1,round=2) waiting=[#3(netease:b1) #2(netease:a2,round=1) #4(netease:b2,round=1)]
+record #1(netease:a1,ready) 1.2s-1m1.2s
+emit 1.16 queue pending=#5(netease:a3,likes=1,round=2) waiting=[#3(netease:b1) #2(netease:a2,round=1) #4(netease:b2,round=1)]
+emit 1.17 playback -
+-- 11 --
+> 1m2s enqueue m3 netease:c1
+persist mode=rotation entrySeq=6 current=- pending=#5(netease:a3,likes=1,round=2) waiting=[#3(netease:b1) #6(netease:c1) #2(netease:a2,round=1) #4(netease:b2,round=1)]
+emit 1.18 activity added m3 #6(netease:c1)
+emit 1.19 queue pending=#5(netease:a3,likes=1,round=2) waiting=[#3(netease:b1) #6(netease:c1) #2(netease:a2,round=1) #4(netease:b2,round=1)]
+reply q11 enqueued #6(netease:c1)
+prepare netease:c1
+-- 12 --
+> 1m2s enqueue m3 netease:c2
+persist mode=rotation entrySeq=7 current=- pending=#5(netease:a3,likes=1,round=2) waiting=[#3(netease:b1) #6(netease:c1) #2(netease:a2,round=1) #4(netease:b2,round=1) #7(netease:c2,round=1)]
+emit 1.20 activity added m3 #7(netease:c2,round=1)
+emit 1.21 queue pending=#5(netease:a3,likes=1,round=2) waiting=[#3(netease:b1) #6(netease:c1) #2(netease:a2,round=1) #4(netease:b2,round=1) #7(netease:c2,round=1)]
+reply q12 enqueued #7(netease:c2,round=1)
+prepare netease:c2
+-- 13 --
+> 1m3s remove m1 2
+persist mode=rotation entrySeq=7 current=- pending=#5(netease:a3,likes=1,round=2) waiting=[#3(netease:b1) #6(netease:c1) #4(netease:b2,round=1) #7(netease:c2,round=1)]
+emit 1.22 activity removed m1 #2(netease:a2,round=1)
+emit 1.23 queue pending=#5(netease:a3,likes=1,round=2) waiting=[#3(netease:b1) #6(netease:c1) #4(netease:b2,round=1) #7(netease:c2,round=1)]
+reply q13 done
+-- 14 --
+> 1m4s failed netease:a3 没有版权
+persist mode=rotation entrySeq=7 current=- pending=#3(netease:b1) waiting=[#6(netease:c1) #4(netease:b2,round=1) #7(netease:c2,round=1)]
+emit 1.24 activity failed - #5(netease:a3,likes=1,round=2) 没有版权
+emit 1.25 queue pending=#3(netease:b1) waiting=[#6(netease:c1) #4(netease:b2,round=1) #7(netease:c2,round=1)]
+-- 15 --
+> 1m4s failed netease:b1 没有版权
+persist mode=rotation entrySeq=7 current=- pending=#6(netease:c1) waiting=[#4(netease:b2,round=1) #7(netease:c2,round=1)]
+emit 1.26 activity failed - #3(netease:b1) 没有版权
+emit 1.27 queue pending=#6(netease:c1) waiting=[#4(netease:b2,round=1) #7(netease:c2,round=1)]
+-- 16 --
+> 1m4s failed netease:c1 没有版权
+persist mode=rotation entrySeq=7 current=- pending=#4(netease:b2,round=1) waiting=[#7(netease:c2,round=1)]
+emit 1.28 activity failed - #6(netease:c1) 没有版权
+emit 1.29 queue pending=#4(netease:b2,round=1) waiting=[#7(netease:c2,round=1)]
+-- 17 --
+> 1m5s enqueue m1 netease:a4
+persist mode=rotation entrySeq=8 current=- pending=#4(netease:b2,round=1) waiting=[#7(netease:c2,round=1) #8(netease:a4,round=1)]
+emit 1.30 activity added m1 #8(netease:a4,round=1)
+emit 1.31 queue pending=#4(netease:b2,round=1) waiting=[#7(netease:c2,round=1) #8(netease:a4,round=1)]
+reply q17 enqueued #8(netease:a4,round=1)
+prepare netease:a4
diff --git a/internal/domain/room/testdata/sequential.txtar b/internal/domain/room/testdata/sequential.txtar
new file mode 100644
index 0000000..268767e
--- /dev/null
+++ b/internal/domain/room/testdata/sequential.txtar
@@ -0,0 +1,43 @@
+顺序播放:点歌后准备,开播;播完接着播下一首;队列播空后进入空闲。
+-- 01 --
+> restore persistent
+-- 02 --
+> 0s join s1 m1
+emit 1.1 members [s1=m1]
+-- 03 --
+> 1s enqueue m1 netease:a
+persist mode=sequential entrySeq=1 current=- pending=#1(netease:a) waiting=[]
+emit 1.2 activity added m1 #1(netease:a)
+emit 1.3 queue pending=#1(netease:a) waiting=[]
+reply q03 enqueued #1(netease:a)
+prepare netease:a
+-- 04 --
+> 2s enqueue m2 netease:b
+persist mode=sequential entrySeq=2 current=- pending=#1(netease:a) waiting=[#2(netease:b)]
+emit 1.4 activity added m2 #2(netease:b)
+emit 1.5 queue pending=#1(netease:a) waiting=[#2(netease:b)]
+reply q04 enqueued #2(netease:b)
+prepare netease:b
+-- 05 --
+> 3s prepared netease:b 1m
+persist mode=sequential entrySeq=2 current=- pending=#1(netease:a) waiting=[#2(netease:b,ready)]
+emit 1.6 queue pending=#1(netease:a) waiting=[#2(netease:b,ready)]
+-- 06 --
+> 4s prepared netease:a 2m
+persist mode=sequential entrySeq=2 current=#1(netease:a,ready)@4.2s pending=- waiting=[#2(netease:b,ready)]
+emit 1.7 queue pending=- waiting=[#2(netease:b,ready)]
+emit 1.8 playback #1(netease:a,ready)@4.2s
+schedule 1 2m4.2s
+-- 07 --
+> 2m4.2s timer 1
+persist mode=sequential entrySeq=2 current=#2(netease:b,ready)@2m4.4s pending=- waiting=[]
+record #1(netease:a,ready) 4.2s-2m4.2s
+emit 1.9 queue pending=- waiting=[]
+emit 1.10 playback #2(netease:b,ready)@2m4.4s
+schedule 2 3m4.4s
+recommend 3
+-- 08 --
+> 3m4.4s timer 2
+persist mode=sequential entrySeq=2 current=- pending=- waiting=[]
+record #2(netease:b,ready) 2m4.4s-3m4.4s
+emit 1.11 playback -
diff --git a/internal/domain/room/testdata/vote.txtar b/internal/domain/room/testdata/vote.txtar
new file mode 100644
index 0000000..5022935
--- /dev/null
+++ b/internal/domain/room/testdata/vote.txtar
@@ -0,0 +1,42 @@
+投票切歌:4 个会话需要 2 票,同一成员不能重复投票。
+-- 01 --
+> restore persistent
+-- 02 --
+> 0s join s1 m1
+emit 1.1 members [s1=m1]
+-- 03 --
+> 0s join s2 m2
+emit 1.2 members [s1=m1 s2=m2]
+-- 04 --
+> 0s join s3 m3
+emit 1.3 members [s1=m1 s2=m2 s3=m3]
+-- 05 --
+> 0s join s4 m3
+emit 1.4 members [s1=m1 s2=m2 s3=m3 s4=m3]
+-- 06 --
+> 1s enqueue m1 netease:a
+persist mode=sequential entrySeq=1 current=- pending=#1(netease:a) waiting=[]
+emit 1.5 activity added m1 #1(netease:a)
+emit 1.6 queue pending=#1(netease:a) waiting=[]
+reply q06 enqueued #1(netease:a)
+prepare netease:a
+-- 07 --
+> 1s prepared netease:a 3m
+persist mode=sequential entrySeq=1 current=#1(netease:a,ready)@1.2s pending=- waiting=[]
+emit 1.7 queue pending=- waiting=[]
+emit 1.8 playback #1(netease:a,ready)@1.2s
+schedule 1 3m1.2s
+-- 08 --
+> 10s vote m1
+emit 1.9 activity voted m1 #1(netease:a,ready) 1/2
+reply q08 voted 1/2
+-- 09 --
+> 11s vote m1
+reply q09 error conflict 你已经投过票了。
+-- 10 --
+> 12s vote m2
+persist mode=sequential entrySeq=1 current=- pending=- waiting=[]
+record #1(netease:a,ready) 1.2s-12s
+emit 1.10 activity skipped m2 #1(netease:a,ready) 2/2
+emit 1.11 playback -
+reply q10 voted 2/2 skipped
diff --git a/internal/domain/room/vote_test.go b/internal/domain/room/vote_test.go
new file mode 100644
index 0000000..41011e8
--- /dev/null
+++ b/internal/domain/room/vote_test.go
@@ -0,0 +1,74 @@
+package room
+
+import (
+ "fmt"
+ "testing"
+ "time"
+)
+
+func TestVoteSkipThreshold(t *testing.T) {
+ cases := []struct{ sessions, required int }{{0, 1}, {3, 1}, {4, 2}, {6, 2}, {7, 3}}
+ for _, tc := range cases {
+ t.Run(fmt.Sprintf("%d 个会话", tc.sessions), func(t *testing.T) {
+ r := newRoom(t, Persistent)
+ for i := range tc.sessions {
+ r.Handle(at(0), Join{Session: SessionID(fmt.Sprint("s", i)), Member: alice})
+ }
+ playing(t, r, 0, netease("a"), time.Minute)
+
+ want := VoteResult{Votes: 1, Required: tc.required, Skipped: tc.required == 1}
+ if res, ok := reply(t, r.Handle(at(time.Second), command(bob, VoteSkip{}))).Result.(VoteResult); !ok || res != want {
+ t.Fatalf("%d 个会话的投票结果 = %#v,期望 %#v", tc.sessions, res, want)
+ }
+ })
+ }
+}
+
+func TestVotesResetForNextSong(t *testing.T) {
+ r := newRoom(t, Persistent)
+ for _, s := range []SessionID{"s1", "s2", "s3", "s4"} {
+ r.Handle(at(0), Join{Session: s, Member: alice})
+ }
+ playing(t, r, 0, netease("a"), time.Minute)
+ r.Handle(at(0), enqueue(alice, netease("b")))
+ r.Handle(at(0), Prepared{Track: netease("b"), Result: ready(time.Minute)})
+ r.Handle(at(time.Second), command(alice, VoteSkip{}))
+
+ r.Handle(at(2*time.Minute), Timer{Token: r.endTimer})
+ if r.current == nil || r.current.Entry.Track != netease("b") {
+ t.Fatalf("第一首播完之后应开播下一首:%#v", r.current)
+ }
+ effects := r.Handle(at(2*time.Minute), command(bob, VoteSkip{}))
+ if res, ok := reply(t, effects).Result.(VoteResult); !ok || res != (VoteResult{Votes: 1, Required: 2}) {
+ t.Fatalf("下一首开播后重新计票:%#v", effects)
+ }
+ if res := reply(t, r.Handle(at(2*time.Minute), command(alice, VoteSkip{}))); res.Err != nil {
+ t.Fatalf("给上一首投过票的成员可以给下一首投票:%#v", res)
+ }
+}
+
+func TestVoteWithoutPlayback(t *testing.T) {
+ r := newRoom(t, Persistent)
+ wantError(t, r.Handle(at(0), command(alice, VoteSkip{})), NothingPlaying)
+ r.Handle(at(0), enqueue(alice, netease("a")))
+ wantError(t, r.Handle(at(time.Second), command(alice, VoteSkip{})), NothingPlaying)
+}
+
+func TestStaleTimerIsIgnored(t *testing.T) {
+ r := newRoom(t, Persistent)
+ playing(t, r, 0, netease("a"), 3*time.Minute)
+ stale := r.endTimer
+ r.Handle(at(time.Second), command(alice, VoteSkip{}))
+ if effects := r.Handle(at(3*time.Minute), Timer{Token: stale}); len(effects) != 0 {
+ t.Fatalf("已被切掉的歌的定时器应忽略:%#v", effects)
+ }
+}
+
+func TestSkipDuringStartLeadRecordsNoNegativeDuration(t *testing.T) {
+ r := newRoom(t, Persistent)
+ playing(t, r, 0, netease("a"), time.Minute)
+ plays := find[RecordPlay](r.Handle(at(0), command(alice, VoteSkip{})))
+ if len(plays) != 1 || !plays[0].EndedAt.Equal(plays[0].StartedAt) {
+ t.Fatalf("开播前就被切掉时,结束时间等于开始时间:%#v", plays)
+ }
+}
diff --git a/internal/domain/track/bilibili_test.go b/internal/domain/track/bilibili_test.go
new file mode 100644
index 0000000..9bd46ac
--- /dev/null
+++ b/internal/domain/track/bilibili_test.go
@@ -0,0 +1,56 @@
+package track
+
+import "testing"
+
+func TestParseBilibiliID(t *testing.T) {
+ type parsed struct {
+ bvid string
+ page int
+ }
+ valid := map[string]parsed{
+ "BV1xx411c7mD": {"BV1xx411c7mD", 1},
+ "BV1xx411c7mD/2": {"BV1xx411c7mD", 2},
+ "BV1xx411c7mD/10": {"BV1xx411c7mD", 10},
+ "BV1xx411c7mD/9999": {"BV1xx411c7mD", 9999},
+ }
+ for id, want := range valid {
+ bvid, page, ok := ParseBilibiliID(id)
+ if !ok || bvid != want.bvid || page != want.page {
+ t.Errorf("ParseBilibiliID(%q) = %q, %d, %v,期望 %q, %d, true", id, bvid, page, ok, want.bvid, want.page)
+ }
+ }
+ for _, id := range []string{
+ "", "av170001", "bv1xx411c7mD", "BV1xx411c7m", "BV1xx411c7mDD",
+ "BV1xx411c7m0", "BV1xx411c7mO", "BV1xx411c7mI", "BV1xx411c7ml",
+ "BV1xx411c7mD/", "BV1xx411c7mD/1", "BV1xx411c7mD/01", "BV1xx411c7mD/+2", "BV1xx411c7mD/10000",
+ "BV1xx411c7mD/2/3", "BV1xx411c7mD?p=2", " BV1xx411c7mD",
+ } {
+ if bvid, page, ok := ParseBilibiliID(id); ok {
+ t.Errorf("ParseBilibiliID(%q) = %q, %d, true,期望无效", id, bvid, page)
+ }
+ }
+}
+
+func TestBilibiliID(t *testing.T) {
+ for _, page := range []int{1, 2, 37, 9999} {
+ id := BilibiliID("BV1xx411c7mD", page)
+ if bvid, got, ok := ParseBilibiliID(id); !ok || bvid != "BV1xx411c7mD" || got != page {
+ t.Errorf("BilibiliID(第 %d P) = %q,解析回 %q, %d, %v", page, id, bvid, got, ok)
+ }
+ }
+ if id := BilibiliID("BV1xx411c7mD", 1); id != "BV1xx411c7mD" {
+ t.Errorf("第 1 P 的 ID = %q,期望不带序号", id)
+ }
+}
+
+func TestBilibiliPageURL(t *testing.T) {
+ tests := map[string]string{
+ "BV1xx411c7mD": "https://www.bilibili.com/video/BV1xx411c7mD",
+ "BV1xx411c7mD/3": "https://www.bilibili.com/video/BV1xx411c7mD?p=3",
+ }
+ for id, want := range tests {
+ if got := BilibiliPageURL(id); got != want {
+ t.Errorf("BilibiliPageURL(%q) = %q,期望 %q", id, got, want)
+ }
+ }
+}
diff --git a/internal/domain/track/track_test.go b/internal/domain/track/track_test.go
new file mode 100644
index 0000000..5721d43
--- /dev/null
+++ b/internal/domain/track/track_test.go
@@ -0,0 +1,20 @@
+package track
+
+import "testing"
+
+func TestLinkID(t *testing.T) {
+ tests := map[string]string{
+ "HTTPS://WWW.YouTube.COM/watch?v=dQw4w9WgXcQ": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
+ "Http://Example.com": "http://example.com",
+ "HTTPS://Example.com:8443/A/B?Q=1&a=2#Frag": "https://example.com:8443/A/B?Q=1&a=2#Frag",
+ "HTTPS://Example.com?Q=A": "https://example.com?Q=A",
+ "HTTPS://Example.com#Frag": "https://example.com#Frag",
+ "HTTPS://Example.com/%E4%BD%A0?x=%2F": "https://example.com/%E4%BD%A0?x=%2F",
+ "HTTPS://BÜCHER.Example/A": "https://bÜcher.example/A",
+ }
+ for link, want := range tests {
+ if got := LinkID(link); got != want {
+ t.Errorf("LinkID(%q) = %q,期望 %q", link, got, want)
+ }
+ }
+}
diff --git a/internal/lib/logctx/logctx_test.go b/internal/lib/logctx/logctx_test.go
new file mode 100644
index 0000000..d1615df
--- /dev/null
+++ b/internal/lib/logctx/logctx_test.go
@@ -0,0 +1,79 @@
+package logctx
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "log/slog"
+ "testing"
+)
+
+func record(t *testing.T, ctx context.Context) map[string]any {
+ t.Helper()
+ var buf bytes.Buffer
+ slog.New(NewHandler(slog.NewJSONHandler(&buf, nil))).With("component", "test").InfoContext(ctx, "消息", "error", "出错")
+ var entry map[string]any
+ if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
+ t.Fatalf("日志不是一行 JSON:%v\n%s", err, buf.Bytes())
+ }
+ return entry
+}
+
+func TestHandlerWritesFieldsFromContext(t *testing.T) {
+ ctx := WithJob(WithMember(WithRoom(WithRequest(context.Background(), "a/r1"), "room-1"), "member-1"), 42)
+ entry := record(t, ctx)
+ want := map[string]any{"request_id": "a/r1", "room_id": "room-1", "member_id": "member-1", "job_id": float64(42), "component": "test", "error": "出错"}
+ for key, value := range want {
+ if entry[key] != value {
+ t.Errorf("%s = %v,期望 %v", key, entry[key], value)
+ }
+ }
+}
+
+func TestInnerContextOverridesOuter(t *testing.T) {
+ outer := WithRoom(WithRequest(context.Background(), "a/r1"), "room-1")
+ inner := WithRequest(outer, "b/r2")
+ if got := record(t, inner); got["request_id"] != "b/r2" || got["room_id"] != "room-1" {
+ t.Errorf("内层的日志 = %v,期望 request_id 为 b/r2、room_id 仍为 room-1", got)
+ }
+ if got := record(t, outer); got["request_id"] != "a/r1" {
+ t.Errorf("外层的日志 = %v,期望 request_id 仍为 a/r1", got)
+ }
+}
+
+func withoutTime(groups []string, a slog.Attr) slog.Attr {
+ if len(groups) == 0 && a.Key == slog.TimeKey {
+ return slog.Attr{}
+ }
+ return a
+}
+
+func TestFieldsStayAtTopLevelInGroups(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(NewHandler(slog.NewJSONHandler(&buf, &slog.HandlerOptions{ReplaceAttr: withoutTime})))
+ logger = logger.With("component", "test").WithGroup("river").With("client", "c1").WithGroup("job")
+ logger.InfoContext(WithJob(WithRoom(context.Background(), "room-1"), 42), "消息", "attempt", 1)
+ want := `{"level":"INFO","msg":"消息","component":"test","room_id":"room-1","job_id":42,"river":{"client":"c1","job":{"attempt":1}}}` + "\n"
+ if got := buf.String(); got != want {
+ t.Errorf("日志 = %s期望 %s", got, want)
+ }
+}
+
+func TestDerivedLoggersAreIndependent(t *testing.T) {
+ var buf bytes.Buffer
+ // 逐个打开 group、逐个添加字段,记录它们的切片才会有多余的容量。
+ groups := slog.New(NewHandler(slog.NewJSONHandler(&buf, &slog.HandlerOptions{ReplaceAttr: withoutTime}))).WithGroup("a").WithGroup("b").WithGroup("c")
+ attrs := groups.With("p", 1).With("q", 2).With("r", 3)
+ for _, logger := range []*slog.Logger{groups.WithGroup("x"), groups.WithGroup("y"), attrs.With("s", "first"), attrs.With("s", "second"), attrs} {
+ logger.Info("消息", "n", 1)
+ }
+ want := `{"level":"INFO","msg":"消息","a":{"b":{"c":{"x":{"n":1}}}}}
+{"level":"INFO","msg":"消息","a":{"b":{"c":{"y":{"n":1}}}}}
+{"level":"INFO","msg":"消息","a":{"b":{"c":{"p":1,"q":2,"r":3,"s":"first","n":1}}}}
+{"level":"INFO","msg":"消息","a":{"b":{"c":{"p":1,"q":2,"r":3,"s":"second","n":1}}}}
+{"level":"INFO","msg":"消息","a":{"b":{"c":{"p":1,"q":2,"r":3,"n":1}}}}
+`
+ if got := buf.String(); got != want {
+ t.Errorf("日志 =\n%s期望\n%s", got, want)
+ }
+}
diff --git a/internal/lib/loudness/fuzz_test.go b/internal/lib/loudness/fuzz_test.go
new file mode 100644
index 0000000..58b6e15
--- /dev/null
+++ b/internal/lib/loudness/fuzz_test.go
@@ -0,0 +1,68 @@
+package loudness
+
+import (
+ "bytes"
+ "encoding/binary"
+ "math"
+ "testing"
+)
+
+func fuzzSamples(data []byte, maxSamples int) []float64 {
+ n := len(data) / 8
+ if n > maxSamples {
+ n = maxSamples
+ }
+ samples := make([]float64, n)
+ for i := range samples {
+ v := math.Float64frombits(binary.LittleEndian.Uint64(data[i*8:]))
+ if math.IsNaN(v) || math.IsInf(v, 0) {
+ v = 0
+ }
+ samples[i] = math.Max(-8, math.Min(8, v))
+ }
+ return samples
+}
+
+func FuzzAnalyzerSIMDParity(f *testing.F) {
+ if !hasAVX2 {
+ f.Skip("CPU 不支持 AVX2")
+ }
+ f.Add([]byte{}, uint16(0))
+ f.Add(make([]byte, 512), uint16(7))
+ f.Add(bytes.Repeat([]byte{0xFF}, 1024), uint16(513))
+
+ f.Fuzz(func(t *testing.T, data []byte, split uint16) {
+ samples := fuzzSamples(data, 16384)
+ sp := int(split) % (len(samples) + 1)
+
+ run := func(simd bool) (*Result, error) {
+ an := NewAnalyzer(48000, 2)
+ an.simdOK = simd
+ an.Write(samples[:sp])
+ an.Write(samples[sp:])
+ return an.Result()
+ }
+
+ want, errWant := run(false)
+ got, errGot := run(true)
+ if (errWant == nil) != (errGot == nil) {
+ t.Fatalf("错误不一致: 标量 %v, SIMD %v", errWant, errGot)
+ }
+ if errWant != nil {
+ return
+ }
+ if math.IsInf(want.Integrated, -1) {
+ if !math.IsInf(got.Integrated, -1) {
+ t.Fatalf("Integrated: 标量 -Inf, SIMD %v", got.Integrated)
+ }
+ } else if math.Abs(got.Integrated-want.Integrated) > 1e-9 {
+ t.Fatalf("Integrated 偏差过大: 标量 %v, SIMD %v", want.Integrated, got.Integrated)
+ }
+ if got.Peak != want.Peak {
+ t.Fatalf("Peak 不一致: 标量 %v, SIMD %v", want.Peak, got.Peak)
+ }
+ if got.Duration != want.Duration {
+ t.Fatalf("Duration 不一致: 标量 %v, SIMD %v", want.Duration, got.Duration)
+ }
+ })
+}
diff --git a/internal/lib/loudness/loudness_bench_test.go b/internal/lib/loudness/loudness_bench_test.go
new file mode 100644
index 0000000..910043f
--- /dev/null
+++ b/internal/lib/loudness/loudness_bench_test.go
@@ -0,0 +1,89 @@
+package loudness
+
+import (
+ "bytes"
+ "encoding/binary"
+ "math"
+ "testing"
+)
+
+func benchSamples() []float64 {
+ const fs = 48000
+ frames := fs * 60
+ samples := make([]float64, frames*2)
+ for f := 0; f < frames; f++ {
+ v := 0.25 * math.Sin(2*math.Pi*997*float64(f)/fs)
+ samples[f*2] = v
+ samples[f*2+1] = v
+ }
+ return samples
+}
+
+func BenchmarkAnalyzerWrite(b *testing.B) {
+ samples := benchSamples()
+ b.SetBytes(int64(len(samples)) * 8)
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ an := NewAnalyzer(48000, 2)
+ an.Write(samples)
+ if _, err := an.Result(); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+func BenchmarkAnalyzerWriteScalar(b *testing.B) {
+ samples := benchSamples()
+ b.SetBytes(int64(len(samples)) * 8)
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ an := NewAnalyzer(48000, 2)
+ an.simdOK = false
+ an.Write(samples)
+ if _, err := an.Result(); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+func benchWAV() []byte {
+ const (
+ fs = 44100
+ seconds = 180
+ ch = 2
+ bits = 16
+ )
+ frames := fs * seconds
+ dataSize := frames * ch * bits / 8
+ buf := bytes.NewBuffer(make([]byte, 0, 44+dataSize))
+ buf.WriteString("RIFF")
+ binary.Write(buf, binary.LittleEndian, uint32(36+dataSize))
+ buf.WriteString("WAVE")
+ buf.WriteString("fmt ")
+ binary.Write(buf, binary.LittleEndian, uint32(16))
+ binary.Write(buf, binary.LittleEndian, uint16(1)) // PCM
+ binary.Write(buf, binary.LittleEndian, uint16(ch))
+ binary.Write(buf, binary.LittleEndian, uint32(fs))
+ binary.Write(buf, binary.LittleEndian, uint32(fs*ch*bits/8))
+ binary.Write(buf, binary.LittleEndian, uint16(ch*bits/8))
+ binary.Write(buf, binary.LittleEndian, uint16(bits))
+ buf.WriteString("data")
+ binary.Write(buf, binary.LittleEndian, uint32(dataSize))
+ for f := 0; f < frames; f++ {
+ v := int16(0.25 * 32767 * math.Sin(2*math.Pi*997*float64(f)/fs))
+ binary.Write(buf, binary.LittleEndian, v)
+ binary.Write(buf, binary.LittleEndian, v)
+ }
+ return buf.Bytes()
+}
+
+func BenchmarkAnalyzeReaderWAV(b *testing.B) {
+ data := benchWAV()
+ b.SetBytes(int64(len(data)))
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ if _, err := AnalyzeReader(bytes.NewReader(data)); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
diff --git a/internal/lib/loudness/loudness_test.go b/internal/lib/loudness/loudness_test.go
new file mode 100644
index 0000000..6a8a188
--- /dev/null
+++ b/internal/lib/loudness/loudness_test.go
@@ -0,0 +1,302 @@
+package loudness
+
+import (
+ "fmt"
+ "math"
+ "math/rand"
+ "testing"
+)
+
+func TestGateBlocks(t *testing.T) {
+ absGate := math.Pow(10, (-70+0.691)/10)
+ for _, tc := range []struct {
+ name string
+ energies []float64
+ want float64
+ ok bool
+ }{
+ {"absolute boundary", []float64{absGate / 2, absGate}, 0, false},
+ {"above absolute", []float64{absGate / 2, absGate * 2}, absGate * 2, true},
+ {"relative gate", []float64{0, 0.001, 1, 1}, 1, true},
+ {"relative boundary", []float64{1, 19}, 19, true},
+ // 相对门限比绝对门限低时,不能把绝对门限以下的块重新算入。
+ {"retain absolute gate", []float64{absGate / 2, absGate * 2, absGate * 3}, absGate * 2.5, true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ got, ok := gateBlocks(tc.energies)
+ if ok != tc.ok || math.Abs(got-tc.want) > 1e-14*math.Abs(tc.want) {
+ t.Fatalf("gateBlocks = (%g, %v), want (%g, %v)", got, ok, tc.want, tc.ok)
+ }
+ })
+ }
+}
+
+func sine(sampleRate int, freq, amp, seconds float64) []float64 {
+ frames := int(float64(sampleRate) * seconds)
+ samples := make([]float64, frames*2)
+ for f := 0; f < frames; f++ {
+ v := amp * math.Sin(2*math.Pi*freq*float64(f)/float64(sampleRate))
+ samples[f*2] = v
+ samples[f*2+1] = v
+ }
+ return samples
+}
+
+func TestAnalyzeSamplesSine(t *testing.T) {
+ const fs = 48000
+ // 997 Hz、振幅 0.25:原始 RMS 对应约 -12.73 LUFS,
+ // K 加权在 1 kHz 附近约有 +0.6 dB 增益,预期约 -12.1 LUFS。
+ res, err := AnalyzeSamples(sine(fs, 997, 0.25, 3), fs, 2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if math.Abs(res.Integrated-(-12.1)) > 1.0 {
+ t.Errorf("Integrated = %.2f LUFS, 预期约 -12.1", res.Integrated)
+ }
+ if math.Abs(res.Peak-(-12.04)) > 0.5 {
+ t.Errorf("Peak = %.2f dBFS, 预期约 -12.04", res.Peak)
+ }
+ if res.Duration < 2900*1e6 || res.Duration > 3100*1e6 {
+ t.Errorf("Duration = %v, 预期约 3s", res.Duration)
+ }
+}
+
+func monoOf(stereo []float64) []float64 {
+ mono := make([]float64, len(stereo)/2)
+ for i := range mono {
+ mono[i] = stereo[2*i]
+ }
+ return mono
+}
+
+func TestAnalyzeSamplesMonoIsDualMono(t *testing.T) {
+ const fs = 48000
+ for _, tt := range []struct {
+ name string
+ seconds float64
+ }{
+ {"多个 400ms 块", 3},
+ {"不足 400ms", 0.3},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ dual := sine(fs, 997, 0.25, tt.seconds)
+ want, err := AnalyzeSamples(dual, fs, 2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := AnalyzeSamples(monoOf(dual), fs, 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if math.Abs(got.Integrated-want.Integrated) > 1e-9 || got.Peak != want.Peak || got.Duration != want.Duration {
+ t.Errorf("单声道 = %+v,期望与双声道相同:%+v", *got, *want)
+ }
+ // 振幅 0.25 的正弦波每个声道的均方是 0.03125,左右声道之和 0.0625 即 -12.04 dB;997 Hz 处 K 加权的增益与 -0.691 的修正基本抵消。
+ if math.Abs(got.Integrated-(-12.04)) > 0.1 {
+ t.Errorf("Integrated = %.2f LUFS,期望约 -12.04", got.Integrated)
+ }
+ })
+ }
+}
+
+// 单声道的权重在门控之前计入,与 libebur128 的 DUAL_MONO 一致。997 Hz 的正弦波振幅为 a 时,按单声道算约 20·log10(a) - 3.01 LUFS,
+// 振幅 3.55e-4 的安静段是 -72 LUFS,低于 -70 LUFS 的绝对门限;按双单声道算是 -69 LUFS,要参与综合响度。
+// 权重放到门控之后,这一段会被绝对门限丢掉,结果与复制成左右声道的立体声不同。
+func TestAnalyzeSamplesMonoWeightsBeforeGating(t *testing.T) {
+ const fs, quiet = 48000, 3.55e-4
+ for _, tt := range []struct {
+ name string
+ dual []float64
+ want float64 // LUFS
+ }{
+ // 安静段(-69 LUFS)之后是响 6 dB 的一段(-63 LUFS),两段等长:能量平均约 -65 LUFS,安静段在相对门限之上。
+ {"安静段和较响的一段", append(sine(fs, 997, quiet, 2), sine(fs, 997, 2*quiet, 2)...), -65},
+ {"不足 400ms 的安静段", sine(fs, 997, quiet, 0.3), -69},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ want, err := AnalyzeSamples(tt.dual, fs, 2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := AnalyzeSamples(monoOf(tt.dual), fs, 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if math.Abs(got.Integrated-want.Integrated) > 1e-9 {
+ t.Errorf("单声道 Integrated = %.3f LUFS,期望与双声道相同:%.3f", got.Integrated, want.Integrated)
+ }
+ // 两边都是 -Inf 时上一条比较不会失败(结果是 NaN),所以还要核对绝对值。
+ if math.Abs(got.Integrated-tt.want) > 0.1 {
+ t.Errorf("Integrated = %.3f LUFS,期望约 %v", got.Integrated, tt.want)
+ }
+ })
+ }
+}
+
+func interleave(mono []float64, gains ...float64) []float64 {
+ out := make([]float64, 0, len(mono)*len(gains))
+ for _, v := range mono {
+ for _, g := range gains {
+ out = append(out, g*v)
+ }
+ }
+ return out
+}
+
+func TestAnalyzeSamplesMultichannelAddsChannelEnergies(t *testing.T) {
+ const fs, ref = 48000, 0.25
+ tone := monoOf(sine(fs, 997, 1, 3))
+ stereo, err := AnalyzeSamples(interleave(tone, ref, ref), fs, 2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, gains := range [][]float64{
+ {0.05, 0.1, 0.15, 0.2, 0.25, 0.3},
+ } {
+ t.Run(fmt.Sprintf("%d 声道", len(gains)), func(t *testing.T) {
+ got, err := AnalyzeSamples(interleave(tone, gains...), fs, len(gains))
+ if err != nil {
+ t.Fatal(err)
+ }
+ squares := 0.0
+ for _, g := range gains {
+ squares += g * g
+ }
+ want := stereo.Integrated + 10*math.Log10(squares/(2*ref*ref))
+ if math.Abs(got.Integrated-want) > 1e-6 {
+ t.Errorf("Integrated = %.4f LUFS,期望 %.4f", got.Integrated, want)
+ }
+ })
+ }
+}
+
+func TestAnalyzeSamplesSilence(t *testing.T) {
+ res, err := AnalyzeSamples(make([]float64, 48000*2), 48000, 2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !math.IsInf(res.Integrated, -1) {
+ t.Errorf("静音 Integrated = %v, 预期 -Inf", res.Integrated)
+ }
+ if g := res.SpotifyGain(); g != 0 {
+ t.Errorf("静音 SpotifyGain = %v, 预期 0", g)
+ }
+}
+
+func TestAnalyzeSamplesInvalid(t *testing.T) {
+ if _, err := AnalyzeSamples(nil, 48000, 2); err == nil {
+ t.Error("空采样应返回错误")
+ }
+ if _, err := AnalyzeSamples([]float64{0}, 0, 2); err == nil {
+ t.Error("非法采样率应返回错误")
+ }
+}
+
+func TestSpotifyGain(t *testing.T) {
+ cases := []struct {
+ name string
+ r Result
+ want float64
+ }{
+ {"正常提升", Result{Integrated: -20, Peak: -10}, 6},
+ // 响度 -20 LUFS、峰值 -2 dBFS:增益 +6 dB 会使峰值达到 +4 dBFS,被限制到 -1 - (-2) = +1 dB。
+ {"峰值限制", Result{Integrated: -20, Peak: -2}, 1},
+ {"需要降低", Result{Integrated: -10, Peak: -6}, -4},
+ }
+ for _, c := range cases {
+ if got := c.r.SpotifyGain(); math.Abs(got-c.want) > 1e-9 {
+ t.Errorf("%s: SpotifyGain = %v, 预期 %v", c.name, got, c.want)
+ }
+ }
+}
+
+func TestCoefficient(t *testing.T) {
+ // -14 - (-20) = +6 dB,系数约 1.995。
+ r := &Result{Integrated: -20, Peak: -10}
+ if c := r.Coefficient(); math.Abs(c-1.9953) > 0.001 {
+ t.Errorf("Coefficient = %v, 预期约 1.995", c)
+ }
+ // 响度 -8 LUFS 高于目标:-14 - (-8) = -6 dB,系数约 0.501。
+ r = &Result{Integrated: -8, Peak: -3}
+ if c := r.Coefficient(); math.Abs(c-0.5012) > 0.001 {
+ t.Errorf("Coefficient = %v, 预期约 0.501", c)
+ }
+ r = &Result{Integrated: math.Inf(-1), Peak: math.Inf(-1)}
+ if c := r.Coefficient(); c != 1 {
+ t.Errorf("静音 Coefficient = %v, 预期 1", c)
+ }
+}
+
+func TestAnalyzerChunkedParity(t *testing.T) {
+ const fs = 44100
+ samples := sine(fs, 997, 0.25, 3)
+
+ whole, err := AnalyzeSamples(samples, fs, 2)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // 以不规整的小块写入(奇数长度,跨越声道和段的边界)。
+ an := NewAnalyzer(fs, 2)
+ for off := 0; off < len(samples); {
+ n := min(997, len(samples)-off)
+ an.Write(samples[off : off+n])
+ off += n
+ }
+ chunked, err := an.Result()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if math.Abs(chunked.Integrated-whole.Integrated) > 1e-6 {
+ t.Errorf("流式 Integrated = %v, 整体 = %v", chunked.Integrated, whole.Integrated)
+ }
+ if chunked.Peak != whole.Peak {
+ t.Errorf("流式 Peak = %v, 整体 = %v", chunked.Peak, whole.Peak)
+ }
+ if chunked.Duration != whole.Duration {
+ t.Errorf("流式 Duration = %v, 整体 = %v", chunked.Duration, whole.Duration)
+ }
+}
+
+func TestAVX2ScalarParity(t *testing.T) {
+ if !hasAVX2 {
+ t.Skip("CPU 不支持 AVX2")
+ }
+ const fs = 48000
+ // 伪随机采样加静音段,覆盖门控与滤波器状态。
+ rng := rand.New(rand.NewSource(42))
+ samples := make([]float64, fs*5*2)
+ for i := range samples {
+ if i/(fs/5)%2 == 0 {
+ samples[i] = rng.Float64()*2 - 1
+ }
+ }
+
+ run := func(simd bool) *Result {
+ an := NewAnalyzer(fs, 2)
+ an.simdOK = simd
+ for off := 0; off < len(samples); {
+ n := min(999, len(samples)-off) // 奇数长度的块,触发标量边界处理
+ an.Write(samples[off : off+n])
+ off += n
+ }
+ res, err := an.Result()
+ if err != nil {
+ t.Fatal(err)
+ }
+ return res
+ }
+
+ want := run(false)
+ got := run(true)
+ if diff := math.Abs(got.Integrated - want.Integrated); diff > 1e-6 {
+ t.Errorf("Integrated 偏差 = %v (AVX2 %v vs 标量 %v)", diff, got.Integrated, want.Integrated)
+ }
+ if got.Peak != want.Peak {
+ t.Errorf("Peak: AVX2 %v vs 标量 %v", got.Peak, want.Peak)
+ }
+ if got.Duration != want.Duration {
+ t.Errorf("Duration: AVX2 %v vs 标量 %v", got.Duration, want.Duration)
+ }
+}
diff --git a/internal/lib/loudness/mp3_test.go b/internal/lib/loudness/mp3_test.go
new file mode 100644
index 0000000..791fa1b
--- /dev/null
+++ b/internal/lib/loudness/mp3_test.go
@@ -0,0 +1,56 @@
+package loudness
+
+import (
+ "bytes"
+ "math"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+// testdata 中的 MP3 是 3 秒的 LAME 恒定码率文件,内容是粉红噪声,或左右声道不同的两组音调。在 testdata 目录中重新生成:
+//
+// ffmpeg -nostdin -loglevel error -f lavfi -i 'anoisesrc=color=pink:amplitude=0.25:sample_rate=44100:duration=3:seed=7' -c:a libmp3lame -b:a 32k -fflags +bitexact -map_metadata -1 pink-44k1-32k-mono.mp3
+// ffmpeg -nostdin -loglevel error -f lavfi -i 'anoisesrc=color=pink:amplitude=0.25:sample_rate=22050:duration=3:seed=7' -c:a libmp3lame -b:a 32k -fflags +bitexact -map_metadata -1 pink-22k05-32k-mono.mp3
+// ffmpeg -nostdin -loglevel error -f lavfi -i "aevalsrc='0.2*sin(2*PI*220*t)+0.1*sin(2*PI*3300*t)|0.15*sin(2*PI*330*t)+0.12*sin(2*PI*2500*t)':s=22050:d=3" -c:a libmp3lame -b:a 32k -fflags +bitexact -map_metadata -1 tones-22k05-32k-stereo.mp3
+//
+// 参考值是 ffmpeg 解码同一个文件得到的采样(单声道复制成左右声道)交给 AnalyzeSamples 的结果,采样用
+// ffmpeg -nostdin -loglevel error -i 文件 -f f64le - 取得。ffmpeg n9.0.2 生成的文件与仓库中的逐字节相同;
+// 其他版本生成的字节可能不同,参考值要按新文件重新计算。
+//
+// 22.05 kHz 的低码率 MP3 解码出错时,采样会削波,响度偏高几 dB,所以要核对响度和峰值,不能只看能否解码。
+// 容差 0.05 dB:gl3d 与 ffmpeg 解码出的采样只差浮点误差,容差留给其他平台上乘加融合造成的差异。
+// 时长正好是 3 秒:Info 帧里的 LAME 标签有效,gl3d 把编码器在两端加的延迟和填充裁掉了,这条断言也固定了裁剪。
+func TestAnalyzeReaderMP3MatchesReference(t *testing.T) {
+ tests := []struct {
+ file string
+ integrated float64 // LUFS
+ peak float64 // dBFS
+ }{
+ {"pink-44k1-32k-mono.mp3", -24.964, -14.83},
+ {"pink-22k05-32k-mono.mp3", -24.913, -14.64},
+ {"tones-22k05-32k-stereo.mp3", -13.471, -10.54},
+ }
+ for _, tt := range tests {
+ t.Run(tt.file, func(t *testing.T) {
+ data, err := os.ReadFile(filepath.Join("testdata", tt.file))
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := AnalyzeReader(bytes.NewReader(data))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if math.Abs(got.Integrated-tt.integrated) > 0.05 {
+ t.Errorf("Integrated = %.3f LUFS,期望 %.3f", got.Integrated, tt.integrated)
+ }
+ if math.Abs(got.Peak-tt.peak) > 0.05 {
+ t.Errorf("Peak = %.2f dBFS,期望 %.2f", got.Peak, tt.peak)
+ }
+ if got.Duration != 3*time.Second {
+ t.Errorf("Duration = %v,期望 3 秒", got.Duration)
+ }
+ })
+ }
+}
diff --git a/internal/lib/loudness/reader_test.go b/internal/lib/loudness/reader_test.go
new file mode 100644
index 0000000..185b9eb
--- /dev/null
+++ b/internal/lib/loudness/reader_test.go
@@ -0,0 +1,98 @@
+package loudness
+
+import (
+ "bytes"
+ "encoding/binary"
+ "math"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/dihemoe/alisten/internal/lib/wav"
+)
+
+func pcm16WAV(samples []float64, sampleRate, channels int) ([]byte, []float64) {
+ decoded := make([]float64, len(samples))
+ data := make([]byte, 0, len(samples)*2)
+ for i, v := range samples {
+ s := int16(math.Round(v * 32767))
+ decoded[i] = float64(s) / 32768
+ data = binary.LittleEndian.AppendUint16(data, uint16(s))
+ }
+ b := []byte("RIFF")
+ b = binary.LittleEndian.AppendUint32(b, uint32(36+len(data)))
+ b = append(b, "WAVEfmt "...)
+ b = binary.LittleEndian.AppendUint32(b, 16)
+ b = binary.LittleEndian.AppendUint16(b, 1)
+ b = binary.LittleEndian.AppendUint16(b, uint16(channels))
+ b = binary.LittleEndian.AppendUint32(b, uint32(sampleRate))
+ b = binary.LittleEndian.AppendUint32(b, uint32(sampleRate*channels*2))
+ b = binary.LittleEndian.AppendUint16(b, uint16(channels*2))
+ b = binary.LittleEndian.AppendUint16(b, 16)
+ b = append(b, "data"...)
+ b = binary.LittleEndian.AppendUint32(b, uint32(len(data)))
+ return append(b, data...), decoded
+}
+
+func TestAnalyzeReaderMatchesSamples(t *testing.T) {
+ const fs = 48000
+ file, decoded := pcm16WAV(sine(fs, 997, 0.25, 3), fs, 2)
+ want, err := AnalyzeSamples(decoded, fs, 2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := AnalyzeReader(bytes.NewReader(file))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if math.Abs(got.Integrated-want.Integrated) > 1e-9 || math.Abs(got.Peak-want.Peak) > 1e-9 || got.Duration != want.Duration {
+ t.Errorf("AnalyzeReader = %+v,期望与 AnalyzeSamples 相同:%+v", *got, *want)
+ }
+}
+
+func wavTestdata(t *testing.T, name string) []byte {
+ t.Helper()
+ data, err := os.ReadFile(filepath.Join("..", "wav", "testdata", name))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return data
+}
+
+func TestAnalyzeReaderMonoMatchesDualMono(t *testing.T) {
+ const fs = 48000
+ wavFile, _ := pcm16WAV(monoOf(sine(fs, 997, 0.25, 3)), fs, 1)
+ for _, tt := range []struct {
+ name string
+ file []byte
+ }{
+ {"WAV", wavFile},
+ {"FLAC", wavTestdata(t, "sine.flac")},
+ {"MP3", wavTestdata(t, "sine-mpeg1.mp3")},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ a, err := wav.Decode(bytes.NewReader(tt.file))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if a.Channels != 1 {
+ t.Fatalf("解码为 %d 声道,期望单声道", a.Channels)
+ }
+ dual := make([]float64, 0, 2*len(a.Samples))
+ for _, v := range a.Samples {
+ dual = append(dual, v, v)
+ }
+ want, err := AnalyzeSamples(dual, a.SampleRate, 2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := AnalyzeReader(bytes.NewReader(tt.file))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if math.Abs(got.Integrated-want.Integrated) > 1e-9 || got.Peak != want.Peak || got.Duration != want.Duration {
+ t.Errorf("AnalyzeReader = %+v,期望与双声道相同:%+v", *got, *want)
+ }
+ })
+ }
+}
diff --git a/internal/lib/loudness/testdata/pink-22k05-32k-mono.mp3 b/internal/lib/loudness/testdata/pink-22k05-32k-mono.mp3
new file mode 100644
index 0000000..15fbd69
Binary files /dev/null and b/internal/lib/loudness/testdata/pink-22k05-32k-mono.mp3 differ
diff --git a/internal/lib/loudness/testdata/pink-44k1-32k-mono.mp3 b/internal/lib/loudness/testdata/pink-44k1-32k-mono.mp3
new file mode 100644
index 0000000..3563060
Binary files /dev/null and b/internal/lib/loudness/testdata/pink-44k1-32k-mono.mp3 differ
diff --git a/internal/lib/loudness/testdata/tones-22k05-32k-stereo.mp3 b/internal/lib/loudness/testdata/tones-22k05-32k-stereo.mp3
new file mode 100644
index 0000000..57278ef
Binary files /dev/null and b/internal/lib/loudness/testdata/tones-22k05-32k-stereo.mp3 differ
diff --git a/internal/lib/wav/decode_test.go b/internal/lib/wav/decode_test.go
new file mode 100644
index 0000000..2f73cfb
--- /dev/null
+++ b/internal/lib/wav/decode_test.go
@@ -0,0 +1,284 @@
+package wav
+
+import (
+ "bytes"
+ "context"
+ "encoding/binary"
+ "errors"
+ "io"
+ "math"
+ "os"
+ "path/filepath"
+ "runtime"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/DiheMoe/gl3d"
+)
+
+func chunk(id string, body []byte) []byte {
+ b := append([]byte(id), binary.LittleEndian.AppendUint32(nil, uint32(len(body)))...)
+ b = append(b, body...)
+ if len(body)%2 == 1 {
+ b = append(b, 0)
+ }
+ return b
+}
+
+func fmtChunk(format uint16, channels, rate int, bits uint16) []byte {
+ b := binary.LittleEndian.AppendUint16(nil, format)
+ b = binary.LittleEndian.AppendUint16(b, uint16(channels))
+ b = binary.LittleEndian.AppendUint32(b, uint32(rate))
+ align := uint16(channels) * bits / 8
+ b = binary.LittleEndian.AppendUint32(b, uint32(rate)*uint32(align))
+ b = binary.LittleEndian.AppendUint16(b, align)
+ return binary.LittleEndian.AppendUint16(b, bits)
+}
+
+func riff(chunks ...[]byte) []byte {
+ body := []byte("WAVE")
+ for _, c := range chunks {
+ body = append(body, c...)
+ }
+ return append(append([]byte("RIFF"), binary.LittleEndian.AppendUint32(nil, uint32(len(body)))...), body...)
+}
+
+func le[T int16 | int32 | uint32 | uint64](values ...T) []byte {
+ var b bytes.Buffer
+ for _, v := range values {
+ if err := binary.Write(&b, binary.LittleEndian, v); err != nil {
+ panic(err)
+ }
+ }
+ return b.Bytes()
+}
+
+func int24(values ...int32) []byte {
+ var b []byte
+ for _, v := range values {
+ b = append(b, byte(v), byte(v>>8), byte(v>>16))
+ }
+ return b
+}
+
+func TestDecodeWAV(t *testing.T) {
+ // WAVE_FORMAT_EXTENSIBLE:fmt 块 40 字节,子格式 GUID 的前两个字节是真正的格式。
+ extensible := append(fmtChunk(formatExtensible, 1, 8000, 16), le[int16](22, 16)...)
+ extensible = append(extensible, le[uint32](0)...)
+ extensible = append(extensible, binary.LittleEndian.AppendUint16(nil, formatPCM)...)
+ extensible = append(extensible, make([]byte, 14)...)
+ tests := []struct {
+ name string
+ file []byte
+ channels int
+ want []float64
+ }{
+ {"8 位无符号", riff(chunk("fmt ", fmtChunk(formatPCM, 1, 8000, 8)), chunk("data", []byte{0, 64, 128, 192, 255})),
+ 1, []float64{-1, -0.5, 0, 0.5, 127.0 / 128}},
+ {"16 位双声道", riff(chunk("fmt ", fmtChunk(formatPCM, 2, 8000, 16)), chunk("data", le[int16](-32768, 16384, 0, 32767))),
+ 2, []float64{-1, 0.5, 0, 32767.0 / 32768}},
+ {"24 位", riff(chunk("fmt ", fmtChunk(formatPCM, 1, 8000, 24)), chunk("data", int24(-8388608, 4194304, -1))),
+ 1, []float64{-1, 0.5, -1.0 / 8388608}},
+ {"32 位", riff(chunk("fmt ", fmtChunk(formatPCM, 1, 8000, 32)), chunk("data", le[int32](math.MinInt32, 1<<30))),
+ 1, []float64{-1, 0.5}},
+ {"32 位浮点", riff(chunk("fmt ", fmtChunk(formatFloat, 1, 8000, 32)), chunk("data", le(math.Float32bits(-1), math.Float32bits(0.25)))),
+ 1, []float64{-1, 0.25}},
+ {"64 位浮点", riff(chunk("fmt ", fmtChunk(formatFloat, 1, 8000, 64)), chunk("data", le(math.Float64bits(-0.75), math.Float64bits(1)))),
+ 1, []float64{-0.75, 1}},
+ {"WAVE_FORMAT_EXTENSIBLE", riff(chunk("fmt ", extensible), chunk("data", le[int16](16384))),
+ 1, []float64{0.5}},
+ {"跳过 data 之前的其他块,包括奇数长度块的填充字节", riff(chunk("fmt ", fmtChunk(formatPCM, 1, 8000, 16)), chunk("LIST", []byte("abc")), chunk("data", le[int16](-16384))),
+ 1, []float64{-0.5}},
+ {"data 末尾不足一个采样的字节忽略", riff(chunk("fmt ", fmtChunk(formatPCM, 1, 8000, 16)), chunk("data", append(le[int16](16384, -16384), 7))),
+ 1, []float64{0.5, -0.5}},
+ {"fmt 块比用到的字节更长,包括奇数长度的填充字节", riff(chunk("fmt ", append(fmtChunk(formatPCM, 1, 8000, 16), make([]byte, 35)...)), chunk("data", le[int16](-16384))),
+ 1, []float64{-0.5}},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ a, err := Decode(bytes.NewReader(tt.file))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if a.SampleRate != 8000 || a.Channels != tt.channels || !slices.Equal(a.Samples, tt.want) {
+ t.Errorf("解码为 %d Hz、%d 声道、%v,期望 8000 Hz、%d 声道、%v", a.SampleRate, a.Channels, a.Samples, tt.channels, tt.want)
+ }
+ if want := time.Duration(len(tt.want)/tt.channels) * time.Second / 8000; a.Duration() != want {
+ t.Errorf("Duration = %v,期望 %v", a.Duration(), want)
+ }
+ })
+ }
+}
+
+func TestDecodeRejectsMalformedWAV(t *testing.T) {
+ pcm16 := chunk("fmt ", fmtChunk(formatPCM, 1, 8000, 16))
+ tests := []struct {
+ name string
+ file []byte
+ want string
+ }{
+ {"不是 WAVE", append([]byte("RIFF\x04\x00\x00\x00WAVX"), make([]byte, 8)...), "无法识别的音频格式"},
+ {"data 在 fmt 之前", riff(chunk("data", le[int16](1)), pcm16), "缺少 fmt 块"},
+ {"没有 data 块", riff(pcm16), "缺少 data 块"},
+ {"MP3 编码的 WAV", riff(chunk("fmt ", fmtChunk(0x55, 1, 8000, 16)), chunk("data", le[int16](1))), "不支持的音频格式 0x55"},
+ {"4 位", riff(chunk("fmt ", fmtChunk(formatPCM, 1, 8000, 4)), chunk("data", []byte{1})), "不支持的位深 4"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if _, err := Decode(bytes.NewReader(tt.file)); err == nil || !strings.Contains(err.Error(), tt.want) {
+ t.Errorf("错误 = %v,应包含 %q", err, tt.want)
+ }
+ })
+ }
+}
+
+func TestDecodeDoesNotAllocateByDeclaredChunkSize(t *testing.T) {
+ header := append([]byte("RIFF"), le[uint32](0xFFFFFFFF)...)
+ header = append(header, "WAVEfmt "...)
+ header = append(header, le[uint32](0xFFFFFFF0)...)
+ for _, tt := range []struct {
+ name string
+ body int
+ }{
+ {"文件在 fmt 块用到的字节之前结束", 24},
+ {"文件在 fmt 块用到的字节之后结束", 100},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ file := append(slices.Clone(header), make([]byte, tt.body)...)
+ var before, after runtime.MemStats
+ runtime.ReadMemStats(&before)
+ _, err := Decode(bytes.NewReader(file))
+ runtime.ReadMemStats(&after)
+ if err == nil || !strings.Contains(err.Error(), "读取 fmt 块失败") {
+ t.Errorf("错误 = %v,应包含 %q", err, "读取 fmt 块失败")
+ }
+ if alloc := after.TotalAlloc - before.TotalAlloc; alloc > 1<<20 {
+ t.Errorf("fmt 块声明了 %d 字节,文件只有 %d 字节,解码却分配了 %d 字节", uint32(0xFFFFFFF0), len(file), alloc)
+ }
+ })
+ }
+}
+
+// testdata 中的文件由 ffmpeg 的 sine 源生成:1000 Hz、振幅 1/8、0.5 秒。在 testdata 目录中重新生成:
+//
+// ffmpeg -nostdin -loglevel error -f lavfi -i 'sine=frequency=1000:sample_rate=44100:duration=0.5' -c:a flac -sample_fmt s16 -fflags +bitexact -map_metadata -1 sine.flac
+// ffmpeg -nostdin -loglevel error -f lavfi -i 'sine=frequency=1000:sample_rate=44100:duration=0.5' -c:a libmp3lame -q:a 5 -fflags +bitexact -map_metadata -1 sine-mpeg1.mp3
+// ffmpeg -nostdin -loglevel error -f lavfi -i 'sine=frequency=1000:sample_rate=22050:duration=0.5' -c:a libmp3lame -q:a 5 -fflags +bitexact -map_metadata -1 sine-mpeg2.mp3
+// ffmpeg -nostdin -loglevel error -f lavfi -i 'sine=frequency=1000:sample_rate=8000:duration=0.5' -c:a libmp3lame -q:a 5 -fflags +bitexact -map_metadata -1 sine-mpeg25.mp3
+//
+// ffmpeg n9.0.2 生成的文件与仓库中的逐字节相同;其他版本生成的字节可能不同,测试只检查解码出的采样率、声道数、时长、
+// 峰值和半周期数。MP3 编码器在两端加了静音:Info 帧里的 LAME 标签有效,gl3d 把它们裁掉,
+// 所以时长正好是 0.5 秒,这条断言也固定了裁剪。
+func TestDecodeCompressed(t *testing.T) {
+ tests := []struct {
+ file string
+ rate, channels int
+ }{
+ {"sine.flac", 44100, 1},
+ {"sine-mpeg1.mp3", 44100, 1},
+ {"sine-mpeg2.mp3", 22050, 1},
+ {"sine-mpeg25.mp3", 8000, 1},
+ }
+ for _, tt := range tests {
+ t.Run(tt.file, func(t *testing.T) {
+ data, err := os.ReadFile(filepath.Join("testdata", tt.file))
+ if err != nil {
+ t.Fatal(err)
+ }
+ a, err := Decode(bytes.NewReader(data))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if a.SampleRate != tt.rate || a.Channels != tt.channels {
+ t.Fatalf("解码为 %d Hz、%d 声道,期望 %d Hz、%d 声道", a.SampleRate, a.Channels, tt.rate, tt.channels)
+ }
+ if d := a.Duration(); d != 500*time.Millisecond {
+ t.Errorf("Duration = %v,期望 500ms", d)
+ }
+ // 只统计越过 ±0.05 的正负交替,编码器在两端加的静音中的细小噪声不算在内。
+ peak, halves, sign := 0.0, 0, 0.0
+ for i := 0; i < len(a.Samples); i += tt.channels {
+ v := a.Samples[i]
+ peak = max(peak, math.Abs(v))
+ if math.Abs(v) > 0.05 && math.Signbit(v) != math.Signbit(sign) {
+ if sign != 0 {
+ halves++
+ }
+ sign = v
+ }
+ }
+ if peak < 0.11 || peak > 0.15 {
+ t.Errorf("峰值 = %.4f,期望约 0.125", peak)
+ }
+ // 1000 Hz 的正弦波 0.5 秒有 1000 个半周期。
+ if halves < 995 || halves > 1005 {
+ t.Errorf("正负交替 %d 次,期望约 1000 次", halves)
+ }
+ })
+ }
+}
+
+// 417 字节是 128 kbps、44.1 kHz 的帧长。帧头之后全是零:Layer III 的这样一帧解码出 1152 个采样帧的静音。
+func mp3Frame(header ...byte) []byte {
+ return append(header, make([]byte, 417-len(header))...)
+}
+
+var (
+ silentFrame = mp3Frame(0xFF, 0xFB, 0x90, 0x00)
+ layer2Frame = mp3Frame(0xFF, 0xFD, 0x90, 0x00)
+)
+
+// 准备任务在 serve 进程中解码整首歌,稳态下每次 Read 都不能分配内存。
+func TestMP3ReadDoesNotAllocate(t *testing.T) {
+ dec, err := NewDecoder(bytes.NewReader(bytes.Repeat(silentFrame, 300)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ buf := make([]float64, 32768)
+ if allocs := testing.AllocsPerRun(100, func() {
+ if _, err := dec.Read(buf); err != nil {
+ t.Fatal(err)
+ }
+ }); allocs != 0 {
+ t.Errorf("每次 Read 分配 %v 次,期望 0", allocs)
+ }
+}
+
+func asError[T error](err error) bool {
+ var target T
+ return errors.As(err, &target)
+}
+
+func TestDecodeRejectsUndecodableMP3(t *testing.T) {
+ tests := []struct {
+ name string
+ file []byte
+ want string
+ is func(error) bool
+ }{
+ {"Layer II", bytes.Repeat(layer2Frame, 3), "不支持的 MP3 格式", asError[*gl3d.UnsupportedError]},
+ {"最后一帧被截断", bytes.Repeat(silentFrame, 4)[:3*len(silentFrame)+200], "MP3 数据被截断", asError[*gl3d.TruncatedError]},
+ {"ID3 标签声明的长度超过上限", []byte("ID3\x04\x00\x00\x7f\x7f\x7f\x7f\x00\x00"), "MP3 超出解码的资源上限", asError[*gl3d.LimitError]},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if _, err := Decode(bytes.NewReader(tt.file)); err == nil || !strings.Contains(err.Error(), tt.want) || !tt.is(err) {
+ t.Errorf("错误 = %v,应包含 %q 并保留 gl3d 的错误", err, tt.want)
+ }
+ })
+ }
+}
+
+type failingReader struct{ err error }
+
+func (r failingReader) Read([]byte) (int, error) { return 0, r.err }
+
+func TestDecodeMP3ReturnsReaderError(t *testing.T) {
+ file := bytes.Repeat(silentFrame, 20)
+ r := io.MultiReader(bytes.NewReader(file[:5*len(silentFrame)+100]), failingReader{context.Canceled})
+ if _, err := Decode(r); !errors.Is(err, context.Canceled) {
+ t.Errorf("错误 = %v,期望 reader 返回的 %v", err, context.Canceled)
+ }
+}
diff --git a/internal/lib/wav/testdata/sine-mpeg1.mp3 b/internal/lib/wav/testdata/sine-mpeg1.mp3
new file mode 100644
index 0000000..b447126
Binary files /dev/null and b/internal/lib/wav/testdata/sine-mpeg1.mp3 differ
diff --git a/internal/lib/wav/testdata/sine-mpeg2.mp3 b/internal/lib/wav/testdata/sine-mpeg2.mp3
new file mode 100644
index 0000000..87292e8
Binary files /dev/null and b/internal/lib/wav/testdata/sine-mpeg2.mp3 differ
diff --git a/internal/lib/wav/testdata/sine-mpeg25.mp3 b/internal/lib/wav/testdata/sine-mpeg25.mp3
new file mode 100644
index 0000000..c0bfea4
Binary files /dev/null and b/internal/lib/wav/testdata/sine-mpeg25.mp3 differ
diff --git a/internal/lib/wav/testdata/sine.flac b/internal/lib/wav/testdata/sine.flac
new file mode 100644
index 0000000..a8b7105
Binary files /dev/null and b/internal/lib/wav/testdata/sine.flac differ
diff --git a/protocol/bilibili_test.go b/protocol/bilibili_test.go
new file mode 100644
index 0000000..efdefa9
--- /dev/null
+++ b/protocol/bilibili_test.go
@@ -0,0 +1,23 @@
+package protocol
+
+import (
+ "strings"
+ "testing"
+)
+
+// B 站曲目 ID 的格式与 internal/domain/track 的规则一致:BV 号,或从第 2 P 起的 BV 号/分P序号。
+func TestBilibiliTrackIDs(t *testing.T) {
+ valid := []string{"BV1xx411c7mD", "BV1xx411c7mD/2", "BV1xx411c7mD/9999"}
+ invalid := []string{"BV1xx411c7mD/1", "BV1xx411c7mD/02", "av170001", "BV1xx411c7mD/extra", ""}
+ for _, id := range valid {
+ if err := decodeData("queue.add", `{"track":{"source":"bilibili","id":"`+id+`"}}`); err != nil {
+ t.Errorf("合法 ID %q:%v", id, err)
+ }
+ }
+ for _, id := range invalid {
+ err := decodeData("queue.add", `{"track":{"source":"bilibili","id":"`+id+`"}}`)
+ if got := errText(err); !strings.Contains(got, "data.track.id") {
+ t.Errorf("非法 ID %q:错误 = %q,应指出 data.track.id", id, got)
+ }
+ }
+}
diff --git a/protocol/decode_test.go b/protocol/decode_test.go
new file mode 100644
index 0000000..db244b5
--- /dev/null
+++ b/protocol/decode_test.go
@@ -0,0 +1,69 @@
+package protocol
+
+import (
+ "encoding/json"
+ "errors"
+ "testing"
+)
+
+func errText(err error) string {
+ if err == nil {
+ return ""
+ }
+ var arg *ArgumentError
+ if !errors.As(err, &arg) {
+ return "不是 ArgumentError:" + err.Error()
+ }
+ return arg.Error()
+}
+
+func TestDecodeObject(t *testing.T) {
+ tests := []struct {
+ raw string
+ want string
+ }{
+ {`{"a":1}`, ""},
+ {`{"z":1,"b":2}`, "data.b:未知字段"},
+ }
+ for _, tt := range tests {
+ _, err := decodeObject(json.RawMessage(tt.raw), "data", "a")
+ if got := errText(err); got != tt.want {
+ t.Errorf("decodeObject(%s) = %q,期望 %q", tt.raw, got, tt.want)
+ }
+ }
+}
+
+func TestDecodeInteger(t *testing.T) {
+ if n, err := decodeInteger(json.RawMessage(`42`), "x"); err != nil || n != 42 {
+ t.Fatalf("decodeInteger = %d, %v", n, err)
+ }
+ for _, raw := range []string{`null`} {
+ _, err := decodeInteger(json.RawMessage(raw), "x")
+ if got := errText(err); got != "x:应为整数" {
+ t.Errorf("decodeInteger(%s) = %q", raw, got)
+ }
+ }
+}
+
+func TestCheckOneOf(t *testing.T) {
+ tests := []struct {
+ raw string
+ want string
+ }{
+ {`{"track":1}`, ""},
+ {`{"track":1,"requester":"张三"}`, ""},
+ {`{"source":1,"keyword":1,"requester":"张三"}`, ""},
+ {`{"requester":"张三"}`, "data:只能二选一"},
+ {`{"track":1,"keyword":1,"requester":"张三"}`, "data:只能二选一"},
+ }
+ for _, tt := range tests {
+ var m map[string]json.RawMessage
+ if err := json.Unmarshal([]byte(tt.raw), &m); err != nil {
+ t.Fatal(err)
+ }
+ err := checkOneOf(m, "data", "只能二选一", []string{"requester"}, []string{"track"}, []string{"source", "keyword"})
+ if got := errText(err); got != tt.want {
+ t.Errorf("checkOneOf(%s) = %q,期望 %q", tt.raw, got, tt.want)
+ }
+ }
+}
diff --git a/protocol/http_test.go b/protocol/http_test.go
new file mode 100644
index 0000000..fdda5a9
--- /dev/null
+++ b/protocol/http_test.go
@@ -0,0 +1,115 @@
+package protocol
+
+import (
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func TestHTTPRequestValidation(t *testing.T) {
+ decode := map[string]func([]byte) error{
+ "updateMe": func(b []byte) error { _, err := DecodeUpdateMeRequest(b); return err },
+ "createRoom": func(b []byte) error { _, err := DecodeCreateRoomRequest(b); return err },
+ "adminCreate": func(b []byte) error { _, err := DecodeAdminCreateRoomRequest(b); return err },
+ }
+ tests := []struct {
+ name string
+ op string
+ body string
+ want string
+ }{
+ {"修改资料:昵称 80 个字符", "updateMe", `{"name":"` + strings.Repeat("名", 80) + `"}`, ""},
+ {"修改资料:昵称超过 80 个字符", "updateMe", `{"name":"` + strings.Repeat("名", 81) + `"}`, "name:长度不能超过 80 个字符"},
+ {"修改资料:邮箱超过 320 个字符", "updateMe", `{"email":"` + strings.Repeat("a", 321) + `"}`, "email:长度不能超过 320 个字符"},
+ {"建房:缺少描述", "createRoom", `{"name":"房间"}`, "description:缺少必填字段"},
+ {"建房:描述超过 500 个字符", "createRoom", `{"name":"房间","description":"` + strings.Repeat("述", 501) + `"}`, "description:长度不能超过 500 个字符"},
+ {"建房:名称为 null", "createRoom", `{"name":null,"description":""}`, "name:应为字符串"},
+ {"后台建房:名称 80 个字符、描述 500 个字符", "adminCreate", `{"name":"` + strings.Repeat("名", 80) + `","description":"` + strings.Repeat("述", 500) + `"}`, ""},
+ {"后台建房:名称超过 80 个字符", "adminCreate", `{"name":"` + strings.Repeat("名", 81) + `","description":""}`, "name:长度不能超过 80 个字符"},
+ {"后台建房:别名", "adminCreate", `{"name":"房间","description":"","slug":"lofi-24"}`, ""},
+ {"后台建房:别名不能为空", "adminCreate", `{"name":"房间","description":"","slug":""}`, "slug:格式不正确"},
+ {"后台建房:别名超过 32 个字符", "adminCreate", `{"name":"房间","description":"","slug":"` + strings.Repeat("a", 33) + `"}`, "slug:格式不正确"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := errText(decode[tt.op]([]byte(tt.body))); got != tt.want {
+ t.Errorf("错误 = %q,期望 %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestErrorCodeHTTPStatus(t *testing.T) {
+ tests := map[ErrorCode]int{
+ ErrorCodeQueueFull: 409,
+ ErrorCodeInternal: 500,
+ }
+ for code, want := range tests {
+ if got := code.HTTPStatus(); got != want {
+ t.Errorf("%s 的 HTTP 状态码 = %d,期望 %d", code, got, want)
+ }
+ }
+}
+
+func roomRoute(t *testing.T, pattern string) RoomRoute {
+ t.Helper()
+ for _, r := range RoomRoutes {
+ if r.Pattern == pattern {
+ return r
+ }
+ }
+ t.Fatalf("没有房间内请求的路由 %s", pattern)
+ return RoomRoute{}
+}
+
+func TestRoomRouteDecode(t *testing.T) {
+ const (
+ search = "GET /api/room/search/tracks"
+ playlist = "GET /api/room/playlists/{source}/{playlistId}/tracks"
+ like = "POST /api/room/queue/{entryId}/likes"
+ vote = "POST /api/room/skip-votes"
+ settings = "PATCH /api/room/settings"
+ )
+ qq, keyword := SourceQQ, "晴天"
+ tests := []struct {
+ name string
+ pattern string
+ path map[string]string
+ query string
+ body string
+ want any
+ wantErr string
+ }{
+ {"搜索:查询串按字段的类型解析", search, nil, "source=netease&keyword=%E6%99%B4%E5%A4%A9&page=2&pageSize=10", "", SearchTracksRequest{Source: SourceNetease, Keyword: "晴天", Page: 2, PageSize: 10}, ""},
+ {"搜索:页码不是整数", search, nil, "source=netease&keyword=a&page=one&pageSize=10", "", nil, "page:应为整数"},
+ {"搜索:页码超出范围", search, nil, "source=netease&keyword=a&page=0&pageSize=10", "", nil, "page:不能小于 1"},
+ {"搜索:来源不是枚举中的值", search, nil, "source=apple&keyword=a&page=1&pageSize=10", "", nil, "source:取值应为 netease、qq、bilibili、link 之一"},
+ {"搜索:缺少必填的参数", search, nil, "source=netease&keyword=a&page=1", "", nil, "pageSize:缺少必填字段"},
+ {"搜索:重复的参数", search, nil, "source=netease&keyword=a&page=1&page=2&pageSize=10", "", nil, "page:参数重复"},
+ {"搜索:未知参数", search, nil, "source=netease&keyword=a&page=1&pageSize=10&sort=hot", "", nil, "sort:未知参数"},
+ {"搜索:查询串格式不正确", search, nil, "keyword=%zz", "", nil, "查询串格式不正确"},
+ {"歌单:路径参数", playlist, map[string]string{"source": "netease", "playlistId": "3778678"}, "", "", PlaylistTracksRequest{Source: SourceNetease, PlaylistID: "3778678"}, ""},
+ {"歌单:路径中的来源不是枚举中的值", playlist, map[string]string{"source": "apple", "playlistId": "1"}, "", "", nil, "source:取值应为 netease、qq、bilibili、link 之一"},
+ {"删歌:DELETE 的路径参数", "DELETE /api/room/queue/{entryId}", map[string]string{"entryId": "7"}, "", "", QueueRemoveRequest{EntryID: "7"}, ""},
+ {"点赞:不带请求体", like, map[string]string{"entryId": "7"}, "", "", QueueLikeRequest{EntryID: "7"}, ""},
+ {"点赞:请求体为空对象", like, map[string]string{"entryId": "7"}, "", "{}", QueueLikeRequest{EntryID: "7"}, ""},
+ {"点赞:请求体中有路径参数", like, map[string]string{"entryId": "7"}, "", `{"entryId":"8"}`, nil, "entryId:未知字段"},
+ {"点赞:请求体为 null", like, map[string]string{"entryId": "7"}, "", "null", nil, "应为 JSON 对象"},
+ {"投票:不带请求体", vote, nil, "", "", PlaybackVoteSkipRequest{}, ""},
+ {"投票:请求体不是对象", vote, nil, "", "[]", nil, "应为 JSON 对象"},
+ {"投票:请求体为 null", vote, nil, "", "null", nil, "应为 JSON 对象"},
+ {"播放模式", settings, nil, "", `{"playMode":"rotation"}`, SettingsSetPlayModeRequest{PlayMode: PlayModeRotation}, ""},
+ {"播放模式:缺少字段", settings, nil, "", "", nil, "playMode:缺少必填字段"},
+ {"聊天:POST 不接受查询参数", "POST /api/room/messages", nil, "content=hi", `{"content":"你好"}`, nil, "content:未知参数"},
+ {"点歌:请求体与 WebSocket 的 data 相同", "POST /api/room/queue", nil, "", `{"source":"qq","keyword":"晴天"}`, QueueAddRequest{Source: &qq, Keyword: &keyword}, ""},
+ {"快照:GET 不读请求体", "GET /api/room", nil, "", "不是 JSON", RoomSyncRequest{}, ""},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := roomRoute(t, tt.pattern).Decode(func(name string) string { return tt.path[name] }, tt.query, []byte(tt.body))
+ if errText(err) != tt.wantErr || !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("Decode = %#v、%q,期望 %#v、%q", got, errText(err), tt.want, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/protocol/link_test.go b/protocol/link_test.go
new file mode 100644
index 0000000..8b2e94f
--- /dev/null
+++ b/protocol/link_test.go
@@ -0,0 +1,33 @@
+package protocol
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// testdata/Link.json 的用例也由网页生成的测试(protocol.gen.test.ts)使用,两边对每个用例的判断必须相同。
+func TestLinkCases(t *testing.T) {
+ data, err := os.ReadFile(filepath.Join("testdata", "Link.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var cases []struct {
+ Value string
+ Valid bool
+ Note string
+ }
+ if err := json.Unmarshal(data, &cases); err != nil {
+ t.Fatal(err)
+ }
+ for _, c := range cases {
+ frame, err := json.Marshal(map[string]any{"kind": "request", "type": RequestQueueAdd, "id": "a1", "data": map[string]any{"track": map[string]string{"source": "link", "id": c.Value}}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := DecodeRequest(frame); (err == nil) != c.Valid {
+ t.Errorf("%s:%q 的解码错误为 %v,期望接受:%v", c.Note, c.Value, err, c.Valid)
+ }
+ }
+}
diff --git a/protocol/testdata/Link.json b/protocol/testdata/Link.json
new file mode 100644
index 0000000..6d5e0ea
--- /dev/null
+++ b/protocol/testdata/Link.json
@@ -0,0 +1,56 @@
+[
+ {"value": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "valid": true, "note": "常见的链接"},
+ {"value": "http://example.com", "valid": true, "note": "没有路径"},
+ {"value": "HTTPS://WWW.YOUTUBE.COM/watch?v=dQw4w9WgXcQ", "valid": true, "note": "scheme 和主机名是大写"},
+ {"value": "hTTp://Example.com/A?b=2&a=1#Frag", "valid": true, "note": "大小写混合,带查询串和片段"},
+ {"value": "https://music.163.com/#/song?id=186016", "valid": true, "note": "片段中的路由"},
+ {"value": "https://example.com:8080/a", "valid": true, "note": "端口"},
+ {"value": "https://example.com:/a", "valid": true, "note": "空的端口"},
+ {"value": "https://example.com:00443/a", "valid": true, "note": "端口有前导零"},
+ {"value": "https://[2606:4700:4700::1111]:8443/a", "valid": true, "note": "IPv6 地址"},
+ {"value": "https://[::ffff:192.0.2.1]/a", "valid": true, "note": "IPv4 映射的 IPv6 地址"},
+ {"value": "https://1.1.1.1/a", "valid": true, "note": "IPv4 地址"},
+ {"value": "https://example.com./a", "valid": true, "note": "主机名结尾有点"},
+ {"value": "https://a-b_c~d.example/a", "valid": true, "note": "主机名中的 -、_ 和 ~"},
+ {"value": "https://xn--fsqu00a.xn--0zwm56d/a", "valid": true, "note": "punycode 写的国际化域名"},
+ {"value": "https://例子.测试/a", "valid": true, "note": "国际化域名"},
+ {"value": "https://example.com/你好?q=中文#片段", "valid": true, "note": "路径、查询串和片段中的非 ASCII 字符"},
+ {"value": "https://example.com/%E4%BD%A0%e5%a5%bd", "valid": true, "note": "路径中的百分号编码"},
+ {"value": "https://example.com/?q=100%", "valid": true, "note": "查询串中的 % 不检查"},
+ {"value": "https://example.com/歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌", "valid": true, "note": "正好 2048 字节"},
+ {"value": "ftp://example.com/a", "valid": false, "note": "不是 http 或 https"},
+ {"value": "/watch?v=x", "valid": false, "note": "相对地址"},
+ {"value": "https:example.com/a", "valid": false, "note": "缺少 //"},
+ {"value": "https:/example.com/a", "valid": false, "note": "缺少一个 /"},
+ {"value": "https:///a", "valid": false, "note": "没有主机名"},
+ {"value": "https://:8080/a", "valid": false, "note": "只有端口,没有主机名"},
+ {"value": "https://user:secret@example.com/a", "valid": false, "note": "带用户信息"},
+ {"value": "https://user@example.com/a", "valid": false, "note": "只有用户名的用户信息"},
+ {"value": "https://@example.com/a", "valid": false, "note": "空的用户信息"},
+ {"value": "https://example.com:65536/a", "valid": false, "note": "端口超过 65535"},
+ {"value": "https://example.com:8a/a", "valid": false, "note": "端口不是数字"},
+ {"value": "https://example.com:80:80/a", "valid": false, "note": "两个端口"},
+ {"value": "https://[::1/a", "valid": false, "note": "方括号不完整"},
+ {"value": "https://[1:2:3:4:5:6:7:8:9]/a", "valid": false, "note": "方括号中不是 IPv6 地址"},
+ {"value": "https://[fe80::1%25eth0]/a", "valid": false, "note": "带 zone 的 IPv6 地址"},
+ {"value": "https://exa%41mple.com/a", "valid": false, "note": "主机名中的百分号编码"},
+ {"value": "https://a{b}.example/a", "valid": false, "note": "主机名中的 { 和 }:url.Parse 不接受,浏览器接受"},
+ {"value": "https://a {
+ it.each<[string, Route]>([
+ ['/', { name: 'lobby', filter: '', panel: null }],
+ ['/?q=+%E5%A4%9C&panel=chat', { name: 'lobby', filter: ' 夜', panel: 'chat' }],
+ ['/admin', { name: 'admin', panel: null }],
+ ['/settings?panel=queue', { name: 'settings', panel: 'queue' }],
+ ['/history', { name: 'history', filter: '', panel: null }],
+ ['/history?q=%E6%99%B4&panel=history', { name: 'history', filter: '晴', panel: 'history' }],
+ ['/r/my-room', { name: 'listen', ref: 'my-room', panel: null }],
+ ['/r/my-room?panel=history', { name: 'listen', ref: 'my-room', panel: 'history' }],
+ ['/r/my-room/queue?panel=queue', { name: 'queue', ref: 'my-room', panel: 'queue' }],
+ ['/r/my-room/chat', { name: 'chat', ref: 'my-room', panel: null }],
+ ['/r/my-room/search', { name: 'search', ref: 'my-room', panel: null, query: { view: 'all', keyword: '', source: 'qq', page: 1 } }],
+ ['/r/my-room/search?q=%E6%99%B4%E5%A4%A9&source=netease&view=playlists&page=2&panel=chat', { name: 'search', ref: 'my-room', panel: 'chat', query: { view: 'playlists', keyword: '晴天', source: 'netease', page: 2 } }],
+ ['/r/my-room/search?source=netease&view=tracks', { name: 'search', ref: 'my-room', panel: null, query: { view: 'tracks', keyword: '', source: 'netease', page: 1 } }],
+ ['/r/my-room/search?view=recommend', { name: 'search', ref: 'my-room', panel: null, query: { view: 'recommend' } }],
+ ['/r/my-room/playlists/netease/123?page=3&panel=queue', { name: 'playlist', ref: 'my-room', panel: 'queue', source: 'netease', id: '123', page: 3 }],
+ ])('%s', (url, route) => {
+ expect(parse(url)).toEqual(route)
+ expect(formatRoute(route)).toBe(url)
+ })
+
+ it.each([
+ ['/admin?from=menu', '/admin'],
+ ['/settings?panel=lyrics', '/settings'],
+ ['/?q=', '/'],
+ ['/r/my-room?panel=lyrics', '/r/my-room'],
+ ['/r/my-room/search?q=%20a%20', '/r/my-room/search?q=a&source=qq'],
+ ['/r/my-room/search?q=a&source=kuwo&view=lyrics&page=0', '/r/my-room/search?q=a&source=qq'],
+ ['/r/my-room/search?q=a&source=qq&view=playlists', '/r/my-room/search?q=a&source=qq'],
+ ['/r/my-room/search?q=a&page=2', '/r/my-room/search?q=a&source=qq'],
+ ['/r/my-room/search?view=tracks&page=2', '/r/my-room/search?view=tracks'],
+ ['/r/my-room/search?view=recommend&q=a&source=netease&page=2', '/r/my-room/search?view=recommend'],
+ ['/r/my-room/playlists/netease/123?page=1.5', '/r/my-room/playlists/netease/123'],
+ ])('%s 规范化为 %s', (url, canonical) => {
+ const route = parse(url)
+ expect(route && formatRoute(route)).toBe(canonical)
+ })
+
+ it.each(['/r/', '/r/my-room/listen', '/r/my-room/queue/extra', '/settings/profile', '/r/my-room/playlists/qq/123', '/r/my-room/playlists/netease'])('%s 不对应任何页面', (url) => {
+ expect(parse(url)).toBeNull()
+ })
+})
+
+describe('isNewEntry', () => {
+ it('换页面和进入另一个房间写入新记录', () => {
+ expect(isNewEntry({ name: 'lobby', filter: '', panel: null }, { name: 'listen', ref: 'a', panel: null })).toBe(true)
+ expect(isNewEntry({ name: 'listen', ref: 'a', panel: null }, { name: 'queue', ref: 'a', panel: null })).toBe(true)
+ expect(isNewEntry({ name: 'listen', ref: 'a', panel: null }, { name: 'listen', ref: 'b', panel: null })).toBe(true)
+ })
+
+ it('开关侧栏和输入筛选词只替换当前地址', () => {
+ expect(isNewEntry({ name: 'queue', ref: 'a', panel: null }, { name: 'queue', ref: 'a', panel: 'chat' })).toBe(false)
+ expect(isNewEntry({ name: 'settings', panel: null }, { name: 'settings', panel: 'chat' })).toBe(false)
+ expect(isNewEntry({ name: 'history', filter: '晴', panel: null }, { name: 'history', filter: '晴天', panel: null })).toBe(false)
+ })
+
+ it('提交新的关键词写入新记录,换来源、类别、页码和推荐只替换当前地址', () => {
+ const search = (query: SearchQuery): Route => ({ name: 'search', ref: 'a', panel: null, query })
+ const sunny = search({ view: 'all', keyword: '晴天', source: 'qq', page: 1 })
+ expect(isNewEntry(sunny, search({ view: 'all', keyword: '七里香', source: 'qq', page: 1 }))).toBe(true)
+ expect(isNewEntry(sunny, search({ view: 'all', keyword: '', source: 'qq', page: 1 }))).toBe(true)
+ expect(isNewEntry(sunny, search({ view: 'tracks', keyword: '晴天', source: 'netease', page: 2 }))).toBe(false)
+ expect(isNewEntry(sunny, search({ view: 'recommend' }))).toBe(false)
+ expect(isNewEntry(search({ view: 'recommend' }), search({ view: 'tracks', keyword: '', source: 'qq', page: 1 }))).toBe(false)
+ expect(isNewEntry(search({ view: 'recommend' }), sunny)).toBe(true)
+ })
+
+ it('打开另一个歌单写入新记录,翻页只替换当前地址', () => {
+ const playlist = (id: string, page: number): Route => ({ name: 'playlist', ref: 'a', panel: null, source: 'netease', id, page })
+ expect(isNewEntry(playlist('1', 1), playlist('2', 1))).toBe(true)
+ expect(isNewEntry(playlist('1', 1), playlist('1', 2))).toBe(false)
+ })
+})
+
+describe('leaveStep', () => {
+ const leaving = new Set(['my-room', 'a1b2c3'])
+
+ it('不在这个房间的页面上时留在原处', () => {
+ expect(leaveStep({ name: 'admin', panel: null }, leaving, true)).toBe('stay')
+ expect(leaveStep({ name: 'listen', ref: 'other-room', panel: null }, leaving, true)).toBe('stay')
+ })
+
+ it('在这个房间的页面上一直后退,直接打开的房间换成发现房间', () => {
+ expect(leaveStep({ name: 'queue', ref: 'a1b2c3', panel: 'chat' }, leaving, true)).toBe('back')
+ expect(leaveStep({ name: 'listen', ref: 'my-room', panel: null }, leaving, false)).toBe('lobby')
+ })
+})
diff --git a/web/src/core/clock/clock.test.ts b/web/src/core/clock/clock.test.ts
new file mode 100644
index 0000000..2f63d20
--- /dev/null
+++ b/web/src/core/clock/clock.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from 'vitest'
+import { Clock } from './clock'
+
+function manualClock(start = 1000) {
+ let now = start
+ return {
+ clock: new Clock(() => now),
+ advance: (ms: number) => {
+ now += ms
+ },
+ }
+}
+
+describe('clock', () => {
+ it('没有样本时不知道服务器时间', () => {
+ const { clock } = manualClock()
+ expect(clock.offsetMs).toBeNull()
+ expect(clock.serverNow()).toBeNull()
+ })
+
+ it('偏移等于服务器时间减去发送与收到时间的中点', () => {
+ const { clock, advance } = manualClock(1000)
+ clock.addSample(1000, 1100, 5050)
+ expect(clock.offsetMs).toBe(4000)
+ advance(500)
+ expect(clock.serverNow()).toBe(5500)
+ })
+
+ it('取往返时间最小的样本', () => {
+ const { clock } = manualClock()
+ clock.addSample(1000, 1300, 6150)
+ clock.addSample(2000, 2040, 7020)
+ clock.addSample(3000, 3200, 9100)
+ expect(clock.offsetMs).toBe(5000)
+ })
+
+ it('只看最近 12 个样本', () => {
+ const { clock } = manualClock()
+ clock.addSample(0, 10, 1005)
+ for (let i = 1; i <= 11; i++)
+ clock.addSample(i * 1000, i * 1000 + 100, i * 1000 + 50 + 2000)
+ expect(clock.offsetMs).toBe(1000)
+ clock.addSample(12_000, 12_100, 12_050 + 2000)
+ expect(clock.offsetMs).toBe(2000)
+ })
+
+ it('清空后丢弃样本,也不接受清空前发出的 ping', () => {
+ const { clock, advance } = manualClock(1000)
+ clock.addSample(1000, 1100, 5050)
+ advance(1000)
+ clock.reset()
+ expect(clock.offsetMs).toBeNull()
+ clock.addSample(1500, 2100, 9000)
+ expect(clock.offsetMs).toBeNull()
+ clock.addSample(2000, 2100, 7050)
+ expect(clock.offsetMs).toBe(5000)
+ })
+})
diff --git a/web/src/core/errors/error-message.test.ts b/web/src/core/errors/error-message.test.ts
new file mode 100644
index 0000000..529a107
--- /dev/null
+++ b/web/src/core/errors/error-message.test.ts
@@ -0,0 +1,59 @@
+import type { ErrorCode } from '@/core/realtime'
+import { describe, expect, it } from 'vitest'
+import { ApiError, IdentityRenewedError } from '@/core/api'
+import { RequestError } from '@/core/realtime'
+import { errorMessage, errorText } from './error-message'
+
+describe('errorText', () => {
+ const cases: { name: string, code: ErrorCode, message: string, want: string }[] = [
+ { name: '字段路径换成通用说法', code: 'INVALID_ARGUMENT', message: 'name:长度不能少于 1 个字符', want: '提交的内容不符合要求,请检查后再试。' },
+ { name: '协议版本不一致', code: 'CLIENT_OUTDATED', message: '前端与服务端的协议版本不一致,请刷新页面', want: '应用已经更新,稍后会自动刷新页面。' },
+ { name: '房间密码错误照旧显示', code: 'UNAUTHORIZED', message: '房间密码错误', want: '房间密码错误' },
+ { name: '重复点歌照旧显示', code: 'CONFLICT', message: '这首歌已经在播放列表里了。', want: '这首歌已经在播放列表里了。' },
+ { name: '播放列表已满照旧显示', code: 'QUEUE_FULL', message: '临时房间的播放列表最多 10 首歌,等播放几首之后再点。', want: '临时房间的播放列表最多 10 首歌,等播放几首之后再点。' },
+ { name: '没有在放的歌照旧显示', code: 'NOTHING_PLAYING', message: '现在没有在放的歌。', want: '现在没有在放的歌。' },
+ { name: '音乐平台出错照旧显示', code: 'PROVIDER_ERROR', message: '音乐平台没有正常响应,请稍后再试。', want: '音乐平台没有正常响应,请稍后再试。' },
+ { name: '房间暂时不可用照旧显示', code: 'UNAVAILABLE', message: '房间暂时不可用,请稍后再试。', want: '房间暂时不可用,请稍后再试。' },
+ { name: '服务器出错照旧显示', code: 'INTERNAL', message: '服务器出错了,请稍后再试。', want: '服务器出错了,请稍后再试。' },
+ ]
+
+ for (const tc of cases) {
+ it(tc.name, () => {
+ expect(errorText(tc.code, tc.message)).toBe(tc.want)
+ })
+ }
+
+ it('操作给出的说法优先', () => {
+ expect(errorText('INVALID_ARGUMENT', 'keyword:长度不能超过 500 个字符', { INVALID_ARGUMENT: '关键词太长了' })).toBe('关键词太长了')
+ expect(errorText('CONFLICT', '这首歌已经在播放列表里了。', { INVALID_ARGUMENT: '关键词太长了' })).toBe('这首歌已经在播放列表里了。')
+ })
+
+ it('操作可以照原样显示服务端的 message', () => {
+ expect(errorText('INVALID_ARGUMENT', '不是能识别的 cookies', { INVALID_ARGUMENT: message => message })).toBe('不是能识别的 cookies')
+ })
+})
+
+describe('errorMessage', () => {
+ it('按错误码转换服务端的错误', () => {
+ expect(errorMessage(new ApiError('cookies:长度不能少于 1 个字符', 'INVALID_ARGUMENT', 400))).toBe('提交的内容不符合要求,请检查后再试。')
+ expect(errorMessage(new RequestError('前端与服务端的协议版本不一致,请刷新页面', 'CLIENT_OUTDATED'))).toBe('应用已经更新,稍后会自动刷新页面。')
+ expect(errorMessage(new RequestError('你已经投过票了。', 'CONFLICT'))).toBe('你已经投过票了。')
+ })
+
+ it('前端构造的错误照旧显示', () => {
+ expect(errorMessage(new ApiError('无法连接服务器,请检查网络后再试。', null, 0))).toBe('无法连接服务器,请检查网络后再试。')
+ expect(errorMessage(new RequestError('与房间的连接断开了,连上之后再试一次。', null))).toBe('与房间的连接断开了,连上之后再试一次。')
+ expect(errorMessage(new IdentityRenewedError())).toBe(new IdentityRenewedError().message)
+ })
+
+ it('浏览器和程序缺陷的错误不显示原文', () => {
+ expect(errorMessage(new TypeError('Cannot read properties of undefined'))).toBe('出错了,请稍后再试。')
+ expect(errorMessage('boom')).toBe('出错了,请稍后再试。')
+ })
+
+ it('操作给出的说法只替换对应的错误码', () => {
+ const overrides = { INVALID_ARGUMENT: '昵称不能为空' }
+ expect(errorMessage(new ApiError('name:长度不能少于 1 个字符', 'INVALID_ARGUMENT', 400), overrides)).toBe('昵称不能为空')
+ expect(errorMessage(new ApiError('服务器出错了,请稍后再试。', 'INTERNAL', 500), overrides)).toBe('服务器出错了,请稍后再试。')
+ })
+})
diff --git a/web/src/core/realtime/backoff.test.ts b/web/src/core/realtime/backoff.test.ts
new file mode 100644
index 0000000..94e1d93
--- /dev/null
+++ b/web/src/core/realtime/backoff.test.ts
@@ -0,0 +1,12 @@
+import { describe, expect, it } from 'vitest'
+import { reconnectDelay } from './backoff'
+
+describe('reconnectDelay', () => {
+ it.each([
+ [1, 3000],
+ [4, 24_000],
+ [5, 30_000],
+ ])('第 %i 次等待 %i 毫秒', (attempt, delay) => {
+ expect(reconnectDelay(attempt)).toBe(delay)
+ })
+})
diff --git a/web/src/core/realtime/protocol.gen.test.ts b/web/src/core/realtime/protocol.gen.test.ts
new file mode 100644
index 0000000..07db7d9
--- /dev/null
+++ b/web/src/core/realtime/protocol.gen.test.ts
@@ -0,0 +1,15 @@
+// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
+
+import { readFileSync } from 'node:fs'
+import { describe, expect, it } from 'vitest'
+import { isLink } from './protocol.gen'
+
+function cases(name: string): { value: string, valid: boolean }[] {
+ return JSON.parse(readFileSync(new URL(`../../../../protocol/testdata/${name}.json`, import.meta.url), 'utf8')) as { value: string, valid: boolean }[]
+}
+
+describe('isLink', () => {
+ it('对 protocol/testdata/Link.json 中的每个用例与服务端的判断相同', () => {
+ expect(cases('Link').filter(c => isLink(c.value) !== c.valid).map(c => c.value)).toEqual([])
+ })
+})
diff --git a/web/src/core/room/replica.test.ts b/web/src/core/room/replica.test.ts
new file mode 100644
index 0000000..a85f0a2
--- /dev/null
+++ b/web/src/core/room/replica.test.ts
@@ -0,0 +1,114 @@
+import type { Entry, RoomEventMessage, RoomSnapshot } from '@/core/realtime'
+import { describe, expect, it } from 'vitest'
+import { RoomReplica } from './replica'
+
+const alice = { id: 'm1', name: 'alice', avatarHash: '' }
+
+function snapshot(term: number, seq: number, patch: Partial = {}): RoomSnapshot {
+ return {
+ term,
+ seq,
+ room: { id: 'r1', name: '房间', description: '', kind: 'persistent' },
+ playMode: 'sequential',
+ nowPlaying: null,
+ pending: null,
+ items: [],
+ members: { sessions: [], onlineCount: 0 },
+ ...patch,
+ }
+}
+
+function entry(id: string): Entry {
+ return {
+ id,
+ track: { source: 'netease', id },
+ meta: { title: id, artist: '', album: '', coverUrl: '', pageUrl: '', durationMs: 1000 },
+ by: alice,
+ auto: false,
+ likes: 0,
+ status: 'ready',
+ }
+}
+
+function queued(term: number, seq: number, ids: string[]): RoomEventMessage {
+ return { kind: 'event', type: 'queue.updated', term, seq, data: { pending: null, items: ids.map(entry) } }
+}
+
+function chat(term: number, seq: number, content: string): RoomEventMessage {
+ return { kind: 'event', type: 'chat.message', term, seq, data: { member: alice, content, sentAtMs: 1 } }
+}
+
+const nothing = { changed: false, notice: null, sync: false }
+
+describe('roomReplica', () => {
+ it('依次应用连续的事件', () => {
+ const replica = new RoomReplica(snapshot(3, 10))
+ expect(replica.receive(queued(3, 11, ['a']))).toEqual({ changed: true, notice: null, sync: false })
+ expect(replica.receive({ kind: 'event', type: 'settings.updated', term: 3, seq: 12, data: { playMode: 'random' } }).changed).toBe(true)
+ expect(replica.receive({ kind: 'event', type: 'room.updated', term: 3, seq: 13, data: { name: '新名字', description: '介绍' } }).changed).toBe(true)
+ expect(replica.state.items.map(e => e.id)).toEqual(['a'])
+ expect(replica.state.playMode).toBe('random')
+ expect(replica.state.room).toEqual({ id: 'r1', name: '新名字', description: '介绍', kind: 'persistent' })
+ })
+
+ it('丢弃旧任期和重复的事件', () => {
+ const replica = new RoomReplica(snapshot(3, 10))
+ expect(replica.receive(queued(2, 11, ['old']))).toEqual(nothing)
+ expect(replica.receive(queued(3, 10, ['dup']))).toEqual(nothing)
+ expect(replica.state.items).toEqual([])
+ })
+
+ it('seq 不连续或任期更大时请求快照,等待期间只请求一次', () => {
+ const gap = new RoomReplica(snapshot(3, 10))
+ expect(gap.receive(queued(3, 12, ['a']))).toEqual({ changed: false, notice: null, sync: true })
+ expect(gap.receive(queued(3, 13, ['b']))).toEqual(nothing)
+ expect(gap.state.items).toEqual([])
+
+ const newer = new RoomReplica(snapshot(3, 10))
+ expect(newer.receive(queued(4, 1, ['a'])).sync).toBe(true)
+ })
+
+ it('等待快照期间照常显示通知,并按编号去重', () => {
+ const replica = new RoomReplica(snapshot(3, 10))
+ expect(replica.receive(chat(3, 12, '第一条')).notice?.data).toMatchObject({ content: '第一条' })
+ expect(replica.receive(chat(3, 12, '第一条')).notice).toBeNull()
+ expect(replica.receive(chat(3, 13, '第二条')).notice?.data).toMatchObject({ content: '第二条' })
+ expect(replica.receive(chat(2, 20, '旧任期')).notice).toBeNull()
+ })
+
+ it('以快照为准,之后接着快照的编号应用事件', () => {
+ const replica = new RoomReplica(snapshot(3, 10))
+ replica.receive(chat(3, 12, '等待时收到'))
+ replica.applySnapshot(snapshot(3, 11, { items: [entry('a')] }))
+ expect(replica.state.items.map(e => e.id)).toEqual(['a'])
+ expect(replica.receive(chat(3, 12, '等待时收到'))).toEqual({ changed: false, notice: null, sync: false })
+ expect(replica.receive(queued(3, 13, ['a', 'b'])).changed).toBe(true)
+ expect(replica.state.items.map(e => e.id)).toEqual(['a', 'b'])
+ })
+
+ it('连续的通知事件推进编号', () => {
+ const replica = new RoomReplica(snapshot(3, 10))
+ expect(replica.receive(chat(3, 11, '你好')).notice?.data).toMatchObject({ content: '你好' })
+ expect(replica.receive(queued(3, 12, ['a'])).changed).toBe(true)
+ })
+
+ it('获取快照失败后,下一个不连续的事件再次请求', async () => {
+ const replica = new RoomReplica(snapshot(3, 10))
+ expect(replica.receive(queued(3, 12, ['a'])).sync).toBe(true)
+ await expect(replica.sync(() => Promise.reject(new Error('与房间的连接已断开')))).rejects.toThrow('与房间的连接已断开')
+ expect(replica.receive(queued(3, 13, ['b'])).sync).toBe(true)
+ })
+
+ it('请求快照期间丢弃状态事件,快照到达后接着它的编号应用', async () => {
+ const replica = new RoomReplica(snapshot(3, 10))
+ let respond: (room: RoomSnapshot) => void = () => {}
+ const syncing = replica.sync(() => new Promise((resolve) => {
+ respond = resolve
+ }))
+ expect(replica.receive(queued(3, 11, ['a']))).toEqual(nothing)
+ respond(snapshot(3, 11, { items: [entry('b')] }))
+ await syncing
+ expect(replica.state.items.map(e => e.id)).toEqual(['b'])
+ expect(replica.receive(queued(3, 12, ['b', 'c'])).changed).toBe(true)
+ })
+})
diff --git a/web/src/features/admin/qr-login.test.ts b/web/src/features/admin/qr-login.test.ts
new file mode 100644
index 0000000..a23f094
--- /dev/null
+++ b/web/src/features/admin/qr-login.test.ts
@@ -0,0 +1,47 @@
+import type { QrLoginState } from './qr-login'
+import { describe, expect, it } from 'vitest'
+import { nextQrLogin, startQrLogin } from './qr-login'
+
+const expiresAtMs = 120_000
+const waiting = startQrLogin(expiresAtMs)
+
+describe('nextQrLogin', () => {
+ it('等待扫码和已扫码时继续查询,并记下平台给出的说明', () => {
+ expect(nextQrLogin(waiting, { kind: 'checked', result: { status: 'waiting', message: '' } })).toEqual({ state: waiting, polling: true })
+ expect(nextQrLogin(waiting, { kind: 'checked', result: { status: 'scanned', message: '请在手机上确认' } }))
+ .toEqual({ state: { status: 'scanned', message: '请在手机上确认', expiresAtMs }, polling: true })
+ })
+
+ it('登录成功、二维码过期或登录失败时停止查询', () => {
+ for (const status of ['success', 'expired', 'failed'] as const) {
+ expect(nextQrLogin(waiting, { kind: 'checked', result: { status, message: '二维码已失效' } }))
+ .toEqual({ state: { status, message: '二维码已失效', expiresAtMs }, polling: false })
+ }
+ })
+
+ it('查询失败时按登录失败处理,停止查询', () => {
+ expect(nextQrLogin(waiting, { kind: 'checked', result: null })).toEqual({ state: { status: 'failed', message: '', expiresAtMs }, polling: false })
+ })
+
+ it('到了 expiresAt 按过期处理,停止查询', () => {
+ expect(nextQrLogin(waiting, { kind: 'tick', nowMs: expiresAtMs - 1 })).toEqual({ state: waiting, polling: true })
+ const scanned: QrLoginState = { status: 'scanned', message: '', expiresAtMs }
+ expect(nextQrLogin(scanned, { kind: 'tick', nowMs: expiresAtMs })).toEqual({ state: { status: 'expired', message: '', expiresAtMs }, polling: false })
+ })
+
+ it('结束之后到达的查询结果和计时都不再改变状态', () => {
+ const success: QrLoginState = { status: 'success', message: '', expiresAtMs }
+ expect(nextQrLogin(success, { kind: 'tick', nowMs: expiresAtMs })).toEqual({ state: success, polling: false })
+ const expired: QrLoginState = { status: 'expired', message: '', expiresAtMs }
+ expect(nextQrLogin(expired, { kind: 'checked', result: { status: 'scanned', message: '' } })).toEqual({ state: expired, polling: false })
+ expect(nextQrLogin(expired, { kind: 'checked', result: null })).toEqual({ state: expired, polling: false })
+ const failed: QrLoginState = { status: 'failed', message: '', expiresAtMs }
+ expect(nextQrLogin(failed, { kind: 'checked', result: { status: 'success', message: '' } })).toEqual({ state: failed, polling: false })
+ })
+
+ it('本机判定过期之后才到的登录成功仍按成功处理', () => {
+ const expired: QrLoginState = { status: 'expired', message: '', expiresAtMs }
+ expect(nextQrLogin(expired, { kind: 'checked', result: { status: 'success', message: '已登录' } }))
+ .toEqual({ state: { status: 'success', message: '已登录', expiresAtMs }, polling: false })
+ })
+})
diff --git a/web/src/features/admin/source-account-view.test.ts b/web/src/features/admin/source-account-view.test.ts
new file mode 100644
index 0000000..56e9bc0
--- /dev/null
+++ b/web/src/features/admin/source-account-view.test.ts
@@ -0,0 +1,35 @@
+import type { AdminGetNeteaseCredentialResponse, AdminGetQQCredentialResponse } from '@/core/api'
+import { describe, expect, it } from 'vitest'
+import { sourceAccountView } from './source-account-view'
+
+type State = AdminGetNeteaseCredentialResponse | AdminGetQQCredentialResponse | null | undefined
+
+const cases: { name: string, state: State, want: ReturnType }[] = [
+ { name: '正在查询', state: undefined, want: { status: '正在查询…', details: [] } },
+ { name: '查询失败', state: null, want: { status: '查询失败', details: [] } },
+ { name: '没有保存凭据', state: { set: false, account: null }, want: { status: '未登录', details: [] } },
+ { name: '保存凭据但没有账号资料', state: { set: true, account: null }, want: { status: '已保存登录信息,未识别到账号', details: [] } },
+ {
+ name: '上游已验证的会员账号',
+ state: { set: true, account: { id: '123', name: '测试账号', verified: true, vip: true } },
+ want: { status: '已登录', details: ['昵称:测试账号', '账号 ID:123', '会员状态:会员'] },
+ },
+ {
+ name: '非会员和未知昵称',
+ state: { set: true, account: { id: '123', name: null, verified: true, vip: false } },
+ want: { status: '已登录', details: ['账号 ID:123', '会员状态:非会员'] },
+ },
+ {
+ name: '仅从本地凭据识别的账号',
+ state: { set: true, account: { id: '456', name: null, verified: false, vip: null } },
+ want: { status: '已保存登录信息,未经 QQ 音乐核实', details: ['账号 ID:456'] },
+ },
+]
+
+describe('sourceAccountView', () => {
+ for (const tc of cases) {
+ it(tc.name, () => {
+ expect(sourceAccountView(tc.state, 'QQ 音乐')).toEqual(tc.want)
+ })
+ }
+})
diff --git a/web/src/features/admin/youtube-cookies.test.ts b/web/src/features/admin/youtube-cookies.test.ts
new file mode 100644
index 0000000..a3f489e
--- /dev/null
+++ b/web/src/features/admin/youtube-cookies.test.ts
@@ -0,0 +1,82 @@
+import type { AdminGetYoutubeCredentialResponse } from '@/core/api'
+import { describe, expect, it } from 'vitest'
+import { canSubmitYoutubeCookies, shouldWarnAboutPlaintext, youtubeCookiesText, youtubeCookiesTooLarge, youtubeCookiesView } from './youtube-cookies-view'
+
+describe('canSubmitYoutubeCookies', () => {
+ it('空字符串和纯空白不能提交', () => {
+ expect(canSubmitYoutubeCookies('', false)).toBe(false)
+ expect(canSubmitYoutubeCookies(' \n\t ', false)).toBe(false)
+ })
+
+ it('非空内容可以提交,保存期间不能重复提交', () => {
+ expect(canSubmitYoutubeCookies('# Netscape HTTP Cookie File\n', false)).toBe(true)
+ expect(canSubmitYoutubeCookies('# Netscape HTTP Cookie File\n', true)).toBe(false)
+ })
+
+ it('超过 64 KiB 不能提交', () => {
+ expect(canSubmitYoutubeCookies('a'.repeat(64 * 1024), false)).toBe(false)
+ })
+})
+
+describe('youtubeCookiesTooLarge', () => {
+ // 请求体 {"cookies":"…"} 在内容之外还有 14 个字节。
+ it('按请求体的字节数计算,刚好 64 KiB 不算超过', () => {
+ expect(youtubeCookiesTooLarge('a'.repeat(64 * 1024 - 14))).toBe(false)
+ expect(youtubeCookiesTooLarge('a'.repeat(64 * 1024 - 13))).toBe(true)
+ })
+
+ it('汉字按 UTF-8 的字节数计算,换行和制表符按 JSON 转义之后计算', () => {
+ expect(youtubeCookiesTooLarge('中'.repeat(21_840))).toBe(false)
+ expect(youtubeCookiesTooLarge('中'.repeat(21_841))).toBe(true)
+ expect(youtubeCookiesTooLarge('\t'.repeat(32_761))).toBe(false)
+ expect(youtubeCookiesTooLarge('\t'.repeat(32_762))).toBe(true)
+ expect(youtubeCookiesTooLarge('\n'.repeat(32_761))).toBe(false)
+ expect(youtubeCookiesTooLarge('\n'.repeat(32_762))).toBe(true)
+ })
+})
+
+describe('shouldWarnAboutPlaintext', () => {
+ const cases = [
+ { location: { protocol: 'https:', hostname: 'music.example.com' }, want: false },
+ { location: { protocol: 'http:', hostname: 'localhost' }, want: false },
+ { location: { protocol: 'http:', hostname: '127.0.0.1' }, want: false },
+ { location: { protocol: 'http:', hostname: '127.0.0.2' }, want: false },
+ { location: { protocol: 'http:', hostname: 'app.localhost' }, want: false },
+ { location: { protocol: 'http:', hostname: '::1' }, want: false },
+ { location: { protocol: 'http:', hostname: '[::1]' }, want: false },
+ { location: { protocol: 'http:', hostname: '192.168.1.20' }, want: true },
+ { location: { protocol: 'http:', hostname: 'music.example.com' }, want: true },
+ ]
+
+ for (const tc of cases) {
+ it(`${tc.location.protocol}//${tc.location.hostname} 的警示为 ${tc.want}`, () => {
+ expect(shouldWarnAboutPlaintext(tc.location)).toBe(tc.want)
+ })
+ }
+})
+
+describe('youtubeCookiesText', () => {
+ it('说明明文传输风险', () => {
+ expect(youtubeCookiesText.plaintextWarning).toBe('当前连接不是 HTTPS,填写的 cookies 会以明文在网络上传输,只在可信网络中填写。')
+ })
+})
+
+describe('youtubeCookiesView', () => {
+ const cases: { name: string, state: AdminGetYoutubeCredentialResponse | null | undefined, want: ReturnType }[] = [
+ { name: '正在查询', state: undefined, want: { status: '正在查询…', updatedAtMs: null } },
+ { name: '查询失败', state: null, want: { status: '查询失败', updatedAtMs: null } },
+ { name: '未配置', state: { set: false, updatedAtMs: 0 }, want: { status: '未配置', updatedAtMs: null } },
+ { name: '已配置并显示更新时间', state: { set: true, updatedAtMs: 1_793_184_000_000 }, want: { status: '已配置', updatedAtMs: 1_793_184_000_000 } },
+ ]
+
+ for (const tc of cases) {
+ it(tc.name, () => {
+ expect(youtubeCookiesView(tc.state)).toEqual(tc.want)
+ })
+ }
+
+ it('已有 cookies 不进入状态视图', () => {
+ const state = { set: true, updatedAtMs: 1_793_184_000_000, cookies: '虚构 cookie,不能回显' }
+ expect(youtubeCookiesView(state)).toEqual({ status: '已配置', updatedAtMs: 1_793_184_000_000 })
+ })
+})
diff --git a/web/src/features/history/records.test.ts b/web/src/features/history/records.test.ts
new file mode 100644
index 0000000..da4d2cf
--- /dev/null
+++ b/web/src/features/history/records.test.ts
@@ -0,0 +1,85 @@
+import type { PlayRecord, SearchCategory, SearchRecord, SearchSource } from './records'
+import { describe, expect, it } from 'vitest'
+import { parsePlays, parseSearches, playHistoryLimit, recordPlay, recordSearch } from './records'
+
+function play(id: string, playedAtMs: number, source: PlayRecord['track']['source'] = 'netease'): PlayRecord {
+ return {
+ track: { source, id },
+ meta: { title: id, artist: '', album: '', coverUrl: '', pageUrl: '', durationMs: 1000 },
+ playedAtMs,
+ }
+}
+
+function search(query: string, searchedAtMs: number, source: SearchSource = 'netease', category: SearchCategory = 'all'): SearchRecord {
+ return { query, source, category, searchedAtMs }
+}
+
+describe('recordPlay', () => {
+ it('新的记录排在最前,同一首歌只留最近一次', () => {
+ const plays = recordPlay([play('2', 200), play('1', 100)], play('1', 300))
+ expect(plays).toEqual([play('1', 300), play('2', 200)])
+ })
+
+ it('来源不同的同一个 ID 是两首歌', () => {
+ expect(recordPlay([play('1', 100, 'qq')], play('1', 200))).toEqual([play('1', 200), play('1', 100, 'qq')])
+ })
+
+ it('最多保留 500 条', () => {
+ const plays = Array.from({ length: playHistoryLimit }, (_, i) => play(String(i), i))
+ const next = recordPlay(plays, play('new', 1000))
+ expect(next).toHaveLength(500)
+ expect(next[0]).toEqual(play('new', 1000))
+ expect(next[499]).toEqual(play('498', 498))
+ })
+})
+
+describe('parsePlays', () => {
+ it('保留 B 站的播放记录', () => {
+ const record = play('BV1xx411c7mD/2', 1, 'bilibili')
+ expect(parsePlays(JSON.stringify([record]))).toEqual([record])
+ })
+
+ it('丢弃不完整的记录', () => {
+ const broken = [
+ { track: { source: 'netease', id: '2' }, playedAtMs: 1 },
+ { ...play('3', 3), track: { source: 'other', id: '3' } },
+ { ...play('4', 4), meta: { title: '4', artist: '', album: '', coverUrl: '', durationMs: 1 } },
+ null,
+ ]
+ expect(parsePlays(JSON.stringify([play('1', 1), ...broken]))).toEqual([play('1', 1)])
+ })
+
+ it('没有记录或者内容无法解析时为空', () => {
+ expect(parsePlays(null)).toEqual([])
+ expect(parsePlays('{')).toEqual([])
+ expect(parsePlays('{}')).toEqual([])
+ })
+})
+
+describe('recordSearch', () => {
+ it('不区分大小写和首尾空白,同一个查询只留最近的一条,平台和类别取最近一次', () => {
+ const searches = recordSearch([search('晴天', 1), search('Jay', 2, 'qq', 'tracks')], search(' jay ', 3, 'netease', 'playlists'), 10)
+ expect(searches).toEqual([search('jay', 3, 'netease', 'playlists'), search('晴天', 1)])
+ })
+
+ it('超过上限时丢弃最旧的', () => {
+ expect(recordSearch([search('b', 2), search('a', 1)], search('c', 3), 2)).toEqual([search('c', 3), search('b', 2)])
+ })
+})
+
+describe('parseSearches', () => {
+ it('合并重复的查询,保留最新的一条,按时间从新到旧排列', () => {
+ const stored = JSON.stringify([search('Jay', 1, 'qq'), search('晴天', 2), search(' jay', 3, 'netease', 'tracks')])
+ expect(parseSearches(stored, 10)).toEqual([search('jay', 3, 'netease', 'tracks'), search('晴天', 2)])
+ })
+
+ it('丢弃无效的记录,只保留上限以内的', () => {
+ const stored = JSON.stringify([search('a', 1), search(' ', 2), { ...search('b', 3), source: 'link' }, { query: 'c' }, search('d', 4), search('e', 5)])
+ expect(parseSearches(stored, 2)).toEqual([search('e', 5), search('d', 4)])
+ })
+
+ it('没有记录或者内容无法解析时为空', () => {
+ expect(parseSearches(null, 10)).toEqual([])
+ expect(parseSearches('[', 10)).toEqual([])
+ })
+})
diff --git a/web/src/features/lyrics/lrc.test.ts b/web/src/features/lyrics/lrc.test.ts
new file mode 100644
index 0000000..539aace
--- /dev/null
+++ b/web/src/features/lyrics/lrc.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from 'vitest'
+import { lineIndexAt, parseLrc } from './lrc'
+
+describe('parseLrc', () => {
+ it('解析分、秒和不同精度的小数部分', () => {
+ expect(parseLrc('[00:12.34]晴天\n[01:02]刮风这天\n[00:01.5]a\n[00:02.123]b')).toEqual([
+ { timeMs: 1500, text: 'a' },
+ { timeMs: 2123, text: 'b' },
+ { timeMs: 12_340, text: '晴天' },
+ { timeMs: 62_000, text: '刮风这天' },
+ ])
+ })
+
+ it('一行有多个时间标签时,每个标签各生成一行', () => {
+ expect(parseLrc('[00:05.00][00:01.00] 副歌 ')).toEqual([
+ { timeMs: 1000, text: '副歌' },
+ { timeMs: 5000, text: '副歌' },
+ ])
+ })
+
+ it('忽略没有时间标签的行,连续的空白行只保留一行', () => {
+ expect(parseLrc('[ar:周杰伦]\n[00:01.00]一\n[00:02.00]\n[00:03.00] \n[00:04.00]二\r\n[00:05.00]')).toEqual([
+ { timeMs: 1000, text: '一' },
+ { timeMs: 2000, text: '' },
+ { timeMs: 4000, text: '二' },
+ { timeMs: 5000, text: '' },
+ ])
+ })
+})
+
+describe('lineIndexAt', () => {
+ const lines = [{ timeMs: 1000, text: '一' }, { timeMs: 2000, text: '二' }, { timeMs: 3000, text: '三' }]
+
+ it('返回开始时间不晚于当前位置的最后一行', () => {
+ expect(lineIndexAt(lines, 0)).toBe(-1)
+ expect(lineIndexAt(lines, 1000)).toBe(0)
+ expect(lineIndexAt(lines, 2999)).toBe(1)
+ expect(lineIndexAt(lines, 60_000)).toBe(2)
+ expect(lineIndexAt([], 1000)).toBe(-1)
+ })
+})
diff --git a/web/src/features/playback/document-title.test.ts b/web/src/features/playback/document-title.test.ts
new file mode 100644
index 0000000..c978aab
--- /dev/null
+++ b/web/src/features/playback/document-title.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from 'vitest'
+import { documentTitle } from './document-title'
+
+describe('documentTitle', () => {
+ it('没有在放的歌时只有品牌名', () => {
+ expect(documentTitle('周末听歌会', null)).toBe('周末听歌会')
+ })
+
+ it('放歌时是歌名 - 歌手 | 品牌名', () => {
+ expect(documentTitle('周末听歌会', { title: '晴天', artist: '周杰伦' })).toBe('晴天 - 周杰伦 | 周末听歌会')
+ })
+
+ it('歌手为空时不留多余的分隔符', () => {
+ expect(documentTitle('Alisten', { title: '片头', artist: '' })).toBe('片头 | Alisten')
+ })
+})
diff --git a/web/src/features/playback/reminder.test.ts b/web/src/features/playback/reminder.test.ts
new file mode 100644
index 0000000..de55fc6
--- /dev/null
+++ b/web/src/features/playback/reminder.test.ts
@@ -0,0 +1,69 @@
+import type { Member, NowPlaying } from '@/core/realtime'
+import { describe, expect, it } from 'vitest'
+import { shouldRemind } from './reminder'
+
+const me: Member = { id: 'm1', name: '小明', avatarHash: '' }
+const other: Member = { id: 'm2', name: '阿青', avatarHash: '' }
+const bot: Member = { id: 'm3', name: '点歌机器人', avatarHash: '' }
+
+const live = true
+const snapshot = false
+const on = true
+const off = false
+const waited = 60_000
+
+function playing(id: string, by: Member | null, likes = 0): NowPlaying {
+ return {
+ durationMs: 1000,
+ entry: {
+ id,
+ track: { source: 'netease', id },
+ meta: { title: id, artist: '', album: '', coverUrl: '', pageUrl: '', durationMs: 1000 },
+ by,
+ auto: by === null,
+ likes,
+ status: 'ready',
+ },
+ gain: 1,
+ lyric: '',
+ mediaUrl: `/media/${id}`,
+ startedAtMs: 0,
+ }
+}
+
+describe('shouldRemind', () => {
+ it('实时事件开始播放自己点的歌时提醒', () => {
+ expect(shouldRemind(null, playing('1', me), me.id, live, on, waited)).toBe(true)
+ expect(shouldRemind(playing('1', other), playing('2', me), me.id, live, on, waited)).toBe(true)
+ })
+
+ it('快照里正在播放自己点的歌时不提醒', () => {
+ expect(shouldRemind(null, playing('1', me), me.id, snapshot, on, waited)).toBe(false)
+ expect(shouldRemind(playing('1', other), playing('2', me), me.id, snapshot, on, waited)).toBe(false)
+ })
+
+ it('同一首歌的后续更新不再提醒', () => {
+ expect(shouldRemind(playing('1', me), playing('1', me, 1), me.id, live, on, waited)).toBe(false)
+ })
+
+ it('别人、机器人和 AutoDJ 点的歌不提醒', () => {
+ for (const by of [other, bot, null])
+ expect(shouldRemind(null, playing('1', by), me.id, live, on, waited)).toBe(false)
+ })
+
+ it('关掉提醒后不提醒', () => {
+ expect(shouldRemind(playing('1', other), playing('2', me), me.id, live, off, waited)).toBe(false)
+ })
+
+ it('点完不到 30 秒就开播的歌不提醒', () => {
+ expect(shouldRemind(playing('1', other), playing('2', me), me.id, live, on, 29_999)).toBe(false)
+ })
+
+ it('等满 30 秒才开播的歌提醒', () => {
+ expect(shouldRemind(playing('1', other), playing('2', me), me.id, live, on, 30_000)).toBe(true)
+ })
+
+ it('开播前没见过的歌不提醒', () => {
+ expect(shouldRemind(playing('1', other), playing('2', me), me.id, live, on, null)).toBe(false)
+ })
+})
diff --git a/web/src/features/playback/timeline.test.ts b/web/src/features/playback/timeline.test.ts
new file mode 100644
index 0000000..1e855a2
--- /dev/null
+++ b/web/src/features/playback/timeline.test.ts
@@ -0,0 +1,48 @@
+import type { PlaybackSnapshot } from './timeline'
+import { describe, expect, it } from 'vitest'
+import { driftSeekPosition, startPlan } from './timeline'
+
+function playback(positionMs: number, state: Partial = {}): PlaybackSnapshot {
+ return { positionMs, paused: false, seeking: false, readyState: 4, ...state }
+}
+
+describe('startPlan', () => {
+ it('还没到开始时间时等待', () => {
+ expect(startPlan(-200, 180_000)).toEqual({ kind: 'wait', delayMs: 200 })
+ })
+
+ it('在时长之内时从应在位置开始', () => {
+ expect(startPlan(0, 180_000)).toEqual({ kind: 'play', positionMs: 0 })
+ expect(startPlan(42_000, 180_000)).toEqual({ kind: 'play', positionMs: 42_000 })
+ })
+
+ it('超过时长时这首歌已经结束', () => {
+ expect(startPlan(180_000, 180_000)).toEqual({ kind: 'ended' })
+ })
+})
+
+describe('driftSeekPosition', () => {
+ it('偏差不超过 1 秒时不进行校正', () => {
+ for (const actual of [10_149, 10_150, 11_000, 9851, 9850, 9000])
+ expect(driftSeekPosition(playback(actual), 10_000)).toBeNull()
+ })
+
+ it('偏差超过 1 秒时返回定位位置', () => {
+ expect(driftSeekPosition(playback(11_001), 10_000)).toBe(10_000)
+ expect(driftSeekPosition(playback(8999), 10_000)).toBe(10_000)
+ })
+
+ it('没有足够数据时不进行校正', () => {
+ for (const readyState of [0, 1, 2])
+ expect(driftSeekPosition(playback(5001, { readyState }), 4000)).toBeNull()
+ expect(driftSeekPosition(playback(5001, { readyState: 3 }), 4000)).toBe(4000)
+ })
+
+ it('暂停时不进行校正', () => {
+ expect(driftSeekPosition(playback(5001, { paused: true }), 4000)).toBeNull()
+ })
+
+ it('正在定位时不进行校正', () => {
+ expect(driftSeekPosition(playback(5001, { seeking: true }), 4000)).toBeNull()
+ })
+})
diff --git a/web/src/features/search/link.test.ts b/web/src/features/search/link.test.ts
new file mode 100644
index 0000000..d970c74
--- /dev/null
+++ b/web/src/features/search/link.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from 'vitest'
+import { linkOf, pickableLink } from './link'
+
+describe('linkOf', () => {
+ it.each([
+ [' http://example.com/a.mp3 ', 'http://example.com/a.mp3'],
+ ['HTTPS://Example.com/A', 'HTTPS://Example.com/A'],
+ ])('%s 是链接', (input, link) => {
+ expect(linkOf(input)).toBe(link)
+ })
+
+ it.each(['晴天'])('%s 不是链接', (input) => {
+ expect(linkOf(input)).toBeNull()
+ })
+
+ it.each([
+ 'https://user@example.com/a',
+ ])('服务端不接受的 %s 不是链接', (input) => {
+ expect(linkOf(input)).toBeNull()
+ })
+})
+
+describe('pickableLink', () => {
+ it.each([
+ 'https://www.bilibili.com/video/BV1xx411c7mD?p=2',
+ 'https://m.bilibili.com/video/BV1xx411c7mD',
+ 'https://bilibili.com/video/av170001',
+ 'HTTPS://B23.TV/qingtian',
+ ])('临时房间可以点 B 站的链接 %s', (link) => {
+ expect(pickableLink(` ${link} `, 'ephemeral')).toBe(link)
+ })
+
+ it.each([
+ 'https://www.youtube.com/watch?v=x',
+ 'https://notbilibili.com/video/BV1xx411c7mD',
+ 'https://bilibili.com.example/video/BV1xx411c7mD',
+ ])('其他链接 %s 只在常驻房间可以点', (link) => {
+ expect(pickableLink(link, 'ephemeral')).toBeNull()
+ expect(pickableLink(link, 'persistent')).toBe(link)
+ })
+
+ it('不在房间里时都不能点', () => {
+ expect(pickableLink('https://www.bilibili.com/video/BV1xx411c7mD', undefined)).toBeNull()
+ expect(pickableLink('https://www.youtube.com/watch?v=x', undefined)).toBeNull()
+ })
+
+ it('不是链接时都不能点', () => {
+ expect(pickableLink('晴天', 'persistent')).toBeNull()
+ expect(pickableLink('晴天', 'ephemeral')).toBeNull()
+ })
+})
diff --git a/web/src/features/search/search-store.test.ts b/web/src/features/search/search-store.test.ts
new file mode 100644
index 0000000..d674c04
--- /dev/null
+++ b/web/src/features/search/search-store.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from 'vitest'
+import { cachedResult, nextLoad, rememberResult } from './search-store'
+
+describe('nextLoad', () => {
+ it('条件没变时沿用当前结果,强制刷新时绕过缓存重新请求', () => {
+ expect(nextLoad('a', 'a', true, true, false)).toBeNull()
+ expect(nextLoad('a', 'a', true, true, true)).toEqual({ shown: 'a', loading: true, from: 'request' })
+ })
+
+ it('没有关键词时显示最近搜索,不发出请求', () => {
+ expect(nextLoad(null, 'a', false, true, false)).toEqual({ shown: null, loading: false, from: 'none' })
+ })
+
+ it('回到已经显示过的条件时直接用缓存,不必连着房间', () => {
+ expect(nextLoad('a', 'b', true, false, false)).toEqual({ shown: 'a', loading: false, from: 'cache' })
+ })
+
+ it('没连上房间时推迟请求并让当前结果作废,连上之后回到原来的条件也会重新请求', () => {
+ expect(nextLoad('a', 'b', false, false, false)).toEqual({ shown: null, loading: true, from: 'none' })
+ expect(nextLoad('b', null, false, false, false)).toEqual({ shown: null, loading: true, from: 'none' })
+ expect(nextLoad('b', null, false, true, false)).toEqual({ shown: 'b', loading: true, from: 'request' })
+ })
+})
+
+describe('搜索结果的缓存', () => {
+ const minute = 60 * 1000
+
+ it('取回超过 10 分钟的结果失效', () => {
+ const cache = rememberResult(new Map(), 'a', { value: 1, at: 0 }, 0)
+ expect(cachedResult(cache, 'a', 10 * minute - 1)?.value).toBe(1)
+ expect(cachedResult(cache, 'a', 10 * minute)).toBeNull()
+ expect(cachedResult(cache, 'b', 0)).toBeNull()
+ })
+
+ it('最多保留 10 份,丢掉最久没显示的;再次显示的移到最近', () => {
+ let cache = new Map()
+ for (let i = 0; i < 10; i++)
+ cache = rememberResult(cache, `k${i}`, { value: i, at: i }, i)
+ cache = rememberResult(cache, 'k0', { value: 0, at: 0 }, 10)
+ cache = rememberResult(cache, 'k10', { value: 10, at: 11 }, 11)
+ expect([...cache.keys()]).toEqual(['k2', 'k3', 'k4', 'k5', 'k6', 'k7', 'k8', 'k9', 'k0', 'k10'])
+ })
+
+ it('记下新结果时去掉已经失效的', () => {
+ const cache = rememberResult(new Map([['old', { value: 1, at: 0 }]]), 'new', { value: 2, at: 10 * minute }, 10 * minute)
+ expect([...cache.keys()]).toEqual(['new'])
+ })
+})
diff --git a/web/src/shared/lib/book-title.test.ts b/web/src/shared/lib/book-title.test.ts
new file mode 100644
index 0000000..2c62c90
--- /dev/null
+++ b/web/src/shared/lib/book-title.test.ts
@@ -0,0 +1,12 @@
+import { describe, expect, it } from 'vitest'
+import { bookTitle } from './book-title'
+
+describe('歌名的书名号', () => {
+ it('加上书名号', () => {
+ expect(bookTitle('晴天')).toBe('《晴天》')
+ })
+
+ it('歌名里已经有书名号时不再加', () => {
+ expect(bookTitle('日语填词《5:20AM》(完整版)')).toBe('日语填词《5:20AM》(完整版)')
+ })
+})
diff --git a/web/src/shared/lib/session-history.test.ts b/web/src/shared/lib/session-history.test.ts
new file mode 100644
index 0000000..943c2c1
--- /dev/null
+++ b/web/src/shared/lib/session-history.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest'
+import { afterMarker, afterPop, afterPush, canGoBack, canGoForward } from './session-history'
+
+describe('历史记录的序号', () => {
+ it('第一条记录不能后退,写入新记录之后可以后退、不能前进', () => {
+ const first = { index: 0, last: 0 }
+ expect(canGoBack(first)).toBe(false)
+ const second = afterPush(first)
+ expect(canGoBack(second)).toBe(true)
+ expect(canGoForward(second)).toBe(false)
+ })
+
+ it('后退之后可以前进,再写入新记录就丢掉了前面的记录', () => {
+ const back = afterPop({ index: 3, last: 3 }, 1)
+ expect(back).toEqual({ index: 1, last: 3 })
+ expect(canGoForward(back)).toBe(true)
+ expect(afterPush(back)).toEqual({ index: 2, last: 2 })
+ })
+
+ it('刷新之后不知道前面的记录,前进到了才知道', () => {
+ expect(afterPop({ index: 1, last: 1 }, 2)).toEqual({ index: 2, last: 2 })
+ })
+
+ it('打开弹窗的标记记录不占序号,同样丢掉前面的记录', () => {
+ const marked = afterMarker({ index: 1, last: 3 })
+ expect(marked).toEqual({ index: 1, last: 1 })
+ expect(canGoForward(marked)).toBe(false)
+ })
+})