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(), "