Files
tver/internal/web/newer_test.go
T

272 lines
11 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
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) {
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{"&lt;script&gt;", "&lt;b&gt;title&lt;/b&gt;", "&lt;img src=x onerror=alert(1)&gt;", "&lt;svg onload=alert(1)&gt;"} {
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())
}
}