package engine import ( "context" "io" "net/http" "strings" "testing" "time" "github.com/torrentclaw/torrentclaw-cli/internal/agent" ) // --------------------------------------------------------------------------- // StreamEngine unit tests (no network) // --------------------------------------------------------------------------- func TestStreamBuildMagnet(t *testing.T) { hash := "abc123def456abc123def456abc123def456abc1" magnet := buildMagnet(hash) if !strings.HasPrefix(magnet, "magnet:?xt=urn:btih:"+hash) { t.Errorf("magnet should start with btih, got: %s", magnet[:60]) } // Should contain trackers for _, tracker := range defaultTrackers { if !strings.Contains(magnet, "tr=") { t.Errorf("magnet should contain tracker param for %s", tracker) } } } func TestStreamBuildMagnetPassthrough(t *testing.T) { // If input already is a magnet, Start should use it directly // Here we test that buildMagnet produces a valid magnet from a hash hash := "0000000000000000000000000000000000000000" magnet := buildMagnet(hash) if !strings.Contains(magnet, hash) { t.Error("magnet should contain the info hash") } } func TestVideoExtensions(t *testing.T) { exts := []string{".mkv", ".mp4", ".avi", ".webm", ".mov", ".ts", ".flv", ".m4v", ".mpg", ".mpeg", ".vob", ".wmv"} for _, ext := range exts { if !VideoExts[ext] { t.Errorf("expected %s to be a video extension", ext) } } nonVideo := []string{".txt", ".zip", ".nfo", ".srt", ".jpg", ".exe"} for _, ext := range nonVideo { if VideoExts[ext] { t.Errorf("expected %s to NOT be a video extension", ext) } } } func TestCalculateBufferTarget(t *testing.T) { tests := []struct { name string totalBytes int64 bufferBytes int64 want int64 }{ {"small file (<200MB) uses 5%", 100 * 1024 * 1024, 0, 100 * 1024 * 1024 / 20}, {"large file (10GB) caps at 10MB", 10 * 1024 * 1024 * 1024, 0, 10 * 1024 * 1024}, {"medium file (500MB) caps at 10MB", 500 * 1024 * 1024, 0, 10 * 1024 * 1024}, // 5% of 500MB = 25MB > 10MB cap {"override takes precedence", 10 * 1024 * 1024 * 1024, 5 * 1024 * 1024, 5 * 1024 * 1024}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { s := &StreamEngine{ totalBytes: tt.totalBytes, cfg: StreamConfig{BufferBytes: tt.bufferBytes}, } got := s.calculateBufferTarget() if got != tt.want { t.Errorf("calculateBufferTarget() = %d, want %d", got, tt.want) } }) } } func TestIsVideoFile(t *testing.T) { tests := []struct { name string fileName string want bool }{ {"mp4", "movie.mp4", true}, {"mkv", "movie.mkv", true}, {"avi", "movie.avi", true}, {"nfo", "movie.nfo", false}, {"txt", "readme.txt", false}, {"srt", "subtitles.srt", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { s := &StreamEngine{fileName: tt.fileName} if got := s.IsVideoFile(); got != tt.want { t.Errorf("IsVideoFile(%q) = %v, want %v", tt.fileName, got, tt.want) } }) } } func TestStreamStatusConstants(t *testing.T) { // Verify status constants are distinct statuses := []StreamStatus{ StreamStatusMetadata, StreamStatusBuffering, StreamStatusReady, StreamStatusError, } seen := map[StreamStatus]bool{} for _, s := range statuses { if seen[s] { t.Errorf("duplicate status value: %d", s) } seen[s] = true } } func TestStreamEngineGetters(t *testing.T) { s := &StreamEngine{ fileName: "movie.mkv", totalBytes: 4 * 1024 * 1024 * 1024, bufferTarget: 10 * 1024 * 1024, } if s.FileName() != "movie.mkv" { t.Errorf("FileName() = %q", s.FileName()) } if s.FileLength() != 4*1024*1024*1024 { t.Errorf("FileLength() = %d", s.FileLength()) } if s.BufferTarget() != 10*1024*1024 { t.Errorf("BufferTarget() = %d", s.BufferTarget()) } } // --------------------------------------------------------------------------- // StreamServer unit tests // --------------------------------------------------------------------------- func TestMimeTypeFromExt(t *testing.T) { tests := []struct { filename string want string }{ {"movie.mp4", "video/mp4"}, {"movie.m4v", "video/mp4"}, {"movie.mkv", "video/x-matroska"}, {"movie.avi", "video/x-msvideo"}, {"movie.webm", "video/webm"}, {"movie.mov", "video/quicktime"}, {"movie.ts", "video/mp2t"}, {"movie.flv", "video/x-flv"}, {"movie.mpg", "video/mpeg"}, {"movie.mpeg", "video/mpeg"}, {"movie.wmv", "video/x-ms-wmv"}, {"movie.vob", "video/x-ms-vob"}, {"unknown.xyz", "application/octet-stream"}, {"file.MP4", "video/mp4"}, // case insensitive {"FILE.MKV", "video/x-matroska"}, } for _, tt := range tests { t.Run(tt.filename, func(t *testing.T) { got := mimeTypeFromExt(tt.filename) if got != tt.want { t.Errorf("mimeTypeFromExt(%q) = %q, want %q", tt.filename, got, tt.want) } }) } } func TestStreamServerStartShutdown(t *testing.T) { // Test server lifecycle without a real StreamEngine // We can't test actual streaming, but we can test the HTTP server mechanics // Create a minimal engine with just enough state for the server s := &StreamEngine{ fileName: "test.mp4", totalBytes: 1024, } srv := NewStreamServer(s, 0) if srv.Port() != 0 { t.Errorf("initial port should be 0, got %d", srv.Port()) } // We can't Start() because NewFileReader needs a real torrent File // But we can test that Shutdown on an un-started server doesn't panic if err := srv.Shutdown(context.Background()); err != nil { t.Errorf("shutdown of un-started server should not error: %v", err) } } // --------------------------------------------------------------------------- // Task integration with stream fields // --------------------------------------------------------------------------- func TestNewTaskFromAgentWithMode(t *testing.T) { at := agent.Task{ ID: "stream-task-1", InfoHash: "abc123def456abc123def456abc123def456abc1", Title: "Movie (2024)", PreferredMethod: "auto", Mode: "stream", } task := NewTaskFromAgent(at) if task.Mode != "stream" { t.Errorf("Mode = %q, want stream", task.Mode) } if task.Status != StatusClaimed { t.Errorf("Status = %q, want claimed", task.Status) } } func TestNewTaskFromAgentDefaultMode(t *testing.T) { at := agent.Task{ ID: "download-task-1", InfoHash: "abc123def456abc123def456abc123def456abc1", PreferredMethod: "auto", // Mode not set } task := NewTaskFromAgent(at) if task.Mode != "download" { t.Errorf("Mode = %q, want download (default)", task.Mode) } } func TestToStatusUpdateIncludesStreamURL(t *testing.T) { task := &Task{ ID: "stream-task-2", Status: StatusDownloading, ResolvedMethod: MethodTorrent, Mode: "stream", StreamURL: "http://127.0.0.1:43210/stream", DownloadedBytes: 500, TotalBytes: 1000, SpeedBps: 100, FileName: "movie.mkv", } update := task.ToStatusUpdate() if update.StreamURL != "http://127.0.0.1:43210/stream" { t.Errorf("StreamURL = %q, want http://127.0.0.1:43210/stream", update.StreamURL) } if update.Status != "downloading" { t.Errorf("Status = %q", update.Status) } } func TestToStatusUpdateNoStreamURL(t *testing.T) { task := &Task{ ID: "download-task-2", Status: StatusDownloading, ResolvedMethod: MethodTorrent, Mode: "download", } update := task.ToStatusUpdate() if update.StreamURL != "" { t.Errorf("StreamURL should be empty for download tasks, got %q", update.StreamURL) } } // --------------------------------------------------------------------------- // StreamServer HTTP test (with mock ReadSeeker) // --------------------------------------------------------------------------- func TestStreamHTTPHandler(t *testing.T) { // We create an HTTP handler manually to test Range request support // This simulates what StreamServer.handler does, but with a string reader content := strings.Repeat("X", 1000) // 1KB of data handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reader := strings.NewReader(content) w.Header().Set("Content-Type", "video/mp4") http.ServeContent(w, r, "test.mp4", time.Time{}, reader) }) // Test full content request t.Run("full request", func(t *testing.T) { req, _ := http.NewRequest("GET", "/stream", nil) rr := &responseRecorder{headers: http.Header{}, body: &strings.Builder{}} handler.ServeHTTP(rr, req) if rr.statusCode != http.StatusOK { t.Errorf("status = %d, want 200", rr.statusCode) } if ct := rr.headers.Get("Content-Type"); ct != "video/mp4" { t.Errorf("Content-Type = %q, want video/mp4", ct) } if rr.body.Len() != 1000 { t.Errorf("body length = %d, want 1000", rr.body.Len()) } }) // Test Range request t.Run("range request", func(t *testing.T) { req, _ := http.NewRequest("GET", "/stream", nil) req.Header.Set("Range", "bytes=0-99") rr := &responseRecorder{headers: http.Header{}, body: &strings.Builder{}} handler.ServeHTTP(rr, req) if rr.statusCode != http.StatusPartialContent { t.Errorf("status = %d, want 206 Partial Content", rr.statusCode) } if rr.body.Len() != 100 { t.Errorf("body length = %d, want 100", rr.body.Len()) } }) // Test Range request middle t.Run("range request middle", func(t *testing.T) { req, _ := http.NewRequest("GET", "/stream", nil) req.Header.Set("Range", "bytes=500-599") rr := &responseRecorder{headers: http.Header{}, body: &strings.Builder{}} handler.ServeHTTP(rr, req) if rr.statusCode != http.StatusPartialContent { t.Errorf("status = %d, want 206", rr.statusCode) } if rr.body.Len() != 100 { t.Errorf("body length = %d, want 100", rr.body.Len()) } }) // Test HEAD request t.Run("HEAD request", func(t *testing.T) { req, _ := http.NewRequest("HEAD", "/stream", nil) rr := &responseRecorder{headers: http.Header{}, body: &strings.Builder{}} handler.ServeHTTP(rr, req) if rr.statusCode != http.StatusOK { t.Errorf("status = %d, want 200", rr.statusCode) } }) } // responseRecorder is a minimal http.ResponseWriter for testing type responseRecorder struct { statusCode int headers http.Header body *strings.Builder } func (r *responseRecorder) Header() http.Header { return r.headers } func (r *responseRecorder) WriteHeader(code int) { r.statusCode = code } func (r *responseRecorder) Write(b []byte) (int, error) { if r.statusCode == 0 { r.statusCode = http.StatusOK } return r.body.Write(b) } // Ensure responseRecorder implements ReadSeeker expectations func (r *responseRecorder) ReadFrom(src io.Reader) (int64, error) { n, err := io.Copy(r.body, src) return n, err }