enclosure_test.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580
  1. // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
  2. // SPDX-License-Identifier: Apache-2.0
  3. package model
  4. import (
  5. "os"
  6. "testing"
  7. "miniflux.app/v2/internal/config"
  8. )
  9. func TestEnclosure_Html5MimeTypeGivesOriginalMimeType(t *testing.T) {
  10. enclosure := Enclosure{MimeType: "thing/thisMimeTypeIsNotExpectedToBeReplaced"}
  11. if enclosure.Html5MimeType() != enclosure.MimeType {
  12. t.Fatalf(
  13. "HTML5 MimeType must provide original MimeType if not explicitly Replaced. Got %s ,expected '%s' ",
  14. enclosure.Html5MimeType(),
  15. enclosure.MimeType,
  16. )
  17. }
  18. }
  19. func TestEnclosure_Html5MimeTypeReplaceStandardM4vByAppleSpecificMimeType(t *testing.T) {
  20. enclosure := Enclosure{MimeType: "video/m4v"}
  21. if enclosure.Html5MimeType() != "video/x-m4v" {
  22. // Solution from this stackoverflow discussion:
  23. // https://stackoverflow.com/questions/15277147/m4v-mimetype-video-mp4-or-video-m4v/66945470#66945470
  24. // tested at the time of this commit (06/2023) on latest Firefox & Vivaldi on this feed
  25. // https://www.florenceporcel.com/podcast/lfhdu.xml
  26. t.Fatalf(
  27. "HTML5 MimeType must be replaced by 'video/x-m4v' when originally video/m4v to ensure playbacks in browsers. Got '%s'",
  28. enclosure.Html5MimeType(),
  29. )
  30. }
  31. }
  32. func TestEnclosure_IsAudio(t *testing.T) {
  33. testCases := []struct {
  34. name string
  35. mimeType string
  36. expected bool
  37. }{
  38. {"MP3 audio", "audio/mpeg", true},
  39. {"WAV audio", "audio/wav", true},
  40. {"OGG audio", "audio/ogg", true},
  41. {"Mixed case audio", "Audio/MP3", true},
  42. {"Video file", "video/mp4", false},
  43. {"Image file", "image/jpeg", false},
  44. {"Text file", "text/plain", false},
  45. {"Empty mime type", "", false},
  46. {"Audio with extra info", "audio/mpeg; charset=utf-8", true},
  47. }
  48. for _, tc := range testCases {
  49. t.Run(tc.name, func(t *testing.T) {
  50. enclosure := &Enclosure{MimeType: tc.mimeType}
  51. if got := enclosure.IsAudio(); got != tc.expected {
  52. t.Errorf("IsAudio() = %v, want %v for mime type %s", got, tc.expected, tc.mimeType)
  53. }
  54. })
  55. }
  56. }
  57. func TestEnclosure_IsVideo(t *testing.T) {
  58. testCases := []struct {
  59. name string
  60. mimeType string
  61. expected bool
  62. }{
  63. {"MP4 video", "video/mp4", true},
  64. {"AVI video", "video/avi", true},
  65. {"WebM video", "video/webm", true},
  66. {"M4V video", "video/m4v", true},
  67. {"Mixed case video", "Video/MP4", true},
  68. {"Audio file", "audio/mpeg", false},
  69. {"Image file", "image/jpeg", false},
  70. {"Text file", "text/plain", false},
  71. {"Empty mime type", "", false},
  72. {"Video with extra info", "video/mp4; codecs=\"avc1.42E01E\"", true},
  73. }
  74. for _, tc := range testCases {
  75. t.Run(tc.name, func(t *testing.T) {
  76. enclosure := &Enclosure{MimeType: tc.mimeType}
  77. if got := enclosure.IsVideo(); got != tc.expected {
  78. t.Errorf("IsVideo() = %v, want %v for mime type %s", got, tc.expected, tc.mimeType)
  79. }
  80. })
  81. }
  82. }
  83. func TestEnclosure_IsImage(t *testing.T) {
  84. testCases := []struct {
  85. name string
  86. mimeType string
  87. url string
  88. expected bool
  89. }{
  90. {"JPEG image by mime", "image/jpeg", "http://example.com/file", true},
  91. {"PNG image by mime", "image/png", "http://example.com/file", true},
  92. {"GIF image by mime", "image/gif", "http://example.com/file", true},
  93. {"Mixed case image mime", "Image/JPEG", "http://example.com/file", true},
  94. {"JPG file extension", "application/octet-stream", "http://example.com/photo.jpg", true},
  95. {"JPEG file extension", "text/plain", "http://example.com/photo.jpeg", true},
  96. {"PNG file extension", "unknown/type", "http://example.com/photo.png", true},
  97. {"GIF file extension", "binary/data", "http://example.com/photo.gif", true},
  98. {"Mixed case extension", "text/plain", "http://example.com/photo.JPG", true},
  99. {"Image mime and extension", "image/jpeg", "http://example.com/photo.jpg", true},
  100. {"Video file", "video/mp4", "http://example.com/video.mp4", false},
  101. {"Audio file", "audio/mpeg", "http://example.com/audio.mp3", false},
  102. {"Text file", "text/plain", "http://example.com/file.txt", false},
  103. {"No extension", "text/plain", "http://example.com/file", false},
  104. {"Other extension", "text/plain", "http://example.com/file.pdf", false},
  105. {"Empty values", "", "", false},
  106. }
  107. for _, tc := range testCases {
  108. t.Run(tc.name, func(t *testing.T) {
  109. enclosure := &Enclosure{MimeType: tc.mimeType, URL: tc.url}
  110. if got := enclosure.IsImage(); got != tc.expected {
  111. t.Errorf("IsImage() = %v, want %v for mime type %s and URL %s", got, tc.expected, tc.mimeType, tc.url)
  112. }
  113. })
  114. }
  115. }
  116. func TestEnclosureList_FindMediaPlayerEnclosure(t *testing.T) {
  117. testCases := []struct {
  118. name string
  119. enclosures EnclosureList
  120. expectedNil bool
  121. }{
  122. {
  123. name: "Returns first audio enclosure",
  124. enclosures: EnclosureList{
  125. &Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
  126. &Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"},
  127. },
  128. expectedNil: false,
  129. },
  130. {
  131. name: "Returns first video enclosure",
  132. enclosures: EnclosureList{
  133. &Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"},
  134. &Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
  135. },
  136. expectedNil: false,
  137. },
  138. {
  139. name: "Skips image enclosure and returns audio",
  140. enclosures: EnclosureList{
  141. &Enclosure{URL: "http://example.com/image.jpg", MimeType: "image/jpeg"},
  142. &Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
  143. },
  144. expectedNil: false,
  145. },
  146. {
  147. name: "Skips enclosure with empty URL",
  148. enclosures: EnclosureList{
  149. &Enclosure{URL: "", MimeType: "audio/mpeg"},
  150. &Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
  151. },
  152. expectedNil: false,
  153. },
  154. {
  155. name: "Returns nil for no media enclosures",
  156. enclosures: EnclosureList{
  157. &Enclosure{URL: "http://example.com/image.jpg", MimeType: "image/jpeg"},
  158. &Enclosure{URL: "http://example.com/doc.pdf", MimeType: "application/pdf"},
  159. },
  160. expectedNil: true,
  161. },
  162. {
  163. name: "Returns nil for empty list",
  164. enclosures: EnclosureList{},
  165. expectedNil: true,
  166. },
  167. {
  168. name: "Returns nil for all empty URLs",
  169. enclosures: EnclosureList{
  170. &Enclosure{URL: "", MimeType: "audio/mpeg"},
  171. &Enclosure{URL: "", MimeType: "video/mp4"},
  172. },
  173. expectedNil: true,
  174. },
  175. }
  176. for _, tc := range testCases {
  177. t.Run(tc.name, func(t *testing.T) {
  178. result := tc.enclosures.FindMediaPlayerEnclosure()
  179. if tc.expectedNil {
  180. if result != nil {
  181. t.Errorf("FindMediaPlayerEnclosure() = %v, want nil", result)
  182. }
  183. } else {
  184. if result == nil {
  185. t.Errorf("FindMediaPlayerEnclosure() = nil, want non-nil")
  186. } else if !result.IsAudio() && !result.IsVideo() {
  187. t.Errorf("FindMediaPlayerEnclosure() returned non-media enclosure: %s", result.MimeType)
  188. }
  189. }
  190. })
  191. }
  192. }
  193. func TestEnclosureList_ContainsAudioOrVideo(t *testing.T) {
  194. testCases := []struct {
  195. name string
  196. enclosures EnclosureList
  197. expected bool
  198. }{
  199. {
  200. name: "Contains audio",
  201. enclosures: EnclosureList{
  202. &Enclosure{MimeType: "audio/mpeg"},
  203. &Enclosure{MimeType: "image/jpeg"},
  204. },
  205. expected: true,
  206. },
  207. {
  208. name: "Contains video",
  209. enclosures: EnclosureList{
  210. &Enclosure{MimeType: "image/jpeg"},
  211. &Enclosure{MimeType: "video/mp4"},
  212. },
  213. expected: true,
  214. },
  215. {
  216. name: "Contains both audio and video",
  217. enclosures: EnclosureList{
  218. &Enclosure{MimeType: "audio/mpeg"},
  219. &Enclosure{MimeType: "video/mp4"},
  220. },
  221. expected: true,
  222. },
  223. {
  224. name: "Contains only images",
  225. enclosures: EnclosureList{
  226. &Enclosure{MimeType: "image/jpeg"},
  227. &Enclosure{MimeType: "image/png"},
  228. },
  229. expected: false,
  230. },
  231. {
  232. name: "Contains only documents",
  233. enclosures: EnclosureList{
  234. &Enclosure{MimeType: "application/pdf"},
  235. &Enclosure{MimeType: "text/plain"},
  236. },
  237. expected: false,
  238. },
  239. {
  240. name: "Empty list",
  241. enclosures: EnclosureList{},
  242. expected: false,
  243. },
  244. {
  245. name: "Single audio enclosure",
  246. enclosures: EnclosureList{
  247. &Enclosure{MimeType: "audio/wav"},
  248. },
  249. expected: true,
  250. },
  251. {
  252. name: "Single video enclosure",
  253. enclosures: EnclosureList{
  254. &Enclosure{MimeType: "video/webm"},
  255. },
  256. expected: true,
  257. },
  258. }
  259. for _, tc := range testCases {
  260. t.Run(tc.name, func(t *testing.T) {
  261. result := tc.enclosures.ContainsAudioOrVideo()
  262. if result != tc.expected {
  263. t.Errorf("ContainsAudioOrVideo() = %v, want %v", result, tc.expected)
  264. }
  265. })
  266. }
  267. }
  268. func TestEnclosure_ProxifyEnclosureURL(t *testing.T) {
  269. // Initialize config for testing
  270. os.Clearenv()
  271. os.Setenv("BASE_URL", "http://localhost")
  272. os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test-private-key")
  273. var err error
  274. parser := config.NewConfigParser()
  275. config.Opts, err = parser.ParseEnvironmentVariables()
  276. if err != nil {
  277. t.Fatalf(`Config parsing failure: %v`, err)
  278. }
  279. testCases := []struct {
  280. name string
  281. url string
  282. mimeType string
  283. mediaProxyOption string
  284. mediaProxyResourceTypes []string
  285. expectedURLChanged bool
  286. }{
  287. {
  288. name: "HTTP URL with audio type - proxy mode all",
  289. url: "http://example.com/audio.mp3",
  290. mimeType: "audio/mpeg",
  291. mediaProxyOption: "all",
  292. mediaProxyResourceTypes: []string{"audio", "video"},
  293. expectedURLChanged: true,
  294. },
  295. {
  296. name: "HTTPS URL with video type - proxy mode all",
  297. url: "https://example.com/video.mp4",
  298. mimeType: "video/mp4",
  299. mediaProxyOption: "all",
  300. mediaProxyResourceTypes: []string{"audio", "video"},
  301. expectedURLChanged: true,
  302. },
  303. {
  304. name: "HTTP URL with video type - proxy mode http-only",
  305. url: "http://example.com/video.mp4",
  306. mimeType: "video/mp4",
  307. mediaProxyOption: "http-only",
  308. mediaProxyResourceTypes: []string{"audio", "video"},
  309. expectedURLChanged: true,
  310. },
  311. {
  312. name: "HTTPS URL with video type - proxy mode http-only",
  313. url: "https://example.com/video.mp4",
  314. mimeType: "video/mp4",
  315. mediaProxyOption: "http-only",
  316. mediaProxyResourceTypes: []string{"audio", "video"},
  317. expectedURLChanged: false,
  318. },
  319. {
  320. name: "HTTP URL with image type - not in resource types",
  321. url: "http://example.com/image.jpg",
  322. mimeType: "image/jpeg",
  323. mediaProxyOption: "all",
  324. mediaProxyResourceTypes: []string{"audio", "video"},
  325. expectedURLChanged: false,
  326. },
  327. {
  328. name: "HTTP URL with image type - in resource types",
  329. url: "http://example.com/image.jpg",
  330. mimeType: "image/jpeg",
  331. mediaProxyOption: "all",
  332. mediaProxyResourceTypes: []string{"audio", "video", "image"},
  333. expectedURLChanged: true,
  334. },
  335. {
  336. name: "HTTP URL - proxy mode none",
  337. url: "http://example.com/audio.mp3",
  338. mimeType: "audio/mpeg",
  339. mediaProxyOption: "none",
  340. mediaProxyResourceTypes: []string{"audio", "video"},
  341. expectedURLChanged: false,
  342. },
  343. {
  344. name: "Empty URL",
  345. url: "",
  346. mimeType: "audio/mpeg",
  347. mediaProxyOption: "all",
  348. mediaProxyResourceTypes: []string{"audio", "video"},
  349. expectedURLChanged: false,
  350. },
  351. {
  352. name: "Non-media MIME type",
  353. url: "http://example.com/doc.pdf",
  354. mimeType: "application/pdf",
  355. mediaProxyOption: "all",
  356. mediaProxyResourceTypes: []string{"audio", "video"},
  357. expectedURLChanged: false,
  358. },
  359. }
  360. for _, tc := range testCases {
  361. t.Run(tc.name, func(t *testing.T) {
  362. enclosure := &Enclosure{
  363. URL: tc.url,
  364. MimeType: tc.mimeType,
  365. }
  366. originalURL := enclosure.URL
  367. // Call the method
  368. enclosure.ProxifyEnclosureURL(tc.mediaProxyOption, tc.mediaProxyResourceTypes)
  369. // Check if URL changed as expected
  370. urlChanged := enclosure.URL != originalURL
  371. if urlChanged != tc.expectedURLChanged {
  372. t.Errorf("ProxifyEnclosureURL() URL changed = %v, want %v. Original: %s, New: %s",
  373. urlChanged, tc.expectedURLChanged, originalURL, enclosure.URL)
  374. }
  375. // If URL should have changed, verify it's not empty
  376. if tc.expectedURLChanged && enclosure.URL == "" {
  377. t.Error("ProxifyEnclosureURL() resulted in empty URL when proxification was expected")
  378. }
  379. // If URL shouldn't have changed, verify it's identical
  380. if !tc.expectedURLChanged && enclosure.URL != originalURL {
  381. t.Errorf("ProxifyEnclosureURL() URL changed unexpectedly from %s to %s", originalURL, enclosure.URL)
  382. }
  383. })
  384. }
  385. }
  386. func TestEnclosureList_ProxifyEnclosureURL(t *testing.T) {
  387. // Initialize config for testing
  388. os.Clearenv()
  389. os.Setenv("BASE_URL", "http://localhost")
  390. os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test-private-key")
  391. var err error
  392. parser := config.NewConfigParser()
  393. config.Opts, err = parser.ParseEnvironmentVariables()
  394. if err != nil {
  395. t.Fatalf(`Config parsing failure: %v`, err)
  396. }
  397. testCases := []struct {
  398. name string
  399. enclosures EnclosureList
  400. mediaProxyOption string
  401. mediaProxyResourceTypes []string
  402. expectedChangedCount int
  403. }{
  404. {
  405. name: "Mixed enclosures with all proxy mode",
  406. enclosures: EnclosureList{
  407. &Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
  408. &Enclosure{URL: "https://example.com/video.mp4", MimeType: "video/mp4"},
  409. &Enclosure{URL: "http://example.com/image.jpg", MimeType: "image/jpeg"},
  410. &Enclosure{URL: "http://example.com/doc.pdf", MimeType: "application/pdf"},
  411. },
  412. mediaProxyOption: "all",
  413. mediaProxyResourceTypes: []string{"audio", "video"},
  414. expectedChangedCount: 2, // audio and video should be proxified
  415. },
  416. {
  417. name: "Mixed enclosures with http-only proxy mode",
  418. enclosures: EnclosureList{
  419. &Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
  420. &Enclosure{URL: "https://example.com/video.mp4", MimeType: "video/mp4"},
  421. &Enclosure{URL: "http://example.com/video2.mp4", MimeType: "video/mp4"},
  422. },
  423. mediaProxyOption: "http-only",
  424. mediaProxyResourceTypes: []string{"audio", "video"},
  425. expectedChangedCount: 2, // only HTTP URLs should be proxified
  426. },
  427. {
  428. name: "No media types in resource list",
  429. enclosures: EnclosureList{
  430. &Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
  431. &Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"},
  432. },
  433. mediaProxyOption: "all",
  434. mediaProxyResourceTypes: []string{"image"},
  435. expectedChangedCount: 0, // no matching resource types
  436. },
  437. {
  438. name: "Proxy mode none",
  439. enclosures: EnclosureList{
  440. &Enclosure{URL: "http://example.com/audio.mp3", MimeType: "audio/mpeg"},
  441. &Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"},
  442. },
  443. mediaProxyOption: "none",
  444. mediaProxyResourceTypes: []string{"audio", "video"},
  445. expectedChangedCount: 0,
  446. },
  447. {
  448. name: "Empty enclosure list",
  449. enclosures: EnclosureList{},
  450. mediaProxyOption: "all",
  451. mediaProxyResourceTypes: []string{"audio", "video"},
  452. expectedChangedCount: 0,
  453. },
  454. {
  455. name: "Enclosures with empty URLs",
  456. enclosures: EnclosureList{
  457. &Enclosure{URL: "", MimeType: "audio/mpeg"},
  458. &Enclosure{URL: "http://example.com/video.mp4", MimeType: "video/mp4"},
  459. },
  460. mediaProxyOption: "all",
  461. mediaProxyResourceTypes: []string{"audio", "video"},
  462. expectedChangedCount: 1, // only the non-empty URL should be processed
  463. },
  464. }
  465. for _, tc := range testCases {
  466. t.Run(tc.name, func(t *testing.T) {
  467. // Store original URLs
  468. originalURLs := make([]string, len(tc.enclosures))
  469. for i, enclosure := range tc.enclosures {
  470. originalURLs[i] = enclosure.URL
  471. }
  472. // Call the method
  473. tc.enclosures.ProxifyEnclosureURL(tc.mediaProxyOption, tc.mediaProxyResourceTypes)
  474. // Count how many URLs actually changed
  475. changedCount := 0
  476. for i, enclosure := range tc.enclosures {
  477. if enclosure.URL != originalURLs[i] {
  478. changedCount++
  479. // Verify that changed URLs are not empty (unless they were empty originally)
  480. if originalURLs[i] != "" && enclosure.URL == "" {
  481. t.Errorf("Enclosure %d: ProxifyEnclosureURL resulted in empty URL", i)
  482. }
  483. }
  484. }
  485. if changedCount != tc.expectedChangedCount {
  486. t.Errorf("ProxifyEnclosureURL() changed %d URLs, want %d", changedCount, tc.expectedChangedCount)
  487. }
  488. })
  489. }
  490. }
  491. func TestEnclosure_ProxifyEnclosureURL_EdgeCases(t *testing.T) {
  492. // Initialize config for testing
  493. os.Clearenv()
  494. os.Setenv("BASE_URL", "http://localhost")
  495. os.Setenv("MEDIA_PROXY_PRIVATE_KEY", "test-private-key")
  496. var err error
  497. parser := config.NewConfigParser()
  498. config.Opts, err = parser.ParseEnvironmentVariables()
  499. if err != nil {
  500. t.Fatalf(`Config parsing failure: %v`, err)
  501. }
  502. t.Run("Empty resource types slice", func(t *testing.T) {
  503. enclosure := &Enclosure{
  504. URL: "http://example.com/audio.mp3",
  505. MimeType: "audio/mpeg",
  506. }
  507. originalURL := enclosure.URL
  508. enclosure.ProxifyEnclosureURL("all", []string{})
  509. // With empty resource types, URL should not change
  510. if enclosure.URL != originalURL {
  511. t.Errorf("URL should not change with empty resource types. Original: %s, New: %s", originalURL, enclosure.URL)
  512. }
  513. })
  514. t.Run("Nil resource types slice", func(t *testing.T) {
  515. enclosure := &Enclosure{
  516. URL: "http://example.com/audio.mp3",
  517. MimeType: "audio/mpeg",
  518. }
  519. originalURL := enclosure.URL
  520. enclosure.ProxifyEnclosureURL("all", nil)
  521. // With nil resource types, URL should not change
  522. if enclosure.URL != originalURL {
  523. t.Errorf("URL should not change with nil resource types. Original: %s, New: %s", originalURL, enclosure.URL)
  524. }
  525. })
  526. t.Run("Invalid proxy mode", func(t *testing.T) {
  527. enclosure := &Enclosure{
  528. URL: "http://example.com/audio.mp3",
  529. MimeType: "audio/mpeg",
  530. }
  531. originalURL := enclosure.URL
  532. enclosure.ProxifyEnclosureURL("invalid-mode", []string{"audio"})
  533. // With invalid proxy mode, the function still proxifies non-HTTPS URLs
  534. // because shouldProxifyURL defaults to checking URL scheme
  535. if enclosure.URL == originalURL {
  536. t.Errorf("URL should change for HTTP URL even with invalid proxy mode. Original: %s, New: %s", originalURL, enclosure.URL)
  537. }
  538. })
  539. }