Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions Sources/NextcloudKit/NKSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Sources/NextcloudKit/NextcloudKit+Upload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
63 changes: 60 additions & 3 deletions Tests/NextcloudKitUnitTests/ChunkedUploadAssemblyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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] = [
Expand Down
45 changes: 45 additions & 0 deletions Tests/NextcloudKitUnitTests/ChunkedUploadURLProtocol.swift
Original file line number Diff line number Diff line change
@@ -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() {}
}
Loading