jellyfin-tools/jellyfin-year-review/cmd/review/main_test.go
jellyfin-year-review/cmd/review/main_test.goRaw
1package main
2
3import (
4 "bytes"
5 "encoding/json"
6 "image"
7 "image/jpeg"
8 "net/http"
9 "net/http/httptest"
10 "os"
11 "path/filepath"
12 "strings"
13 "testing"
14
15 "reelreview/internal/generate"
16 "reelreview/internal/model"
17)
18
19// Test the actual CLI generation path against source-shaped Jellyfin and native
20// Jellystat fixtures, including parent-series artwork and deterministic caching.
21func TestGenerateDownloadsJellyfinPosters(t *testing.T) {
22 const user = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
23 const movie = "11111111111111111111111111111111"
24 const series = "22222222222222222222222222222222"
25 const episode = "33333333333333333333333333333333"
26 const key = "synthetic-operator-key"
27 var jpegBytes bytes.Buffer
28 if e := jpeg.Encode(&jpegBytes, image.NewRGBA(image.Rect(0, 0, 2, 3)), nil); e != nil {
29 t.Fatal(e)
30 }
31 images := 0
32 api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
33 // Jellyfin 12 accepts credentials in Authorization, without legacy headers.
34 if r.Header.Get("X-Emby-Token") != "" {
35 t.Error("unexpected legacy authentication header")
36 }
37 if !strings.Contains(r.Header.Get("Authorization"), `Token="`+key+`"`) {
38 w.WriteHeader(401)
39 return
40 }
41 switch r.URL.Path {
42 case "/jellyfin/Users":
43 json.NewEncoder(w).Encode([]map[string]any{{"Id": user, "Name": "Viewer"}})
44 case "/jellyfin/Items":
45 json.NewEncoder(w).Encode(map[string]any{"TotalRecordCount": 3, "Items": []map[string]any{
46 {"Id": movie, "Name": "Film", "Type": "Movie", "ImageTags": map[string]string{"Primary": "film-v1"}},
47 {"Id": series, "Name": "Show", "Type": "Series", "ImageTags": map[string]string{"Primary": "show-v1"}},
48 {"Id": episode, "Name": "Episode", "Type": "Episode", "SeriesId": series, "SeriesName": "Show"},
49 }})
50 case "/jellyfin/Items/" + movie + "/Images/Primary", "/jellyfin/Items/" + series + "/Images/Primary":
51 images++
52 w.Header().Set("Content-Type", "image/jpeg")
53 w.Write(jpegBytes.Bytes())
54 default:
55 t.Error("unexpected generation request")
56 w.WriteHeader(404)
57 }
58 }))
59 defer api.Close()
60 t.Setenv("JELLYFIN_URL", api.URL+"/jellyfin")
61 t.Setenv("JELLYFIN_API_KEY", key)
62 root := t.TempDir()
63 history := filepath.Join(root, "history.jsonl")
64 f, e := os.Create(history)
65 if e != nil {
66 t.Fatal(e)
67 }
68 enc := json.NewEncoder(f)
69 for _, row := range []map[string]any{
70 {"Id": "movie-row", "UserId": user, "NowPlayingItemId": movie, "PlaybackDuration": 3600, "ActivityDateInserted": "2025-02-03T19:00:00Z"},
71 {"Id": "episode-row", "UserId": user, "NowPlayingItemId": series, "EpisodeId": episode, "PlaybackDuration": 3600, "ActivityDateInserted": "2025-02-03T20:00:00Z"},
72 } {
73 if e = enc.Encode(row); e != nil {
74 t.Fatal(e)
75 }
76 }
77 f.Close()
78 output := filepath.Join(root, "data")
79 args := []string{"generate", "--year", "2025", "--history", history, "--captured-at", "2026-01-01T00:00:00Z", "--coverage", "synthetic native fixture", "--output", output, "--cache", filepath.Join(root, "cache")}
80 if e = run(args); e != nil {
81 t.Fatal(e)
82 }
83 before, e := generate.Current(output)
84 if e != nil {
85 t.Fatal(e)
86 }
87 if e = run(args); e != nil {
88 t.Fatal(e)
89 }
90 after, e := generate.Current(output)
91 if e != nil || before != after || images != 2 {
92 t.Fatal("generation was not deterministic or cache was not reused")
93 }
94 api.Close() // The published files must remain usable without their source.
95 b, e := os.ReadFile(filepath.Join(after, user, "2025.json"))
96 if e != nil {
97 t.Fatal(e)
98 }
99 if strings.Contains(string(b), key) || strings.Contains(string(b), api.URL) {
100 t.Fatal("source credentials or remote image URL entered review JSON")
101 }
102 var review model.Review
103 if e = json.Unmarshal(b, &review); e != nil {
104 t.Fatal(e)
105 }
106 if len(review.Movies) != 1 || len(review.Shows) != 1 || review.Seconds != 7200 {
107 t.Fatal("incorrect generated source statistics")
108 }
109 for _, poster := range []string{review.Movies[0].Poster, review.Shows[0].Poster} {
110 if !strings.HasPrefix(poster, "/art/") || !generate.SafeAsset(strings.TrimPrefix(poster, "/art/")) {
111 t.Fatal("nonlocal generated poster")
112 }
113 stored, e := os.ReadFile(filepath.Join(after, strings.TrimPrefix(poster, "/")))
114 if e != nil || !bytes.Equal(stored, jpegBytes.Bytes()) {
115 t.Fatal("local poster absent after source shutdown")
116 }
117 }
118}