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