261 lines
14 KiB
Go
261 lines
14 KiB
Go
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"))
|
|
}
|
|
}
|
|
}
|