Files
element-x-ios/UnitTests/Sources/MediaUploadingPreprocessorTests.swift
Mauro b31950789f Move synchronous image upload preparation off the main actor (#6323)
* Move synchronous image upload preparation off the main actor

Signed-off-by: Ivan Magda <imagda15@gmail.com>

* Make media preparation cooperative with cancellation

Replace the dispatch queue with concurrent copy, image and video-thumbnail
functions. Return sendable upload metadata directly, bound each batch to
two preparations and discard unfinished results when cancellation occurs.

Keep file metadata reads synchronous and prevent the preview from sending
a result after its preparation task has been cancelled. Replace unrelated
tests with cancellation and batch-order coverage requested during review.

* Update MediaUploadingPreprocessor.swift

* Run media preparation in a nonisolated preprocessor

Make MediaUploadingPreprocessor a nonisolated struct whose copy, image
processing and video thumbnail run in @concurrent instance methods,
instead of a main-actor type with static helpers and an extra setting
parameter.

Check cancellation with plain early returns between the slow steps,
name the batch limit, stop logging cancelled video exports as errors,
and restore develop's structure and doc comments where they were changed
without need.

Replace the tests that didn't reach the new code with tests for cleanup
on failure, on batch failure and on cancellation, and make the preview
test set up its blocking mock before the view model starts processing.

* Apply review suggestions to media preparation

Docs:
- Drop the doc comment on maximumConcurrentProcessingCount, the name says it.
- Move the cleanup note into the main description of both processMedia
  functions, so the Returns lines match develop again.

Tests:
- Rename the test helper to copyResourceToTemporaryDirectory(_:).

---------

Signed-off-by: Ivan Magda <imagda15@gmail.com>
Co-authored-by: Ivan Magda <imagda15@gmail.com>
2026-10-13 17:33:22 +02:00

588 lines
30 KiB
Swift
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// Copyright 2025 Element Creations Ltd.
// Copyright 2023-2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
@testable import ElementX
import SwiftUI
import Testing
import UniformTypeIdentifiers
final class MediaUploadingPreprocessorTests {
let maxUploadSize: UInt = 100 * 1024 * 1024
var userSettings: UserSettings!
var mediaUploadingPreprocessor: MediaUploadingPreprocessor!
init() {
userSettings = UserSettings.mock()
userSettings.app.optimizeMediaUploads = false
mediaUploadingPreprocessor = MediaUploadingPreprocessor(userSettings: userSettings)
}
@Test
func audioFileProcessing() async throws {
let url = try #require(Bundle(for: Self.self).url(forResource: "test_audio.mp3", withExtension: nil), "Failed retrieving test asset")
guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .audio(audioURL, audioInfo) = result else {
Issue.record("Failed processing asset")
return
}
// Check that the file name is preserved
#expect(audioURL.lastPathComponent == "test_audio.mp3")
#expect(audioInfo.mimetype == "audio/mpeg")
#expect(isEqual(audioInfo.duration ?? 0, 10.03, within: 0.1))
#expect(isEqual(audioInfo.size ?? 0, 194_811, within: 100))
}
@Test(.timeLimit(.minutes(2))) // Transcoding videos can be slow on a busy CI runner.
func landscapeMovVideoProcessing() async throws {
let url = try #require(Bundle(for: Self.self).url(forResource: "landscape_test_video.mov", withExtension: nil), "Failed retrieving test asset")
guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .video(videoURL, thumbnailURL, videoInfo) = result else {
Issue.record("Failed processing asset")
return
}
// Check that the file name is preserved
#expect(videoURL.lastPathComponent == "landscape_test_video.mp4")
#expect(videoURL.pathExtension == "mp4", "The file extension should match the container we use.")
// Check that the thumbnail is generated correctly
let thumbnailData = try Data(contentsOf: thumbnailURL)
let thumbnail = try #require(UIImage(data: thumbnailData), "Invalid thumbnail")
#expect(thumbnail.size.width <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.width)
#expect(thumbnail.size.height <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.height)
// Check resulting video info
#expect(videoInfo.mimetype == "video/mp4")
#expect(videoInfo.blurhash == "K9F$LJZ9,+8yA9-:yT,@%1")
#expect(isEqual(videoInfo.size ?? 0, 4_016_620, within: 100))
#expect(videoInfo.width == 1280)
#expect(videoInfo.height == 720)
#expect(isEqual(videoInfo.duration ?? 0, 5.03, within: 0.1))
#expect(videoInfo.thumbnailInfo != nil)
#expect(videoInfo.thumbnailInfo?.mimetype == "image/jpeg")
#expect(isEqual(videoInfo.thumbnailInfo?.size ?? 0, 183_093, within: 100))
#expect(videoInfo.thumbnailInfo?.width == 800)
#expect(videoInfo.thumbnailInfo?.height == 450)
}
@Test(.timeLimit(.minutes(2))) // Transcoding videos can be slow on a busy CI runner.
func landscapeMovVideoOptimizedProcessing() async throws {
let url = try #require(Bundle(for: Self.self).url(forResource: "landscape_test_video.mov", withExtension: nil), "Failed retrieving test asset")
userSettings.app.optimizeMediaUploads = true
guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .video(optimizedVideoURL, _, optimizedVideoInfo) = optimizedResult else {
Issue.record("Failed processing asset")
return
}
#expect(optimizedVideoURL.pathExtension == "mp4", "The file extension should match the container we use.")
// Check optimised video info
#expect(optimizedVideoInfo.mimetype == "video/mp4")
#expect(optimizedVideoInfo.blurhash == "K9F$LJZ9,+8yA9-:yT,@%1")
#expect(isEqual(optimizedVideoInfo.size ?? 0, 4_016_620, within: 100)) // Note: The video is already 720p so it doesn't change size.
#expect(optimizedVideoInfo.width == 1280)
#expect(optimizedVideoInfo.height == 720)
#expect(isEqual(optimizedVideoInfo.duration ?? 0, 5.03, within: 0.1))
}
@Test(.timeLimit(.minutes(2))) // Transcoding videos can be slow on a busy CI runner.
func portraitMp4VideoProcessing() async throws {
let url = try #require(Bundle(for: Self.self).url(forResource: "portrait_test_video.mp4", withExtension: nil), "Failed retrieving test asset")
guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .video(videoURL, thumbnailURL, videoInfo) = result else {
Issue.record("Failed processing asset")
return
}
// Check that the file name is preserved
#expect(videoURL.lastPathComponent == "portrait_test_video.mp4")
#expect(videoURL.pathExtension == "mp4", "The file extension should match the container we use.")
// Check that the thumbnail is generated correctly
let thumbnailData = try Data(contentsOf: thumbnailURL)
let thumbnail = try #require(UIImage(data: thumbnailData), "Invalid thumbnail")
#expect(thumbnail.size.width <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.width)
#expect(thumbnail.size.height <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.height)
// Check resulting video info
#expect(videoInfo.mimetype == "video/mp4")
#expect(videoInfo.blurhash == "KRB{K[TLMtw7S4oez,emtS")
#expect(isEqual(videoInfo.size ?? 0, 2_004_280, within: 100))
#expect(videoInfo.width == 1080)
#expect(videoInfo.height == 1920)
#expect(isEqual(videoInfo.duration ?? 0, 3.07, within: 0.1))
#expect(videoInfo.thumbnailInfo != nil)
#expect(videoInfo.thumbnailInfo?.mimetype == "image/jpeg")
#expect(isEqual(videoInfo.thumbnailInfo?.size ?? 0, 40976, within: 100))
#expect(videoInfo.thumbnailInfo?.width == 337)
#expect(videoInfo.thumbnailInfo?.height == 600)
}
@Test(.timeLimit(.minutes(2))) // Transcoding videos can be slow on a busy CI runner.
func portraitMp4VideoOptimizedProcessing() async throws {
let url = try #require(Bundle(for: Self.self).url(forResource: "portrait_test_video.mp4", withExtension: nil), "Failed retrieving test asset")
userSettings.app.optimizeMediaUploads = true
guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .video(optimizedVideoURL, _, optimizedVideoInfo) = optimizedResult else {
Issue.record("Failed processing asset")
return
}
#expect(optimizedVideoURL.pathExtension == "mp4", "The file extension should match the container we use.")
// Check optimised video info
#expect(optimizedVideoInfo.mimetype == "video/mp4")
#expect(optimizedVideoInfo.blurhash == "KRB{K[TLMtw7S4oez,emtS")
#expect(isEqual(optimizedVideoInfo.size ?? 0, 3_780_012, within: 100)) // Note: This is slightly stupid because it is larger now 🤦‍♂️
#expect(optimizedVideoInfo.width == 720)
#expect(optimizedVideoInfo.height == 1280)
#expect(isEqual(optimizedVideoInfo.duration ?? 0, 3.07, within: 0.1))
}
@Test
func landscapeImageProcessing() async throws {
let url = try #require(Bundle(for: Self.self).url(forResource: "landscape_test_image.jpg", withExtension: nil), "Failed retrieving test asset")
guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .image(convertedImageURL, thumbnailURL, imageInfo) = result else {
Issue.record("Failed processing asset")
return
}
try compare(originalImageAt: url, toConvertedImageAt: convertedImageURL, withThumbnailAt: thumbnailURL)
// Check resulting image info
#expect(imageInfo.mimetype == "image/jpeg")
#expect(imageInfo.blurhash == "K%I#.NofkC_4ayayxujsWB")
#expect(isEqual(imageInfo.size ?? 0, 3_305_795, within: 100))
#expect(imageInfo.width == 6103)
#expect(imageInfo.height == 2621)
#expect(imageInfo.thumbnailInfo != nil)
#expect(imageInfo.thumbnailInfo?.mimetype == "image/jpeg")
#expect(isEqual(imageInfo.thumbnailInfo?.size ?? 0, 87733, within: 100))
#expect(imageInfo.thumbnailInfo?.width == 800)
#expect(imageInfo.thumbnailInfo?.height == 344)
// Repeat with optimised media setting
userSettings.app.optimizeMediaUploads = true
guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .image(optimizedImageURL, thumbnailURL, optimizedImageInfo) = optimizedResult else {
Issue.record("Failed processing asset")
return
}
try compare(originalImageAt: url, toConvertedImageAt: optimizedImageURL, withThumbnailAt: thumbnailURL)
// Check optimised image info
#expect(optimizedImageInfo.mimetype == "image/jpeg")
#expect(optimizedImageInfo.blurhash == "K%I#.NofkC_4ayaxxujsWB")
#expect(isEqual(optimizedImageInfo.size ?? 0, 524_226, within: 100))
#expect(optimizedImageInfo.width == 2048)
#expect(optimizedImageInfo.height == 879)
}
@Test
func portraitImageProcessing() async throws {
let url = try #require(Bundle(for: Self.self).url(forResource: "portrait_test_image.jpg", withExtension: nil), "Failed retrieving test asset")
guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .image(convertedImageURL, thumbnailURL, imageInfo) = result else {
Issue.record("Failed processing asset")
return
}
try compare(originalImageAt: url, toConvertedImageAt: convertedImageURL, withThumbnailAt: thumbnailURL)
// Check resulting image info
#expect(imageInfo.mimetype == "image/jpeg")
#expect(imageInfo.blurhash == "KdE|0Ls+RP^-n*RP%OWAV@")
#expect(isEqual(imageInfo.size ?? 0, 4_414_666, within: 100))
#expect(imageInfo.width == 3024)
#expect(imageInfo.height == 4032)
#expect(imageInfo.thumbnailInfo != nil)
#expect(imageInfo.thumbnailInfo?.mimetype == "image/jpeg")
#expect(isEqual(imageInfo.thumbnailInfo?.size ?? 0, 258_914, within: 100))
#expect(imageInfo.thumbnailInfo?.width == 600)
#expect(imageInfo.thumbnailInfo?.height == 800)
// Repeat with optimised media setting
userSettings.app.optimizeMediaUploads = true
guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .image(optimizedImageURL, thumbnailURL, optimizedImageInfo) = optimizedResult else {
Issue.record("Failed processing asset")
return
}
try compare(originalImageAt: url, toConvertedImageAt: optimizedImageURL, withThumbnailAt: thumbnailURL)
// Check optimised image info
#expect(optimizedImageInfo.mimetype == "image/jpeg")
#expect(optimizedImageInfo.blurhash == "KdE|0Ls+RP^-n*RP%OWAV@")
#expect(isEqual(optimizedImageInfo.size ?? 0, 1_462_937, within: 100))
#expect(optimizedImageInfo.width == 1536)
#expect(optimizedImageInfo.height == 2048)
}
@Test
func pngImageProcessing() async throws {
let url = try #require(Bundle(for: Self.self).url(forResource: "test_image.png", withExtension: nil), "Failed retrieving test asset")
guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .image(convertedImageURL, _, imageInfo) = result else {
Issue.record("Failed processing asset")
return
}
// Make sure the output file matches the image info.
#expect(mimeType(from: convertedImageURL) == "image/png", "PNGs should always be sent as PNG to preserve the alpha channel.")
#expect(convertedImageURL.pathExtension == "png", "The file extension should match the MIME type.")
// Check resulting image info
#expect(imageInfo.mimetype == "image/png")
#expect(imageInfo.blurhash == "K0TSUA~qfQ~qj[fQfQfQfQ")
#expect(isEqual(imageInfo.size ?? 0, 4868, within: 100))
#expect(imageInfo.width == 240)
#expect(imageInfo.height == 240)
#expect(imageInfo.thumbnailInfo != nil)
#expect(imageInfo.thumbnailInfo?.mimetype == "image/jpeg")
#expect(isEqual(imageInfo.thumbnailInfo?.size ?? 0, 1725, within: 100))
#expect(imageInfo.thumbnailInfo?.width == 240)
#expect(imageInfo.thumbnailInfo?.height == 240)
// Repeat with optimised media setting
userSettings.app.optimizeMediaUploads = true
guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .image(optimizedImageURL, _, optimizedImageInfo) = optimizedResult else {
Issue.record("Failed processing asset")
return
}
// Make sure the output file matches the image info.
#expect(mimeType(from: optimizedImageURL) == "image/png", "PNGs should always be sent as PNG to preserve the alpha channel.")
#expect(optimizedImageURL.pathExtension == "png", "The file extension should match the MIME type.")
// Check optimised image info
#expect(optimizedImageInfo.mimetype == "image/png")
#expect(optimizedImageInfo.blurhash == "K0TSUA~qfQ~qj[fQfQfQfQ")
#expect(isEqual(optimizedImageInfo.size ?? 0, 8199, within: 100))
// Assert that resizing didn't upscale to the maxPixelSize.
#expect(optimizedImageInfo.width == 240)
#expect(optimizedImageInfo.height == 240)
}
@Test
func heicImageProcessing() async throws {
let url = try #require(Bundle(for: Self.self).url(forResource: "test_apple_image.heic", withExtension: nil), "Failed retrieving test asset")
guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .image(convertedImageURL, thumbnailURL, imageInfo) = result else {
Issue.record("Failed processing asset")
return
}
try compare(originalImageAt: url, toConvertedImageAt: convertedImageURL, withThumbnailAt: thumbnailURL)
// Make sure the output file matches the image info.
#expect(mimeType(from: convertedImageURL) == "image/heic", "Unoptimised HEICs should always be sent as is.")
#expect(convertedImageURL.pathExtension == "heic", "The file extension should match the MIME type.")
// Check resulting image info
#expect(imageInfo.mimetype == "image/heic")
#expect(imageInfo.blurhash == "KGD]3ns:T00$kWxFXmt6xv")
#expect(isEqual(imageInfo.size ?? 0, 1_848_525, within: 100))
#expect(imageInfo.width == 3024)
#expect(imageInfo.height == 4032)
#expect(imageInfo.thumbnailInfo != nil)
#expect(imageInfo.thumbnailInfo?.mimetype == "image/jpeg")
#expect(isEqual(imageInfo.thumbnailInfo?.size ?? 0, 218_108, within: 100))
#expect(imageInfo.thumbnailInfo?.width == 600)
#expect(imageInfo.thumbnailInfo?.height == 800)
// Repeat with optimised media setting
userSettings.app.optimizeMediaUploads = true
guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .image(optimizedImageURL, thumbnailURL, optimizedImageInfo) = optimizedResult else {
Issue.record("Failed processing asset")
return
}
try compare(originalImageAt: url, toConvertedImageAt: optimizedImageURL, withThumbnailAt: thumbnailURL)
// Make sure the output file matches the image info.
#expect(mimeType(from: optimizedImageURL) == "image/jpeg", "Optimised HEICs should always be converted to JPEG for compatibility.")
#expect(optimizedImageURL.pathExtension == "jpeg", "The file extension should match the MIME type.")
// Check optimised image info
#expect(optimizedImageInfo.mimetype == "image/jpeg")
#expect(optimizedImageInfo.blurhash == "KGD]3ns:T00#kWxFb^s:xv")
#expect(isEqual(optimizedImageInfo.size ?? 0, 1_049_393, within: 100))
#expect(optimizedImageInfo.width == 1536)
#expect(optimizedImageInfo.height == 2048)
}
@Test
func gifImageProcessing() async throws {
let url = try #require(Bundle(for: Self.self).url(forResource: "test_animated_image.gif", withExtension: nil), "Failed retrieving test asset")
let originalSizeValue = try UInt64(FileManager.default.sizeForItem(at: url))
let originalSize = try #require(originalSizeValue > 0 ? originalSizeValue : nil, "File size must be greater than zero")
guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .image(convertedImageURL, _, imageInfo) = result else {
Issue.record("Failed processing asset")
return
}
// Make sure the output file matches the image info.
#expect(mimeType(from: convertedImageURL) == "image/gif", "GIFs should always be sent as GIF to preserve the animation.")
#expect(convertedImageURL.pathExtension == "gif", "The file extension should match the MIME type.")
// Check resulting image info
#expect(imageInfo.mimetype == "image/gif")
#expect(imageInfo.blurhash == "KpRMPTj[_NxuaeRj%MofMx")
#expect(isEqual(imageInfo.size ?? 0, originalSize, within: 100))
#expect(imageInfo.width == 331)
#expect(imageInfo.height == 472)
#expect(imageInfo.thumbnailInfo != nil)
#expect(imageInfo.thumbnailInfo?.mimetype == "image/jpeg")
#expect(isEqual(imageInfo.thumbnailInfo?.size ?? 0, 34215, within: 100))
#expect(imageInfo.thumbnailInfo?.width == 331)
#expect(imageInfo.thumbnailInfo?.height == 472)
// Repeat with optimised media setting
userSettings.app.optimizeMediaUploads = true
guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .image(optimizedImageURL, _, optimizedImageInfo) = optimizedResult else {
Issue.record("Failed processing asset")
return
}
// Make sure the output file matches the image info.
#expect(mimeType(from: optimizedImageURL) == "image/gif", "GIFs should always be sent as GIF to preserve the animation.")
#expect(optimizedImageURL.pathExtension == "gif", "The file extension should match the MIME type.")
// Ensure optimised image is still the same as the original image.
#expect(optimizedImageInfo.mimetype == "image/gif")
#expect(optimizedImageInfo.blurhash == "KpRMPTj[_NxuaeRj%MofMx")
#expect(isEqual(optimizedImageInfo.size ?? 0, originalSize, within: 100))
#expect(optimizedImageInfo.width == 331)
#expect(optimizedImageInfo.height == 472)
}
@Test
func rotatedImageProcessing() async throws {
let url = try #require(Bundle(for: Self.self).url(forResource: "test_rotated_image.jpg", withExtension: nil), "Failed retrieving test asset")
guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .image(convertedImageURL, thumbnailURL, imageInfo) = result else {
Issue.record("Failed processing asset")
return
}
try compare(originalImageAt: url, toConvertedImageAt: convertedImageURL, withThumbnailAt: thumbnailURL)
// Check resulting image info
#expect(imageInfo.mimetype == "image/jpeg")
#expect(imageInfo.width == 2848)
#expect(imageInfo.height == 4272)
#expect(imageInfo.thumbnailInfo != nil)
#expect(imageInfo.thumbnailInfo?.width == 533)
#expect(imageInfo.thumbnailInfo?.height == 800)
// Repeat with optimised media setting
userSettings.app.optimizeMediaUploads = true
guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .image(optimizedImageURL, thumbnailURL, optimizedImageInfo) = optimizedResult else {
Issue.record("Failed processing asset")
return
}
try compare(originalImageAt: url, toConvertedImageAt: optimizedImageURL, withThumbnailAt: thumbnailURL)
// Check optimised image info
#expect(optimizedImageInfo.mimetype == "image/jpeg")
#expect(optimizedImageInfo.width == 1365)
#expect(optimizedImageInfo.height == 2048)
}
@Test
func batchPreservesInputOrderBeyondConcurrencyLimit() async throws {
let names = ["test_image.png", "test_animated_image.gif", "landscape_test_image.jpg"]
try #require(names.count > MediaUploadingPreprocessor.Constants.maximumConcurrentProcessingCount)
let inputs = try names.map { try #require(Bundle(for: Self.self).url(forResource: $0, withExtension: nil)) }
let results = try await mediaUploadingPreprocessor.processMedia(at: inputs, maxUploadSize: maxUploadSize).get()
defer {
for result in results {
try? FileManager.default.removeItem(at: result.url.deletingLastPathComponent())
}
}
#expect(results.map(\.url.lastPathComponent) == names)
for result in results {
let thumbnailURL = try #require(result.thumbnailURL)
#expect(try Data(contentsOf: result.url).isEmpty == false)
#expect(try Data(contentsOf: thumbnailURL).isEmpty == false)
}
}
@Test
func failedPreparationRemovesItsFiles() async throws {
let url = try copyResourceToTemporaryDirectory("test_image.png")
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
guard case .failure(.maxUploadSizeExceeded) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: 1) else {
Issue.record("Expected the upload size to be exceeded")
return
}
#expect(try preparedFolders(for: url).isEmpty)
}
@Test
func batchFailureReturnsTheErrorAndRemovesPreparedFiles() async throws {
let url = try copyResourceToTemporaryDirectory("test_image.png")
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let missingURL = url.deletingLastPathComponent().appendingPathComponent("missing.png")
// The missing file comes last so that at least one image has already been prepared when the batch fails.
guard case .failure(.failedProcessingMedia) = await mediaUploadingPreprocessor.processMedia(at: [url, url, missingURL], maxUploadSize: maxUploadSize) else {
Issue.record("Expected the missing file to fail the batch")
return
}
#expect(try preparedFolders(for: url).isEmpty)
}
@Test
func cancellationRemovesPreparedFiles() async throws {
let url = try copyResourceToTemporaryDirectory("test_image.png")
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let task = Task { await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize) }
await Task.yield() // Let the preparation start.
task.cancel()
guard case .failure(.cancelled) = await task.value else {
Issue.record("Expected the preparation to be cancelled")
return
}
#expect(try preparedFolders(for: url).isEmpty)
}
// MARK: - Private
/// Copies a test asset to a uniquely named file, so the folders the preprocessor prepares from it can be found afterwards.
private func copyResourceToTemporaryDirectory(_ resource: String) throws -> URL {
let assetURL = try #require(Bundle(for: Self.self).url(forResource: resource, withExtension: nil))
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("\(Self.self)-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let url = directory.appendingPathComponent("\(UUID().uuidString).\(assetURL.pathExtension)")
try FileManager.default.copyItem(at: assetURL, to: url)
return url
}
/// The unique folders created by the preprocessor for the given file that still exist.
private func preparedFolders(for url: URL) throws -> [URL] {
let name = url.deletingPathExtension().lastPathComponent
return try FileManager.default.contentsOfDirectory(at: FileManager.default.temporaryDirectory, includingPropertiesForKeys: nil)
.filter { UUID(uuidString: $0.lastPathComponent) != nil }
.filter { folder in
let contents = (try? FileManager.default.contentsOfDirectory(atPath: folder.path(percentEncoded: false))) ?? []
return contents.contains { $0.contains(name) }
}
}
private func isEqual<N: UnsignedInteger>(_ lhs: N, _ rhs: N, within tolerance: N) -> Bool {
isEqual(Double(lhs), Double(rhs), within: Double(tolerance))
}
private func isEqual<N: SignedNumeric & Comparable>(_ lhs: N, _ rhs: N, within tolerance: N) -> Bool {
abs(lhs - rhs) <= tolerance
}
private func compare(originalImageAt originalImageURL: URL, toConvertedImageAt convertedImageURL: URL, withThumbnailAt thumbnailURL: URL) throws {
guard let originalImageData = try? Data(contentsOf: originalImageURL),
let originalImage = UIImage(data: originalImageData),
let convertedImageData = try? Data(contentsOf: convertedImageURL),
let convertedImage = UIImage(data: convertedImageData) else {
fatalError()
}
if userSettings.app.optimizeMediaUploads {
// Check that new image has been scaled within the requirements for an optimised image
#expect(convertedImage.size.width <= MediaUploadingPreprocessor.Constants.optimizedMaxPixelSize)
#expect(convertedImage.size.height <= MediaUploadingPreprocessor.Constants.optimizedMaxPixelSize)
} else {
// Check that the file name is preserved
#expect(originalImageURL.lastPathComponent == convertedImageURL.lastPathComponent)
// Check that new image is the same size as the original one
#expect(originalImage.size == convertedImage.size)
}
// Check that the GPS data has been stripped
let originalMetadata = try metadata(from: originalImageData)
#expect(originalMetadata.value(forKeyPath: "\(kCGImagePropertyGPSDictionary)") != nil)
let convertedMetadata = try metadata(from: convertedImageData)
#expect(convertedMetadata.value(forKeyPath: "\(kCGImagePropertyGPSDictionary)") == nil)
// Check that the thumbnail is generated correctly
let thumbnailData = try Data(contentsOf: thumbnailURL)
let thumbnail = try #require(UIImage(data: thumbnailData), "Invalid thumbnail")
if thumbnail.size.width > thumbnail.size.height {
#expect(thumbnail.size.width <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.width)
#expect(thumbnail.size.height <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.height)
} else {
#expect(thumbnail.size.width <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.height)
#expect(thumbnail.size.height <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.width)
}
let thumbnailMetadata = try metadata(from: thumbnailData)
#expect(thumbnailMetadata.value(forKeyPath: "\(kCGImagePropertyGPSDictionary)") == nil)
}
private func metadata(from imageData: Data) throws -> NSDictionary {
let imageSource = try #require(CGImageSourceCreateWithData(imageData as NSData, nil), "Invalid asset")
return try #require(CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as NSDictionary?, "Test asset is expected to contain metadata")
}
private func mimeType(from url: URL) -> String? {
guard let imageSource = CGImageSourceCreateWithURL(url as NSURL, nil),
let typeIdentifier = CGImageSourceGetType(imageSource),
let type = UTType(typeIdentifier as String),
let mimeType = type.preferredMIMEType else {
Issue.record("Failed to get mimetype from URL.")
return nil
}
return mimeType
}
}