From 526e3fce1494d51b06cb9f53595a68831d3bdbfa Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 27 Aug 2026 11:10:23 +1200 Subject: [PATCH 1/2] Add MediaUploader actor with broadcast queue lifecycle Add a per-site upload queue actor that materializes each source, uploads it, and publishes ordered pending/failed snapshots through a replaying publisher. Supports cancel, retry, remove, bulk operations, live policy refresh, and teardown, and cleans up staging directories on every exit path. Materialization goes through a MediaSourceMaterializing seam so tests can drive timing and outcomes with a mock. --- Modules/Package.swift | 3 +- .../Upload/MediaSourceMaterializing.swift | 12 + .../Upload/MediaUploader.swift | 446 ++++++++++++++ .../MediaUploaderTests.swift | 542 ++++++++++++++++++ .../TestSupport.swift | 276 +++++++++ 5 files changed, 1278 insertions(+), 1 deletion(-) create mode 100644 Modules/Sources/WordPressMediaLibrary/Upload/MediaSourceMaterializing.swift create mode 100644 Modules/Sources/WordPressMediaLibrary/Upload/MediaUploader.swift create mode 100644 Modules/Tests/WordPressMediaLibraryTests/MediaUploaderTests.swift create mode 100644 Modules/Tests/WordPressMediaLibraryTests/TestSupport.swift diff --git a/Modules/Package.swift b/Modules/Package.swift index 2e108247974c..7901a33a0cee 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -154,7 +154,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..0b3615712099 --- /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..762a3edc2392 --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Upload/MediaUploader.swift @@ -0,0 +1,446 @@ +@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 { stateSubject.send(snapshot()) } + } + + /// 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] = [.content] + ) { + 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 case .pending(let pending) in entries.values { + 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) { + 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), + payload: .unmaterialized(source), + 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 remove(_ uploadID: UUID) { + guard case .failed(let entry)? = entries[uploadID] else { return } + entries.removeValue(forKey: uploadID) + Self.removeStagingDirectory(entry.materialized?.stagingDirectory) + } + + func cancelAllPending() { + for case .pending(let pending) in entries.values { + cancelWork(of: pending) + } + entries.removeAll { + if case .pending = $0.value { true } else { false } + } + } + + 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 removeAllFailed() { + for case .failed(let failedEntry) in entries.values { + Self.removeStagingDirectory(failedEntry.materialized?.stagingDirectory) + } + entries.removeAll { + if case .failed = $0.value { true } else { false } + } + } + + public func tearDown() { + isTornDown = true + for entry in entries.values { + switch entry { + case .pending(let pending): + cancelWork(of: pending) + case .failed(let failedEntry): + Self.removeStagingDirectory(failedEntry.materialized?.stagingDirectory) + } + } + entries = [:] + stateSubject.send(completion: .finished) + } + + // MARK: - Internals + + private func beginWork(for id: UUID) -> InternalPending? { + guard case .pending(let entry)? = entries[id] else { return nil } + return entry + } + + private func makeWorkTask(for id: UUID) -> Task { + Task { [weak self, materializer, transport] in + do { + guard let entry = await self?.beginWork(for: id) else { return } + let overall = entry.overallProgress + let params: MediaCreateParams + let uploadWeight: Double + switch entry.payload { + case .unmaterialized(let source): + let stageChild = Self.makeSubprogress( + of: overall, + weight: source.materializationProgressWeight + ) + let materialized = try await materializer.materialize( + source: source, + into: stageChild + ) + // 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 + uploadWeight = 1.0 - source.materializationProgressWeight + case .staged(let materialized): + // 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 + uploadWeight = 1.0 + } + let uploadChild = Self.makeSubprogress(of: overall, weight: uploadWeight) + _ = try await transport.upload(params: params, fulfilling: uploadChild) + 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.payload = .staged(materialized) + entries[id] = .pending(entry) + return true + } + + /// Attaches a fresh 0...100 child that claims `weight` of `overall`'s + /// total, so a stage reports its own fine-grained progress while + /// contributing its allotted fraction to the row's overall bar. + private static func makeSubprogress(of overall: Progress, weight: Double) -> Progress { + let pending = Int64((Double(overall.totalUnitCount) * weight).rounded()) + let child = Progress(totalUnitCount: 100) + overall.addChild(child, withPendingUnitCount: pending) + return child + } + + 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 let staged = materialized, case .fileNotFound? = error as? 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 + // remove-only and delete any staging directory leftovers. + Self.removeStagingDirectory(staged.stagingDirectory) + materialized = nil + } + entries[entry.id] = .failed( + InternalFailed( + id: entry.id, + displayName: entry.displayName, + kind: entry.kind, + materialized: materialized, + errorMessage: error.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), + payload: .staged(materialized), + task: makeWorkTask(for: failedEntry.id) + ) + ) + } + + 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 { + /// What the work task still has to do. Holding the source only until + /// materialization completes releases large in-memory payloads (e.g. + /// camera images) as soon as their bytes are staged on disk. + enum Payload { + case unmaterialized(UploadSource) + case staged(MaterializedUpload) + } + + let id: UUID + let displayName: String + let kind: MediaKind + let overallProgress: Progress + var payload: Payload + let task: Task + + var materialized: MaterializedUpload? { + if case .staged(let materialized) = payload { materialized } else { nil } + } + + 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..e7ccf136cac9 --- /dev/null +++ b/Modules/Tests/WordPressMediaLibraryTests/MediaUploaderTests.swift @@ -0,0 +1,542 @@ +import Foundation +import Testing +import UIKit +import UniformTypeIdentifiers +import WordPressAPI +import WordPressAPIInternal +@testable import WordPressMediaLibrary + +@Suite("MediaUploader") +final class MediaUploaderTests { + private let fixtures = TempFixtureDirectory() + + @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 fixtures.writePDF(name: "doc.pdf") + + let stateBefore = await uploader.snapshot() + #expect(stateBefore.pending.isEmpty) + + await uploader.enqueue(sources: [.file(sourceURL)]) + + try await waitForState(of: uploader) { $0.isEmpty } + + 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 fixtures.writePDF(name: "success.pdf") + + await uploader.enqueue(sources: [.file(sourceURL)]) + let state = try await waitForState(of: uploader) { $0.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 fixtures.writePDF(name: "fail.pdf") + + await uploader.enqueue(sources: [.file(sourceURL)]) + let state = try await waitForState(of: uploader) { $0.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 fixtures.writePDF(name: "cancel.pdf") + + 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 fixtures.writePDF(name: "retry.pdf") + + await uploader.enqueue(sources: [.file(sourceURL)]) + let failedState = try await waitForState(of: uploader) { $0.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) + + let finalState = try await waitForState(of: uploader) { $0.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 = try fixtures.makeDirectory() + + let fakeTransport = FakeUploadTransport() + await fakeTransport.setResponses([.failure(URLError(.timedOut))]) + let materializer = UploadSourceMaterializer( + policy: makeAllowEverythingPolicy(), + temporaryRoot: root + ) + let uploader = MediaUploader( + transport: fakeTransport, + materializer: materializer + ) + + let sourceURL = try fixtures.writePDF(name: "purge.pdf") + + await uploader.enqueue(sources: [.file(sourceURL)]) + let failed = try #require( + await waitForState(of: uploader) { $0.failed.count == 1 }.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) + + // 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 = try await waitForState(of: uploader) { $0.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 fixtures.writePDF(name: "rejected.pdf") + + await uploader.enqueue(sources: [.file(sourceURL)]) + let state = try await waitForState(of: uploader) { $0.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("remove drops failed entry") + func removeDropsFailedEntry() async throws { + let fakeTransport = FakeUploadTransport() + await fakeTransport.setResponses([.failure(URLError(.timedOut))]) + let uploader = MediaUploader(transport: fakeTransport, policy: makeAllowEverythingPolicy()) + + let sourceURL = try fixtures.writePDF(name: "remove.pdf") + + await uploader.enqueue(sources: [.file(sourceURL)]) + let failedState = try await waitForState(of: uploader) { $0.failed.count == 1 } + let failedID = failedState.failed[0].id + + await uploader.remove(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 fixtures.writePDF(name: "a.pdf") + let url2 = try fixtures.writePDF(name: "b.pdf") + + 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 fixtures.writePDF(name: "fail-teardown.pdf") + + await uploader.enqueue(sources: [.file(failURL)]) + try await waitForState(of: uploader) { $0.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 = BlockingFakeUploadTransport() + await fakeTransport.failFirstCall(with: URLError(.timedOut)) + let uploader = MediaUploader(transport: fakeTransport, policy: makeAllowEverythingPolicy()) + + let urlA = try fixtures.writePDF(name: "first.pdf") + let urlB = try fixtures.writePDF(name: "second.pdf") + + await uploader.enqueue(sources: [.file(urlA)]) + try await waitForState(of: uploader) { $0.failed.count == 1 } + await uploader.enqueue(sources: [.file(urlB)]) + await fakeTransport.waitUntilBlocked() + + 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 fixtures.writePDF() + + 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 + ) + + let pdfURL = try fixtures.writePDF() + + 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 + ) + + let pdfURL = try fixtures.writePDF() + + 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 + ) + + let pdfURL = try fixtures.writePDF() + + 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)) + + 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 + ) + + // 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 fixtures.writeFile(name: "fake-materialized.bin", content: Data("payload".utf8)) + #expect(FileManager.default.fileExists(atPath: realTemp.path)) + + let pdfURL = try fixtures.writePDF() + + 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() + + // Staged-file deletion happens after the work Task fully unwinds and + // is not reflected in any published state, so poll for it. + try await waitUntil { !FileManager.default.fileExists(atPath: realTemp.path) } + + let after = await uploader.snapshot() + #expect(after.pending.isEmpty) + #expect(after.failed.isEmpty) + } + + @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 fixtures.writePDF(name: "first.pdf") + let urlB = try fixtures.writePDF(name: "second.pdf") + + await uploader.enqueue(sources: [.file(urlA)]) + try await waitForState(of: uploader) { $0.failed.count == 1 } + await uploader.enqueue(sources: [.file(urlB)]) + let state = try await waitForState(of: uploader) { $0.failed.count == 2 } + #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 fixtures.writePDF() + + await uploader.enqueue(sources: [.file(pdfURL)]) + let state = try await waitForState(of: uploader) { $0.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 fixtures.writePDF() + + // 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)]) + + 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 fixtures.writePDF() + + await uploader.enqueue(sources: [.file(pdfURL)]) + try await waitForState(of: uploader) { $0.failed.count == 1 } + let uploadsBefore = await transport.uploadCount + #expect(uploadsBefore == 0) + + await uploader.updatePolicy(makeAllowEverythingPolicy()) + await uploader.enqueue(sources: [.file(pdfURL)]) + try await waitForState(of: uploader) { $0.pending.isEmpty } + + 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..fee6b19433d9 --- /dev/null +++ b/Modules/Tests/WordPressMediaLibraryTests/TestSupport.swift @@ -0,0 +1,276 @@ +import AVFoundation +import Foundation +import Testing +import UniformTypeIdentifiers +import WordPressAPI +import WordPressAPIInternal +@testable import WordPressMediaLibrary + +// 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. +/// Optionally fails the first call instead, to build a mixed (1 failed + +/// 1 pending) state through a single uploader. +actor BlockingFakeUploadTransport: MediaUploadTransport { + private var callIndex = 0 + private var firstCallError: Error? + private var continuation: CheckedContinuation? + + func failFirstCall(with error: Error) { + 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() + } + + /// Returns once an upload call is suspended on the continuation. + func waitUntilBlocked() async { + while continuation == nil { + await Task.yield() + } + } + + func unblock() { + continuation?.resume() + continuation = nil + } +} + +// MARK: - Waiting on uploader state + +struct WaitTimedOut: Error {} + +/// Returns the first uploader state (starting with the current one) that +/// satisfies `predicate`, or throws once `timeout` elapses or the publisher +/// finishes. +@discardableResult +func waitForState( + of uploader: MediaUploader, + timeout: Duration = .seconds(5), + where predicate: @escaping @Sendable (UploaderState) -> Bool +) async throws -> UploaderState { + try await withThrowingTaskGroup(of: UploaderState.self) { group in + group.addTask { + for await state in uploader.statePublisher.values where predicate(state) { + return state + } + throw WaitTimedOut() + } + group.addTask { + try await Task.sleep(for: timeout) + throw WaitTimedOut() + } + defer { group.cancelAll() } + return try await group.next()! + } +} + +/// Polls `condition` until it holds, for side effects (like file deletion) +/// that no published state reflects. +func waitUntil( + timeout: Duration = .seconds(5), + _ condition: () -> Bool +) async throws { + let clock = ContinuousClock() + let deadline = clock.now + timeout + while !condition() { + guard clock.now < deadline else { throw WaitTimedOut() } + try await Task.sleep(for: .milliseconds(10)) + } +} + +// 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], + imageMaxDimension: Int? = nil, + videoMaxDurationSeconds: TimeInterval? = nil, + stripGPSLocation: Bool = false +) -> MediaUploadPolicy { + MediaUploadPolicy( + filePickerContentTypes: filePickerContentTypes, + isAllowedForUpload: isAllowedForUpload, + imageMaxDimension: imageMaxDimension, + imageJpegQuality: 0.9, + convertHEICToJPEG: true, + videoMaxDurationSeconds: videoMaxDurationSeconds, + videoExportPreset: AVAssetExportPresetMediumQuality, + videoOutputContentType: .mpeg4Movie, + stripGPSLocation: stripGPSLocation + ) +} + +// 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? + + func materialize( + source: UploadSource, + into stageProgress: Progress + ) async throws -> MaterializedUpload { + 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 + +/// Per-suite root for fixture files, removed when the suite deinitializes so +/// tests need no per-file cleanup. +final class TempFixtureDirectory { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("MediaLibraryTests-\(UUID().uuidString)", isDirectory: true) + + deinit { + try? FileManager.default.removeItem(at: root) + } + + /// Creates a fresh, empty directory under the root. + func makeDirectory() throws -> URL { + let dir = root.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + /// Writes `content` into a fresh directory under the root. + func writeFile(name: String, content: Data) throws -> URL { + let url = try makeDirectory().appendingPathComponent(name) + try content.write(to: url) + return url + } + + func writePDF(name: String = "doc.pdf") throws -> URL { + try writeFile(name: name, content: Data("%PDF-1.4\n%EOF\n".utf8)) + } +} From 5e4e90f349e48bc6f3eb6884ca12989da9b1c994 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 27 Aug 2026 11:10:23 +1200 Subject: [PATCH 2/2] Use the shared test support in materializer tests --- .../UploadSourceMaterializerTests.swift | 64 ++++++------------- 1 file changed, 20 insertions(+), 44 deletions(-) diff --git a/Modules/Tests/WordPressMediaLibraryTests/UploadSourceMaterializerTests.swift b/Modules/Tests/WordPressMediaLibraryTests/UploadSourceMaterializerTests.swift index f10fa91ab063..c9f00a25abfb 100644 --- a/Modules/Tests/WordPressMediaLibraryTests/UploadSourceMaterializerTests.swift +++ b/Modules/Tests/WordPressMediaLibraryTests/UploadSourceMaterializerTests.swift @@ -28,14 +28,9 @@ private func expectThrowsCase( @Suite("UploadSourceMaterializer") final class UploadSourceMaterializerTests { - /// Per-test root for fixtures and staging output, so tests never write - /// into the production staging directory and need no per-result cleanup. - private let root = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - - deinit { - try? FileManager.default.removeItem(at: root) - } + /// Root for fixtures and staging output, so tests never write into the + /// production staging directory and need no per-result cleanup. + private let fixtures = TempFixtureDirectory() private func policy( allow: @escaping @Sendable (UTType, String) -> Bool = { _, _ in true }, @@ -43,15 +38,10 @@ final class UploadSourceMaterializerTests { videoMaxDurationSeconds: TimeInterval? = nil, stripGPSLocation: Bool = false ) -> MediaUploadPolicy { - MediaUploadPolicy( - filePickerContentTypes: [.content], + makePolicy( isAllowedForUpload: allow, imageMaxDimension: imageMaxDimension, - imageJpegQuality: 0.9, - convertHEICToJPEG: true, videoMaxDurationSeconds: videoMaxDurationSeconds, - videoExportPreset: AVAssetExportPresetMediumQuality, - videoOutputContentType: .mpeg4Movie, stripGPSLocation: stripGPSLocation ) } @@ -76,7 +66,7 @@ final class UploadSourceMaterializerTests { @Test("materialization failures remove their parent temp directory") func failureRemovesTempDir() async throws { let tempURL = try createTempPDF() - let inspectableRoot = try makeTempDir() + let inspectableRoot = try fixtures.makeDirectory() let m = makeMaterializer(policy(allow: { _, _ in false }), temporaryRoot: inspectableRoot) await expectThrowsCase(.disallowedContentType) { @@ -286,7 +276,7 @@ final class UploadSourceMaterializerTests { func imagePlaygroundAppliesImagePolicy() async throws { // Plant a HEIC where Image Playground would have written its output; // the image policy must convert it like any other picked image. - let imageURL = try makeTempDir().appendingPathComponent("Generated.heic") + let imageURL = try fixtures.makeDirectory().appendingPathComponent("Generated.heic") try makeSyntheticHEIC().write(to: imageURL) let result = try await makeMaterializer(policy()) @@ -304,7 +294,7 @@ final class UploadSourceMaterializerTests { @Test("remote dispatch: GIF passthrough preserves bytes") func remoteDispatchGIFPassthroughPreservesBytes() async throws { - let parentDir = try makeTempDir() + let parentDir = try fixtures.makeDirectory() let sourceGIF = parentDir.appendingPathComponent("download.tmp") try gifFixture.write(to: sourceGIF) @@ -326,7 +316,7 @@ final class UploadSourceMaterializerTests { @Test("remote dispatch passes the caption through", arguments: [UTType.gif, .jpeg]) func remoteDispatchPassesCaptionThrough(contentType: UTType) async throws { - let parentDir = try makeTempDir() + let parentDir = try fixtures.makeDirectory() let sourceFile = parentDir.appendingPathComponent("download.tmp") let bytes = contentType == .gif @@ -347,7 +337,7 @@ final class UploadSourceMaterializerTests { @Test("remote dispatch rejects non-image, non-GIF content types") func remoteDispatchRejectsNonImageNonGifContentType() async throws { - let parentDir = try makeTempDir() + let parentDir = try fixtures.makeDirectory() let sourceFile = parentDir.appendingPathComponent("vid.tmp") try Data([0x00]).write(to: sourceFile) @@ -368,7 +358,7 @@ final class UploadSourceMaterializerTests { arguments: [UTType.jpeg, .gif] ) func remoteDispatchContainsTraversingName(contentType: UTType) async throws { - let parentDir = try makeTempDir() + let parentDir = try fixtures.makeDirectory() let sourceFile = parentDir.appendingPathComponent("download.tmp") let bytes = contentType == .gif @@ -402,7 +392,7 @@ final class UploadSourceMaterializerTests { @Test("remote dispatch image branch rejects non-image bytes") func remoteDispatchImageBranchRejectsNonImageBytes() async throws { - let parentDir = try makeTempDir() + let parentDir = try fixtures.makeDirectory() let sourceFile = parentDir.appendingPathComponent("a.tmp") try Data("404 Not Found".utf8).write(to: sourceFile) @@ -422,7 +412,7 @@ final class UploadSourceMaterializerTests { @Test(".file SVG passes through raw-copied") func fileSVGRawCopy() async throws { - let url = try writeTempFixture(svgFixture, name: "art.svg") + let url = try fixtures.writeFile(name: "art.svg", content: svgFixture) let m = makeMaterializer(policy()) let result = try await m.materialize(source: .file(url), into: stage()) @@ -434,7 +424,7 @@ final class UploadSourceMaterializerTests { @Test("remote dispatch: SVG passthrough preserves bytes") func remoteDispatchSVGPassthroughPreservesBytes() async throws { - let parentDir = try makeTempDir() + let parentDir = try fixtures.makeDirectory() let sourceSVG = parentDir.appendingPathComponent("download.tmp") try svgFixture.write(to: sourceSVG) @@ -454,7 +444,7 @@ final class UploadSourceMaterializerTests { @Test("remote dispatch: SVG policy rejection surfaces disallowedContentType") func remoteDispatchSVGPolicyRejectionSurfacesDisallowed() async throws { - let parentDir = try makeTempDir() + let parentDir = try fixtures.makeDirectory() let sourceSVG = parentDir.appendingPathComponent("download.tmp") try svgFixture.write(to: sourceSVG) @@ -603,7 +593,7 @@ final class UploadSourceMaterializerTests { @Test("orphan sweep deletes only entries created before the cutoff") func sweepSkipsEntriesCreatedAfterCutoff() throws { - let sweepRoot = try makeTempDir() + let sweepRoot = try fixtures.makeDirectory() let orphaned = sweepRoot.appendingPathComponent(UUID().uuidString, isDirectory: true) let fresh = sweepRoot.appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: orphaned, withIntermediateDirectories: true) @@ -632,33 +622,19 @@ extension UploadSourceMaterializerTests { _ policy: MediaUploadPolicy, temporaryRoot: URL? = nil ) -> UploadSourceMaterializer { - UploadSourceMaterializer(policy: policy, temporaryRoot: temporaryRoot ?? root) - } - - /// Fresh directory under the per-test root; removed with it in deinit. - private func makeTempDir() throws -> URL { - let dir = root.appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - return dir - } - - /// Writes fixture bytes into a fresh directory under the per-test root. - private func writeTempFixture(_ data: Data, name: String) throws -> URL { - let url = try makeTempDir().appendingPathComponent(name) - try data.write(to: url) - return url + UploadSourceMaterializer(policy: policy, temporaryRoot: temporaryRoot ?? fixtures.root) } private func writeTempFixture(_ data: Data, ext: String) throws -> URL { - try writeTempFixture(data, name: "fixture.\(ext)") + try fixtures.writeFile(name: "fixture.\(ext)", content: data) } private func createTempPDF(name: String = "test.pdf") throws -> URL { - try writeTempFixture(Data("%PDF-1.4\n%EOF\n".utf8), name: name) + try fixtures.writePDF(name: name) } private func createTempGIF() throws -> URL { - try writeTempFixture(gifFixture, name: "test.gif") + try fixtures.writeFile(name: "test.gif", content: gifFixture) } /// A short video carrying a QuickTime ISO-6709 location, used to prove the @@ -671,7 +647,7 @@ extension UploadSourceMaterializerTests { return try await createBlankVideo( durationSeconds: 1, metadata: [location], - in: makeTempDir() + in: fixtures.makeDirectory() ) } }