264 lines
10 KiB
Go
264 lines
10 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.kazu634.com/kazu634/tver/internal/tver"
|
|
)
|
|
|
|
type fakeFetcher struct {
|
|
episodes []tver.Episode
|
|
episodesByGenre map[string][]tver.Episode
|
|
err error
|
|
calls []string
|
|
seriesEpisodes []tver.Episode
|
|
seriesErr error
|
|
seriesCalls []string
|
|
}
|
|
|
|
func (f *fakeFetcher) FetchNewer(_ context.Context, genre string) ([]tver.Episode, error) {
|
|
f.calls = append(f.calls, genre)
|
|
if f.episodesByGenre != nil {
|
|
return f.episodesByGenre[genre], f.err
|
|
}
|
|
return f.episodes, f.err
|
|
}
|
|
|
|
func (f *fakeFetcher) FetchSeriesEpisodes(_ context.Context, seriesID string) ([]tver.Episode, error) {
|
|
f.seriesCalls = append(f.seriesCalls, seriesID)
|
|
return f.seriesEpisodes, f.seriesErr
|
|
}
|
|
|
|
func newTestHandler(fetcher *fakeFetcher, now func() time.Time) http.Handler {
|
|
return newTestHandlerWithStore(fetcher, &fakeJobStore{}, now)
|
|
}
|
|
|
|
func newTestHandlerWithStore(fetcher *fakeFetcher, jobs *fakeJobStore, now func() time.Time) http.Handler {
|
|
return newTestHandlerWithVideoDir(fetcher, jobs, "", now)
|
|
}
|
|
|
|
func newTestHandlerWithVideoDir(fetcher *fakeFetcher, jobs *fakeJobStore, videoDir string, now func() time.Time) http.Handler {
|
|
if now == nil {
|
|
now = func() time.Time { return time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC) }
|
|
}
|
|
return NewHandler(fetcher, jobs, videoDir, now, log.New(io.Discard, "", 0))
|
|
}
|
|
|
|
func getPage(handler http.Handler, path string) *httptest.ResponseRecorder {
|
|
response := httptest.NewRecorder()
|
|
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil))
|
|
return response
|
|
}
|
|
|
|
func TestNewerPageShowsEpisodeFieldsAndJST(t *testing.T) {
|
|
endAt := time.Date(2026, 10, 3, 21, 34, 0, 0, time.UTC).Unix()
|
|
fetcher := &fakeFetcher{episodes: []tver.Episode{{
|
|
ID: "ep123", SeriesTitle: "番組名", Title: "第1話", BroadcasterName: "放送局",
|
|
Duration: 2712, EndAt: endAt, ThumbnailPath: "/images/ep123.jpg",
|
|
BroadcastDateLabel: "10月3日放送分",
|
|
}}}
|
|
response := getPage(newTestHandler(fetcher, nil), "/")
|
|
if response.Code != http.StatusOK {
|
|
t.Fatalf("status = %d", response.Code)
|
|
}
|
|
body := response.Body.String()
|
|
for _, want := range []string{
|
|
"番組名", "第1話", "放送局", "10月3日放送分",
|
|
"長さ: 46分",
|
|
"https://statics.tver.jp/images/ep123.jpg",
|
|
"https://tver.jp/episodes/ep123",
|
|
"2026-10-04 06:34 JST", `datetime="2026-10-04T06:34:00+09:00"`,
|
|
`value="drama" selected`, `name="hide_previews" value="1" checked`,
|
|
`hx-get="/newer"`, `hx-target="#newer-results"`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("page missing %q", want)
|
|
}
|
|
}
|
|
if len(fetcher.calls) != 1 || fetcher.calls[0] != "drama" {
|
|
t.Errorf("calls = %v", fetcher.calls)
|
|
}
|
|
}
|
|
|
|
func TestDurationMinutesRoundsUp(t *testing.T) {
|
|
for _, tt := range []struct {
|
|
seconds int64
|
|
minutes int64
|
|
}{
|
|
{0, 1}, {1, 1}, {59, 1}, {60, 1}, {61, 2}, {2712, 46},
|
|
} {
|
|
if got := durationMinutes(tt.seconds); got != tt.minutes {
|
|
t.Errorf("%d seconds = %d minutes; want %d", tt.seconds, got, tt.minutes)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNewerFragmentUpdatesURLAndFullPageRestoresState(t *testing.T) {
|
|
fetcher := &fakeFetcher{episodes: []tver.Episode{{ID: "variety-1", Duration: 200}}}
|
|
handler := newTestHandler(fetcher, nil)
|
|
response := getPage(handler, "/newer?genre=variety&hide_previews=0")
|
|
if response.Code != http.StatusOK {
|
|
t.Fatalf("fragment status = %d", response.Code)
|
|
}
|
|
if got := response.Header().Get("HX-Push-Url"); got != "/?genre=variety&hide_previews=0" {
|
|
t.Errorf("HX-Push-Url = %q", got)
|
|
}
|
|
if body := response.Body.String(); !strings.Contains(body, `id="newer-results"`) || !strings.Contains(body, "variety-1") || strings.Contains(body, "<html") {
|
|
t.Errorf("not an episode fragment: %s", body)
|
|
}
|
|
reload := getPage(handler, "/?genre=variety&hide_previews=0")
|
|
if reload.Code != http.StatusOK || !strings.Contains(reload.Body.String(), `value="variety" selected`) || strings.Contains(reload.Body.String(), `value="1" checked`) || !strings.Contains(reload.Body.String(), "variety-1") {
|
|
t.Errorf("reload did not restore state: status=%d body=%s", reload.Code, reload.Body.String())
|
|
}
|
|
if len(fetcher.calls) != 1 || fetcher.calls[0] != "variety" {
|
|
t.Errorf("calls = %v", fetcher.calls)
|
|
}
|
|
}
|
|
|
|
func TestNewerInvalidGenreReturns400WithoutFetch(t *testing.T) {
|
|
fetcher := &fakeFetcher{}
|
|
handler := newTestHandler(fetcher, nil)
|
|
for _, path := range []string{"/?genre=news", "/newer?genre=news", "/?genre=", "/?hide_previews=invalid", "/newer?hide_previews="} {
|
|
response := getPage(handler, path)
|
|
if response.Code != http.StatusBadRequest {
|
|
t.Errorf("%s: status = %d", path, response.Code)
|
|
}
|
|
}
|
|
if len(fetcher.calls) != 0 {
|
|
t.Errorf("unexpected fetch calls: %v", fetcher.calls)
|
|
}
|
|
}
|
|
|
|
func TestPreviewFilterBoundaryAndToggle(t *testing.T) {
|
|
fetcher := &fakeFetcher{episodes: []tver.Episode{
|
|
{ID: "preview-180", Duration: 180},
|
|
{ID: "episode-181", Duration: 181},
|
|
}}
|
|
handler := newTestHandler(fetcher, nil)
|
|
hidden := getPage(handler, "/")
|
|
if strings.Contains(hidden.Body.String(), "preview-180") || !strings.Contains(hidden.Body.String(), "episode-181") {
|
|
t.Errorf("default filter: %s", hidden.Body.String())
|
|
}
|
|
shown := getPage(handler, "/newer?genre=drama&hide_previews=0")
|
|
if !strings.Contains(shown.Body.String(), "preview-180") || !strings.Contains(shown.Body.String(), "episode-181") {
|
|
t.Errorf("show previews: %s", shown.Body.String())
|
|
}
|
|
checked := getPage(handler, "/newer?genre=drama&hide_previews=0&hide_previews=1")
|
|
if strings.Contains(checked.Body.String(), "preview-180") || checked.Header().Get("HX-Push-Url") != "/?genre=drama&hide_previews=1" {
|
|
t.Errorf("checked form state: %s", checked.Body.String())
|
|
}
|
|
if len(fetcher.calls) != 1 {
|
|
t.Errorf("filter caused %d TVer fetches; want 1", len(fetcher.calls))
|
|
}
|
|
}
|
|
|
|
func TestNewerCacheExpiresPerGenre(t *testing.T) {
|
|
now := time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC)
|
|
fetcher := &fakeFetcher{episodes: []tver.Episode{{ID: "ep", Duration: 200}}}
|
|
handler := newTestHandler(fetcher, func() time.Time { return now })
|
|
getPage(handler, "/")
|
|
now = now.Add(9*time.Minute + 59*time.Second)
|
|
getPage(handler, "/?genre=drama&hide_previews=0")
|
|
getPage(handler, "/?genre=variety")
|
|
if got := strings.Join(fetcher.calls, ","); got != "drama,variety" {
|
|
t.Fatalf("before expiry calls = %q", got)
|
|
}
|
|
now = now.Add(2 * time.Second)
|
|
getPage(handler, "/")
|
|
getPage(handler, "/?genre=variety")
|
|
if got := strings.Join(fetcher.calls, ","); got != "drama,variety,drama" {
|
|
t.Errorf("after expiry calls = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestNewerCacheKeepsGenreResultsSeparateAndExpiresAtTenMinutes(t *testing.T) {
|
|
now := time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC)
|
|
fetcher := &fakeFetcher{episodesByGenre: map[string][]tver.Episode{
|
|
"drama": {{ID: "drama-only", Duration: 200}},
|
|
"variety": {{ID: "variety-only", Duration: 200}},
|
|
}}
|
|
handler := newTestHandler(fetcher, func() time.Time { return now })
|
|
checkPage := func(path, wantID, absentID string) {
|
|
t.Helper()
|
|
response := getPage(handler, path)
|
|
body := response.Body.String()
|
|
if response.Code != http.StatusOK || !strings.Contains(body, "/episodes/"+wantID) || strings.Contains(body, "/episodes/"+absentID) {
|
|
t.Errorf("%s at %s: status=%d, wrong genre contents: %s", path, now.Format(time.RFC3339), response.Code, body)
|
|
}
|
|
}
|
|
checkCalls := func(want string) {
|
|
t.Helper()
|
|
if got := strings.Join(fetcher.calls, ","); got != want {
|
|
t.Errorf("fetch calls = %q; want %q", got, want)
|
|
}
|
|
}
|
|
checkPage("/", "drama-only", "variety-only")
|
|
now = now.Add(5 * time.Minute)
|
|
checkPage("/?genre=variety", "variety-only", "drama-only")
|
|
now = now.Add(4*time.Minute + 59*time.Second)
|
|
checkPage("/", "drama-only", "variety-only")
|
|
checkCalls("drama,variety")
|
|
now = now.Add(time.Second)
|
|
checkPage("/", "drama-only", "variety-only")
|
|
checkPage("/?genre=variety", "variety-only", "drama-only")
|
|
checkCalls("drama,variety,drama")
|
|
now = now.Add(5 * time.Minute)
|
|
checkPage("/?genre=variety", "variety-only", "drama-only")
|
|
checkCalls("drama,variety,drama,variety")
|
|
}
|
|
|
|
func TestNewerPageEscapesUntrustedEpisodeFields(t *testing.T) {
|
|
fetcher := &fakeFetcher{episodes: []tver.Episode{{
|
|
ID: "safe-id", SeriesTitle: `<script>alert(1)</script>`, Title: `<b>title</b>`,
|
|
BroadcasterName: `<img src=x onerror=alert(1)>`, BroadcastDateLabel: `<svg onload=alert(1)>`,
|
|
ThumbnailPath: `/image.jpg" onerror="alert(1)`, Duration: 200,
|
|
}}}
|
|
response := getPage(newTestHandler(fetcher, nil), "/")
|
|
if response.Code != http.StatusOK {
|
|
t.Fatalf("status = %d", response.Code)
|
|
}
|
|
body := response.Body.String()
|
|
for _, unsafe := range []string{"<script>alert(1)</script>", "<b>title</b>", "<img src=x", "<svg onload", `onerror="alert(1)"`} {
|
|
if strings.Contains(body, unsafe) {
|
|
t.Errorf("untrusted markup was not escaped: %q", unsafe)
|
|
}
|
|
}
|
|
for _, escaped := range []string{"<script>", "<b>title</b>", "<img src=x onerror=alert(1)>", "<svg onload=alert(1)>"} {
|
|
if !strings.Contains(body, escaped) {
|
|
t.Errorf("escaped field missing: %q", escaped)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNewerFailureShowsFriendlyMessageAndRedactsToken(t *testing.T) {
|
|
fetcher := &fakeFetcher{err: errors.New("GET https://platform-api.tver.jp/?platform_token=secret-123&x=1: upstream unavailable")}
|
|
var logs bytes.Buffer
|
|
handler := NewHandler(fetcher, &fakeJobStore{}, "", time.Now, log.New(&logs, "", 0))
|
|
response := getPage(handler, "/")
|
|
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "新着を取得できませんでした") {
|
|
t.Errorf("error page: status=%d body=%s", response.Code, response.Body.String())
|
|
}
|
|
if strings.Contains(response.Body.String(), "upstream") || strings.Contains(response.Body.String(), "secret-123") {
|
|
t.Errorf("error detail leaked into page: %s", response.Body.String())
|
|
}
|
|
fragment := getPage(handler, "/newer?genre=variety&hide_previews=0")
|
|
if fragment.Code != http.StatusOK || !strings.Contains(fragment.Body.String(), `id="newer-results"`) || !strings.Contains(fragment.Body.String(), "新着を取得できませんでした") || strings.Contains(fragment.Body.String(), "<html") {
|
|
t.Errorf("error fragment: status=%d body=%s", fragment.Code, fragment.Body.String())
|
|
}
|
|
if fragment.Header().Get("HX-Push-Url") != "/?genre=variety&hide_previews=0" || strings.Contains(fragment.Body.String(), "secret-123") {
|
|
t.Errorf("error fragment URL or token leak: header=%q body=%s", fragment.Header().Get("HX-Push-Url"), fragment.Body.String())
|
|
}
|
|
if !strings.Contains(logs.String(), "upstream unavailable") || strings.Contains(logs.String(), "secret-123") {
|
|
t.Errorf("log detail or token redaction failed: %s", logs.String())
|
|
}
|
|
}
|