core: harden watch-order relations
This commit is contained in:
@@ -2,245 +2,129 @@ package jikan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"sort"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mal/internal/watchorder"
|
||||
)
|
||||
|
||||
var canonicalRelationOrder = []string{
|
||||
"prequel",
|
||||
"sequel",
|
||||
"parent story",
|
||||
"full story",
|
||||
"alternative version",
|
||||
"alternative setting",
|
||||
}
|
||||
const chiakiWatchOrderURL = "https://chiaki.site/?/tools/watch_order/id/%d"
|
||||
const watchOrderCacheTTL = time.Hour * 24
|
||||
const maxWatchOrderEntries = 120
|
||||
|
||||
var extraRelationOrder = []string{
|
||||
"side story",
|
||||
"spin-off",
|
||||
"summary",
|
||||
"other",
|
||||
}
|
||||
|
||||
var relationPriorityOrder = append(
|
||||
append([]string{}, canonicalRelationOrder...),
|
||||
extraRelationOrder...,
|
||||
)
|
||||
|
||||
func relationKey(rel string) string {
|
||||
key := strings.ToLower(strings.TrimSpace(rel))
|
||||
key = strings.ReplaceAll(key, "_", " ")
|
||||
key = strings.Join(strings.Fields(key), " ")
|
||||
|
||||
switch key {
|
||||
case "prequels":
|
||||
return "prequel"
|
||||
case "sequels":
|
||||
return "sequel"
|
||||
case "side stories":
|
||||
return "side story"
|
||||
case "spin off", "spinoff":
|
||||
return "spin-off"
|
||||
func watchOrderTypeLabel(value string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
switch normalized {
|
||||
case "tv":
|
||||
return "TV"
|
||||
case "movie":
|
||||
return "Movie"
|
||||
default:
|
||||
return key
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
|
||||
func relationLabel(rel string) string {
|
||||
key := relationKey(rel)
|
||||
switch key {
|
||||
case "prequel":
|
||||
return "Prequels"
|
||||
case "sequel":
|
||||
return "Sequels"
|
||||
case "parent story":
|
||||
return "Parent story"
|
||||
case "full story":
|
||||
return "Full story"
|
||||
case "alternative version":
|
||||
return "Alternative version"
|
||||
case "alternative setting":
|
||||
return "Alternative setting"
|
||||
case "side story":
|
||||
return "Side story"
|
||||
case "spin-off":
|
||||
return "Spin-off"
|
||||
case "summary":
|
||||
return "Summary"
|
||||
case "other":
|
||||
return "Other"
|
||||
default:
|
||||
return strings.TrimSpace(rel)
|
||||
}
|
||||
func isAllowedWatchOrderType(value string) bool {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
return normalized == "tv" || normalized == "movie"
|
||||
}
|
||||
|
||||
func isCanonicalRelation(rel string) bool {
|
||||
return slices.Contains(canonicalRelationOrder, relationKey(rel))
|
||||
func relationCacheKey(id int) string {
|
||||
return fmt.Sprintf("relations:watch-order:%d", id)
|
||||
}
|
||||
|
||||
func isExtraRelation(rel string) bool {
|
||||
return slices.Contains(extraRelationOrder, relationKey(rel))
|
||||
}
|
||||
func (c *Client) getWatchOrder(ctx context.Context, id int) (watchorder.WatchOrderResult, error) {
|
||||
cacheKey := relationCacheKey(id)
|
||||
|
||||
func isFranchiseRelation(rel string) bool {
|
||||
return isCanonicalRelation(rel) || isExtraRelation(rel)
|
||||
}
|
||||
|
||||
func relationOrder(rel string) int {
|
||||
key := relationKey(rel)
|
||||
for i, allowed := range relationPriorityOrder {
|
||||
if key == allowed {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(relationPriorityOrder) + 1
|
||||
}
|
||||
|
||||
func relationAiredAt(anime Anime) (time.Time, bool) {
|
||||
from := strings.TrimSpace(anime.Aired.From)
|
||||
if from != "" {
|
||||
if parsed, err := time.Parse(time.RFC3339, from); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
if parsed, err := time.Parse("2006-01-02", from); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
var cached watchorder.WatchOrderResult
|
||||
if c.getCache(ctx, cacheKey, &cached) {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
if anime.Year > 0 {
|
||||
return time.Date(anime.Year, time.January, 1, 0, 0, 0, 0, time.UTC), true
|
||||
watchOrderURL := fmt.Sprintf(chiakiWatchOrderURL, id)
|
||||
requestCtx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := watchorder.FetchWatchOrder(requestCtx, c.httpClient, watchOrderURL)
|
||||
if err != nil {
|
||||
return watchorder.WatchOrderResult{}, err
|
||||
}
|
||||
|
||||
return time.Time{}, false
|
||||
c.setCache(ctx, cacheKey, result, watchOrderCacheTTL)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func sortRelationEntriesChronological(entries []RelationEntry) {
|
||||
sort.SliceStable(entries, func(i int, j int) bool {
|
||||
left := entries[i]
|
||||
right := entries[j]
|
||||
|
||||
leftAiredAt, leftHasAiredAt := relationAiredAt(left.Anime)
|
||||
rightAiredAt, rightHasAiredAt := relationAiredAt(right.Anime)
|
||||
|
||||
if leftHasAiredAt != rightHasAiredAt {
|
||||
return leftHasAiredAt
|
||||
}
|
||||
|
||||
if leftHasAiredAt && !leftAiredAt.Equal(rightAiredAt) {
|
||||
return leftAiredAt.Before(rightAiredAt)
|
||||
}
|
||||
|
||||
leftRelationOrder := relationOrder(left.Relation)
|
||||
rightRelationOrder := relationOrder(right.Relation)
|
||||
if leftRelationOrder != rightRelationOrder {
|
||||
return leftRelationOrder < rightRelationOrder
|
||||
}
|
||||
|
||||
leftTitle := strings.ToLower(left.Anime.DisplayTitle())
|
||||
rightTitle := strings.ToLower(right.Anime.DisplayTitle())
|
||||
if leftTitle != rightTitle {
|
||||
return leftTitle < rightTitle
|
||||
}
|
||||
|
||||
return left.Anime.MalID < right.Anime.MalID
|
||||
})
|
||||
}
|
||||
|
||||
func relationEntries(ctx context.Context, c *Client, anime Anime) ([]RelationEntry, error) {
|
||||
entries := make([]RelationEntry, 0)
|
||||
|
||||
for _, group := range anime.Relations {
|
||||
if !isFranchiseRelation(group.Relation) {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, entry := range group.Entry {
|
||||
if entry.Type != "anime" {
|
||||
continue
|
||||
}
|
||||
|
||||
relAnime, err := c.GetAnimeByID(ctx, entry.MalID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries = append(entries, RelationEntry{
|
||||
Anime: relAnime,
|
||||
Relation: relationLabel(group.Relation),
|
||||
IsCurrent: false,
|
||||
IsExtra: !isCanonicalRelation(group.Relation),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func relationMap(ctx context.Context, c *Client, id int) (map[int]RelationEntry, error) {
|
||||
func (c *Client) currentOnlyRelation(ctx context.Context, id int) ([]RelationEntry, error) {
|
||||
currentAnime, err := c.GetAnimeByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := map[int]RelationEntry{
|
||||
currentAnime.MalID: {
|
||||
Anime: currentAnime,
|
||||
Relation: "Current",
|
||||
IsCurrent: true,
|
||||
IsExtra: false,
|
||||
},
|
||||
return []RelationEntry{{
|
||||
Anime: currentAnime,
|
||||
Relation: "Current",
|
||||
IsCurrent: true,
|
||||
IsExtra: false,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry, error) {
|
||||
result, err := c.getWatchOrder(ctx, id)
|
||||
if err != nil {
|
||||
return c.currentOnlyRelation(ctx, id)
|
||||
}
|
||||
|
||||
queue := []Anime{currentAnime}
|
||||
visited := map[int]bool{currentAnime.MalID: true}
|
||||
seen := make(map[int]bool)
|
||||
relations := make([]RelationEntry, 0, len(result.WatchOrder)+1)
|
||||
|
||||
for len(queue) > 0 {
|
||||
anime := queue[0]
|
||||
queue = queue[1:]
|
||||
for _, watchOrderEntry := range result.WatchOrder {
|
||||
if len(relations) >= maxWatchOrderEntries {
|
||||
break
|
||||
}
|
||||
|
||||
entries, err := relationEntries(ctx, c, anime)
|
||||
if !isAllowedWatchOrderType(watchOrderEntry.Type) {
|
||||
continue
|
||||
}
|
||||
|
||||
if seen[watchOrderEntry.ID] {
|
||||
continue
|
||||
}
|
||||
|
||||
anime, err := c.GetAnimeByID(ctx, watchOrderEntry.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[watchOrderEntry.ID] = true
|
||||
relations = append(relations, RelationEntry{
|
||||
Anime: anime,
|
||||
Relation: watchOrderTypeLabel(watchOrderEntry.Type),
|
||||
IsCurrent: watchOrderEntry.ID == id,
|
||||
IsExtra: false,
|
||||
})
|
||||
if watchOrderEntry.ID == id {
|
||||
relations[len(relations)-1].Relation = "Current"
|
||||
}
|
||||
}
|
||||
|
||||
if !seen[id] {
|
||||
currentAnime, err := c.GetAnimeByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, rel := range entries {
|
||||
existing, exists := result[rel.Anime.MalID]
|
||||
if !exists {
|
||||
result[rel.Anime.MalID] = rel
|
||||
} else if !existing.IsCurrent {
|
||||
if existing.IsExtra && !rel.IsExtra {
|
||||
// Prefer canonical timeline links over extras when both point to the same anime.
|
||||
result[rel.Anime.MalID] = rel
|
||||
} else if existing.IsExtra && rel.IsExtra && relationOrder(rel.Relation) < relationOrder(existing.Relation) {
|
||||
// Keep the most specific extra label when multiple extra relations exist.
|
||||
result[rel.Anime.MalID] = rel
|
||||
}
|
||||
}
|
||||
|
||||
if !rel.IsExtra && !visited[rel.Anime.MalID] {
|
||||
visited[rel.Anime.MalID] = true
|
||||
queue = append(queue, rel.Anime)
|
||||
}
|
||||
}
|
||||
relations = append([]RelationEntry{{
|
||||
Anime: currentAnime,
|
||||
Relation: "Current",
|
||||
IsCurrent: true,
|
||||
IsExtra: false,
|
||||
}}, relations...)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry, error) {
|
||||
relationByID, err := relationMap(ctx, c, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ordered := make([]RelationEntry, 0, len(relationByID))
|
||||
for _, entry := range relationByID {
|
||||
ordered = append(ordered, entry)
|
||||
}
|
||||
|
||||
sortRelationEntriesChronological(ordered)
|
||||
|
||||
return ordered, nil
|
||||
if len(relations) == 0 {
|
||||
return c.currentOnlyRelation(ctx, id)
|
||||
}
|
||||
|
||||
return relations, nil
|
||||
}
|
||||
|
||||
48
internal/jikan/relations_test.go
Normal file
48
internal/jikan/relations_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package jikan
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsAllowedWatchOrderType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{name: "tv", input: "tv", want: true},
|
||||
{name: "movie", input: "movie", want: true},
|
||||
{name: "case and whitespace", input: " TV ", want: true},
|
||||
{name: "tv special", input: "tv special", want: false},
|
||||
{name: "ova", input: "ova", want: false},
|
||||
{name: "empty", input: "", want: false},
|
||||
}
|
||||
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
got := isAllowedWatchOrderType(testCase.input)
|
||||
if got != testCase.want {
|
||||
t.Fatalf("expected %v, got %v", testCase.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchOrderTypeLabel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{name: "tv", input: "tv", want: "TV"},
|
||||
{name: "movie", input: "movie", want: "Movie"},
|
||||
{name: "trimmed passthrough", input: " tv special ", want: "tv special"},
|
||||
}
|
||||
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
got := watchOrderTypeLabel(testCase.input)
|
||||
if got != testCase.want {
|
||||
t.Fatalf("expected %q, got %q", testCase.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
184
internal/watchorder/watch_order.go
Normal file
184
internal/watchorder/watch_order.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package watchorder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
const defaultUserAgent = "anime-relations-scraper/1.0 (+https://github.com/mkelvers/anime-relations)"
|
||||
|
||||
var idPattern = regexp.MustCompile(`/id/(\d+)`)
|
||||
|
||||
var ErrInvalidWatchOrderURL = errors.New("invalid watch order url")
|
||||
|
||||
type WatchOrderEntry struct {
|
||||
ID int `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
TitleAlt string `json:"title_alt,omitempty"`
|
||||
}
|
||||
|
||||
type WatchOrderResult struct {
|
||||
ID int `json:"id"`
|
||||
WatchOrder []WatchOrderEntry `json:"watch_order"`
|
||||
}
|
||||
|
||||
type watchOrderRow struct {
|
||||
id int
|
||||
typeID int
|
||||
title string
|
||||
alternativeTitle string
|
||||
}
|
||||
|
||||
func parseRootID(url string) (int, error) {
|
||||
match := idPattern.FindStringSubmatch(url)
|
||||
if len(match) != 2 {
|
||||
return 0, ErrInvalidWatchOrderURL
|
||||
}
|
||||
|
||||
id, err := strconv.Atoi(match[1])
|
||||
if err != nil {
|
||||
return 0, ErrInvalidWatchOrderURL
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func fetchDocument(ctx context.Context, httpClient *http.Client, url string) (*goquery.Document, error) {
|
||||
client := httpClient
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
request.Header.Set("User-Agent", defaultUserAgent)
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode)
|
||||
}
|
||||
|
||||
document, err := goquery.NewDocumentFromReader(response.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse html: %w", err)
|
||||
}
|
||||
|
||||
return document, nil
|
||||
}
|
||||
|
||||
func extractTypeLabelsByID(doc *goquery.Document) map[int]string {
|
||||
typeLabels := make(map[int]string)
|
||||
|
||||
doc.Find("#wo_type_filter label").Each(func(_ int, selection *goquery.Selection) {
|
||||
input := selection.Find("input[type='checkbox']")
|
||||
rawID, exists := input.Attr("value")
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
|
||||
typeID, err := strconv.Atoi(strings.TrimSpace(rawID))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
label := strings.TrimSpace(selection.Text())
|
||||
if label == "" {
|
||||
return
|
||||
}
|
||||
|
||||
typeLabels[typeID] = label
|
||||
})
|
||||
|
||||
return typeLabels
|
||||
}
|
||||
|
||||
func parseAttrInt(selection *goquery.Selection, attrName string) (int, bool) {
|
||||
rawValue, exists := selection.Attr(attrName)
|
||||
if !exists {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
value, err := strconv.Atoi(strings.TrimSpace(rawValue))
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return value, true
|
||||
}
|
||||
|
||||
func extractRows(doc *goquery.Document) []watchOrderRow {
|
||||
rows := make([]watchOrderRow, 0)
|
||||
|
||||
doc.Find("tr[data-id]").Each(func(_ int, selection *goquery.Selection) {
|
||||
id, ok := parseAttrInt(selection, "data-id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
typeID, ok := parseAttrInt(selection, "data-type")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(selection.Find(".wo_title").First().Text())
|
||||
alternativeTitle := strings.TrimSpace(selection.Find(".uk-text-small").First().Text())
|
||||
|
||||
rows = append(rows, watchOrderRow{
|
||||
id: id,
|
||||
typeID: typeID,
|
||||
title: title,
|
||||
alternativeTitle: alternativeTitle,
|
||||
})
|
||||
})
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
func FetchWatchOrder(ctx context.Context, httpClient *http.Client, url string) (WatchOrderResult, error) {
|
||||
rootID, err := parseRootID(url)
|
||||
if err != nil {
|
||||
return WatchOrderResult{}, err
|
||||
}
|
||||
|
||||
doc, err := fetchDocument(ctx, httpClient, url)
|
||||
if err != nil {
|
||||
return WatchOrderResult{}, err
|
||||
}
|
||||
|
||||
rows := extractRows(doc)
|
||||
if len(rows) == 0 {
|
||||
return WatchOrderResult{ID: rootID, WatchOrder: []WatchOrderEntry{}}, nil
|
||||
}
|
||||
|
||||
typeByID := extractTypeLabelsByID(doc)
|
||||
|
||||
entries := make([]WatchOrderEntry, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
typeName := strings.TrimSpace(typeByID[row.typeID])
|
||||
|
||||
entries = append(entries, WatchOrderEntry{
|
||||
ID: row.id,
|
||||
Type: typeName,
|
||||
Title: row.title,
|
||||
TitleAlt: row.alternativeTitle,
|
||||
})
|
||||
}
|
||||
|
||||
return WatchOrderResult{ID: rootID, WatchOrder: entries}, nil
|
||||
}
|
||||
105
internal/watchorder/watch_order_test.go
Normal file
105
internal/watchorder/watch_order_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package watchorder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testServer(body string) *httptest.Server {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(body))
|
||||
})
|
||||
|
||||
return httptest.NewServer(handler)
|
||||
}
|
||||
|
||||
func testHTMLWithMetadata() string {
|
||||
return `
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<div id="wo_type_filter">
|
||||
<label><input type="checkbox" value="1" checked> TV</label>
|
||||
<label><input type="checkbox" value="3" checked> Movie</label>
|
||||
</div>
|
||||
<table id="wo_list">
|
||||
<tr data-id="442" data-anilist-id="442" data-type="3">
|
||||
<td>
|
||||
<span class="wo_title">Naruto Movie 1</span>
|
||||
<span class="uk-text-small">Naruto the Movie 1</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
func testHTMLEmptyRows() string {
|
||||
return `
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<div id="wo_type_filter">
|
||||
<label><input type="checkbox" value="1" checked> TV</label>
|
||||
<label><input type="checkbox" value="3" checked> Movie</label>
|
||||
</div>
|
||||
<table id="wo_list"></table>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
func TestFetchWatchOrder_OutputShape(t *testing.T) {
|
||||
server := testServer(testHTMLWithMetadata())
|
||||
defer server.Close()
|
||||
|
||||
url := server.URL + "/?/tools/watch_order/id/442"
|
||||
result, err := FetchWatchOrder(context.Background(), &http.Client{Timeout: time.Second}, url)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if result.ID != 442 {
|
||||
t.Fatalf("expected root id 442, got %d", result.ID)
|
||||
}
|
||||
|
||||
if len(result.WatchOrder) != 1 {
|
||||
t.Fatalf("expected 1 watch_order entry, got %d", len(result.WatchOrder))
|
||||
}
|
||||
|
||||
entry := result.WatchOrder[0]
|
||||
if entry.ID != 442 {
|
||||
t.Fatalf("expected entry id 442, got %d", entry.ID)
|
||||
}
|
||||
if entry.Type != "Movie" {
|
||||
t.Fatalf("expected type Movie, got %q", entry.Type)
|
||||
}
|
||||
if entry.Title != "Naruto Movie 1" {
|
||||
t.Fatalf("expected title Naruto Movie 1, got %q", entry.Title)
|
||||
}
|
||||
if entry.TitleAlt != "Naruto the Movie 1" {
|
||||
t.Fatalf("expected title_alt Naruto the Movie 1, got %q", entry.TitleAlt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchWatchOrder_NoRowsReturnsEmpty(t *testing.T) {
|
||||
server := testServer(testHTMLEmptyRows())
|
||||
defer server.Close()
|
||||
|
||||
url := server.URL + "/?/tools/watch_order/id/1535"
|
||||
result, err := FetchWatchOrder(context.Background(), &http.Client{Timeout: time.Second}, url)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if result.ID != 1535 {
|
||||
t.Fatalf("expected root id 1535, got %d", result.ID)
|
||||
}
|
||||
|
||||
if len(result.WatchOrder) != 0 {
|
||||
t.Fatalf("expected no entries, got %d", len(result.WatchOrder))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user