package web import ( "errors" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "gitea.kazu634.com/kazu634/tver/internal/store" "gitea.kazu634.com/kazu634/tver/internal/tver" ) func TestRegisterSubscriptionChoicesAndServerOwnedSeries(t *testing.T) { for _, download := range []bool{false, true} { t.Run(map[bool]string{false: "without existing", true: "with existing"}[download], func(t *testing.T) { fetcher := &fakeFetcher{episodes: []tver.Episode{{ID: "ep1", SeriesID: "sr1", SeriesTitle: "本当の番組", Duration: 200}}, seriesEpisodes: []tver.Episode{{ID: "ep1", SeriesID: "sr1", SeriesTitle: "本当の番組", Title: "公開中", Duration: 200}}} jobs := &fakeJobStore{} handler := newTestHandlerWithStore(fetcher, jobs, nil) page := getPage(handler, "/") if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), "番組を登録") || jobs.subscribedCalls != 1 || len(jobs.subscribedIDs) != 1 || jobs.subscribedIDs[0] != "sr1" { t.Fatalf("新着の登録状態: %d ids=%v body=%s", page.Code, jobs.subscribedIDs, page.Body.String()) } choice := getPage(handler, "/subscriptions/choice?genre=drama&episode_id=ep1") if choice.Code != http.StatusOK || !strings.Contains(choice.Body.String(), "公開中の回もダウンロードする") || !strings.Contains(choice.Body.String(), ">登録") { t.Fatalf("登録時の選択: %d %s", choice.Code, choice.Body.String()) } values := url.Values{"genre": {"drama"}, "episode_id": {"ep1"}, "series_id": {"forged"}, "series_title": {"偽番組"}} if download { values.Set("download_existing", "1") } registered := postForm(handler, "/subscriptions/register", values) if registered.Code != http.StatusOK || !strings.Contains(registered.Body.String(), "登録済み") { t.Fatalf("登録応答: %d %s", registered.Code, registered.Body.String()) } if jobs.registerCalls != 1 || jobs.registerDownload != download || len(jobs.registerEpisodes) != 1 || jobs.registerEpisodes[0].ID != "ep1" || jobs.registerEpisodes[0].SeriesID != "sr1" || jobs.subscriptions["sr1"].SeriesTitle != "本当の番組" { t.Fatalf("登録内容: calls=%d download=%t episodes=%+v subs=%+v", jobs.registerCalls, jobs.registerDownload, jobs.registerEpisodes, jobs.subscriptions) } if len(fetcher.seriesCalls) != 1 || fetcher.seriesCalls[0] != "sr1" { t.Fatalf("番組一覧取得: %v", fetcher.seriesCalls) } if !strings.Contains(getPage(handler, "/subscriptions").Body.String(), "最終確認: 2026-10-03 09:00 JST") { t.Fatal("登録直後の最終確認日時が表示されない") } fetcher.seriesErr = errors.New("API unavailable") duplicate := postForm(handler, "/subscriptions/register", values) if duplicate.Code != http.StatusOK || !strings.Contains(duplicate.Body.String(), "登録済み") || len(fetcher.seriesCalls) != 1 || jobs.registerCalls != 1 { t.Fatalf("重複登録が API を再取得: %d %s calls=%v register=%d", duplicate.Code, duplicate.Body.String(), fetcher.seriesCalls, jobs.registerCalls) } reloaded := getPage(handler, "/") if !strings.Contains(reloaded.Body.String(), "登録済み") || strings.Contains(reloaded.Body.String(), ">番組を登録") { t.Fatalf("登録済みの表示: %s", reloaded.Body.String()) } }) } } func TestRegisterSubscriptionRejectsMissingCacheAndFetchFailure(t *testing.T) { fetcher := &fakeFetcher{episodes: []tver.Episode{{ID: "ep1", SeriesID: "sr1", SeriesTitle: "番組", Duration: 200}}} jobs := &fakeJobStore{} handler := newTestHandlerWithStore(fetcher, jobs, nil) values := url.Values{"genre": {"drama"}, "episode_id": {"ep1"}} if got := postForm(handler, "/subscriptions/register", values).Code; got != http.StatusNotFound { t.Fatalf("キャッシュ無しの登録: %d", got) } getPage(handler, "/") for _, path := range []string{"/subscriptions/register", "/subscriptions/sr1/pause", "/subscriptions/sr1/resume", "/subscriptions/sr1/delete"} { 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")) } } for _, tt := range []struct { path string want int }{ {"/subscriptions/choice?genre=news&episode_id=ep1", http.StatusBadRequest}, {"/subscriptions/choice?genre=drama", http.StatusBadRequest}, {"/subscriptions/choice?genre=drama&episode_id=missing", http.StatusNotFound}, } { response := getPage(handler, tt.path) if response.Code != tt.want { t.Errorf("不正な選択 %s: got=%d want=%d", tt.path, response.Code, tt.want) } } for _, tt := range []struct { values url.Values want int }{ {url.Values{"genre": {"news"}, "episode_id": {"ep1"}}, http.StatusBadRequest}, {url.Values{"genre": {"drama"}}, http.StatusBadRequest}, {url.Values{"genre": {"drama"}, "episode_id": {"missing"}}, http.StatusNotFound}, } { response := postForm(handler, "/subscriptions/register", tt.values) if response.Code != tt.want { t.Errorf("不正な登録 %v: got=%d want=%d", tt.values, response.Code, tt.want) } } values.Set("download_existing", "bad") if got := postForm(handler, "/subscriptions/register", values).Code; got != http.StatusBadRequest { t.Fatalf("不正な選択: %d", got) } values.Del("download_existing") fetcher.seriesErr = errors.New("platform_token=secret& upstream failure") failed := postForm(handler, "/subscriptions/register", values) if failed.Code != http.StatusOK || !strings.Contains(failed.Body.String(), "公開中の回を取得できませんでした") || strings.Contains(failed.Body.String(), "secret") || jobs.registerCalls != 0 { t.Fatalf("取得失敗時に未登録: %d %s calls=%d", failed.Code, failed.Body.String(), jobs.registerCalls) } fetcher.seriesErr = nil fetcher.seriesEpisodes = []tver.Episode{{ID: "ep1", SeriesID: "sr1", SeriesTitle: "番組", Duration: 200}} jobs.registerErr = errors.New("secret database detail") failed = postForm(handler, "/subscriptions/register", values) if failed.Code != http.StatusOK || !strings.Contains(failed.Body.String(), "番組を登録できませんでした") || strings.Contains(failed.Body.String(), "secret") || len(jobs.subscriptions) != 0 { t.Fatalf("DB 失敗時に未登録: %d %s subscriptions=%v", failed.Code, failed.Body.String(), jobs.subscriptions) } } func TestNewerShowsInitiallySubscribedSeriesOncePerSeriesLookup(t *testing.T) { fetcher := &fakeFetcher{episodes: []tver.Episode{ {ID: "ep1", SeriesID: "sr1", SeriesTitle: "番組", Duration: 200}, {ID: "ep2", SeriesID: "sr1", SeriesTitle: "番組", Duration: 200}, }} jobs := &fakeJobStore{subscriptions: map[string]store.Subscription{ "sr1": {SeriesID: "sr1", SeriesTitle: "番組", Enabled: true}, }} response := getPage(newTestHandlerWithStore(fetcher, jobs, nil), "/") body := response.Body.String() if response.Code != http.StatusOK || strings.Count(body, "登録済み") != 2 || strings.Contains(body, ">番組を登録") || jobs.subscribedCalls != 1 || len(jobs.subscribedIDs) != 1 || jobs.subscribedIDs[0] != "sr1" { t.Fatalf("登録済み番組の初期表示: status=%d calls=%d ids=%v body=%s", response.Code, jobs.subscribedCalls, jobs.subscribedIDs, body) } } func TestSubscriptionMutationsWithoutHtmxRedirect(t *testing.T) { fetcher := &fakeFetcher{ episodes: []tver.Episode{{ID: "ep1", SeriesID: "sr1", SeriesTitle: "番組", Duration: 200}}, seriesEpisodes: []tver.Episode{{ID: "ep1", SeriesID: "sr1", SeriesTitle: "番組", Duration: 200}}, } jobs := &fakeJobStore{} handler := newTestHandlerWithStore(fetcher, jobs, nil) getPage(handler, "/") form := url.Values{"genre": {"drama"}, "episode_id": {"ep1"}} request := httptest.NewRequest(http.MethodPost, "/subscriptions/register", 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" { t.Fatalf("通常登録 POST: status=%d Location=%q", response.Code, response.Header().Get("Location")) } request = httptest.NewRequest(http.MethodPost, "/subscriptions/sr1/pause", nil) response = httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusSeeOther || response.Header().Get("Location") != "/subscriptions" || jobs.subscriptions["sr1"].Enabled { t.Fatalf("通常操作 POST: status=%d Location=%q subscription=%+v", response.Code, response.Header().Get("Location"), jobs.subscriptions["sr1"]) } } func TestSubscriptionsPagePauseResumeDeleteAndEscaping(t *testing.T) { checked := time.Date(2026, 10, 4, 2, 30, 0, 0, time.UTC) jobs := &fakeJobStore{subscriptions: map[string]store.Subscription{ "sr1": {SeriesID: "sr1", SeriesTitle: ``, Enabled: true, CreatedAt: time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC), LastCheckedAt: &checked, JobCount: 3}, "sr2": {SeriesID: "sr2", SeriesTitle: "別番組", Enabled: false, JobCount: 0}, }} handler := newTestHandlerWithStore(&fakeFetcher{}, jobs, nil) page := getPage(handler, "/subscriptions") if page.Code != http.StatusOK { t.Fatalf("登録番組ページ: %d", page.Code) } body := page.Body.String() for _, want := range []string{"<script>", "登録日時: 2026-10-03 09:00 JST", "最終確認: 2026-10-04 11:30 JST", "最終確認: 未確認", "関連ジョブ: 3件", "有効", "一時停止", `hx-confirm="番組の登録を解除しますか?"`} { if !strings.Contains(body, want) { t.Errorf("登録番組の情報 %q がない", want) } } if strings.Contains(body, ``) { t.Fatal("番組名がエスケープされていない") } paused := postForm(handler, "/subscriptions/sr1/pause", nil) if paused.Code != http.StatusOK || jobs.subscriptions["sr1"].Enabled || !strings.Contains(paused.Body.String(), `action="/subscriptions/sr1/resume"`) || strings.Contains(paused.Body.String(), "