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")
+ }
}