package reader
import (
"archive/zip"
"bytes"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
)
// 本文件:本地书籍(TXT / EPUB)导入与阅读的回归测试。
// newLocalBookService 复用登录测试的建库逻辑,另给一个临时 DataDir,
// 避免本地书籍文件写到仓库目录里。
func newLocalBookService(t *testing.T) *ReaderService {
t.Helper()
svc, _ := newLoginTestService(t)
svc.cfg.App.DataDir = t.TempDir()
return svc
}
const sampleTXT = `书名:测试小说
作者:某某
第1章 开端
第一章的正文内容。
这里还有一行。
第2章 发展
第二章的正文内容。
第3章 结局
第三章的正文内容。
`
func TestSplitTXTChapters(t *testing.T) {
chapters := splitTXTChapters(sampleTXT)
if len(chapters) != 3 {
t.Fatalf("章节数 = %d,期望 3:%+v", len(chapters), chapters)
}
wantTitles := []string{"第1章 开端", "第2章 发展", "第3章 结局"}
for i, c := range chapters {
if c.Title != wantTitles[i] {
t.Errorf("第 %d 章标题 = %q,期望 %q", i+1, c.Title, wantTitles[i])
}
}
// 第一章要从文件头开始,把书名/作者并进去
if chapters[0].Start != 0 {
t.Errorf("第一章起点 = %d,期望 0", chapters[0].Start)
}
if chapters[len(chapters)-1].End != len(sampleTXT) {
t.Errorf("末章终点 = %d,期望 %d", chapters[len(chapters)-1].End, len(sampleTXT))
}
// 区间必须首尾相接且不重叠
for i := 1; i < len(chapters); i++ {
if chapters[i].Start != chapters[i-1].End {
t.Errorf("第 %d/%d 章区间不连续:%d vs %d", i, i+1, chapters[i-1].End, chapters[i].Start)
}
}
// 每章正文要能切出来且包含该章内容
body := sampleTXT[chapters[1].Start:chapters[1].End]
if !strings.Contains(body, "第二章的正文内容") {
t.Errorf("第二章区间内容不对: %q", body)
}
if strings.Contains(body, "第三章") {
t.Errorf("第二章区间串到了第三章: %q", body)
}
}
func TestSplitTXTChaptersFallback(t *testing.T) {
chapters := splitTXTChapters("没有任何章节标记的一段文字。\n第二行。")
if len(chapters) != 1 || chapters[0].Title != "全文" {
t.Fatalf("无章标记应整本当一章: %+v", chapters)
}
if chapters[0].Start != 0 || chapters[0].End != len("没有任何章节标记的一段文字。\n第二行。") {
t.Fatalf("整本区间不对: %+v", chapters[0])
}
}
func TestDecodeTextFileGBK(t *testing.T) {
// "第1章 开端" 的 GBK 编码
gbk := []byte{0xB5, 0xDA, '1', 0xD5, 0xC2, ' ', 0xBF, 0xAA, 0xB6, 0xCB}
got, charset := decodeTextFile(gbk)
if charset != "gbk" {
t.Fatalf("字符集 = %q,期望 gbk", charset)
}
if got != "第1章 开端" {
t.Fatalf("解码结果 = %q", got)
}
}
func TestImportLocalBookAndRead(t *testing.T) {
svc := newLocalBookService(t)
ctx := t.Context()
book, err := svc.ImportLocalBook(ctx, "u1", "测试小说.txt", []byte(sampleTXT))
if err != nil {
t.Fatalf("导入失败: %v", err)
}
if !book.IsLocal || book.LocalPath == "" {
t.Fatalf("导入后应标记为本地书籍: %+v", book)
}
if book.Name != "测试小说" {
t.Errorf("书名 = %q", book.Name)
}
if book.TotalChapterNum != 3 {
t.Errorf("总章数 = %d,期望 3", book.TotalChapterNum)
}
chapters, err := svc.ListChapters(ctx, book.ID)
if err != nil || len(chapters) != 3 {
t.Fatalf("目录 = %d 章, err=%v", len(chapters), err)
}
got, err := svc.LocalChapterContent(ctx, "u1", book.ID, 1)
if err != nil {
t.Fatalf("读第二章失败: %v", err)
}
if got.Type != "text" {
t.Errorf("type = %q", got.Type)
}
if !strings.Contains(got.Content, "第二章的正文内容") {
t.Errorf("第二章正文 = %q", got.Content)
}
if strings.Contains(got.Content, "第三章") || strings.Contains(got.Content, "第一章的正文") {
t.Errorf("第二章正文串章了: %q", got.Content)
}
// 书名等前言应落在第一章
first, err := svc.LocalChapterContent(ctx, "u1", book.ID, 0)
if err != nil {
t.Fatalf("读第一章失败: %v", err)
}
if !strings.Contains(first.Content, "第一章的正文内容") {
t.Errorf("第一章正文 = %q", first.Content)
}
// 越界与越权
if _, err := svc.LocalChapterContent(ctx, "u1", book.ID, 9); err == nil {
t.Error("越界章节应报错")
}
if _, err := svc.LocalChapterContent(ctx, "other", book.ID, 0); err == nil {
t.Error("他人书架应拒绝")
}
// 书架列表要带 is_local
books, err := svc.ListBooks(ctx, "u1")
if err != nil || len(books) != 1 || !books[0].IsLocal {
t.Fatalf("书架列表未标记本地书籍: %+v, err=%v", books, err)
}
// 重复导入同名文件:覆盖更新且保留进度
if err := svc.SaveProgress(ctx, "u1", book.ID, 1, 0, "第2章 发展"); err != nil {
t.Fatal(err)
}
again, err := svc.ImportLocalBook(ctx, "u1", "测试小说.txt", []byte(sampleTXT))
if err != nil {
t.Fatalf("重复导入失败: %v", err)
}
if again.ID != book.ID {
t.Errorf("同名导入应覆盖同一本书,得到新 ID %s", again.ID)
}
if again.DurChapterIndex != 1 {
t.Errorf("章节数不变时应保留进度,得到 %d", again.DurChapterIndex)
}
// 移出书架要删掉落盘文件
path, err := svc.localFilePath(again)
if err != nil {
t.Fatal(err)
}
if err := svc.RemoveBook(ctx, "u1", again.ID); err != nil {
t.Fatalf("移出书架失败: %v", err)
}
if _, err := os.ReadFile(path); err == nil {
t.Error("移出书架后本地文件应被删除")
}
}
// epubFixture 造一个最小可用的 EPUB(NCX 目录 + 两个 XHTML 章节)。
func epubFixture(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
add := func(name, body string) {
w, err := zw.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte(body)); err != nil {
t.Fatal(err)
}
}
add("mimetype", "application/epub+zip")
add("META-INF/container.xml", `
第一段 & 实体。
第二段。
`) add("OEBPS/ch2.xhtml", `第二章正文。
又一段。
`) if err := zw.Close(); err != nil { t.Fatal(err) } return buf.Bytes() } func TestImportEPUBAndRead(t *testing.T) { svc := newLocalBookService(t) ctx := t.Context() book, err := svc.ImportLocalBook(ctx, "u1", "测试电子书.epub", epubFixture(t)) if err != nil { t.Fatalf("导入 EPUB 失败: %v", err) } if book.TotalChapterNum != 2 { t.Fatalf("章数 = %d,期望 2", book.TotalChapterNum) } chapters, err := svc.ListChapters(ctx, book.ID) if err != nil || len(chapters) != 2 { t.Fatalf("目录 = %+v, err=%v", chapters, err) } if chapters[0].Title != "第一章 起风" || chapters[1].Title != "第二章 落雨" { t.Fatalf("章节标题应取自 NCX: %q / %q", chapters[0].Title, chapters[1].Title) } // 标题不要留在正文里 first, err := svc.LocalChapterContent(ctx, "u1", book.ID, 0) if err != nil { t.Fatalf("读第一章失败: %v", err) } if !strings.Contains(first.Content, "第一段 & 实体。") { t.Errorf("实体未解码: %q", first.Content) } if !strings.Contains(first.Content, "第一段 & 实体。\n第二段。") { t.Errorf("段落换行不对: %q", first.Content) } if strings.Contains(first.Content, "var x=1") { t.Errorf("script 应被剔除: %q", first.Content) } second, err := svc.LocalChapterContent(ctx, "u1", book.ID, 1) if err != nil || !strings.Contains(second.Content, "第二章正文。") { t.Fatalf("读第二章失败: %v / %q", err, second.Content) } } // TestImportEPUBWithImage 端到端:导入带图片的 EPUB,章节正文里图片应变成签名 // 地址,且该地址能取回原始图片字节。 func TestImportEPUBWithImage(t *testing.T) { svc := newLocalBookService(t) ctx := t.Context() var buf bytes.Buffer zw := zip.NewWriter(&buf) add := func(name, body string) { w, err := zw.Create(name) if err != nil { t.Fatal(err) } if _, err := w.Write([]byte(body)); err != nil { t.Fatal(err) } } imgBytes := []byte("\xff\xd8\xff\xe0fakejpeg") add("mimetype", "application/epub+zip") add("META-INF/container.xml", `图片前。

图片后。
`) w, err := zw.Create("OEBPS/images/pic.jpg") if err != nil { t.Fatal(err) } if _, err := w.Write(imgBytes); err != nil { t.Fatal(err) } if err := zw.Close(); err != nil { t.Fatal(err) } book, err := svc.ImportLocalBook(ctx, "u1", "带图.epub", buf.Bytes()) if err != nil { t.Fatalf("导入失败: %v", err) } chapters, err := svc.ListChapters(ctx, book.ID) if err != nil || len(chapters) != 1 || chapters[0].Title != "插图章" { t.Fatalf("目录 = %+v, err=%v", chapters, err) } content, err := svc.LocalChapterContent(ctx, "u1", book.ID, 0) if err != nil { t.Fatalf("读正文失败: %v", err) } if !strings.Contains(content.Content, "图片前。") || !strings.Contains(content.Content, "图片后。") { t.Fatalf("正文缺内容: %q", content.Content) } var asset string for _, line := range strings.Split(content.Content, "\n") { if strings.HasPrefix(line, imgMarkerPrefix) { asset = strings.TrimPrefix(line, imgMarkerPrefix) } } if !strings.HasPrefix(asset, "/api/reader/local/asset?") { t.Fatalf("图片未换成签名地址: %q(正文 %q)", asset, content.Content) } u, err := url.Parse(asset) if err != nil { t.Fatal(err) } entry, err := svc.VerifyLocalAssetURL(u.Query().Get("b"), u.Query().Get("p"), u.Query().Get("s")) if err != nil { t.Fatalf("资源签名校验失败: %v", err) } data, ct, err := svc.ReadLocalAsset(ctx, book.ID, entry) if err != nil { t.Fatalf("读图片失败: %v", err) } if !bytes.Equal(data, imgBytes) { t.Fatalf("图片字节不一致: %q", data) } if ct != "image/jpeg" { t.Fatalf("Content-Type = %q", ct) } } func TestHTMLToText(t *testing.T) { got := htmlToText("乙
丙
正文一。
正文二
尾巴。

内容。
`) w, err := zw.Create("OEBPS/images/cover.jpg") if err != nil { t.Fatal(err) } if _, err := w.Write(coverBytes); err != nil { t.Fatal(err) } if err := zw.Close(); err != nil { t.Fatal(err) } return buf.Bytes(), coverBytes } // TestImportEPUBCover 封面要能从 OPF 声明(EPUB3 / EPUB2)与文件名兜底三条路 // 解析出来,并转成可访问的签名资源地址。 func TestImportEPUBCover(t *testing.T) { ctx := t.Context() cases := []struct { name string epub3, epub2 bool }{ {"EPUB3 cover-image", true, false}, {"EPUB2 meta cover", false, true}, {"文件名兜底", false, false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { svc := newLocalBookService(t) raw, coverBytes := epubWithCoverFixture(t, tc.epub3, tc.epub2) book, err := svc.ImportLocalBook(ctx, "u1", "有封面.epub", raw) if err != nil { t.Fatalf("导入失败: %v", err) } if !strings.HasPrefix(book.CoverURL, "/api/reader/local/asset?") { t.Fatalf("封面未生成签名地址: %q", book.CoverURL) } u, err := url.Parse(book.CoverURL) if err != nil { t.Fatal(err) } entry, err := svc.VerifyLocalAssetURL(u.Query().Get("b"), u.Query().Get("p"), u.Query().Get("s")) if err != nil { t.Fatalf("封面签名校验失败: %v", err) } data, ct, err := svc.ReadLocalAsset(ctx, book.ID, entry) if err != nil { t.Fatalf("读封面失败: %v", err) } if !bytes.Equal(data, coverBytes) { t.Fatalf("封面字节不一致: %q", data) } if ct != "image/jpeg" { t.Fatalf("封面 MIME = %q,期望 image/jpeg", ct) } // 落库后书架列表/详情也要带上封面,重开服务不丢 books, err := svc.ListBooks(ctx, "u1") if err != nil || len(books) != 1 || books[0].CoverURL != book.CoverURL { t.Fatalf("书架封面 = %+v, err=%v", books, err) } }) } } // TestBackfillLocalCoverOnListBooks 早期导入的本地 EPUB 没存封面, // 加载书架时应该自动补上并落库,不必让用户重新导入。 func TestBackfillLocalCoverOnListBooks(t *testing.T) { svc := newLocalBookService(t) ctx := t.Context() raw, coverBytes := epubWithCoverFixture(t, true, false) book, err := svc.ImportLocalBook(ctx, "u1", "老书.epub", raw) if err != nil { t.Fatalf("导入失败: %v", err) } // 模拟修复前导入的旧数据:库里没有封面 book.CoverURL = "" if err := svc.repo.UpdateBook(ctx, book); err != nil { t.Fatal(err) } books, err := svc.ListBooks(ctx, "u1") if err != nil || len(books) != 1 { t.Fatalf("书架 = %+v, err=%v", books, err) } restored := books[0].CoverURL if !strings.HasPrefix(restored, "/api/reader/local/asset?") { t.Fatalf("书架加载未回填封面: %q", restored) } // 已经写回数据库,下次不用再解析 stored, err := svc.repo.GetBook(ctx, book.ID) if err != nil || stored.CoverURL != restored { t.Fatalf("封面未落库: %+v, err=%v", stored, err) } // 回填出来的地址必须真能取到封面图 u, err := url.Parse(restored) if err != nil { t.Fatal(err) } entry, err := svc.VerifyLocalAssetURL(u.Query().Get("b"), u.Query().Get("p"), u.Query().Get("s")) if err != nil { t.Fatalf("回填封面签名校验失败: %v", err) } data, _, err := svc.ReadLocalAsset(ctx, book.ID, entry) if err != nil || !bytes.Equal(data, coverBytes) { t.Fatalf("回填封面取回失败: %v / %q", err, data) } } // TestBackfillLocalCoverSkipsTXTAndCoveredBooks TXT 没有内嵌图片,已有封面的书 // 也不该被重复处理。 func TestBackfillLocalCoverSkipsTXTAndCoveredBooks(t *testing.T) { svc := newLocalBookService(t) ctx := t.Context() txt, err := svc.ImportLocalBook(ctx, "u1", "小说.txt", []byte(sampleTXT)) if err != nil { t.Fatalf("导入 TXT 失败: %v", err) } if svc.BackfillLocalCover(ctx, txt) { t.Error("TXT 不该被回填封面") } raw, _ := epubWithCoverFixture(t, true, false) epub, err := svc.ImportLocalBook(ctx, "u1", "已带封面.epub", raw) if err != nil { t.Fatalf("导入 EPUB 失败: %v", err) } if svc.BackfillLocalCover(ctx, epub) { t.Error("已有封面的书不该被回填") } } // TestImportLocalBookFromPath 服务器选书:原地引用不复制,章节可读, // 移出书架不删除源文件。 func TestImportLocalBookFromPath(t *testing.T) { svc := newLocalBookService(t) ctx := t.Context() srcDir := t.TempDir() srcPath := filepath.Join(srcDir, "服务器上的书.txt") if err := os.WriteFile(srcPath, []byte(sampleTXT), 0o640); err != nil { t.Fatal(err) } book, err := svc.ImportLocalBookFromPath(ctx, "u1", srcPath) if err != nil { t.Fatalf("从服务器路径导入失败: %v", err) } if !book.LocalExternal { t.Fatal("应标记为原地引用") } if book.LocalPath != srcPath { t.Fatalf("LocalPath = %q,期望源文件路径 %q", book.LocalPath, srcPath) } if book.TotalChapterNum != 3 { t.Fatalf("章节数 = %d,期望 3", book.TotalChapterNum) } if book.Name != "服务器上的书" { t.Fatalf("书名 = %q", book.Name) } content, err := svc.LocalChapterContent(ctx, "u1", book.ID, 0) if err != nil { t.Fatalf("读正文失败: %v", err) } if !strings.Contains(content.Content, "第一章的正文内容。") { t.Fatalf("正文不对: %q", content.Content) } // 不应把文件复制进 data/reader/local localDir, err := svc.localBooksDir() if err != nil { t.Fatal(err) } entries, err := os.ReadDir(localDir) if err != nil { t.Fatal(err) } if len(entries) != 0 { t.Fatalf("原地引用不该产生托管副本,目录里有 %d 个文件", len(entries)) } // 移出书架只解除引用,源文件必须还在 if err := svc.RemoveBook(ctx, "u1", book.ID); err != nil { t.Fatalf("移出书架失败: %v", err) } if _, err := os.Stat(srcPath); err != nil { t.Fatalf("原地引用的源文件被删除了: %v", err) } } // TestImportLocalBookFromPathRejectsUnsupported 只接受 TXT / EPUB,目录与超限文件要报错。 func TestImportLocalBookFromPathRejectsUnsupported(t *testing.T) { svc := newLocalBookService(t) ctx := t.Context() dir := t.TempDir() pdf := filepath.Join(dir, "book.pdf") if err := os.WriteFile(pdf, []byte("%PDF-1.4"), 0o640); err != nil { t.Fatal(err) } if _, err := svc.ImportLocalBookFromPath(ctx, "u1", pdf); err == nil { t.Fatal("PDF 不该被接受") } if _, err := svc.ImportLocalBookFromPath(ctx, "u1", dir); err == nil { t.Fatal("目录不该被接受") } if _, err := svc.ImportLocalBookFromPath(ctx, "u1", filepath.Join(dir, "nope.txt")); err == nil { t.Fatal("不存在的文件应报错") } } // TestImportLocalAudioDir 目录导入有声书:音频与 .strm 成为章节,按相对路径排序, // 其它文件忽略;.strm 指向远端走媒体代理,指向本地文件走本地音频端点。 func TestImportLocalAudioDir(t *testing.T) { svc := newLocalBookService(t) ctx := t.Context() base := t.TempDir() dir := filepath.Join(base, "有声书") shared := filepath.Join(base, "共享") for _, d := range []string{filepath.Join(dir, "00-cd1"), shared} { if err := os.MkdirAll(d, 0o750); err != nil { t.Fatal(err) } } write := func(path, body string) { if err := os.WriteFile(path, []byte(body), 0o640); err != nil { t.Fatal(err) } } // 相对路径排序:00-cd1/ 子目录在前,其后是同级文件 write(filepath.Join(dir, "00-cd1", "01-正文.mp3"), "mp3-bytes-2") write(filepath.Join(dir, "01-开场.mp3"), "mp3-bytes") write(filepath.Join(dir, "02-远端.strm"), "https://cdn.example.com/a.mp3\n") localTarget := filepath.Join(shared, "尾巴.flac") write(localTarget, "flac-bytes") write(filepath.Join(dir, "03-本地.strm"), localTarget+"\n") // 非音频文件应被忽略 write(filepath.Join(dir, "cover.jpg"), "jpg") write(filepath.Join(dir, "readme.txt"), "txt") book, err := svc.ImportLocalAudioDir(ctx, "u1", dir) if err != nil { t.Fatalf("导入有声书目录失败: %v", err) } if book.Type != 1 { t.Fatalf("Type = %d,期望 1(音频)", book.Type) } if !book.LocalExternal || book.LocalPath != dir { t.Fatalf("应原地引用目录,得到 LocalPath=%q external=%v", book.LocalPath, book.LocalExternal) } if book.Name != "有声书" { t.Fatalf("书名应取目录名,得到 %q", book.Name) } if book.TotalChapterNum != 4 { t.Fatalf("章节数 = %d,期望 4(忽略 cover.jpg / readme.txt)", book.TotalChapterNum) } chapters, err := svc.ListChapters(ctx, book.ID) if err != nil { t.Fatal(err) } wantTitles := []string{"01-正文", "01-开场", "02-远端", "03-本地"} for i, want := range wantTitles { if chapters[i].Title != want { t.Fatalf("第 %d 章标题 = %q,期望 %q(完整目录 %+v)", i, chapters[i].Title, want, chapters) } } // 本地音频文件 → 本地音频流端点 first, err := svc.LocalChapterContent(ctx, "u1", book.ID, 1) if err != nil { t.Fatalf("读第 2 章失败: %v", err) } if first.Type != "audio" || len(first.Tracks) != 1 { t.Fatalf("第 2 章应为单轨音频: %+v", first) } if !strings.HasPrefix(first.Tracks[0], "/api/reader/local/audio?") { t.Fatalf("本地音频应走本地流端点: %q", first.Tracks[0]) } // .strm 指向远端 → 媒体代理 remote, err := svc.LocalChapterContent(ctx, "u1", book.ID, 2) if err != nil { t.Fatalf("读第 3 章失败: %v", err) } if !strings.HasPrefix(remote.Tracks[0], "/api/reader/media?") { t.Fatalf("远端 .strm 应走媒体代理: %q", remote.Tracks[0]) } // .strm 指向本地文件 → 本地音频流端点 local, err := svc.LocalChapterContent(ctx, "u1", book.ID, 3) if err != nil { t.Fatalf("读第 4 章失败: %v", err) } if !strings.HasPrefix(local.Tracks[0], "/api/reader/local/audio?") { t.Fatalf("本地 .strm 应走本地流端点: %q", local.Tracks[0]) } u, err := url.Parse(local.Tracks[0]) if err != nil { t.Fatal(err) } resolved, err := svc.VerifyLocalAudioURL(u.Query().Get("b"), u.Query().Get("p"), u.Query().Get("s")) if err != nil { t.Fatalf("音频签名校验失败: %v", err) } if resolved != localTarget { t.Fatalf("解析出的音频路径 = %q,期望 %q", resolved, localTarget) } } // TestImportLocalAudioDirNoAudio 目录里没有音频时应给出明确错误。 func TestImportLocalAudioDirNoAudio(t *testing.T) { svc := newLocalBookService(t) dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("x"), 0o640); err != nil { t.Fatal(err) } if _, err := svc.ImportLocalAudioDir(t.Context(), "u1", dir); err == nil { t.Fatal("没有音频文件时应报错") } } // TestLocalAudioURLSignature 音频流地址的签名可校验、改一处即失效。 func TestLocalAudioURLSignature(t *testing.T) { svc := newLocalBookService(t) path := filepath.Join("media", "有声书", "01.mp3") raw := svc.LocalAudioURL("book-1", path) u, err := url.Parse(raw) if err != nil { t.Fatal(err) } if !strings.HasPrefix(raw, "/api/reader/local/audio?b=book-1&p=") { t.Fatalf("音频地址格式不对: %q", raw) } got, err := svc.VerifyLocalAudioURL("book-1", u.Query().Get("p"), u.Query().Get("s")) if err != nil || got != path { t.Fatalf("签名校验失败: %v / %q", err, got) } if _, err := svc.VerifyLocalAudioURL("book-1", u.Query().Get("p"), "deadbeef"); err == nil { t.Fatal("错误签名应校验失败") } if _, err := svc.VerifyLocalAudioURL("book-2", u.Query().Get("p"), u.Query().Get("s")); err == nil { t.Fatal("换一本书后签名应失效") } } // TestIsRemoteMediaURL 区分远端地址与本地路径。 func TestIsRemoteMediaURL(t *testing.T) { remote := []string{ "https://cdn.example.com/a.mp3", "http://x/y.flac", "webdav://host/a.mp3", "/api/strm/play/local/abc", } for _, raw := range remote { if !isRemoteMediaURL(raw) { t.Errorf("%q 应判为远端", raw) } } local := []string{ "本地音频.flac", `D:\media\有声书\01.mp3`, "sub/02.mp3", } for _, raw := range local { if isRemoteMediaURL(raw) { t.Errorf("%q 应判为本地路径", raw) } } } // TestImportEPUBWithoutCover 没有封面图时不应硬凑,CoverURL 保持为空。 func TestImportEPUBWithoutCover(t *testing.T) { svc := newLocalBookService(t) book, err := svc.ImportLocalBook(t.Context(), "u1", "无封面.epub", epubFixture(t)) if err != nil { t.Fatalf("导入失败: %v", err) } if book.CoverURL != "" { t.Fatalf("无封面书籍不该有 cover_url: %q", book.CoverURL) } } // TestImportEPUBCoverFallbackOnBrokenDeclaration 声明的封面 id 指不到条目时, // 应退回“文件名像封面”的图片,而不是直接没有封面。 func TestImportEPUBCoverFallbackOnBrokenDeclaration(t *testing.T) { svc := newLocalBookService(t) coverBytes := []byte("\xff\xd8\xff\xe0fallbackjpeg") var buf bytes.Buffer zw := zip.NewWriter(&buf) add := func(name, body string) { w, err := zw.Create(name) if err != nil { t.Fatal(err) } if _, err := w.Write([]byte(body)); err != nil { t.Fatal(err) } } add("mimetype", "application/epub+zip") add("META-INF/container.xml", `内容。
`) // 声明失效,只能靠文件名(含“封面”)兜底 add("OEBPS/images/封面.png", string(coverBytes)) if err := zw.Close(); err != nil { t.Fatal(err) } book, err := svc.ImportLocalBook(t.Context(), "u1", "兜底.epub", buf.Bytes()) if err != nil { t.Fatalf("导入失败: %v", err) } if !strings.HasPrefix(book.CoverURL, "/api/reader/local/asset?") { t.Fatalf("应兜底找到封面: %q", book.CoverURL) } u, err := url.Parse(book.CoverURL) if err != nil { t.Fatal(err) } entry, err := svc.VerifyLocalAssetURL(u.Query().Get("b"), u.Query().Get("p"), u.Query().Get("s")) if err != nil { t.Fatalf("封面签名校验失败: %v", err) } if filepath.Base(entry) != "封面.png" { t.Fatalf("兜底应命中封面.png,实际 %q", entry) } data, _, err := svc.ReadLocalAsset(t.Context(), book.ID, entry) if err != nil || !bytes.Equal(data, coverBytes) { t.Fatalf("读兜底封面失败: %v / %q", err, data) } }