diff --git a/Sources/NextcloudKit/Models/NKPhotoAlbum.swift b/Sources/NextcloudKit/Models/NKPhotoAlbum.swift
new file mode 100644
index 00000000..481c4105
--- /dev/null
+++ b/Sources/NextcloudKit/Models/NKPhotoAlbum.swift
@@ -0,0 +1,55 @@
+// SPDX-FileCopyrightText: Nextcloud GmbH
+// SPDX-FileCopyrightText: 2026 Marino Faggiana
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import Foundation
+
+public struct NKPhotoAlbum: Identifiable, Hashable, Sendable {
+ public let account: String
+ public let href: String
+ public let lastPhotoId: String?
+ public let itemCount: Int?
+ public let location: String?
+ public let dateRange: String?
+ public let collaborators: String?
+
+ /// Stable identity scoped to the account, independent of album metadata.
+ public var id: String { "\(account.utf8.count):\(account)\(href)" }
+
+ /// The album name decoded from the last component of its DAV path.
+ public var name: String {
+ guard let component = href.split(separator: "/").last else { return href }
+ return String(component).removingPercentEncoding ?? String(component)
+ }
+
+ /// Dates supplied by the server, or nil when the date range is missing or invalid.
+ public let startDate: Date?
+ public let endDate: Date?
+
+ public init(account: String,
+ href: String,
+ lastPhotoId: String? = nil,
+ itemCount: Int? = nil,
+ location: String? = nil,
+ dateRange: String? = nil,
+ collaborators: String? = nil) {
+ self.account = account
+ self.href = href
+ self.lastPhotoId = lastPhotoId
+ self.itemCount = itemCount
+ self.location = location
+ self.dateRange = dateRange
+ self.collaborators = collaborators
+
+ if let data = dateRange?.data(using: .utf8),
+ let range = try? JSONDecoder().decode([String: TimeInterval].self, from: data),
+ let start = range["start"], let end = range["end"],
+ start.isFinite, end.isFinite, start <= end {
+ self.startDate = Date(timeIntervalSince1970: start)
+ self.endDate = Date(timeIntervalSince1970: end)
+ } else {
+ self.startDate = nil
+ self.endDate = nil
+ }
+ }
+}
diff --git a/Sources/NextcloudKit/NextcloudKit+Albums.swift b/Sources/NextcloudKit/NextcloudKit+Albums.swift
new file mode 100644
index 00000000..81c9c333
--- /dev/null
+++ b/Sources/NextcloudKit/NextcloudKit+Albums.swift
@@ -0,0 +1,424 @@
+// SPDX-FileCopyrightText: Nextcloud GmbH
+// SPDX-FileCopyrightText: 2026 Dhanesh
+// SPDX-FileCopyrightText: 2026 Marino Faggiana
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import Foundation
+import Alamofire
+import SwiftyJSON
+import SwiftyXMLParser
+
+public extension NextcloudKit {
+
+ // MARK: - Album
+
+ func fetchAllAlbums(
+ for account: String,
+ options: NKRequestOptions = NKRequestOptions(),
+ taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in },
+ completion: @escaping (Result<[NKPhotoAlbum], NKError>) -> Void) {
+ guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account),
+ let endpoint = albumEndpoint(userId: nkSession.userId),
+ let url = nkCommonInstance.createStandardUrl(
+ serverUrl: nkSession.urlBase,
+ endpoint: endpoint
+ ),
+ let collectionURL = try? url.asURL(),
+ let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else {
+ return options.queue.async { completion(.failure(NKError.urlError)) }
+ }
+ let propfindXML = """
+
+
+
+
+
+
+
+
+
+
+ """
+ var urlRequest: URLRequest
+
+ do {
+ try urlRequest = URLRequest(url: url, method: HTTPMethod(rawValue: "PROPFIND"), headers: headers)
+ urlRequest.httpBody = propfindXML.data(using: .utf8)
+ urlRequest.timeoutInterval = options.timeout
+ } catch {
+ return options.queue.async { completion(.failure(NKError(error: error))) }
+ }
+
+ nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in
+ task.taskDescription = options.taskDescription
+ taskHandler(task)
+ }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in
+ switch response.result {
+ case .failure(let error):
+ let error = NKError(error: error, afResponse: response, responseData: response.data)
+ options.queue.async { completion(.failure(error)) }
+ case .success:
+ guard let data = response.data else {
+ return options.queue.async { completion(.failure(NKError.invalidData)) }
+ }
+ let albums = self.parseAlbumsXML(account: account, data: data, collectionURL: collectionURL)
+ options.queue.async { completion(.success(albums)) }
+ }
+ }
+ }
+
+ func createNewAlbum(
+ for account: String,
+ albumName: String,
+ options: NKRequestOptions = NKRequestOptions(),
+ taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in },
+ completion: @escaping (Result) -> Void) {
+ guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account),
+ let endpoint = albumEndpoint(userId: nkSession.userId, albumName: albumName),
+ let url = nkCommonInstance.createStandardUrl(
+ serverUrl: nkSession.urlBase,
+ endpoint: endpoint
+ ),
+ let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else {
+ return options.queue.async { completion(.failure(NKError.urlError)) }
+ }
+ var urlRequest: URLRequest
+
+ do {
+ try urlRequest = URLRequest(url: url, method: HTTPMethod(rawValue: "MKCOL"), headers: headers)
+ urlRequest.timeoutInterval = options.timeout
+ } catch {
+ return options.queue.async { completion(.failure(NKError(error: error))) }
+ }
+
+ nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in
+ task.taskDescription = options.taskDescription
+ taskHandler(task)
+ }.response(queue: self.nkCommonInstance.backgroundQueue) { response in
+ if let error = response.error {
+ let error = NKError(error: error, afResponse: response, responseData: response.data)
+ options.queue.async { completion(.failure(error)) }
+ } else {
+ options.queue.async { completion(.success(account)) }
+ }
+ }
+ }
+
+ func renameAlbum(
+ account: String,
+ from name: String,
+ to newName: String,
+ options: NKRequestOptions = NKRequestOptions(),
+ taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in },
+ completion: @escaping (Result) -> Void) {
+ guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account),
+ let endpoint = albumEndpoint(userId: nkSession.userId, albumName: name),
+ let url = nkCommonInstance.createStandardUrl(
+ serverUrl: nkSession.urlBase,
+ endpoint: endpoint
+ ),
+ var headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else {
+ return options.queue.async { completion(.failure(NKError.urlError)) }
+ }
+ guard let destinationEndpoint = albumEndpoint(userId: nkSession.userId, albumName: newName),
+ let destinationUrl = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: destinationEndpoint),
+ let destination = try? destinationUrl.asURL() else {
+ return options.queue.async { completion(.failure(NKError.urlError)) }
+ }
+
+ // Add the required MOVE header
+ headers.add(name: "Destination", value: destination.absoluteString)
+ // Disallow overwriting an existing destination to avoid silent data loss
+ headers.add(name: "Overwrite", value: "F")
+
+ var urlRequest: URLRequest
+ do {
+ try urlRequest = URLRequest(url: url, method: .init(rawValue: "MOVE"), headers: headers)
+ urlRequest.timeoutInterval = options.timeout
+ } catch {
+ return options.queue.async { completion(.failure(NKError(error: error))) }
+ }
+
+ nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in
+ task.taskDescription = options.taskDescription
+ taskHandler(task)
+ }.response(queue: self.nkCommonInstance.backgroundQueue) { response in
+ if let error = response.error {
+ let error = NKError(error: error, afResponse: response, responseData: response.data)
+ options.queue.async { completion(.failure(error)) }
+ } else {
+ options.queue.async { completion(.success(account)) }
+ }
+ }
+ }
+
+ func deleteAlbum(
+ albumName: String,
+ account: String,
+ options: NKRequestOptions = NKRequestOptions(),
+ taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in },
+ completion: @escaping (Result) -> Void) {
+
+ guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account),
+ let endpoint = albumEndpoint(userId: nkSession.userId, albumName: albumName),
+ let url = nkCommonInstance.createStandardUrl(
+ serverUrl: nkSession.urlBase,
+ endpoint: endpoint
+ ),
+ let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else {
+ return options.queue.async { completion(.failure(NKError.urlError)) }
+ }
+
+ var urlRequest: URLRequest
+ do {
+ try urlRequest = URLRequest(url: url, method: .delete, headers: headers)
+ urlRequest.timeoutInterval = options.timeout
+ } catch {
+ return options.queue.async { completion(.failure(NKError(error: error))) }
+ }
+
+ nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in
+ task.taskDescription = options.taskDescription
+ taskHandler(task)
+ }.response(queue: self.nkCommonInstance.backgroundQueue) { response in
+ if let error = response.error {
+ let error = NKError(error: error, afResponse: response, responseData: response.data)
+ options.queue.async { completion(.failure(error)) }
+ } else {
+ options.queue.async { completion(.success(account)) }
+ }
+ }
+ }
+
+ // MARK: - Album Photo
+
+ func fetchAlbumPhotos(
+ for album: String,
+ account: String,
+ options: NKRequestOptions = NKRequestOptions(),
+ taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in },
+ completion: @escaping (Result<[NKFile], NKError>) -> Void) {
+ guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account),
+ let endpoint = albumEndpoint(userId: nkSession.userId, albumName: album),
+ let url = nkCommonInstance.createStandardUrl(
+ serverUrl: nkSession.urlBase,
+ endpoint: endpoint
+ ),
+ let headers = nkCommonInstance.getStandardHeaders(account: account, options: options, contentType: "application/xml", accept: "application/xml") else {
+ return options.queue.async { completion(.failure(NKError.urlError)) }
+ }
+ var urlRequest: URLRequest
+
+ do {
+ try urlRequest = URLRequest(url: url, method: HTTPMethod(rawValue: "PROPFIND"), headers: headers)
+ urlRequest.httpBody = NKDataFileXML(nkCommonInstance: self.nkCommonInstance).getRequestBodyFile(createProperties: options.createProperties, removeProperties: options.removeProperties).data(using: .utf8)
+ urlRequest.timeoutInterval = options.timeout
+ } catch {
+ return options.queue.async { completion(.failure(NKError(error: error))) }
+ }
+
+ nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in
+ task.taskDescription = options.taskDescription
+ taskHandler(task)
+ }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in
+ switch response.result {
+ case .failure(let error):
+ let error = NKError(error: error, afResponse: response, responseData: response.data)
+ options.queue.async { completion(.failure(error)) }
+
+ case .success:
+ guard let data = response.data else {
+ return options.queue.async { completion(.failure(NKError.invalidData)) }
+ }
+ Task {
+ let files = await NKDataFileXML(nkCommonInstance: self.nkCommonInstance).convertDataFile(xmlData: data, nkSession: nkSession, rootFileName: self.nkCommonInstance.rootFileName, showHiddenFiles: true, includeHiddenFiles: [])
+ options.queue.async { completion(.success(files)) }
+ }
+ }
+ }
+ }
+
+ func copyPhotoToAlbum(
+ account: String,
+ sourcePath: String,
+ albumName: String,
+ fileName: String,
+ options: NKRequestOptions = NKRequestOptions(),
+ taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in },
+ completion: @escaping (Result) -> Void) {
+ guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account),
+ var headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else {
+ return options.queue.async { completion(.failure(NKError.urlError)) }
+ }
+ guard let destinationEndpoint = albumEndpoint(userId: nkSession.userId, albumName: albumName, fileName: fileName),
+ let destinationUrl = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: destinationEndpoint),
+ let destination = try? destinationUrl.asURL() else {
+ return options.queue.async { completion(.failure(NKError.urlError)) }
+ }
+ guard let sourceUrl = albumPhotoSourceURL(sourcePath: sourcePath, serverUrl: nkSession.urlBase) else {
+ return options.queue.async { completion(.failure(NKError.urlError)) }
+ }
+
+ headers.add(
+ name: "Destination",
+ value: destination.absoluteString
+ )
+
+ var urlRequest: URLRequest
+ do {
+ try urlRequest = URLRequest(url: sourceUrl, method: .init(rawValue: "COPY"), headers: headers)
+ urlRequest.timeoutInterval = options.timeout
+ } catch {
+ return options.queue.async { completion(.failure(NKError(error: error))) }
+ }
+
+ nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in
+ task.taskDescription = options.taskDescription
+ taskHandler(task)
+ }.response(queue: self.nkCommonInstance.backgroundQueue) { response in
+ if let error = response.error {
+ let error = NKError(error: error, afResponse: response, responseData: response.data)
+ options.queue.async { completion(.failure(error)) }
+ } else {
+ options.queue.async { completion(.success(account)) }
+ }
+ }
+ }
+
+ func deletePhotoFromAlbum(albumName: String,
+ fileName: String,
+ account: String,
+ options: NKRequestOptions = NKRequestOptions(),
+ taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in },
+ completion: @escaping (Result) -> Void) {
+ // An empty file name would target the album itself with DELETE.
+ guard !fileName.isEmpty else {
+ return options.queue.async { completion(.failure(NKError.invalidData)) }
+ }
+
+ guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account),
+ let endpoint = albumEndpoint(userId: nkSession.userId, albumName: albumName, fileName: fileName),
+ let url = nkCommonInstance.createStandardUrl(
+ serverUrl: nkSession.urlBase,
+ endpoint: endpoint
+ ),
+ let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else {
+ return options.queue.async { completion(.failure(NKError.urlError)) }
+ }
+
+ var urlRequest: URLRequest
+ do {
+ try urlRequest = URLRequest(url: url, method: .delete, headers: headers)
+ urlRequest.timeoutInterval = options.timeout
+ } catch {
+ return options.queue.async { completion(.failure(NKError(error: error))) }
+ }
+
+ nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in
+ task.taskDescription = options.taskDescription
+ taskHandler(task)
+ }.response(queue: self.nkCommonInstance.backgroundQueue) { response in
+ if let error = response.error {
+ let error = NKError(error: error, afResponse: response, responseData: response.data)
+ options.queue.async { completion(.failure(error)) }
+ } else {
+ options.queue.async { completion(.success(account)) }
+ }
+ }
+ }
+
+ func deletePhotoFromAlbumAsync(
+ albumName: String,
+ fileName: String,
+ account: String,
+ options: NKRequestOptions = NKRequestOptions(),
+ taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }
+ ) async throws {
+ try await withCheckedThrowingContinuation { continuation in
+ deletePhotoFromAlbum(
+ albumName: albumName,
+ fileName: fileName,
+ account: account,
+ options: options,
+ taskHandler: taskHandler
+ ) { result in
+ switch result {
+ case .success:
+ continuation.resume()
+
+ case .failure(let error):
+ continuation.resume(throwing: error)
+ }
+ }
+ }
+ }
+
+ // MARK: - Helper
+
+ // Encode each raw path component once, including literal percent signs and slashes.
+ private func albumEndpoint(userId: String, albumName: String? = nil, fileName: String? = nil) -> String? {
+ let allowedCharacters = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
+ var components = ["remote.php", "dav", "photos", userId, "albums"]
+ if let albumName { components.append(albumName) }
+ if let fileName { components.append(fileName) }
+
+ var encodedComponents: [String] = []
+ for component in components {
+ guard let encoded = component.addingPercentEncoding(withAllowedCharacters: allowedCharacters) else { return nil }
+ encodedComponents.append(encoded)
+ }
+ return encodedComponents.joined(separator: "/") + (fileName == nil ? "/" : "")
+ }
+
+ // Resolve root-relative DAV paths against the origin, and relative paths against the installation.
+ internal func albumPhotoSourceURL(sourcePath: String, serverUrl: String) -> URL? {
+ guard let encodedSource = sourcePath.urlEncoded,
+ let encodedBase = serverUrl.urlEncoded,
+ let baseURL = URL(string: encodedBase.hasSuffix("/") ? encodedBase : encodedBase + "/"),
+ let sourceURL = URL(string: encodedSource, relativeTo: baseURL)?.absoluteURL,
+ let scheme = sourceURL.scheme?.lowercased(),
+ scheme == "http" || scheme == "https" else {
+ return nil
+ }
+ return sourceURL
+ }
+
+ internal func parseAlbumsXML(account: String, data: Data, collectionURL: URL) -> [NKPhotoAlbum] {
+ let xml = XML.parse(data)
+ var albums: [NKPhotoAlbum] = []
+ let elements = xml["d:multistatus", "d:response"]
+
+ for element in elements {
+ let href = element["d:href"].element?.text ?? ""
+ // PROPFIND also returns the requested collection, which is not an album.
+ guard !href.isEmpty,
+ let resourceURL = URL(string: href, relativeTo: collectionURL),
+ resourceURL.pathComponents != collectionURL.pathComponents else {
+ continue
+ }
+ let prop = element["d:propstat"]["d:prop"]
+ let lastPhoto = prop["nc:last-photo"].element?.text
+ let nbItems = prop["nc:nbItems"].element?.text.flatMap { Int($0) }
+ let location = prop["nc:location"].element?.text
+ let dateRange = prop["nc:dateRange"].element?.text
+ let collaborators = prop["nc:collaborators"].element?.text
+
+ // Optionally skip entries with 404 status
+ let status = element["d:propstat"]["d:status"].element?.text ?? ""
+ if status.contains("200") {
+ let album = NKPhotoAlbum(
+ account: account,
+ href: href,
+ lastPhotoId: lastPhoto,
+ itemCount: nbItems,
+ location: location,
+ dateRange: dateRange,
+ collaborators: collaborators
+ )
+ albums.append(album)
+ }
+ }
+
+ return albums
+ }
+}
diff --git a/Sources/NextcloudKit/NextcloudKit+Upload.swift b/Sources/NextcloudKit/NextcloudKit+Upload.swift
index da5fddf5..0f112319 100644
--- a/Sources/NextcloudKit/NextcloudKit+Upload.swift
+++ b/Sources/NextcloudKit/NextcloudKit+Upload.swift
@@ -79,7 +79,8 @@ public extension NextcloudKit {
options.queue.async { taskHandler(task) }
}) .uploadProgress { progress in
options.queue.async { progressHandler(progress) }
- } .responseData(queue: self.nkCommonInstance.backgroundQueue) { response in
+ } .response(queue: self.nkCommonInstance.backgroundQueue) { response in
+ let response = response.map { $0 ?? Data() }
options.queue.async {
completionHandler(account, response, self.evaluateResponse(response))
}
@@ -311,9 +312,12 @@ public extension NextcloudKit {
// Notify start upload
uploadStart(chunkedFiles)
- // Global progress baseline (bytes of fully uploaded chunks)
- var uploadedSoFar: Int64 = 0
- uploadProgressHandler(totalFileSize, 0, totalFileSize > 0 ? 0.0 : 1.0)
+ // Remaining chunks have cumulative sizes, recomputed from disk by chunkedFile.
+ // Include completed chunks when resuming; the interrupted chunk is sent again.
+ let remainingBytes = chunkedFiles.last?.size ?? 0
+ var uploadedSoFar = max(0, totalFileSize - remainingBytes)
+ let initialFraction = totalFileSize > 0 ? Double(uploadedSoFar) / Double(totalFileSize) : 1.0
+ uploadProgressHandler(totalFileSize, uploadedSoFar, initialFraction)
// Clear box before starting this chunk
let actorRequest = ActorRequest()
diff --git a/Sources/NextcloudKit/NextcloudKit+WebDAV.swift b/Sources/NextcloudKit/NextcloudKit+WebDAV.swift
index 61d7c76f..8ebd69a4 100644
--- a/Sources/NextcloudKit/NextcloudKit+WebDAV.swift
+++ b/Sources/NextcloudKit/NextcloudKit+WebDAV.swift
@@ -119,7 +119,8 @@ public extension NextcloudKit {
nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in
task.taskDescription = options.taskDescription
taskHandler(task)
- }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in
+ }.response(queue: self.nkCommonInstance.backgroundQueue) { response in
+ let response = response.map { $0 ?? Data() }
let result = self.evaluateResponse(response)
options.queue.async {
@@ -206,7 +207,8 @@ public extension NextcloudKit {
nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in
task.taskDescription = options.taskDescription
taskHandler(task)
- }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in
+ }.response(queue: self.nkCommonInstance.backgroundQueue) { response in
+ let response = response.map { $0 ?? Data() }
let result = self.evaluateResponse(response)
options.queue.async {
diff --git a/Tests/NextcloudKitUnitTests/AlbumPhotoDeletionTests.swift b/Tests/NextcloudKitUnitTests/AlbumPhotoDeletionTests.swift
new file mode 100644
index 00000000..183a8be3
--- /dev/null
+++ b/Tests/NextcloudKitUnitTests/AlbumPhotoDeletionTests.swift
@@ -0,0 +1,45 @@
+// SPDX-FileCopyrightText: Nextcloud GmbH
+// SPDX-FileCopyrightText: 2026 Marino Faggiana
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import Foundation
+import Testing
+import NextcloudKit
+
+struct AlbumPhotoDeletionTests {
+ @Test func emptyFileNameIsRejectedBeforeSessionLookup() async {
+ // No session: invalid input must be rejected before URL or request creation.
+ let result: Result = await withCheckedContinuation { continuation in
+ NextcloudKit.shared.deletePhotoFromAlbum(
+ albumName: "Holiday",
+ fileName: "",
+ account: UUID().uuidString,
+ taskHandler: { _ in Issue.record("Deletion must not create a network task") }
+ ) { result in
+ continuation.resume(returning: result)
+ }
+ }
+ switch result {
+ case .success:
+ Issue.record("An empty file name must not succeed")
+ case .failure(let error):
+ #expect(error == .invalidData)
+ }
+ }
+
+ @Test func asyncDeletionRejectsEmptyFileName() async {
+ do {
+ try await NextcloudKit.shared.deletePhotoFromAlbumAsync(
+ albumName: "Holiday",
+ fileName: "",
+ account: UUID().uuidString,
+ taskHandler: { _ in Issue.record("Deletion must not create a network task") }
+ )
+ Issue.record("An empty file name must throw")
+ } catch let error as NKError {
+ #expect(error == .invalidData)
+ } catch {
+ Issue.record("Unexpected error: \(error)")
+ }
+ }
+}
diff --git a/Tests/NextcloudKitUnitTests/AlbumResponseTests.swift b/Tests/NextcloudKitUnitTests/AlbumResponseTests.swift
new file mode 100644
index 00000000..d4411895
--- /dev/null
+++ b/Tests/NextcloudKitUnitTests/AlbumResponseTests.swift
@@ -0,0 +1,55 @@
+// SPDX-FileCopyrightText: Nextcloud GmbH
+// SPDX-FileCopyrightText: 2026 Marino Faggiana
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import Foundation
+import Testing
+@testable import NextcloudKit
+
+struct AlbumResponseTests {
+ @Test(arguments: [
+ "/nextcloud/remote.php/dav/photos/alice/albums/",
+ "/nextcloud/remote.php/dav/photos/alice/albums",
+ "https://example.com/nextcloud/remote.php/dav/photos/alice/albums/"
+ ])
+ func excludesCollectionButKeepsAnAlbumNamedAlbums(collectionHref: String) throws {
+ let collectionURL = try #require(URL(string: "https://example.com/nextcloud/remote.php/dav/photos/alice/albums/"))
+ let albumHref = "/nextcloud/remote.php/dav/photos/alice/albums/albums/"
+ let xml = """
+
+
+ \(collectionHref)
+ 1
+ HTTP/1.1 200 OK
+
+
+ \(albumHref)
+ 2
+ HTTP/1.1 200 OK
+
+
+ """
+ let albums = NextcloudKit.shared.parseAlbumsXML(account: "alice", data: Data(xml.utf8), collectionURL: collectionURL)
+ #expect(albums.map(\.href) == [albumHref])
+ #expect(albums.first?.name == "albums")
+ }
+
+ @Test(arguments: [
+ "/nextcloud/remote.php/dav/files/alice/photo.jpg",
+ "remote.php/dav/files/alice/photo.jpg",
+ "https://example.com/nextcloud/remote.php/dav/files/alice/photo.jpg"
+ ])
+ func resolvesSourcePathsWithoutDuplicatingInstallation(sourcePath: String) {
+ for base in ["https://example.com/nextcloud", "https://example.com/nextcloud/"] {
+ let url = NextcloudKit.shared.albumPhotoSourceURL(sourcePath: sourcePath, serverUrl: base)
+ #expect(url?.absoluteString == "https://example.com/nextcloud/remote.php/dav/files/alice/photo.jpg")
+ }
+ }
+
+ @Test func preservesSpecialCharactersInRawSourceNames() {
+ let url = NextcloudKit.shared.albumPhotoSourceURL(
+ sourcePath: "/nextcloud/remote.php/dav/files/alice/holiday #100%.jpg",
+ serverUrl: "https://example.com/nextcloud")
+ #expect(url?.absoluteString == "https://example.com/nextcloud/remote.php/dav/files/alice/holiday%20%23100%25.jpg")
+ }
+}
diff --git a/Tests/NextcloudKitUnitTests/NKPhotoAlbumTests.swift b/Tests/NextcloudKitUnitTests/NKPhotoAlbumTests.swift
new file mode 100644
index 00000000..05a5ffcd
--- /dev/null
+++ b/Tests/NextcloudKitUnitTests/NKPhotoAlbumTests.swift
@@ -0,0 +1,40 @@
+// SPDX-FileCopyrightText: Nextcloud GmbH
+// SPDX-FileCopyrightText: 2026 Marino Faggiana
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import Foundation
+import Testing
+import NextcloudKit
+
+struct NKPhotoAlbumTests {
+ @Test func identityIsScopedToAccountAndStableAcrossMetadataChanges() {
+ let album = NKPhotoAlbum(account: "a", href: "/albums/one/")
+ let updated = NKPhotoAlbum(account: "a", href: album.href, itemCount: 3)
+ let otherAccount = NKPhotoAlbum(account: "b", href: album.href)
+ #expect(album.id == updated.id)
+ #expect(album.id != otherAccount.id)
+ #expect(Set([album, album]).count == 1)
+ }
+
+ @Test func nameDecodesOnlyTheLastPathComponent() {
+ let album = NKPhotoAlbum(account: "a", href: "/albums/Caff%C3%A8%20%23%20100%25%2Festate/")
+ #expect(album.name == "Caffè # 100%/estate")
+ #expect(NKPhotoAlbum(account: "a", href: "/albums/100%/").name == "100%")
+ #expect(NKPhotoAlbum(account: "a", href: "").name.isEmpty)
+ }
+
+ @Test func dateRangeUsesUnixSecondsAndPreservesRawValue() {
+ let raw = #"{"start":1700000000,"end":1700003600}"#
+ let album = NKPhotoAlbum(account: "a", href: "/albums/one/", dateRange: raw)
+ #expect(album.startDate == Date(timeIntervalSince1970: 1700000000))
+ #expect(album.endDate == Date(timeIntervalSince1970: 1700003600))
+ #expect(album.dateRange == raw)
+ }
+
+ @Test(arguments: [nil, "", "invalid", "{}", #"{"start":1}"#, #"{"start":2,"end":1}"#, #"{"start":"1","end":2}"#] as [String?])
+ func invalidDateRangesHaveNoDates(raw: String?) {
+ let album = NKPhotoAlbum(account: "a", href: "/albums/one/", dateRange: raw)
+ #expect(album.startDate == nil)
+ #expect(album.endDate == nil)
+ }
+}