arnaucube
arnaucube/jellyfin-toolsPublic Self-hosted repository
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}