package allanime import ( "bytes" "context" "encoding/json" "io" "net/http" "strings" "testing" ) func TestGraphqlRequest_SuccessAndHeaders(t *testing.T) { t.Parallel() var method, url, ct, referer, ua string var bodyBuf bytes.Buffer provider := &AllAnimeProvider{ httpClient: &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { method = req.Method url = req.URL.String() ct = req.Header.Get("Content-Type") referer = req.Header.Get("Referer") ua = req.Header.Get("User-Agent") if _, err := io.Copy(&bodyBuf, req.Body); err != nil { t.Fatalf("copy request body: %v", err) } return mockStringResponse(http.StatusOK, `{"data":{"key":"val"}}`), nil }), }, } _, err := provider.graphqlRequest( context.Background(), "query($id:String!){show(_id:$id){name}}", map[string]any{"id": "abc"}, ) if err != nil { t.Fatalf("graphqlRequest() error = %v", err) } verifyGraphqlRequest(t, method, url, ct, referer, ua, bodyBuf.Bytes()) } func TestGraphqlRequest_Errors(t *testing.T) { t.Parallel() tests := []struct { name string status int body string }{ { name: "graphql error in response", status: http.StatusOK, body: `{"errors":[{"message":"not found"}]}`, }, { name: "non-200 status", status: http.StatusInternalServerError, body: `{"data":{}}`, }, { name: "invalid json body", status: http.StatusOK, body: `not json`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() provider := &AllAnimeProvider{ httpClient: &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { return mockStringResponse(tt.status, tt.body), nil }), }, } _, err := provider.graphqlRequest( context.Background(), "query($id:String!){show(_id:$id){name}}", map[string]any{"id": "abc"}, ) if err == nil { t.Error("expected error, got nil") } }) } } func verifyGraphqlRequest(t *testing.T, method, url, ct, referer, ua string, body []byte) { t.Helper() if method != http.MethodPost { t.Errorf("method = %q, want POST", method) } if url != allAnimeBaseURL+"/api" { t.Errorf("url = %q, want %q", url, allAnimeBaseURL+"/api") } if ct != "application/json" { t.Errorf("Content-Type = %q", ct) } if referer != allAnimeReferer { t.Errorf("Referer = %q", referer) } if ua != defaultUserAgent { t.Errorf("User-Agent = %q", ua) } var sent map[string]any if err := json.Unmarshal(body, &sent); err != nil { t.Fatalf("unmarshal sent body: %v", err) } if sent["query"] != "query($id:String!){show(_id:$id){name}}" { t.Errorf("unexpected query in body") } vars, ok := sent["variables"].(map[string]any) if !ok || vars["id"] != "abc" { t.Errorf("unexpected variables in body") } } func TestGraphqlRequest_SetsTranslationTypeLower(t *testing.T) { t.Parallel() provider := &AllAnimeProvider{ httpClient: &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { return mockStringResponse(http.StatusOK, `{"data":{}}`), nil }), }, } _, err := provider.graphqlRequest( context.Background(), "query($t:VaildTranslationTypeEnumType!){x(translationType:$t){id}}", map[string]any{"translationType": "SUB"}, ) if err != nil { t.Fatalf("graphqlRequest: %v", err) } } func TestGraphqlRequestWithHash_Plain(t *testing.T) { t.Parallel() provider := &AllAnimeProvider{ utlsClient: &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { if req.Method != http.MethodGet { t.Errorf("method = %q, want GET", req.Method) } if !strings.Contains(req.URL.String(), episodeQueryHash) { t.Errorf("url should contain hash, got %q", req.URL.String()) } if req.Header.Get("Referer") != allAnimeReferer { t.Errorf("Referer = %q", req.Header.Get("Referer")) } return mockStringResponse(http.StatusOK, `{"data":{"episode":{"sourceUrls":[{"sourceUrl":"https://example.test/v.mp4","sourceName":"default"}]}}}`), nil }), }, } result, err := provider.graphqlRequestWithHash( context.Background(), "show123", "1", "sub", ) if err != nil { t.Fatalf("graphqlRequestWithHash: %v", err) } data, ok := result["data"].(map[string]any) if !ok { t.Fatal("result missing data key") } sources := nestedSlice(data, "episode", "sourceUrls") if len(sources) != 1 { t.Fatalf("got %d sources, want 1", len(sources)) } } func TestGraphqlRequestWithHash_Encrypted(t *testing.T) { t.Parallel() encryptedPayload := buildEncryptedTobeparsedPayload(t, []byte(`{"sourceUrls":[{"sourceUrl":"https://e.test/v.mp4","sourceName":"default"}]}`)) provider := &AllAnimeProvider{ utlsClient: &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { return mockStringResponse(http.StatusOK, `{"data":{"tobeparsed":"`+encryptedPayload+`"}}`), nil }), }, } result, err := provider.graphqlRequestWithHash( context.Background(), "show456", "2", "dub", ) if err != nil { t.Fatalf("graphqlRequestWithHash: %v", err) } sources := nestedSlice(result, "episode", "sourceUrls") if len(sources) != 1 { t.Fatalf("got %d sources, want 1", len(sources)) } } func TestGraphqlRequestWithHash_Non200(t *testing.T) { t.Parallel() provider := &AllAnimeProvider{ utlsClient: &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { return mockStringResponse(http.StatusNotFound, `not found`), nil }), }, } _, err := provider.graphqlRequestWithHash( context.Background(), "x", "1", "sub", ) if err == nil { t.Fatal("expected error for non-200") } } func TestGraphqlRequestWithHash_EmptyData(t *testing.T) { t.Parallel() provider := &AllAnimeProvider{ utlsClient: &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { return mockStringResponse(http.StatusOK, `{"data":{}}`), nil }), }, } _, err := provider.graphqlRequestWithHash( context.Background(), "x", "1", "sub", ) if err == nil { t.Fatal("expected error for empty data") } } func TestGetEpisodeSources_EncryptedHash(t *testing.T) { t.Parallel() encrypted := buildEncryptedTobeparsedPayload(t, []byte(`{"sourceUrls":[{"sourceUrl":"https://direct.test/v.mp4","sourceName":"default"}]}`)) provider := &AllAnimeProvider{ httpClient: &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { t.Error("fallback POST should not be called") return nil, nil }), }, utlsClient: &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { return mockStringResponse(http.StatusOK, `{"data":{"tobeparsed":"`+encrypted+`"}}`), nil }), }, extractor: newProviderExtractor(), } sources, err := provider.GetEpisodeSources(context.Background(), "show1", "1", "sub") if err != nil { t.Fatalf("GetEpisodeSources: %v", err) } if len(sources) == 0 { t.Fatal("expected at least one source") } if sources[0].URL != "https://direct.test/v.mp4" { t.Errorf("URL = %q", sources[0].URL) } } func TestGetEpisodeSources_FallbackPost(t *testing.T) { t.Parallel() sourceResponse := `{"data":{"episode":{"sourceUrls":[{"sourceUrl":"https://direct.test/v.mp4","sourceName":"default"}]}}}` fallbackCalled := false provider := &AllAnimeProvider{ httpClient: &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { fallbackCalled = true return mockStringResponse(http.StatusOK, sourceResponse), nil }), }, utlsClient: &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { return mockStringResponse(http.StatusNotFound, `not found`), nil }), }, extractor: newProviderExtractor(), } sources, err := provider.GetEpisodeSources(context.Background(), "show3", "3", "sub") if err != nil { t.Fatalf("GetEpisodeSources: %v", err) } if !fallbackCalled { t.Fatal("fallback POST was not called") } if len(sources) == 0 { t.Fatal("expected at least one source") } } func TestGetEpisodeSources_BothFail(t *testing.T) { t.Parallel() provider := &AllAnimeProvider{ httpClient: &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { return mockStringResponse(http.StatusNotFound, `not found`), nil }), }, utlsClient: &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { return mockStringResponse(http.StatusNotFound, `not found`), nil }), }, extractor: newProviderExtractor(), } _, err := provider.GetEpisodeSources(context.Background(), "show4", "4", "sub") if err == nil { t.Fatal("expected error when both requests fail") } } func TestGetAvailableEpisodes(t *testing.T) { t.Parallel() tests := []struct { name string body string wantSub int wantDub int wantErr bool }{ { name: "sub and dub available", body: `{"data":{"show":{"availableEpisodesDetail":{"sub":["1","2","3"],"dub":["1"]},"lastEpisodeInfo":{}}}}`, wantSub: 3, wantDub: 1, }, { name: "sub only", body: `{"data":{"show":{"availableEpisodesDetail":{"sub":["1","2"],"dub":null},"lastEpisodeInfo":{}}}}`, wantSub: 2, wantDub: 0, }, { name: "show not found", body: `{"data":{"show":null}}`, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() provider := &AllAnimeProvider{ httpClient: &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { return mockStringResponse(http.StatusOK, tt.body), nil }), }, } available, err := provider.GetAvailableEpisodes(context.Background(), "showX") if (err != nil) != tt.wantErr { t.Fatalf("GetAvailableEpisodes() error = %v, wantErr %v", err, tt.wantErr) } if tt.wantErr { return } if len(available.Sub) != tt.wantSub { t.Errorf("Sub count = %d, want %d", len(available.Sub), tt.wantSub) } if len(available.Dub) != tt.wantDub { t.Errorf("Dub count = %d, want %d", len(available.Dub), tt.wantDub) } }) } } func TestSearch(t *testing.T) { t.Parallel() t.Run("returns results", func(t *testing.T) { t.Parallel() provider := &AllAnimeProvider{ httpClient: &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { return mockStringResponse(http.StatusOK, `{"data":{"shows":{"edges":[{"_id":"id1","malId":"1","name":"Title One"},{"_id":"id2","malId":"2","name":"Title Two"}]}}}`), nil }), }, } results, err := provider.Search(context.Background(), "test", "sub") if err != nil { t.Fatalf("Search: %v", err) } if len(results) != 2 { t.Fatalf("len = %d, want 2", len(results)) } if results[0].ID != "id1" || results[0].MalID != "1" || results[0].Name != "Title One" { t.Errorf("result[0] = %+v", results[0]) } }) t.Run("empty results", func(t *testing.T) { t.Parallel() provider := &AllAnimeProvider{ httpClient: &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { return mockStringResponse(http.StatusOK, `{"data":{"shows":{"edges":[]}}}`), nil }), }, } results, err := provider.Search(context.Background(), "nonexistent", "sub") if err != nil { t.Fatalf("Search: %v", err) } if len(results) != 0 { t.Errorf("len = %d, want 0", len(results)) } }) } func TestGetStreams_FullSuccess(t *testing.T) { t.Parallel() searchBody := `{"data":{"shows":{"edges":[{"_id":"show123","malId":"1","name":"Test Anime"}]}}}` encrypted := buildEncryptedTobeparsedPayload(t, []byte(`{"sourceUrls":[{"sourceUrl":"https://stream.test/video.mp4","sourceName":"default"}]}`)) provider := &AllAnimeProvider{ httpClient: &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { return mockStringResponse(http.StatusOK, searchBody), nil }), }, utlsClient: &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { return mockStringResponse(http.StatusOK, `{"data":{"tobeparsed":"`+encrypted+`"}}`), nil }), }, extractor: newProviderExtractor(), } result, err := provider.GetStreams(context.Background(), 1, []string{"Test Anime"}, "1", "sub") if err != nil { t.Fatalf("GetStreams: %v", err) } if result.URL != "https://stream.test/video.mp4" { t.Errorf("URL = %q", result.URL) } if result.Referer != allAnimeReferer { t.Errorf("Referer = %q", result.Referer) } if result.Type != "mp4" { t.Errorf("Type = %q", result.Type) } } func TestGetStreams_ShowNotFound(t *testing.T) { t.Parallel() provider := &AllAnimeProvider{ httpClient: &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { return mockStringResponse(http.StatusOK, `{"data":{"shows":{"edges":[]}}}`), nil }), }, utlsClient: &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { t.Error("should not call episode sources when show not found") return nil, nil }), }, extractor: newProviderExtractor(), } _, err := provider.GetStreams(context.Background(), 999, []string{"Nothing"}, "1", "sub") if err == nil { t.Fatal("expected error for show not found") } } func TestGetStreams_NoSources(t *testing.T) { t.Parallel() provider := &AllAnimeProvider{ httpClient: &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { return mockStringResponse(http.StatusOK, `{"data":{"shows":{"edges":[{"_id":"showX","malId":"1","name":"Anime"}]}}}`), nil }), }, utlsClient: &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { return mockStringResponse(http.StatusNotFound, `not found`), nil }), }, extractor: newProviderExtractor(), } _, err := provider.GetStreams(context.Background(), 1, []string{"Anime"}, "1", "sub") if err == nil { t.Fatal("expected error when no sources") } } func TestParseProviderResponse(t *testing.T) { t.Parallel() t.Run("extracts links and subtitles", func(t *testing.T) { t.Parallel() body := `{"links":[{"link":"https://cdn.test/video.mp4","resolutionStr":"1080p"}],"subtitles":[{"lang":"en","src":"https://sub.test/en.vtt"}]}` extractor := &providerExtractor{ baseURL: allAnimeSiteURL, referer: allAnimeReferer, } sources := extractor.parseResponse(context.Background(), body) if len(sources) == 0 { t.Fatal("expected at least one source") } if sources[0].URL != "https://cdn.test/video.mp4" { t.Errorf("URL = %q", sources[0].URL) } if sources[0].Quality != "1080p" { t.Errorf("Quality = %q", sources[0].Quality) } if len(sources[0].Subtitles) != 1 { t.Fatalf("subtitles count = %d, want 1", len(sources[0].Subtitles)) } if sources[0].Subtitles[0].Lang != "en" { t.Errorf("sub lang = %q", sources[0].Subtitles[0].Lang) } }) t.Run("invalid json returns empty", func(t *testing.T) { t.Parallel() extractor := &providerExtractor{ baseURL: allAnimeSiteURL, referer: allAnimeReferer, } sources := extractor.parseResponse(context.Background(), "not json") if len(sources) != 0 { t.Errorf("expected empty, got %d sources", len(sources)) } }) t.Run("empty response returns empty", func(t *testing.T) { t.Parallel() extractor := &providerExtractor{ baseURL: allAnimeSiteURL, referer: allAnimeReferer, } sources := extractor.parseResponse(context.Background(), "{}") if len(sources) != 0 { t.Errorf("expected empty, got %d sources", len(sources)) } }) } func TestParseExternalEmbedResponse(t *testing.T) { t.Parallel() t.Run("ok.ru extracts hls manifest", func(t *testing.T) { t.Parallel() body := `{"flashvars":{"metadata":"{\"hlsManifestUrl\":\"https://ok.example.test/playlist.m3u8\"}"}}` sources := parseEmbed("https://ok.ru/video/123", body, allAnimeReferer) if len(sources) != 1 { t.Fatalf("got %d sources, want 1", len(sources)) } if sources[0].URL != "https://ok.example.test/playlist.m3u8" { t.Errorf("URL = %q", sources[0].URL) } if sources[0].Provider != "ok" { t.Errorf("Provider = %q", sources[0].Provider) } }) t.Run("mp4upload extracts src", func(t *testing.T) { t.Parallel() body := `src: "https://mp4upload.example.test/video.mp4"` sources := parseEmbed("https://mp4upload.com/e/abc", body, allAnimeReferer) if len(sources) != 1 { t.Fatalf("got %d sources, want 1", len(sources)) } if sources[0].URL != "https://mp4upload.example.test/video.mp4" { t.Errorf("URL = %q", sources[0].URL) } if sources[0].Provider != "mp4upload" { t.Errorf("Provider = %q", sources[0].Provider) } }) t.Run("unknown embed returns empty", func(t *testing.T) { t.Parallel() sources := parseEmbed("https://unknown.example.com/video", "", allAnimeReferer) if len(sources) != 0 { t.Errorf("expected empty, got %d sources", len(sources)) } }) } func TestParseM3U8Sources(t *testing.T) { t.Parallel() t.Run("parses bandwidth entries", func(t *testing.T) { t.Parallel() body := "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1920x1080\n1080p.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=5000000\n720p.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=2500000\n480p.m3u8" masterURL := "https://cdn.test/master.m3u8" sources := parseM3U8Sources(body, masterURL, allAnimeReferer) if len(sources) != 3 { t.Fatalf("got %d sources, want 3", len(sources)) } expected := []struct { url string quality string }{ {"https://cdn.test/1080p.m3u8", "1080p"}, {"https://cdn.test/720p.m3u8", "720p"}, {"https://cdn.test/480p.m3u8", "480p"}, } for i, exp := range expected { if sources[i].URL != exp.url { t.Errorf("sources[%d].URL = %q, want %q", i, sources[i].URL, exp.url) } if sources[i].Quality != exp.quality { t.Errorf("sources[%d].Quality = %q, want %q", i, sources[i].Quality, exp.quality) } if sources[i].Type != "m3u8" { t.Errorf("sources[%d].Type = %q", i, sources[i].Type) } } }) t.Run("empty body returns nothing", func(t *testing.T) { t.Parallel() sources := parseM3U8Sources("", "https://cdn.test/master.m3u8", allAnimeReferer) if len(sources) != 0 { t.Errorf("expected empty, got %d", len(sources)) } }) t.Run("absolute URLs not rebased", func(t *testing.T) { t.Parallel() body := "#EXT-X-STREAM-INF:BANDWIDTH=8000000\nhttps://cdn2.test/video.m3u8" sources := parseM3U8Sources(body, "https://cdn.test/master.m3u8", allAnimeReferer) if len(sources) != 1 { t.Fatalf("got %d sources", len(sources)) } if sources[0].URL != "https://cdn2.test/video.m3u8" { t.Errorf("URL = %q", sources[0].URL) } }) } func TestExtractVideoLinks(t *testing.T) { t.Parallel() t.Run("fetches and parses provider response", func(t *testing.T) { t.Parallel() extractor := &providerExtractor{ httpClient: &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { if req.Method != http.MethodGet { t.Errorf("method = %q, want GET", req.Method) } if req.Header.Get("Referer") != allAnimeReferer { t.Errorf("Referer = %q", req.Header.Get("Referer")) } body := `{"links":[{"link":"https://cdn.test/video.mp4","resolutionStr":"720p"}]}` return mockStringResponse(http.StatusOK, body), nil }), }, baseURL: allAnimeSiteURL, referer: allAnimeReferer, } sources, err := extractor.ExtractVideoLinks(context.Background(), "/some-path") if err != nil { t.Fatalf("ExtractVideoLinks: %v", err) } if len(sources) != 1 { t.Fatalf("got %d sources, want 1", len(sources)) } if sources[0].Provider != "wixmp" { t.Errorf("Provider = %q", sources[0].Provider) } }) t.Run("server error returns empty sources", func(t *testing.T) { t.Parallel() extractor := &providerExtractor{ httpClient: &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { return mockStringResponse(http.StatusInternalServerError, ""), nil }), }, baseURL: allAnimeSiteURL, referer: allAnimeReferer, } sources, err := extractor.ExtractVideoLinks(context.Background(), "/error-path") if err != nil { t.Fatalf("expected no error, got %v", err) } if len(sources) != 0 { t.Errorf("expected empty sources, got %d", len(sources)) } }) } func TestExtractEmbedVideoLinks(t *testing.T) { t.Parallel() t.Run("ok.ru embed extracted", func(t *testing.T) { t.Parallel() extractor := &providerExtractor{ httpClient: &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { body := `{"flashvars":{"metadata":"{\"hlsManifestUrl\":\"https://ok.test/play.m3u8\"}"}}` return mockStringResponse(http.StatusOK, body), nil }), }, referer: allAnimeReferer, } sources, err := extractor.ExtractEmbedVideoLinks(context.Background(), "https://ok.ru/video/123") if err != nil { t.Fatalf("ExtractEmbedVideoLinks: %v", err) } if len(sources) != 1 { t.Fatalf("got %d sources, want 1", len(sources)) } if sources[0].URL != "https://ok.test/play.m3u8" { t.Errorf("URL = %q", sources[0].URL) } }) t.Run("unknown embed returns empty", func(t *testing.T) { t.Parallel() extractor := &providerExtractor{ httpClient: &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { return mockStringResponse(http.StatusOK, ""), nil }), }, referer: allAnimeReferer, } sources, err := extractor.ExtractEmbedVideoLinks(context.Background(), "https://unknown.com/video") if err != nil { t.Fatalf("ExtractEmbedVideoLinks: %v", err) } if len(sources) != 0 { t.Errorf("expected empty, got %d sources", len(sources)) } }) }