package reader import ( "context" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "github.com/truewhile/MeBox/internal/model" ) // 正文持久缓存(reader_content_cache.go)的端到端测试: // 抓取 → 写缓存 → 再次读取命中且不再访问书源 → 批量接口统计正确。 // cacheTestServer 每章返回固定正文并统计各章请求次数。 type cacheTestServer struct { *httptest.Server mu sync.Mutex hits map[string]int body func(chapter int) string failOn map[int]bool } func newCacheTestServer() *cacheTestServer { s := &cacheTestServer{hits: map[string]int{}, failOn: map[int]bool{}} s.body = func(chapter int) string { return fmt.Sprintf("第%d章的正文内容。", chapter) } s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") switch { case strings.HasPrefix(r.URL.Path, "/book/1/toc"): _, _ = w.Write([]byte(cacheTestTocHTML)) case strings.HasPrefix(r.URL.Path, "/book/1/c"): chapter := 0 _, _ = fmt.Sscanf(strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/book/1/c"), ".html"), "%d", &chapter) s.mu.Lock() s.hits[fmt.Sprintf("c%d", chapter)]++ fail := s.failOn[chapter] s.mu.Unlock() if fail { http.Error(w, "boom", http.StatusInternalServerError) return } _, _ = w.Write([]byte(fmt.Sprintf(`
%s
`, s.body(chapter)))) case strings.HasPrefix(r.URL.Path, "/book/"): _, _ = w.Write([]byte(cacheTestBookInfoHTML)) default: http.NotFound(w, r) } })) return s } func (s *cacheTestServer) count(chapter int) int { s.mu.Lock() defer s.mu.Unlock() return s.hits[fmt.Sprintf("c%d", chapter)] } func cacheTestSourceJSON(server string) string { return fmt.Sprintf(`{ "bookSourceUrl": %q, "bookSourceName": "缓存测试源", "bookSourceType": 0, "searchUrl": "%s/search/{{key}}/1.html", "ruleSearch": {"bookList": "class.item", "name": "tag.h3@text", "bookUrl": "tag.a@href"}, "ruleBookInfo": {"name": "class.info@tag.h1@text", "tocUrl": "class.info@class.toc@href"}, "ruleToc": {"chapterList": "class.chapters@tag.li", "chapterName": "tag.a@text", "chapterUrl": "tag.a@href"}, "ruleContent": {"content": "id.content@textNodes"} }`, server, server) } const cacheTestTocHTML = ` ` const cacheTestBookInfoHTML = `

缓存之书

