package service import ( "context" "fmt" "net/http" "net/http/httptest" "path/filepath" "testing" "github.com/glebarez/sqlite" "github.com/stretchr/testify/require" "go.uber.org/zap" "gorm.io/gorm" "github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/repository" ) func newDanmakuTestService(t *testing.T) *DanmakuService { t.Helper() // 独立临时文件库,避免测试间通过共享内存库串数据。 db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "danmaku-test.db")), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&model.Setting{}, &model.Media{})) repos := repository.New(db) t.Cleanup(func() { sqlDB, err := db.DB() if err == nil { _ = sqlDB.Close() } }) return NewDanmakuService(zap.NewNop(), repos) } // seedDanmakuMedia inserts a media row so the service can resolve a name and // episode number. func seedDanmakuMedia(t *testing.T, svc *DanmakuService, id, title, originalName string, episodeNum int) { t.Helper() m := model.Media{Title: title} if id != "" { m.ID = id } if originalName != "" { m.OriginalName = originalName } if episodeNum > 0 { m.EpisodeNum = episodeNum } require.NoError(t, svc.repo.DB.Create(&m).Error) } // danmakuSourceServer records the search request (name + episode params) and // serves episodes + Bilibili-format XML comments, following the dandanplay // protocol. The search payload can be customized per test (default: one anime, // one episode). type danmakuSourceServer struct { server *httptest.Server lastSearch string // full query (anime=...&episode=...) searchResponse string // JSON body served for /api/v2/search/episodes } func newDanmakuSourceServer(t *testing.T) *danmakuSourceServer { return newDanmakuSourceServerWithSearch( t, `{"hasMore":false,"animes":[{"animeId":1001,"animeTitle":"测试动画","episodes":[{"episodeId":25484,"episodeTitle":"第1话"}]}]}`, ) } func newDanmakuSourceServerWithSearch(t *testing.T, searchResponse string) *danmakuSourceServer { t.Helper() ds := &danmakuSourceServer{searchResponse: searchResponse} mux := http.NewServeMux() mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) { ds.lastSearch = r.URL.RawQuery w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, ds.searchResponse) }) mux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/xml") fmt.Fprint(w, `弹幕A弹幕B弹幕C`) }) mux.HandleFunc("/api/v2/comment/99999", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/xml") fmt.Fprint(w, `显式指定弹幕`) }) ds.server = httptest.NewServer(mux) t.Cleanup(ds.server.Close) return ds } func (ds *danmakuSourceServer) URL() string { return ds.server.URL } func TestDanmakuConfigDefaults(t *testing.T) { svc := newDanmakuTestService(t) cfg := svc.Config(context.Background()) require.True(t, cfg.Enabled) require.Equal(t, "1", cfg.Opacity) require.Equal(t, "24", cfg.FontSize) require.Equal(t, "1", cfg.Area) require.Empty(t, cfg.Source) } func TestDanmakuConfigReadsPersistedValues(t *testing.T) { svc := newDanmakuTestService(t) ctx := context.Background() require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuEnabledKey, "false")) require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, "https://dm.example.com")) require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuOpacityKey, "0.7")) cfg := svc.Config(ctx) require.False(t, cfg.Enabled) require.Equal(t, "https://dm.example.com", cfg.Source) require.Equal(t, "0.7", cfg.Opacity) } func TestDanmakuFetchDisablesWhenToggleOff(t *testing.T) { svc := newDanmakuTestService(t) ctx := context.Background() require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuEnabledKey, "false")) seedDanmakuMedia(t, svc, "m1", "测试动画", "", 0) res, err := svc.Fetch(ctx, "m1", "", "") require.NoError(t, err) require.False(t, res.Enabled) require.Empty(t, res.Raw) // 禁用时无弹幕数据可探测,source_type 保持未知(交给播放器兜底)。 require.Equal(t, "auto", res.SourceType) } func TestDanmakuFetchWithDandanplaySource(t *testing.T) { srv := newDanmakuSourceServer(t) svc := newDanmakuTestService(t) ctx := context.Background() require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL())) seedDanmakuMedia(t, svc, "m2", "测试动画", "", 0) res, err := svc.Fetch(ctx, "m2", "", "") require.NoError(t, err) require.True(t, res.Enabled) require.Equal(t, "xml", res.SourceType) require.Contains(t, res.Raw, "弹幕A") require.Contains(t, res.Raw, `p="0.5,1,16777215,user1"`) require.Equal(t, "测试动画", res.AnimeTitle) require.Equal(t, "第1话", res.EpisodeTitle) require.Equal(t, int64(25484), res.EpisodeID) require.Equal(t, "search", res.MatchMode) } func TestDanmakuFetchUsesOriginalNameForSearch(t *testing.T) { srv := newDanmakuSourceServer(t) svc := newDanmakuTestService(t) ctx := context.Background() require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL())) seedDanmakuMedia(t, svc, "m3", "刮削标题A", "日文原名B", 0) res, err := svc.Fetch(ctx, "m3", "", "") require.NoError(t, err) require.True(t, res.Enabled) require.Contains(t, res.Raw, "弹幕A") // 集数为 0 时不附加 episode 过滤参数。 require.NotContains(t, srv.lastSearch, "episode=") } func TestDanmakuFetchSearchesByEpisodeNumber(t *testing.T) { srv := newDanmakuSourceServer(t) svc := newDanmakuTestService(t) ctx := context.Background() require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL())) // 第 3 话:搜索时应带 episode=3,命中该集弹幕库。 seedDanmakuMedia(t, svc, "m2", "测试动画", "", 3) res, err := svc.Fetch(ctx, "m2", "", "") require.NoError(t, err) require.True(t, res.Enabled) require.Contains(t, srv.lastSearch, "anime=") require.Contains(t, srv.lastSearch, "episode=3") require.Contains(t, res.Raw, "弹幕A") } func TestDanmakuFetchKeywordKeepsEpisode(t *testing.T) { srv := newDanmakuSourceServer(t) svc := newDanmakuTestService(t) ctx := context.Background() require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL())) seedDanmakuMedia(t, svc, "m5", "测试动画", "", 5) // 手动搜索关键词时保留媒体集数,仍只命中该集。 res, err := svc.Fetch(ctx, "m5", "另一个名字", "") require.NoError(t, err) require.True(t, res.Enabled) require.Contains(t, srv.lastSearch, "anime=") require.Contains(t, srv.lastSearch, "episode=5") } func TestDanmakuFetchUsesDefaultSourceWhenEmpty(t *testing.T) { require.Equal(t, DanmakuDefaultSource, "https://api.dandanplay.net") } func TestDanmakuFetchHandlesSearch404(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) }) srv := httptest.NewServer(mux) defer srv.Close() // 配置源 404 会回退官方,官方同样 404 才能稳定复现错误。 official := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) })) defer official.Close() overrideDanmakuOfficialBase(t, official.URL) svc := newDanmakuTestService(t) ctx := context.Background() require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL)) seedDanmakuMedia(t, svc, "m4", "测试动画", "", 0) res, err := svc.Fetch(ctx, "m4", "", "") require.Error(t, err) require.True(t, res.Enabled) } // 多部番剧命中时返回候选列表(disambiguation),不擅自选第一个。 func TestDanmakuFetchReturnsCandidatesOnAmbiguity(t *testing.T) { srv := newDanmakuSourceServerWithSearch( t, `{"hasMore":false,"animes":[`+ `{"animeId":1001,"animeTitle":"测试动画","episodes":[{"episodeId":25484,"episodeTitle":"第1话"}]},`+ `{"animeId":2002,"animeTitle":"测试动画 剧场版","episodes":[{"episodeId":30001,"episodeTitle":"正片"}]}`+ `]}`, ) svc := newDanmakuTestService(t) ctx := context.Background() require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL())) seedDanmakuMedia(t, svc, "m6", "测试动画", "", 0) res, err := svc.Fetch(ctx, "m6", "", "") require.NoError(t, err) require.True(t, res.Enabled) // 歧义时不取弹幕,返回候选交由播放器选择。 require.Empty(t, res.Raw) require.Len(t, res.Candidates, 2) require.Equal(t, int64(1001), res.Candidates[0].AnimeID) require.Equal(t, "测试动画", res.Candidates[0].AnimeTitle) require.Len(t, res.Candidates[0].Episodes, 1) require.Equal(t, int64(25484), res.Candidates[0].Episodes[0].EpisodeID) } // 显式指定 episodeId 时跳过搜索,直接拉取该弹幕库。 func TestDanmakuFetchWithExplicitEpisodeID(t *testing.T) { srv := newDanmakuSourceServer(t) svc := newDanmakuTestService(t) ctx := context.Background() require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL())) // 媒体名与搜索无关也能用显式 episodeId 命中。 res, err := svc.Fetch(ctx, "m2", "", "99999") require.NoError(t, err) require.True(t, res.Enabled) require.Contains(t, res.Raw, "显式指定弹幕") require.Empty(t, res.Candidates) require.Equal(t, int64(99999), res.EpisodeID) require.Equal(t, "manual", res.MatchMode) } func TestDetectDanmakuSourceType(t *testing.T) { cases := []struct { raw string want string }{ {`text`, "xml"}, {`text`, "xml"}, {`{"count":2,"comments":[{"p":"0.00,1,16777215,[u]","m":"hi","t":0}]}`, "json"}, {`[{"time":1,"text":"x"}]`, "json"}, {"", "auto"}, {" \n ", "auto"}, } for _, c := range cases { if got := detectDanmakuSourceType(c.raw); got != c.want { t.Errorf("detectDanmakuSourceType(%q) = %q, want %q", c.raw, got, c.want) } } } // 弹弹play JSON 源:comment 接口返回 {"count":N,"comments":[{p,m,t}]}, // Fetch 应自动探测 source_type=json 并原样透传。 func TestDanmakuFetchDetectsJSONSource(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v2/search/episodes": w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":1001,"animeTitle":"测试动画","episodes":[{"episodeId":25484,"episodeTitle":"第1话"}]}]}`) case "/api/v2/comment/25484": w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, `{"count":2,"comments":[{"cid":1,"p":"0.00,1,16777215,[u]","m":"第一弹","t":0},{"cid":2,"p":"3.50,5,16711680,[u]","m":"顶部弹幕","t":3.5}]}`) default: http.NotFound(w, r) } })) t.Cleanup(srv.Close) svc := newDanmakuTestService(t) ctx := context.Background() require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srv.URL)) seedDanmakuMedia(t, svc, "mjson", "测试动画", "", 0) res, err := svc.Fetch(ctx, "mjson", "", "") require.NoError(t, err) require.True(t, res.Enabled) require.Equal(t, "json", res.SourceType) require.Contains(t, res.Raw, `"m":"第一弹"`) require.Empty(t, res.Candidates) // XML 源仍按 XML 探测。 srvXML := newDanmakuSourceServer(t) require.NoError(t, svc.repo.Setting.Set(ctx, DanmakuSourceKey, srvXML.URL())) res2, err := svc.Fetch(ctx, "mjson", "", "") require.NoError(t, err) require.Equal(t, "xml", res2.SourceType) require.Contains(t, res2.Raw, "弹幕A") }