test: add automated tests and fixtures

This commit is contained in:
DiheChen committed 2026-10-30 01:33:34 +08:00
1 parent c4008505e6
commit 50039353ad
215 files changed
+34050

No files matched your search

+76
View File
@@ -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 "只有行尾不同"
}
+613
View File
@@ -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)
}
}
}
}
+94
View File
@@ -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)
}
}
+344
View File
@@ -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
}
+273
View File
@@ -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": "座位上的成员,<b>座位号</b> 从 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}$"}
}
}
}
]
}
@@ -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": "座位上的成员,<b>座位号</b> 从 1 开始 & 不超过 8"
}
}
}
}
+179
View File
@@ -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]
}
@@ -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<string, never>
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 = <T>(request: HttpRequest) => Promise<T>
export function httpClient(send: HttpSend) {
return {
createMember: () => send<CreateMemberResponse>({ method: 'POST', path: '/api/members', auth: 'none', body: {} }),
getRoom: (ref: string) => send<Room>({ method: 'GET', path: `/api/rooms/${encodeURIComponent(ref)}`, auth: 'none' }),
createRoom: (body: CreateRoomRequest) => send<CreateRoomResponse>({ method: 'POST', path: '/api/rooms', auth: 'none', body }),
getSeat: (ref: string, seat: string) => send<Member>({ method: 'GET', path: `/api/rooms/${encodeURIComponent(ref)}/seats/${encodeURIComponent(seat)}`, auth: 'member' }),
adminUpdateRoom: (id: string, body: AdminUpdateRoomRequest) => send<void>({ method: 'PATCH', path: `/api/admin/rooms/${encodeURIComponent(id)}`, auth: 'admin', body }),
}
}
@@ -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([])
})
})
@@ -0,0 +1,197 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
export const PROTOCOL_VERSION = '362d144af81ba8bf'
export type Empty = Record<string, never>
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<string, never>
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<T extends RequestType = RequestType> {
kind: 'request'
type: T
id: string
data: RequestDataMap[T]
}
export type ResponseMessage<T extends RequestType = RequestType> = {
[K in T]: { kind: 'response', type: K, id: string } & (
| { data: ResponseDataMap[K], error?: undefined }
| { data: Empty, error: ErrorInfo }
)
}[T]
export type ConnectionEventMessage<T extends ConnectionEventType = ConnectionEventType> = {
[K in T]: { kind: 'event', type: K, data: ConnectionEventDataMap[K] }
}[T]
export type RoomEventMessage<T extends RoomEventType = RoomEventType> = {
[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 = <T extends RequestType>(type: T, data: RequestDataMap[T]) => Promise<ResponseDataMap[T]>
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)
}
+426
View File
@@ -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)
}
}
@@ -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)
}
}
}
+62
View File
@@ -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, &quota{left: left})
}
+171
View File
@@ -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)
}
}
}
+363
View File
@@ -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)
}
}
})
}
+259
View File
@@ -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)
}
}
+203
View File
@@ -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)
}
}
+409
View File
@@ -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)
}
})
}
+45
View File
@@ -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)
}
}
+55
View File
@@ -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))
}
})
}
+685
View File
@@ -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)
}
})
}
+55
View File
@@ -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)
}
}
+38
View File
@@ -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)
}
})
}
+93
View File
@@ -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
}
+207
View File
@@ -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)
}
}
@@ -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))
}
@@ -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)
}
}
+119
View File
@@ -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")
}
+295
View File
@@ -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("读取凭据失败时没有返回错误")
}
}
+144
View File
@@ -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())
}
}
+425
View File
@@ -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 = "<script>alert(2)</script>"
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 清单", `<?xml version="1.0" encoding="UTF-8"?>
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" profiles="urn:mpeg:dash:profile:isoff-on-demand:2011" type="static" mediaPresentationDuration="PT2S" minBufferTime="PT1S">
<Period><AdaptationSet mimeType="audio/mp4"><Representation id="1" bandwidth="128000" codecs="mp4a.40.2">
<BaseURL>http://%s/audio.m4s</BaseURL>
</Representation></AdaptationSet></Period>
</MPD>
`, "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)
}
}
}
+22
View File
@@ -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()
}
@@ -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)
}
}
@@ -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)
}
})
}
}
@@ -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)
}
}
+200
View File
@@ -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)
}
@@ -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)
}
}
+1
View File
@@ -0,0 +1 @@
{"sgc":false,"sfy":false,"qfy":false,"lrc":{"version":2,"lyric":"[00:16.35]故事的小黄花\n[00:20.16]从出生那年就飘着\n"},"code":200}
@@ -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}
+1
View File
@@ -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}
@@ -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":[]}}
@@ -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}
@@ -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}
@@ -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}
+1
View File
@@ -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}
@@ -0,0 +1 @@
{"songs":[],"privileges":[{"id":1,"st":-200,"code":0}],"code":200}
@@ -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}
@@ -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)
}
}
@@ -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)
}
}
}
@@ -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)
}
}
}
@@ -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)
}
}
}
@@ -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
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
}
@@ -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)
}
}
}
@@ -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)
}
}
+162
View File
@@ -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("别名冲突时票据不应删除")
}
}
@@ -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 表接受了第二行")
}
}
@@ -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("曲目表不随房间删除")
}
}
@@ -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)
}
@@ -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])
}
}
@@ -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)
}
}
+63
View File
@@ -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)
}
}
File diff suppressed because it is too large. Load diff
+217
View File
@@ -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)
}
}
+367
View File
@@ -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)
}
}
+1
View File
@@ -0,0 +1 @@
{"retcode":1101,"code":1101,"subcode":1101}
+1
View File
@@ -0,0 +1 @@
{"retcode":0,"code":0,"subcode":0,"lyric":"W3RpOuaZtOWkqV0KW2FyOuWRqOadsOS8pl0KWzAwOjAwLjAwXeaZtOWkqSAtIOWRqOadsOS8piAoSmF5IENob3UpCg=="}
@@ -0,0 +1 @@
{"code":0,"subcode":0,"message":"","data":{"display_num":321321,"list":[{"dissid":"7382629476","dissname":"精选&#32;&#124;&#32;好听到单曲循环的热歌","imgurl":"http://p.qpic.cn/music_cover/Z89aLA93LOSOicz0QOnMboqgLaiaFohjweglHh6JSoL8hrjOfFOC6DXw/600","introduction":"音乐,是生命中的一束光。","listennum":1646891961,"song_count":130,"creator":{"name":"故事未完待续","qq":1968987427}},{"dissid":"7709684645","dissname":"抖音热播&#32;&#124;&#32;瞬间心动的神仙歌曲","imgurl":"http://qpic.y.qq.com/music_cover/aDicdhbLYu1KGBxNOsA6ff1v3vCMPkgFNhdQf18FKSOUiaVCmJ7TOIXQ/300","introduction":"抖音热播的神仙歌曲,建议收藏。","listennum":979011192,"song_count":121,"creator":{"name":"行走的荷尔蒙","qq":1945851593}}]}}
+1
View File
@@ -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}}}
@@ -0,0 +1 @@
{"code":0,"data":[],"url":null,"url1":{},"extra_data":[],"joox":0,"joox_login":0,"msgid":0}
+1
View File
@@ -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":"周杰伦"}]}]}
+93
View File
@@ -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
}
+177
View File
@@ -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)
}
}
+887
View File
@@ -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: &current,
},
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: &current}}},
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)
}
}
+127
View File
@@ -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)
}
}
@@ -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)
}
}
+55
View File
@@ -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
}
+25
View File
@@ -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)
}
}
+56
View File
@@ -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,
}
}
@@ -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 = "<html><script>alert(1)</script></html>"
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, `<?xml version="1.0" encoding="UTF-8"?><Error><Code>AccessDenied</Code><Message>Access Denied</Message></Error>`)
})
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)
}
}
+195
View File
@@ -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)
}
}
@@ -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 = "<script>alert(2)</script>"
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, `<!doctype html><html><head><title>标签</title></head><body><audio src="/tagged.wav"></audio></body></html>`)
}),
"/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, `<!doctype html><html><head><title>列表</title></head><body><audio src="/one.wav"></audio><audio src="/two.wav"></audio></body></html>`)
}),
"/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("分片没有被下载")
}
}
+147
View File
@@ -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()
}
+169
View File
@@ -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)
}
}
+505
View File
@@ -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, `<!doctype html><html><head><title>晴天</title><meta property="og:title" content="晴天">`+
`<meta property="og:image" content="%[1]s/cover.jpg"></head><body><audio src="%[1]s/song.wav"></audio></body></html>`, base)
})
mux.HandleFunc("/empty", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, "<!doctype html><html><head><title>没有媒体</title></head><body>你好</body></html>")
})
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())
}
}
+506
View File
@@ -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)
}
}
+362
View File
@@ -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: &quota{}}
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)
}
}
+195
View File
@@ -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)
}
}
+89
View File
@@ -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)
}
}
}
+91
View File
@@ -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)
}
}
+98
View File
@@ -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("对象删除失败时准备结果应保留,后台还能看到这首歌并重试")
}
}
+282
View File
@@ -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)
}
}
+180
View File
@@ -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)
}
})
}
+294
View File
@@ -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
}
+268
View File
@@ -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)
}
+20
View File
@@ -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 }
+136
View File
@@ -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)
}
+81
View File
@@ -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
}
Loaded 100 of 215 files, more files were not shown because too many files have changed in this diff. Show more