目录
` // prepareCacheTestBook 建好「书源 + 书架书籍 + 目录缓存」并打开独立缓存目录。 func prepareCacheTestBook(t *testing.T) (*ReaderService, *cacheTestServer, *model.ReaderBook) { t.Helper() srv := newCacheTestServer() t.Cleanup(srv.Close) svc, _ := newLoginTestService(t) svc.cfg.Cache.CacheDir = t.TempDir() svc.cfg.Cache.ReaderContentTTLHours = 168 srcID := importTestSource(t, svc, cacheTestSourceJSON(srv.URL), srv.URL) ctx := t.Context() book, err := svc.AddBook(ctx, "u1", SearchOrigin{ SourceID: srcID, Origin: srv.URL, OriginName: "缓存测试源", BookURL: srv.URL + "/book/1", }, "缓存之书", "作者", "") if err != nil { t.Fatalf("加入书架失败: %v", err) } chapters := make([]ChapterInput, 0, 3) for i := 1; i <= 3; i++ { chapters = append(chapters, ChapterInput{ Index: i - 1, Title: fmt.Sprintf("第 %d 章", i), URL: fmt.Sprintf("%s/book/1/c%d.html", srv.URL, i), }) } if err := svc.SaveChapters(ctx, book.ID, chapters); err != nil { t.Fatalf("保存目录失败: %v", err) } return svc, srv, book } // 第一次读取抓源并写缓存;第二次直接命中持久缓存,不再访问书源。 func TestContentCacheHitAndPersist(t *testing.T) { svc, srv, book := prepareCacheTestBook(t) ctx := t.Context() first, err := svc.GetContentForBook(ctx, "u1", book.ID, 0) if err != nil { t.Fatalf("首次读取失败: %v", err) } if !strings.Contains(first.Content, "第1章的正文内容。") { t.Fatalf("正文不符合预期: %q", first.Content) } if got := srv.count(1); got != 1 { t.Fatalf("首次读取应访问书源 1 次,实际 %d", got) } second, err := svc.GetContentForBook(ctx, "u1", book.ID, 0) if err != nil { t.Fatalf("二次读取失败: %v", err) } if second.Content != first.Content { t.Fatalf("缓存命中的正文不一致: %q vs %q", second.Content, first.Content) } if got := srv.count(1); got != 1 { t.Fatalf("缓存命中不应再访问书源,实际 %d 次", got) } // 缓存文件确实落盘 path := svc.contentFilePath(book.Origin, book.BookURL, contentChapterKey(book, model.ReaderChapter{ Index: 0, Title: "第 1 章", URL: srv.URL + "/book/1/c1.html", })) if path == "" { t.Fatal("缓存路径为空(cache_dir 未生效)") } if _, err := os.Stat(path); err != nil { t.Fatalf("缓存文件未落盘: %v", err) } } // 目录刷新后同一章(地址不变)仍应命中缓存,不因「更新目录」冷启动。 func TestContentCacheSurvivesTocRefresh(t *testing.T) { svc, srv, book := prepareCacheTestBook(t) ctx := t.Context() if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 1); err != nil { t.Fatalf("读取第 2 章失败: %v", err) } // 模拟「更新目录」:同一批章节重新落库(行 ID 会变,身份不变)。 chapters := make([]ChapterInput, 0, 3) for i := 1; i <= 3; i++ { chapters = append(chapters, ChapterInput{ Index: i - 1, Title: fmt.Sprintf("第 %d 章", i), URL: fmt.Sprintf("%s/book/1/c%d.html", srv.URL, i), }) } if err := svc.SaveChapters(ctx, book.ID, chapters); err != nil { t.Fatalf("重写目录失败: %v", err) } // 刷新后的 remap(refreshBookToc 内的同一调用)。 oldChapters := []model.ReaderChapter{ {Index: 0, Title: "第 1 章", URL: srv.URL + "/book/1/c1.html"}, {Index: 1, Title: "第 2 章", URL: srv.URL + "/book/1/c2.html"}, {Index: 2, Title: "第 3 章", URL: srv.URL + "/book/1/c3.html"}, } newChapters, _ := svc.repo.ListChapters(ctx, book.ID) svc.RemapContentCacheOnTocChange(ctx, book, oldChapters, newChapters) if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 1); err != nil { t.Fatalf("刷新后读取失败: %v", err) } if got := srv.count(2); got != 1 { t.Fatalf("目录刷新后应命中缓存(书源请求仍为 1 次),实际 %d 次", got) } } // 并发请求同一章只应打一次书源(读穿透单飞)。 func TestContentCacheSingleFlight(t *testing.T) { svc, srv, book := prepareCacheTestBook(t) ctx := t.Context() var wg sync.WaitGroup errs := make([]error, 6) for i := 0; i < 6; i++ { wg.Add(1) go func(i int) { defer wg.Done() _, errs[i] = svc.GetContentForBook(ctx, "u1", book.ID, 0) }(i) } wg.Wait() for i, err := range errs { if err != nil { t.Fatalf("并发读取 %d 失败: %v", i, err) } } if got := srv.count(1); got != 1 { t.Fatalf("同一章并发应单飞为 1 次书源请求,实际 %d 次", got) } } // 批量接口:单章失败不影响其余章节,统计 hit/miss/failed。 func TestContentBatchStatsAndFailureIsolation(t *testing.T) { svc, srv, book := prepareCacheTestBook(t) ctx := t.Context() // 预热第 1 章(进缓存),第 2 章按失败处理。 if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0); err != nil { t.Fatalf("预热失败: %v", err) } srv.mu.Lock() srv.failOn[2] = true srv.mu.Unlock() result, err := svc.GetContentBatch(ctx, "u1", book.ID, []int{0, 1, 2}, false) if err != nil { t.Fatalf("批量读取失败: %v", err) } if len(result.Items) != 3 { t.Fatalf("应返回 3 项,实际 %d", len(result.Items)) } if result.Stats.Hit != 1 { t.Fatalf("第 1 章应命中缓存,hit=%d", result.Stats.Hit) } if result.Stats.Failed != 1 { t.Fatalf("第 2 章应失败,failed=%d", result.Stats.Failed) } if result.Stats.Miss != 1 { t.Fatalf("第 3 章应为新抓,miss=%d", result.Stats.Miss) } if !strings.Contains(result.Items[2].Content, "第3章的正文内容。") { t.Fatalf("第 3 章内容异常: %+v", result.Items[2]) } if result.Items[1].Error == "" { t.Fatalf("第 2 章应带错误: %+v", result.Items[1]) } // 失败章不写缓存:去掉失败开关后重试应真正抓源。 srv.mu.Lock() srv.failOn[2] = false srv.mu.Unlock() if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 1); err != nil { t.Fatalf("恢复后读取第 2 章失败: %v", err) } if got := srv.count(2); got != 2 { t.Fatalf("失败章不应进缓存,恢复后应重抓(2 次),实际 %d", got) } // 越界/卷章节被忽略。 if got, err := svc.GetContentBatch(ctx, "u1", book.ID, []int{-1, 99}, false); err != nil || len(got.Items) != 0 { t.Fatalf("越界索引应被忽略: items=%d err=%v", len(got.Items), err) } } // 书源更新(RawJSON 变化)后旧缓存失效,重新抓取。 func TestContentCacheInvalidatedOnSourceUpdate(t *testing.T) { svc, srv, book := prepareCacheTestBook(t) ctx := t.Context() if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0); err != nil { t.Fatalf("首次读取失败: %v", err) } // 重新导入同一书源(RawJSON 变化)→ 指纹变化。 updated := strings.Replace(cacheTestSourceJSON(srv.URL), "缓存测试源", "缓存测试源v2", 1) if _, err := svc.ImportSources(ctx, updated); err != nil { t.Fatalf("更新书源失败: %v", err) } if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0); err != nil { t.Fatalf("更新后读取失败: %v", err) } if got := srv.count(1); got != 2 { t.Fatalf("书源更新后应重抓(2 次),实际 %d 次", got) } } // 移出书架且无其他引用时清理缓存文件与索引。 func TestContentCacheClearedOnRemoveBook(t *testing.T) { svc, _, book := prepareCacheTestBook(t) ctx := t.Context() if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0); err != nil { t.Fatalf("读取失败: %v", err) } dir := svc.contentBookDir(book.Origin, book.BookURL) if _, err := os.Stat(dir); err != nil { t.Fatalf("缓存目录应存在: %v", err) } if err := svc.RemoveBook(ctx, "u1", book.ID); err != nil { t.Fatalf("移出书架失败: %v", err) } if _, err := os.Stat(dir); !os.IsNotExist(err) { t.Fatalf("移出书架后缓存目录应被清理,err=%v", err) } if rows, err := svc.repo.ListContentCacheByBook(ctx, contentBookKey(book.Origin, book.BookURL)); err != nil || len(rows) != 0 { t.Fatalf("缓存索引应清空: rows=%d err=%v", len(rows), err) } } // 音频清单使用短 TTL:过期后不命中并自动清理。 func TestContentCacheAudioShortTTL(t *testing.T) { svc, srv, book := prepareCacheTestBook(t) ctx := t.Context() ch := model.ReaderChapter{Index: 0, Title: "第 1 章", URL: srv.URL + "/book/1/c1.html"} svc.saveCachedContent(ctx, mustSource(t, svc, srv.URL), book, ch, &ChapterContent{ Type: "audio", Tracks: []string{srv.URL + "/a.mp3"}, }) row, err := svc.repo.GetContentCache(ctx, contentBookKey(book.Origin, book.BookURL), contentChapterKey(book, ch), "audio") if err != nil { t.Fatalf("音频缓存未写入: %v", err) } if row.ExpiresAt == 0 { t.Fatal("音频缓存应带 TTL") } // 过期后读取视为未命中,并清掉条目。 row.ExpiresAt = 1 if err := svc.repo.UpsertContentCache(ctx, row); err != nil { t.Fatal(err) } if _, ok := svc.loadCachedContent(ctx, mustSource(t, svc, srv.URL), nil, book, ch, "audio"); ok { t.Fatal("过期缓存不应命中") } if _, err := svc.repo.GetContentCache(ctx, contentBookKey(book.Origin, book.BookURL), contentChapterKey(book, ch), "audio"); err == nil { t.Fatal("过期条目应被删除") } } // mustSource 取(并解析)指定书源记录,供直接调用缓存方法的测试使用。 func mustSource(t *testing.T, svc *ReaderService, sourceURL string) *model.ReaderBookSource { t.Helper() src, err := svc.repo.GetSourceByURL(context.Background(), sourceURL) if err != nil { t.Fatalf("取书源失败: %v", err) } return src } // 缓存键:不同书源/书本地址/章节地址必须落在不同键上。 func TestContentCacheKeys(t *testing.T) { base := &model.ReaderBook{Origin: "https://a.com", BookURL: "https://a.com/book/1", TocURL: "https://a.com/toc/1"} ch1 := model.ReaderChapter{Index: 0, Title: "第 1 章", URL: "/book/1/c1.html"} ch2 := model.ReaderChapter{Index: 0, Title: "第 1 章", URL: "https://a.com/book/1/c1.html"} // 相对与绝对地址指向同一章:绝对化后身份一致。 if contentChapterKey(base, ch1) != contentChapterKey(base, ch2) { t.Fatal("同一章的相对/绝对地址应得到同一身份") } if contentBookKey("https://a.com", "https://a.com/book/1") == contentBookKey("https://b.com", "https://a.com/book/1") { t.Fatal("不同书源的同一地址不应共享键") } if contentChapterIdentity(base, model.ReaderChapter{Index: 0, Title: "卷名", IsVolume: true}) != "title|卷名" { t.Fatal("卷章节应退化为标题身份") } } // 缓存目录不可用时(未配置)不应影响正文读取。 func TestContentCacheWithoutCacheDir(t *testing.T) { srv := newCacheTestServer() defer srv.Close() svc, _ := newLoginTestService(t) svc.cfg.Cache.CacheDir = "" svc.cfg.App.DataDir = filepath.Join(t.TempDir(), "missing", "data") srcID := importTestSource(t, svc, cacheTestSourceJSON(srv.URL), srv.URL) ctx := t.Context() book, err := svc.AddBook(ctx, "u1", SearchOrigin{ SourceID: srcID, Origin: srv.URL, OriginName: "缓存测试源", BookURL: srv.URL + "/book/1", }, "缓存之书", "作者", "") if err != nil { t.Fatal(err) } if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{{Index: 0, Title: "第 1 章", URL: srv.URL + "/book/1/c1.html"}}); err != nil { t.Fatal(err) } out, err := svc.GetContentForBook(ctx, "u1", book.ID, 0) if err != nil { t.Fatalf("无缓存目录时读取失败: %v", err) } if !strings.Contains(out.Content, "第1章") { t.Fatalf("正文异常: %q", out.Content) } }