347 lines
17 KiB
Go
347 lines
17 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>", "<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(), "<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 "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, "<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)
|
|
}
|
|
jobs.jobs["ep"] = store.Job{ID: 1, Status: "running"}
|
|
if got := postForm(handler, "/jobs/1/cancel", nil).Code; got != http.StatusConflict {
|
|
t.Fatalf("実行中の取り消し: %d", got)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|