package httpapi_test import ( "context" "database/sql" "encoding/json" "fmt" "io" "log/slog" "net/http" "net/http/httptest" "net/url" "strings" "testing" "git.vakhrushev.me/av/jellybit/internal/httpapi" "git.vakhrushev.me/av/jellybit/internal/ingest" "git.vakhrushev.me/av/jellybit/internal/layout" "git.vakhrushev.me/av/jellybit/internal/magnet" "git.vakhrushev.me/av/jellybit/internal/recognize" "git.vakhrushev.me/av/jellybit/internal/store" "git.vakhrushev.me/av/jellybit/internal/worker" ) // Валидные lowercase-ULID для маршрутов (pathID валидирует формат). const ( tid = "01arz3ndektsv4rrffq69g5fav" tid2 = "01arz3ndektsv4rrffq69g5fb0" cid = "01arz3ndektsv4rrffq69g5fc0" cid2 = "01arz3ndektsv4rrffq69g5fd0" ) type fakeIngestor struct { res ingest.Result err error lastReq ingest.Request } func (f *fakeIngestor) Ingest(_ context.Context, req ingest.Request) (ingest.Result, error) { f.lastReq = req return f.res, f.err } type fakeCommander struct { cancelled []string retried []string err error } func (f *fakeCommander) Cancel(_ context.Context, id string) error { if f.err != nil { return f.err } f.cancelled = append(f.cancelled, id) return nil } func (f *fakeCommander) Retry(_ context.Context, id string) error { if f.err != nil { return f.err } f.retried = append(f.retried, id) return nil } type fakeReader struct { list []store.Download get *store.Download pageErr error total int // общее число под фильтром (для пагинации); 0 → len(list) lastFilter store.ListFilter // последний переданный фильтр } func (f *fakeReader) ListDownloads(_ context.Context) ([]store.Download, error) { return f.list, nil } func (f *fakeReader) ListDownloadsPage(_ context.Context, flt store.ListFilter) ([]store.Download, int, error) { f.lastFilter = flt if f.pageErr != nil { return nil, 0, f.pageErr } total := f.total if total == 0 { total = len(f.list) } return f.list, total, nil } func (f *fakeReader) GetDownload(_ context.Context, id string) (*store.Download, error) { if f.get != nil { return f.get, nil } return &store.Download{ID: id, State: store.StateCancelled}, nil } func (f *fakeReader) LayoutSizeByDownload(_ context.Context, _ []string) (map[string]int64, error) { return nil, nil } func newServer(t *testing.T, d httpapi.Deps) *httptest.Server { t.Helper() if d.Logger == nil { d.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) } h, err := httpapi.NewRouter(d) if err != nil { t.Fatalf("NewRouter: %v", err) } srv := httptest.NewServer(h) t.Cleanup(srv.Close) return srv } func TestAPIAdd(t *testing.T) { ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, Infohashes: []string{"abc"}, State: store.StateDownloading}} srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}}) resp, err := http.Post(srv.URL+"/api/downloads", "application/json", strings.NewReader(`{"source":"magnet:?xt=urn:btih:abc","context":"Дюна"}`)) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { t.Fatalf("status = %d, want 201", resp.StatusCode) } var got map[string]any _ = json.NewDecoder(resp.Body).Decode(&got) if got["id"] != tid || got["state"] != "downloading" { t.Errorf("body = %v", got) } if ing.lastReq.Context != "Дюна" { t.Errorf("контекст не проброшен: %q", ing.lastReq.Context) } } func TestAPIAddBadInput(t *testing.T) { // Источник не magnet → ingest оборачивает magnet.ErrNotMagnet; транспорт // классифицирует это как 400 (некорректный источник). ing := &fakeIngestor{err: fmt.Errorf("ingest: parse source: %w", magnet.ErrNotMagnet)} srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}}) resp, err := http.Post(srv.URL+"/api/downloads", "application/json", strings.NewReader(`{"source":"x"}`)) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400", resp.StatusCode) } } // F7: oversized `.torrent` — ingest.ErrTorrentTooLarge классифицируется как 400 // (промах ввода), а не 500. func TestAPIAddTorrentTooLarge(t *testing.T) { ing := &fakeIngestor{err: fmt.Errorf("ingest: torrent too large: %w", ingest.ErrTorrentTooLarge)} srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}}) resp, err := http.Post(srv.URL+"/api/downloads", "application/json", strings.NewReader(`{"source":"x"}`)) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400", resp.StatusCode) } } func TestAPIList(t *testing.T) { reader := &fakeReader{list: []store.Download{ {ID: tid2, SourceType: store.SourceMagnet, State: store.StateCompleted, Infohashes: []store.Infohash{{DownloadID: tid2, Infohash: "abc", Kind: store.HashV1}}}, {ID: tid, SourceType: store.SourceMagnet, State: store.StateDownloading}, }} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader}) resp, err := http.Get(srv.URL + "/api/downloads") if err != nil { t.Fatal(err) } defer resp.Body.Close() var got []map[string]any _ = json.NewDecoder(resp.Body).Decode(&got) if len(got) != 2 { t.Fatalf("len = %d, want 2", len(got)) } hashes, _ := got[0]["infohashes"].([]any) if got[0]["state"] != "completed" || len(hashes) != 1 || hashes[0] != "abc" { t.Errorf("first = %v", got[0]) } } func TestAPICancel(t *testing.T) { cmd := &fakeCommander{} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}}) resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } if len(cmd.cancelled) != 1 || cmd.cancelled[0] != tid { t.Errorf("cancel вызван неверно: %v", cmd.cancelled) } } func TestUIRetry(t *testing.T) { cmd := &fakeCommander{} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}}) resp, err := http.Post(srv.URL+"/ui/downloads/"+tid+"/retry", "application/x-www-form-urlencoded", nil) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { // 303 → редирект на / → 200 t.Fatalf("status = %d, want 200", resp.StatusCode) } if len(cmd.retried) != 1 || cmd.retried[0] != tid { t.Errorf("retry вызван неверно: %v", cmd.retried) } } func TestAPICommandConflict(t *testing.T) { // Конфликт состояния (worker.ErrConflict) → 409, не 500. cmd := &fakeCommander{err: fmt.Errorf("cancel: download 5 in wrong state: %w", worker.ErrConflict)} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}}) resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusConflict { t.Fatalf("status = %d, want 409", resp.StatusCode) } var got map[string]any _ = json.NewDecoder(resp.Body).Decode(&got) if got["download_id"] != tid { t.Errorf("download_id корреляции нет: %v", got) } } func TestAPICommandNotReady(t *testing.T) { // Недокачанный источник (worker.ErrNotReady) → 409 с конкретным сообщением // «торрент ещё качается», а не генерик «действие недоступно». cmd := &fakeCommander{err: fmt.Errorf("apply: торрент ещё качается: %w", worker.ErrNotReady)} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}}) resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusConflict { t.Fatalf("status = %d, want 409", resp.StatusCode) } var got map[string]any _ = json.NewDecoder(resp.Body).Decode(&got) if msg, _ := got["error"].(string); !strings.Contains(msg, "качается") { t.Errorf("error = %q, want содержащее «качается»", msg) } } func TestAPICommandInvalidInput(t *testing.T) { // Промах пользователя (worker.ErrInvalidInput) → 400, не 500. cmd := &fakeCommander{err: fmt.Errorf("choose candidate: invalid id %q: %w", "foo", worker.ErrInvalidInput)} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}}) resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400", resp.StatusCode) } } func TestAPICommandCollision(t *testing.T) { // Коллизия цели (layout.ErrCollision) → 409 (штатно ушло в review), не 500. cmd := &fakeCommander{err: fmt.Errorf("apply: %w", layout.ErrCollision)} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}}) resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusConflict { t.Fatalf("status = %d, want 409", resp.StatusCode) } var got map[string]any _ = json.NewDecoder(resp.Body).Decode(&got) if msg, _ := got["error"].(string); !strings.Contains(msg, "уже существует") { t.Errorf("error = %q, want содержащее «уже существует»", msg) } } func TestIndexRenders(t *testing.T) { reader := &fakeReader{list: []store.Download{ {ID: tid, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc", State: store.StateDownloading}, }} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader}) resp, err := http.Get(srv.URL + "/") if err != nil { t.Fatal(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d", resp.StatusCode) } if !strings.Contains(string(body), "jellybit") || !strings.Contains(string(body), "downloading") { t.Error("страница не содержит ожидаемого контента") } } // Карточка catched несёт самозавершающийся htmx-поллинг (обновится без // перезагрузки при переходе в downloading) и человекочитаемый бейдж. func TestCatchedCardSelfPolls(t *testing.T) { reader := &fakeReader{get: &store.Download{ ID: tid, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc", State: store.StateCatched, }} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader}) resp, err := http.Get(srv.URL + "/fragments/downloads/" + tid + "/card") if err != nil { t.Fatal(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d", resp.StatusCode) } s := string(body) if !strings.Contains(s, `hx-get="/fragments/downloads/`+tid+`/card"`) { t.Errorf("карточка catched без самополлинга: %s", s) } if !strings.Contains(s, "принято, добавляется") { t.Errorf("нет подписи бейджа catched: %s", s) } } type ingestErr string func (e ingestErr) Error() string { return string(e) } func TestIndexTitleFromDisplayName(t *testing.T) { // Заголовок карточки — имя раздачи (display_name), а не сырой magnet. reader := &fakeReader{list: []store.Download{{ ID: tid, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc", DisplayName: "Дюна: Часть вторая (2024)", State: store.StateDownloading, }}} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader}) resp, err := http.Get(srv.URL + "/") if err != nil { t.Fatal(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if !strings.Contains(string(body), "Дюна: Часть вторая (2024)") { t.Error("заголовок карточки не из display_name") } if strings.Contains(string(body), "magnet:?xt=urn:btih:abc") { t.Error("сырой magnet просочился в список вместо имени раздачи") } } func TestIndexListFilterParams(t *testing.T) { // GET-параметры списка транслируются в store.ListFilter (серверная выборка). reader := &fakeReader{total: 100} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader}) resp, err := http.Get(srv.URL + "/?f=review&q=дюна&page=2&all=1") if err != nil { t.Fatal(err) } defer resp.Body.Close() f := reader.lastFilter if f.Group != store.GroupReview { t.Errorf("Group = %q, want review", f.Group) } if f.Query != "дюна" { t.Errorf("Query = %q", f.Query) } if !f.IncludeDeleted { t.Errorf("IncludeDeleted = false, want true (all=1)") } if f.Limit != 25 || f.Offset != 25 { // page 2, размер 25 t.Errorf("Limit/Offset = %d/%d, want 25/25", f.Limit, f.Offset) } } func TestIndexPageClamp(t *testing.T) { reader := &fakeReader{} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader}) resp, err := http.Get(srv.URL + "/?page=0") if err != nil { t.Fatal(err) } defer resp.Body.Close() if reader.lastFilter.Offset != 0 { t.Errorf("page=0 → Offset %d, want 0 (клэмп на 1)", reader.lastFilter.Offset) } } // detailReviewData — ReviewData для страницы /download/{id} с матчем метабазы. func detailReviewData(provider, providerID, chosenID, chosenURL string) *worker.ReviewData { rd := &worker.ReviewData{ Download: store.Download{ ID: tid, State: store.StateReview, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:deadbeef", DisplayName: "Дюна", Infohashes: []store.Infohash{{DownloadID: tid, Infohash: "deadbeef", Kind: store.HashV1}}, }, Recognition: &store.Recognition{ID: "1", DownloadID: tid, IsCurrent: true}, Plan: recognize.Plan{ Type: recognize.MediaMovie, Title: "Дюна", Year: 2024, Files: []recognize.PlanFile{{Src: "dune.mkv", Role: recognize.RoleMain}}, }, Preview: []layout.Link{{Src: "dune.mkv", Dst: "/srv/media/movies/Дюна (2024)/Дюна (2024).mkv"}}, Provider: provider, ProviderID: providerID, } if chosenID != "" { rd.Candidates = []store.MetadataCandidate{{ ID: cid, Provider: "tmdb", ProviderID: chosenID, Chosen: true, URL: store.NullString(chosenURL), }} } return rd } func TestDownloadMatchLinkFromCandidate(t *testing.T) { // Выбранный кандидат совпадает с эффективным provider+id → ссылка кандидата. rv := &fakeReviewer{data: detailReviewData("tmdb", "693134", "693134", "https://custom.example/x")} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: &fakeReader{}, Reviewer: rv}) resp, err := http.Get(srv.URL + "/download/" + tid) if err != nil { t.Fatal(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if !strings.Contains(string(body), "https://custom.example/x") { t.Error("ссылка на запись метабазы (URL кандидата) не показана") } } func TestDownloadMatchLinkMismatchUsesBuilt(t *testing.T) { // Эффективный id (999, ручной override) отличается от выбранного кандидата // (693134) → ссылка строится по эффективному id, а не берётся у кандидата. rv := &fakeReviewer{data: detailReviewData("tmdb", "999", "693134", "https://custom.example/x")} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: &fakeReader{}, Reviewer: rv}) resp, err := http.Get(srv.URL + "/download/" + tid) if err != nil { t.Fatal(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if !strings.Contains(string(body), "https://www.themoviedb.org/movie/999") { t.Error("ссылка не построена по эффективному provider_id (movie/999)") } if strings.Contains(string(body), "https://custom.example/x") { t.Error("взят URL кандидата, не совпадающего с эффективным id") } } func TestDownloadTorrentInfoBlock(t *testing.T) { rv := &fakeReviewer{data: detailReviewData("tmdb", "693134", "693134", "https://x/y")} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: &fakeReader{}, Reviewer: rv}) resp, err := http.Get(srv.URL + "/download/" + tid) if err != nil { t.Fatal(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) for _, want := range []string{"Информация о торренте", "magnet:?xt=urn:btih:deadbeef", "Дюна"} { if !strings.Contains(string(body), want) { t.Errorf("страница загрузки не содержит %q", want) } } } // --- Ревью --- type fakeReviewer struct { data *worker.ReviewData applyErr error refined map[string]string ignored map[string]string chosen map[string]string providerSet map[string]string sourceAdded map[string]string applied []string deferred []string undone []string deleted []string dismissed []string relinked []string rerecognized []string cleared []string nameRefreshed []string } func (f *fakeReviewer) ReviewData(_ context.Context, _ string) (*worker.ReviewData, error) { return f.data, nil } func (f *fakeReviewer) Apply(_ context.Context, id string) error { if f.applyErr != nil { return f.applyErr } f.applied = append(f.applied, id) return nil } func (f *fakeReviewer) Refine(_ context.Context, id string, hint string) error { if f.refined == nil { f.refined = map[string]string{} } f.refined[id] = hint return nil } func (f *fakeReviewer) IgnoreFile(_ context.Context, id string, src string) error { if f.ignored == nil { f.ignored = map[string]string{} } f.ignored[id] = src return nil } func (f *fakeReviewer) Defer(_ context.Context, id string) error { f.deferred = append(f.deferred, id) return nil } func (f *fakeReviewer) Undo(_ context.Context, id string) error { f.undone = append(f.undone, id) return nil } func (f *fakeReviewer) Delete(_ context.Context, id string) error { f.deleted = append(f.deleted, id) return nil } func (f *fakeReviewer) Dismiss(_ context.Context, id string) error { f.dismissed = append(f.dismissed, id) return nil } func (f *fakeReviewer) Relink(_ context.Context, id string) error { f.relinked = append(f.relinked, id) return nil } func (f *fakeReviewer) Rerecognize(_ context.Context, id string) error { f.rerecognized = append(f.rerecognized, id) return nil } func (f *fakeReviewer) ChooseCandidate(_ context.Context, id, candidateID string) error { if f.chosen == nil { f.chosen = map[string]string{} } f.chosen[id] = candidateID return nil } func (f *fakeReviewer) SetProviderID(_ context.Context, id string, provider, providerID string) error { if f.providerSet == nil { f.providerSet = map[string]string{} } f.providerSet[id] = provider + ":" + providerID return nil } func (f *fakeReviewer) AddManualSource(_ context.Context, id, provider, providerID string) error { if f.sourceAdded == nil { f.sourceAdded = map[string]string{} } f.sourceAdded[id] = provider + ":" + providerID return nil } func (f *fakeReviewer) ClearProvider(_ context.Context, id string) error { f.cleared = append(f.cleared, id) return nil } func (f *fakeReviewer) RefreshDisplayName(_ context.Context, id string) error { f.nameRefreshed = append(f.nameRefreshed, id) return nil } func seriesReviewData() *worker.ReviewData { s, e := 2, 1 plan := recognize.Plan{ Type: recognize.MediaSeries, Title: "Фарго", Year: 2015, Files: []recognize.PlanFile{ {Src: "Fargo/e1.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e}, }, } preview := []layout.Link{ {Src: "Fargo/e1.mkv", Dst: "/srv/media/series/Фарго (2015)/Season 02/Фарго (2015) S02E01.mkv"}, } return &worker.ReviewData{ Download: store.Download{ID: tid, State: store.StateReview, SourceRef: "magnet:?xt=urn:btih:abc"}, Recognition: &store.Recognition{ ID: "1", DownloadID: tid, IsCurrent: true, Reasons: `["нет матча в базе"]`, }, Plan: plan, Preview: preview, Candidates: []store.MetadataCandidate{ {ID: cid, Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"), Year: sql.NullInt64{Int64: 2014, Valid: true}}, {ID: cid2, Provider: "tmdb", ProviderID: "60622", Title: store.NullString("Fargo")}, }, // Единый список источников: нейронка активна (матча нет) + кандидаты. Sources: []worker.SourceOption{ {Kind: worker.SourceNeural, Provider: "none", Title: "Фарго", Year: 2015, Type: "series", Active: true, Plan: plan, Preview: preview}, {Kind: worker.SourceCandidate, CandidateID: cid, Provider: "tvdb", ProviderID: "269613", URL: "https://www.thetvdb.com/dereferrer/series/269613", Title: "Fargo", Year: 2014, Type: "series", Plan: plan, Preview: preview}, {Kind: worker.SourceCandidate, CandidateID: cid2, Provider: "tmdb", ProviderID: "60622", Title: "Fargo", Type: "series", Plan: plan, Preview: preview}, }, Hints: []string{"второй сезон"}, } } // noRedirectClient — не следует за 3xx, чтобы проверять Location. func noRedirectClient() *http.Client { return &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} } // Веб-приём при дедупе (в т.ч. на «спящую» desync-запись) ведёт на страницу // существующей записи, а не на список — пользователь видит, что нового не завели. func TestUIAddDeduplicatedRedirectsToRecord(t *testing.T) { ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, State: store.StateTargetMissing, Deduplicated: true}} srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}}) req, _ := http.NewRequest(http.MethodPost, srv.URL+"/ui/downloads", strings.NewReader("source=magnet:?xt=urn:btih:abc")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := noRedirectClient().Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusSeeOther { t.Fatalf("status = %d, want 303", resp.StatusCode) } if loc := resp.Header.Get("Location"); loc != "/download/"+tid { t.Errorf("Location = %q, want /download/%s", loc, tid) } } func TestReviewRenders(t *testing.T) { rv := &fakeReviewer{data: seriesReviewData()} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: &fakeReader{}, Reviewer: rv}) resp, err := http.Get(srv.URL + "/review/" + tid) if err != nil { t.Fatal(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d", resp.StatusCode) } for _, want := range []string{"Фарго", "нет матча в базе", "Fargo/e1.mkv", "Season 02", "Применить", "Уточнить", "Источник и раскладка", "269613", "распознано нейронкой", "Добавить", "Сезон 2"} { if !strings.Contains(string(body), want) { t.Errorf("страница ревью не содержит %q", want) } } // Кнопки «выбрать» больше нет — выбор одним кликом по радио. if strings.Contains(string(body), ">выбрать<") { t.Error("страница ревью всё ещё содержит кнопку «выбрать»") } } func TestReviewShowsMatchLink(t *testing.T) { // Подтверждённый матч в списке источников — ссылкой на запись. rd := seriesReviewData() rd.Provider = "tvdb" rd.ProviderID = "269613" // Активен кандидат tvdb (индекс 1: нейронка, tvdb, tmdb). rd.Sources[0].Active = false rd.Sources[1].Active = true rv := &fakeReviewer{data: rd} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: &fakeReader{}, Reviewer: rv}) resp, err := http.Get(srv.URL + "/review/" + tid) if err != nil { t.Fatal(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if !strings.Contains(string(body), "https://www.thetvdb.com/dereferrer/series/269613") { t.Error("строка «Выбрано» не содержит ссылку на запись метабазы") } } func TestChooseCandidate(t *testing.T) { rv := &fakeReviewer{data: seriesReviewData()} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: &fakeReader{}, Reviewer: rv}) resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/"+tid+"/candidate", map[string][]string{"candidate_id": {cid}}) if err != nil { t.Fatal(err) } defer resp.Body.Close() if rv.chosen[tid] != cid { t.Errorf("ChooseCandidate получил %q", rv.chosen[tid]) } if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/"+tid) { t.Errorf("Location = %q", loc) } } func TestSetProviderAndNoBase(t *testing.T) { rv := &fakeReviewer{data: seriesReviewData()} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: &fakeReader{}, Reviewer: rv}) cl := noRedirectClient() if _, err := cl.PostForm(srv.URL+"/ui/downloads/"+tid+"/provider", map[string][]string{"provider": {"tvdb"}, "provider_id": {"269613"}}); err != nil { t.Fatal(err) } if rv.providerSet[tid] != "tvdb:269613" { t.Errorf("SetProviderID получил %q", rv.providerSet[tid]) } if _, err := cl.Post(srv.URL+"/ui/downloads/"+tid+"/nobase", "", nil); err != nil { t.Fatal(err) } if len(rv.cleared) != 1 || rv.cleared[0] != tid { t.Errorf("ClearProvider = %v", rv.cleared) } } func TestAddManualSource(t *testing.T) { rv := &fakeReviewer{data: seriesReviewData()} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: &fakeReader{}, Reviewer: rv}) cl := noRedirectClient() // URL записи → провайдер+id разбираются на границе, воркер получает их. if _, err := cl.PostForm(srv.URL+"/ui/downloads/"+tid+"/source", map[string][]string{"provider": {"tmdb"}, "provider_id": {"https://www.themoviedb.org/tv/60622-fargo"}}); err != nil { t.Fatal(err) } if rv.sourceAdded[tid] != "tmdb:60622" { t.Errorf("AddManualSource получил %q, want tmdb:60622", rv.sourceAdded[tid]) } } func TestRefreshNameNonHTMXRedirectsToDownload(t *testing.T) { rv := &fakeReviewer{data: seriesReviewData()} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: &fakeReader{}, Reviewer: rv}) resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/"+tid+"/refresh-name", nil) if err != nil { t.Fatal(err) } defer resp.Body.Close() if len(rv.nameRefreshed) != 1 || rv.nameRefreshed[0] != tid { t.Errorf("RefreshDisplayName вызван = %v", rv.nameRefreshed) } // Без htmx — PRG на страницу загрузки (увидеть новое имя здесь же), не на список. if loc := resp.Header.Get("Location"); loc != "/download/"+tid { t.Errorf("Location = %q, want /download/%s", loc, tid) } } func TestRefreshNameHTMXSwapsMain(t *testing.T) { rv := &fakeReviewer{data: seriesReviewData()} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: &fakeReader{}, Reviewer: rv}) req, _ := http.NewRequest(http.MethodPost, srv.URL+"/ui/downloads/"+tid+"/refresh-name", nil) req.Header.Set("HX-Request", "true") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) if !strings.Contains(string(body), `id="download-main"`) { t.Error("htmx-ответ не содержит фрагмент download-main") } if len(rv.nameRefreshed) != 1 { t.Errorf("RefreshDisplayName вызван = %v", rv.nameRefreshed) } } // Стоп-кран «Закрыть» рендерится в danger-зоне для терминального состояния // (target_missing) и постит на /dismiss. func TestDownloadPageShowsDismissButtonOnTerminal(t *testing.T) { rd := seriesReviewData() rd.Download.State = store.StateTargetMissing rv := &fakeReviewer{data: rd} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: &fakeReader{}, Reviewer: rv}) resp, err := http.Get(srv.URL + "/download/" + tid) if err != nil { t.Fatal(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if !strings.Contains(string(body), "/dismiss") || !strings.Contains(string(body), "Закрыть загрузку") { t.Error("кнопка «Закрыть» не показана в danger-зоне для target_missing") } } // POST /ui/downloads/{id}/dismiss вызывает Reviewer.Dismiss. func TestUIDismiss(t *testing.T) { rv := &fakeReviewer{data: seriesReviewData()} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: &fakeReader{}, Reviewer: rv}) resp, err := http.Post(srv.URL+"/ui/downloads/"+tid+"/dismiss", "application/x-www-form-urlencoded", nil) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { // 303 → редирект на / → 200 t.Fatalf("status = %d, want 200", resp.StatusCode) } if len(rv.dismissed) != 1 || rv.dismissed[0] != tid { t.Errorf("Dismiss вызван неверно: %v", rv.dismissed) } } func TestDownloadPageShowsRefreshNameButtonOnDone(t *testing.T) { // Кнопка гейтится наличием распознавания, не состоянием ревью: на терминальном // done (есть план) она всё равно доступна. rd := seriesReviewData() rd.Download.State = store.StateDone rv := &fakeReviewer{data: rd} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: &fakeReader{}, Reviewer: rv}) resp, err := http.Get(srv.URL + "/download/" + tid) if err != nil { t.Fatal(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if !strings.Contains(string(body), "/refresh-name") { t.Error("кнопка «Обновить имя» не показана на done при наличии распознавания") } } func TestDownloadPageHidesRefreshNameWithoutRecognition(t *testing.T) { rv := &fakeReviewer{data: &worker.ReviewData{ Download: store.Download{ID: tid, State: store.StateDownloading, SourceRef: "magnet:?xt=urn:btih:abc"}, }} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: &fakeReader{}, Reviewer: rv}) resp, err := http.Get(srv.URL + "/download/" + tid) if err != nil { t.Fatal(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if strings.Contains(string(body), "/refresh-name") { t.Error("кнопка «Обновить имя» показана без распознавания") } } func TestAddManualSource_RejectsBadURL(t *testing.T) { rv := &fakeReviewer{data: seriesReviewData()} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: &fakeReader{}, Reviewer: rv}) // URL TVDB со slug (без числового id) не разбирается → отклонён, источник // не добавлен, пользователю уходит сообщение (не «внутренняя ошибка»). resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/"+tid+"/source", map[string][]string{"provider": {"tvdb"}, "provider_id": {"https://www.thetvdb.com/series/fargo"}}) if err != nil { t.Fatal(err) } defer resp.Body.Close() if _, called := rv.sourceAdded[tid]; called { t.Errorf("невалидный ввод не должен вызывать AddManualSource: %v", rv.sourceAdded) } loc := resp.Header.Get("Location") if !strings.HasPrefix(loc, "/review/"+tid) || !strings.Contains(loc, "err=") { t.Errorf("Location = %q, want редирект на ревью с ?err=", loc) } if strings.Contains(loc, "%D0%B2%D0%BD%D1%83%D1%82%D1%80%D0%B5%D0%BD") { // «внутрен…» t.Errorf("ошибка ввода подана как внутренняя: %q", loc) } } // TestAddManualSource_HTMXError: невалидный ручной ввод на htmx-пути возвращает // партиал блока с ошибкой (не редирект, не «внутренняя ошибка»), источник не // добавлен. func TestAddManualSource_HTMXError(t *testing.T) { rv := &fakeReviewer{data: seriesReviewData()} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: &fakeReader{}, Reviewer: rv}) form := url.Values{"provider": {"tvdb"}, "provider_id": {"https://www.thetvdb.com/series/fargo"}} req, err := http.NewRequest(http.MethodPost, srv.URL+"/ui/downloads/"+tid+"/source", strings.NewReader(form.Encode())) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("HX-Request", "true") resp, err := noRedirectClient().Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (партиал с ошибкой)", resp.StatusCode) } if _, called := rv.sourceAdded[tid]; called { t.Errorf("невалидный ввод не должен вызывать AddManualSource: %v", rv.sourceAdded) } if !strings.Contains(string(body), `id="source-block"`) { t.Error("htmx-ответ не содержит блок источника") } if !strings.Contains(string(body), "block-error") { t.Error("htmx-ответ не содержит баннер ошибки блока") } if strings.Contains(string(body), "внутренняя ошибка") { t.Error("ошибка ввода подана как внутренняя") } } func TestApplyRedirectsToIndex(t *testing.T) { rv := &fakeReviewer{data: seriesReviewData()} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: &fakeReader{}, Reviewer: rv}) resp, err := noRedirectClient().Post(srv.URL+"/ui/downloads/"+tid+"/apply", "", nil) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusSeeOther { t.Fatalf("status = %d, want 303", resp.StatusCode) } if loc := resp.Header.Get("Location"); loc != "/" { t.Errorf("Location = %q, want /", loc) } if len(rv.applied) != 1 { t.Errorf("Apply не вызван: %v", rv.applied) } } func TestApplyCollisionRedirectsToReview(t *testing.T) { rv := &fakeReviewer{data: seriesReviewData(), applyErr: ingestErr("collision")} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: &fakeReader{}, Reviewer: rv}) resp, err := noRedirectClient().Post(srv.URL+"/ui/downloads/"+tid+"/apply", "", nil) if err != nil { t.Fatal(err) } defer resp.Body.Close() if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/"+tid) { t.Errorf("Location = %q, want /review/{id}?err=...", loc) } } func TestRefinePostsHint(t *testing.T) { rv := &fakeReviewer{data: seriesReviewData()} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: &fakeReader{}, Reviewer: rv}) resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/"+tid+"/refine", map[string][]string{"hint": {"это второй сезон"}}) if err != nil { t.Fatal(err) } defer resp.Body.Close() if rv.refined[tid] != "это второй сезон" { t.Errorf("Refine получил %q", rv.refined[tid]) } if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/"+tid) { t.Errorf("Location = %q", loc) } } func TestIgnoreFile(t *testing.T) { rv := &fakeReviewer{data: seriesReviewData()} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: &fakeReader{}, Reviewer: rv}) cl := noRedirectClient() if _, err := cl.PostForm(srv.URL+"/ui/downloads/"+tid+"/ignore", map[string][]string{"src": {"Fargo/sample.mkv"}}); err != nil { t.Fatal(err) } if rv.ignored[tid] != "Fargo/sample.mkv" { t.Errorf("IgnoreFile получил %q", rv.ignored[tid]) } } // TestChooseCandidateHTMX: на htmx-запрос выбор возвращает партиал блока // источника (а не полную страницу и не редирект), обновлённый под выбор. func TestChooseCandidateHTMX(t *testing.T) { rv := &fakeReviewer{data: seriesReviewData()} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: &fakeReader{}, Reviewer: rv}) req, err := http.NewRequest(http.MethodPost, srv.URL+"/ui/downloads/"+tid+"/candidate", strings.NewReader("candidate_id="+cid)) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("HX-Request", "true") resp, err := noRedirectClient().Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (партиал, не редирект)", resp.StatusCode) } if rv.chosen[tid] != cid { t.Errorf("ChooseCandidate получил %q", rv.chosen[tid]) } // Ответ — партиал блока, а не полная страница. if strings.Contains(string(body), "") { t.Error("htmx-ответ должен быть партиалом, а не полной страницей") } if !strings.Contains(string(body), `id="source-block"`) { t.Error("htmx-ответ не содержит корневой контейнер блока #source-block") } } func TestUndoAndDefer(t *testing.T) { rv := &fakeReviewer{data: seriesReviewData()} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: &fakeReader{}, Reviewer: rv}) cl := noRedirectClient() if _, err := cl.Post(srv.URL+"/ui/downloads/"+tid+"/undo", "", nil); err != nil { t.Fatal(err) } if _, err := cl.Post(srv.URL+"/ui/downloads/"+tid+"/defer", "", nil); err != nil { t.Fatal(err) } if len(rv.undone) != 1 || len(rv.deferred) != 1 { t.Errorf("undo=%v defer=%v", rv.undone, rv.deferred) } } func TestRelink(t *testing.T) { rv := &fakeReviewer{data: seriesReviewData()} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: &fakeReader{}, Reviewer: rv}) cl := noRedirectClient() if _, err := cl.Post(srv.URL+"/ui/downloads/"+tid+"/relink", "", nil); err != nil { t.Fatal(err) } if len(rv.relinked) != 1 || rv.relinked[0] != tid { t.Errorf("relinked = %v, want [%s]", rv.relinked, tid) } } func TestRerecognize(t *testing.T) { rv := &fakeReviewer{data: seriesReviewData()} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: &fakeReader{}, Reviewer: rv}) cl := noRedirectClient() if _, err := cl.Post(srv.URL+"/ui/downloads/"+tid+"/rerecognize", "", nil); err != nil { t.Fatal(err) } if len(rv.rerecognized) != 1 || rv.rerecognized[0] != tid { t.Errorf("rerecognized = %v, want [%s]", rv.rerecognized, tid) } }