package web import ( "errors" "net/http" "net/http/httptest" "net/url" "regexp" "slices" "strings" "testing" "time" "gitea.kazu634.com/kazu634/tver/internal/store" "gitea.kazu634.com/kazu634/tver/internal/tver" ) func postForm(handler http.Handler, path string, values url.Values) *httptest.ResponseRecorder { r := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.Header.Set("HX-Request", "true") w := httptest.NewRecorder() handler.ServeHTTP(w, r) return w } func TestEnqueueUsesCachedEpisodeAndDuplicateStatus(t *testing.T) { fetcher := &fakeFetcher{episodes: []tver.Episode{{ID: "ep1", SeriesID: "series1", SeriesTitle: "正しい番組", Title: "正しい回", BroadcasterName: "正しい局", Duration: 1234, EndAt: 1791061200, ThumbnailPath: "/correct.jpg"}}} jobs := &fakeJobStore{} handler := newTestHandlerWithStore(fetcher, jobs, nil) getPage(handler, "/") if jobs.statusCalls != 1 || len(jobs.statusIDs) != 1 || jobs.statusIDs[0] != "ep1" { t.Fatalf("状態の一括取得: calls=%d ids=%v", jobs.statusCalls, jobs.statusIDs) } values := url.Values{"genre": {"drama"}, "episode_id": {"ep1"}, "series_title": {"偽番組"}, "title": {"偽回"}} response := postForm(handler, "/episodes/enqueue", values) if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "待機中") || !strings.Contains(response.Body.String(), `class="episode-action"`) || strings.Contains(response.Body.String(), "ダウンロード") { t.Fatalf("登録後の一覧: %s", page.Body.String()) } } func TestEnqueueRejectsInvalidOrExpiredCache(t *testing.T) { now := time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC) fetcher := &fakeFetcher{episodes: []tver.Episode{{ID: "ep1", Duration: 200}}} jobs := &fakeJobStore{} handler := newTestHandlerWithStore(fetcher, jobs, func() time.Time { return now }) valid := url.Values{"genre": {"drama"}, "episode_id": {"ep1"}} if got := postForm(handler, "/episodes/enqueue", valid).Code; got != http.StatusNotFound { t.Fatalf("未取得キャッシュ: %d", got) } getPage(handler, "/") for _, values := range []url.Values{ {"genre": {"news"}, "episode_id": {"ep1"}}, {"genre": {"drama"}}, } { if got := postForm(handler, "/episodes/enqueue", values).Code; got != http.StatusBadRequest { t.Errorf("不正なフォーム: %d", got) } } if got := postForm(handler, "/episodes/enqueue", url.Values{"genre": {"drama"}, "episode_id": {"unknown"}}).Code; got != http.StatusNotFound { t.Fatalf("同じジャンルの未収録 ID: %d", got) } if got := postForm(handler, "/episodes/enqueue", url.Values{"genre": {"variety"}, "episode_id": {"ep1"}}).Code; got != http.StatusNotFound { t.Fatalf("別ジャンルのキャッシュ: %d", got) } now = now.Add(newerCacheTTL) if got := postForm(handler, "/episodes/enqueue", valid).Code; got != http.StatusNotFound { t.Fatalf("期限切れキャッシュ: %d", got) } if jobs.enqueueCalls != 0 || len(fetcher.calls) != 1 { t.Fatalf("無効な入力で登録・再取得: enqueue=%d fetch=%v", jobs.enqueueCalls, fetcher.calls) } } func TestEnqueueUsesSelectedGenreCacheForSameEpisodeID(t *testing.T) { fetcher := &fakeFetcher{episodesByGenre: map[string][]tver.Episode{ "drama": {{ID: "shared", SeriesTitle: "ドラマの番組", Duration: 200}}, "variety": {{ID: "shared", SeriesTitle: "バラエティの番組", Duration: 200}}, }} jobs := &fakeJobStore{} handler := newTestHandlerWithStore(fetcher, jobs, nil) getPage(handler, "/") getPage(handler, "/?genre=variety") response := postForm(handler, "/episodes/enqueue", url.Values{ "genre": {"variety"}, "episode_id": {"shared"}, "series_title": {"偽タイトル"}, }) if response.Code != http.StatusOK || jobs.lastEpisode.SeriesTitle != "バラエティの番組" || jobs.enqueueCalls != 1 || !slices.Equal(fetcher.calls, []string{"drama", "variety"}) { t.Fatalf("選択ジャンルのキャッシュ: status=%d episode=%+v calls=%v", response.Code, jobs.lastEpisode, fetcher.calls) } } func TestNewerStatusesAndRetry(t *testing.T) { fetcher := &fakeFetcher{episodes: []tver.Episode{{ID: "failed", Duration: 200}, {ID: "canceled", Duration: 200}, {ID: "running", Duration: 200}, {ID: "done", Duration: 200}}} jobs := &fakeJobStore{jobs: map[string]store.Job{ "failed": {ID: 1, Status: "failed"}, "canceled": {ID: 2, Status: "canceled"}, "running": {ID: 3, Status: "running"}, "done": {ID: 4, Status: "succeeded"}, }} handler := newTestHandlerWithStore(fetcher, jobs, nil) body := getPage(handler, "/").Body.String() for _, want := range []string{"失敗", "取り消し", "ダウンロード中", "完了"} { if !strings.Contains(body, want) { t.Errorf("状態表示がない: %s", want) } } if got := strings.Count(body, ">再試行"); got != 2 { t.Fatalf("再試行ボタン数: %d", got) } response := postForm(handler, "/episodes/retry", url.Values{"genre": {"drama"}, "episode_id": {"failed"}}) if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "待機中") || jobs.jobs["failed"].Status != "queued" { t.Fatalf("新着から再試行: %d %s", response.Code, response.Body.String()) } if got := postForm(handler, "/episodes/retry", url.Values{"genre": {"drama"}, "episode_id": {"running"}}).Code; got != http.StatusConflict { t.Fatalf("実行中の再試行: %d", got) } if got := postForm(handler, "/episodes/retry", url.Values{"genre": {"drama"}, "episode_id": {"missing"}}).Code; got != http.StatusNotFound { t.Fatalf("キャッシュにない ID の再試行: %d", got) } } func TestQueueGroupsProgressAndPolling(t *testing.T) { phase, errorKind, errorMessage := "video", "drm", `` downloaded, total := int64(25), int64(100) speed, eta := float64(2048), float64(63) index, count := int32(2), int32(10) created := time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC) jobs := &fakeJobStore{jobs: map[string]store.Job{ "running": {ID: 1, Status: "running", Source: "subscription", SeriesTitle: "番組A", Title: "第1話", BroadcasterName: "局A", CreatedAt: created, Phase: &phase, DownloadedBytes: &downloaded, TotalBytes: &total, TotalIsEstimate: true, SpeedBPS: &speed, ETASeconds: &eta, FragmentIndex: &index, FragmentCount: &count}, "queued": {ID: 2, Status: "queued", Source: "manual", SeriesTitle: "番組B", CreatedAt: created}, "failed": {ID: 3, Status: "failed", ErrorKind: &errorKind, ErrorMessage: &errorMessage, CreatedAt: created}, "canceled": {ID: 4, Status: "canceled", CreatedAt: created}, "done": {ID: 5, Status: "succeeded", CreatedAt: created}, }} handler := newTestHandlerWithStore(&fakeFetcher{}, jobs, nil) response := getPage(handler, "/queue") if response.Code != http.StatusOK { t.Fatalf("待ち行列: %d", response.Code) } body := response.Body.String() for _, want := range []string{"ダウンロード中", "待機中", "失敗", "取り消し", "完了(直近50件)", "番組A", "第1話", "局A", "自動", "手動", "2026-10-03 09:00 JST", "映像", "約25%", "2.0 KiB/秒", "残り1分3秒", "断片 2/10", "DRM のため取得不可", "
", "<script>", `hx-get="/queue/list"`, `hx-trigger="every 5s"`} { if !strings.Contains(body, want) { t.Errorf("待ち行列に %q がない", want) } } if strings.Contains(body, errorMessage) || !strings.Contains(body, `action="/jobs/1/cancel"`) || strings.Contains(body, "全体") { t.Errorf("不正な詳細・操作・全体進捗: %s", body) } fragment := getPage(handler, "/queue/list") if fragment.Code != http.StatusOK || strings.Contains(fragment.Body.String(), "(.*?)`).FindStringSubmatch(response.Body.String()) if len(section) != 2 || !strings.Contains(section[1], "番組-"+statuses[i]) { t.Errorf("%s に %s がない: %v", label, statuses[i], section) continue } for j, other := range statuses { if j != i && strings.Contains(section[1], "番組-"+other) { t.Errorf("%s に %s が混入", label, other) } } cancelAction := `action="/jobs/` + string(rune('1'+i)) + `/cancel"` retryAction := `action="/jobs/` + string(rune('1'+i)) + `/retry"` switch statuses[i] { case "running", "queued": if !strings.Contains(section[1], cancelAction) || strings.Contains(section[1], retryAction) { t.Errorf("%s の操作が不正: %s", statuses[i], section[1]) } case "failed", "canceled": if !strings.Contains(section[1], retryAction) || strings.Contains(section[1], cancelAction) { t.Errorf("%s の操作が不正: %s", statuses[i], section[1]) } default: if strings.Contains(section[1], "/cancel") || strings.Contains(section[1], "/retry") { t.Errorf("%s に操作がある: %s", statuses[i], section[1]) } } } } func TestQueueEscapesEpisodeFields(t *testing.T) { attack := `` message := `` kind := "extract" jobs := &fakeJobStore{jobs: map[string]store.Job{ "bad": {ID: 1, Status: "failed", SeriesTitle: attack, Title: attack, BroadcasterName: attack, ErrorKind: &kind, ErrorMessage: &message}, }} response := getPage(newTestHandlerWithStore(&fakeFetcher{}, jobs, nil), "/queue") body := response.Body.String() if response.Code != http.StatusOK || strings.Contains(body, attack) || strings.Contains(body, message) || strings.Count(body, "<script>") != 3 || !strings.Contains(body, "<img src=x onerror=alert(1)>") { t.Fatalf("待ち行列の HTML エスケープ: %d %s", response.Code, body) } } func TestQueueCancelRetryAndStopPolling(t *testing.T) { jobs := &fakeJobStore{jobs: map[string]store.Job{"ep": {ID: 1, Status: "queued"}}} handler := newTestHandlerWithStore(&fakeFetcher{}, jobs, nil) if !strings.Contains(getPage(handler, "/queue").Body.String(), `hx-get="/queue/list"`) { t.Fatal("待機中なのに更新がない") } canceled := postForm(handler, "/jobs/1/cancel", nil) if canceled.Code != http.StatusOK || !strings.Contains(canceled.Body.String(), `action="/jobs/1/retry"`) || strings.Contains(canceled.Body.String(), `hx-get="/queue/list"`) { t.Fatalf("取り消し後: %d %s", canceled.Code, canceled.Body.String()) } retried := postForm(handler, "/jobs/1/retry", nil) if retried.Code != http.StatusOK || !strings.Contains(retried.Body.String(), `action="/jobs/1/cancel"`) || !strings.Contains(retried.Body.String(), `hx-get="/queue/list"`) { t.Fatalf("再試行後: %d %s", retried.Code, retried.Body.String()) } if got := postForm(handler, "/jobs/1/retry", nil).Code; got != http.StatusConflict { t.Fatalf("待機中の再試行: %d", got) } if got := postForm(handler, "/jobs/bad/cancel", nil).Code; got != http.StatusBadRequest { t.Fatalf("不正な ID: %d", got) } if got := postForm(handler, "/jobs/99/cancel", nil).Code; got != http.StatusNotFound { t.Fatalf("存在しないジョブ: %d", got) } jobs.jobs["ep"] = store.Job{ID: 1, Status: "running"} runningCanceled := postForm(handler, "/jobs/1/cancel", nil) if runningCanceled.Code != http.StatusOK || jobs.jobs["ep"].Status != "canceled" || !strings.Contains(runningCanceled.Body.String(), `action="/jobs/1/retry"`) { t.Fatalf("実行中の取り消し: %d %+v", runningCanceled.Code, jobs.jobs["ep"]) } } func TestStateChangingGETIsRejected(t *testing.T) { jobs := &fakeJobStore{jobs: map[string]store.Job{"ep": {ID: 1, Status: "queued"}}} handler := newTestHandlerWithStore(&fakeFetcher{episodes: []tver.Episode{{ID: "ep", Duration: 200}}}, jobs, nil) getPage(handler, "/") for _, path := range []string{ "/episodes/enqueue?episode_id=ep&genre=drama", "/episodes/retry?episode_id=ep&genre=drama", "/jobs/1/cancel", "/jobs/1/retry", } { response := getPage(handler, path) if response.Code != http.StatusMethodNotAllowed || !strings.Contains(response.Header().Get("Allow"), "POST") { t.Errorf("GET %s: status=%d Allow=%q", path, response.Code, response.Header().Get("Allow")) } } if jobs.jobs["ep"].Status != "queued" || jobs.enqueueCalls != 0 { t.Fatalf("GET が状態を変更した: jobs=%+v enqueue=%d", jobs.jobs, jobs.enqueueCalls) } } func TestStateChangingPOSTWithoutHtmxRedirects(t *testing.T) { jobs := &fakeJobStore{} handler := newTestHandlerWithStore(&fakeFetcher{episodes: []tver.Episode{{ID: "ep", Duration: 200}}}, jobs, nil) getPage(handler, "/") form := url.Values{"episode_id": {"ep"}, "genre": {"drama"}} request := httptest.NewRequest(http.MethodPost, "/episodes/enqueue", strings.NewReader(form.Encode())) request.Header.Set("Content-Type", "application/x-www-form-urlencoded") response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusSeeOther || response.Header().Get("Location") != "/?genre=drama" || jobs.jobs["ep"].Status != "queued" { t.Fatalf("通常 POST 後の遷移: status=%d Location=%q jobs=%+v", response.Code, response.Header().Get("Location"), jobs.jobs) } request = httptest.NewRequest(http.MethodPost, "/jobs/1/cancel", nil) response = httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusSeeOther || response.Header().Get("Location") != "/queue" || jobs.jobs["ep"].Status != "canceled" { t.Fatalf("待ち行列操作後の遷移: status=%d Location=%q jobs=%+v", response.Code, response.Header().Get("Location"), jobs.jobs) } } func TestNewerStoreFailureDoesNotOfferDownload(t *testing.T) { jobs := &fakeJobStore{err: errors.New("db unavailable")} handler := newTestHandlerWithStore(&fakeFetcher{episodes: []tver.Episode{{ID: "ep", Duration: 200}}}, jobs, nil) response := getPage(handler, "/") if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "状態を取得できませんでした") || strings.Contains(response.Body.String(), ">ダウンロード") { t.Errorf("状態取得失敗: %d %s", response.Code, response.Body.String()) } } func TestEnqueueStoreFailureShowsShortError(t *testing.T) { jobs := &fakeJobStore{} handler := newTestHandlerWithStore(&fakeFetcher{episodes: []tver.Episode{{ID: "ep", Duration: 200}}}, jobs, nil) getPage(handler, "/") jobs.err = errors.New("secret database detail") response := postForm(handler, "/episodes/enqueue", url.Values{"genre": {"drama"}, "episode_id": {"ep"}}) if response.Code != http.StatusInternalServerError || !strings.Contains(response.Body.String(), "待ち行列に登録できませんでした") || strings.Contains(response.Body.String(), "secret") { t.Errorf("DB エラーの表示: %d %s", response.Code, response.Body.String()) } } func TestQueueProgressAndFailureLabels(t *testing.T) { s := &server{zone: time.FixedZone("JST", 9*60*60)} for _, tt := range []struct{ kind, label string }{ {"drm", "DRM のため取得不可"}, {"geo", "地域制限"}, {"network", "ネットワーク"}, {"unavailable", "配信終了・存在しない"}, {"extract", "抽出失敗"}, {"invalid_output", "出力形式の不正"}, {"unknown", "不明"}, } { if got := errorKindText(tt.kind); got != tt.label { t.Errorf("%s: %q", tt.kind, got) } } for _, tt := range []struct{ phase, label string }{ {"video", "映像"}, {"audio", "音声"}, {"merging", "結合"}, {"moving", "移動"}, } { if got := s.queueJob(store.Job{Phase: &tt.phase}).PhaseText; got != tt.label { t.Errorf("%s: %q", tt.phase, got) } } downloaded, total := int64(50), int64(100) job := store.Job{DownloadedBytes: &downloaded, TotalBytes: &total} if got := s.queueJob(job).PercentText; got != "50%" { t.Errorf("確定した段階の割合: %q", got) } job.TotalIsEstimate = true if got := s.queueJob(job).PercentText; got != "約50%" { t.Errorf("推定した段階の割合: %q", got) } total = 0 if got := s.queueJob(job).PercentText; got != "" { t.Errorf("総量ゼロでの割合: %q", got) } }