diff --git a/Modules/Package.swift b/Modules/Package.swift index bf5a4979c6b9..f92c58456f1f 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -143,7 +143,8 @@ let package = Package( "WordPressUI", "WordPressCore", .product(name: "WordPressAPI", package: "wordpress-rs"), - .product(name: "Logging", package: "swift-log") + .product(name: "Logging", package: "swift-log"), + .product(name: "Collections", package: "swift-collections") ] ), .testTarget( diff --git a/Modules/Sources/WordPressMediaLibrary/Upload/MediaSourceMaterializing.swift b/Modules/Sources/WordPressMediaLibrary/Upload/MediaSourceMaterializing.swift new file mode 100644 index 000000000000..f605fe865b85 --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Upload/MediaSourceMaterializing.swift @@ -0,0 +1,12 @@ +import Foundation + +/// Test seam over `UploadSourceMaterializer.materialize`. The actor talks +/// to materialization via this protocol so tests can substitute a mock. +protocol MediaSourceMaterializing: Sendable { + func materialize( + source: UploadSource, + into stageProgress: Progress + ) async throws -> MaterializedUpload +} + +extension UploadSourceMaterializer: MediaSourceMaterializing {} diff --git a/Modules/Sources/WordPressMediaLibrary/Upload/MediaUploader.swift b/Modules/Sources/WordPressMediaLibrary/Upload/MediaUploader.swift new file mode 100644 index 000000000000..f78b45f62811 --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Upload/MediaUploader.swift @@ -0,0 +1,485 @@ +@preconcurrency import Combine +import Foundation +import OrderedCollections +import UniformTypeIdentifiers +import WordPressAPI +import WordPressCore +import os + +public actor MediaUploader { + /// UTTypes the document picker offers. Lock-backed rather than actor + /// state so SwiftUI can read it synchronously; refreshed by + /// `updatePolicy(_:)`. + nonisolated var filePickerContentTypes: [UTType] { + _filePickerContentTypes.withLock { $0 } + } + private nonisolated let _filePickerContentTypes: OSAllocatedUnfairLock<[UTType]> + + private let transport: any MediaUploadTransport + private var materializer: any MediaSourceMaterializing + + /// Multicasts state to every observer and replays the latest snapshot + /// to new subscribers, so a re-pushed Media Library screen sees the + /// in-flight state immediately. + private nonisolated let stateSubject = CurrentValueSubject( + UploaderState(entries: []) + ) + + /// Every in-flight or failed upload, keyed by id and held in submission + /// order. The `InternalEntry` case encodes pending-vs-failed, so an id is + /// in exactly one state and can never be orphaned. In-flight to failed + /// (and failed to pending via Retry) updates the value in place, + /// preserving its slot so the Uploads screen does not reshuffle. + /// `didSet` is the single emit point, and every operation mutates the + /// dictionary exactly once, so one action publishes one snapshot. + private var entries: OrderedDictionary = [:] { + didSet { emit() } + } + + /// Set once by `tearDown()`. New work is refused afterwards: the state + /// subject has already completed, so anything enqueued later would + /// upload invisibly with no way to observe or cancel it. + private var isTornDown = false + + public init( + client: WordPressClient, + policy: MediaUploadPolicy + ) { + self.init( + transport: DefaultMediaUploadTransport(client: client), + policy: policy + ) + } + + init( + transport: any MediaUploadTransport, + policy: MediaUploadPolicy + ) { + self.transport = transport + self.materializer = UploadSourceMaterializer(policy: policy) + self._filePickerContentTypes = OSAllocatedUnfairLock( + initialState: policy.filePickerContentTypes + ) + } + + /// Module-internal test seam. + init( + transport: any MediaUploadTransport, + materializer: any MediaSourceMaterializing, + filePickerContentTypes: [UTType] + ) { + self.transport = transport + self.materializer = materializer + self._filePickerContentTypes = OSAllocatedUnfairLock( + initialState: filePickerContentTypes + ) + } + + deinit { + // Safety net for an owner that drops the uploader without calling + // tearDown(): stop in-flight work and complete the subject so + // `statePublisher.values` iterations terminate instead of suspending + // forever. Staged files are reclaimed by the next-launch sweep. + for entry in entries.values { + if case .pending(let pending) = entry { + pending.task.cancel() + } + } + stateSubject.send(completion: .finished) + } + + /// Deletes upload staging files orphaned by a crash or force-quit. Call once + /// at app launch: in-memory uploader state never survives process + /// termination, so anything still on disk is orphaned. + public static func sweepOrphanedStagingFiles() { + UploadSourceMaterializer.sweepOrphanedStagingFiles() + } + + /// Replays the current snapshot to each new subscriber, then emits every + /// future transition until the actor tears down. Call sites can iterate + /// it as an `AsyncSequence` via `statePublisher.values`. + nonisolated var statePublisher: AnyPublisher { + stateSubject.eraseToAnyPublisher() + } + + func snapshot() -> UploaderState { + UploaderState(entries: entries.values.map { $0.viewModelValue }) + } + + /// Applies a fresh policy to all future enqueues, so user-visible settings + /// (like stripping GPS locations) take effect without recreating the + /// uploader and losing in-flight state. In-flight uploads keep the + /// materializer their work task captured at enqueue time and finish under + /// the policy that was active when they were enqueued. Retry re-uploads + /// the already-materialized bytes and never re-consults the policy. + /// + /// Replaces the materializer with a default-rooted production one, so do + /// not call this on a seam-constructed uploader whose test materializer + /// or staging root must stay injected. Basename dedup in the new + /// materializer restarts from scratch, which is harmless: a single + /// enqueue batch always shares one materializer, and the server enforces + /// final filename uniqueness. + public func updatePolicy(_ policy: MediaUploadPolicy) { + guard !isTornDown else { return } + materializer = UploadSourceMaterializer(policy: policy) + _filePickerContentTypes.withLock { $0 = policy.filePickerContentTypes } + } + + func enqueue(sources: [UploadSource]) { + guard !isTornDown, !sources.isEmpty else { return } + var updated = entries + for source in sources { + let id = UUID() + updated[id] = .pending( + InternalPending( + id: id, + displayName: sourceDisplayName(source), + kind: source.estimatedKind, + overallProgress: Progress(totalUnitCount: 100), + source: source, + materialized: nil, + task: makeWorkTask(for: id) + ) + ) + } + entries = updated + } + + func cancel(_ uploadID: UUID) { + guard case .pending(let entry)? = entries[uploadID] else { return } + entries.removeValue(forKey: uploadID) + cancelWork(of: entry) + } + + func retry(_ uploadID: UUID) { + guard !isTornDown, + case .failed(let failedEntry)? = entries[uploadID], + let retryEntry = makeRetryEntry(from: failedEntry) + else { + return + } + // Overwrite the entry in place so it keeps its slot across the + // failed to pending retry transition. + entries[failedEntry.id] = retryEntry + } + + func dismiss(_ uploadID: UUID) { + guard case .failed(let entry)? = entries[uploadID] else { return } + entries.removeValue(forKey: uploadID) + Self.removeStagingDirectory(entry.materialized?.stagingDirectory) + } + + func cancelAllPending() { + var remaining = entries + for (id, entry) in entries { + guard case .pending(let pending) = entry else { continue } + remaining.removeValue(forKey: id) + cancelWork(of: pending) + } + entries = remaining + } + + func retryAllFailed() { + guard !isTornDown else { return } + var updated = entries + for (id, entry) in entries { + guard case .failed(let failedEntry) = entry, + let retryEntry = makeRetryEntry(from: failedEntry) + else { + continue + } + updated[id] = retryEntry + } + entries = updated + } + + func dismissAllFailed() { + var remaining = entries + for (id, entry) in entries { + guard case .failed(let failedEntry) = entry else { continue } + remaining.removeValue(forKey: id) + Self.removeStagingDirectory(failedEntry.materialized?.stagingDirectory) + } + entries = remaining + } + + public func tearDown() { + isTornDown = true + cancelAllPending() + dismissAllFailed() + stateSubject.send(completion: .finished) + } + + // MARK: - Internals + + /// The work an entry's task performs: the full materialize-then-upload + /// pipeline for a fresh enqueue, or upload-only for a retry of an + /// already staged file. + private enum WorkPhase { + case materialize(UploadSource, overall: Progress) + case uploadOnly(MaterializedUpload, overall: Progress) + } + + private func beginWork(for id: UUID) -> WorkPhase? { + guard case .pending(let entry)? = entries[id] else { return nil } + if let source = entry.source { + return .materialize(source, overall: entry.overallProgress) + } + if let materialized = entry.materialized { + return .uploadOnly(materialized, overall: entry.overallProgress) + } + return nil + } + + private func makeWorkTask(for id: UUID) -> Task { + Task { [weak self, materializer, transport] in + do { + guard let phase = await self?.beginWork(for: id) else { return } + let params: MediaCreateParams + let overall: Progress + let uploadWeight: Double + switch phase { + case .materialize(let source, let overallProgress): + let materialized = try await Self.runMaterializeStage( + source: source, + materializer: materializer, + overall: overallProgress + ) + // No checkCancellation here: a cancel that lands now would + // cause us to throw and silently discard `materialized`, + // leaving its staging directory orphaned. Always hop to + // markMaterialized; it owns the post-materialize race. + guard let self else { + // Actor was deallocated (e.g. registry torn down while + // materialize was in flight). markMaterialized won't + // run, so remove the staged directory directly. + Self.removeStagingDirectory(materialized.stagingDirectory) + return + } + guard await self.markMaterialized(id: id, materialized: materialized) else { + // The row was cancelled while materializing; + // markMaterialized removed the staged directory. + return + } + try Task.checkCancellation() + params = materialized.params + overall = overallProgress + uploadWeight = 1.0 - source.materializationProgressWeight + case .uploadOnly(let materialized, let overallProgress): + // The staged file may be gone (e.g. iOS purged it while + // the app was suspended). Surface a clear "file not + // found" rather than the opaque transport-level error, + // since this path reuses the stored path without + // re-materializing. + guard FileManager.default.fileExists(atPath: materialized.params.filePath) + else { + throw MaterializerError.fileNotFound + } + params = materialized.params + overall = overallProgress + uploadWeight = 1.0 + } + try await Self.runUploadStage( + params: params, + transport: transport, + overall: overall, + progressWeight: uploadWeight + ) + await self?.markSucceeded(id: id) + } catch { + // A user cancel removed the entry synchronously before any + // error could surface, so markFailed no-ops for it. Any + // error (cancellation included) arriving while the row is + // still present was not user-initiated and is shown as a + // failure instead of silently discarding the upload. + await self?.markFailed(id: id, error: error) + } + } + } + + /// Records the staged payload for a still-pending row and releases the + /// original source, so large in-memory payloads (e.g. camera images) are + /// freed as soon as their bytes are on disk. Returns false when the row + /// was cancelled while materialization was in flight; cancel() could not + /// have known about the staged directory (the entry had no materialized + /// payload yet), so it is removed here. + private func markMaterialized(id: UUID, materialized: MaterializedUpload) -> Bool { + guard case .pending(var entry)? = entries[id] else { + Self.removeStagingDirectory(materialized.stagingDirectory) + return false + } + entry.source = nil + entry.materialized = materialized + entries[id] = .pending(entry) + return true + } + + private static func runMaterializeStage( + source: UploadSource, + materializer: any MediaSourceMaterializing, + overall: Progress + ) async throws -> MaterializedUpload { + let weight = source.materializationProgressWeight + let stagePending = Int64( + (Double(overall.totalUnitCount) * weight).rounded() + ) + let stageChild = Progress(totalUnitCount: 100) + overall.addChild(stageChild, withPendingUnitCount: stagePending) + return try await materializer.materialize( + source: source, + into: stageChild + ) + } + + private static func runUploadStage( + params: MediaCreateParams, + transport: any MediaUploadTransport, + overall: Progress, + progressWeight: Double + ) async throws { + let uploadPending = Int64( + (Double(overall.totalUnitCount) * progressWeight).rounded() + ) + let uploadChild = Progress(totalUnitCount: 100) + overall.addChild(uploadChild, withPendingUnitCount: uploadPending) + _ = try await transport.upload(params: params, fulfilling: uploadChild) + } + + private func markSucceeded(id: UUID) { + guard case .pending(let entry)? = entries[id] else { return } + entries.removeValue(forKey: id) + Self.removeStagingDirectory(entry.materialized?.stagingDirectory) + } + + private func markFailed(id: UUID, error: Error) { + // Failure keeps the entry's slot, only flipping pending to failed. + guard case .pending(let entry)? = entries[id] else { return } + var materialized = entry.materialized + if materialized != nil, + let materializerError = error as? MaterializerError, + case .fileNotFound = materializerError + { + // The staged file is gone (e.g. purged by the system), so another + // retry can never succeed. Drop the payload to degrade the row to + // dismiss-only and remove any staging directory leftovers. + Self.removeStagingDirectory(materialized?.stagingDirectory) + materialized = nil + } + entries[entry.id] = .failed( + InternalFailed( + id: entry.id, + displayName: entry.displayName, + kind: entry.kind, + materialized: materialized, + errorMessage: (error as? LocalizedError)?.errorDescription + ?? (error as NSError).localizedDescription + ) + ) + } + + /// Cancels the entry's work and defers the staged-file deletion until + /// the task has fully unwound. The transport opens the staged file + /// lazily, so deleting it while the task might still open the path would + /// surface a bogus file-not-found instead of a clean cancellation. + private func cancelWork(of entry: InternalPending) { + entry.overallProgress.cancel() + entry.task.cancel() + guard let stagingDirectory = entry.materialized?.stagingDirectory else { return } + let task = entry.task + Task { + await task.value + Self.removeStagingDirectory(stagingDirectory) + } + } + + private func makeRetryEntry(from failedEntry: InternalFailed) -> InternalEntry? { + guard let materialized = failedEntry.materialized else { return nil } + return .pending( + InternalPending( + id: failedEntry.id, + displayName: failedEntry.displayName, + kind: failedEntry.kind, + overallProgress: Progress(totalUnitCount: 100), + source: nil, + materialized: materialized, + task: makeWorkTask(for: failedEntry.id) + ) + ) + } + + private func emit() { + stateSubject.send(snapshot()) + } + + private static func removeStagingDirectory(_ url: URL?) { + guard let url else { return } + try? FileManager.default.removeItem(at: url) + } + + private func sourceDisplayName(_ source: UploadSource) -> String { + switch source { + case .photoLibrary(_, let name, _): return name ?? Strings.uploadFallbackPhotoName + case .cameraImage: return Strings.uploadFallbackCameraImageName + case .cameraVideo: return Strings.uploadFallbackCameraVideoName + case .file(let url): return url.lastPathComponent + case .remoteURL(let remote): return remote.suggestedName + case .imagePlayground(_, let suggestedName): return suggestedName + } + } +} + +/// Actor-internal upload entry. Holds the rich state (Task handle, staged +/// payload) the view-facing `UploadEntry` omits. The case encodes the +/// pending-vs-failed state directly, so a single `[UUID: InternalEntry]` map +/// keeps that invariant without a second dictionary to synchronize. +private enum InternalEntry { + case pending(InternalPending) + case failed(InternalFailed) + + var viewModelValue: UploadEntry { + switch self { + case .pending(let p): return .pending(p.viewModelValue) + case .failed(let f): return .failed(f.viewModelValue) + } + } +} + +private struct InternalPending { + let id: UUID + let displayName: String + let kind: MediaKind + let overallProgress: Progress + /// The original source, kept only until materialization completes so + /// large in-memory payloads (e.g. camera images) are released as soon + /// as their bytes are staged on disk. + var source: UploadSource? + var materialized: MaterializedUpload? + let task: Task + + var viewModelValue: PendingUpload { + PendingUpload( + id: id, + displayName: materialized?.displayName ?? displayName, + kind: materialized?.kind ?? kind, + progress: overallProgress + ) + } +} + +private struct InternalFailed { + let id: UUID + let displayName: String + let kind: MediaKind + let materialized: MaterializedUpload? + let errorMessage: String + + var viewModelValue: FailedUpload { + FailedUpload( + id: id, + displayName: materialized?.displayName ?? displayName, + kind: materialized?.kind ?? kind, + errorMessage: errorMessage, + isRetryable: materialized != nil + ) + } +} diff --git a/Modules/Tests/WordPressMediaLibraryTests/MediaUploaderTests.swift b/Modules/Tests/WordPressMediaLibraryTests/MediaUploaderTests.swift new file mode 100644 index 000000000000..ee5b3b6a8291 --- /dev/null +++ b/Modules/Tests/WordPressMediaLibraryTests/MediaUploaderTests.swift @@ -0,0 +1,615 @@ +import Foundation +import Testing +import UIKit +import UniformTypeIdentifiers +import WordPressAPI +import WordPressAPIInternal +@testable import WordPressMediaLibrary + +@Suite("MediaUploader") +struct MediaUploaderTests { + @Test("enqueue moves source through to pending state and fires upload") + func enqueueProducesPending() async throws { + let fakeTransport = FakeUploadTransport() + let uploader = MediaUploader(transport: fakeTransport, policy: makeAllowEverythingPolicy()) + + let sourceURL = try writeTempPDF(name: "doc.pdf") + defer { try? FileManager.default.removeItem(at: sourceURL) } + + let stateBefore = await uploader.snapshot() + #expect(stateBefore.pending.isEmpty) + + await uploader.enqueue(sources: [.file(sourceURL)]) + + // Wait a beat for the upload task to start and complete. + try await Task.sleep(nanoseconds: 100_000_000) + + let uploadCount = await fakeTransport.uploadCount + #expect(uploadCount == 1) + } + + @Test("success path removes pending entry") + func successRemovesPending() async throws { + let fakeTransport = FakeUploadTransport() + let uploader = MediaUploader(transport: fakeTransport, policy: makeAllowEverythingPolicy()) + + let sourceURL = try writeTempPDF(name: "success.pdf") + defer { try? FileManager.default.removeItem(at: sourceURL) } + + await uploader.enqueue(sources: [.file(sourceURL)]) + try await Task.sleep(nanoseconds: 200_000_000) + + let state = await uploader.snapshot() + #expect(state.pending.isEmpty) + #expect(state.failed.isEmpty) + } + + @Test("failure surfaces in failed list with localized message") + func failureSurfacesInFailed() async throws { + let fakeTransport = FakeUploadTransport() + await fakeTransport.setResponses([.failure(URLError(.timedOut))]) + let uploader = MediaUploader(transport: fakeTransport, policy: makeAllowEverythingPolicy()) + + let sourceURL = try writeTempPDF(name: "fail.pdf") + defer { try? FileManager.default.removeItem(at: sourceURL) } + + await uploader.enqueue(sources: [.file(sourceURL)]) + try await Task.sleep(nanoseconds: 200_000_000) + + let state = await uploader.snapshot() + #expect(state.pending.isEmpty) + #expect(state.failed.count == 1) + #expect(!state.failed[0].errorMessage.isEmpty) + } + + @Test("cancel removes pending silently without moving to failed") + func cancelRemovesSilently() async throws { + let blocking = BlockingFakeUploadTransport() + let uploader = MediaUploader(transport: blocking, policy: makeAllowEverythingPolicy()) + + let sourceURL = try writeTempPDF(name: "cancel.pdf") + defer { try? FileManager.default.removeItem(at: sourceURL) } + + await uploader.enqueue(sources: [.file(sourceURL)]) + // Yield so the entry's work Task gets to run and block. + await Task.yield() + + let stateDuring = await uploader.snapshot() + #expect(stateDuring.pending.count == 1) + + let uploadID = stateDuring.pending[0].id + await uploader.cancel(uploadID) + // Signal the blocking upload to unblock (it'll be cancelled already). + await blocking.unblock() + + let stateAfter = await uploader.snapshot() + #expect(stateAfter.pending.isEmpty) + #expect(stateAfter.failed.isEmpty) + } + + @Test("retry rebuilds pending from a failed entry") + func retryRebuildsPending() async throws { + let fakeTransport = FakeUploadTransport() + // First call fails, second succeeds. + await fakeTransport.setResponses([.failure(URLError(.timedOut))]) + let uploader = MediaUploader(transport: fakeTransport, policy: makeAllowEverythingPolicy()) + + let sourceURL = try writeTempPDF(name: "retry.pdf") + defer { try? FileManager.default.removeItem(at: sourceURL) } + + await uploader.enqueue(sources: [.file(sourceURL)]) + try await Task.sleep(nanoseconds: 200_000_000) + + let failedState = await uploader.snapshot() + #expect(failedState.failed.count == 1) + #expect(failedState.failed[0].isRetryable) + + let failedID = failedState.failed[0].id + await uploader.retry(failedID) + + let retryingState = await uploader.snapshot() + #expect(retryingState.pending.count == 1) + #expect(retryingState.failed.isEmpty) + + try await Task.sleep(nanoseconds: 200_000_000) + + let finalState = await uploader.snapshot() + #expect(finalState.pending.isEmpty) + #expect(finalState.failed.isEmpty) + } + + @Test("retry after the staged file is purged surfaces a clear file-not-found error") + func retryAfterPurgeFailsClearly() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("RetryPurge-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let fakeTransport = FakeUploadTransport() + await fakeTransport.setResponses([.failure(URLError(.timedOut))]) + let materializer = UploadSourceMaterializer( + policy: makeAllowEverythingPolicy(), + temporaryRoot: root + ) + let uploader = MediaUploader( + transport: fakeTransport, + materializer: materializer, + filePickerContentTypes: [.content] + ) + + let sourceURL = try writeTempPDF(name: "purge.pdf") + defer { try? FileManager.default.removeItem(at: sourceURL) } + + await uploader.enqueue(sources: [.file(sourceURL)]) + try await Task.sleep(nanoseconds: 200_000_000) + + let failed = try #require(await uploader.snapshot().failed.first) + // The materialized file was retained on disk after the failure. + let stagedBefore = try FileManager.default.contentsOfDirectory( + at: root, + includingPropertiesForKeys: nil + ) + #expect(!stagedBefore.isEmpty) + + // Simulate iOS purging the staging dir while the app was suspended. + for url in stagedBefore { try FileManager.default.removeItem(at: url) } + await uploader.retry(failed.id) + try await Task.sleep(nanoseconds: 200_000_000) + + // Retry reuses the stored path without re-materializing, so it fails + // again, but with the clear file-not-found message, not the opaque + // transport error (the transport must not even be reached). The row + // also degrades to non-retryable: the staged file is gone, so another + // retry could never succeed. + let finalState = await uploader.snapshot() + #expect(finalState.failed.count == 1) + let finalFailed = try #require(finalState.failed.first) + #expect(finalFailed.errorMessage == Strings.uploadErrorFileNotFound) + #expect(!finalFailed.isRetryable) + } + + @Test("retry on materialization-failure entry is no-op") + func retryOnNonRetryableIsNoOp() async throws { + let fakeTransport = FakeUploadTransport() + let uploader = MediaUploader(transport: fakeTransport, policy: makeRejectEverythingPolicy()) + + let sourceURL = try writeTempPDF(name: "rejected.pdf") + defer { try? FileManager.default.removeItem(at: sourceURL) } + + await uploader.enqueue(sources: [.file(sourceURL)]) + try await Task.sleep(nanoseconds: 100_000_000) + + let state = await uploader.snapshot() + #expect(state.failed.count == 1) + #expect(!state.failed[0].isRetryable) + + let failedID = state.failed[0].id + await uploader.retry(failedID) + + let stateAfter = await uploader.snapshot() + #expect(stateAfter.failed.count == 1) + } + + @Test("dismiss removes failed entry") + func dismissRemovesFailed() async throws { + let fakeTransport = FakeUploadTransport() + await fakeTransport.setResponses([.failure(URLError(.timedOut))]) + let uploader = MediaUploader(transport: fakeTransport, policy: makeAllowEverythingPolicy()) + + let sourceURL = try writeTempPDF(name: "dismiss.pdf") + defer { try? FileManager.default.removeItem(at: sourceURL) } + + await uploader.enqueue(sources: [.file(sourceURL)]) + try await Task.sleep(nanoseconds: 200_000_000) + + let failedState = await uploader.snapshot() + #expect(failedState.failed.count == 1) + let failedID = failedState.failed[0].id + + await uploader.dismiss(failedID) + + let afterState = await uploader.snapshot() + #expect(afterState.failed.isEmpty) + } + + @Test("cancelAllPending only acts on pending items") + func cancelAllPendingOnlyActsOnPending() async throws { + let blocking = BlockingFakeUploadTransport() + let uploader = MediaUploader(transport: blocking, policy: makeAllowEverythingPolicy()) + + let url1 = try writeTempPDF(name: "a.pdf") + let url2 = try writeTempPDF(name: "b.pdf") + defer { + try? FileManager.default.removeItem(at: url1) + try? FileManager.default.removeItem(at: url2) + } + + await uploader.enqueue(sources: [.file(url1), .file(url2)]) + await Task.yield() + + let state = await uploader.snapshot() + #expect(state.pending.count == 2) + + await uploader.cancelAllPending() + await blocking.unblock() + + let afterState = await uploader.snapshot() + #expect(afterState.pending.isEmpty) + #expect(afterState.failed.isEmpty) + } + + @Test("tearDown drains both lists and finishes the stream") + func tearDownDrainsBothLists() async throws { + let fakeTransport = FakeUploadTransport() + await fakeTransport.setResponses([.failure(URLError(.timedOut))]) + let uploader = MediaUploader(transport: fakeTransport, policy: makeAllowEverythingPolicy()) + + let failURL = try writeTempPDF(name: "fail-teardown.pdf") + defer { try? FileManager.default.removeItem(at: failURL) } + + await uploader.enqueue(sources: [.file(failURL)]) + try await Task.sleep(nanoseconds: 200_000_000) + + let stateBefore = await uploader.snapshot() + #expect(stateBefore.failed.count == 1) + + await uploader.tearDown() + + let stateAfter = await uploader.snapshot() + #expect(stateAfter.isEmpty) + + // A newly subscribed stream after teardown should terminate immediately. + var receivedStates = 0 + for await _ in uploader.statePublisher.values { + receivedStates += 1 + } + #expect(receivedStates == 0) + } + + @Test("failure keeps its slot in submission order; later pending stays after") + func failureKeepsSlotInOrder() async throws { + // First upload fails; second blocks so it stays pending. + let fakeTransport = BlockingAndThenFailFakeUploadTransport() + await fakeTransport.configureFirstCallAsFailure(URLError(.timedOut)) + let uploader = MediaUploader(transport: fakeTransport, policy: makeAllowEverythingPolicy()) + + let urlA = try writeTempPDF(name: "first.pdf") + let urlB = try writeTempPDF(name: "second.pdf") + defer { + try? FileManager.default.removeItem(at: urlA.deletingLastPathComponent()) + try? FileManager.default.removeItem(at: urlB.deletingLastPathComponent()) + } + + await uploader.enqueue(sources: [.file(urlA)]) + try await Task.sleep(nanoseconds: 200_000_000) + await uploader.enqueue(sources: [.file(urlB)]) + try await Task.sleep(nanoseconds: 150_000_000) + + let state = await uploader.snapshot() + #expect(state.entries.count == 2) + // First slot is the failed `first.pdf`; second slot is pending + // `second.pdf`. The crucial bit is that `first.pdf` did NOT + // migrate to the end after failing. + if case .failed(let f) = state.entries[0] { + #expect(f.displayName == "first.pdf") + } else { + Issue.record("first.pdf should be in slot 0 (failed) after failure") + } + if case .pending(let p) = state.entries[1] { + #expect(p.displayName == "second.pdf") + } else { + Issue.record("second.pdf should be in slot 1 (pending)") + } + + await fakeTransport.unblock() + } + + @Test("UploadSource.materializationProgressWeight is 0.05 for on-device sources") + func materializationProgressWeightLocalSources() async throws { + let pdfURL = try writeTempPDF() + defer { try? FileManager.default.removeItem(at: pdfURL.deletingLastPathComponent()) } + + let cases: [UploadSource] = [ + .photoLibrary(itemProvider: NSItemProvider(), suggestedName: nil, hint: .image), + .cameraImage(UIImage(), capturedAt: Date()), + .cameraVideo(pdfURL, capturedAt: Date()), + .file(pdfURL) + ] + for source in cases { + #expect(source.materializationProgressWeight == 0.05) + } + } + + @Test func materializationProgressWeight_remoteURL_splitsEvenly() { + let remoteURL = UploadSource.remoteURL( + .init( + url: URL(string: "https://example.com/a.jpg")!, + suggestedName: "a", + contentType: .jpeg, + caption: nil + ) + ) + #expect(remoteURL.materializationProgressWeight == 0.5) + } + + @Test func materializationProgressWeight_imagePlayground_isLight() { + let imagePlayground = UploadSource.imagePlayground( + URL(fileURLWithPath: "/tmp/x.heic"), + suggestedName: "x" + ) + #expect(imagePlayground.materializationProgressWeight == 0.05) + } + + @Test("enqueue inserts the pending row before materialization completes") + func rowAppearsBeforeMaterialization() async throws { + let transport = FakeUploadTransport() + let mock = MockMaterializer() + let uploader = MediaUploader( + transport: transport, + materializer: mock, + filePickerContentTypes: [.content] + ) + + let pdfURL = try writeTempPDF() + defer { try? FileManager.default.removeItem(at: pdfURL.deletingLastPathComponent()) } + + await uploader.enqueue(sources: [.file(pdfURL)]) + + // The row should be visible immediately; do not await materialization. + let snapshot = await uploader.snapshot() + #expect(snapshot.pending.count == 1) + #expect(snapshot.failed.isEmpty) + + // Cancel to drain the in-flight Task before the test exits. + if let id = snapshot.pending.first?.id { + await uploader.cancel(id) + } + } + + @Test("stage progress feeds the row's overall progress (5% local weight)") + func materializationProgressReachesUI() async throws { + let transport = FakeUploadTransport() + let mock = MockMaterializer() + let uploader = MediaUploader( + transport: transport, + materializer: mock, + filePickerContentTypes: [.content] + ) + + let pdfURL = try writeTempPDF() + defer { try? FileManager.default.removeItem(at: pdfURL.deletingLastPathComponent()) } + + await uploader.enqueue(sources: [.file(pdfURL)]) + + // Wait until the work Task has entered materialize. + await mock.waitForStart() + + let stage = await mock.lastStageProgress + #expect(stage != nil) + stage?.completedUnitCount = 50 + + // Re-read snapshot; the entry's overall progress should reflect + // 50% of the 5% weight = 0.025. + let snapshot = await uploader.snapshot() + let row = try #require(snapshot.pending.first) + #expect(abs(row.progress.fractionCompleted - 0.025) < 0.001) + + // Drain. + if let id = snapshot.pending.first?.id { + await uploader.cancel(id) + } + } + + @Test("cancel during materialization removes the row silently") + func cancelDuringMaterialization() async throws { + let transport = FakeUploadTransport() + let mock = MockMaterializer() + let uploader = MediaUploader( + transport: transport, + materializer: mock, + filePickerContentTypes: [.content] + ) + + let pdfURL = try writeTempPDF() + defer { try? FileManager.default.removeItem(at: pdfURL.deletingLastPathComponent()) } + + await uploader.enqueue(sources: [.file(pdfURL)]) + await mock.waitForStart() + + let snapshot = await uploader.snapshot() + let id = try #require(snapshot.pending.first?.id) + + // Cancel while the mock is still blocked. + await uploader.cancel(id) + + // Now let the mock resolve as success; it'll throw CancellationError + // because of the checkCancellation inside MockMaterializer. + let materialized = MaterializedUpload( + tempFileURL: pdfURL, + params: MediaCreateParams(filePath: pdfURL.path), + kind: .document + ) + await mock.complete(with: .success(materialized)) + + // Allow the work Task to fully unwind. + try await Task.sleep(for: .milliseconds(50)) + + let after = await uploader.snapshot() + #expect(after.pending.isEmpty) + #expect(after.failed.isEmpty) + let uploadCount = await transport.uploadCount + #expect(uploadCount == 0) + } + + @Test("cancel between materialize and upload removes the row AND the temp dir") + func cancelBetweenMaterializeAndUploadCleansOrphan() async throws { + let transport = BlockingFakeUploadTransport() + let mock = MockMaterializer() + let uploader = MediaUploader( + transport: transport, + materializer: mock, + filePickerContentTypes: [.content] + ) + + // Create a real on-disk temp file the mock will return as the + // materialized output. We assert this file (or its parent dir) is + // gone after cancel. + let realTemp = try writeTempFile(name: "fake-materialized.bin", content: Data("payload".utf8)) + let parentDir = realTemp.deletingLastPathComponent() + defer { try? FileManager.default.removeItem(at: parentDir) } + #expect(FileManager.default.fileExists(atPath: realTemp.path)) + + let pdfURL = try writeTempPDF() + defer { try? FileManager.default.removeItem(at: pdfURL.deletingLastPathComponent()) } + + await uploader.enqueue(sources: [.file(pdfURL)]) + await mock.waitForStart() + + let snapshot = await uploader.snapshot() + let id = try #require(snapshot.pending.first?.id) + + // Resolve materialize with a successful materialized payload pointing + // at our real on-disk file. The work Task hops back to the actor + // (markMaterialized) AFTER this returns. + let materialized = MaterializedUpload( + tempFileURL: realTemp, + params: MediaCreateParams(filePath: realTemp.path), + kind: .document + ) + await mock.complete(with: .success(materialized)) + + // Race: cancel ASAP; it may land before or after markMaterialized. + // Either way, the orphan-cleanup path must remove the temp dir. + await uploader.cancel(id) + + // Unblock the transport in case the work Task reached the upload + // stage before the cancel landed: staged-file deletion is deferred + // until the task fully unwinds, so the task must be able to finish. + await transport.unblock() + + // Let the work Task fully unwind, plus any cleanup hops. + try await Task.sleep(for: .milliseconds(100)) + + let after = await uploader.snapshot() + #expect(after.pending.isEmpty) + #expect(after.failed.isEmpty) + #expect(!FileManager.default.fileExists(atPath: realTemp.path), "orphan temp dir leaked") + } + + @Test("materialization failure keeps its slot in submission order") + func materializationFailureKeepsSlot() async throws { + // Reject-all policy makes the first source's materialization fail. + let transport = BlockingFakeUploadTransport() + let uploader = MediaUploader(transport: transport, policy: makeRejectEverythingPolicy()) + + let urlA = try writeTempPDF(name: "first.pdf") + let urlB = try writeTempPDF(name: "second.pdf") + defer { + try? FileManager.default.removeItem(at: urlA.deletingLastPathComponent()) + try? FileManager.default.removeItem(at: urlB.deletingLastPathComponent()) + } + + await uploader.enqueue(sources: [.file(urlA)]) + try await Task.sleep(for: .milliseconds(150)) + await uploader.enqueue(sources: [.file(urlB)]) + try await Task.sleep(for: .milliseconds(150)) + + let state = await uploader.snapshot() + #expect(state.entries.count == 2) + if case .failed(let f) = state.entries[0] { + #expect(f.displayName == "first.pdf") + #expect(!f.isRetryable) + } else { + Issue.record("first.pdf should be failed in slot 0") + } + if case .failed(let f) = state.entries[1] { + // Reject-all means both fail at materialization. + #expect(f.displayName == "second.pdf") + #expect(!f.isRetryable) + } else if case .pending(let p) = state.entries[1] { + // Transport blocks if we ever reach upload, which we don't. + Issue.record("second.pdf unexpectedly reached upload phase: \(p.displayName)") + } + + await transport.unblock() + } + + @Test("transport cancellation with the row still present surfaces as failed") + func transportCancellationBecomesFailed() async throws { + // The user's cancel() removes the row synchronously before any error + // can arrive, so a cancellation error reaching a still-present row is + // system-initiated. It must surface as a retryable failure, not + // silently discard the upload. + let transport = FakeUploadTransport() + await transport.setResponses([.failure(URLError(.cancelled))]) + let uploader = MediaUploader(transport: transport, policy: makeAllowEverythingPolicy()) + + let pdfURL = try writeTempPDF() + defer { try? FileManager.default.removeItem(at: pdfURL.deletingLastPathComponent()) } + + await uploader.enqueue(sources: [.file(pdfURL)]) + try await Task.sleep(for: .milliseconds(200)) + + let state = await uploader.snapshot() + #expect(state.pending.isEmpty) + #expect(state.failed.count == 1, "a non-user cancellation must not vanish silently") + let failedRow = try #require(state.failed.first) + #expect(failedRow.isRetryable) + } + + @Test("enqueue after tearDown is a no-op") + func enqueueAfterTearDownIsNoOp() async throws { + let transport = FakeUploadTransport() + let uploader = MediaUploader(transport: transport, policy: makeAllowEverythingPolicy()) + await uploader.tearDown() + + let pdfURL = try writeTempPDF() + defer { try? FileManager.default.removeItem(at: pdfURL.deletingLastPathComponent()) } + + // The state subject already completed, so an upload started now + // would be invisible and uncancellable. It must be refused. + await uploader.enqueue(sources: [.file(pdfURL)]) + try await Task.sleep(for: .milliseconds(100)) + + let state = await uploader.snapshot() + #expect(state.isEmpty) + let uploadCount = await transport.uploadCount + #expect(uploadCount == 0) + } + + @Test("updatePolicy applies to enqueues made after the update") + func updatePolicyAppliesToNewEnqueues() async throws { + let transport = FakeUploadTransport() + let uploader = MediaUploader(transport: transport, policy: makeRejectEverythingPolicy()) + + let pdfURL = try writeTempPDF() + defer { try? FileManager.default.removeItem(at: pdfURL.deletingLastPathComponent()) } + + await uploader.enqueue(sources: [.file(pdfURL)]) + try await Task.sleep(for: .milliseconds(200)) + + let before = await uploader.snapshot() + #expect(before.failed.count == 1) + let uploadsBefore = await transport.uploadCount + #expect(uploadsBefore == 0) + + await uploader.updatePolicy(makeAllowEverythingPolicy()) + await uploader.enqueue(sources: [.file(pdfURL)]) + try await Task.sleep(for: .milliseconds(200)) + + let uploadsAfter = await transport.uploadCount + #expect(uploadsAfter == 1) + } + + @Test("updatePolicy refreshes filePickerContentTypes") + func updatePolicyRefreshesPickerTypes() async { + let uploader = MediaUploader( + transport: FakeUploadTransport(), + policy: makeAllowEverythingPolicy() + ) + #expect(uploader.filePickerContentTypes == [.content]) + + await uploader.updatePolicy(makePolicy(filePickerContentTypes: [.pdf])) + #expect(uploader.filePickerContentTypes == [.pdf]) + } +} diff --git a/Modules/Tests/WordPressMediaLibraryTests/TestSupport.swift b/Modules/Tests/WordPressMediaLibraryTests/TestSupport.swift new file mode 100644 index 000000000000..1d7f0b00191c --- /dev/null +++ b/Modules/Tests/WordPressMediaLibraryTests/TestSupport.swift @@ -0,0 +1,253 @@ +import Foundation +import Testing +import UniformTypeIdentifiers +import WordPressAPI +import WordPressAPIInternal +@testable import WordPressMediaLibrary + +// MARK: - Recording tracker + +@MainActor +final class RecordingMediaTracker: MediaTracker { + var events: [MediaTrackerEvent] = [] + func track(_ event: MediaTrackerEvent) { events.append(event) } +} + +// MARK: - Fake upload transports + +actor FakeUploadTransport: MediaUploadTransport { + var uploadCount = 0 + var responses: [Result] = [] + + func upload( + params: MediaCreateParams, + fulfilling progress: Progress + ) async throws -> MediaWithEditContext { + uploadCount += 1 + progress.completedUnitCount = progress.totalUnitCount + if responses.isEmpty { + return MediaWithEditContext.fixture() + } + return try responses.removeFirst().get() + } + + func setResponses(_ responses: [Result]) { + self.responses = responses + } +} + +/// A transport that blocks until signalled, used to test cancel mid-flight. +actor BlockingFakeUploadTransport: MediaUploadTransport { + private var continuation: CheckedContinuation? + + func upload( + params: MediaCreateParams, + fulfilling progress: Progress + ) async throws -> MediaWithEditContext { + await withCheckedContinuation { cont in + self.continuation = cont + } + try Task.checkCancellation() + return MediaWithEditContext.fixture() + } + + func unblock() { + continuation?.resume() + continuation = nil + } +} + +/// Fails the first call then blocks the second — used to build a mixed +/// (1 failed + 1 pending) banner state through a single uploader. +actor BlockingAndThenFailFakeUploadTransport: MediaUploadTransport { + private var callIndex = 0 + private var firstCallError: Error? + private var continuation: CheckedContinuation? + + func configureFirstCallAsFailure(_ error: Error) { + self.firstCallError = error + } + + func upload( + params: MediaCreateParams, + fulfilling progress: Progress + ) async throws -> MediaWithEditContext { + callIndex += 1 + if callIndex == 1, let firstCallError { + throw firstCallError + } + await withCheckedContinuation { cont in + self.continuation = cont + } + try Task.checkCancellation() + return MediaWithEditContext.fixture() + } + + func unblock() { + continuation?.resume() + continuation = nil + } +} + +// MARK: - MediaWithEditContext fixture + +extension MediaWithEditContext { + static func fixture(id: Int64 = 9999) -> MediaWithEditContext { + MediaWithEditContext( + id: id, + date: "", + dateGmt: Date(timeIntervalSince1970: 0), + guid: PostGuidWithEditContext(raw: nil, rendered: ""), + link: "", + modified: "", + modifiedGmt: Date(timeIntervalSince1970: 0), + slug: "", + status: .inherit, + postType: "", + password: nil, + permalinkTemplate: "", + generatedSlug: "", + title: PostTitleWithEditContext(raw: nil, rendered: ""), + author: 0, + commentStatus: .closed, + pingStatus: .closed, + template: "", + altText: "", + caption: MediaCaptionWithEditContext(raw: "", rendered: ""), + description: MediaDescriptionWithEditContext(raw: "", rendered: ""), + mediaType: .file, + mimeType: "", + mediaDetails: MediaDetails(noHandle: .init()), + postId: nil, + sourceUrl: "", + missingImageSizes: [] + ) + } +} + +// MARK: - MediaUploadPolicy helper + +func makeAllowEverythingPolicy() -> MediaUploadPolicy { + makePolicy(isAllowedForUpload: { _, _ in true }) +} + +/// A policy that rejects every file, used to force materialization failures. +func makeRejectEverythingPolicy() -> MediaUploadPolicy { + makePolicy(isAllowedForUpload: { _, _ in false }) +} + +func makePolicy( + isAllowedForUpload: @escaping @Sendable (UTType, String) -> Bool = { _, _ in true }, + filePickerContentTypes: [UTType] = [.content] +) -> MediaUploadPolicy { + MediaUploadPolicy( + filePickerContentTypes: filePickerContentTypes, + isAllowedForUpload: isAllowedForUpload, + imageMaxDimension: nil, + imageJpegQuality: 0.9, + convertHEICToJPEG: true, + videoMaxDurationSeconds: nil, + videoExportPreset: "AVAssetExportPresetMediumQuality", + videoOutputContentType: .mpeg4Movie, + stripGPSLocation: false + ) +} + +// MARK: - Mock materializer + +/// Test seam that lets a test drive materialization timing and outcome. +/// - Suspends on a "start" continuation when `materialize` is called. +/// - When unblocked by the test, either throws the configured error or +/// returns the configured `MaterializedUpload`. +actor MockMaterializer: MediaSourceMaterializing { + enum Outcome { + case success(MaterializedUpload) + case failure(Error) + } + + private var startedContinuations: [CheckedContinuation] = [] + private var completionContinuations: [CheckedContinuation] = [] + private(set) var lastStageProgress: Progress? + private(set) var lastSource: UploadSource? + private(set) var sawCancellation = false + + nonisolated func materialize( + source: UploadSource, + into stageProgress: Progress + ) async throws -> MaterializedUpload { + try await materializeAsync(source: source, into: stageProgress) + } + + private func materializeAsync( + source: UploadSource, + into stageProgress: Progress + ) async throws -> MaterializedUpload { + self.lastSource = source + lastStageProgress = stageProgress + await withCheckedContinuation { cont in + startedContinuations.append(cont) + } + let outcome = await withCheckedContinuation { cont in + completionContinuations.append(cont) + } + // Mirror the real materializer's contract: if cancellation is + // observed before we hand back the payload, clean up the temp dir + // so the caller is not responsible for it. + if Task.isCancelled { + if case .success(let m) = outcome { + try? FileManager.default.removeItem(at: m.stagingDirectory) + } + throw CancellationError() + } + switch outcome { + case .success(let m): return m + case .failure(let e): throw e + } + } + + /// Signals that `materialize` has been entered. The test typically + /// awaits this before driving stageProgress or calling cancel. + func waitForStart() async { + // Spin until at least one start continuation has been captured. + while startedContinuations.isEmpty { + await Task.yield() + } + let cont = startedContinuations.removeFirst() + cont.resume() + } + + /// Resolve the in-flight `materialize` call. If the work Task hasn't + /// reached the completion suspension point yet, spin-wait briefly so + /// callers don't need to insert sleeps. + func complete(with outcome: Outcome) async { + while completionContinuations.isEmpty { + await Task.yield() + } + let cont = completionContinuations.removeFirst() + cont.resume(returning: outcome) + } +} + +// MARK: - Temp file helpers + +func writeTempFile(name: String, content: Data) throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("MediaLibraryTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let url = dir.appendingPathComponent(name) + try content.write(to: url) + return url +} + +func writeTempPDF(name: String = "doc.pdf") throws -> URL { + try writeTempFile(name: name, content: Data("%PDF-1.4\n%EOF\n".utf8)) +} + +func writeTempJPEG(name: String = "IMG_1234.jpg") throws -> URL { + let jpegHeader: [UInt8] = [0xFF, 0xD8, 0xFF, 0xE0] + return try writeTempFile(name: name, content: Data(jpegHeader)) +} + +func writeTempMOV(name: String = "IMG_1234.mov") throws -> URL { + try writeTempFile(name: name, content: Data("fake-mov".utf8)) +}