unarr/internal/engine/thumbnail_test.go

171 lines
5.6 KiB
Go
Raw Normal View History

package engine
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func thumbReq(remoteAddr, query string) *http.Request {
r := httptest.NewRequest(http.MethodGet, "http://stream.test/thumbnail"+query, nil)
r.RemoteAddr = remoteAddr
return r
}
func indexOfArg(ss []string, target string) int {
for i, s := range ss {
if s == target {
return i
}
}
return -1
}
// TestStreamScopeThumb_Vector pins the scope string against the web's
// TypeScript minter (tests/unit/stream-token.test.ts asserts the same vector).
// A token the web mints for a file MUST reduce to the same scope here or the
// thumbnail 404s.
func TestStreamScopeThumb_Vector(t *testing.T) {
got := streamScopeThumb("/movies/Example (2020)/Example.mkv")
const want = "thumb:d3f919154ea48832a0b52e4b4ca3e81185ea5f4e2b9e5fece32c6651908cbdd8"
if got != want {
t.Fatalf("streamScopeThumb mismatch (web parity broken!): got %q want %q", got, want)
}
}
func TestStreamScopeThumb_DistinctPerPath(t *testing.T) {
a := streamScopeThumb("/a.mkv")
b := streamScopeThumb("/b.mkv")
if a == b {
t.Error("distinct paths produced the same thumb scope")
}
if streamScopeThumb("/a.mkv") != a {
t.Error("same path produced a different thumb scope (not deterministic)")
}
if !strings.HasPrefix(a, "thumb:") || len(a) != len("thumb:")+64 {
t.Errorf("scope %q is not thumb:<64 hex>", a)
}
}
func TestBuildThumbnailArgs(t *testing.T) {
args := buildThumbnailArgs("/x/movie.mkv", 123.5, 320)
joined := strings.Join(args, " ")
ssIdx, iIdx := indexOfArg(args, "-ss"), indexOfArg(args, "-i")
if ssIdx < 0 || iIdx < 0 || ssIdx > iIdx {
t.Errorf("-ss must precede -i (fast input seek): %v", args)
}
if args[ssIdx+1] != "123.500" {
t.Errorf("pos arg = %q, want 123.500", args[ssIdx+1])
}
if args[iIdx+1] != "/x/movie.mkv" {
t.Errorf("input arg = %q, want the path", args[iIdx+1])
}
for _, want := range []string{"-frames:v 1", "scale=320:-2", "-f mjpeg", "pipe:1", "-an", "-sn"} {
if !strings.Contains(joined, want) {
t.Errorf("args missing %q: %v", want, args)
}
}
}
func TestParseThumbPos(t *testing.T) {
cases := map[string]float64{"": 0, "abc": 0, "-5": 0, "0": 0, "12.5": 12.5, "600": 600}
for in, want := range cases {
if got := parseThumbPos(in); got != want {
t.Errorf("parseThumbPos(%q) = %v, want %v", in, got, want)
}
}
}
func TestParseThumbWidth(t *testing.T) {
cases := map[string]int{"": 320, "abc": 320, "10": 80, "5000": 640, "200": 200, "640": 640, "80": 80}
for in, want := range cases {
if got := parseThumbWidth(in); got != want {
t.Errorf("parseThumbWidth(%q) = %v, want %v", in, got, want)
}
}
}
func TestThumbnailHandler_MissingPath_400(t *testing.T) {
srv := NewStreamServer(0)
rec := httptest.NewRecorder()
srv.thumbnailHandler(rec, thumbReq("198.51.100.7:40000", ""))
if rec.Code != http.StatusBadRequest {
t.Errorf("missing path: status = %d, want 400", rec.Code)
}
}
func TestThumbnailHandler_BadToken_404(t *testing.T) {
srv := NewStreamServer(0)
rec := httptest.NewRecorder()
// Path present (so we pass the 400 gate) but a bogus token → 404, no oracle.
srv.thumbnailHandler(rec, thumbReq("198.51.100.7:40000", "?p="+url.QueryEscape("/tmp/x.mkv")+"&t=deadbeef.0000"))
if rec.Code != http.StatusNotFound {
t.Errorf("bad token: status = %d, want 404", rec.Code)
}
}
func TestThumbnailHandler_ValidToken_NonexistentFile_404(t *testing.T) {
srv := NewStreamServer(0)
path := "/nonexistent/never-here.mkv"
tok := mintStreamToken(srv.streamSecret, streamScopeThumb(path), time.Now())
rec := httptest.NewRecorder()
srv.thumbnailHandler(rec, thumbReq("198.51.100.7:40000", "?p="+url.QueryEscape(path)+"&t="+tok))
if rec.Code != http.StatusNotFound {
t.Errorf("valid token but missing file: status = %d, want 404 (regular-file clamp)", rec.Code)
}
}
func TestThumbnailHandler_NoFFmpeg_503(t *testing.T) {
srv := NewStreamServer(0) // ffmpegPath left empty
dir := t.TempDir()
path := filepath.Join(dir, "movie.mkv")
if err := os.WriteFile(path, []byte("not really a video"), 0o600); err != nil {
t.Fatal(err)
}
tok := mintStreamToken(srv.streamSecret, streamScopeThumb(path), time.Now())
rec := httptest.NewRecorder()
srv.thumbnailHandler(rec, thumbReq("198.51.100.7:40000", "?p="+url.QueryEscape(path)+"&t="+tok))
if rec.Code != http.StatusServiceUnavailable {
t.Errorf("no ffmpeg configured: status = %d, want 503", rec.Code)
}
}
// TestThumbnailHandler_Success exercises the full success branch with a stub
// "ffmpeg" that writes JPEG magic bytes to stdout — no real ffmpeg/video
// needed. Validates 200 + image/jpeg + the body is passed through verbatim.
func TestThumbnailHandler_Success(t *testing.T) {
srv := NewStreamServer(0)
dir := t.TempDir()
path := filepath.Join(dir, "movie.mkv")
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
stub := filepath.Join(dir, "ffmpeg.sh")
// JPEG SOI marker (FF D8 FF) + filler, regardless of args.
if err := os.WriteFile(stub, []byte("#!/bin/sh\nprintf '\\377\\330\\377stub'\n"), 0o755); err != nil {
t.Fatal(err)
}
srv.SetFFmpegPath(stub)
tok := mintStreamToken(srv.streamSecret, streamScopeThumb(path), time.Now())
rec := httptest.NewRecorder()
srv.thumbnailHandler(rec, thumbReq("198.51.100.7:40000",
"?p="+url.QueryEscape(path)+"&t="+tok+"&pos=10&w=200"))
if rec.Code != http.StatusOK {
t.Fatalf("stub ffmpeg: status = %d, want 200 (body=%q)", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "image/jpeg" {
t.Errorf("Content-Type = %q, want image/jpeg", ct)
}
if !strings.HasPrefix(rec.Body.String(), "\xff\xd8\xff") {
t.Errorf("body missing JPEG magic bytes: %q", rec.Body.String())
}
}