diff --git a/Package.swift b/Package.swift index 74aa0969..1a94aa5d 100644 --- a/Package.swift +++ b/Package.swift @@ -19,7 +19,7 @@ let package = Package( dependencies: [ .package( url: "https://github.com/claucambra/NextcloudCapabilitiesKit.git", - .upToNextMajor(from: "2.1.2") + .upToNextMajor(from: "2.3.0") ), .package(url: "https://github.com/nextcloud/NextcloudKit", from: "5.0.4"), .package(url: "https://github.com/realm/realm-swift.git", exact: "20.0.1"), diff --git a/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+SyncEngine.swift b/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+SyncEngine.swift index 5b844c50..a54abe80 100644 --- a/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+SyncEngine.swift +++ b/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+SyncEngine.swift @@ -31,7 +31,10 @@ extension Enumerator { ) { let results = await self.scanRecursively( Item.rootContainer( - account: account, remoteInterface: remoteInterface, dbManager: dbManager + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + remoteSupportsTrash: await remoteInterface.supportsTrash(account: account) ).metadata, account: account, remoteInterface: remoteInterface, diff --git a/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+Trash.swift b/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+Trash.swift index db3bbba6..853a2781 100644 --- a/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+Trash.swift +++ b/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+Trash.swift @@ -48,7 +48,7 @@ extension Enumerator { remoteInterface: RemoteInterface, dbManager: FilesDatabaseManager, trashItems: [NKTrash] - ) { + ) async { var newTrashedItems = [NSFileProviderItem]() // NKTrash items do not have an etag ; we assume they cannot be modified while they are in @@ -71,7 +71,8 @@ extension Enumerator { parentItemIdentifier: .trashContainer, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: await remoteInterface.supportsTrash(account: account) ) newTrashedItems.append(item) diff --git a/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift b/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift index 4faa1a62..05f98474 100644 --- a/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift +++ b/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift @@ -451,7 +451,7 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { return } - Self.completeChangesObserver( + await Self.completeChangesObserver( observer, anchor: anchor, account: account, diff --git a/Sources/NextcloudFileProviderKit/Interface/NextcloudKit+RemoteInterface.swift b/Sources/NextcloudFileProviderKit/Interface/NextcloudKit+RemoteInterface.swift index b145084b..f379a780 100644 --- a/Sources/NextcloudFileProviderKit/Interface/NextcloudKit+RemoteInterface.swift +++ b/Sources/NextcloudFileProviderKit/Interface/NextcloudKit+RemoteInterface.swift @@ -12,24 +12,6 @@ import NextcloudCapabilitiesKit import NextcloudKit import OSLog -fileprivate let CapabilitiesFetchInterval: TimeInterval = 30 * 60 // 30mins - -actor RetrievedCapabilitiesActor { - static let shared: RetrievedCapabilitiesActor = { - let instance = RetrievedCapabilitiesActor() - return instance - }() - var data: [String: (capabilities: Capabilities, retrievedAt: Date)] = [:] - - func setCapabilities( - forAccount account: String, - capabilities: Capabilities, - retrievedAt: Date = Date() - ) async { - self.data[account] = (capabilities: capabilities, retrievedAt: retrievedAt) - } -} - extension NextcloudKit: RemoteInterface { public func setDelegate(_ delegate: any NextcloudKitDelegate) { @@ -398,6 +380,10 @@ extension NextcloudKit: RemoteInterface { options: NKRequestOptions = .init(), taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } ) async -> (account: String, capabilities: Capabilities?, data: Data?, error: NKError) { + let ncKitAccount = account.ncKitAccount + await RetrievedCapabilitiesActor.shared.setOngoingFetch( + forAccount: ncKitAccount, ongoing: true + ) let result = await withCheckedContinuation { continuation in getCapabilities(account: account.ncKitAccount, options: options, taskHandler: taskHandler) { account, data, error in let capabilities: Capabilities? = { @@ -407,6 +393,9 @@ extension NextcloudKit: RemoteInterface { continuation.resume(returning: (account, capabilities, data?.data, error)) } } + await RetrievedCapabilitiesActor.shared.setOngoingFetch( + forAccount: ncKitAccount, ongoing: false + ) if let capabilities = result.1 { await RetrievedCapabilitiesActor.shared.setCapabilities( forAccount: account.ncKitAccount, capabilities: capabilities @@ -415,39 +404,6 @@ extension NextcloudKit: RemoteInterface { return result } - public func currentCapabilities( - account: Account, - options: NKRequestOptions = .init(), - taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } - ) async -> (account: String, capabilities: Capabilities?, data: Data?, error: NKError) { - let ncKitAccount = account.ncKitAccount - guard let lastRetrieval = await RetrievedCapabilitiesActor.shared.data[ncKitAccount], - lastRetrieval.retrievedAt.timeIntervalSince(Date()) > -CapabilitiesFetchInterval - else { - return await fetchCapabilities( - account: account, options: options, taskHandler: taskHandler - ) - } - return (account.ncKitAccount, lastRetrieval.capabilities, nil, .success) - } - - public func currentCapabilitiesSync(account: Account) -> Capabilities? { - let semaphore = DispatchSemaphore(value: 0) - var capabilities: Capabilities? - Task { - let (_, fetchedCapabilities, _, error) = await currentCapabilities(account: account) - if error != .success { - Logger - .init(subsystem: Logger.subsystem, category: "NextcloudKitRemoteInterface") - .error("Error during sync capabilities fetch: \(error.errorDescription, privacy: .public)") - } - capabilities = fetchedCapabilities - semaphore.signal() - } - semaphore.wait() - return capabilities - } - public func fetchUserProfile( account: Account, options: NKRequestOptions = .init(), diff --git a/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift b/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift index e906ceca..2491193a 100644 --- a/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift +++ b/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift @@ -10,6 +10,8 @@ import FileProvider import Foundation import NextcloudCapabilitiesKit import NextcloudKit +import OSLog + public enum EnumerateDepth: String { case target = "0" @@ -157,16 +159,6 @@ public protocol RemoteInterface { taskHandler: @escaping (_ task: URLSessionTask) -> Void ) async -> (account: String, capabilities: Capabilities?, data: Data?, error: NKError) - // This method should result in fetches only after a certain period of time. - // Alternatively, it should only fetch when capabilities are guaranteed to have changed. - func currentCapabilities( - account: Account, - options: NKRequestOptions, - taskHandler: @escaping (_ task: URLSessionTask) -> Void - ) async -> (account: String, capabilities: Capabilities?, data: Data?, error: NKError) - - func currentCapabilitiesSync(account: Account) -> Capabilities? - func fetchUserProfile( account: Account, options: NKRequestOptions, @@ -179,3 +171,44 @@ public protocol RemoteInterface { taskHandler: @escaping (_ task: URLSessionTask) -> Void ) async -> AuthenticationAttemptResultState } + +public extension RemoteInterface { + + private var logger: Logger { + Logger(subsystem: Logger.subsystem, category: "RemoteInterface") + } + + func currentCapabilities( + account: Account, + options: NKRequestOptions = .init(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, capabilities: Capabilities?, data: Data?, error: NKError) { + let ncKitAccount = account.ncKitAccount + await RetrievedCapabilitiesActor.shared.awaitFetchCompletion(forAccount: ncKitAccount) + guard let lastRetrieval = await RetrievedCapabilitiesActor.shared.data[ncKitAccount], + lastRetrieval.retrievedAt.timeIntervalSince(Date()) > -CapabilitiesFetchInterval + else { + return await fetchCapabilities( + account: account, options: options, taskHandler: taskHandler + ) + } + return (account.ncKitAccount, lastRetrieval.capabilities, nil, .success) + } + + func supportsTrash( + account: Account, + options: NKRequestOptions = .init(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> Bool { + var remoteSupportsTrash = false + let (_, capabilities, _, _) = await currentCapabilities( + account: account, options: .init(), taskHandler: { _ in } + ) + if let filesCapabilities = capabilities?.files { + remoteSupportsTrash = filesCapabilities.undelete + } else { + logger.warning("Could not get capabilities, will assume trash is unavailable.") + } + return remoteSupportsTrash + } +} diff --git a/Sources/NextcloudFileProviderKit/Item/Item+Create.swift b/Sources/NextcloudFileProviderKit/Item/Item+Create.swift index 1cab9b4f..e3b40d82 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item+Create.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item+Create.swift @@ -99,7 +99,8 @@ public extension Item { parentItemIdentifier: parentItemIdentifier, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: await remoteInterface.supportsTrash(account: account) ) return (fpItem, nil) @@ -216,7 +217,8 @@ public extension Item { parentItemIdentifier: itemTemplate.parentItemIdentifier, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: await remoteInterface.supportsTrash(account: account) ) return (fpItem, nil) @@ -412,7 +414,8 @@ public extension Item { parentItemIdentifier: rootItem.parentItemIdentifier, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: await remoteInterface.supportsTrash(account: account) ) } @@ -495,7 +498,7 @@ public extension Item { let relativePath = parentItemRelativePath + "/" + itemTemplate.filename guard ignoredFiles == nil || ignoredFiles?.isExcluded(relativePath) == false else { - return Item.createIgnored( + return await Item.createIgnored( basedOn: itemTemplate, parentItemRemotePath: parentItemRemotePath, contents: url, @@ -604,7 +607,8 @@ public extension Item { parentItemIdentifier: parentItemIdentifier, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: await remoteInterface.supportsTrash(account: account) ) } diff --git a/Sources/NextcloudFileProviderKit/Item/Item+Fetch.swift b/Sources/NextcloudFileProviderKit/Item/Item+Fetch.swift index de367313..df11d995 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item+Fetch.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item+Fetch.swift @@ -304,7 +304,8 @@ public extension Item { parentItemIdentifier: parentItemIdentifier, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: await remoteInterface.supportsTrash(account: account) ) return (localPath, fpItem, nil) diff --git a/Sources/NextcloudFileProviderKit/Item/Item+Ignored.swift b/Sources/NextcloudFileProviderKit/Item/Item+Ignored.swift index 3a1f6009..d9293b64 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item+Ignored.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item+Ignored.swift @@ -17,7 +17,7 @@ extension Item { remoteInterface: RemoteInterface, progress: Progress, dbManager: FilesDatabaseManager - ) -> (Item?, Error?) { + ) async -> (Item?, Error?) { let filename = itemTemplate.filename Self.logger.info( """ @@ -60,7 +60,8 @@ extension Item { parentItemIdentifier: itemTemplate.parentItemIdentifier, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: await remoteInterface.supportsTrash(account: account) ) if #available(macOS 13.0, *) { diff --git a/Sources/NextcloudFileProviderKit/Item/Item+LockFile.swift b/Sources/NextcloudFileProviderKit/Item/Item+LockFile.swift index 43e15a28..53c32c66 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item+LockFile.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item+LockFile.swift @@ -140,7 +140,8 @@ extension Item { parentItemIdentifier: parentItemIdentifier, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: await remoteInterface.supportsTrash(account: account) ), returnError ) @@ -167,7 +168,7 @@ extension Item { ) assert(isLockFileName(filename), "Should not handle non-lock files here.") - guard let modifiedItem = modifyUnuploaded( + guard let modifiedItem = await modifyUnuploaded( itemTarget: itemTarget, baseVersion: baseVersion, changedFields: changedFields, diff --git a/Sources/NextcloudFileProviderKit/Item/Item+Modify.swift b/Sources/NextcloudFileProviderKit/Item/Item+Modify.swift index 7b01497d..87d3241b 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item+Modify.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item+Modify.swift @@ -88,7 +88,8 @@ public extension Item { parentItemIdentifier: newParentItemIdentifier, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: await remoteInterface.supportsTrash(account: account) ) return (modifiedItem, nil) } @@ -224,7 +225,8 @@ public extension Item { parentItemIdentifier: parentItemIdentifier, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: await remoteInterface.supportsTrash(account: account) ) return (modifiedItem, nil) } @@ -517,7 +519,8 @@ public extension Item { parentItemIdentifier: parentItemIdentifier, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: await remoteInterface.supportsTrash(account: account) ) } @@ -565,7 +568,7 @@ public extension Item { Will delete locally with no remote effect. """ ) - guard let modifiedIgnored = modifyUnuploaded( + guard let modifiedIgnored = await modifyUnuploaded( itemTarget: itemTarget, baseVersion: baseVersion, changedFields: changedFields, diff --git a/Sources/NextcloudFileProviderKit/Item/Item+Trash.swift b/Sources/NextcloudFileProviderKit/Item/Item+Trash.swift index 2eb89ff0..35c8a321 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item+Trash.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item+Trash.swift @@ -45,7 +45,8 @@ extension Item { parentItemIdentifier: .trashContainer, account: account, remoteInterface: modifiedItem.remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: await modifiedItem.remoteInterface.supportsTrash(account: account) ) // The server may have renamed the trashed file so we need to scan the entire trash @@ -106,7 +107,8 @@ extension Item { parentItemIdentifier: .trashContainer, account: account, remoteInterface: modifiedItem.remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: await modifiedItem.remoteInterface.supportsTrash(account: account) ) // Now we can directly update info on the child items @@ -197,7 +199,8 @@ extension Item { parentItemIdentifier: parentItemIdentifier, account: account, remoteInterface: modifiedItem.remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: await modifiedItem.remoteInterface.supportsTrash(account: account) ), nil) } diff --git a/Sources/NextcloudFileProviderKit/Item/Item+Unuploaded.swift b/Sources/NextcloudFileProviderKit/Item/Item+Unuploaded.swift index a6a9719a..c9354432 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item+Unuploaded.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item+Unuploaded.swift @@ -75,7 +75,7 @@ extension Item { forcedChunkSize: Int? = nil, progress: Progress = .init(), dbManager: FilesDatabaseManager - ) -> Item? { + ) async -> Item? { var modifiedParentItemIdentifier = parentItemIdentifier var modifiedMetadata = metadata @@ -124,7 +124,8 @@ extension Item { parentItemIdentifier: modifiedParentItemIdentifier, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: await remoteInterface.supportsTrash(account: account) ) } } diff --git a/Sources/NextcloudFileProviderKit/Item/Item.swift b/Sources/NextcloudFileProviderKit/Item/Item.swift index 614b4a31..b775f43d 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item.swift @@ -29,6 +29,8 @@ public class Item: NSObject, NSFileProviderItem { public let account: Account public let remoteInterface: RemoteInterface + private let remoteSupportsTrash: Bool + public var itemIdentifier: NSFileProviderItemIdentifier { NSFileProviderItemIdentifier(metadata.ocId) } @@ -55,7 +57,7 @@ public class Item: NSObject, NSFileProviderItem { directoryCapabilities.insert(.allowsEvicting) } - if remoteInterface.currentCapabilitiesSync(account: account)?.files?.undelete == true { + if remoteSupportsTrash { directoryCapabilities.insert(.allowsTrashing) } @@ -73,9 +75,7 @@ public class Item: NSObject, NSFileProviderItem { .allowsReparenting, .allowsEvicting, ] - if remoteInterface.currentCapabilitiesSync(account: account)?.files?.undelete == true, - !isLockFileName(filename) - { + if remoteSupportsTrash, !isLockFileName(filename) { itemCapabilities.insert(.allowsTrashing) } return itemCapabilities @@ -223,7 +223,8 @@ public class Item: NSObject, NSFileProviderItem { public static func rootContainer( account: Account, remoteInterface: RemoteInterface, - dbManager: FilesDatabaseManager + dbManager: FilesDatabaseManager, + remoteSupportsTrash: Bool ) -> Item { let metadata = SendableItemMetadata( ocId: NSFileProviderItemIdentifier.rootContainer.rawValue, @@ -256,14 +257,16 @@ public class Item: NSObject, NSFileProviderItem { parentItemIdentifier: .rootContainer, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: remoteSupportsTrash ) } public static func trashContainer( remoteInterface: RemoteInterface, account: Account, - dbManager: FilesDatabaseManager + dbManager: FilesDatabaseManager, + remoteSupportsTrash: Bool ) -> Item { let metadata = SendableItemMetadata( ocId: NSFileProviderItemIdentifier.trashContainer.rawValue, @@ -295,7 +298,8 @@ public class Item: NSObject, NSFileProviderItem { parentItemIdentifier: .trashContainer, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: remoteSupportsTrash ) } @@ -306,13 +310,15 @@ public class Item: NSObject, NSFileProviderItem { parentItemIdentifier: NSFileProviderItemIdentifier, account: Account, remoteInterface: RemoteInterface, - dbManager: FilesDatabaseManager + dbManager: FilesDatabaseManager, + remoteSupportsTrash: Bool ) { self.metadata = metadata self.parentItemIdentifier = parentItemIdentifier self.account = account self.remoteInterface = remoteInterface self.dbManager = dbManager + self.remoteSupportsTrash = remoteSupportsTrash super.init() } @@ -323,14 +329,23 @@ public class Item: NSObject, NSFileProviderItem { dbManager: FilesDatabaseManager ) async -> Item? { // resolve the given identifier to a record in the model + + let remoteSupportsTrash = await remoteInterface.supportsTrash(account: account) + guard identifier != .rootContainer else { return Item.rootContainer( - account: account, remoteInterface: remoteInterface, dbManager: dbManager + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + remoteSupportsTrash: remoteSupportsTrash ) } guard identifier != .trashContainer else { return Item.trashContainer( - remoteInterface: remoteInterface, account: account, dbManager: dbManager + remoteInterface: remoteInterface, + account: account, + dbManager: dbManager, + remoteSupportsTrash: remoteSupportsTrash ) } @@ -355,7 +370,8 @@ public class Item: NSObject, NSFileProviderItem { parentItemIdentifier: parentItemIdentifier, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: remoteSupportsTrash ) } diff --git a/Sources/NextcloudFileProviderKit/Metadata/SendableItemMetadata+Array.swift b/Sources/NextcloudFileProviderKit/Metadata/SendableItemMetadata+Array.swift index b9de3670..26448afb 100644 --- a/Sources/NextcloudFileProviderKit/Metadata/SendableItemMetadata+Array.swift +++ b/Sources/NextcloudFileProviderKit/Metadata/SendableItemMetadata+Array.swift @@ -15,6 +15,7 @@ extension Array { let logger = Logger( subsystem: Logger.subsystem, category: "itemMetadataToFileProviderItems" ) + let remoteSupportsTrash = await remoteInterface.supportsTrash(account: account) return try await concurrentChunkedCompactMap { itemMetadata in guard !itemMetadata.e2eEncrypted else { @@ -57,7 +58,8 @@ extension Array { parentItemIdentifier: parentItemIdentifier, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: remoteSupportsTrash ) logger.debug( """ diff --git a/Sources/NextcloudFileProviderKit/Utilities/RetrievedCapabilitiesActor.swift b/Sources/NextcloudFileProviderKit/Utilities/RetrievedCapabilitiesActor.swift new file mode 100644 index 00000000..5de18d0e --- /dev/null +++ b/Sources/NextcloudFileProviderKit/Utilities/RetrievedCapabilitiesActor.swift @@ -0,0 +1,59 @@ +// +// RetrievedCapabilitiesActor.swift +// NextcloudFileProviderKit +// +// Created by Claudio Cambra on 28/5/25. +// + +import Foundation +import NextcloudCapabilitiesKit + +let CapabilitiesFetchInterval: TimeInterval = 30 * 60 // 30mins + +actor RetrievedCapabilitiesActor { + static let shared: RetrievedCapabilitiesActor = { + let instance = RetrievedCapabilitiesActor() + return instance + }() + var ongoingFetches: Set = [] + var data: [String: (capabilities: Capabilities, retrievedAt: Date)] = [:] + + private var ongoingFetchContinuations: [String: [CheckedContinuation]] = [:] + + func setCapabilities( + forAccount account: String, + capabilities: Capabilities, + retrievedAt: Date = Date() + ) { + self.data[account] = (capabilities: capabilities, retrievedAt: retrievedAt) + } + + func setOngoingFetch(forAccount account: String, ongoing: Bool) { + if ongoing { + ongoingFetches.insert(account) + } else { + ongoingFetches.remove(account) + // If there are any continuations waiting for this account, resume them. + if let continuations = ongoingFetchContinuations.removeValue(forKey: account) { + continuations.forEach { $0.resume() } + } + } + } + + func awaitFetchCompletion(forAccount account: String) async { + guard ongoingFetches.contains(account) else { return } + + // If a fetch is ongoing, create a continuation and store it. + await withCheckedContinuation { continuation in + var existingContinuations = ongoingFetchContinuations[account, default: []] + existingContinuations.append(continuation) + ongoingFetchContinuations[account] = existingContinuations + } + } + + func reset() { + ongoingFetches = [] + ongoingFetchContinuations = [:] + data = [:] + } +} diff --git a/Tests/Interface/Item+Init.swift b/Tests/Interface/Item+Init.swift new file mode 100644 index 00000000..e322e3a3 --- /dev/null +++ b/Tests/Interface/Item+Init.swift @@ -0,0 +1,29 @@ +// +// Item+Init.swift +// NextcloudFileProviderKit +// +// Created by Claudio Cambra on 27/5/25. +// + +import FileProvider +import Foundation +import NextcloudFileProviderKit + +public extension Item { + convenience init( + metadata: SendableItemMetadata, + parentItemIdentifier: NSFileProviderItemIdentifier, + account: Account, + remoteInterface: RemoteInterface, + dbManager: FilesDatabaseManager + ) { + self.init( + metadata: metadata, + parentItemIdentifier: parentItemIdentifier, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + remoteSupportsTrash: true + ) + } +} diff --git a/Tests/Interface/MockEnumerator.swift b/Tests/Interface/MockEnumerator.swift index c0af1ac6..238fff6e 100644 --- a/Tests/Interface/MockEnumerator.swift +++ b/Tests/Interface/MockEnumerator.swift @@ -12,11 +12,11 @@ import NextcloudFileProviderKit public class MockEnumerator: NSObject, NSFileProviderEnumerator { let account: Account let dbManager: FilesDatabaseManager - let remoteInterface: any RemoteInterface + let remoteInterface: MockRemoteInterface public var enumeratorItems: [SendableItemMetadata] = [] public init( - account: Account, dbManager: FilesDatabaseManager, remoteInterface: any RemoteInterface + account: Account, dbManager: FilesDatabaseManager, remoteInterface: MockRemoteInterface ) { self.account = account self.dbManager = dbManager @@ -26,6 +26,7 @@ public class MockEnumerator: NSObject, NSFileProviderEnumerator { public func enumerateItems( for observer: any NSFileProviderEnumerationObserver, startingAt page: NSFileProviderPage ) { + let remoteSupportsTrash = remoteInterface.directMockCapabilities()?.files?.undelete ?? false var items: [Item] = [] for item in enumeratorItems { guard let parentItemIdentifier = dbManager.parentItemIdentifierFromMetadata(item) else { @@ -37,7 +38,8 @@ public class MockEnumerator: NSObject, NSFileProviderEnumerator { parentItemIdentifier: parentItemIdentifier, account: account, remoteInterface: remoteInterface, - dbManager: dbManager + dbManager: dbManager, + remoteSupportsTrash: remoteSupportsTrash ) items.append(item) } diff --git a/Tests/Interface/MockRemoteInterface.swift b/Tests/Interface/MockRemoteInterface.swift index add2fcc2..ca848d79 100644 --- a/Tests/Interface/MockRemoteInterface.swift +++ b/Tests/Interface/MockRemoteInterface.swift @@ -11,7 +11,7 @@ import NextcloudCapabilitiesKit import NextcloudFileProviderKit import NextcloudKit -fileprivate let mockCapabilities = ##""" +let mockCapabilities = ##""" { "ocs": { "meta": { @@ -1139,27 +1139,12 @@ public class MockRemoteInterface: RemoteInterface { taskHandler: @escaping (URLSessionTask) -> Void ) async -> (account: String, capabilities: Capabilities?, data: Data?, error: NKError) { let capsData = capabilities.data(using: .utf8) - return (account.ncKitAccount, Capabilities(data: capsData ?? Data()), capsData, .success) + return (account.ncKitAccount, directMockCapabilities(), capsData, .success) } - public func currentCapabilities( - account: Account, - options: NKRequestOptions = .init(), - taskHandler: @escaping (URLSessionTask) -> Void = { _ in } - ) async -> (account: String, capabilities: Capabilities?, data: Data?, error: NKError) { - return await fetchCapabilities(account: account, options: options, taskHandler: taskHandler) - } - - public func currentCapabilitiesSync(account: Account) -> Capabilities? { - let semaphore = DispatchSemaphore(value: 0) - var capabilities: Capabilities? - Task { - let result = await currentCapabilities(account: account) - capabilities = result.capabilities - semaphore.signal() - } - semaphore.wait() - return capabilities + public func directMockCapabilities() -> Capabilities? { + let capsData = capabilities.data(using: .utf8) + return Capabilities(data: capsData ?? Data()) } public func fetchUserProfile( diff --git a/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift b/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift index 7571ca61..554ccfd5 100644 --- a/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift +++ b/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift @@ -591,7 +591,10 @@ final class EnumeratorTests: XCTestCase { ) let storedRootItem = Item.rootContainer( - account: Self.account, remoteInterface: remoteInterface, dbManager: Self.dbManager + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + remoteSupportsTrash: await remoteInterface.supportsTrash(account: Self.account) ) print(storedRootItem.metadata.serverUrl) XCTAssertEqual(storedRootItem.childItemCount?.intValue, 3) // All items diff --git a/Tests/NextcloudFileProviderKitTests/ItemPropertyTests.swift b/Tests/NextcloudFileProviderKitTests/ItemPropertyTests.swift index d46ce7f9..4a754349 100644 --- a/Tests/NextcloudFileProviderKitTests/ItemPropertyTests.swift +++ b/Tests/NextcloudFileProviderKitTests/ItemPropertyTests.swift @@ -21,6 +21,11 @@ final class ItemPropertyTests: XCTestCase { realmConfig: .defaultConfiguration, account: account ) + override func setUp() { + super.setUp() + Realm.Configuration.defaultConfiguration.inMemoryIdentifier = name + } + func testMetadataContentType() { var metadata = SendableItemMetadata(ocId: "test-id", fileName: "test.txt", account: Self.account) @@ -331,11 +336,10 @@ final class ItemPropertyTests: XCTestCase { XCTAssertFalse(item.capabilities.contains(.allowsTrashing)) } - func testItemTrashabilityAffectedByCapabilities() { + func testItemTrashabilityAffectedByCapabilities() async { let remoteInterface = MockRemoteInterface() XCTAssert(remoteInterface.capabilities.contains(##""undelete": true,"##)) - remoteInterface.capabilities = - remoteInterface.capabilities.replacingOccurrences(of: ##""undelete": true,"##, with: "") + let remoteSupportsTrash = await remoteInterface.supportsTrash(account: Self.account) let metadata = SendableItemMetadata(ocId: "test-id", fileName: "test", account: Self.account) let item = Item( @@ -343,9 +347,48 @@ final class ItemPropertyTests: XCTestCase { parentItemIdentifier: .rootContainer, account: Self.account, remoteInterface: remoteInterface, + dbManager: Self.dbManager, + remoteSupportsTrash: remoteSupportsTrash + ) + XCTAssertTrue(item.capabilities.contains(.allowsTrashing)) + } + + func testStoredItemTrashabilityFalseAffectedByCapabilities() async { + let db = Self.dbManager.ncDatabase() + debugPrint(db) + + let remoteInterface = MockRemoteInterface() + XCTAssert(remoteInterface.capabilities.contains(##""undelete": true,"##)) + remoteInterface.capabilities = + remoteInterface.capabilities.replacingOccurrences(of: ##""undelete": true,"##, with: "") + let metadata = + SendableItemMetadata(ocId: "test-id", fileName: "test", account: Self.account) + Self.dbManager.addItemMetadata(metadata) + let item = await Item.storedItem( + identifier: .init(metadata.ocId), + account: Self.account, + remoteInterface: remoteInterface, dbManager: Self.dbManager ) - XCTAssertFalse(item.capabilities.contains(.allowsTrashing)) + XCTAssertEqual(item?.capabilities.contains(.allowsTrashing), false) + } + + func testStoredItemTrashabilityTrueAffectedByCapabilities() async { + let db = Self.dbManager.ncDatabase() + debugPrint(db) + + let remoteInterface = MockRemoteInterface() + XCTAssert(remoteInterface.capabilities.contains(##""undelete": true,"##)) + let metadata = + SendableItemMetadata(ocId: "test-id", fileName: "test", account: Self.account) + Self.dbManager.addItemMetadata(metadata) + let item = await Item.storedItem( + identifier: .init(metadata.ocId), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + XCTAssertEqual(item?.capabilities.contains(.allowsTrashing), true) } func testItemShared() { diff --git a/Tests/NextcloudFileProviderKitTests/RemoteInterfaceTests.swift b/Tests/NextcloudFileProviderKitTests/RemoteInterfaceTests.swift new file mode 100644 index 00000000..dcd67511 --- /dev/null +++ b/Tests/NextcloudFileProviderKitTests/RemoteInterfaceTests.swift @@ -0,0 +1,486 @@ +// +// RemoteInterfaceTests.swift +// +// +// Created by Claudio Cambra on 27/5/25. +// + +import Alamofire +import Foundation +import NextcloudCapabilitiesKit +import NextcloudKit +import Testing +@testable import TestInterface +@testable import NextcloudFileProviderKit + +fileprivate struct TestableRemoteInterface: RemoteInterface { + func setDelegate(_ delegate: any NextcloudKitDelegate) {} + + func createFolder( + remotePath: String, + account: Account, + options: NKRequestOptions, + taskHandler: @escaping (URLSessionTask) -> Void + ) async -> (account: String, ocId: String?, date: NSDate?, error: NKError) { + ("", nil, nil, .invalidResponseError) + } + + func upload( + remotePath: String, + localPath: String, + creationDate: Date?, + modificationDate: Date?, + account: Account, + options: NKRequestOptions, + requestHandler: @escaping (UploadRequest) -> Void, + taskHandler: @escaping (URLSessionTask) -> Void, + progressHandler: @escaping (Progress) -> Void + ) async -> ( + account: String, + ocId: String?, + etag: String?, + date: NSDate?, + size: Int64, + response: HTTPURLResponse?, + afError: AFError?, + remoteError: NKError + ) { ("", nil, nil, nil, 0, nil, nil, .invalidResponseError) } + + func chunkedUpload( + localPath: String, + remotePath: String, + remoteChunkStoreFolderName: String, + chunkSize: Int, + remainingChunks: [RemoteFileChunk], + creationDate: Date?, + modificationDate: Date?, + account: Account, + options: NKRequestOptions, + currentNumChunksUpdateHandler: @escaping (Int) -> Void, + chunkCounter: @escaping (Int) -> Void, + chunkUploadStartHandler: @escaping ([RemoteFileChunk]) -> Void, + requestHandler: @escaping (UploadRequest) -> Void, + taskHandler: @escaping (URLSessionTask) -> Void, + progressHandler: @escaping (Progress) -> Void, + chunkUploadCompleteHandler: @escaping (RemoteFileChunk) -> Void + ) async -> ( + account: String, + fileChunks: [RemoteFileChunk]?, + file: NKFile?, + afError: AFError?, + remoteError: NKError + ) { ("", nil, nil, nil, .invalidResponseError) } + + func move( + remotePathSource: String, + remotePathDestination: String, + overwrite: Bool, + account: Account, + options: NKRequestOptions, + taskHandler: @escaping (URLSessionTask) -> Void + ) async -> (account: String, data: Data?, error: NKError) { ("", nil, .invalidResponseError) } + + func download( + remotePath: String, + localPath: String, + account: Account, + options: NKRequestOptions, + requestHandler: @escaping (DownloadRequest) -> Void, + taskHandler: @escaping (URLSessionTask) -> Void, + progressHandler: @escaping (Progress) -> Void + ) async -> ( + account: String, + etag: String?, + date: NSDate?, + length: Int64, + response: HTTPURLResponse?, + afError: AFError?, + remoteError: NKError + ) { ("", nil, nil, 0, nil, nil, .invalidResponseError) } + + func enumerate( + remotePath: String, + depth: EnumerateDepth, + showHiddenFiles: Bool, + includeHiddenFiles: [String], + requestBody: Data?, + account: Account, + options: NKRequestOptions, + taskHandler: @escaping (URLSessionTask) -> Void + ) async -> (account: String, files: [NKFile], data: Data?, error: NKError) { + ("", [], nil, .invalidResponseError) + } + + func delete( + remotePath: String, + account: Account, + options: NKRequestOptions, + taskHandler: @escaping (URLSessionTask) -> Void + ) async -> (account: String, response: HTTPURLResponse?, error: NKError) { + ("", nil, .invalidResponseError) + } + + func setLockStateForFile( + remotePath: String, + lock: Bool, + account: Account, + options: NKRequestOptions, + taskHandler: @escaping (URLSessionTask) -> Void + ) async -> (account: String, response: HTTPURLResponse?, error: NKError) { + ("", nil, .invalidResponseError) + } + + func trashedItems( + account: Account, options: NKRequestOptions, taskHandler: @escaping (URLSessionTask) -> Void + ) async -> (account: String, trashedItems: [NKTrash], data: Data?, error: NKError) { + ("", [], nil, .invalidResponseError) + } + + func restoreFromTrash( + filename: String, + account: Account, + options: NKRequestOptions, + taskHandler: @escaping (URLSessionTask) -> Void + ) async -> (account: String, data: Data?, error: NKError) { ("", nil, .invalidResponseError) } + + func downloadThumbnail( + url: URL, + account: Account, + options: NKRequestOptions, + taskHandler: @escaping (URLSessionTask) -> Void + ) async -> (account: String, data: Data?, error: NKError) { ("", nil, .invalidResponseError) } + + func fetchUserProfile( + account: Account, options: NKRequestOptions, taskHandler: @escaping (URLSessionTask) -> Void + ) async -> (account: String, userProfile: NKUserProfile?, data: Data?, error: NKError) { + ("", nil, nil, .invalidResponseError) + } + + func tryAuthenticationAttempt( + account: Account, options: NKRequestOptions, taskHandler: @escaping (URLSessionTask) -> Void + ) async -> AuthenticationAttemptResultState { .connectionError } + + typealias FetchResult = (account: String, capabilities: Capabilities?, data: Data?, error: NKError) + + var fetchCapabilitiesHandler: + ((Account, NKRequestOptions, @escaping (URLSessionTask) -> Void) async -> FetchResult)? + + func fetchCapabilities( + account: Account, + options: NKRequestOptions = .init(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> FetchResult { + let ncKitAccount = account.ncKitAccount + await RetrievedCapabilitiesActor.shared.setOngoingFetch( + forAccount: ncKitAccount, ongoing: true + ) + var response: FetchResult + if let handler = fetchCapabilitiesHandler { + response = await handler(account, options, taskHandler) + if let caps = response.capabilities { + await RetrievedCapabilitiesActor.shared.setCapabilities( + forAccount: ncKitAccount, capabilities: caps, retrievedAt: Date() + ) + } + } else { + print("Error: fetchCapabilitiesHandler not set in TestableRemoteInterface") + response = (account.ncKitAccount, nil, nil, .invalidResponseError) + } + await RetrievedCapabilitiesActor.shared.setOngoingFetch( + forAccount: account.ncKitAccount, ongoing: false + ) + return response + } +} + +@Suite("RemoteInterface Extension Tests", .serialized) +struct RemoteInterfaceExtensionTests { + + let testAccount = Account(user: "a1", id: "1", serverUrl: "example.com", password: "pass") + let otherAccount = Account(user: "a2", id: "2", serverUrl: "example.com", password: "word") + + func capabilitiesFromMockJSON(jsonString: String = mockCapabilities) -> (Capabilities, Data) { + let data = jsonString.data(using: .utf8)! + let caps = Capabilities(data: data)! + return (caps, data) + } + + @Test func currentCapabilitiesReturnsFreshCache() async { + await RetrievedCapabilitiesActor.shared.reset() + var remoteInterface = TestableRemoteInterface() + remoteInterface.fetchCapabilitiesHandler = { _, _, _ in + Issue.record("fetchCapabilities should NOT be called when cache is fresh.") + return (self.testAccount.ncKitAccount, nil, nil, .invalidResponseError) + } + + let (freshCaps, _) = capabilitiesFromMockJSON() + let freshDate = Date() // Now + + // Setup: Put fresh data into the shared actor + await RetrievedCapabilitiesActor.shared.setCapabilities( + forAccount: testAccount.ncKitAccount, + capabilities: freshCaps, + retrievedAt: freshDate + ) + + let result = await remoteInterface.currentCapabilities(account: testAccount) + + #expect(result.error == .success) + #expect(result.capabilities == freshCaps) + #expect(result.data == nil, "Data should be nil as no fetch occurred") + #expect(result.account == testAccount.ncKitAccount) + } + + @Test func currentCapabilitiesFetchesOnNoCache() async throws { + await RetrievedCapabilitiesActor.shared.reset() + + let (fetchedCaps, fetchedData) = capabilitiesFromMockJSON() + var fetcherCalled = false + var remoteInterface = TestableRemoteInterface() + remoteInterface.fetchCapabilitiesHandler = { acc, _, _ in + fetcherCalled = true + #expect(acc.ncKitAccount == self.testAccount.ncKitAccount) + return (acc.ncKitAccount, fetchedCaps, fetchedData, .success) + } + + let result = await remoteInterface.currentCapabilities(account: testAccount) + + #expect(fetcherCalled, "fetchCapabilities should be called when cache is empty.") + #expect(result.error == .success) + #expect(result.capabilities == fetchedCaps) + #expect(result.data == fetchedData) + + let actorCache = await RetrievedCapabilitiesActor.shared.data + #expect(actorCache[testAccount.ncKitAccount]?.capabilities == fetchedCaps) + } + + @Test func currentCapabilitiesFetchesOnStaleCache() async throws { + await RetrievedCapabilitiesActor.shared.reset() + + let (staleCaps, _) = capabilitiesFromMockJSON(jsonString: """ + { + "ocs": { + "meta": { + "status": "ok", + "statuscode": 100, + "message": "OK" + }, + "data": { + "capabilities": { + "files": { + "undelete": false + } + } + } + } + } + """) // Different caps + let staleDate = Date(timeIntervalSinceNow: -(CapabilitiesFetchInterval + 300)) // Definitely stale + + // Setup: Put stale data into the actor + await RetrievedCapabilitiesActor.shared.setCapabilities( + forAccount: testAccount.ncKitAccount, + capabilities: staleCaps, + retrievedAt: staleDate + ) + + let (newCaps, newData) = capabilitiesFromMockJSON() // Fresh data to be fetched + var fetcherCalled = false + var remoteInterface = TestableRemoteInterface() + remoteInterface.fetchCapabilitiesHandler = { acc, _, _ in + fetcherCalled = true + return (acc.ncKitAccount, newCaps, newData, .success) + } + + let result = await remoteInterface.currentCapabilities(account: testAccount) + + #expect(fetcherCalled, "fetchCapabilities should be called for stale cache.") + #expect(result.error == .success) + #expect(result.capabilities == newCaps, "Should return newly fetched capabilities.") + #expect(result.data == newData) + + let actorCache = await RetrievedCapabilitiesActor.shared.data + #expect(actorCache[testAccount.ncKitAccount]?.capabilities == newCaps) + #expect((actorCache[testAccount.ncKitAccount]?.retrievedAt ?? .distantPast) > staleDate) + } + + @Test func currentCapabilitiesAwaitsAndUsesCache() async throws { + await RetrievedCapabilitiesActor.shared.reset() + + let (cachedCaps, cachedData) = capabilitiesFromMockJSON() + var fetcherCalledCount = 0 + + var remoteInterface = TestableRemoteInterface() + remoteInterface.fetchCapabilitiesHandler = { acc, _, _ in + fetcherCalledCount += 1 + // This fetcher should not be called if cache is fresh after await. + return (acc.ncKitAccount, cachedCaps, cachedData, .success) + } + + // 1. Simulate an external process starting a fetch for testAccount + await RetrievedCapabilitiesActor.shared.setOngoingFetch( + forAccount: testAccount.ncKitAccount, ongoing: true + ) + + var currentCapabilitiesReturned = false + let currentCapabilitiesTask = Task { + // 2. This call to currentCapabilities should await the ongoing fetch. + let result = await remoteInterface.currentCapabilities(account: testAccount) + currentCapabilitiesReturned = true + // Assertions on the result will be done after the task. + #expect(result.capabilities == cachedCaps) + #expect(result.error == .success) + } + + // 3. Give currentCapabilitiesTask a moment to hit the await. + try await Task.sleep(for: .milliseconds(100)) + #expect(currentCapabilitiesReturned == false, "currentCapabilities should be awaiting.") + + // 4. Now, the "external" fetch completes and populates the cache. + await RetrievedCapabilitiesActor.shared.setCapabilities( + forAccount: testAccount.ncKitAccount, + capabilities: cachedCaps, + retrievedAt: Date() // Fresh date + ) + await RetrievedCapabilitiesActor.shared.setOngoingFetch( + forAccount: testAccount.ncKitAccount, ongoing: false + ) + + // 5. currentCapabilitiesTask should now complete. + await currentCapabilitiesTask.value + #expect(currentCapabilitiesReturned == true) + + // Check if fetchCapabilities was called. + // If the logic is: await -> check cache -> fetch if needed. + // And we made cache fresh before await unblocked, it should NOT call fetch. + #expect(fetcherCalledCount == 0, "fetchCapabilities should not have been called if cache was fresh after await.") + } + + @Test func supportsTrashTrue() async throws { + await RetrievedCapabilitiesActor.shared.reset() // Reset shared actor + + // JSON where files.undelete is true (default mockCapabilitiesJSON) + let (capsWithTrash, dataWithTrash) = capabilitiesFromMockJSON() + #expect(capsWithTrash.files?.undelete == true) + + var remoteInterface = TestableRemoteInterface() + remoteInterface.fetchCapabilitiesHandler = { acc, _, _ in + return (acc.ncKitAccount, capsWithTrash, dataWithTrash, .success) + } + await RetrievedCapabilitiesActor.shared.setCapabilities( + forAccount: testAccount.ncKitAccount, + capabilities: capsWithTrash, // any capability + retrievedAt: Date(timeIntervalSinceNow: -(CapabilitiesFetchInterval + 100)) // Stale + ) + + let result = await remoteInterface.supportsTrash(account: testAccount) + #expect(result == true) + } + + @Test func supportsTrashFalse() async throws { + await RetrievedCapabilitiesActor.shared.reset() + let jsonNoUndelete = """ + { + "ocs": { + "meta": { + "status": "ok", + "statuscode": 100, + "message": "OK" + }, + "data": { + "capabilities": { + "files": { + "undelete": false + } + } + } + } + } + """ + let (capsNoTrash, dataNoTrash) = capabilitiesFromMockJSON(jsonString: jsonNoUndelete) + #expect(capsNoTrash.files?.undelete == false) + + var remoteInterface = TestableRemoteInterface() + remoteInterface.fetchCapabilitiesHandler = { acc, _, _ in + await RetrievedCapabilitiesActor.shared.setCapabilities( + forAccount: acc.ncKitAccount, capabilities: capsNoTrash, retrievedAt: Date() + ) + return (acc.ncKitAccount, capsNoTrash, dataNoTrash, .success) + } + await RetrievedCapabilitiesActor.shared.setCapabilities( // Stale entry + forAccount: testAccount.ncKitAccount, + capabilities: capsNoTrash, + retrievedAt: Date(timeIntervalSinceNow: -(CapabilitiesFetchInterval + 100)) + ) + + let result = await remoteInterface.supportsTrash(account: testAccount) + #expect(result == false) + } + + @Test func supportsTrashNilCapabilities() async throws { + await RetrievedCapabilitiesActor.shared.reset() + var remoteInterface = TestableRemoteInterface() + remoteInterface.fetchCapabilitiesHandler = { acc, _, _ in + return (acc.ncKitAccount, nil, nil, .invalidResponseError) + } + await RetrievedCapabilitiesActor.shared.setCapabilities( + forAccount: testAccount.ncKitAccount, + capabilities: capabilitiesFromMockJSON().0, + retrievedAt: Date(timeIntervalSinceNow: -(CapabilitiesFetchInterval + 100)) + ) + + let result = await remoteInterface.supportsTrash(account: testAccount) + #expect(!result) + } + + @Test func supportsTrashNilFilesSection() async throws { + await RetrievedCapabilitiesActor.shared.reset() + let jsonNoFilesSection = """ + { + "ocs": { + "meta": { + "status": "ok", + "statuscode": 100, + "message": "OK" + }, + "data": { + "capabilities": { + "core": { + "pollinterval": 60 + } + } + } + } + } + """ + // This JSON will result in `Capabilities.files` being nil + let (capsNoFiles, dataNoFiles) = capabilitiesFromMockJSON(jsonString: jsonNoFilesSection) + #expect(capsNoFiles.files?.undelete != true) // Check our parsing logic + + var remoteInterface = TestableRemoteInterface() + remoteInterface.fetchCapabilitiesHandler = { acc, _, _ in + (acc.ncKitAccount, capsNoFiles, dataNoFiles, .success) + } + await RetrievedCapabilitiesActor.shared.setCapabilities( // Stale entry + forAccount: testAccount.ncKitAccount, + capabilities: capsNoFiles, + retrievedAt: Date(timeIntervalSinceNow: -(CapabilitiesFetchInterval + 100)) + ) + + let result = await remoteInterface.supportsTrash(account: testAccount) + #expect(!result) + } + + @Test func supportsTrashHandlesErrorFromCurrentCapabilities() async throws { + await RetrievedCapabilitiesActor.shared.reset() + var remoteInterface = TestableRemoteInterface() + remoteInterface.fetchCapabilitiesHandler = { acc, _, _ in + return (acc.ncKitAccount, nil, nil, .invalidResponseError) + } + // Ensure fetch is triggered + // (e.g., actor has no data or stale data for testAccount.ncKitAccount) + + let result = await remoteInterface.supportsTrash(account: testAccount) + #expect(!result, "supportsTrash should return false if currentCapabilities errors.") + } +} diff --git a/Tests/NextcloudFileProviderKitTests/RetrievedCapabilitiesActorTests.swift b/Tests/NextcloudFileProviderKitTests/RetrievedCapabilitiesActorTests.swift new file mode 100644 index 00000000..6dec63a3 --- /dev/null +++ b/Tests/NextcloudFileProviderKitTests/RetrievedCapabilitiesActorTests.swift @@ -0,0 +1,185 @@ +// +// RetrievedCapabilitiesActorTests.swift +// NextcloudFileProviderKit +// +// Created by Claudio Cambra on 28/5/25. +// + +import Foundation +import NextcloudCapabilitiesKit +import Testing +@testable import NextcloudFileProviderKit +@testable import TestInterface + +@Suite("RetrievedCapabilitiesActor tests") +struct RetrievedCapabilitiesActorTests { + + let account1 = "acc1" + let account2 = "acc2" + + @Test func setCapabilitiesCompletes() async { + let actor = RetrievedCapabilitiesActor() // New instance for the test + let capsData = mockCapabilities.data(using: .utf8)! + let caps = Capabilities(data: capsData)! + let specificDate = Date(timeIntervalSince1970: 1234567890) + + // We call the public API. + await actor.setCapabilities(forAccount: account1, capabilities: caps, retrievedAt: specificDate) + let setCaps = await actor.data[account1] + + #expect(setCaps?.retrievedAt == specificDate) + #expect(setCaps?.capabilities != nil) + } + + @Test func setOngoingFetchTrueCausesSuspension() async throws { + let actor = RetrievedCapabilitiesActor() + var awaiterDidProceed = false + + // 1. Mark fetch as ongoing + await actor.setOngoingFetch(forAccount: account1, ongoing: true) + + // 2. Attempt to await in a separate task + let awaitingTask = Task { + await actor.awaitFetchCompletion(forAccount: account1) + awaiterDidProceed = true // This should only become true after resumption + } + + // 3. Give the awaitingTask a moment to potentially run and suspend + try await Task.sleep(for: .milliseconds(100)) + #expect(!awaiterDidProceed, "`awaitFetchCompletion` should suspend if fetch is ongoing.") + + // 4. Clean up: complete the fetch to allow the task to finish + await actor.setOngoingFetch(forAccount: account1, ongoing: false) + await awaitingTask.value // Ensure the task fully completes + #expect(awaiterDidProceed, "Awaiter should proceed after fetch is no longer ongoing.") + } + + @Test func setOngoingFetchFalseResumesAwaiter() async throws { + let actor = RetrievedCapabilitiesActor() + var awaiterCompleted = false + + // 1. Mark fetch as ongoing and start an awaiter + await actor.setOngoingFetch(forAccount: account1, ongoing: true) + let awaitingTask = Task { + await actor.awaitFetchCompletion(forAccount: account1) + awaiterCompleted = true + } + + // 2. Ensure it's waiting + try await Task.sleep(for: .milliseconds(100)) + #expect(awaiterCompleted == false, "Awaiter should be suspended initially.") + + // 3. Mark fetch as not ongoing, which should resume the awaiter + await actor.setOngoingFetch(forAccount: account1, ongoing: false) + + // 4. Await the task's completion and check the flag + await awaitingTask.value + #expect(awaiterCompleted, "Awaiter should complete after `setOngoingFetch(false)`.") + } + + @Test func awaitFetchCompletionReturnsImmediately() async throws { + let actor = RetrievedCapabilitiesActor() + var didAwaiterCompleteImmediately = false + + let task = Task { + await actor.awaitFetchCompletion(forAccount: account1) + didAwaiterCompleteImmediately = true + } + await task.value + + #expect( + didAwaiterCompleteImmediately, + "`awaitFetchCompletion` should let the task complete quickly if no fetch is ongoing." + ) + } + + @Test func awaitFetchCompletion_suspendsAndResumes_behavioral() async throws { + let actor = RetrievedCapabilitiesActor() + var didAwaiterComplete = false + + // 1. Mark fetch as ongoing + await actor.setOngoingFetch(forAccount: account1, ongoing: true) + + // 2. Start task that awaits + let awaitingTask = Task { + await actor.awaitFetchCompletion(forAccount: account1) + didAwaiterComplete = true + } + + // 3. Check for suspension (indirectly) + try await Task.sleep(for: .milliseconds(100)) + #expect(!didAwaiterComplete, "Awaiter should be suspended while fetch is ongoing.") + + // 4. Mark fetch as completed + await actor.setOngoingFetch(forAccount: account1, ongoing: false) + + // 5. Awaiter should complete + await awaitingTask.value + #expect(didAwaiterComplete, "Awaiter should complete after fetch is no longer ongoing.") + } + + @Test func awaitFetchCompletion_multipleAwaiters_behavioral() async throws { + let actor = RetrievedCapabilitiesActor() + var awaiter1Complete = false + var awaiter2Complete = false + + await actor.setOngoingFetch(forAccount: account1, ongoing: true) + + let task1 = Task { + await actor.awaitFetchCompletion(forAccount: account1) + awaiter1Complete = true + } + let task2 = Task { + await actor.awaitFetchCompletion(forAccount: account1) + awaiter2Complete = true + } + + try await Task.sleep(for: .milliseconds(100)) + #expect(!awaiter1Complete && !awaiter2Complete, "Both awaiters should be suspended.") + + await actor.setOngoingFetch(forAccount: account1, ongoing: false) + + await task1.value + await task2.value + #expect( + awaiter1Complete && awaiter2Complete, + "Both awaiters should complete after fetch is no longer ongoing." + ) + } + + @Test func setOngoingFetch_false_isolatesAccountResumption_behavioral() async throws { + let actor = RetrievedCapabilitiesActor() + var acc1AwaiterDone = false + var acc2AwaiterDone = false + + // Start fetches for both accounts + await actor.setOngoingFetch(forAccount: account1, ongoing: true) + await actor.setOngoingFetch(forAccount: account2, ongoing: true) + + // Setup awaiters + let taskAcc1 = Task { + await actor.awaitFetchCompletion(forAccount: account1) + acc1AwaiterDone = true + } + let taskAcc2 = Task { + await actor.awaitFetchCompletion(forAccount: account2) + acc2AwaiterDone = true + } + + try await Task.sleep(for: .milliseconds(100)) // Allow tasks to suspend + #expect(!acc1AwaiterDone && !acc2AwaiterDone, "Both awaiters initially suspended.") + + // Complete fetch for account1 ONLY + await actor.setOngoingFetch(forAccount: account1, ongoing: false) + await taskAcc1.value + + #expect(acc1AwaiterDone, "Awaiter for account1 should complete.") + #expect(!acc2AwaiterDone, "Awaiter for account2 should still be suspended.") + + // Complete fetch for account2 + await actor.setOngoingFetch(forAccount: account2, ongoing: false) + await taskAcc2.value // Wait for acc2's awaiter to complete + + #expect(acc2AwaiterDone, "Awaiter for account2 should now complete.") + } +}