番組 URL から番組を登録する機能を追加(ステップ11)
This commit is contained in:
@@ -85,6 +85,9 @@ func NewHandler(fetcher NewerFetcher, jobs JobStore, videoDir string, now func()
|
||||
mux.HandleFunc("POST /jobs/{id}/cancel", s.cancelJob)
|
||||
mux.HandleFunc("GET /subscriptions", s.subscriptionsPage)
|
||||
mux.HandleFunc("GET /subscriptions/list", s.subscriptionsFragment)
|
||||
mux.HandleFunc("POST /subscriptions/from-url/confirm", s.confirmSubscriptionURL)
|
||||
mux.HandleFunc("POST /subscriptions/from-url/register", s.registerSubscriptionURL)
|
||||
mux.HandleFunc("GET /subscriptions/from-url/cancel", s.cancelSubscriptionURL)
|
||||
mux.HandleFunc("GET /subscriptions/choice", s.subscriptionChoice)
|
||||
mux.HandleFunc("POST /subscriptions/register", s.registerSubscription)
|
||||
mux.HandleFunc("POST /subscriptions/{id}/pause", s.pauseSubscription)
|
||||
|
||||
@@ -21,6 +21,7 @@ var platformTokenPattern = regexp.MustCompile(`(?i)(platform_token=)[^&\s]+`)
|
||||
|
||||
type NewerFetcher interface {
|
||||
FetchNewer(context.Context, string) ([]tver.Episode, error)
|
||||
FetchSeries(context.Context, string) (string, error)
|
||||
FetchSeriesEpisodes(context.Context, string) ([]tver.Episode, error)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,13 +16,21 @@ import (
|
||||
)
|
||||
|
||||
type fakeFetcher struct {
|
||||
episodes []tver.Episode
|
||||
episodesByGenre map[string][]tver.Episode
|
||||
err error
|
||||
calls []string
|
||||
seriesEpisodes []tver.Episode
|
||||
seriesErr error
|
||||
seriesCalls []string
|
||||
episodes []tver.Episode
|
||||
episodesByGenre map[string][]tver.Episode
|
||||
err error
|
||||
calls []string
|
||||
seriesEpisodes []tver.Episode
|
||||
seriesErr error
|
||||
seriesCalls []string
|
||||
seriesTitle string
|
||||
seriesTitleErr error
|
||||
seriesTitleCalls []string
|
||||
}
|
||||
|
||||
func (f *fakeFetcher) FetchSeries(_ context.Context, seriesID string) (string, error) {
|
||||
f.seriesTitleCalls = append(f.seriesTitleCalls, seriesID)
|
||||
return f.seriesTitle, f.seriesTitleErr
|
||||
}
|
||||
|
||||
func (f *fakeFetcher) FetchNewer(_ context.Context, genre string) ([]tver.Episode, error) {
|
||||
|
||||
@@ -18,6 +18,9 @@ main { max-width: 72rem; margin: 2rem auto; padding: 0 2rem; }
|
||||
.subscription-action button, .subscription-item button { font: inherit; padding: .35rem .7rem; }
|
||||
.subscription-item { border: 1px solid #d6dee4; border-radius: .5rem; margin: .75rem 0; padding: .8rem 1rem; }
|
||||
.subscription-item form { display: inline-block; margin-right: .5rem; }
|
||||
.subscription-url-entry { border: 1px solid #d6dee4; border-radius: .5rem; margin-bottom: 1.5rem; padding: .8rem 1rem; }
|
||||
.subscription-url-entry input[type="url"] { font: inherit; min-width: min(32rem, 70vw); padding: .35rem .5rem; }
|
||||
.subscription-url-entry button { font: inherit; padding: .35rem .7rem; }
|
||||
.episode-action form { display: inline; }
|
||||
.episode-action button, .queue-job button { font: inherit; padding: .35rem .7rem; }
|
||||
.queue-job { border: 1px solid #d6dee4; border-radius: .5rem; margin: .75rem 0; padding: .8rem 1rem; }
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gitea.kazu634.com/kazu634/tver/internal/store"
|
||||
"gitea.kazu634.com/kazu634/tver/internal/tver"
|
||||
)
|
||||
|
||||
const seriesURLInputError = "番組ページ(https://tver.jp/series/…)の URL を入力してください"
|
||||
|
||||
var seriesURLPathPattern = regexp.MustCompile(`^/series/([A-Za-z0-9]+)/?$`)
|
||||
var seriesIDPattern = regexp.MustCompile(`^[A-Za-z0-9]+$`)
|
||||
|
||||
type subscriptionURLResultView struct {
|
||||
SeriesID, Title, ErrorText string
|
||||
EpisodeCount int
|
||||
Subscribed bool
|
||||
}
|
||||
|
||||
type subscriptionURLRegisteredView struct {
|
||||
Subscriptions subscriptionsView
|
||||
}
|
||||
|
||||
func parseSeriesURL(raw string) (string, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Scheme != "https" || parsed.User != nil || (!strings.EqualFold(parsed.Host, "tver.jp") && !strings.EqualFold(parsed.Host, "www.tver.jp")) {
|
||||
return "", errors.New(seriesURLInputError)
|
||||
}
|
||||
match := seriesURLPathPattern.FindStringSubmatch(parsed.Path)
|
||||
if len(match) != 2 {
|
||||
return "", errors.New(seriesURLInputError)
|
||||
}
|
||||
return match[1], nil
|
||||
}
|
||||
|
||||
func (s *server) confirmSubscriptionURL(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("HX-Request") != "true" {
|
||||
http.Redirect(w, r, "/subscriptions", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "入力が不正です", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
seriesID, err := parseSeriesURL(r.Form.Get("series_url"))
|
||||
if err != nil {
|
||||
s.render(w, "subscription_url_result", subscriptionURLResultView{ErrorText: seriesURLInputError})
|
||||
return
|
||||
}
|
||||
title, episodes, ok := s.fetchSubscriptionSeries(w, r, seriesID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
subscribed, err := s.isSubscribed(r.Context(), seriesID)
|
||||
if err != nil {
|
||||
s.logger.Printf("番組の登録状態取得に失敗: %v", err)
|
||||
http.Error(w, "登録状態を取得できませんでした", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.render(w, "subscription_url_result", subscriptionURLResultView{
|
||||
SeriesID: seriesID, Title: title, EpisodeCount: len(episodes), Subscribed: subscribed,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) registerSubscriptionURL(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "入力が不正です", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
seriesID := r.Form.Get("series_id")
|
||||
if !seriesIDPattern.MatchString(seriesID) {
|
||||
http.Error(w, "番組 ID が不正です", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
choice := r.Form.Get("download_existing")
|
||||
if choice != "" && choice != "1" {
|
||||
http.Error(w, "公開中の回の選択が不正です", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
subscribed, err := s.isSubscribed(r.Context(), seriesID)
|
||||
if err != nil {
|
||||
s.logger.Printf("番組の登録状態取得に失敗: %v", err)
|
||||
http.Error(w, "登録状態を取得できませんでした", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if subscribed {
|
||||
s.subscriptionURLRegisteredResponse(w, r)
|
||||
return
|
||||
}
|
||||
title, episodes, ok := s.fetchSubscriptionSeries(w, r, seriesID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items := make([]store.Episode, 0, len(episodes))
|
||||
for _, episode := range episodes {
|
||||
items = append(items, storeEpisode(episode))
|
||||
}
|
||||
_, err = s.jobs.RegisterSubscription(r.Context(), seriesID, title, items, choice == "1")
|
||||
if err != nil {
|
||||
s.logger.Printf("番組URLからの登録に失敗: %v", err)
|
||||
s.subscriptionURLFailure(w, r, "番組を登録できませんでした。再試行してください")
|
||||
return
|
||||
}
|
||||
s.subscriptionURLRegisteredResponse(w, r)
|
||||
}
|
||||
|
||||
func (s *server) cancelSubscriptionURL(w http.ResponseWriter, _ *http.Request) {
|
||||
s.render(w, "subscription_url_result", subscriptionURLResultView{})
|
||||
}
|
||||
|
||||
func (s *server) fetchSubscriptionSeries(w http.ResponseWriter, r *http.Request, seriesID string) (string, []tver.Episode, bool) {
|
||||
title, err := s.fetcher.FetchSeries(r.Context(), seriesID)
|
||||
if err != nil {
|
||||
if errors.Is(err, tver.ErrSeriesNotFound) {
|
||||
s.subscriptionURLFailure(w, r, "番組が見つかりません")
|
||||
return "", nil, false
|
||||
}
|
||||
s.logSubscriptionFetchError(err)
|
||||
s.subscriptionURLFailure(w, r, "番組情報を取得できませんでした。再試行してください")
|
||||
return "", nil, false
|
||||
}
|
||||
episodes, err := s.fetcher.FetchSeriesEpisodes(r.Context(), seriesID)
|
||||
if err != nil {
|
||||
s.logSubscriptionFetchError(err)
|
||||
s.subscriptionURLFailure(w, r, "番組情報を取得できませんでした。再試行してください")
|
||||
return "", nil, false
|
||||
}
|
||||
return title, episodes, true
|
||||
}
|
||||
|
||||
func (s *server) logSubscriptionFetchError(err error) {
|
||||
s.logger.Printf("番組URLの情報取得に失敗: %s", platformTokenPattern.ReplaceAllString(err.Error(), "${1}[REDACTED]"))
|
||||
}
|
||||
|
||||
func (s *server) subscriptionURLFailure(w http.ResponseWriter, r *http.Request, message string) {
|
||||
if r.Header.Get("HX-Request") != "true" {
|
||||
http.Redirect(w, r, "/subscriptions", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.render(w, "subscription_url_result", subscriptionURLResultView{ErrorText: message})
|
||||
}
|
||||
|
||||
func (s *server) subscriptionURLRegisteredResponse(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("HX-Request") != "true" {
|
||||
http.Redirect(w, r, "/subscriptions", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
view, err := s.loadSubscriptions(r.Context())
|
||||
if err != nil {
|
||||
s.logger.Printf("登録番組の取得に失敗: %v", err)
|
||||
http.Error(w, "登録番組を取得できませんでした", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
view.OutOfBand = true
|
||||
s.render(w, "subscription_url_registered", subscriptionURLRegisteredView{Subscriptions: view})
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"log"
|
||||
"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 TestParseSeriesURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, raw, want string
|
||||
valid bool
|
||||
}{
|
||||
{"without www", "https://tver.jp/series/sr123", "sr123", true},
|
||||
{"with www", "https://www.tver.jp/series/ABC123", "ABC123", true},
|
||||
{"uppercase host", "https://TVER.JP/series/sr123", "sr123", true},
|
||||
{"trailing slash", "https://tver.jp/series/sr123/", "sr123", true},
|
||||
{"query and fragment", "https://tver.jp/series/sr123/?foo=bar#section", "sr123", true},
|
||||
{"surrounding whitespace", " \thttps://tver.jp/series/sr123\n", "sr123", true},
|
||||
{"http", "http://tver.jp/series/sr123", "", false},
|
||||
{"subdomain suffix", "https://tver.jp.example.com/series/sr123", "", false},
|
||||
{"evil prefix", "https://eviltver.jp/series/sr123", "", false},
|
||||
{"userinfo on allowed host", "https://evil@tver.jp/series/sr123", "", false},
|
||||
{"allowed host as userinfo", "https://tver.jp@evil.example/series/sr123", "", false},
|
||||
{"episode", "https://tver.jp/episodes/ep123", "", false},
|
||||
{"lp", "https://tver.jp/lp/example", "", false},
|
||||
{"corner", "https://tver.jp/corner/example", "", false},
|
||||
{"feature", "https://tver.jp/feature/example", "", false},
|
||||
{"missing id", "https://tver.jp/series/", "", false},
|
||||
{"invalid id", "https://tver.jp/series/sr_123", "", false},
|
||||
{"extra path", "https://tver.jp/series/sr123/episodes", "", false},
|
||||
{"encoded slash in id", "https://tver.jp/series/sr123%2Fevil", "", false},
|
||||
{"encoded dot segments", "https://tver.jp/series/%2E%2E/episodes", "", false},
|
||||
{"duplicate slash", "https://tver.jp//series/sr123", "", false},
|
||||
{"port", "https://tver.jp:443/series/sr123", "", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseSeriesURL(tt.raw)
|
||||
if tt.valid {
|
||||
if err != nil || got != tt.want {
|
||||
t.Fatalf("parseSeriesURL(%q)=%q, %v; want %q", tt.raw, got, err, tt.want)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || err.Error() != seriesURLInputError {
|
||||
t.Fatalf("parseSeriesURL(%q)=%q, %v; want input error", tt.raw, got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriptionsPageShowsSeriesURLForm(t *testing.T) {
|
||||
page := getPage(newTestHandlerWithStore(&fakeFetcher{}, &fakeJobStore{}, nil), "/subscriptions")
|
||||
body := page.Body.String()
|
||||
if page.Code != http.StatusOK || !strings.Contains(body, `name="series_url"`) || !strings.Contains(body, `hx-post="/subscriptions/from-url/confirm"`) || !strings.Contains(body, `id="subscriptions-list"`) {
|
||||
t.Fatalf("URL入力フォーム: status=%d body=%s", page.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmSubscriptionURL(t *testing.T) {
|
||||
fetcher := &fakeFetcher{
|
||||
seriesTitle: `<script>alert("title")</script>`,
|
||||
seriesEpisodes: []tver.Episode{
|
||||
{ID: "ep1", SeriesID: "sr1"},
|
||||
{ID: "ep2", SeriesID: "sr1"},
|
||||
},
|
||||
}
|
||||
handler := newTestHandlerWithStore(fetcher, &fakeJobStore{}, nil)
|
||||
response := postForm(handler, "/subscriptions/from-url/confirm", url.Values{"series_url": {"https://tver.jp/series/sr1?x=1#fragment"}})
|
||||
body := response.Body.String()
|
||||
for _, want := range []string{"<script>", "番組ID: sr1", "公開中の回数: 2件", `name="series_id" value="sr1"`, "公開中の回もダウンロードする", ">登録</button>", ">キャンセル</button>"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("確認画面に %q がない: %s", want, body)
|
||||
}
|
||||
}
|
||||
if response.Code != http.StatusOK || strings.Contains(body, `<script>`) || strings.Contains(body, `name="series_title"`) || strings.Contains(body, `checked`) {
|
||||
t.Fatalf("確認画面: status=%d body=%s", response.Code, body)
|
||||
}
|
||||
if len(fetcher.seriesTitleCalls) != 1 || fetcher.seriesTitleCalls[0] != "sr1" || len(fetcher.seriesCalls) != 1 || fetcher.seriesCalls[0] != "sr1" {
|
||||
t.Fatalf("取得したID: title=%v episodes=%v", fetcher.seriesTitleCalls, fetcher.seriesCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmSubscriptionURLAlreadyRegisteredIncludingPaused(t *testing.T) {
|
||||
fetcher := &fakeFetcher{seriesTitle: "番組", seriesEpisodes: []tver.Episode{{ID: "ep1", SeriesID: "sr1"}}}
|
||||
jobs := &fakeJobStore{subscriptions: map[string]store.Subscription{"sr1": {SeriesID: "sr1", SeriesTitle: "番組", Enabled: false}}}
|
||||
response := postForm(newTestHandlerWithStore(fetcher, jobs, nil), "/subscriptions/from-url/confirm", url.Values{"series_url": {"https://tver.jp/series/sr1"}})
|
||||
body := response.Body.String()
|
||||
if response.Code != http.StatusOK || !strings.Contains(body, "番組") || !strings.Contains(body, "公開中の回数: 1件") || !strings.Contains(body, "登録済み") || strings.Contains(body, ">登録</button>") || len(fetcher.seriesTitleCalls) != 1 || len(fetcher.seriesCalls) != 1 {
|
||||
t.Fatalf("登録済み確認: status=%d body=%s title=%v episodes=%v", response.Code, body, fetcher.seriesTitleCalls, fetcher.seriesCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriptionURLNotFoundAndInvalidURL(t *testing.T) {
|
||||
fetcher := &fakeFetcher{seriesTitleErr: tver.ErrSeriesNotFound}
|
||||
jobs := &fakeJobStore{}
|
||||
handler := newTestHandlerWithStore(fetcher, jobs, nil)
|
||||
notFound := postForm(handler, "/subscriptions/from-url/confirm", url.Values{"series_url": {"https://tver.jp/series/missing"}})
|
||||
if notFound.Code != http.StatusOK || !strings.Contains(notFound.Body.String(), "番組が見つかりません") || len(fetcher.seriesCalls) != 0 {
|
||||
t.Fatalf("404: status=%d body=%s episodeCalls=%v", notFound.Code, notFound.Body.String(), fetcher.seriesCalls)
|
||||
}
|
||||
registerNotFound := postForm(handler, "/subscriptions/from-url/register", url.Values{"series_id": {"missing"}})
|
||||
if registerNotFound.Code != http.StatusOK || !strings.Contains(registerNotFound.Body.String(), "番組が見つかりません") || jobs.registerCalls != 0 {
|
||||
t.Fatalf("登録時404: status=%d body=%s registerCalls=%d", registerNotFound.Code, registerNotFound.Body.String(), jobs.registerCalls)
|
||||
}
|
||||
invalid := postForm(handler, "/subscriptions/from-url/confirm", url.Values{"series_url": {"https://evil.example/series/sr1"}})
|
||||
if invalid.Code != http.StatusOK || !strings.Contains(invalid.Body.String(), seriesURLInputError) || len(fetcher.seriesTitleCalls) != 2 {
|
||||
t.Fatalf("不正URL: status=%d body=%s titleCalls=%v", invalid.Code, invalid.Body.String(), fetcher.seriesTitleCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterSubscriptionURLWithAndWithoutExistingEpisodes(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{seriesTitle: "番組", seriesEpisodes: []tver.Episode{}}
|
||||
jobs := &fakeJobStore{}
|
||||
handler := newTestHandlerWithStore(fetcher, jobs, nil)
|
||||
confirmation := postForm(handler, "/subscriptions/from-url/confirm", url.Values{"series_url": {"https://tver.jp/series/sr1"}})
|
||||
if confirmation.Code != http.StatusOK || !strings.Contains(confirmation.Body.String(), "公開中の回はありません") {
|
||||
t.Fatalf("0件の確認: %d %s", confirmation.Code, confirmation.Body.String())
|
||||
}
|
||||
values := url.Values{"series_id": {"sr1"}, "series_title": {"偽番組"}}
|
||||
if download {
|
||||
values.Set("download_existing", "1")
|
||||
}
|
||||
registered := postForm(handler, "/subscriptions/from-url/register", values)
|
||||
body := registered.Body.String()
|
||||
if registered.Code != http.StatusOK || !strings.Contains(body, "登録済み") || !strings.Contains(body, `id="subscriptions-list"`) || !strings.Contains(body, `hx-swap-oob="outerHTML"`) || !strings.Contains(body, `id="subscription-url-input"`) {
|
||||
t.Fatalf("登録応答: %d %s", registered.Code, body)
|
||||
}
|
||||
if jobs.registerCalls != 1 || jobs.registerDownload != download || len(jobs.registerEpisodes) != 0 || jobs.subscriptions["sr1"].SeriesTitle != "番組" {
|
||||
t.Fatalf("登録内容: calls=%d download=%t episodes=%v subscriptions=%v", jobs.registerCalls, jobs.registerDownload, jobs.registerEpisodes, jobs.subscriptions)
|
||||
}
|
||||
if len(fetcher.seriesTitleCalls) != 2 || len(fetcher.seriesCalls) != 2 {
|
||||
t.Fatalf("確認と登録で再取得していない: title=%v episodes=%v", fetcher.seriesTitleCalls, fetcher.seriesCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterSubscriptionURLUsesServerFetchedData(t *testing.T) {
|
||||
fetcher := &fakeFetcher{seriesTitle: "本当の番組", seriesEpisodes: []tver.Episode{{
|
||||
ID: "ep1", SeriesID: "sr1", SeriesTitle: "本当の番組", Title: "公開中の回", Duration: 200,
|
||||
}}}
|
||||
jobs := &fakeJobStore{}
|
||||
handler := newTestHandlerWithStore(fetcher, jobs, nil)
|
||||
response := postForm(handler, "/subscriptions/from-url/register", url.Values{
|
||||
"series_id": {"sr1"}, "series_title": {"偽番組"}, "download_existing": {"1"},
|
||||
})
|
||||
if response.Code != http.StatusOK || jobs.registerCalls != 1 || !jobs.registerDownload || len(jobs.registerEpisodes) != 1 || jobs.registerEpisodes[0].ID != "ep1" || jobs.subscriptions["sr1"].SeriesTitle != "本当の番組" {
|
||||
t.Fatalf("サーバー取得値での登録: status=%d calls=%d download=%t episodes=%+v subscriptions=%+v", response.Code, jobs.registerCalls, jobs.registerDownload, jobs.registerEpisodes, jobs.subscriptions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterSubscriptionURLAlreadyRegisteredDoesNothing(t *testing.T) {
|
||||
fetcher := &fakeFetcher{seriesTitleErr: errors.New("must not fetch"), seriesErr: errors.New("must not fetch")}
|
||||
jobs := &fakeJobStore{subscriptions: map[string]store.Subscription{"sr1": {SeriesID: "sr1", SeriesTitle: "番組", Enabled: false}}}
|
||||
response := postForm(newTestHandlerWithStore(fetcher, jobs, nil), "/subscriptions/from-url/register", url.Values{"series_id": {"sr1"}})
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "登録済み") || jobs.registerCalls != 0 || len(fetcher.seriesTitleCalls) != 0 || len(fetcher.seriesCalls) != 0 {
|
||||
t.Fatalf("登録済みの再登録: status=%d body=%s register=%d title=%v episodes=%v", response.Code, response.Body.String(), jobs.registerCalls, fetcher.seriesTitleCalls, fetcher.seriesCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterSubscriptionURLFetchFailuresDoNotRegisterAndRedactToken(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
seriesErr error
|
||||
episodesErr error
|
||||
wantEpisodeCall int
|
||||
}{
|
||||
{"series", errors.New("platform_token=secret-series& upstream"), nil, 0},
|
||||
{"episodes", nil, errors.New("platform_token=secret-episodes& upstream"), 1},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fetcher := &fakeFetcher{seriesTitle: "番組", seriesTitleErr: tt.seriesErr, seriesErr: tt.episodesErr}
|
||||
jobs := &fakeJobStore{}
|
||||
var logs bytes.Buffer
|
||||
handler := NewHandler(fetcher, jobs, "", time.Now, log.New(&logs, "", 0))
|
||||
response := postForm(handler, "/subscriptions/from-url/register", url.Values{"series_id": {"sr1"}})
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "番組情報を取得できませんでした。再試行してください") || jobs.registerCalls != 0 || len(jobs.subscriptions) != 0 {
|
||||
t.Fatalf("取得失敗: status=%d body=%s calls=%d subscriptions=%v", response.Code, response.Body.String(), jobs.registerCalls, jobs.subscriptions)
|
||||
}
|
||||
if strings.Contains(logs.String(), "secret-") || !strings.Contains(logs.String(), "platform_token=[REDACTED]") || len(fetcher.seriesCalls) != tt.wantEpisodeCall {
|
||||
t.Fatalf("ログまたは呼出回数: logs=%s episodeCalls=%v", logs.String(), fetcher.seriesCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterSubscriptionURLRejectsInvalidChoice(t *testing.T) {
|
||||
fetcher := &fakeFetcher{}
|
||||
jobs := &fakeJobStore{}
|
||||
handler := newTestHandlerWithStore(fetcher, jobs, nil)
|
||||
for _, values := range []url.Values{
|
||||
{},
|
||||
{"series_id": {"bad_id"}},
|
||||
{"series_id": {"../sr1"}},
|
||||
{"series_id": {"sr1"}, "download_existing": {"yes"}},
|
||||
} {
|
||||
response := postForm(handler, "/subscriptions/from-url/register", values)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Errorf("不正値 %v: status=%d body=%s", values, response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
if len(fetcher.seriesTitleCalls) != 0 || len(fetcher.seriesCalls) != 0 || jobs.subscribedCalls != 0 || jobs.registerCalls != 0 {
|
||||
t.Fatalf("不正な hidden 値で外部取得または DB 操作を実行: title=%v episodes=%v subscribed=%d register=%d", fetcher.seriesTitleCalls, fetcher.seriesCalls, jobs.subscribedCalls, jobs.registerCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelSubscriptionURLClearsConfirmation(t *testing.T) {
|
||||
response := getPage(newTestHandlerWithStore(&fakeFetcher{}, &fakeJobStore{}, nil), "/subscriptions/from-url/cancel")
|
||||
body := response.Body.String()
|
||||
if response.Code != http.StatusOK || !strings.Contains(body, `id="subscription-url-result"`) || strings.Contains(body, `name="series_id"`) || strings.Contains(body, `role="alert"`) || strings.Contains(body, `role="status"`) {
|
||||
t.Fatalf("キャンセル応答: status=%d body=%q", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriptionURLPostsWithoutHtmxRedirect(t *testing.T) {
|
||||
fetcher := &fakeFetcher{seriesTitle: "番組", seriesEpisodes: []tver.Episode{}}
|
||||
jobs := &fakeJobStore{}
|
||||
handler := newTestHandlerWithStore(fetcher, jobs, nil)
|
||||
for _, tt := range []struct {
|
||||
path string
|
||||
form url.Values
|
||||
}{
|
||||
{"/subscriptions/from-url/confirm", url.Values{"series_url": {"https://tver.jp/series/sr1"}}},
|
||||
{"/subscriptions/from-url/register", url.Values{"series_id": {"sr1"}}},
|
||||
} {
|
||||
request := httptest.NewRequest(http.MethodPost, tt.path, strings.NewReader(tt.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") != "/subscriptions" {
|
||||
t.Errorf("通常POST %s: status=%d Location=%q", tt.path, response.Code, response.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
if len(fetcher.seriesTitleCalls) != 1 || jobs.registerCalls != 1 {
|
||||
t.Fatalf("登録POSTが処理されていない: fetch=%v register=%d", fetcher.seriesTitleCalls, jobs.registerCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriptionURLPostRoutesRejectGet(t *testing.T) {
|
||||
handler := newTestHandlerWithStore(&fakeFetcher{}, &fakeJobStore{}, nil)
|
||||
for _, path := range []string{"/subscriptions/from-url/confirm", "/subscriptions/from-url/register"} {
|
||||
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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,8 @@ type subscriptionView struct {
|
||||
}
|
||||
|
||||
type subscriptionsView struct {
|
||||
Items []subscriptionView
|
||||
Items []subscriptionView
|
||||
OutOfBand bool
|
||||
}
|
||||
|
||||
func (s *server) subscriptionChoice(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -18,11 +18,48 @@
|
||||
|
||||
{{define "subscriptions_page"}}
|
||||
<h1>登録番組</h1>
|
||||
<section class="subscription-url-entry">
|
||||
<h2>番組URLから登録</h2>
|
||||
<form action="/subscriptions/from-url/confirm" method="post" hx-post="/subscriptions/from-url/confirm" hx-target="#subscription-url-result" hx-swap="outerHTML">
|
||||
<label>番組URL <input id="subscription-url-input" type="url" name="series_url" placeholder="https://tver.jp/series/…" required></label>
|
||||
<button type="submit">確認</button>
|
||||
</form>
|
||||
<div id="subscription-url-result"></div>
|
||||
</section>
|
||||
{{template "subscriptions_list" .}}
|
||||
{{end}}
|
||||
|
||||
{{define "subscription_url_result"}}
|
||||
<div id="subscription-url-result">
|
||||
{{if .ErrorText}}
|
||||
<p role="alert">{{.ErrorText}}</p>
|
||||
{{else if .SeriesID}}
|
||||
<h3>{{.Title}}</h3>
|
||||
<p>番組ID: {{.SeriesID}}</p>
|
||||
{{if eq .EpisodeCount 0}}<p>公開中の回はありません</p>{{else}}<p>公開中の回数: {{.EpisodeCount}}件</p>{{end}}
|
||||
{{if .Subscribed}}
|
||||
<p role="status">登録済み</p>
|
||||
<button type="button" hx-get="/subscriptions/from-url/cancel" hx-target="#subscription-url-result" hx-swap="outerHTML">キャンセル</button>
|
||||
{{else}}
|
||||
<form action="/subscriptions/from-url/register" method="post" hx-post="/subscriptions/from-url/register" hx-target="#subscription-url-result" hx-swap="outerHTML">
|
||||
<input type="hidden" name="series_id" value="{{.SeriesID}}">
|
||||
<label><input type="checkbox" name="download_existing" value="1">公開中の回もダウンロードする</label>
|
||||
<button type="submit">登録</button>
|
||||
<button type="button" hx-get="/subscriptions/from-url/cancel" hx-target="#subscription-url-result" hx-swap="outerHTML">キャンセル</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "subscription_url_registered"}}
|
||||
<div id="subscription-url-result"><p role="status">登録済み</p></div>
|
||||
<input id="subscription-url-input" type="url" name="series_url" placeholder="https://tver.jp/series/…" required hx-swap-oob="outerHTML">
|
||||
{{template "subscriptions_list" .Subscriptions}}
|
||||
{{end}}
|
||||
|
||||
{{define "subscriptions_list"}}
|
||||
<div id="subscriptions-list">
|
||||
<div id="subscriptions-list" {{if .OutOfBand}}hx-swap-oob="outerHTML"{{end}}>
|
||||
{{range .Items}}
|
||||
<article class="subscription-item">
|
||||
<h2>{{.Title}}</h2>
|
||||
|
||||
Reference in New Issue
Block a user