diff --git a/Sources/NextcloudKit/Models/NKLock.swift b/Sources/NextcloudKit/Models/NKLock.swift index 487d3d36..8585fe3f 100644 --- a/Sources/NextcloudKit/Models/NKLock.swift +++ b/Sources/NextcloudKit/Models/NKLock.swift @@ -95,8 +95,7 @@ public struct NKLock: Equatable, Sendable { self.time = Date(timeIntervalSince1970: rawTime) self.timeOut = Date(timeIntervalSince1970: rawTime + rawTimeOut) self.token = lockToken - self.etag = properties["d:getetag"].text? - .trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + self.etag = NKLockOperationResult.normalizedETag(properties["d:getetag"].text) } /// diff --git a/Sources/NextcloudKit/Models/NKLockOperationResult.swift b/Sources/NextcloudKit/Models/NKLockOperationResult.swift new file mode 100644 index 00000000..953f5cc6 --- /dev/null +++ b/Sources/NextcloudKit/Models/NKLockOperationResult.swift @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import SwiftyXMLParser + +/// +/// Result of a file lock or unlock operation. +/// +public struct NKLockOperationResult: Sendable { + /// + /// Normalized resource ETag returned by the operation. + /// + public let etag: String? + + /// + /// The created lock, or `nil` when the resource was unlocked. + /// + public let lock: NKLock? + + /// + /// Creates a lock operation result. + /// + public init(etag: String? = nil, lock: NKLock? = nil) { + self.etag = etag + self.lock = lock + } + + init(data: Data) { + self.init(xml: XML.parse(data)["d:prop"]) + } + + init(xml properties: XML.Accessor) { + self.etag = Self.normalizedETag(properties["d:getetag"].text) + self.lock = NKLock(xml: properties) + } + + static func normalizedETag(_ value: String?) -> String? { + guard let value else { + return nil + } + + let normalized = value.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + return normalized.isEmpty ? nil : normalized + } +} diff --git a/Sources/NextcloudKit/NextcloudKit+FilesLock.swift b/Sources/NextcloudKit/NextcloudKit+FilesLock.swift index eb3e9c90..65f0df31 100644 --- a/Sources/NextcloudKit/NextcloudKit+FilesLock.swift +++ b/Sources/NextcloudKit/NextcloudKit+FilesLock.swift @@ -11,7 +11,7 @@ public extension NextcloudKit { /// Sends a WebDAV `LOCK` or `UNLOCK` request for a file on the server, depending on the `shouldLock` flag. /// This is used to prevent or release concurrent edits on a file. /// - /// > Structured Concurrency: Use ``lockUnlockFile(serverUrlFileName:type:shouldLock:account:options:taskHandler:)`` for an `async` implementation which `throws`. + /// > Structured Concurrency: Use ``lockUnlockFileResult(serverUrlFileName:type:shouldLock:account:options:taskHandler:)`` for an `async` implementation which `throws`. /// /// Parameters: /// - serverUrlFileName: Fully qualified and encoded URL of the file to lock/unlock. @@ -86,23 +86,43 @@ public extension NextcloudKit { /// - options: Optional request configuration (headers, queue, etc.). /// - taskHandler: Optional monitoring of the `URLSessionTask`. /// - /// - Returns: A tuple containing the account, the server response, and any error encountered. + /// - Returns: The operation result, including the resource ETag even when unlocking. /// - func lockUnlockFile(serverUrlFileName: String, type: NKLockType? = nil, shouldLock: Bool, account: String, options: NKRequestOptions = NKRequestOptions(), taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }) async throws -> NKLock? { + func lockUnlockFileResult(serverUrlFileName: String, type: NKLockType? = nil, shouldLock: Bool, account: String, options: NKRequestOptions = NKRequestOptions(), taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }) async throws -> NKLockOperationResult { try await withCheckedThrowingContinuation { continuation in lockUnlockFile(serverUrlFileName: serverUrlFileName, type: type, shouldLock: shouldLock, account: account, options: options, taskHandler: taskHandler) { _, responseData, error in switch error { case .success: - if let data = responseData?.data, - let lock = NKLock(data: data) { - continuation.resume(returning: lock) + guard let data = responseData?.data, !data.isEmpty else { + continuation.resume(returning: NKLockOperationResult()) return } - continuation.resume(returning: nil) + continuation.resume(returning: NKLockOperationResult(data: data)) default: continuation.resume(throwing: error) - } + } } } } + + /// + /// Asynchronously locks or unlocks a file on the server via WebDAV. + /// + /// - Parameters: + /// - serverUrlFileName: The server-side full URL of the file to lock or unlock. + /// - shouldLock: `true` to lock the file, `false` to unlock it. + /// - account: The Nextcloud account performing the action. + /// - options: Optional request configuration (headers, queue, etc.). + /// - taskHandler: Optional monitoring of the `URLSessionTask`. + /// + /// - Returns: The created lock, or `nil` when the resource was unlocked. + /// + func lockUnlockFile(serverUrlFileName: String, type: NKLockType? = nil, shouldLock: Bool, account: String, options: NKRequestOptions = NKRequestOptions(), taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }) async throws -> NKLock? { + try await lockUnlockFileResult(serverUrlFileName: serverUrlFileName, + type: type, + shouldLock: shouldLock, + account: account, + options: options, + taskHandler: taskHandler).lock + } } diff --git a/Tests/NextcloudKitUnitTests/FilesLockUnitTests.swift b/Tests/NextcloudKitUnitTests/FilesLockUnitTests.swift new file mode 100644 index 00000000..a4e3d307 --- /dev/null +++ b/Tests/NextcloudKitUnitTests/FilesLockUnitTests.swift @@ -0,0 +1,164 @@ +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import Alamofire +import Mocker +import XCTest +@testable import NextcloudKit + +final class FilesLockUnitTests: XCTestCase { + private let account = "files-lock-unit-test" + private let fileURL = URL(string: "https://example.invalid/remote.php/dav/files/user/file.txt")! + private let xmlDataType = Mock.DataType(name: "xml", headerValue: "application/xml") + + override func tearDown() { + Mocker.removeAll() + super.tearDown() + } + + func test_lockUnlockFileResult_withLockResponse_returnsLockAndETag() async throws { + var mock = Mock(url: fileURL, + dataType: xmlDataType, + statusCode: 200, + data: [HTTPMethod(rawValue: "LOCK"): lockData(etag: "\"etag-b\"")]) + mock.register() + + let result = try await makeKit().lockUnlockFileResult(serverUrlFileName: fileURL.absoluteString, + shouldLock: true, + account: account) + + XCTAssertEqual(result.etag, "etag-b") + XCTAssertEqual(result.lock?.etag, "etag-b") + } + + func test_lockUnlockFileResult_withUnlockResponse_returnsETagWithoutLock() async throws { + var mock = Mock(url: fileURL, + dataType: xmlDataType, + statusCode: 200, + data: [HTTPMethod(rawValue: "UNLOCK"): unlockData(etag: "etag-c")]) + mock.register() + + let result = try await makeKit().lockUnlockFileResult(serverUrlFileName: fileURL.absoluteString, + shouldLock: false, + account: account) + + XCTAssertNil(result.lock) + XCTAssertEqual(result.etag, "etag-c") + } + + func test_lockUnlockFileResult_withEmptyOrMissingETag_returnsNilValues() async throws { + var missingETagMock = Mock(url: fileURL, + dataType: xmlDataType, + statusCode: 200, + data: [HTTPMethod(rawValue: "UNLOCK"): unlockData()]) + missingETagMock.register() + + let missingETagResult = try await makeKit().lockUnlockFileResult(serverUrlFileName: fileURL.absoluteString, + shouldLock: false, + account: account) + + XCTAssertNil(missingETagResult.lock) + XCTAssertNil(missingETagResult.etag) + + Mocker.removeAll() + var emptyResponseMock = Mock(url: fileURL, + dataType: xmlDataType, + statusCode: 200, + data: [HTTPMethod(rawValue: "UNLOCK"): Data()]) + emptyResponseMock.register() + + let emptyResponseResult = try await makeKit().lockUnlockFileResult(serverUrlFileName: fileURL.absoluteString, + shouldLock: false, + account: account) + + XCTAssertNil(emptyResponseResult.lock) + XCTAssertNil(emptyResponseResult.etag) + } + + func test_lockUnlockFileResult_withFailure_throws() async { + for statusCode in [412, 500] { + var mock = Mock(url: fileURL, + dataType: xmlDataType, + statusCode: statusCode, + data: [HTTPMethod(rawValue: "UNLOCK"): Data()]) + mock.register() + + do { + _ = try await makeKit().lockUnlockFileResult(serverUrlFileName: fileURL.absoluteString, + shouldLock: false, + account: account) + XCTFail("Expected HTTP \(statusCode) to throw") + } catch { + XCTAssertEqual((error as? NKError)?.errorCode, statusCode) + } + + Mocker.removeAll() + } + } + + func test_lockUnlockFile_existingAsyncAPI_preservesLockOptionality() async throws { + var lockMock = Mock(url: fileURL, + dataType: xmlDataType, + statusCode: 200, + data: [HTTPMethod(rawValue: "LOCK"): lockData(etag: "etag-b")]) + lockMock.register() + + let lock = try await makeKit().lockUnlockFile(serverUrlFileName: fileURL.absoluteString, + shouldLock: true, + account: account) + XCTAssertNotNil(lock) + + Mocker.removeAll() + var unlockMock = Mock(url: fileURL, + dataType: xmlDataType, + statusCode: 200, + data: [HTTPMethod(rawValue: "UNLOCK"): unlockData(etag: "etag-c")]) + unlockMock.register() + + let unlock = try await makeKit().lockUnlockFile(serverUrlFileName: fileURL.absoluteString, + shouldLock: false, + account: account) + XCTAssertNil(unlock) + } + + private func makeKit() -> NextcloudKit { + let kit = NextcloudKit() + kit.appendSession(account: account, + urlBase: "https://example.invalid", + user: "user", + userId: "user", + password: "password", + userAgent: "NextcloudKit unit test", + groupIdentifier: "") + return kit + } + + private func lockData(etag: String?) -> Data { + let etagElement = etag.map { "\($0)" } ?? "" + let xml = """ + + + 1 + user-id + \(NKLockType.token.rawValue) + User Name + 1 + 60 + \(etagElement) + + """ + return Data(xml.utf8) + } + + private func unlockData(etag: String? = nil) -> Data { + let etagElement = etag.map { "\($0)" } ?? "" + let xml = """ + + + 0 + \(etagElement) + + """ + return Data(xml.utf8) + } +} diff --git a/Tests/NextcloudKitUnitTests/NKLockUnitTests.swift b/Tests/NextcloudKitUnitTests/NKLockUnitTests.swift index 7e1d4257..c84e10cf 100644 --- a/Tests/NextcloudKitUnitTests/NKLockUnitTests.swift +++ b/Tests/NextcloudKitUnitTests/NKLockUnitTests.swift @@ -26,6 +26,18 @@ struct NKLockUnitTests { return Data(xml.utf8) } + private func makeUnlockData(etagElement: String = "") -> Data { + let xml = """ + + + 0 + \(etagElement) + + """ + + return Data(xml.utf8) + } + @Test("Parses quoted ETag from LOCK response") func parsesQuotedETag() { let lock = NKLock(data: makeLockData(etagElement: "\"etag-after-lock\"")) @@ -50,4 +62,44 @@ struct NKLockUnitTests { #expect(lock != nil) #expect(lock?.etag == "etag-after-lock") } + + @Test("LOCK result includes the lock and ETag") + func lockResultIncludesLockAndETag() { + let result = NKLockOperationResult(data: makeLockData(etagElement: "etag-b")) + + #expect(result.lock != nil) + #expect(result.etag == "etag-b") + #expect(result.lock?.etag == result.etag) + } + + @Test("UNLOCK result retains ETag without a lock") + func unlockResultRetainsETag() { + let result = NKLockOperationResult(data: makeUnlockData(etagElement: "etag-c")) + + #expect(result.lock == nil) + #expect(result.etag == "etag-c") + } + + @Test("Unlocked response without ETag has no result values") + func unlockResultWithoutETagHasNoValues() { + let result = NKLockOperationResult(data: makeUnlockData()) + + #expect(result.lock == nil) + #expect(result.etag == nil) + } + + @Test("Empty response result has no values") + func emptyResponseResultHasNoValues() { + let result = NKLockOperationResult() + + #expect(result.lock == nil) + #expect(result.etag == nil) + } + + @Test("Result normalizes quoted ETags") + func resultNormalizesQuotedETag() { + let result = NKLockOperationResult(data: makeUnlockData(etagElement: "\"etag-c\"")) + + #expect(result.etag == "etag-c") + } }