diff --git a/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift b/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift index f24d8888..2ea40b85 100644 --- a/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift +++ b/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift @@ -29,12 +29,12 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { private let anchor = NSFileProviderSyncAnchor(Date().description.data(using: .utf8)!) private let pageItemCount: Int private var pageNum = 0 + private var nextFoldersServerUrlsToEnumerateWorkingSet = [String]() static let logger = Logger(subsystem: Logger.subsystem, category: "enumerator") let account: Account let remoteInterface: RemoteInterface - var serverUrl: String = "" var isInvalidated = false - weak var listener: EnumerationListener? + private(set) var serverUrl: String = "" private static func isSystemIdentifier(_ identifier: NSFileProviderItemIdentifier) -> Bool { identifier == .rootContainer || identifier == .trashContainer || identifier == .workingSet @@ -46,7 +46,6 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { remoteInterface: RemoteInterface, dbManager: FilesDatabaseManager, domain: NSFileProviderDomain? = nil, - listener: EnumerationListener? = nil, pageSize: Int = 100 ) { self.enumeratedItemIdentifier = enumeratedItemIdentifier @@ -54,7 +53,6 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { self.account = account self.dbManager = dbManager self.domain = domain - self.listener = listener self.pageItemCount = pageSize if Self.isSystemIdentifier(enumeratedItemIdentifier) { @@ -112,9 +110,6 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { public func enumerateItems( for observer: NSFileProviderEnumerationObserver, startingAt page: NSFileProviderPage ) { - let actionId = UUID() - listener?.enumerationActionStarted(actionId: actionId) - Self.logger.debug( """ Received enumerate items request for enumerator with user: @@ -182,7 +177,6 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { let error = trashReadError.fileProviderError( handlingNoSuchItemErrorUsingItemIdentifier: self.enumeratedItemIdentifier ) ?? NSFileProviderError(.cannotSynchronize) - listener?.enumerationActionFailed(actionId: actionId, error: error) observer.finishEnumeratingWithError(error) return } @@ -195,7 +189,6 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { numPage: 1, trashItems: trashedItems ) - listener?.enumerationActionFinished(actionId: actionId) } return } @@ -214,7 +207,6 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { let error = NSError.fileProviderErrorForNonExistentItem( withIdentifier: self.enumeratedItemIdentifier ) - listener?.enumerationActionFailed(actionId: actionId, error: error) observer.finishEnumeratingWithError(error) return } @@ -228,24 +220,41 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { ) Task { + var providedPage: NSFileProviderPage? = nil // Used for pagination token sent to server // Do not pass in the NSFileProviderPage default pages, these are not valid Nextcloud // pagination tokens - var providedPage: NSFileProviderPage? = nil if page != NSFileProviderPage.initialPageSortedByName as NSFileProviderPage && page != NSFileProviderPage.initialPageSortedByDate as NSFileProviderPage { - providedPage = page + if let pageString = String(data: page.rawValue, encoding: .utf8), + let urlDetector = try? NSDataDetector( + types: NSTextCheckingResult.CheckingType.link.rawValue + ), + !urlDetector.matches( + in: pageString, options: [], range: .init(location: 0, length: pageString.count) + ).isEmpty + { + Self.logger.info( + "Setting enumerator server URL to \(self.serverUrl, privacy: .public)" + ) + serverUrl = pageString + pageNum = 0 // If paginating new target server URL reset to 0 + } else { + providedPage = page + } } - let depth: EnumerateDepth = enumeratedItemIdentifier == .workingSet ? - .targetAndAllChildren : .targetAndDirectChildren - let (metadatas, _, _, _, nextPage, readError) = await Self.readServerUrl( + + let readResult = await Self.readServerUrl( serverUrl, pageSettings: (page: providedPage, index: pageNum, size: pageItemCount), account: account, remoteInterface: remoteInterface, dbManager: dbManager, - depth: depth + depth: .targetAndDirectChildren ) + let metadatas = readResult.metadatas + let readError = readResult.readError + var nextPage = readResult.nextPage guard readError == nil else { Self.logger.error( @@ -261,7 +270,6 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { let error = readError?.fileProviderError( handlingNoSuchItemErrorUsingItemIdentifier: self.enumeratedItemIdentifier ) ?? NSFileProviderError(.cannotSynchronize) - listener?.enumerationActionFailed(actionId: actionId, error: error) observer.finishEnumeratingWithError(error) return } @@ -270,42 +278,50 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { Self.logger.error( """ Finishing enumeration for: \(self.account.ncKitAccount, privacy: .public) - with serverUrl: \(self.serverUrl, privacy: .public) - with invalid metadatas. + with serverUrl: \(self.serverUrl, privacy: .public) + with invalid metadatas. """ ) - listener?.enumerationActionFailed( - actionId: actionId, error: NSFileProviderError(.cannotSynchronize) - ) observer.finishEnumeratingWithError(NSFileProviderError(.cannotSynchronize)) return } + if enumeratedItemIdentifier == .workingSet { + for metadata in metadatas where metadata.directory { + let metadataRemoteUrl = metadata.serverUrl + "/" + metadata.fileName + nextFoldersServerUrlsToEnumerateWorkingSet.append(metadataRemoteUrl) + } + + // If we have finished paged enumeration of the current serverUrl, move to next + // child to scan + if nextPage == nil && !nextFoldersServerUrlsToEnumerateWorkingSet.isEmpty { + let nextServerUrl = nextFoldersServerUrlsToEnumerateWorkingSet.removeFirst() + nextPage = EnumeratorPageResponse(nextServerUrl: nextServerUrl) + } + } + Self.logger.info( """ Finished reading page: \(self.pageNum, privacy: .public) serverUrl: \(self.serverUrl, privacy: .public) for user: \(self.account.ncKitAccount, privacy: .public). + Next page token is: \(nextPage?.token ?? "", privacy: .public) Processed \(metadatas.count) metadatas """ ) completeEnumerationObserver(observer, nextPage: nextPage, itemMetadatas: metadatas) - listener?.enumerationActionFinished(actionId: actionId) } } public func enumerateChanges( for observer: NSFileProviderChangeObserver, from anchor: NSFileProviderSyncAnchor ) { - let actionId = UUID() - listener?.enumerationActionStarted(actionId: actionId) - Self.logger.debug( """ - Received enumerate changes request for enumerator for user: - \(self.account.ncKitAccount, privacy: .public) - with serverUrl: \(self.serverUrl, privacy: .public) + Received enumerate changes request for enumerator + for user: \(self.account.ncKitAccount, privacy: .public) + with serverUrl: \(self.serverUrl, privacy: .public) """ ) /* @@ -320,7 +336,7 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { if enumeratedItemIdentifier == .workingSet { Self.logger.debug( - "Enumerating changes in working set for: \(self.account.ncKitAccount, privacy: .public)" + "Enumerating working set changes for \(self.account.ncKitAccount, privacy: .public)" ) // Unlike when enumerating items we can't progressively enumerate items as we need to @@ -342,9 +358,6 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { For user: \(self.account.ncKitAccount, privacy: .public) """ ) - listener?.enumerationActionFailed( - actionId: actionId, error: NSFileProviderError(.cannotSynchronize) - ) observer.finishEnumeratingWithError(NSFileProviderError(.cannotSynchronize)) return } @@ -361,7 +374,6 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { let fpError = error?.fileProviderError( handlingNoSuchItemErrorUsingItemIdentifier: self.enumeratedItemIdentifier ) ?? NSFileProviderError(.cannotSynchronize) - listener?.enumerationActionFailed(actionId: actionId, error: fpError) observer.finishEnumeratingWithError(fpError) return } @@ -384,7 +396,6 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { updatedMetadatas: updatedMetadatas, deletedMetadatas: deletedMetadatas ) - listener?.enumerationActionFinished(actionId: actionId) } return } else if enumeratedItemIdentifier == .trashContainer { @@ -431,7 +442,6 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { let error = trashReadError.fileProviderError( handlingNoSuchItemErrorUsingItemIdentifier: self.enumeratedItemIdentifier ) ?? NSFileProviderError(.cannotSynchronize) - listener?.enumerationActionFailed(actionId: actionId, error: error) observer.finishEnumeratingWithError(error) return } @@ -444,7 +454,6 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { dbManager: dbManager, trashItems: trashedItems ) - listener?.enumerationActionFinished(actionId: actionId) } return } @@ -504,7 +513,6 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { Could not delete metadata nor report deletion. """ ) - listener?.enumerationActionFailed(actionId: actionId, error: error) observer.finishEnumeratingWithError(error) return } @@ -538,7 +546,6 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { updatedMetadatas: nil, deletedMetadatas: [itemMetadata] ) - listener?.enumerationActionFinished(actionId: actionId) return } else if readError!.isNoChangesError { // All is well, just no changed etags Self.logger.info( @@ -547,12 +554,10 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { Finishing change enumeration. """ ) - listener?.enumerationActionFinished(actionId: actionId) observer.finishEnumeratingChanges(upTo: anchor, moreComing: false) return } - listener?.enumerationActionFailed(actionId: actionId, error: error) observer.finishEnumeratingWithError(error) return } @@ -575,7 +580,6 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { updatedMetadatas: updatedMetadatas, deletedMetadatas: deletedMetadatas ) - listener?.enumerationActionFinished(actionId: actionId) } } diff --git a/Sources/NextcloudFileProviderKit/Enumeration/EnumeratorPageResponse.swift b/Sources/NextcloudFileProviderKit/Enumeration/EnumeratorPageResponse.swift index 5d560c92..c4f64cab 100644 --- a/Sources/NextcloudFileProviderKit/Enumeration/EnumeratorPageResponse.swift +++ b/Sources/NextcloudFileProviderKit/Enumeration/EnumeratorPageResponse.swift @@ -20,7 +20,6 @@ struct EnumeratorPageResponse: Sendable { let normalisedHeaders = Dictionary(uniqueKeysWithValues: headers.map { ($0.key.lowercased(), $0.value) }) - print(normalisedHeaders) guard Bool(normalisedHeaders["x-nc-paginate"]?.lowercased() ?? "false") == true, let responsePaginateToken = normalisedHeaders["x-nc-paginate-token"] else { return nil } @@ -33,4 +32,10 @@ struct EnumeratorPageResponse: Sendable { total = nil } } + + init(nextServerUrl: String) { + self.token = nextServerUrl + self.index = -1 + self.total = nil + } } diff --git a/Sources/NextcloudFileProviderKit/Reporting/EnumerationListener.swift b/Sources/NextcloudFileProviderKit/Reporting/EnumerationListener.swift deleted file mode 100644 index 0e33945a..00000000 --- a/Sources/NextcloudFileProviderKit/Reporting/EnumerationListener.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// EnumerationListener.swift -// -// -// Created by Claudio Cambra on 16/7/24. -// - -import FileProvider -import Foundation - -public protocol EnumerationListener: NSObject { - func enumerationActionStarted(actionId: UUID) - func enumerationActionFinished(actionId: UUID) - func enumerationActionFailed(actionId: UUID, error: Error) -} diff --git a/Tests/Interface/MockEnumerationListener.swift b/Tests/Interface/MockEnumerationListener.swift deleted file mode 100644 index f7c8d9d7..00000000 --- a/Tests/Interface/MockEnumerationListener.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// MockEnumerationListener.swift -// -// -// Created by Claudio Cambra on 17/7/24. -// - -import Foundation -import NextcloudFileProviderKit - -public class MockEnumerationListener: NSObject, EnumerationListener { - public var startActions = [UUID: Date]() - public var finishActions = [UUID: Date]() - public var errorActions = [UUID: Date]() - - public func enumerationActionStarted(actionId: UUID) { - print("Enumeration action started with id: \(actionId)") - startActions[actionId] = Date() - } - - public func enumerationActionFinished(actionId: UUID) { - print("Enumeration action finished with id: \(actionId)") - finishActions[actionId] = Date() - } - - public func enumerationActionFailed(actionId: UUID, error: Error) { - print("Enumeration action failed with id: \(actionId) and error: \(error)") - errorActions[actionId] = Date() - } -} diff --git a/Tests/Interface/MockRemoteInterface.swift b/Tests/Interface/MockRemoteInterface.swift index 4df8e5b3..3cc07201 100644 --- a/Tests/Interface/MockRemoteInterface.swift +++ b/Tests/Interface/MockRemoteInterface.swift @@ -1083,6 +1083,7 @@ public class MockRemoteInterface: RemoteInterface { } let reachedEnd = firstItem + itemCount >= files.count let lastItem = min(firstItem + itemCount, files.count) - 1 + assert(firstItem <= lastItem) let itemsPage = Array(files[firstItem...lastItem]) let responseData = generateResponse(itemCount: files.count, finalPage: reachedEnd) return (account.ncKitAccount, itemsPage, responseData, .success) diff --git a/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift b/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift index 38f3b305..f7849d4c 100644 --- a/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift +++ b/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift @@ -213,6 +213,204 @@ final class EnumeratorTests: XCTestCase { XCTAssertEqual(dbFolder.etag, remoteFolder.versionIdentifier) } + func testWorkingSetPaginatedEnumeration() async throws { + // 1. Setup a flat file structure with more items than the page size. + rootItem.children = [] // Clear existing children + for i in 0..<15 { + let childItem = MockRemoteItem( + identifier: "item\(i)", + name: "item\(i).txt", + remotePath: Self.account.davFilesUrl + "/item\(i).txt", + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + childItem.parent = rootItem + rootItem.children.append(childItem) + } + + let db = Self.dbManager.ncDatabase() // Strong ref for in-memory test db + debugPrint(db) + let remoteInterface = MockRemoteInterface(rootItem: rootItem, pagination: true) + + // 2. Create the enumerator with a specific page size. + let enumerator = Enumerator( + enumeratedItemIdentifier: .workingSet, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + pageSize: 5 + ) + let observer = MockEnumerationObserver(enumerator: enumerator) + + // 3. Enumerate the items. + try await observer.enumerateItems() + + // 4. Assert the results. + XCTAssertEqual(observer.items.count, 15) + + for item in observer.items { + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: item.itemIdentifier.rawValue)) + } + + // The enumeration flow: + // - Initial call enumerates the root. + // - The enumerator then gets the first page of children. + // - The observer requests the next pages until all items are fetched. + // With 15 children and a page size of 5, we expect 3 pages of children. + // Total pages observed: 1 (initial) + 3 (for children) = 4 + XCTAssertEqual(observer.observedPages.count, 4) + XCTAssertEqual( + observer.observedPages.first, + NSFileProviderPage.initialPageSortedByName as NSFileProviderPage + ) + } + + func testWorkingSetPaginatedEnumerationWithMultipleDirectories() async throws { + // 1. Setup a more complex, nested directory structure. + // root + // |- folder1 (7 items) + // |- folder2 + // |- subfolder (3 items) + // |- (8 items) + + rootItem.children = [] // Clear existing children + + let folder1 = MockRemoteItem( + identifier: "folder1", + name: "folder1", + remotePath: Self.account.davFilesUrl + "/folder1", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + folder1.parent = rootItem + rootItem.children.append(folder1) + + for i in 0..<7 { + let childItem = MockRemoteItem( + identifier: "folder1-item\(i)", + name: "item\(i).txt", + remotePath: folder1.remotePath + "/item\(i).txt", + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + childItem.parent = folder1 + folder1.children.append(childItem) + } + + let folder2 = MockRemoteItem( + identifier: "folder2", + name: "folder2", + remotePath: Self.account.davFilesUrl + "/folder2", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + folder2.parent = rootItem + rootItem.children.append(folder2) + + for i in 0..<8 { + let childItem = MockRemoteItem( + identifier: "folder2-item\(i)", + name: "item\(i).txt", + remotePath: folder2.remotePath + "/item\(i).txt", + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + childItem.parent = folder2 + folder2.children.append(childItem) + } + + let subfolder = MockRemoteItem( + identifier: "subfolder", + name: "subfolder", + remotePath: folder2.remotePath + "/subfolder", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + subfolder.parent = folder2 + folder2.children.append(subfolder) + + for i in 0..<3 { + let childItem = MockRemoteItem( + identifier: "subfolder-item\(i)", + name: "item\(i).txt", + remotePath: subfolder.remotePath + "/item\(i).txt", + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + childItem.parent = subfolder + subfolder.children.append(childItem) + } + + let db = Self.dbManager.ncDatabase() + debugPrint(db) + let remoteInterface = MockRemoteInterface(rootItem: rootItem, pagination: true) + + // 2. Create the enumerator. + let enumerator = Enumerator( + enumeratedItemIdentifier: .workingSet, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + pageSize: 5 + ) + let observer = MockEnumerationObserver(enumerator: enumerator) + + // 3. Enumerate the items. + try await observer.enumerateItems() + + // 4. Assert the results. + // Total items = + // 0 (root) + + // 1 (folder1) + + // 7 (items in folder1) + + // 1 (folder2) + + // 8 (items in folder2) + + // 1 (subfolder) + + // 3 (items in subfolder) + // = 22 + let expectedTotalItems = 0 + 1 + 7 + 1 + 8 + 1 + 3 + print(observer.items.map(\.itemIdentifier.rawValue)) + XCTAssertEqual(observer.items.count, expectedTotalItems) + + for item in observer.items { + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: item.itemIdentifier.rawValue)) + } + + // The working set enumeration is recursive. The enumerator will first list the items + // in the root directory, then it will be called again with the URLs of the subdirectories + // as pages. This process continues until all directories have been enumerated. + // We can verify that all items are discovered and stored in the database. + let allItemIds = [ + rootItem.identifier, + folder1.identifier, + folder2.identifier, + subfolder.identifier + ] + folder1.children.map(\.identifier) + + folder2.children.map(\.identifier) + + subfolder.children.map(\.identifier) + + for itemId in allItemIds { + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: itemId), "Item with id \(itemId) should be in the database") + } + } + func testWorkingSetChangeEnumeration() async throws { let db = Self.dbManager.ncDatabase() // Strong ref for in memory test db debugPrint(db) @@ -691,29 +889,6 @@ final class EnumeratorTests: XCTestCase { XCTAssertFalse(storedItemA.filename.isEmpty) } - func testListenerInvocations() async throws { - let db = Self.dbManager.ncDatabase() // Strong ref for in memory test db - debugPrint(db) - let remoteInterface = MockRemoteInterface(rootItem: rootItem) - let listener = MockEnumerationListener() - - let enumerator = Enumerator( - enumeratedItemIdentifier: .workingSet, - account: Self.account, - remoteInterface: remoteInterface, - dbManager: Self.dbManager, - listener: listener - ) - let observer = MockEnumerationObserver(enumerator: enumerator) - try await observer.enumerateItems() - - // Check enumeration actions - XCTAssertEqual(listener.startActions.count, 1) - XCTAssertEqual(listener.finishActions.count, 1) - XCTAssertTrue(listener.errorActions.isEmpty) - XCTAssertTrue(listener.startActions.first!.value < listener.finishActions.first!.value) - } - func testTrashEnumeration() async throws { let db = Self.dbManager.ncDatabase() // Strong ref for in memory test db debugPrint(db) // Avoid build-time warning about unused variable, ensure compiler won't free @@ -990,4 +1165,130 @@ final class EnumeratorTests: XCTestCase { XCTAssertEqual(observer.observedPages.first, NSFileProviderPage.initialPageSortedByName as NSFileProviderPage) XCTAssertEqual(observer.observedPages.count, 5) } + + func testEmptyFolderPaginatedEnumeration() async throws { + // 1. Setup: remoteFolder exists in the DB but has no children. + // Ensure the folder itself is in the database, as the enumerator for a specific item + // will try to fetch its metadata. + remoteFolder.children = [] // Ensure it's empty for this test + Self.dbManager.addItemMetadata(remoteFolder.toItemMetadata(account: Self.account)) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: remoteFolder.identifier), "Folder metadata should be in DB for enumeration.") + + let db = Self.dbManager.ncDatabase() // Strong ref for in-memory test db + debugPrint(db) + // Enable pagination in MockRemoteInterface to ensure the pagination path is taken + let remoteInterface = MockRemoteInterface(rootItem: rootItem, pagination: true) + + // 2. Create enumerator for the empty folder with a specific pageSize. + let enumerator = Enumerator( + enumeratedItemIdentifier: NSFileProviderItemIdentifier(remoteFolder.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + pageSize: 5 // Page size can be anything, as the folder is empty + ) + let observer = MockEnumerationObserver(enumerator: enumerator) + + // 3. Enumerate items. + try await observer.enumerateItems() + + // 4. Assertions. + // When enumerating a folder (even an empty one) with depth .targetAndDirectChildren, + // the folder item itself should not be returned. + XCTAssertEqual(observer.items.count, 0, "Should enumerate nothing.") + + // For an empty folder, there's only one "page" of results (the folder itself). + XCTAssertEqual(observer.observedPages.count, 1, "Should be one page call for an empty folder.") + + // Verify the folder's metadata in the database is up-to-date. + // This ensures the enumeration process also updates the target item's metadata if necessary + let dbFolderMetadata = + try XCTUnwrap(Self.dbManager.itemMetadata(ocId: remoteFolder.identifier)) + XCTAssertEqual( + dbFolderMetadata.etag, + remoteFolder.versionIdentifier, + "Folder ETag should be updated in DB if changed by enumeration." + ) + let storedFolderItem = Item( + metadata: dbFolderMetadata, + parentItemIdentifier: .init(rootItem.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + let childItemCount = storedFolderItem.childItemCount as? Int + let expectedChildItemCount = remoteFolder.children.count + XCTAssertEqual(childItemCount, expectedChildItemCount) + } + + func testFolderWithFewItemsPaginatedEnumeration() async throws { + // 1. Setup: remoteFolder with 3 children (fewer than pageSize 5). + // Add folder metadata to DB. + remoteFolder.children = [] + for i in 0..<3 { + let childItem = MockRemoteItem( + identifier: "fewItems-child\(i)", + name: "child\(i).txt", + remotePath: remoteFolder.remotePath + "/child\(i).txt", + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + childItem.parent = remoteFolder + remoteFolder.children.append(childItem) + } + Self.dbManager.addItemMetadata(remoteFolder.toItemMetadata(account: Self.account)) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: remoteFolder.identifier)) + + let db = Self.dbManager.ncDatabase() + debugPrint(db) + let remoteInterface = MockRemoteInterface(rootItem: rootItem, pagination: true) + + // 2. Create enumerator with pageSize > number of children. + let enumerator = Enumerator( + enumeratedItemIdentifier: NSFileProviderItemIdentifier(remoteFolder.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + pageSize: 5 + ) + let observer = MockEnumerationObserver(enumerator: enumerator) + + // 3. Enumerate items. + try await observer.enumerateItems() + + // 4. Assertions. + // Expected items: 0 (folder itself) + 3 children = 3 items. + XCTAssertEqual(observer.items.count, 3, "Should enumerate the 3 children.") + XCTAssertFalse( + observer.items.contains(where: { $0.itemIdentifier.rawValue == remoteFolder.identifier }), + "Folder itself should be enumerated." + ) + for i in 0..<3 { + XCTAssertTrue( + observer.items.contains(where: { $0.itemIdentifier.rawValue == "fewItems-child\(i)" }), + "Child item fewItems-child\(i) should be enumerated." + ) + } + + // All items fit on one page. + XCTAssertEqual( + observer.observedPages.count, + 1, + "Should be one page call as all items fit in the first page." + ) + + // Verify folder metadata in DB. + let dbFolderMetadata = + try XCTUnwrap(Self.dbManager.itemMetadata(ocId: remoteFolder.identifier)) + XCTAssertEqual(dbFolderMetadata.etag, remoteFolder.versionIdentifier) + // Ensure all children are also in the DB after enumeration + for i in 0..<3 { + XCTAssertNotNil( + Self.dbManager.itemMetadata(ocId: "fewItems-child\(i)"), + "Child item fewItems-child\(i) metadata should be in DB." + ) + } + } }