From 5f5689c526d7dd987e155949a3892f342fb08748 Mon Sep 17 00:00:00 2001 From: Lucian Cerbu Date: Thu, 3 Sep 2026 19:19:35 +0300 Subject: [PATCH] - Read the shared-folder role through Stela V2 with V1 failsafe - Send pageSize on folder-by-id reads - Load Shares once and give each screen its own spinner --- Permanent.xcodeproj/project.pbxproj | 8 + .../UIViewControllerExtension.swift | 9 +- .../Download/DownloadManagerGCD.swift | 2 + .../Common/Models/Data/FolderV2Models.swift | 7 +- .../Common/Network/FolderV2Endpoint.swift | 6 +- .../ViewController/SharesViewController.swift | 15 +- .../ViewModel/SharedFilesViewModel.swift | 8 +- .../ViewModel/FileMenuViewModel.swift | 165 ++++++++++-------- PermanentTests/FileMenuViewModelTests.swift | 139 +++++++++++++++ PermanentTests/FilesEndpointTests.swift | 6 + PermanentTests/FolderV2ModelsTests.swift | 61 +++++++ .../SharesViewControllerTests.swift | 22 +++ .../UIViewControllerSpinnerTests.swift | 66 +++++++ 13 files changed, 434 insertions(+), 80 deletions(-) create mode 100644 PermanentTests/FolderV2ModelsTests.swift create mode 100644 PermanentTests/UIViewControllerSpinnerTests.swift diff --git a/Permanent.xcodeproj/project.pbxproj b/Permanent.xcodeproj/project.pbxproj index 624e0c61..b92bfc69 100644 --- a/Permanent.xcodeproj/project.pbxproj +++ b/Permanent.xcodeproj/project.pbxproj @@ -396,6 +396,8 @@ 5E7B9BAC2FB1300200C1D3D0 /* ActivityFeedAndInviteViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7B9B9B2FB1300200C1D3D0 /* ActivityFeedAndInviteViewModelTests.swift */; }; 5E7B9BAD2FB1300200C1D3D0 /* ArrayAndSequenceExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7B9B9C2FB1300200C1D3D0 /* ArrayAndSequenceExtensionTests.swift */; }; 5E7B9BAE2FB1300200C1D3D0 /* DonateViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7B9B9D2FB1300200C1D3D0 /* DonateViewModelTests.swift */; }; + 9CBB9ACA108840A69B585109 /* UIViewControllerSpinnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39D35817F0254FCE858EC0E6 /* UIViewControllerSpinnerTests.swift */; }; + 13F57C8DB2DB4A4EB984A0BE /* FolderV2ModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDC60F3AAB1F46C3BF13FF68 /* FolderV2ModelsTests.swift */; }; 615BE4F55BBD469EBDCCA6CD /* BillingEndpointTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C26E991100BD450E8FD1389C /* BillingEndpointTests.swift */; }; 5E7B9BAF2FB1300200C1D3D0 /* GiftStorageViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7B9BA02FB1300200C1D3D0 /* GiftStorageViewModelTests.swift */; }; 5E7B9BBB2FB139DF00C1D3D0 /* OnboardingPathAndChartTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7B9BB62FB139DF00C1D3D0 /* OnboardingPathAndChartTests.swift */; }; @@ -1558,6 +1560,8 @@ 5E7B9B9B2FB1300200C1D3D0 /* ActivityFeedAndInviteViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityFeedAndInviteViewModelTests.swift; sourceTree = ""; }; 5E7B9B9C2FB1300200C1D3D0 /* ArrayAndSequenceExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayAndSequenceExtensionTests.swift; sourceTree = ""; }; 5E7B9B9D2FB1300200C1D3D0 /* DonateViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonateViewModelTests.swift; sourceTree = ""; }; + 39D35817F0254FCE858EC0E6 /* UIViewControllerSpinnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewControllerSpinnerTests.swift; sourceTree = ""; }; + CDC60F3AAB1F46C3BF13FF68 /* FolderV2ModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderV2ModelsTests.swift; sourceTree = ""; }; C26E991100BD450E8FD1389C /* BillingEndpointTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BillingEndpointTests.swift; sourceTree = ""; }; 5E7B9B9E2FB1300200C1D3D0 /* EditMetadataViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditMetadataViewModelTests.swift; sourceTree = ""; }; 5E7B9B9F2FB1300200C1D3D0 /* FABViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FABViewModelTests.swift; sourceTree = ""; }; @@ -3669,6 +3673,8 @@ 5E7B9B9B2FB1300200C1D3D0 /* ActivityFeedAndInviteViewModelTests.swift */, 5E7B9B9C2FB1300200C1D3D0 /* ArrayAndSequenceExtensionTests.swift */, 5E7B9B9D2FB1300200C1D3D0 /* DonateViewModelTests.swift */, + 39D35817F0254FCE858EC0E6 /* UIViewControllerSpinnerTests.swift */, + CDC60F3AAB1F46C3BF13FF68 /* FolderV2ModelsTests.swift */, C26E991100BD450E8FD1389C /* BillingEndpointTests.swift */, 5E7B9B9E2FB1300200C1D3D0 /* EditMetadataViewModelTests.swift */, 5E7B9B9F2FB1300200C1D3D0 /* FABViewModelTests.swift */, @@ -6197,6 +6203,8 @@ 5E7B9BAD2FB1300200C1D3D0 /* ArrayAndSequenceExtensionTests.swift in Sources */, 5E7B9BC72FB13D9000C1D3D0 /* FilePreviewViewModelTests.swift in Sources */, 5E7B9BAE2FB1300200C1D3D0 /* DonateViewModelTests.swift in Sources */, + 9CBB9ACA108840A69B585109 /* UIViewControllerSpinnerTests.swift in Sources */, + 13F57C8DB2DB4A4EB984A0BE /* FolderV2ModelsTests.swift in Sources */, 615BE4F55BBD469EBDCCA6CD /* BillingEndpointTests.swift in Sources */, 5E7B9BAF2FB1300200C1D3D0 /* GiftStorageViewModelTests.swift in Sources */, 5E7B9B7B2FB0D11B00C1D3D0 /* GiftStorageViewTests.swift in Sources */, diff --git a/Permanent/Common/Extensions/UIViewControllerExtension.swift b/Permanent/Common/Extensions/UIViewControllerExtension.swift index dbf6a9db..c7d25d27 100644 --- a/Permanent/Common/Extensions/UIViewControllerExtension.swift +++ b/Permanent/Common/Extensions/UIViewControllerExtension.swift @@ -8,9 +8,16 @@ import UIKit import SwiftUI -private var spinnerView: UIView? +private var spinnerViewKey: UInt8 = 0 extension UIViewController { + /// One overlay per screen. A single shared slot let one screen's hide remove another screen's + /// overlay, or turn its show into a no-op, leaving a spinner nothing could dismiss. + private var spinnerView: UIView? { + get { objc_getAssociatedObject(self, &spinnerViewKey) as? UIView } + set { objc_setAssociatedObject(self, &spinnerViewKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + func showSpinner(colored color: UIColor = .primary) { if spinnerView != nil { return diff --git a/Permanent/Common/Managers/Download/DownloadManagerGCD.swift b/Permanent/Common/Managers/Download/DownloadManagerGCD.swift index 4abaccdf..9e838ed7 100644 --- a/Permanent/Common/Managers/Download/DownloadManagerGCD.swift +++ b/Permanent/Common/Managers/Download/DownloadManagerGCD.swift @@ -111,6 +111,8 @@ class DownloadManagerGCD: Downloader { } } + /// Still V1: its consumers read the legacy `shareVOS` list, so moving it needs a FolderV2Data to + /// FolderVO adapter like `RecordV2Data.toRecordVOPayload()`. Stela does return pending shares to managers. func getFolder(_ file: FileDownloadInfo, then handler: @escaping GetFolderResponse) { let apiOperation = APIOperation(FilesEndpoint.getFolder(itemInfo: (file.folderLinkId, file.parentFolderLinkId))) self.operation = apiOperation diff --git a/Permanent/Common/Models/Data/FolderV2Models.swift b/Permanent/Common/Models/Data/FolderV2Models.swift index 79bfa0ca..0a0318b4 100644 --- a/Permanent/Common/Models/Data/FolderV2Models.swift +++ b/Permanent/Common/Models/Data/FolderV2Models.swift @@ -33,6 +33,9 @@ struct FolderV2Data: Model { let thumbnailUrls: ThumbnailUrlsV2? let shares: [RecordShareV2]? let pendingShares: [PendingShareV2]? + /// The caller's effective role on this folder, as Stela computes it across membership, shares, + /// share token and public access. Short form ("owner"), which `AccessRole.roleForValue` accepts. + let accessRole: String? init(folderId: String? = nil, displayName: String? = nil, @@ -52,7 +55,8 @@ struct FolderV2Data: Model { paths: FolderPathsV2? = nil, thumbnailUrls: ThumbnailUrlsV2? = nil, shares: [RecordShareV2]? = nil, - pendingShares: [PendingShareV2]? = nil) { + pendingShares: [PendingShareV2]? = nil, + accessRole: String? = nil) { self.folderId = folderId self.displayName = displayName self.size = size @@ -72,6 +76,7 @@ struct FolderV2Data: Model { self.thumbnailUrls = thumbnailUrls self.shares = shares self.pendingShares = pendingShares + self.accessRole = accessRole } } diff --git a/Permanent/Common/Network/FolderV2Endpoint.swift b/Permanent/Common/Network/FolderV2Endpoint.swift index dbb540ab..a82a8c52 100644 --- a/Permanent/Common/Network/FolderV2Endpoint.swift +++ b/Permanent/Common/Network/FolderV2Endpoint.swift @@ -38,9 +38,9 @@ extension FolderV2Endpoint: RequestProtocol { let baseURL = APIEnvironment.defaultEnv.apiServer switch self { case .getFolderById(let folderId, _): - // Canonical plural route. The singular `/folder` form is a deprecated - // backend alias that hits the same handler; we use the documented one. - return "\(baseURL)api/v2/folders?folderIds[]=\(folderId)" + // Canonical plural route; the singular `/folder` alias is deprecated. `pageSize` is required even for + // one id (400 without it); 9999 also covers a future multi-id call without paging. + return "\(baseURL)api/v2/folders?folderIds[]=\(folderId)&pageSize=9999" case .getFolderChildren(let folderId, _, let pageSize): return "\(baseURL)api/v2/folders/\(folderId)/children?pageSize=\(pageSize)" } diff --git a/Permanent/Modules/Shares/ViewController/SharesViewController.swift b/Permanent/Modules/Shares/ViewController/SharesViewController.swift index 6aecf3ec..0abec7fc 100644 --- a/Permanent/Modules/Shares/ViewController/SharesViewController.swift +++ b/Permanent/Modules/Shares/ViewController/SharesViewController.swift @@ -48,6 +48,9 @@ class SharesViewController: BaseViewController { private var isGridView = false private var sortActionSheet: SortActionSheet? private var sharesRefreshRequestId = UUID() + /// Archive whose share list is loading right now, nil once it lands. A second fetch for the same archive + /// would supersede this one in the view model, and the spinner would then wait on the duplicate. + private var inFlightSharesArchiveId: Int? override func viewDidLoad() { super.viewDidLoad() @@ -300,15 +303,21 @@ class SharesViewController: BaseViewController { private func syncSharesForCurrentArchive() { guard viewModel != nil else { return } - if loadedArchiveId != sessionArchiveId { + if Self.shouldFetchShares(loadedArchiveId: loadedArchiveId, sessionArchiveId: sessionArchiveId, inFlightArchiveId: inFlightSharesArchiveId) { getShares(shouldShowSpinner: true) - } else if needsCollectionViewReloadOnAppear { + } else if loadedArchiveId == sessionArchiveId, needsCollectionViewReloadOnAppear { needsCollectionViewReloadOnAppear = false collectionView.reloadData() configureCollectionViewBgView() } } + /// The list needs a fetch when it shows another archive than the selected one and no fetch for the + /// selected archive is already in flight. `viewDidLoad` starts the first load; `viewWillAppear` must not repeat it. + static func shouldFetchShares(loadedArchiveId: Int?, sessionArchiveId: Int?, inFlightArchiveId: Int?) -> Bool { + loadedArchiveId != sessionArchiveId && inFlightArchiveId != sessionArchiveId + } + fileprivate func refreshCollectionView(_ completion: (() -> ())? = nil) { collectionView.reloadData() configureCollectionViewBgView() @@ -1224,10 +1233,12 @@ class SharesViewController: BaseViewController { let requestId = UUID() sharesRefreshRequestId = requestId + inFlightSharesArchiveId = sessionArchiveId runRequest({ status in guard self.sharesRefreshRequestId == requestId else { return } + self.inFlightSharesArchiveId = nil self.hideSpinner() switch status { case .success: diff --git a/Permanent/Modules/Shares/ViewModel/SharedFilesViewModel.swift b/Permanent/Modules/Shares/ViewModel/SharedFilesViewModel.swift index 8cd0100c..96db669a 100644 --- a/Permanent/Modules/Shares/ViewModel/SharedFilesViewModel.swift +++ b/Permanent/Modules/Shares/ViewModel/SharedFilesViewModel.swift @@ -70,11 +70,13 @@ class SharedFilesViewModel: FilesViewModel { let apiOperation = APIOperation(ShareEndpoint.getShares) - apiOperation.execute(in: APIRequestDispatcher()) { result in + apiOperation.execute(in: APIRequestDispatcher()) { [weak self] result in DispatchQueue.main.async { + guard let self = self else { return handler(.error(message: .errorMessage)) } // A superseded fetch must not touch published state: a newer request is already - // in flight and its response is the authoritative one. - guard generation == self.sharesRequestGeneration else { return } + // in flight and its response is the authoritative one. Still call back so the caller + // can release its spinner — a stranded spinner is worse than a redundant hide. + guard generation == self.sharesRequestGeneration else { return handler(.success) } switch result { case .json(let response, _): diff --git a/Permanent/ViewModels/ViewModel/FileMenuViewModel.swift b/Permanent/ViewModels/ViewModel/FileMenuViewModel.swift index baaf6146..f00a5efd 100644 --- a/Permanent/ViewModels/ViewModel/FileMenuViewModel.swift +++ b/Permanent/ViewModels/ViewModel/FileMenuViewModel.swift @@ -238,96 +238,121 @@ class FileMenuViewModel: ObservableObject { } // MARK: - Access Role Update + + /// Test seam for the V2 folder read behind the role refresh. Production leaves it nil. + var folderFetchV2Request: ((String, @escaping (OperationResult) -> Void) -> Void)? + + #if DEBUG + /// Which path served the last role refresh, so a test cannot pass on the V1 failsafe while + /// claiming V2. DEBUG-only, with no Release behaviour. + static var lastAccessRoleSource = "none" + #endif + func fetchUpdatedAccessRole() { guard showArchiveInfo, fileViewModel.sharedByArchive != nil else { return } - + + // Folders read the caller's role from Stela. Records, and folders without a folderId, stay on V1. + if fileViewModel.type.isFolder, fileViewModel.folderId > 0 { + fetchFolderAccessRoleV2(folderId: String(fileViewModel.folderId)) + } else { + fetchUpdatedAccessRoleV1() + } + } + + /// GET /api/v2/folders?folderIds[]= carries the caller's effective role. Anything short of a decoded + /// role falls through to V1, so a V2 hiccup never leaves the badge stale. + private func fetchFolderAccessRoleV2(folderId: String) { + let fetch: (String, @escaping (OperationResult) -> Void) -> Void = folderFetchV2Request ?? { folderId, completion in + let operation = APIOperation(FolderV2Endpoint.getFolderById(folderId: folderId, shareToken: "")) + operation.execute(in: APIRequestDispatcher()) { completion($0) } + } + + fetch(folderId) { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + + guard case .json(let response, _) = result, + let model: FolderV2Response = JSONHelper.decoding(from: response, with: FolderV2Response.decoder), + let accessRole = model.items?.first?.accessRole else { + self.fetchUpdatedAccessRoleV1() + return + } + + #if DEBUG + Self.lastAccessRoleSource = "v2" + #endif + self.applyAccessRole(accessRole) + } + } + } + + private func fetchUpdatedAccessRoleV1() { + #if DEBUG + Self.lastAccessRoleSource = "v1" + #endif + let itemInfo = (folderLinkId: fileViewModel.folderLinkId, parentFolderLinkId: fileViewModel.parentFolderLinkId) - + let endpoint: FilesEndpoint if fileViewModel.type.isFolder { endpoint = FilesEndpoint.getFolder(itemInfo: itemInfo) } else { endpoint = FilesEndpoint.getRecord(itemInfo: itemInfo) } - + let apiOperation = APIOperation(endpoint) - + apiOperation.execute(in: APIRequestDispatcher()) { [weak self] result in guard let self = self else { return } - + DispatchQueue.main.async { - switch result { - case .json(let response, _): - if self.fileViewModel.type.isFolder { - guard let model: APIResults = JSONHelper.decoding( - from: response, - with: APIResults.decoder - ), - model.isSuccessful, - let folderData = model.results.first?.data?.first?.folderVO else { - return - } - - var accessRoleString: String? - if let accessVO = folderData.accessVO?.value as? [String: Any], - let role = accessVO["accessRole"] as? String { - accessRoleString = role - } else if let role = folderData.accessRole { - accessRoleString = role - } - - guard let accessRoleString = accessRoleString else { - return - } - - let newAccessRole = AccessRole.roleForValue(accessRoleString) - if self.fileViewModel.accessRole != newAccessRole { - self.fileViewModel.accessRole = newAccessRole - self.updatePermissions(forAccessRole: accessRoleString) - self.regenerateMenuItemsAndAnimateHeight() - } - } else { - guard let model: APIResults = JSONHelper.decoding( - from: response, - with: APIResults.decoder - ), - model.isSuccessful, - let recordData = model.results.first?.data?.first?.recordVO else { - return - } - - var accessRoleString: String? - if let accessVO = recordData.accessVO?.value as? [String: Any], - let role = accessVO["accessRole"] as? String { - accessRoleString = role - } else if let role = recordData.accessRole { - accessRoleString = role - } - - guard let accessRoleString = accessRoleString else { - return - } - - let newAccessRole = AccessRole.roleForValue(accessRoleString) - if self.fileViewModel.accessRole != newAccessRole { - self.fileViewModel.accessRole = newAccessRole - self.updatePermissions(forAccessRole: accessRoleString) - self.regenerateMenuItemsAndAnimateHeight() - } + guard case .json(let response, _) = result else { return } + + let accessRoleString: String? + if self.fileViewModel.type.isFolder { + guard let model: APIResults = JSONHelper.decoding(from: response, with: APIResults.decoder), + model.isSuccessful, + let folderData = model.results.first?.data?.first?.folderVO else { + return } - - case .error: - break - - default: - break + accessRoleString = Self.accessRoleString(accessVO: folderData.accessVO, accessRole: folderData.accessRole) + } else { + guard let model: APIResults = JSONHelper.decoding(from: response, with: APIResults.decoder), + model.isSuccessful, + let recordData = model.results.first?.data?.first?.recordVO else { + return + } + accessRoleString = Self.accessRoleString(accessVO: recordData.accessVO, accessRole: recordData.accessRole) + } + + if let accessRoleString { + self.applyAccessRole(accessRoleString) } } } } - + + /// V1 sends the role either nested in `accessVO` or flat; the nested one wins when both are present. + private static func accessRoleString(accessVO: JSONAny?, accessRole: String?) -> String? { + if let accessVO = accessVO?.value as? [String: Any], + let role = accessVO["accessRole"] as? String { + return role + } + return accessRole + } + + /// Shared tail of both reads: only a changed role touches permissions and the menu. + private func applyAccessRole(_ accessRoleString: String) { + let newAccessRole = AccessRole.roleForValue(accessRoleString) + guard fileViewModel.accessRole != newAccessRole else { return } + + fileViewModel.accessRole = newAccessRole + updatePermissions(forAccessRole: accessRoleString) + regenerateMenuItemsAndAnimateHeight() + } + private func updatePermissions(forAccessRole accessRoleString: String) { let newPermissions = ArchiveVOData.permissions(forAccessRole: accessRoleString) self.fileViewModel.permissions = newPermissions diff --git a/PermanentTests/FileMenuViewModelTests.swift b/PermanentTests/FileMenuViewModelTests.swift index ae9616d7..cdcf6b3a 100644 --- a/PermanentTests/FileMenuViewModelTests.swift +++ b/PermanentTests/FileMenuViewModelTests.swift @@ -901,6 +901,145 @@ final class FileMenuViewModelTests: XCTestCase { // MARK: - Helpers + // MARK: - Access role refresh (V2 first, V1 failsafe) + + /// A shared folder with a real folderId, built from a V2 child so `folderId` is set. + private func makeSharedFolderModel(folderId: String = "55") -> FileModel { + let json = """ + {"folderId": "\(folderId)", "displayName": "Shared", "type": "type.folder.private", "folderLinkId": "7", "archiveId": "3"} + """ + let child = try! FolderChildV2Data.decoder.decode(FolderChildV2Data.self, from: Data(json.utf8)) + var fm = FileModel(model: child, permissions: [.read], accessRole: .viewer) + fm.sharedByArchive = MinArchiveVO(name: "Family Archive", thumbnail: nil, shareStatus: "", shareId: 0, archiveID: 42, folderLinkID: nil, accessRole: nil) + return fm + } + + private func folderResponse(accessRole: String?) -> Any { + var folder: [String: Any] = ["id": "55", "folderId": "55", "displayName": "Shared"] + if let accessRole { folder["accessRole"] = accessRole } + return ["items": [folder]] + } + + func testFetchUpdatedAccessRole_SharedFolder_ReadsRoleFromV2() { + FileMenuViewModel.lastAccessRoleSource = "none" + let sut = FileMenuViewModel(fileViewModel: makeSharedFolderModel(), menuItems: [], showArchiveInfo: true, onDismiss: {}) + var requestedFolderId: String? + sut.folderFetchV2Request = { folderId, completion in + requestedFolderId = folderId + completion(.json(self.folderResponse(accessRole: "editor"), nil)) + } + + sut.fetchUpdatedAccessRole() + let done = expectation(description: "main hop") + DispatchQueue.main.async { done.fulfill() } + wait(for: [done], timeout: 1.0) + + XCTAssertEqual(requestedFolderId, "55") + XCTAssertEqual(sut.fileViewModel.accessRole, .editor) + XCTAssertEqual(sut.fileViewModel.permissions, ArchiveVOData.permissions(forAccessRole: "editor")) + XCTAssertEqual(FileMenuViewModel.lastAccessRoleSource, "v2") + } + + func testFetchUpdatedAccessRole_SharedFolder_V1RoleFormFromV2StillParses() { + let sut = FileMenuViewModel(fileViewModel: makeSharedFolderModel(), menuItems: [], showArchiveInfo: true, onDismiss: {}) + sut.folderFetchV2Request = { _, completion in + completion(.json(self.folderResponse(accessRole: "access.role.curator"), nil)) + } + + sut.fetchUpdatedAccessRole() + let done = expectation(description: "main hop") + DispatchQueue.main.async { done.fulfill() } + wait(for: [done], timeout: 1.0) + + XCTAssertEqual(sut.fileViewModel.accessRole, .curator) + } + + func testFetchUpdatedAccessRole_SharedFolder_SameRole_LeavesPermissionsAlone() { + let sut = FileMenuViewModel(fileViewModel: makeSharedFolderModel(), menuItems: [], showArchiveInfo: true, onDismiss: {}) + sut.folderFetchV2Request = { _, completion in + completion(.json(self.folderResponse(accessRole: "viewer"), nil)) + } + + sut.fetchUpdatedAccessRole() + let done = expectation(description: "main hop") + DispatchQueue.main.async { done.fulfill() } + wait(for: [done], timeout: 1.0) + + XCTAssertEqual(sut.fileViewModel.accessRole, .viewer) + XCTAssertEqual(sut.fileViewModel.permissions, [.read]) + } + + func testFetchUpdatedAccessRole_SharedFolder_V2Error_FallsBackToV1() { + FileMenuViewModel.lastAccessRoleSource = "none" + let sut = FileMenuViewModel(fileViewModel: makeSharedFolderModel(), menuItems: [], showArchiveInfo: true, onDismiss: {}) + sut.folderFetchV2Request = { _, completion in + completion(.error(APIError.unknown, nil)) + } + + sut.fetchUpdatedAccessRole() + let done = expectation(description: "main hop") + DispatchQueue.main.async { done.fulfill() } + wait(for: [done], timeout: 1.0) + + XCTAssertEqual(FileMenuViewModel.lastAccessRoleSource, "v1") + XCTAssertEqual(sut.fileViewModel.accessRole, .viewer) + } + + func testFetchUpdatedAccessRole_SharedFolder_V2WithoutRole_FallsBackToV1() { + FileMenuViewModel.lastAccessRoleSource = "none" + let sut = FileMenuViewModel(fileViewModel: makeSharedFolderModel(), menuItems: [], showArchiveInfo: true, onDismiss: {}) + sut.folderFetchV2Request = { _, completion in + completion(.json(self.folderResponse(accessRole: nil), nil)) + } + + sut.fetchUpdatedAccessRole() + let done = expectation(description: "main hop") + DispatchQueue.main.async { done.fulfill() } + wait(for: [done], timeout: 1.0) + + XCTAssertEqual(FileMenuViewModel.lastAccessRoleSource, "v1") + } + + func testFetchUpdatedAccessRole_FolderWithoutFolderId_UsesV1Directly() { + FileMenuViewModel.lastAccessRoleSource = "none" + var fm = makeFolderModel() + fm.sharedByArchive = MinArchiveVO(name: "Family Archive", thumbnail: nil, shareStatus: "", shareId: 0, archiveID: 42, folderLinkID: nil, accessRole: nil) + let sut = FileMenuViewModel(fileViewModel: fm, menuItems: [], showArchiveInfo: true, onDismiss: {}) + var v2Called = false + sut.folderFetchV2Request = { _, _ in v2Called = true } + + sut.fetchUpdatedAccessRole() + + XCTAssertFalse(v2Called) + XCTAssertEqual(FileMenuViewModel.lastAccessRoleSource, "v1") + } + + func testFetchUpdatedAccessRole_Record_UsesV1Directly() { + FileMenuViewModel.lastAccessRoleSource = "none" + var fm = makeFileModel() + fm.sharedByArchive = MinArchiveVO(name: "Family Archive", thumbnail: nil, shareStatus: "", shareId: 0, archiveID: 42, folderLinkID: nil, accessRole: nil) + let sut = FileMenuViewModel(fileViewModel: fm, menuItems: [], showArchiveInfo: true, onDismiss: {}) + var v2Called = false + sut.folderFetchV2Request = { _, _ in v2Called = true } + + sut.fetchUpdatedAccessRole() + + XCTAssertFalse(v2Called) + XCTAssertEqual(FileMenuViewModel.lastAccessRoleSource, "v1") + } + + func testFetchUpdatedAccessRole_NotShared_DoesNothing() { + FileMenuViewModel.lastAccessRoleSource = "none" + let sut = FileMenuViewModel(fileViewModel: makeFolderModel(), menuItems: [], showArchiveInfo: true, onDismiss: {}) + var v2Called = false + sut.folderFetchV2Request = { _, _ in v2Called = true } + + sut.fetchUpdatedAccessRole() + + XCTAssertFalse(v2Called) + XCTAssertEqual(FileMenuViewModel.lastAccessRoleSource, "none") + } + private func makeFileModel(name: String = "Test.pdf") -> FileModel { FileModel( name: name, diff --git a/PermanentTests/FilesEndpointTests.swift b/PermanentTests/FilesEndpointTests.swift index f3f01c22..3c4d7345 100644 --- a/PermanentTests/FilesEndpointTests.swift +++ b/PermanentTests/FilesEndpointTests.swift @@ -49,6 +49,12 @@ final class FilesEndpointTests: XCTestCase { XCTAssertTrue(url.contains("api/v2/folders?folderIds[]=42"), url) } + func testFolderV2_GetById_SendsTheRequiredPageSize() { + // The server rejects the query without `pageSize` ("pageSize" is required, HTTP 400). + let url = FolderV2Endpoint.getFolderById(folderId: "42", shareToken: "").customURL ?? "" + XCTAssertTrue(url.hasSuffix("api/v2/folders?folderIds[]=42&pageSize=9999"), url) + } + func testFolderV2_EmptyShareToken_ResolvesToNil() { // Private Files passes no share token → bearer-token auth only. XCTAssertNil(FolderV2Endpoint.getFolderChildren(folderId: "1", shareToken: "", pageSize: 1).shareToken) diff --git a/PermanentTests/FolderV2ModelsTests.swift b/PermanentTests/FolderV2ModelsTests.swift new file mode 100644 index 00000000..ed842d38 --- /dev/null +++ b/PermanentTests/FolderV2ModelsTests.swift @@ -0,0 +1,61 @@ +// +// FolderV2ModelsTests.swift +// PermanentTests +// +// Created by Lucian Cerbu on 02.09.2026. +// + +import XCTest +@testable import Permanent + +final class FolderV2ModelsTests: XCTestCase { + + private func decode(_ json: String) throws -> FolderV2Response { + try FolderV2Response.decoder.decode(FolderV2Response.self, from: Data(json.utf8)) + } + + // MARK: - accessRole + + func testFolder_DecodesCallerAccessRole() throws { + let response = try decode(""" + {"items": [{"id": "55", "folderId": "55", "displayName": "Shared", "accessRole": "owner"}]} + """) + XCTAssertEqual(response.items?.first?.accessRole, "owner") + XCTAssertEqual(AccessRole.roleForValue(response.items?.first?.accessRole), .owner) + } + + func testFolder_MissingAccessRole_DecodesToNil() throws { + let response = try decode(""" + {"items": [{"id": "55", "folderId": "55", "displayName": "Shared"}]} + """) + XCTAssertNotNil(response.items?.first) + XCTAssertNil(response.items?.first?.accessRole) + } + + // MARK: - shares + + func testFolder_DecodesSharesWithStatusAndArchive() throws { + let response = try decode(""" + {"items": [{"id": "55", "accessRole": "viewer", + "shares": [{"id": "9", "accessRole": "editor", "status": "ok", "archive": {"id": "3", "name": "Family"}}]}]} + """) + let share = response.items?.first?.shares?.first + XCTAssertEqual(share?.shareId, "9") + XCTAssertEqual(share?.accessRole, "editor") + XCTAssertEqual(share?.status, "ok") + XCTAssertEqual(share?.archive?.name, "Family") + } + + func testFolder_EmptyItems_DecodesToEmptyList() throws { + let response = try decode(""" + {"items": []} + """) + XCTAssertEqual(response.items?.count, 0) + } + + func testFolderV2Data_MemberwiseInit_DefaultsAccessRoleToNil() { + let folder = FolderV2Data(folderId: "1", displayName: "F") + XCTAssertNil(folder.accessRole) + XCTAssertEqual(FolderV2Data(folderId: "1", accessRole: "manager").accessRole, "manager") + } +} diff --git a/PermanentTests/SharesViewControllerTests.swift b/PermanentTests/SharesViewControllerTests.swift index 72b8ac45..c609bab6 100644 --- a/PermanentTests/SharesViewControllerTests.swift +++ b/PermanentTests/SharesViewControllerTests.swift @@ -498,6 +498,28 @@ final class SharesViewControllerTests: XCTestCase { XCTAssertFalse(vc.fabView.isHidden, "deselect restores the FAB when permissions allow") } + // MARK: - First-open double fetch + + func testShouldFetchShares_NothingLoaded_NothingInFlight_Fetches() { + XCTAssertTrue(SharesViewController.shouldFetchShares(loadedArchiveId: nil, sessionArchiveId: 42, inFlightArchiveId: nil)) + } + + func testShouldFetchShares_SameArchiveAlreadyInFlight_DoesNotFetchAgain() { + XCTAssertFalse(SharesViewController.shouldFetchShares(loadedArchiveId: nil, sessionArchiveId: 42, inFlightArchiveId: 42)) + } + + func testShouldFetchShares_OtherArchiveInFlight_FetchesTheSelectedOne() { + XCTAssertTrue(SharesViewController.shouldFetchShares(loadedArchiveId: 7, sessionArchiveId: 42, inFlightArchiveId: 7)) + } + + func testShouldFetchShares_SelectedArchiveAlreadyLoaded_DoesNotFetch() { + XCTAssertFalse(SharesViewController.shouldFetchShares(loadedArchiveId: 42, sessionArchiveId: 42, inFlightArchiveId: nil)) + } + + func testShouldFetchShares_ArchiveSwitchedAfterLoad_Fetches() { + XCTAssertTrue(SharesViewController.shouldFetchShares(loadedArchiveId: 7, sessionArchiveId: 42, inFlightArchiveId: nil)) + } + private func makeController() -> SharesViewController { let vc = SharesViewController() vc.viewModel = MockSharedFilesViewModel() diff --git a/PermanentTests/UIViewControllerSpinnerTests.swift b/PermanentTests/UIViewControllerSpinnerTests.swift new file mode 100644 index 00000000..16940168 --- /dev/null +++ b/PermanentTests/UIViewControllerSpinnerTests.swift @@ -0,0 +1,66 @@ +// +// UIViewControllerSpinnerTests.swift +// PermanentTests +// +// Created by Lucian Cerbu on 03.09.2026. +// + +import XCTest +@testable import Permanent + +@MainActor +final class UIViewControllerSpinnerTests: XCTestCase { + + private func makeController() -> UIViewController { + let controller = UIViewController() + controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) + return controller + } + + // showSpinner adds exactly one overlay subview to the screen's own view; nothing else here adds subviews. + private func overlayCount(_ controller: UIViewController) -> Int { + controller.view.subviews.count + } + + func testShowSpinner_AddsOneOverlay_AndIgnoresRepeats() { + let controller = makeController() + + controller.showSpinner() + controller.showSpinner() + + XCTAssertEqual(overlayCount(controller), 1) + } + + func testShowSpinner_OnTwoScreens_GivesEachItsOwnOverlay() { + let first = makeController() + let second = makeController() + + first.showSpinner() + second.showSpinner() + + XCTAssertEqual(overlayCount(first), 1) + XCTAssertEqual(overlayCount(second), 1, "The second screen must not be blocked by the first screen's spinner") + } + + func testHideSpinner_OnOneScreen_LeavesTheOtherScreenAlone() { + let first = makeController() + let second = makeController() + first.showSpinner() + second.showSpinner() + + first.hideSpinner() + + // The hide fades out, so the overlay is detached only after the animation; the slot is freed at once. + first.showSpinner() + XCTAssertGreaterThanOrEqual(overlayCount(first), 1) + XCTAssertEqual(overlayCount(second), 1, "Hiding one screen's spinner must not remove another screen's overlay") + } + + func testHideSpinner_WithoutShow_DoesNothing() { + let controller = makeController() + + controller.hideSpinner() + + XCTAssertEqual(overlayCount(controller), 0) + } +}