diff --git a/Sources/NextcloudKit/NKSession.swift b/Sources/NextcloudKit/NKSession.swift index fe598bfd..e8b69b2b 100644 --- a/Sources/NextcloudKit/NKSession.swift +++ b/Sources/NextcloudKit/NKSession.swift @@ -65,7 +65,8 @@ public struct NKSession: Sendable { groupIdentifier: String, httpMaximumConnectionsPerHost: Int, httpMaximumConnectionsPerHostInDownload: Int, - httpMaximumConnectionsPerHostInUpload: Int) { + httpMaximumConnectionsPerHostInUpload: Int, + sessionDataOverride: Alamofire.Session? = nil) { self.urlBase = urlBase self.user = user self.userId = userId @@ -91,12 +92,12 @@ public struct NKSession: Sendable { #endif configurationSessionData.httpCookieStorage = HTTPCookieStorage.sharedCookieStorage(forGroupContainerIdentifier: sharedCookieStorage) - sessionData = Alamofire.Session(configuration: configurationSessionData, - delegate: NextcloudKitSessionDelegate(nkCommonInstance: nkCommonInstance), - rootQueue: nkCommonInstance.rootQueue, - requestQueue: nkCommonInstance.requestQueue, - serializationQueue: nkCommonInstance.serializationQueue, - eventMonitors: [NKMonitor(nkCommonInstance: nkCommonInstance)]) + sessionData = sessionDataOverride ?? Alamofire.Session(configuration: configurationSessionData, + delegate: NextcloudKitSessionDelegate(nkCommonInstance: nkCommonInstance), + rootQueue: nkCommonInstance.rootQueue, + requestQueue: nkCommonInstance.requestQueue, + serializationQueue: nkCommonInstance.serializationQueue, + eventMonitors: [NKMonitor(nkCommonInstance: nkCommonInstance)]) // SessionDataNoCache Alamofire let configurationSessionDataNoCache = URLSessionConfiguration.af.default diff --git a/Sources/NextcloudKit/NextcloudKit+Upload.swift b/Sources/NextcloudKit/NextcloudKit+Upload.swift index 0f112319..1779ac73 100644 --- a/Sources/NextcloudKit/NextcloudKit+Upload.swift +++ b/Sources/NextcloudKit/NextcloudKit+Upload.swift @@ -438,7 +438,7 @@ public extension NextcloudKit { options.customHeader?["If-Match"] = nil guard moveRes.error == .success else { - return (account, nil) + throw moveRes.error } try Task.checkCancellation() diff --git a/Tests/NextcloudKitUnitTests/ChunkedUploadAssemblyTests.swift b/Tests/NextcloudKitUnitTests/ChunkedUploadAssemblyTests.swift index f409c915..3eb0c62e 100644 --- a/Tests/NextcloudKitUnitTests/ChunkedUploadAssemblyTests.swift +++ b/Tests/NextcloudKitUnitTests/ChunkedUploadAssemblyTests.swift @@ -2,11 +2,10 @@ // SPDX-License-Identifier: GPL-3.0-or-later import XCTest +import Alamofire @testable import NextcloudKit -/// Unit tests for deriving the assembled file's `NKFile` from a chunk-assembly MOVE response's -/// headers — the primary path that lets `uploadChunkAsync` skip the fragile PROPFIND read-back -/// (whose failure otherwise surfaced as `errorChunkMoveFile`, code -9997). +/// Unit tests for chunk-assembly MOVE failures and deriving the assembled file from response headers. final class ChunkedUploadAssemblyTests: XCTestCase { private func makeKit() -> NextcloudKit { #if swift(<6.0) @@ -16,6 +15,64 @@ final class ChunkedUploadAssemblyTests: XCTestCase { #endif } + private func uploadThroughMockedAssembly(moveStatus: Int) async throws -> (error: NKError?, file: NKFile?) { + let kit = makeKit() + let account = UUID().uuidString + let urlBase = "https://assembly-\(moveStatus).test" + let configuration = URLSessionConfiguration.af.default + configuration.protocolClasses = [ChunkedUploadURLProtocol.self] + let session = NKSession(nkCommonInstance: kit.nkCommonInstance, + urlBase: urlBase, + user: "user", + userId: "user", + password: "password", + account: account, + userAgent: "NextcloudKitTests", + groupIdentifier: "", + httpMaximumConnectionsPerHost: 1, + httpMaximumConnectionsPerHostInDownload: 1, + httpMaximumConnectionsPerHostInUpload: 1, + sessionDataOverride: Alamofire.Session(configuration: configuration)) + kit.nkCommonInstance.nksessions.append(session) + defer { kit.nkCommonInstance.nksessions.remove(account: account) } + + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + try Data("chunk".utf8).write(to: directory.appendingPathComponent("source.txt")) + try Data("chunk".utf8).write(to: directory.appendingPathComponent("00001")) + + do { + let result = try await kit.uploadChunkAsync(directory: directory.path, + fileName: "source.txt", + date: nil, creationDate: nil, + serverUrl: urlBase + "/remote.php/dav/files/user", + chunkFolder: "chunks", + filesChunk: [(fileName: "00001", size: 5)], + chunkSize: 5, + account: account) + return (nil, result.file) + } catch let error as NKError { + return (error, nil) + } + } + + func test_failedAssemblyMove_throwsServerError() async throws { + let result = try await uploadThroughMockedAssembly(moveStatus: 507) + XCTAssertEqual(result.error?.errorCode, 507) + } + + func test_failedAssemblyMove_throwsPreconditionError() async throws { + let result = try await uploadThroughMockedAssembly(moveStatus: 412) + XCTAssertEqual(result.error?.errorCode, 412) + } + + func test_successfulAssemblyMove_returnsFile() async throws { + let result = try await uploadThroughMockedAssembly(moveStatus: 201) + XCTAssertNil(result.error) + XCTAssertEqual(result.file?.ocId, "assembled-file-id") + } + func test_assembledFile_withOCFileId_derivesNKFileFromHeaders() { let kit = makeKit() let headers: [AnyHashable: Any] = [ diff --git a/Tests/NextcloudKitUnitTests/ChunkedUploadURLProtocol.swift b/Tests/NextcloudKitUnitTests/ChunkedUploadURLProtocol.swift new file mode 100644 index 00000000..8807121a --- /dev/null +++ b/Tests/NextcloudKitUnitTests/ChunkedUploadURLProtocol.swift @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +final class ChunkedUploadURLProtocol: URLProtocol { + override class func canInit(with request: URLRequest) -> Bool { + guard let host = request.url?.host else { return false } + return host.hasPrefix("assembly-") && host.hasSuffix(".test") + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let url = request.url else { + client?.urlProtocol(self, didFailWithError: URLError(.badURL)) + return + } + + let statusCode: Int + var headers: [String: String] = [:] + switch request.httpMethod { + case "PROPFIND": + statusCode = 404 + case "MKCOL", "PUT": + statusCode = 201 + case "MOVE": + statusCode = Int(url.host?.split(separator: "-").last?.split(separator: ".").first ?? "") ?? 500 + if statusCode == 201 { + headers = ["OC-FileID": "assembled-file-id", "OC-ETag": "assembled-etag"] + } + default: + statusCode = 500 + } + + let response = HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: "HTTP/1.1", headerFields: headers)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data()) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +}