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

+23
View File
@@ -0,0 +1,23 @@
package protocol
import (
"strings"
"testing"
)
// B 站曲目 ID 的格式与 internal/domain/track 的规则一致:BV 号,或从第 2 P 起的 BV 号/分P序号。
func TestBilibiliTrackIDs(t *testing.T) {
valid := []string{"BV1xx411c7mD", "BV1xx411c7mD/2", "BV1xx411c7mD/9999"}
invalid := []string{"BV1xx411c7mD/1", "BV1xx411c7mD/02", "av170001", "BV1xx411c7mD/extra", ""}
for _, id := range valid {
if err := decodeData("queue.add", `{"track":{"source":"bilibili","id":"`+id+`"}}`); err != nil {
t.Errorf("合法 ID %q:%v", id, err)
}
}
for _, id := range invalid {
err := decodeData("queue.add", `{"track":{"source":"bilibili","id":"`+id+`"}}`)
if got := errText(err); !strings.Contains(got, "data.track.id") {
t.Errorf("非法 ID %q:错误 = %q,应指出 data.track.id", id, got)
}
}
}
+69
View File
@@ -0,0 +1,69 @@
package protocol
import (
"encoding/json"
"errors"
"testing"
)
func errText(err error) string {
if err == nil {
return ""
}
var arg *ArgumentError
if !errors.As(err, &arg) {
return "不是 ArgumentError:" + err.Error()
}
return arg.Error()
}
func TestDecodeObject(t *testing.T) {
tests := []struct {
raw string
want string
}{
{`{"a":1}`, ""},
{`{"z":1,"b":2}`, "data.b:未知字段"},
}
for _, tt := range tests {
_, err := decodeObject(json.RawMessage(tt.raw), "data", "a")
if got := errText(err); got != tt.want {
t.Errorf("decodeObject(%s) = %q,期望 %q", tt.raw, got, tt.want)
}
}
}
func TestDecodeInteger(t *testing.T) {
if n, err := decodeInteger(json.RawMessage(`42`), "x"); err != nil || n != 42 {
t.Fatalf("decodeInteger = %d, %v", n, err)
}
for _, raw := range []string{`null`} {
_, err := decodeInteger(json.RawMessage(raw), "x")
if got := errText(err); got != "x:应为整数" {
t.Errorf("decodeInteger(%s) = %q", raw, got)
}
}
}
func TestCheckOneOf(t *testing.T) {
tests := []struct {
raw string
want string
}{
{`{"track":1}`, ""},
{`{"track":1,"requester":"张三"}`, ""},
{`{"source":1,"keyword":1,"requester":"张三"}`, ""},
{`{"requester":"张三"}`, "data:只能二选一"},
{`{"track":1,"keyword":1,"requester":"张三"}`, "data:只能二选一"},
}
for _, tt := range tests {
var m map[string]json.RawMessage
if err := json.Unmarshal([]byte(tt.raw), &m); err != nil {
t.Fatal(err)
}
err := checkOneOf(m, "data", "只能二选一", []string{"requester"}, []string{"track"}, []string{"source", "keyword"})
if got := errText(err); got != tt.want {
t.Errorf("checkOneOf(%s) = %q,期望 %q", tt.raw, got, tt.want)
}
}
}
+115
View File
@@ -0,0 +1,115 @@
package protocol
import (
"reflect"
"strings"
"testing"
)
func TestHTTPRequestValidation(t *testing.T) {
decode := map[string]func([]byte) error{
"updateMe": func(b []byte) error { _, err := DecodeUpdateMeRequest(b); return err },
"createRoom": func(b []byte) error { _, err := DecodeCreateRoomRequest(b); return err },
"adminCreate": func(b []byte) error { _, err := DecodeAdminCreateRoomRequest(b); return err },
}
tests := []struct {
name string
op string
body string
want string
}{
{"修改资料:昵称 80 个字符", "updateMe", `{"name":"` + strings.Repeat("名", 80) + `"}`, ""},
{"修改资料:昵称超过 80 个字符", "updateMe", `{"name":"` + strings.Repeat("名", 81) + `"}`, "name:长度不能超过 80 个字符"},
{"修改资料:邮箱超过 320 个字符", "updateMe", `{"email":"` + strings.Repeat("a", 321) + `"}`, "email:长度不能超过 320 个字符"},
{"建房:缺少描述", "createRoom", `{"name":"房间"}`, "description:缺少必填字段"},
{"建房:描述超过 500 个字符", "createRoom", `{"name":"房间","description":"` + strings.Repeat("述", 501) + `"}`, "description:长度不能超过 500 个字符"},
{"建房:名称为 null", "createRoom", `{"name":null,"description":""}`, "name:应为字符串"},
{"后台建房:名称 80 个字符、描述 500 个字符", "adminCreate", `{"name":"` + strings.Repeat("名", 80) + `","description":"` + strings.Repeat("述", 500) + `"}`, ""},
{"后台建房:名称超过 80 个字符", "adminCreate", `{"name":"` + strings.Repeat("名", 81) + `","description":""}`, "name:长度不能超过 80 个字符"},
{"后台建房:别名", "adminCreate", `{"name":"房间","description":"","slug":"lofi-24"}`, ""},
{"后台建房:别名不能为空", "adminCreate", `{"name":"房间","description":"","slug":""}`, "slug:格式不正确"},
{"后台建房:别名超过 32 个字符", "adminCreate", `{"name":"房间","description":"","slug":"` + strings.Repeat("a", 33) + `"}`, "slug:格式不正确"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := errText(decode[tt.op]([]byte(tt.body))); got != tt.want {
t.Errorf("错误 = %q,期望 %q", got, tt.want)
}
})
}
}
func TestErrorCodeHTTPStatus(t *testing.T) {
tests := map[ErrorCode]int{
ErrorCodeQueueFull: 409,
ErrorCodeInternal: 500,
}
for code, want := range tests {
if got := code.HTTPStatus(); got != want {
t.Errorf("%s 的 HTTP 状态码 = %d,期望 %d", code, got, want)
}
}
}
func roomRoute(t *testing.T, pattern string) RoomRoute {
t.Helper()
for _, r := range RoomRoutes {
if r.Pattern == pattern {
return r
}
}
t.Fatalf("没有房间内请求的路由 %s", pattern)
return RoomRoute{}
}
func TestRoomRouteDecode(t *testing.T) {
const (
search = "GET /api/room/search/tracks"
playlist = "GET /api/room/playlists/{source}/{playlistId}/tracks"
like = "POST /api/room/queue/{entryId}/likes"
vote = "POST /api/room/skip-votes"
settings = "PATCH /api/room/settings"
)
qq, keyword := SourceQQ, "晴天"
tests := []struct {
name string
pattern string
path map[string]string
query string
body string
want any
wantErr string
}{
{"搜索:查询串按字段的类型解析", search, nil, "source=netease&keyword=%E6%99%B4%E5%A4%A9&page=2&pageSize=10", "", SearchTracksRequest{Source: SourceNetease, Keyword: "晴天", Page: 2, PageSize: 10}, ""},
{"搜索:页码不是整数", search, nil, "source=netease&keyword=a&page=one&pageSize=10", "", nil, "page:应为整数"},
{"搜索:页码超出范围", search, nil, "source=netease&keyword=a&page=0&pageSize=10", "", nil, "page:不能小于 1"},
{"搜索:来源不是枚举中的值", search, nil, "source=apple&keyword=a&page=1&pageSize=10", "", nil, "source:取值应为 netease、qq、bilibili、link 之一"},
{"搜索:缺少必填的参数", search, nil, "source=netease&keyword=a&page=1", "", nil, "pageSize:缺少必填字段"},
{"搜索:重复的参数", search, nil, "source=netease&keyword=a&page=1&page=2&pageSize=10", "", nil, "page:参数重复"},
{"搜索:未知参数", search, nil, "source=netease&keyword=a&page=1&pageSize=10&sort=hot", "", nil, "sort:未知参数"},
{"搜索:查询串格式不正确", search, nil, "keyword=%zz", "", nil, "查询串格式不正确"},
{"歌单:路径参数", playlist, map[string]string{"source": "netease", "playlistId": "3778678"}, "", "", PlaylistTracksRequest{Source: SourceNetease, PlaylistID: "3778678"}, ""},
{"歌单:路径中的来源不是枚举中的值", playlist, map[string]string{"source": "apple", "playlistId": "1"}, "", "", nil, "source:取值应为 netease、qq、bilibili、link 之一"},
{"删歌:DELETE 的路径参数", "DELETE /api/room/queue/{entryId}", map[string]string{"entryId": "7"}, "", "", QueueRemoveRequest{EntryID: "7"}, ""},
{"点赞:不带请求体", like, map[string]string{"entryId": "7"}, "", "", QueueLikeRequest{EntryID: "7"}, ""},
{"点赞:请求体为空对象", like, map[string]string{"entryId": "7"}, "", "{}", QueueLikeRequest{EntryID: "7"}, ""},
{"点赞:请求体中有路径参数", like, map[string]string{"entryId": "7"}, "", `{"entryId":"8"}`, nil, "entryId:未知字段"},
{"点赞:请求体为 null", like, map[string]string{"entryId": "7"}, "", "null", nil, "应为 JSON 对象"},
{"投票:不带请求体", vote, nil, "", "", PlaybackVoteSkipRequest{}, ""},
{"投票:请求体不是对象", vote, nil, "", "[]", nil, "应为 JSON 对象"},
{"投票:请求体为 null", vote, nil, "", "null", nil, "应为 JSON 对象"},
{"播放模式", settings, nil, "", `{"playMode":"rotation"}`, SettingsSetPlayModeRequest{PlayMode: PlayModeRotation}, ""},
{"播放模式:缺少字段", settings, nil, "", "", nil, "playMode:缺少必填字段"},
{"聊天:POST 不接受查询参数", "POST /api/room/messages", nil, "content=hi", `{"content":"你好"}`, nil, "content:未知参数"},
{"点歌:请求体与 WebSocket 的 data 相同", "POST /api/room/queue", nil, "", `{"source":"qq","keyword":"晴天"}`, QueueAddRequest{Source: &qq, Keyword: &keyword}, ""},
{"快照:GET 不读请求体", "GET /api/room", nil, "", "不是 JSON", RoomSyncRequest{}, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := roomRoute(t, tt.pattern).Decode(func(name string) string { return tt.path[name] }, tt.query, []byte(tt.body))
if errText(err) != tt.wantErr || !reflect.DeepEqual(got, tt.want) {
t.Errorf("Decode = %#v、%q,期望 %#v、%q", got, errText(err), tt.want, tt.wantErr)
}
})
}
}
+33
View File
@@ -0,0 +1,33 @@
package protocol
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// testdata/Link.json 的用例也由网页生成的测试(protocol.gen.test.ts)使用,两边对每个用例的判断必须相同。
func TestLinkCases(t *testing.T) {
data, err := os.ReadFile(filepath.Join("testdata", "Link.json"))
if err != nil {
t.Fatal(err)
}
var cases []struct {
Value string
Valid bool
Note string
}
if err := json.Unmarshal(data, &cases); err != nil {
t.Fatal(err)
}
for _, c := range cases {
frame, err := json.Marshal(map[string]any{"kind": "request", "type": RequestQueueAdd, "id": "a1", "data": map[string]any{"track": map[string]string{"source": "link", "id": c.Value}}})
if err != nil {
t.Fatal(err)
}
if _, err := DecodeRequest(frame); (err == nil) != c.Valid {
t.Errorf("%s:%q 的解码错误为 %v,期望接受:%v", c.Note, c.Value, err, c.Valid)
}
}
}
+56
View File
@@ -0,0 +1,56 @@
[
{"value": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "valid": true, "note": "常见的链接"},
{"value": "http://example.com", "valid": true, "note": "没有路径"},
{"value": "HTTPS://WWW.YOUTUBE.COM/watch?v=dQw4w9WgXcQ", "valid": true, "note": "scheme 和主机名是大写"},
{"value": "hTTp://Example.com/A?b=2&a=1#Frag", "valid": true, "note": "大小写混合,带查询串和片段"},
{"value": "https://music.163.com/#/song?id=186016", "valid": true, "note": "片段中的路由"},
{"value": "https://example.com:8080/a", "valid": true, "note": "端口"},
{"value": "https://example.com:/a", "valid": true, "note": "空的端口"},
{"value": "https://example.com:00443/a", "valid": true, "note": "端口有前导零"},
{"value": "https://[2606:4700:4700::1111]:8443/a", "valid": true, "note": "IPv6 地址"},
{"value": "https://[::ffff:192.0.2.1]/a", "valid": true, "note": "IPv4 映射的 IPv6 地址"},
{"value": "https://1.1.1.1/a", "valid": true, "note": "IPv4 地址"},
{"value": "https://example.com./a", "valid": true, "note": "主机名结尾有点"},
{"value": "https://a-b_c~d.example/a", "valid": true, "note": "主机名中的 -、_ 和 ~"},
{"value": "https://xn--fsqu00a.xn--0zwm56d/a", "valid": true, "note": "punycode 写的国际化域名"},
{"value": "https://例子.测试/a", "valid": true, "note": "国际化域名"},
{"value": "https://example.com/你好?q=中文#片段", "valid": true, "note": "路径、查询串和片段中的非 ASCII 字符"},
{"value": "https://example.com/%E4%BD%A0%e5%a5%bd", "valid": true, "note": "路径中的百分号编码"},
{"value": "https://example.com/?q=100%", "valid": true, "note": "查询串中的 % 不检查"},
{"value": "https://example.com/歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌", "valid": true, "note": "正好 2048 字节"},
{"value": "ftp://example.com/a", "valid": false, "note": "不是 http 或 https"},
{"value": "/watch?v=x", "valid": false, "note": "相对地址"},
{"value": "https:example.com/a", "valid": false, "note": "缺少 //"},
{"value": "https:/example.com/a", "valid": false, "note": "缺少一个 /"},
{"value": "https:///a", "valid": false, "note": "没有主机名"},
{"value": "https://:8080/a", "valid": false, "note": "只有端口,没有主机名"},
{"value": "https://user:secret@example.com/a", "valid": false, "note": "带用户信息"},
{"value": "https://user@example.com/a", "valid": false, "note": "只有用户名的用户信息"},
{"value": "https://@example.com/a", "valid": false, "note": "空的用户信息"},
{"value": "https://example.com:65536/a", "valid": false, "note": "端口超过 65535"},
{"value": "https://example.com:8a/a", "valid": false, "note": "端口不是数字"},
{"value": "https://example.com:80:80/a", "valid": false, "note": "两个端口"},
{"value": "https://[::1/a", "valid": false, "note": "方括号不完整"},
{"value": "https://[1:2:3:4:5:6:7:8:9]/a", "valid": false, "note": "方括号中不是 IPv6 地址"},
{"value": "https://[fe80::1%25eth0]/a", "valid": false, "note": "带 zone 的 IPv6 地址"},
{"value": "https://exa%41mple.com/a", "valid": false, "note": "主机名中的百分号编码"},
{"value": "https://a{b}.example/a", "valid": false, "note": "主机名中的 { 和 }:url.Parse 不接受,浏览器接受"},
{"value": "https://a<b.example/a", "valid": false, "note": "主机名中的 <:url.Parse 接受,浏览器不接受"},
{"value": "https://a!b.example/a", "valid": false, "note": "主机名中的 !"},
{"value": "https://example.com\\evil.example/a", "valid": false, "note": "主机名中的反斜杠:浏览器把它当作路径的开始"},
{"value": "https://example.com/a\\b", "valid": false, "note": "路径中的反斜杠:浏览器把它当作 /,服务端原样保存"},
{"value": "https://example.com/?q=a\\b", "valid": false, "note": "查询串中的反斜杠"},
{"value": "https://example.com/#a\\b", "valid": false, "note": "片段中的反斜杠"},
{"value": "https://1.2.3.256/a", "valid": false, "note": "超出范围的 IPv4 地址"},
{"value": "https://127.1/a", "valid": false, "note": "简写的 IPv4 地址"},
{"value": "https://0x7f.0.0.1/a", "valid": false, "note": "十六进制的 IPv4 地址"},
{"value": "https://example.123/a", "valid": false, "note": "最后一段是数字的主机名"},
{"value": "https://1.2.3.4./a", "valid": false, "note": "结尾有点的 IPv4 地址"},
{"value": "https://example.com/%zz", "valid": false, "note": "路径中不完整的百分号编码:url.Parse 不接受"},
{"value": "https://example.com/a#%", "valid": false, "note": "片段中不完整的百分号编码"},
{"value": "https://exa mple.com/a", "valid": false, "note": "主机名中的空格"},
{"value": "https://example.com/a b", "valid": false, "note": "路径中的空格"},
{"value": "https://example.com/a\tb", "valid": false, "note": "制表符"},
{"value": "https://example.com/a", "valid": false, "note": "DEL"},
{"value": "https://example.com/歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌歌a", "valid": false, "note": "超过 2048 字节"}
]
+77
View File
@@ -0,0 +1,77 @@
package protocol
import (
"strings"
"testing"
)
func TestDecodeRequestEnvelope(t *testing.T) {
tests := []struct {
name string
frame string
wantID string
wantErr string
}{
{"kind 不对", `{"kind":"response","type":"room.sync","id":"r1","data":{}}`, "", `kind:应为 "request"`},
{"id 太长", `{"kind":"request","type":"room.sync","id":"` + strings.Repeat("a", 129) + `","data":{}}`, "", "id:应为 1–128 个字符的字符串"},
{"id 含 U+0000", `{"kind":"request","type":"room.sync","id":"a\u0000b","data":{}}`, "", "id:不能包含 U+0000"},
{"缺少 type", `{"kind":"request","id":"r1","data":{}}`, "", "type:应为非空字符串"},
{"多余的字段", `{"kind":"request","type":"room.sync","id":"r1","data":{},"term":1}`, "r1", "term:未知字段"},
{"缺少 data", `{"kind":"request","type":"room.sync","id":"r1"}`, "r1", "data:缺少必填字段"},
{"data 不合法", `{"kind":"request","type":"queue.like","id":"r1","data":{}}`, "r1", "data.entryId:缺少必填字段"},
{"id 按字符计数", `{"kind":"request","type":"room.sync","id":"` + strings.Repeat("歌", 128) + `","data":{}}`, strings.Repeat("歌", 128), ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, err := DecodeRequest([]byte(tt.frame))
if got := errText(err); got != tt.wantErr {
t.Errorf("错误 = %q,期望 %q", got, tt.wantErr)
}
if req.ID != tt.wantID {
t.Errorf("ID = %q,期望 %q", req.ID, tt.wantID)
}
})
}
}
func decodeData(typ, data string) error {
_, err := DecodeRequest([]byte(`{"kind":"request","type":"` + typ + `","id":"r1","data":` + data + `}`))
return err
}
func TestRequestValidation(t *testing.T) {
longLink := "https://example.com/" + strings.Repeat("a", 2048-len("https://example.com/"))
tests := []struct {
name string
typ string
data string
want string
}{
{"点歌:链接超过 2048 字节", "queue.add", `{"track":{"source":"link","id":"` + longLink + `a"}}`, "data.track.id:不能超过 2048 字节"},
{"点歌:链接不是 http", "queue.add", `{"track":{"source":"link","id":"ftp://example.com/a"}}`, "data.track.id:应为 http 或 https 的链接"},
{"点歌:未知来源", "queue.add", `{"track":{"source":"apple","id":"1"}}`, "data.track.source:取值应为 netease、qq、bilibili、link 之一"},
{"点歌:关键词 500 个字符", "queue.add", `{"source":"qq","keyword":"` + strings.Repeat("歌", 500) + `"}`, ""},
{"点歌:关键词超过 500 个字符", "queue.add", `{"source":"qq","keyword":"` + strings.Repeat("歌", 501) + `"}`, "data.keyword:长度不能超过 500 个字符"},
{"点歌:关键词含 NUL", "queue.add", `{"source":"qq","keyword":"晴\u0000天"}`, "data.keyword:不能包含 U+0000"},
{"点歌:两种写法混用", "queue.add", `{"source":"qq","keyword":"晴天","track":{"source":"qq","id":"1"}}`, "data:只能提供以下字段组合之一:track;source、keyword"},
{"点歌:只有来源", "queue.add", `{"source":"qq"}`, "data:只能提供以下字段组合之一:track;source、keyword"},
{"点歌:指定曲目时代填 80 个字符的点歌人", "queue.add", `{"track":{"source":"qq","id":"1"},"requester":"` + strings.Repeat("名", 80) + `"}`, ""},
{"点歌:点歌人为空", "queue.add", `{"source":"qq","keyword":"晴天","requester":""}`, "data.requester:长度不能少于 1 个字符"},
{"点歌:点歌人超过 80 个字符", "queue.add", `{"track":{"source":"qq","id":"1"},"requester":"` + strings.Repeat("名", 81) + `"}`, "data.requester:长度不能超过 80 个字符"},
{"搜索:每页 100 条", "search.tracks", `{"source":"netease","keyword":"a","page":1,"pageSize":100}`, ""},
{"搜索:每页超过 100 条", "search.tracks", `{"source":"netease","keyword":"a","page":1,"pageSize":101}`, "data.pageSize:不能大于 100"},
{"搜索:页码从 1 开始", "search.playlists", `{"source":"netease","keyword":"a","page":0,"pageSize":10}`, "data.page:不能小于 1"},
{"搜索:关键词为空", "search.tracks", `{"source":"netease","keyword":"","page":1,"pageSize":10}`, "data.keyword:长度不能少于 1 个字符"},
{"聊天:长度不受限制", "chat.send", `{"content":"` + strings.Repeat("歌", 1000) + `"}`, ""},
{"心跳:时间必须是整数", "ping", `{"clientTimeMs":1.5}`, "data.clientTimeMs:应为整数"},
{"播放模式:轮流播放", "settings.setPlayMode", `{"playMode":"rotation"}`, ""},
{"播放模式:未知的模式", "settings.setPlayMode", `{"playMode":"shuffle"}`, "data.playMode:取值应为 sequential、random、rotation 之一"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := errText(decodeData(tt.typ, tt.data)); got != tt.want {
t.Errorf("错误 = %q,期望 %q", got, tt.want)
}
})
}
}