From 63891501e57cc82cb757c209ee240767b00a62cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Guillot?= Date: Mon, 7 Jul 2025 18:37:01 -0700 Subject: [PATCH] refactor(model): add test coverage and simplify `ProxifyEnclosureURL` --- internal/api/enclosure.go | 3 +- internal/api/entry.go | 4 +- internal/googlereader/handler.go | 3 +- internal/mediaproxy/media_proxy_test.go | 142 ++++++ internal/mediaproxy/rewriter.go | 42 +- internal/model/enclosure.go | 39 +- internal/model/enclosure_test.go | 559 +++++++++++++++++++++++- 7 files changed, 746 insertions(+), 46 deletions(-) diff --git a/internal/api/enclosure.go b/internal/api/enclosure.go index 2fc176be..90dee627 100644 --- a/internal/api/enclosure.go +++ b/internal/api/enclosure.go @@ -7,6 +7,7 @@ import ( json_parser "encoding/json" "net/http" + "miniflux.app/v2/internal/config" "miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/http/response/json" "miniflux.app/v2/internal/model" @@ -33,7 +34,7 @@ func (h *handler) getEnclosureByID(w http.ResponseWriter, r *http.Request) { return } - enclosure.ProxifyEnclosureURL(h.router) + enclosure.ProxifyEnclosureURL(h.router, config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes()) json.OK(w, r, enclosure) } diff --git a/internal/api/entry.go b/internal/api/entry.go index dd4c7fbb..d1c2976e 100644 --- a/internal/api/entry.go +++ b/internal/api/entry.go @@ -10,6 +10,7 @@ import ( "strconv" "time" + "miniflux.app/v2/internal/config" "miniflux.app/v2/internal/http/request" "miniflux.app/v2/internal/http/response/json" "miniflux.app/v2/internal/integration" @@ -34,8 +35,7 @@ func (h *handler) getEntryFromBuilder(w http.ResponseWriter, r *http.Request, b } entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content) - - entry.Enclosures.ProxifyEnclosureURL(h.router) + entry.Enclosures.ProxifyEnclosureURL(h.router, config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes()) json.OK(w, r, entry) } diff --git a/internal/googlereader/handler.go b/internal/googlereader/handler.go index f07bea6e..ec58733a 100644 --- a/internal/googlereader/handler.go +++ b/internal/googlereader/handler.go @@ -763,8 +763,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque } entry.Content = mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content) - - entry.Enclosures.ProxifyEnclosureURL(h.router) + entry.Enclosures.ProxifyEnclosureURL(h.router, config.Opts.MediaProxyMode(), config.Opts.MediaProxyResourceTypes()) contentItems[i] = contentItem{ ID: convertEntryIDToLongFormItemID(entry.ID), diff --git a/internal/mediaproxy/media_proxy_test.go b/internal/mediaproxy/media_proxy_test.go index b43b7dee..1151ba06 100644 --- a/internal/mediaproxy/media_proxy_test.go +++ b/internal/mediaproxy/media_proxy_test.go @@ -589,3 +589,145 @@ func TestProxyFilterVideoPosterOnce(t *testing.T) { t.Errorf(`Not expected output: got %s`, output) } } + +func TestShouldProxifyURLWithMimeType(t *testing.T) { + testCases := []struct { + name string + mediaURL string + mediaMimeType string + mediaProxyOption string + mediaProxyResourceTypes []string + expected bool + }{ + { + name: "Empty URL should not be proxified", + mediaURL: "", + mediaMimeType: "image/jpeg", + mediaProxyOption: "all", + mediaProxyResourceTypes: []string{"image"}, + expected: false, + }, + { + name: "Data URL should not be proxified", + mediaURL: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==", + mediaMimeType: "image/png", + mediaProxyOption: "all", + mediaProxyResourceTypes: []string{"image"}, + expected: false, + }, + { + name: "HTTP URL with all mode and matching MIME type should be proxified", + mediaURL: "http://example.com/image.jpg", + mediaMimeType: "image/jpeg", + mediaProxyOption: "all", + mediaProxyResourceTypes: []string{"image"}, + expected: true, + }, + { + name: "HTTPS URL with all mode and matching MIME type should be proxified", + mediaURL: "https://example.com/image.jpg", + mediaMimeType: "image/jpeg", + mediaProxyOption: "all", + mediaProxyResourceTypes: []string{"image"}, + expected: true, + }, + { + name: "HTTP URL with http-only mode and matching MIME type should be proxified", + mediaURL: "http://example.com/image.jpg", + mediaMimeType: "image/jpeg", + mediaProxyOption: "http-only", + mediaProxyResourceTypes: []string{"image"}, + expected: true, + }, + { + name: "HTTPS URL with http-only mode should not be proxified", + mediaURL: "https://example.com/image.jpg", + mediaMimeType: "image/jpeg", + mediaProxyOption: "http-only", + mediaProxyResourceTypes: []string{"image"}, + expected: false, + }, + { + name: "URL with none mode should not be proxified", + mediaURL: "http://example.com/image.jpg", + mediaMimeType: "image/jpeg", + mediaProxyOption: "none", + mediaProxyResourceTypes: []string{"image"}, + expected: false, + }, + { + name: "URL with matching MIME type should be proxified", + mediaURL: "http://example.com/video.mp4", + mediaMimeType: "video/mp4", + mediaProxyOption: "all", + mediaProxyResourceTypes: []string{"video"}, + expected: true, + }, + { + name: "URL with non-matching MIME type should not be proxified", + mediaURL: "http://example.com/video.mp4", + mediaMimeType: "video/mp4", + mediaProxyOption: "all", + mediaProxyResourceTypes: []string{"image"}, + expected: false, + }, + { + name: "URL with multiple resource types and matching MIME type should be proxified", + mediaURL: "http://example.com/audio.mp3", + mediaMimeType: "audio/mp3", + mediaProxyOption: "all", + mediaProxyResourceTypes: []string{"image", "audio", "video"}, + expected: true, + }, + { + name: "URL with multiple resource types but non-matching MIME type should not be proxified", + mediaURL: "http://example.com/document.pdf", + mediaMimeType: "application/pdf", + mediaProxyOption: "all", + mediaProxyResourceTypes: []string{"image", "audio", "video"}, + expected: false, + }, + { + name: "URL with empty resource types should not be proxified", + mediaURL: "http://example.com/image.jpg", + mediaMimeType: "image/jpeg", + mediaProxyOption: "all", + mediaProxyResourceTypes: []string{}, + expected: false, + }, + { + name: "URL with partial MIME type match should be proxified", + mediaURL: "http://example.com/image.jpg", + mediaMimeType: "image/jpeg", + mediaProxyOption: "all", + mediaProxyResourceTypes: []string{"image"}, + expected: true, + }, + { + name: "URL with audio MIME type and audio resource type should be proxified", + mediaURL: "http://example.com/song.ogg", + mediaMimeType: "audio/ogg", + mediaProxyOption: "all", + mediaProxyResourceTypes: []string{"audio"}, + expected: true, + }, + { + name: "URL with video MIME type and video resource type should be proxified", + mediaURL: "http://example.com/movie.webm", + mediaMimeType: "video/webm", + mediaProxyOption: "all", + mediaProxyResourceTypes: []string{"video"}, + expected: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := ShouldProxifyURLWithMimeType(tc.mediaURL, tc.mediaMimeType, tc.mediaProxyOption, tc.mediaProxyResourceTypes) + if result != tc.expected { + t.Errorf("Expected %v, got %v for URL: %s, MIME type: %s, proxy option: %s, resource types: %v", + tc.expected, result, tc.mediaURL, tc.mediaMimeType, tc.mediaProxyOption, tc.mediaProxyResourceTypes) + } + }) + } +} diff --git a/internal/mediaproxy/rewriter.go b/internal/mediaproxy/rewriter.go index d1b0cd0d..a96e1305 100644 --- a/internal/mediaproxy/rewriter.go +++ b/internal/mediaproxy/rewriter.go @@ -41,7 +41,7 @@ func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter, case "image": doc.Find("img, picture source").Each(func(i int, img *goquery.Selection) { if srcAttrValue, ok := img.Attr("src"); ok { - if shouldProxy(srcAttrValue, proxyOption) { + if shouldProxifyURL(srcAttrValue, proxyOption) { img.SetAttr("src", proxifyFunction(router, srcAttrValue)) } } @@ -54,7 +54,7 @@ func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter, if !slices.Contains(config.Opts.MediaProxyResourceTypes(), "video") { doc.Find("video").Each(func(i int, video *goquery.Selection) { if posterAttrValue, ok := video.Attr("poster"); ok { - if shouldProxy(posterAttrValue, proxyOption) { + if shouldProxifyURL(posterAttrValue, proxyOption) { video.SetAttr("poster", proxifyFunction(router, posterAttrValue)) } } @@ -64,7 +64,7 @@ func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter, case "audio": doc.Find("audio, audio source").Each(func(i int, audio *goquery.Selection) { if srcAttrValue, ok := audio.Attr("src"); ok { - if shouldProxy(srcAttrValue, proxyOption) { + if shouldProxifyURL(srcAttrValue, proxyOption) { audio.SetAttr("src", proxifyFunction(router, srcAttrValue)) } } @@ -73,13 +73,13 @@ func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter, case "video": doc.Find("video, video source").Each(func(i int, video *goquery.Selection) { if srcAttrValue, ok := video.Attr("src"); ok { - if shouldProxy(srcAttrValue, proxyOption) { + if shouldProxifyURL(srcAttrValue, proxyOption) { video.SetAttr("src", proxifyFunction(router, srcAttrValue)) } } if posterAttrValue, ok := video.Attr("poster"); ok { - if shouldProxy(posterAttrValue, proxyOption) { + if shouldProxifyURL(posterAttrValue, proxyOption) { video.SetAttr("poster", proxifyFunction(router, posterAttrValue)) } } @@ -99,7 +99,7 @@ func proxifySourceSet(element *goquery.Selection, router *mux.Router, proxifyFun imageCandidates := sanitizer.ParseSrcSetAttribute(srcsetAttrValue) for _, imageCandidate := range imageCandidates { - if shouldProxy(imageCandidate.ImageURL, proxyOption) { + if shouldProxifyURL(imageCandidate.ImageURL, proxyOption) { imageCandidate.ImageURL = proxifyFunction(router, imageCandidate.ImageURL) } } @@ -107,15 +107,33 @@ func proxifySourceSet(element *goquery.Selection, router *mux.Router, proxifyFun element.SetAttr("srcset", imageCandidates.String()) } -func shouldProxy(attrValue, proxyOption string) bool { - if strings.HasPrefix(attrValue, "data:") { +// shouldProxifyURL checks if the media URL should be proxified based on the media proxy option and URL scheme. +func shouldProxifyURL(mediaURL, mediaProxyOption string) bool { + switch { + case mediaURL == "": + return false + case strings.HasPrefix(mediaURL, "data:"): + return false + case mediaProxyOption == "all": + return true + case mediaProxyOption != "none" && !urllib.IsHTTPS(mediaURL): + return true + default: return false } - if proxyOption == "all" { - return true +} + +// ShouldProxifyURLWithMimeType checks if the media URL should be proxified based on the media proxy option, URL scheme, and MIME type. +func ShouldProxifyURLWithMimeType(mediaURL, mediaMimeType, mediaProxyOption string, mediaProxyResourceTypes []string) bool { + if !shouldProxifyURL(mediaURL, mediaProxyOption) { + return false } - if !urllib.IsHTTPS(attrValue) { - return true + + for _, mediaType := range mediaProxyResourceTypes { + if strings.HasPrefix(mediaMimeType, mediaType+"/") { + return true + } } + return false } diff --git a/internal/model/enclosure.go b/internal/model/enclosure.go index 94c02002..07115d9e 100644 --- a/internal/model/enclosure.go +++ b/internal/model/enclosure.go @@ -7,9 +7,8 @@ import ( "strings" "github.com/gorilla/mux" - "miniflux.app/v2/internal/config" + "miniflux.app/v2/internal/mediaproxy" - "miniflux.app/v2/internal/urllib" ) // Enclosure represents an attachment. @@ -52,6 +51,13 @@ func (e *Enclosure) IsImage() bool { return strings.HasSuffix(mediaURL, ".jpg") || strings.HasSuffix(mediaURL, ".jpeg") || strings.HasSuffix(mediaURL, ".png") || strings.HasSuffix(mediaURL, ".gif") } +// ProxifyEnclosureURL modifies the enclosure URL to use the media proxy if necessary. +func (e *Enclosure) ProxifyEnclosureURL(router *mux.Router, mediaProxyOption string, mediaProxyResourceTypes []string) { + if mediaproxy.ShouldProxifyURLWithMimeType(e.URL, e.MimeType, mediaProxyOption, mediaProxyResourceTypes) { + e.URL = mediaproxy.ProxifyAbsoluteURL(router, e.URL) + } +} + // EnclosureList represents a list of attachments. type EnclosureList []*Enclosure @@ -77,31 +83,8 @@ func (el EnclosureList) ContainsAudioOrVideo() bool { return false } -func (el EnclosureList) ProxifyEnclosureURL(router *mux.Router) { - proxyOption := config.Opts.MediaProxyMode() - - if proxyOption != "none" { - for i := range el { - if urllib.IsHTTPS(el[i].URL) { - proxifyAbsoluteURLIfMimeType(el[i], router) - } - } - } -} - -func (e *Enclosure) ProxifyEnclosureURL(router *mux.Router) { - proxyOption := config.Opts.MediaProxyMode() - - if proxyOption == "all" || proxyOption != "none" && !urllib.IsHTTPS(e.URL) { - proxifyAbsoluteURLIfMimeType(e, router) - } -} - -func proxifyAbsoluteURLIfMimeType(e *Enclosure, router *mux.Router) { - for _, mediaType := range config.Opts.MediaProxyResourceTypes() { - if strings.HasPrefix(e.MimeType, mediaType+"/") { - e.URL = mediaproxy.ProxifyAbsoluteURL(router, e.URL) - break - } +func (el EnclosureList) ProxifyEnclosureURL(router *mux.Router, mediaProxyOption string, mediaProxyResourceTypes []string) { + for _, enclosure := range el { + enclosure.ProxifyEnclosureURL(router, mediaProxyOption, mediaProxyResourceTypes) } } diff --git a/internal/model/enclosure_test.go b/internal/model/enclosure_test.go index 37b19b4d..bea1420b 100644 --- a/internal/model/enclosure_test.go +++ b/internal/model/enclosure_test.go @@ -4,7 +4,12 @@ package model import ( + "net/http" + "os" "testing" + + "github.com/gorilla/mux" + "miniflux.app/v2/internal/config" ) func TestEnclosure_Html5MimeTypeGivesOriginalMimeType(t *testing.T) { @@ -26,8 +31,560 @@ func TestEnclosure_Html5MimeTypeReplaceStandardM4vByAppleSpecificMimeType(t *tes // tested at the time of this commit (06/2023) on latest Firefox & Vivaldi on this feed // https://www.florenceporcel.com/podcast/lfhdu.xml t.Fatalf( - "HTML5 MimeType must be replaced by 'video/x-m4v' when originally video/m4v to ensure playbacks in brownser. Got '%s'", + "HTML5 MimeType must be replaced by 'video/x-m4v' when originally video/m4v to ensure playbacks in browsers. Got '%s'", enclosure.Html5MimeType(), ) } } + +func TestEnclosure_IsAudio(t *testing.T) { + testCases := []struct { + name string + mimeType string + expected bool + }{ + {"MP3 audio", "audio/mpeg", true}, + {"WAV audio", "audio/wav", true}, + {"OGG audio", "audio/ogg", true}, + {"Mixed case audio", "Audio/MP3", true}, + {"Video file", "video/mp4", false}, + {"Image file", "image/jpeg", false}, + {"Text file", "text/plain", false}, + {"Empty mime type", "", false}, + {"Audio with extra info", "audio/mpeg; charset=utf-8", true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + enclosure := &Enclosure{MimeType: tc.mimeType} + if got := enclosure.IsAudio(); got != tc.expected { + t.Errorf("IsAudio() = %v, want %v for mime type %s", got, tc.expected, tc.mimeType) + } + }) + } +} + +func TestEnclosure_IsVideo(t *testing.T) { + testCases := []struct { + name string + mimeType string + expected bool + }{ + {"MP4 video", "video/mp4", true}, + {"AVI video", "video/avi", true}, + {"WebM video", "video/webm", true}, + {"M4V video", "video/m4v", true}, + {"Mixed case video", "Video/MP4", true}, + {"Audio file", "audio/mpeg", false}, + {"Image file", "image/jpeg", false}, + {"Text file", "text/plain", false}, + {"Empty mime type", "", false}, + {"Video with extra info", "video/mp4; codecs=\"avc1.42E01E\"", true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + enclosure := &Enclosure{MimeType: tc.mimeType} + if got := enclosure.IsVideo(); got != tc.expected { + t.Errorf("IsVideo() = %v, want %v for mime type %s", got, tc.expected, tc.mimeType) + } + }) + } +} + +func TestEnclosure_IsImage(t *testing.T) { + testCases := []struct { + name string + mimeType string + url string + expected bool + }{ + {"JPEG image by mime", "image/jpeg", "http://example.com/file", true}, + {"PNG image by mime", "image/png", "http://example.com/file", true}, + {"GIF image by mime", "image/gif", "http://example.com/file", true}, + {"Mixed case image mime", "Image/JPEG", "http://example.com/file", true}, + {"JPG file extension", "application/octet-stream", "http://example.com/photo.jpg", true}, + {"JPEG file extension", "text/plain", "http://example.com/photo.jpeg", true}, + {"PNG file extension", "unknown/type", "http://example.com/photo.png", true}, + {"GIF file extension", "binary/data", "http://example.com/photo.gif", true}, + {"Mixed case extension", "text/plain", "http://example.com/photo.JPG", true}, + {"Image mime and extension", "image/jpeg", "http://example.com/photo.jpg", true}, + {"Video file", "video/mp4", "http://example.com/video.mp4", false}, + {"Audio file", "audio/mpeg", "http://example.com/audio.mp3", false}, + {"Text file", "text/plain", "http://example.com/file.txt", false}, + {"No extension", "text/plain", "http://example.com/file", false}, + {"Other extension", "text/plain", "http://example.com/file.pdf", false}, + {"Empty values", "", "", false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + enclosure := &Enclosure{MimeType: tc.mimeType, URL: tc.url} + if got := enclosure.IsImage(); got != tc.expected { + t.Errorf("IsImage() = %v, want %v for mime type %s and URL %s", got, tc.expected, tc.mimeType, tc.url) + } + }) + } +} + +func TestEnclosureList_FindMediaPlayerEnclosure(t *testing.T) { + testCases := []struct { + name string + enclosures EnclosureList + expectedNil bool + }{ + { + name: "Returns first audio enclosure", + enclosures: EnclosureList{ + &Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"}, + &Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"}, + }, + expectedNil: false, + }, + { + name: "Returns first video enclosure", + enclosures: EnclosureList{ + &Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"}, + &Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"}, + }, + expectedNil: false, + }, + { + name: "Skips image enclosure and returns audio", + enclosures: EnclosureList{ + &Enclosure{URL: "http://example.com/image.jpg", MimeType: "image/jpeg"}, + &Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"}, + }, + expectedNil: false, + }, + { + name: "Skips enclosure with empty URL", + enclosures: EnclosureList{ + &Enclosure{URL: "", MimeType: "audio/mpeg"}, + &Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"}, + }, + expectedNil: false, + }, + { + name: "Returns nil for no media enclosures", + enclosures: EnclosureList{ + &Enclosure{URL: "http://example.com/image.jpg", MimeType: "image/jpeg"}, + &Enclosure{URL: "http://example.com/doc.pdf", MimeType: "application/pdf"}, + }, + expectedNil: true, + }, + { + name: "Returns nil for empty list", + enclosures: EnclosureList{}, + expectedNil: true, + }, + { + name: "Returns nil for all empty URLs", + enclosures: EnclosureList{ + &Enclosure{URL: "", MimeType: "audio/mpeg"}, + &Enclosure{URL: "", MimeType: "video/mp4"}, + }, + expectedNil: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := tc.enclosures.FindMediaPlayerEnclosure() + if tc.expectedNil { + if result != nil { + t.Errorf("FindMediaPlayerEnclosure() = %v, want nil", result) + } + } else { + if result == nil { + t.Errorf("FindMediaPlayerEnclosure() = nil, want non-nil") + } else if !result.IsAudio() && !result.IsVideo() { + t.Errorf("FindMediaPlayerEnclosure() returned non-media enclosure: %s", result.MimeType) + } + } + }) + } +} + +func TestEnclosureList_ContainsAudioOrVideo(t *testing.T) { + testCases := []struct { + name string + enclosures EnclosureList + expected bool + }{ + { + name: "Contains audio", + enclosures: EnclosureList{ + &Enclosure{MimeType: "audio/mpeg"}, + &Enclosure{MimeType: "image/jpeg"}, + }, + expected: true, + }, + { + name: "Contains video", + enclosures: EnclosureList{ + &Enclosure{MimeType: "image/jpeg"}, + &Enclosure{MimeType: "video/mp4"}, + }, + expected: true, + }, + { + name: "Contains both audio and video", + enclosures: EnclosureList{ + &Enclosure{MimeType: "audio/mpeg"}, + &Enclosure{MimeType: "video/mp4"}, + }, + expected: true, + }, + { + name: "Contains only images", + enclosures: EnclosureList{ + &Enclosure{MimeType: "image/jpeg"}, + &Enclosure{MimeType: "image/png"}, + }, + expected: false, + }, + { + name: "Contains only documents", + enclosures: EnclosureList{ + &Enclosure{MimeType: "application/pdf"}, + &Enclosure{MimeType: "text/plain"}, + }, + expected: false, + }, + { + name: "Empty list", + enclosures: EnclosureList{}, + expected: false, + }, + { + name: "Single audio enclosure", + enclosures: EnclosureList{ + &Enclosure{MimeType: "audio/wav"}, + }, + expected: true, + }, + { + name: "Single video enclosure", + enclosures: EnclosureList{ + &Enclosure{MimeType: "video/webm"}, + }, + expected: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := tc.enclosures.ContainsAudioOrVideo() + if result != tc.expected { + t.Errorf("ContainsAudioOrVideo() = %v, want %v", result, tc.expected) + } + }) + } +} + +func TestEnclosure_ProxifyEnclosureURL(t *testing.T) { + // Initialize config for testing + os.Clearenv() + os.Setenv("BASE_URL", "http://localhost") + os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test-private-key") + + var err error + parser := config.NewParser() + config.Opts, err = parser.ParseEnvironmentVariables() + if err != nil { + t.Fatalf(`Config parsing failure: %v`, err) + } + + router := mux.NewRouter() + router.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy") + + testCases := []struct { + name string + url string + mimeType string + mediaProxyOption string + mediaProxyResourceTypes []string + expectedURLChanged bool + }{ + { + name: "HTTP URL with audio type - proxy mode all", + url: "http://example.com/audio.mp3", + mimeType: "audio/mpeg", + mediaProxyOption: "all", + mediaProxyResourceTypes: []string{"audio", "video"}, + expectedURLChanged: true, + }, + { + name: "HTTPS URL with video type - proxy mode all", + url: "https://example.com/video.mp4", + mimeType: "video/mp4", + mediaProxyOption: "all", + mediaProxyResourceTypes: []string{"audio", "video"}, + expectedURLChanged: true, + }, + { + name: "HTTP URL with video type - proxy mode http-only", + url: "http://example.com/video.mp4", + mimeType: "video/mp4", + mediaProxyOption: "http-only", + mediaProxyResourceTypes: []string{"audio", "video"}, + expectedURLChanged: true, + }, + { + name: "HTTPS URL with video type - proxy mode http-only", + url: "https://example.com/video.mp4", + mimeType: "video/mp4", + mediaProxyOption: "http-only", + mediaProxyResourceTypes: []string{"audio", "video"}, + expectedURLChanged: false, + }, + { + name: "HTTP URL with image type - not in resource types", + url: "http://example.com/image.jpg", + mimeType: "image/jpeg", + mediaProxyOption: "all", + mediaProxyResourceTypes: []string{"audio", "video"}, + expectedURLChanged: false, + }, + { + name: "HTTP URL with image type - in resource types", + url: "http://example.com/image.jpg", + mimeType: "image/jpeg", + mediaProxyOption: "all", + mediaProxyResourceTypes: []string{"audio", "video", "image"}, + expectedURLChanged: true, + }, + { + name: "HTTP URL - proxy mode none", + url: "http://example.com/audio.mp3", + mimeType: "audio/mpeg", + mediaProxyOption: "none", + mediaProxyResourceTypes: []string{"audio", "video"}, + expectedURLChanged: false, + }, + { + name: "Empty URL", + url: "", + mimeType: "audio/mpeg", + mediaProxyOption: "all", + mediaProxyResourceTypes: []string{"audio", "video"}, + expectedURLChanged: false, + }, + { + name: "Non-media MIME type", + url: "http://example.com/doc.pdf", + mimeType: "application/pdf", + mediaProxyOption: "all", + mediaProxyResourceTypes: []string{"audio", "video"}, + expectedURLChanged: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + enclosure := &Enclosure{ + URL: tc.url, + MimeType: tc.mimeType, + } + + originalURL := enclosure.URL + + // Call the method + enclosure.ProxifyEnclosureURL(router, tc.mediaProxyOption, tc.mediaProxyResourceTypes) + + // Check if URL changed as expected + urlChanged := enclosure.URL != originalURL + if urlChanged != tc.expectedURLChanged { + t.Errorf("ProxifyEnclosureURL() URL changed = %v, want %v. Original: %s, New: %s", + urlChanged, tc.expectedURLChanged, originalURL, enclosure.URL) + } + + // If URL should have changed, verify it's not empty + if tc.expectedURLChanged && enclosure.URL == "" { + t.Error("ProxifyEnclosureURL() resulted in empty URL when proxification was expected") + } + + // If URL shouldn't have changed, verify it's identical + if !tc.expectedURLChanged && enclosure.URL != originalURL { + t.Errorf("ProxifyEnclosureURL() URL changed unexpectedly from %s to %s", originalURL, enclosure.URL) + } + }) + } +} + +func TestEnclosureList_ProxifyEnclosureURL(t *testing.T) { + // Initialize config for testing + os.Clearenv() + os.Setenv("BASE_URL", "http://localhost") + os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test-private-key") + + var err error + parser := config.NewParser() + config.Opts, err = parser.ParseEnvironmentVariables() + if err != nil { + t.Fatalf(`Config parsing failure: %v`, err) + } + + router := mux.NewRouter() + router.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy") + + testCases := []struct { + name string + enclosures EnclosureList + mediaProxyOption string + mediaProxyResourceTypes []string + expectedChangedCount int + }{ + { + name: "Mixed enclosures with all proxy mode", + enclosures: EnclosureList{ + &Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"}, + &Enclosure{URL: "https://example.com/video.mp4", MimeType: "video/mp4"}, + &Enclosure{URL: "http://example.com/image.jpg", MimeType: "image/jpeg"}, + &Enclosure{URL: "http://example.com/doc.pdf", MimeType: "application/pdf"}, + }, + mediaProxyOption: "all", + mediaProxyResourceTypes: []string{"audio", "video"}, + expectedChangedCount: 2, // audio and video should be proxified + }, + { + name: "Mixed enclosures with http-only proxy mode", + enclosures: EnclosureList{ + &Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"}, + &Enclosure{URL: "https://example.com/video.mp4", MimeType: "video/mp4"}, + &Enclosure{URL: "http://example.com/video2.mp4", MimeType: "video/mp4"}, + }, + mediaProxyOption: "http-only", + mediaProxyResourceTypes: []string{"audio", "video"}, + expectedChangedCount: 2, // only HTTP URLs should be proxified + }, + { + name: "No media types in resource list", + enclosures: EnclosureList{ + &Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"}, + &Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"}, + }, + mediaProxyOption: "all", + mediaProxyResourceTypes: []string{"image"}, + expectedChangedCount: 0, // no matching resource types + }, + { + name: "Proxy mode none", + enclosures: EnclosureList{ + &Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"}, + &Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"}, + }, + mediaProxyOption: "none", + mediaProxyResourceTypes: []string{"audio", "video"}, + expectedChangedCount: 0, + }, + { + name: "Empty enclosure list", + enclosures: EnclosureList{}, + mediaProxyOption: "all", + mediaProxyResourceTypes: []string{"audio", "video"}, + expectedChangedCount: 0, + }, + { + name: "Enclosures with empty URLs", + enclosures: EnclosureList{ + &Enclosure{URL: "", MimeType: "audio/mpeg"}, + &Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"}, + }, + mediaProxyOption: "all", + mediaProxyResourceTypes: []string{"audio", "video"}, + expectedChangedCount: 1, // only the non-empty URL should be processed + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Store original URLs + originalURLs := make([]string, len(tc.enclosures)) + for i, enclosure := range tc.enclosures { + originalURLs[i] = enclosure.URL + } + + // Call the method + tc.enclosures.ProxifyEnclosureURL(router, tc.mediaProxyOption, tc.mediaProxyResourceTypes) + + // Count how many URLs actually changed + changedCount := 0 + for i, enclosure := range tc.enclosures { + if enclosure.URL != originalURLs[i] { + changedCount++ + // Verify that changed URLs are not empty (unless they were empty originally) + if originalURLs[i] != "" && enclosure.URL == "" { + t.Errorf("Enclosure %d: ProxifyEnclosureURL resulted in empty URL", i) + } + } + } + + if changedCount != tc.expectedChangedCount { + t.Errorf("ProxifyEnclosureURL() changed %d URLs, want %d", changedCount, tc.expectedChangedCount) + } + }) + } +} + +func TestEnclosure_ProxifyEnclosureURL_EdgeCases(t *testing.T) { + // Initialize config for testing + os.Clearenv() + os.Setenv("BASE_URL", "http://localhost") + os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test-private-key") + + var err error + parser := config.NewParser() + config.Opts, err = parser.ParseEnvironmentVariables() + if err != nil { + t.Fatalf(`Config parsing failure: %v`, err) + } + + router := mux.NewRouter() + router.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy") + t.Run("Empty resource types slice", func(t *testing.T) { + enclosure := &Enclosure{ + URL: "http://example.com/audio.mp3", + MimeType: "audio/mpeg", + } + + originalURL := enclosure.URL + enclosure.ProxifyEnclosureURL(router, "all", []string{}) + + // With empty resource types, URL should not change + if enclosure.URL != originalURL { + t.Errorf("URL should not change with empty resource types. Original: %s, New: %s", originalURL, enclosure.URL) + } + }) + + t.Run("Nil resource types slice", func(t *testing.T) { + enclosure := &Enclosure{ + URL: "http://example.com/audio.mp3", + MimeType: "audio/mpeg", + } + + originalURL := enclosure.URL + enclosure.ProxifyEnclosureURL(router, "all", nil) + + // With nil resource types, URL should not change + if enclosure.URL != originalURL { + t.Errorf("URL should not change with nil resource types. Original: %s, New: %s", originalURL, enclosure.URL) + } + }) + t.Run("Invalid proxy mode", func(t *testing.T) { + enclosure := &Enclosure{ + URL: "http://example.com/audio.mp3", + MimeType: "audio/mpeg", + } + + originalURL := enclosure.URL + enclosure.ProxifyEnclosureURL(router, "invalid-mode", []string{"audio"}) + + // With invalid proxy mode, the function still proxifies non-HTTPS URLs + // because shouldProxifyURL defaults to checking URL scheme + if enclosure.URL == originalURL { + t.Errorf("URL should change for HTTP URL even with invalid proxy mode. Original: %s, New: %s", originalURL, enclosure.URL) + } + }) +}