package sources import ( "context" "encoding/json" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "time" "ymhut-box/server/unified-management/internal/config" "ymhut-box/server/unified-management/internal/db" ) func TestCheckOneTreatsRedirectToOKAsRedirected(t *testing.T) { target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok")) })) defer target.Close() redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target.URL, http.StatusFound) })) defer redirector.Close() cfg, store := testStore(t) service := NewService(cfg, store) item, err := store.UpsertSource(db.Source{ CategoryID: "test", CategoryName: "Test", SourceID: "redirect", Name: "Redirect", Method: "GET", APIURL: redirector.URL, TimeoutMS: 3000, CheckIntervalSec: 300, Enabled: true, ClientVisible: true, }) if err != nil { t.Fatal(err) } if err := service.CheckOne(context.Background(), item); err != nil { t.Fatal(err) } checked, err := store.GetSourceBySourceID("redirect") if err != nil { t.Fatal(err) } if checked.LastStatus != "redirected" { t.Fatalf("LastStatus = %q, want redirected", checked.LastStatus) } if !strings.Contains(checked.LastError, `"redirected":true`) { t.Fatalf("LastError does not contain redirect metadata: %s", checked.LastError) } if checked.ConsecutiveFailure != 0 { t.Fatalf("ConsecutiveFailure = %d, want 0", checked.ConsecutiveFailure) } } func TestQueueCheckAllUsesBackgroundContext(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok")) })) defer server.Close() cfg, store := testStore(t) service := NewService(cfg, store) if _, err := store.UpsertSource(db.Source{ CategoryID: "test", CategoryName: "Test", SourceID: "slow-ok", Name: "Slow OK", Method: "GET", APIURL: server.URL, TimeoutMS: 1000, CheckIntervalSec: 300, Enabled: true, ClientVisible: true, }); err != nil { t.Fatal(err) } job := service.QueueCheckAll() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { current, ok := service.CheckJob(job.ID) if ok && current.Status == "completed" { if current.Stats["ok"] != 1 { t.Fatalf("stats = %#v, want one ok", current.Stats) } return } time.Sleep(20 * time.Millisecond) } t.Fatalf("job did not complete: %#v", job) } func TestSubscribeEventsBroadcastsToAllSubscribers(t *testing.T) { cfg, store := testStore(t) service := NewService(cfg, store) eventsA, unsubscribeA := service.SubscribeEvents() defer unsubscribeA() eventsB, unsubscribeB := service.SubscribeEvents() defer unsubscribeB() service.emit("source_check.completed", map[string]any{"jobId": "demo"}) assertEvent := func(name string, events <-chan Event) { t.Helper() select { case event := <-events: if event.Type != "source_check.completed" || event.Data["jobId"] != "demo" { t.Fatalf("%s received unexpected event: %#v", name, event) } case <-time.After(time.Second): t.Fatalf("%s did not receive broadcast event", name) } } assertEvent("subscriber A", eventsA) assertEvent("subscriber B", eventsB) } func TestCheckOneResolvesNestedJSONMediaURL(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "ok": true, "data": map[string]any{ "ignored": "https://example.test/readme.txt", "items": []map[string]any{ {"name": "first"}, {"cover": "/media/poster.webp"}, }, }, }) })) defer server.Close() cfg, store := testStore(t) service := NewService(cfg, store) item, err := store.UpsertSource(db.Source{ CategoryID: "image", CategoryName: "Image", SourceID: "json-cover", Name: "JSON Cover", Method: "GET", APIURL: server.URL + "/api/random", TimeoutMS: 3000, CheckIntervalSec: 300, Enabled: true, ClientVisible: true, }) if err != nil { t.Fatal(err) } if err := service.CheckOne(context.Background(), item); err != nil { t.Fatal(err) } checked, err := store.GetSourceBySourceID("json-cover") if err != nil { t.Fatal(err) } meta := parseHealthMeta(checked.LastError) if meta["resolvedUrl"] != server.URL+"/media/poster.webp" { t.Fatalf("resolvedUrl = %#v, want relative media URL", meta["resolvedUrl"]) } if meta["resolvedKey"] != "data.items.cover" { t.Fatalf("resolvedKey = %#v", meta["resolvedKey"]) } if meta["mediaType"] != "image" { t.Fatalf("mediaType = %#v, want image", meta["mediaType"]) } catalog, err := service.Catalog(false) if err != nil { t.Fatal(err) } categories := catalog["categories"].([]map[string]any) sub := categories[0]["subcategories"].([]map[string]any)[0] if sub["resolvedUrl"] != server.URL+"/media/poster.webp" { t.Fatalf("catalog resolvedUrl = %#v", sub["resolvedUrl"]) } endpoints, err := service.Endpoints(false) if err != nil { t.Fatal(err) } if endpoints[0]["resolvedUrl"] != server.URL+"/media/poster.webp" { t.Fatalf("endpoint resolvedUrl = %#v", endpoints[0]["resolvedUrl"]) } if endpoints[0]["urlTemplate"] != server.URL+"/api/random" { t.Fatalf("urlTemplate changed: %#v", endpoints[0]["urlTemplate"]) } } func TestCheckOneResolvesTextMediaURL(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") _, _ = w.Write([]byte(`play: https://cdn.example.test/video/sample.mp4`)) })) defer server.Close() cfg, store := testStore(t) service := NewService(cfg, store) item, err := store.UpsertSource(db.Source{ CategoryID: "video", CategoryName: "Video", SourceID: "text-video", Name: "Text Video", Method: "GET", APIURL: server.URL, TimeoutMS: 3000, CheckIntervalSec: 300, Enabled: true, ClientVisible: true, }) if err != nil { t.Fatal(err) } if err := service.CheckOne(context.Background(), item); err != nil { t.Fatal(err) } checked, err := store.GetSourceBySourceID("text-video") if err != nil { t.Fatal(err) } meta := parseHealthMeta(checked.LastError) if meta["resolvedUrl"] != "https://cdn.example.test/video/sample.mp4" { t.Fatalf("resolvedUrl = %#v", meta["resolvedUrl"]) } if meta["mediaType"] != "video" { t.Fatalf("mediaType = %#v, want video", meta["mediaType"]) } } func testStore(t *testing.T) (*config.Config, *db.Store) { t.Helper() dir := t.TempDir() cfg := &config.Config{ BaseDir: dir, StorageDir: filepath.Join(dir, "storage"), DataDir: filepath.Join(dir, "data"), UpdatePublicDir: filepath.Join(dir, "data", "update", "public"), DownloadsDir: filepath.Join(dir, "data", "update", "public", "downloads"), BaseURL: "https://update.ymhut.cn", Database: config.DatabaseConfig{ Provider: "sqlite", SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"), HealthIntervalSec: 30, }, } store, err := db.Open(cfg) if err != nil { t.Fatal(err) } t.Cleanup(func() { store.Close() }) return cfg, store }