Skip to content
Draft
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
3 changes: 1 addition & 2 deletions Sources/NextcloudKit/Models/NKLock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

///
Expand Down
46 changes: 46 additions & 0 deletions Sources/NextcloudKit/Models/NKLockOperationResult.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
36 changes: 28 additions & 8 deletions Sources/NextcloudKit/NextcloudKit+FilesLock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
}
164 changes: 164 additions & 0 deletions Tests/NextcloudKitUnitTests/FilesLockUnitTests.swift
Original file line number Diff line number Diff line change
@@ -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 { "<d:getetag>\($0)</d:getetag>" } ?? ""
let xml = """
<?xml version="1.0"?>
<d:prop xmlns:d="DAV:" xmlns:nc="http://nextcloud.org/ns">
<nc:lock>1</nc:lock>
<nc:lock-owner>user-id</nc:lock-owner>
<nc:lock-owner-type>\(NKLockType.token.rawValue)</nc:lock-owner-type>
<nc:lock-owner-displayname>User Name</nc:lock-owner-displayname>
<nc:lock-time>1</nc:lock-time>
<nc:lock-timeout>60</nc:lock-timeout>
\(etagElement)
</d:prop>
"""
return Data(xml.utf8)
}

private func unlockData(etag: String? = nil) -> Data {
let etagElement = etag.map { "<d:getetag>\($0)</d:getetag>" } ?? ""
let xml = """
<?xml version="1.0"?>
<d:prop xmlns:d="DAV:" xmlns:nc="http://nextcloud.org/ns">
<nc:lock>0</nc:lock>
\(etagElement)
</d:prop>
"""
return Data(xml.utf8)
}
}
52 changes: 52 additions & 0 deletions Tests/NextcloudKitUnitTests/NKLockUnitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@ struct NKLockUnitTests {
return Data(xml.utf8)
}

private func makeUnlockData(etagElement: String = "") -> Data {
let xml = """
<?xml version="1.0"?>
<d:prop xmlns:d="DAV:" xmlns:nc="http://nextcloud.org/ns">
<nc:lock>0</nc:lock>
\(etagElement)
</d:prop>
"""

return Data(xml.utf8)
}

@Test("Parses quoted ETag from LOCK response")
func parsesQuotedETag() {
let lock = NKLock(data: makeLockData(etagElement: "<d:getetag>\"etag-after-lock\"</d:getetag>"))
Expand All @@ -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: "<d:getetag>etag-b</d:getetag>"))

#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: "<d:getetag>etag-c</d:getetag>"))

#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: "<d:getetag>\"etag-c\"</d:getetag>"))

#expect(result.etag == "etag-c")
}
}