Przeglądaj źródła

refactor(model): add test coverage and simplify `ProxifyEnclosureURL`

Frédéric Guillot 9 miesięcy temu
rodzic
commit
63891501e5

+ 2 - 1
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)
 }

+ 2 - 2
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)
 }

+ 1 - 2
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),

+ 142 - 0
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)
+			}
+		})
+	}
+}

+ 30 - 12
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
-	}
-	if proxyOption == "all" {
+	case strings.HasPrefix(mediaURL, "data:"):
+		return false
+	case mediaProxyOption == "all":
 		return true
-	}
-	if !urllib.IsHTTPS(attrValue) {
+	case mediaProxyOption != "none" && !urllib.IsHTTPS(mediaURL):
 		return true
+	default:
+		return false
+	}
+}
+
+// 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
+	}
+
+	for _, mediaType := range mediaProxyResourceTypes {
+		if strings.HasPrefix(mediaMimeType, mediaType+"/") {
+			return true
+		}
 	}
+
 	return false
 }

+ 11 - 28
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)
 	}
 }

+ 558 - 1
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)
+		}
+	})
+}