Files

351 lines
18 KiB
Go

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(), "<html") || strings.Contains(response.Body.String(), "episode-card") {
t.Fatalf("登録応答: %d %s", response.Code, response.Body.String())
}
if jobs.lastEpisode.SeriesTitle != "正しい番組" || jobs.lastEpisode.Title != "正しい回" || jobs.lastEpisode.BroadcasterName != "正しい局" || jobs.lastEpisode.DurationSeconds != 1234 || jobs.lastEpisode.ThumbnailPath != "/correct.jpg" || jobs.lastEpisode.EndAt == nil {
t.Fatalf("キャッシュ外の情報を使用: %+v", jobs.lastEpisode)
}
if len(fetcher.calls) != 1 {
t.Fatalf("POST が TVer に再問い合わせ: %v", fetcher.calls)
}
duplicate := postForm(handler, "/episodes/enqueue", values)
if duplicate.Code != http.StatusOK || !strings.Contains(duplicate.Body.String(), "待機中") || len(jobs.jobs) != 1 {
t.Fatalf("二重登録: %d %s jobs=%d", duplicate.Code, duplicate.Body.String(), len(jobs.jobs))
}
page := getPage(handler, "/")
if !strings.Contains(page.Body.String(), "待機中") || strings.Contains(page.Body.String(), ">ダウンロード</button>") {
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, ">再試行</button>"); 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", `<script>alert(1)</script>`
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 のため取得不可", "<details>", "&lt;script&gt;", `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(), "<html") || !strings.Contains(fragment.Body.String(), `id="queue-list"`) {
t.Errorf("待ち行列断片: %d %s", fragment.Code, fragment.Body.String())
}
}
func TestQueueJobsAppearInCorrectSectionsWithLimit(t *testing.T) {
statuses := []string{"running", "queued", "failed", "canceled", "succeeded"}
labels := []string{"ダウンロード中", "待機中", "失敗", "取り消し", "完了"}
jobs := &fakeJobStore{jobs: make(map[string]store.Job)}
for i, status := range statuses {
jobs.jobs[status] = store.Job{ID: int64(i + 1), Status: status, SeriesTitle: "番組-" + status}
}
response := getPage(newTestHandlerWithStore(&fakeFetcher{}, jobs, nil), "/queue")
if response.Code != http.StatusOK {
t.Fatalf("待ち行列: %d", response.Code)
}
if !slices.Equal(jobs.listStatuses, statuses) {
t.Errorf("状態別取得: %v", jobs.listStatuses)
}
if !slices.Equal(jobs.listLimits, []int{50, 50, 50, 50, 50}) {
t.Errorf("状態ごとの取得上限: %v", jobs.listLimits)
}
for i, label := range labels {
section := regexp.MustCompile(`(?s)<section aria-label="` + label + `">(.*?)</section>`).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 := `<script>alert(1)</script>`
message := `<img src=x onerror=alert(1)>`
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, "&lt;script&gt;") != 3 || !strings.Contains(body, "&lt;img src=x onerror=alert(1)&gt;") {
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(), ">ダウンロード</button>") {
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)
}
}