Files
MeBox/internal/service/site_adapter_test.go
T
2026-06-21 09:44:29 +08:00

287 lines
9.0 KiB
Go

package service
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
func TestMTeamAuthenticateRequiresAPIKey(t *testing.T) {
adapter := NewMTeamAdapter()
err := adapter.Authenticate(context.Background(), SiteConfig{
URL: "https://api.m-team.cc",
AuthType: "api_key",
})
if err == nil || !strings.Contains(err.Error(), "API Access Token") {
t.Fatalf("Authenticate error = %v, want API Access Token hint", err)
}
}
func TestMTeamAuthenticateUsesOpenAPIKeyHeader(t *testing.T) {
var gotPath string
var gotKey string
var gotPayload map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotKey = r.Header.Get("x-api-key")
if err := json.NewDecoder(r.Body).Decode(&gotPayload); err != nil {
t.Fatalf("decode request body: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":"0","message":"SUCCESS","data":{"total":"0","data":[]}}`))
}))
defer server.Close()
adapter := NewMTeamAdapter()
err := adapter.Authenticate(context.Background(), SiteConfig{
URL: server.URL,
AuthType: "api_key",
APIKey: "token-123",
Timeout: 5 * time.Second,
})
if err != nil {
t.Fatalf("Authenticate returned error: %v", err)
}
if gotPath != "/api/torrent/search" {
t.Fatalf("path = %q, want /api/torrent/search", gotPath)
}
if gotKey != "token-123" {
t.Fatalf("x-api-key = %q, want token-123", gotKey)
}
if gotPayload["mode"] != "all" || gotPayload["keyword"] != nil {
t.Fatalf("payload = %#v, want mode all without keyword probe", gotPayload)
}
}
func TestMTeamAuthenticateReportsAPIMessage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":1,"message":"key無效","data":null}`))
}))
defer server.Close()
adapter := NewMTeamAdapter()
err := adapter.Authenticate(context.Background(), SiteConfig{
URL: server.URL,
AuthType: "api_key",
APIKey: "bad-token",
Timeout: 5 * time.Second,
})
if err == nil || !strings.Contains(err.Error(), "key無效") {
t.Fatalf("Authenticate error = %v, want key invalid message", err)
}
}
func TestYemaPTAuthenticateUsesAuthorizationHeader(t *testing.T) {
var gotPath string
var gotAuth string
var gotXAPIKey string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
gotXAPIKey = r.Header.Get("x-api-key")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"showType":0,"data":{"id":10,"name":"tester"}}`))
}))
defer server.Close()
adapter := NewYemaPTAdapter()
err := adapter.Authenticate(context.Background(), SiteConfig{
Type: "yemapt",
URL: server.URL,
AuthType: "api_key",
APIKey: "auth-123",
Timeout: 5 * time.Second,
})
if err != nil {
t.Fatalf("Authenticate returned error: %v", err)
}
if gotPath != "/openApi/user/fetchBasicInfo.json" {
t.Fatalf("path = %q, want /openApi/user/fetchBasicInfo.json", gotPath)
}
if gotAuth != "auth-123" {
t.Fatalf("Authorization = %q, want auth-123", gotAuth)
}
if gotXAPIKey != "" {
t.Fatalf("x-api-key = %q, want empty", gotXAPIKey)
}
}
func TestYemaPTAuthenticateReportsAPIMessage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":false,"errorCode":403,"errorMessage":"need api auth"}`))
}))
defer server.Close()
adapter := NewYemaPTAdapter()
err := adapter.Authenticate(context.Background(), SiteConfig{
Type: "yemapt",
URL: server.URL,
AuthType: "api_key",
APIKey: "bad-auth",
Timeout: 5 * time.Second,
})
if err == nil || !strings.Contains(err.Error(), "need api auth") {
t.Fatalf("Authenticate error = %v, want need api auth", err)
}
}
func TestNewSiteAdapterDetectsYemaPTURL(t *testing.T) {
adapter := NewSiteAdapter(&model.Site{
Type: "nexusphp",
URL: "https://www.yemapt.org",
})
if _, ok := adapter.(*YemaPTAdapter); !ok {
t.Fatalf("adapter = %T, want *YemaPTAdapter", adapter)
}
}
func TestBuildRequestAPIKeyHeaderBySite(t *testing.T) {
yemaReq, err := buildRequest(context.Background(), http.MethodGet, "https://www.yemapt.org/openApi/user/fetchBasicInfo.json", SiteConfig{
Type: "yemapt",
URL: "https://www.yemapt.org",
AuthType: "api_key",
APIKey: "yema-auth",
}, nil)
if err != nil {
t.Fatal(err)
}
if got := yemaReq.Header.Get("Authorization"); got != "yema-auth" {
t.Fatalf("YemaPT Authorization = %q, want yema-auth", got)
}
if got := yemaReq.Header.Get("x-api-key"); got != "" {
t.Fatalf("YemaPT x-api-key = %q, want empty", got)
}
mteamReq, err := buildRequest(context.Background(), http.MethodGet, "https://api.m-team.cc/api/torrent/search", SiteConfig{
Type: "mteam",
URL: "https://api.m-team.cc",
AuthType: "api_key",
APIKey: "mteam-auth",
}, nil)
if err != nil {
t.Fatal(err)
}
if got := mteamReq.Header.Get("x-api-key"); got != "mteam-auth" {
t.Fatalf("M-Team x-api-key = %q, want mteam-auth", got)
}
if got := mteamReq.Header.Get("Authorization"); got != "" {
t.Fatalf("M-Team Authorization = %q, want empty", got)
}
}
func TestMTeamPublishedAPIRateLimits(t *testing.T) {
search := mteamAPIRateLimits(mteamAPIEndpointSearch)
if len(search) != 1 || search[0].Limit != 1000 || search[0].Window != 24*time.Hour {
t.Fatalf("search limits = %#v, want 1000/24h", search)
}
detail := mteamAPIRateLimits(mteamAPIEndpointDetail)
if len(detail) != 1 || detail[0].Limit != 100 || detail[0].Window != time.Hour {
t.Fatalf("detail limits = %#v, want 100/1h", detail)
}
download := mteamAPIRateLimits(mteamAPIEndpointDownload)
if len(download) != 2 ||
download[0].Limit != 100 || download[0].Window != time.Hour ||
download[1].Limit != 1000 || download[1].Window != 24*time.Hour {
t.Fatalf("download limits = %#v, want 100/1h and 1000/24h", download)
}
}
func TestPersistentSiteAPIRateLimiterPersistsSlidingWindow(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Setting{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
now := time.Date(2026, 6, 20, 12, 0, 0, 0, time.UTC)
limiter := newPersistentSiteAPIRateLimiter(repos)
limiter.now = func() time.Time { return now }
limit := siteAPIRateLimit{Bucket: "test_1h", Limit: 2, Window: time.Hour}
if err := limiter.Allow(t.Context(), "mteam:test", limit); err != nil {
t.Fatalf("first allow: %v", err)
}
if err := limiter.Allow(t.Context(), "mteam:test", limit); err != nil {
t.Fatalf("second allow: %v", err)
}
err = limiter.Allow(t.Context(), "mteam:test", limit)
var limited *siteAPIRateLimitError
if !errors.As(err, &limited) {
t.Fatalf("third allow error = %v, want siteAPIRateLimitError", err)
}
if limited.RetryAfter != time.Hour {
t.Fatalf("retry_after = %v, want 1h", limited.RetryAfter)
}
restarted := newPersistentSiteAPIRateLimiter(repos)
restarted.now = func() time.Time { return now.Add(30 * time.Minute) }
if err := restarted.Allow(t.Context(), "mteam:test", limit); !errors.As(err, &limited) {
t.Fatalf("restarted allow error = %v, want persisted limit", err)
}
restarted.now = func() time.Time { return now.Add(time.Hour + time.Second) }
if err := restarted.Allow(t.Context(), "mteam:test", limit); err != nil {
t.Fatalf("allow after window: %v", err)
}
}
func TestMTeamRateLimitStopsRequestBeforeHTTP(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":"0","message":"SUCCESS","data":{"total":"0","data":[]}}`))
}))
defer server.Close()
adapter := NewMTeamAdapter()
limiter := &staticSiteAPIRateLimiter{err: &siteAPIRateLimitError{
Bucket: "torrent_search_24h",
Limit: 1000,
Window: 24 * time.Hour,
RetryAfter: time.Hour,
}}
_, err := adapter.Search(t.Context(), SiteConfig{
URL: server.URL,
AuthType: "api_key",
APIKey: "token-123",
Timeout: 5 * time.Second,
rateLimiter: limiter,
}, "show", 1)
if err == nil || !strings.Contains(err.Error(), "rate limit") {
t.Fatalf("Search error = %v, want rate limit", err)
}
if got := requests.Load(); got != 0 {
t.Fatalf("HTTP requests = %d, want 0", got)
}
if limiter.calls != 1 {
t.Fatalf("limiter calls = %d, want 1", limiter.calls)
}
}
type staticSiteAPIRateLimiter struct {
err error
calls int
}
func (l *staticSiteAPIRateLimiter) Allow(context.Context, string, ...siteAPIRateLimit) error {
l.calls++
return l.err
}