package reader import ( "bytes" "errors" "fmt" "io" "net/http" "os" "path/filepath" "strings" "testing" "github.com/truewhile/MeBox/internal/config" ) func TestIsAudioMediaURL(t *testing.T) { cases := []struct { name string url string bookType int want bool }{ {"m4a 直链", "https://cdn.example.com/a.m4a?uid=1&vkey=x", 0, true}, {"mp3 直链", "https://cdn.example.com/a.MP3", 0, true}, {"音频书里的插图", "https://cdn.example.com/img/cover.jpg", 1, false}, {"HLS 播放列表", "https://cdn.example.com/index.m3u8?token=1", 1, false}, {"无后缀签名直链(音频书)", "https://cdn.example.com/stream?token=1", 1, true}, {"无后缀签名直链(文本/图片书)", "https://cdn.example.com/stream?token=1", 0, false}, {"坏地址但书籍是音频", "://bad", 1, true}, } for _, c := range cases { if got := IsAudioMediaURL(c.url, c.bookType); got != c.want { t.Errorf("%s: IsAudioMediaURL(%q, %d) = %v,期望 %v", c.name, c.url, c.bookType, got, c.want) } } } // TestAudioMediaCacheKeyIgnoresQuery 换票会改写 uid/vkey/ct,缓存键必须忽略 // 这些签名/时效参数,否则每次换票都会重新下载一遍。 func TestAudioMediaCacheKeyIgnoresQuery(t *testing.T) { a := audioMediaCacheKey("book-1", "https://cdn.example.com/long/a.m4a?uid=1&vkey=aaa") b := audioMediaCacheKey("book-1", "https://cdn.example.com/long/a.m4a?uid=2&vkey=bbb&ct=3") if a == "" || a != b { t.Fatalf("同一对象换票后缓存键应一致: %q vs %q", a, b) } if c := audioMediaCacheKey("book-2", "https://cdn.example.com/long/a.m4a"); c == a { t.Fatalf("不同书籍的缓存键不应相同") } if d := audioMediaCacheKey("book-1", "https://cdn.example.com/long/b.m4a"); d == a { t.Fatalf("不同对象的缓存键不应相同") } } // TestAudioMediaCacheKeyKeepsIdentifyingQuery 只靠 query 区分章节的书源 // (路径固定、如 /audio?id=12345)不能被合并成同一个缓存对象,否则第二集 // 会命中第一集的缓存直接播错内容。 func TestAudioMediaCacheKeyKeepsIdentifyingQuery(t *testing.T) { ep1 := audioMediaCacheKey("book-1", "https://host/audio?id=12345&vkey=aaa") ep2 := audioMediaCacheKey("book-1", "https://host/audio?id=67890&vkey=bbb") if ep1 == "" || ep2 == "" { t.Fatal("缓存键不应为空") } if ep1 == ep2 { t.Fatalf("不同章节(query 不同)的缓存键不应相同,否则会串集") } } // TestAudioMediaCacheKeyIgnoresQueryOrder 非签名参数的顺序不应影响缓存键。 func TestAudioMediaCacheKeyIgnoresQueryOrder(t *testing.T) { a := audioMediaCacheKey("book-1", "https://host/a.m4a?id=7&chapter=3&vkey=x") b := audioMediaCacheKey("book-1", "https://host/a.m4a?chapter=3&id=7&vkey=y") if a == "" || a != b { t.Fatalf("参数顺序不应影响缓存键: %q vs %q", a, b) } } // ─── 断点续传下载器 ────────────────────────────────────────────────────── func TestParseContentRange(t *testing.T) { cases := []struct { in string start int64 total int64 wantOK bool }{ {"bytes 100-199/1234", 100, 1234, true}, {"bytes 0-99/*", 0, -1, true}, {"BYTES 500-599/600", 500, 600, true}, {"", 0, -1, false}, {"bytes */1234", 0, -1, false}, {"bytes x-1/2", 0, -1, false}, } for _, c := range cases { start, total, ok := parseContentRange(c.in) if ok != c.wantOK || start != c.start || total != c.total { t.Errorf("parseContentRange(%q) = (%d,%d,%v),期望 (%d,%d,%v)", c.in, start, total, ok, c.start, c.total, c.wantOK) } } } // errAfterReader 读到 data 末尾后返回 err,模拟上游中途 RST/提前 EOF。 type errAfterReader struct { data []byte pos int err error } func (r *errAfterReader) Read(p []byte) (int, error) { if r.pos >= len(r.data) { return 0, r.err } n := copy(p, r.data[r.pos:]) r.pos += n return n, nil } func (r *errAfterReader) Close() error { return nil } func newAudioDownloadTestService(t *testing.T) *ReaderService { t.Helper() return &ReaderService{cfg: &config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}} } func okResponse(body []byte) *http.Response { return &http.Response{ StatusCode: http.StatusOK, ContentLength: int64(len(body)), Header: http.Header{"Content-Type": {"audio/mp4"}}, Body: io.NopCloser(bytes.NewReader(body)), } } func rangeResponse(header string, body []byte) *http.Response { return &http.Response{ StatusCode: http.StatusPartialContent, Header: http.Header{"Content-Range": {header}}, Body: io.NopCloser(bytes.NewReader(body)), } } // TestDownloadRemoteAudioResumesAfterReset 上游写到一半断开后,第二轮必须用 // Range 从断点续传,最终文件与上游内容逐字节一致。 func TestDownloadRemoteAudioResumesAfterReset(t *testing.T) { svc := newAudioDownloadTestService(t) const mediaURL = "https://cdn.example.com/long/a.m4a?vkey=1" payload := bytes.Repeat([]byte("0123456789"), 500) // 5000 字节 const firstChunk = 2000 var ranges []string attempt := 0 path, err := svc.DownloadRemoteAudio(t.Context(), "book-1", mediaURL, 3, func(rangeHeader string) (*http.Response, []byte, error) { ranges = append(ranges, rangeHeader) attempt++ if attempt == 1 { // 声明全量长度,只写 2000 字节就断开。 return &http.Response{ StatusCode: http.StatusOK, ContentLength: int64(len(payload)), Header: http.Header{"Content-Type": {"audio/mp4"}}, Body: &errAfterReader{ data: payload[:firstChunk], err: io.ErrUnexpectedEOF, }, }, nil, nil } if rangeHeader != fmt.Sprintf("bytes=%d-", firstChunk) { t.Fatalf("第二轮应请求断点续传,实际 Range=%q", rangeHeader) } return rangeResponse( fmt.Sprintf("bytes %d-%d/%d", firstChunk, len(payload)-1, len(payload)), payload[firstChunk:], ), nil, nil }) if err != nil { t.Fatalf("续传后应当成功: %v", err) } got, readErr := os.ReadFile(path) if readErr != nil { t.Fatalf("读缓存文件失败: %v", readErr) } if !bytes.Equal(got, payload) { t.Fatalf("续传后的内容不完整: %d 字节,期望 %d", len(got), len(payload)) } if len(ranges) != 2 || ranges[0] != "" { t.Fatalf("请求序列 = %q,期望首次整段、第二次续传", ranges) } } // TestDownloadRemoteAudioResumesAcrossCalls 上一次请求失败留下的分片,下一次 // (用户点重试)要能直接接着下,而不是从第 0 字节重来。 func TestDownloadRemoteAudioResumesAcrossCalls(t *testing.T) { svc := newAudioDownloadTestService(t) const mediaURL = "https://cdn.example.com/long/b.m4a" payload := bytes.Repeat([]byte("abcdefgh"), 1000) const firstChunk = 3000 // 第一次调用:上游只给 3000 字节就断,重试也没用(每次都 200 + 截断)。 _, err := svc.DownloadRemoteAudio(t.Context(), "book-2", mediaURL, 1, func(rangeHeader string) (*http.Response, []byte, error) { return &http.Response{ StatusCode: http.StatusOK, ContentLength: int64(len(payload)), Header: http.Header{"Content-Type": {"audio/mp4"}}, Body: &errAfterReader{ data: payload[:firstChunk], err: io.ErrUnexpectedEOF, }, }, nil, nil }) if err == nil { t.Fatalf("上游截断时应当失败") } // 第二次调用:直接请求 bytes=3000-,续完。 var seenRange string path, err := svc.DownloadRemoteAudio(t.Context(), "book-2", mediaURL, 1, func(rangeHeader string) (*http.Response, []byte, error) { seenRange = rangeHeader return rangeResponse( fmt.Sprintf("bytes %d-%d/%d", firstChunk, len(payload)-1, len(payload)), payload[firstChunk:], ), nil, nil }) if err != nil { t.Fatalf("跨请求续传应当成功: %v", err) } if seenRange != fmt.Sprintf("bytes=%d-", firstChunk) { t.Fatalf("应当从上次的断点续传,实际 Range=%q", seenRange) } got, _ := os.ReadFile(path) if !bytes.Equal(got, payload) { t.Fatalf("跨请求续传后的内容不完整: %d 字节", len(got)) } } // TestDownloadRemoteAudioRestartsWhenRangeIgnored 上游忽略 Range 仍回 200 时, // 必须丢掉分片从头写,不能把两段字节拼在一起。 func TestDownloadRemoteAudioRestartsWhenRangeIgnored(t *testing.T) { svc := newAudioDownloadTestService(t) const mediaURL = "https://cdn.example.com/long/c.m4a" payload := bytes.Repeat([]byte("xyz9"), 500) var ranges []string attempt := 0 path, err := svc.DownloadRemoteAudio(t.Context(), "book-3", mediaURL, 3, func(rangeHeader string) (*http.Response, []byte, error) { ranges = append(ranges, rangeHeader) attempt++ if attempt == 1 { return &http.Response{ StatusCode: http.StatusOK, ContentLength: int64(len(payload)), Header: http.Header{"Content-Type": {"audio/mp4"}}, Body: &errAfterReader{data: payload[:700], err: io.ErrUnexpectedEOF}, }, nil, nil } // 无视 Range,整段回 200。 return okResponse(payload), nil, nil }) if err != nil { t.Fatalf("应当成功: %v", err) } if ranges[1] == "" { t.Fatalf("第二轮应当带 Range") } got, _ := os.ReadFile(path) if !bytes.Equal(got, payload) { t.Fatalf("忽略 Range 后必须从头重写,实际 %d 字节(期望 %d)", len(got), len(payload)) } } // TestDownloadRemoteAudioStopsOnNoRetry 上游成批拒绝时(NoRetryError), // 下载器要立刻收手,不能把同一张被拒的票再打几遍。 func TestDownloadRemoteAudioStopsOnNoRetry(t *testing.T) { svc := newAudioDownloadTestService(t) denied := errors.New("音源暂时被上游拒绝(HTTP 401)") calls := 0 _, err := svc.DownloadRemoteAudio(t.Context(), "book-4", "https://cdn.example.com/long/d.m4a", 4, func(rangeHeader string) (*http.Response, []byte, error) { calls++ return nil, nil, &NoRetryError{Err: denied} }) if err == nil || !strings.Contains(err.Error(), "401") { t.Fatalf("应原样返回上游拒绝原因,实际 %v", err) } if calls != 1 { t.Fatalf("不可重试的失败只应请求 1 次,实际 %d 次", calls) } } // ─── 首播直通(边下边发) ──────────────────────────────────────────────── // TestDownloadRemoteAudioStreamingForwardsWhileDownloading 直通模式:字节必须 // 在下载过程中就到达 sink,而不是等整段下完才一次性写出去(首播 TTFB)。 func TestDownloadRemoteAudioStreamingForwardsWhileDownloading(t *testing.T) { svc := newAudioDownloadTestService(t) const mediaURL = "https://cdn.example.com/long/stream.m4a? vkey=1" payload := bytes.Repeat([]byte("stream!"), 1000) var sink bytes.Buffer var startedTotal int64 // 用「读到一半时 sink 里已经有数据」来证明确实在边下边发。 var midLength int path, err := svc.DownloadRemoteAudioStreaming(t.Context(), "book-s", mediaURL, 1, func(rangeHeader string) (*http.Response, []byte, error) { if rangeHeader != "" { t.Fatalf("首播直通不应带 Range,实际 %q", rangeHeader) } return okResponse(payload), nil, nil }, func(total int64, contentType string) { startedTotal = total // 头部回调必须早于任何字节下发。 if sink.Len() != 0 { t.Errorf("写响应头时不应已有字节写出") } }, writerFunc(func(p []byte) (int, error) { if midLength == 0 && sink.Len() == 0 && len(p) > 0 { midLength = sink.Len() } return sink.Write(p) }), ) if err != nil { t.Fatalf("直通下载失败: %v", err) } if startedTotal != int64(len(payload)) { t.Fatalf("头部回调应拿到上游总长 %d,实际 %d", len(payload), startedTotal) } if !bytes.Equal(sink.Bytes(), payload) { t.Fatalf("下发的字节不完整: %d 字节,期望 %d", sink.Len(), len(payload)) } // 下载完成后缓存文件必须完整可命中。 got, readErr := os.ReadFile(path) if readErr != nil { t.Fatalf("读缓存文件失败: %v", readErr) } if !bytes.Equal(got, payload) { t.Fatalf("落盘内容不完整: %d 字节", len(got)) } if !svc.HasCachedRemoteAudio("book-s", mediaURL) { t.Fatal("直通下完后应当命中缓存") } } // TestDownloadRemoteAudioStreamingStopsWhenClientGoesAway 客户端中途断开时 // 停止下载,但保留分片供下次续传(不能把已下的字节白扔掉)。 func TestDownloadRemoteAudioStreamingStopsWhenClientGoesAway(t *testing.T) { svc := newAudioDownloadTestService(t) const mediaURL = "https://cdn.example.com/long/gone.m4a" payload := bytes.Repeat([]byte("abcdefgh"), 2000) clientGone := errors.New("client closed connection") var sink bytes.Buffer _, err := svc.DownloadRemoteAudioStreaming(t.Context(), "book-g", mediaURL, 1, func(rangeHeader string) (*http.Response, []byte, error) { return okResponse(payload), nil, nil }, nil, writerFunc(func(p []byte) (int, error) { // 收下前 1000 字节后模拟播放器断开。 if sink.Len() >= 1000 { return 0, clientGone } room := 1000 - sink.Len() if len(p) <= room { return sink.Write(p) } n, _ := sink.Write(p[:room]) return n, clientGone }), ) if err == nil { t.Fatal("客户端断开时应当停止下载") } var sinkErr *audioSinkError if !errors.As(err, &sinkErr) { t.Fatalf("应标记为写客户端失败(便于上层区分),实际 %v", err) } // 分片要留着:这是续传的原料。 path := svc.RemoteAudioCachePath("book-g", mediaURL) if path == "" { t.Fatal("缓存路径不应为空") } if info, statErr := os.Stat(path + audioPartialSuffix); statErr != nil || info.Size() == 0 { t.Fatalf("断流后应保留 .part 分片,实际 %v", statErr) } if svc.HasCachedRemoteAudio("book-g", mediaURL) { t.Fatal("中断的下载不能留下完整缓存(否则会下发截断音频)") } } // TestDownloadRemoteAudioStreamingAbortsOnRestart 直通模式下上游忽略 Range 回 // 200(需要从 0 重来)时必须中止,而不是把重复字节拼进客户端已收到的流里。 func TestDownloadRemoteAudioStreamingAbortsOnRestart(t *testing.T) { svc := newAudioDownloadTestService(t) const mediaURL = "https://cdn.example.com/long/restart.m4a" payload := bytes.Repeat([]byte("zz"), 100) // 先留一个分片,让第二轮带 Range。 partPath := svc.RemoteAudioCachePath("book-r", mediaURL) + audioPartialSuffix if err := os.MkdirAll(filepath.Dir(partPath), 0o750); err != nil { t.Fatal(err) } if err := os.WriteFile(partPath, payload[:50], 0o640); err != nil { t.Fatal(err) } var sink bytes.Buffer _, err := svc.DownloadRemoteAudioStreaming(t.Context(), "book-r", mediaURL, 1, func(rangeHeader string) (*http.Response, []byte, error) { // 无视 Range,整段回 200 → 直通无法重来。 return okResponse(payload), nil, nil }, nil, &sink, ) if !errors.Is(err, errAudioStreamCannotRestart) { t.Fatalf("应返回不可重来错误,实际 %v", err) } if sink.Len() != 0 { t.Fatalf("中止前不应向客户端写出字节,实际 %d", sink.Len()) } } // writerFunc 把函数适配成 io.Writer。 type writerFunc func(p []byte) (int, error) func (f writerFunc) Write(p []byte) (int, error) { return f(p) }