新着一覧の画面を追加(ジャンル切替、予告の除外、10分キャッシュ)
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
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
|
||||
}
|
||||
|
||||
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 newTestHandler(fetcher *fakeFetcher, 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, 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, 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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user