diff --git a/CHANGELOG.md b/CHANGELOG.md index 80b33d8..63d023d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.5.3] - 2026-4-17 +### Bug fixes +- Fixed a crash that could happen during file uploads. + ## [1.5.2] - 2025-9-5 ### Bug fixes - Added an API call that tells Jamf Pro to update the inventory after uploading to a Cloud DP (GitHub issue #16 "Packages uploaded through Jamf Sync sometimes do not install from a policy", #41 "Packages failing on install when syncing with 1.3.3+", #63 "The packages uploaded to S3 cloud DP via Jamf Sync couldn't be downloaded", #66 "Where does JamfSync store calculated Checksums for destination?"). diff --git a/Jamf Sync.xcodeproj/project.pbxproj b/Jamf Sync.xcodeproj/project.pbxproj index 2590edf..3b063c4 100644 --- a/Jamf Sync.xcodeproj/project.pbxproj +++ b/Jamf Sync.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + 1FE12F34F420D4E74BE3E1B9 /* Jcds2DpAdditionalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B0BB8F2E75BB97315FCFFDE /* Jcds2DpAdditionalTests.swift */; }; + 2AD32CC765B804C67356E7F4 /* GeneralCloudDpTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20024D5A84FE240AE8C05324 /* GeneralCloudDpTests.swift */; }; + 41E403B4E163AE45FD580F6E /* ArgumentParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C54B369474FC396199FB982 /* ArgumentParserTests.swift */; }; + 63F41799AC5DE918435399D1 /* ChecksumsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D89812BAC4405E98A15D1CCB /* ChecksumsTests.swift */; }; 840A79012ACB6E8200161D85 /* SaveableItemListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840A79002ACB6E8200161D85 /* SaveableItemListView.swift */; }; 840A79032ACB75FC00161D85 /* SavableItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840A79022ACB75FC00161D85 /* SavableItem.swift */; }; 840A79072ACC8EFF00161D85 /* FolderInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840A79062ACC8EFF00161D85 /* FolderInstance.swift */; }; @@ -47,6 +51,8 @@ 849809DB2CB8575B001F94C9 /* UploadTimeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849809DA2CB8575B001F94C9 /* UploadTimeTests.swift */; }; 849FC3412BD06A43008BAC02 /* VersionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849FC3402BD06A43008BAC02 /* VersionInfo.swift */; }; 84AB59C52B20D569007333AD /* CloudSessionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AB59C42B20D569007333AD /* CloudSessionDelegate.swift */; }; + 84B084F82F92C83D000713CC /* DpFileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B084F72F92C83D000713CC /* DpFileTests.swift */; }; + 84B084F92F92C83D000713CC /* DpFilesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B084F62F92C83D000713CC /* DpFilesTests.swift */; }; 84B967642B74157C00D73F75 /* HelpMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B967632B74157C00D73F75 /* HelpMenu.swift */; }; 84BA65E32AD057E10058D291 /* SavableItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84BA65E22AD057E10058D291 /* SavableItems.swift */; }; 84BC6DF42AC3122100CF6D39 /* JamfSyncApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84BC6DF32AC3122100CF6D39 /* JamfSyncApp.swift */; }; @@ -71,6 +77,8 @@ 84BF5E3A2CC15FD3008B07A1 /* TemporaryFileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84BF5E392CC15FB4008B07A1 /* TemporaryFileManager.swift */; }; 84BF61A92CC93DFB008B07A1 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84BF61A82CC93DFB008B07A1 /* SettingsView.swift */; }; 84BF61AB2CC94DD5008B07A1 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84BF61AA2CC94DD5008B07A1 /* SettingsViewModel.swift */; }; + 84C0A1012FA100000000AA01 /* MultipartUploadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C0A1002FA100000000AA01 /* MultipartUploadTests.swift */; }; + 84C0A1032FA100000000AA03 /* MockURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C0A1022FA100000000AA02 /* MockURLSession.swift */; }; 84CAB0E52C25C47400582D59 /* FileManager+moveRetainingPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84CAB0E42C25C47400582D59 /* FileManager+moveRetainingPermissions.swift */; }; 84CCB5D22B4C852F00328291 /* SetupViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84CCB5D12B4C852F00328291 /* SetupViewModel.swift */; }; 84CCB5D42B4C946200328291 /* LogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84CCB5D32B4C946200328291 /* LogViewModel.swift */; }; @@ -104,6 +112,8 @@ 84FC416A2AD898B000DCB033 /* Haversack in Frameworks */ = {isa = PBXBuildFile; productRef = 84FC41692AD898B000DCB033 /* Haversack */; }; 84FC418B2ADD7B1000DCB033 /* StoredSettings.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 84FC41892ADD7B1000DCB033 /* StoredSettings.xcdatamodeld */; }; 84FC418E2ADD89AD00DCB033 /* DataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FC418D2ADD89AD00DCB033 /* DataManager.swift */; }; + 9E5386D659B8B8403D02E32F /* TestingUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC5A5F6388C187307AD3EBCC /* TestingUtility.swift */; }; + A1B2C3D4E5F60718293A4B5C /* SynchronizationProgressTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5D /* SynchronizationProgressTests.swift */; }; CE9899B32C9A7A77000C36B7 /* KeepAwake.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE9899B22C9A7A77000C36B7 /* KeepAwake.swift */; }; CE9899B72C9A8C4B000C36B7 /* CompletedChunk.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE9899B62C9A8C4B000C36B7 /* CompletedChunk.swift */; }; /* End PBXBuildFile section */ @@ -127,7 +137,10 @@ /* Begin PBXFileReference section */ 1829A6AA7D2850AF23277749 /* FileManagerMoveRetainingPermissionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManagerMoveRetainingPermissionsTests.swift; sourceTree = ""; }; + 20024D5A84FE240AE8C05324 /* GeneralCloudDpTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralCloudDpTests.swift; sourceTree = ""; }; + 3C54B369474FC396199FB982 /* ArgumentParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArgumentParserTests.swift; sourceTree = ""; }; 4154F4CC0D4B5B8025ECF9D1 /* FileHashTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileHashTests.swift; sourceTree = ""; }; + 7B0BB8F2E75BB97315FCFFDE /* Jcds2DpAdditionalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Jcds2DpAdditionalTests.swift; sourceTree = ""; }; 840A79002ACB6E8200161D85 /* SaveableItemListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveableItemListView.swift; sourceTree = ""; }; 840A79022ACB75FC00161D85 /* SavableItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavableItem.swift; sourceTree = ""; }; 840A79062ACC8EFF00161D85 /* FolderInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderInstance.swift; sourceTree = ""; }; @@ -168,6 +181,8 @@ 849809DA2CB8575B001F94C9 /* UploadTimeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadTimeTests.swift; sourceTree = ""; }; 849FC3402BD06A43008BAC02 /* VersionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionInfo.swift; sourceTree = ""; }; 84AB59C42B20D569007333AD /* CloudSessionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudSessionDelegate.swift; sourceTree = ""; }; + 84B084F62F92C83D000713CC /* DpFilesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DpFilesTests.swift; sourceTree = ""; }; + 84B084F72F92C83D000713CC /* DpFileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DpFileTests.swift; sourceTree = ""; }; 84B967632B74157C00D73F75 /* HelpMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelpMenu.swift; sourceTree = ""; }; 84BA65E22AD057E10058D291 /* SavableItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavableItems.swift; sourceTree = ""; }; 84BC6DF02AC3122100CF6D39 /* Jamf Sync.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Jamf Sync.app"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -196,6 +211,8 @@ 84BF5E392CC15FB4008B07A1 /* TemporaryFileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryFileManager.swift; sourceTree = ""; }; 84BF61A82CC93DFB008B07A1 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 84BF61AA2CC94DD5008B07A1 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; + 84C0A1002FA100000000AA01 /* MultipartUploadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipartUploadTests.swift; sourceTree = ""; }; + 84C0A1022FA100000000AA02 /* MockURLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockURLSession.swift; sourceTree = ""; }; 84CAB0E42C25C47400582D59 /* FileManager+moveRetainingPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+moveRetainingPermissions.swift"; sourceTree = ""; }; 84CCB5D12B4C852F00328291 /* SetupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupViewModel.swift; sourceTree = ""; }; 84CCB5D32B4C946200328291 /* LogViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogViewModel.swift; sourceTree = ""; }; @@ -227,9 +244,12 @@ 84FC41652AD895F400DCB033 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = ""; }; 84FC418A2ADD7B1000DCB033 /* StoredSettings.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = StoredSettings.xcdatamodel; sourceTree = ""; }; 84FC418D2ADD89AD00DCB033 /* DataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataManager.swift; sourceTree = ""; }; + A1B2C3D4E5F60718293A4B5D /* SynchronizationProgressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynchronizationProgressTests.swift; sourceTree = ""; }; CE9899B22C9A7A77000C36B7 /* KeepAwake.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeepAwake.swift; sourceTree = ""; }; CE9899B62C9A8C4B000C36B7 /* CompletedChunk.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CompletedChunk.swift; sourceTree = ""; }; CMDLINE123456789ABCDEF0 /* CommandLineProcessingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandLineProcessingTests.swift; sourceTree = ""; }; + D89812BAC4405E98A15D1CCB /* ChecksumsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChecksumsTests.swift; sourceTree = ""; }; + FC5A5F6388C187307AD3EBCC /* TestingUtility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestingUtility.swift; sourceTree = ""; }; TEMPFILE123456789ABCDEF0 /* TemporaryFileManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryFileManagerTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -263,11 +283,17 @@ 8422AF622F60846E0019C2DE /* Model */ = { isa = PBXGroup; children = ( + 84B084F62F92C83D000713CC /* DpFilesTests.swift */, + 84B084F72F92C83D000713CC /* DpFileTests.swift */, + D89812BAC4405E98A15D1CCB /* ChecksumsTests.swift */, 846499D42B64268A00A8EA7B /* DistributionPointTests.swift */, 846499E12B6AA7C300A8EA7B /* FileShareDpTests.swift */, 846499CE2B630D7700A8EA7B /* FolderDpTests.swift */, + 20024D5A84FE240AE8C05324 /* GeneralCloudDpTests.swift */, + 7B0BB8F2E75BB97315FCFFDE /* Jcds2DpAdditionalTests.swift */, 846499E72B6BFCA900A8EA7B /* Jcds2DpTests.swift */, 846499E32B6B080B00A8EA7B /* SynchronizeTaskTests.swift */, + A1B2C3D4E5F60718293A4B5D /* SynchronizationProgressTests.swift */, ); path = Model; sourceTree = ""; @@ -275,11 +301,12 @@ 8422B1232F64AE9B0019C2DE /* Utility */ = { isa = PBXGroup; children = ( + 3C54B369474FC396199FB982 /* ArgumentParserTests.swift */, CMDLINE123456789ABCDEF0 /* CommandLineProcessingTests.swift */, - TEMPFILE123456789ABCDEF0 /* TemporaryFileManagerTests.swift */, - 841227A02BEADD6E0097B83E /* XmlErrorParserTests.swift */, 4154F4CC0D4B5B8025ECF9D1 /* FileHashTests.swift */, 1829A6AA7D2850AF23277749 /* FileManagerMoveRetainingPermissionsTests.swift */, + TEMPFILE123456789ABCDEF0 /* TemporaryFileManagerTests.swift */, + 841227A02BEADD6E0097B83E /* XmlErrorParserTests.swift */, ); path = Utility; sourceTree = ""; @@ -288,6 +315,7 @@ isa = PBXGroup; children = ( 849809DA2CB8575B001F94C9 /* UploadTimeTests.swift */, + 84C0A1002FA100000000AA01 /* MultipartUploadTests.swift */, ); path = Multipart; sourceTree = ""; @@ -299,6 +327,7 @@ 846499E52B6B089200A8EA7B /* MockDistributionPointSync.swift */, 846499D02B631B6A00A8EA7B /* MockFileManager.swift */, 846499DF2B699FB500A8EA7B /* MockJamfProInstance.swift */, + 84C0A1022FA100000000AA02 /* MockURLSession.swift */, ); path = Mocks; sourceTree = ""; @@ -448,6 +477,7 @@ 84FC41652AD895F400DCB033 /* KeychainHelper.swift */, 8468917D2BCEC4BB00B9FCA4 /* OutputStream_write.swift */, 84BF5E392CC15FB4008B07A1 /* TemporaryFileManager.swift */, + FC5A5F6388C187307AD3EBCC /* TestingUtility.swift */, 84DD583A2BC5C2A700E8DA23 /* URL+isDirectory.swift */, 84E489982B5AC80600FFFE59 /* UserSettings.swift */, 849FC3402BD06A43008BAC02 /* VersionInfo.swift */, @@ -673,6 +703,7 @@ 840A79012ACB6E8200161D85 /* SaveableItemListView.swift in Sources */, 84BC6E492AC380FD00CF6D39 /* FolderView.swift in Sources */, 849FC3412BD06A43008BAC02 /* VersionInfo.swift in Sources */, + 9E5386D659B8B8403D02E32F /* TestingUtility.swift in Sources */, 84E489992B5AC80600FFFE59 /* UserSettings.swift in Sources */, 84BC6E472AC380D200CF6D39 /* JamfProServerView.swift in Sources */, 8412279F2BEADBB20097B83E /* XmlErrorParser.swift in Sources */, @@ -730,13 +761,20 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 41E403B4E163AE45FD580F6E /* ArgumentParserTests.swift in Sources */, 8422B1252F64B50E0019C2DE /* FileHashTests.swift in Sources */, + 84B084F82F92C83D000713CC /* DpFileTests.swift in Sources */, + 84B084F92F92C83D000713CC /* DpFilesTests.swift in Sources */, 841227A12BEADD6E0097B83E /* XmlErrorParserTests.swift in Sources */, 846499E02B699FB500A8EA7B /* MockJamfProInstance.swift in Sources */, 846499DE2B699F8E00A8EA7B /* MockDistributionPoint.swift in Sources */, + 63F41799AC5DE918435399D1 /* ChecksumsTests.swift in Sources */, 8422B1212F6494FD0019C2DE /* CommandLineProcessingTests.swift in Sources */, 846499D52B64268A00A8EA7B /* DistributionPointTests.swift in Sources */, + 2AD32CC765B804C67356E7F4 /* GeneralCloudDpTests.swift in Sources */, + 1FE12F34F420D4E74BE3E1B9 /* Jcds2DpAdditionalTests.swift in Sources */, 846499E42B6B080B00A8EA7B /* SynchronizeTaskTests.swift in Sources */, + A1B2C3D4E5F60718293A4B5C /* SynchronizationProgressTests.swift in Sources */, 846499E82B6BFCA900A8EA7B /* Jcds2DpTests.swift in Sources */, 846499E22B6AA7C300A8EA7B /* FileShareDpTests.swift in Sources */, 849809DB2CB8575B001F94C9 /* UploadTimeTests.swift in Sources */, @@ -746,6 +784,8 @@ 8422B1222F649B8B0019C2DE /* TemporaryFileManagerTests.swift in Sources */, 846499CF2B630D7700A8EA7B /* FolderDpTests.swift in Sources */, 846499E62B6B089200A8EA7B /* MockDistributionPointSync.swift in Sources */, + 84C0A1012FA100000000AA01 /* MultipartUploadTests.swift in Sources */, + 84C0A1032FA100000000AA03 /* MockURLSession.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -906,6 +946,7 @@ CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"JamfSync/Resources/Preview Content\""; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; @@ -918,7 +959,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.5.2; + MARKETING_VERSION = 1.5.3; PRODUCT_BUNDLE_IDENTIFIER = com.jamf.jamfsync; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -937,6 +978,7 @@ CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"JamfSync/Resources/Preview Content\""; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; @@ -949,7 +991,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.5.2; + MARKETING_VERSION = 1.5.3; PRODUCT_BUNDLE_IDENTIFIER = com.jamf.jamfsync; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/JamfSync/Model/DistributionPoint.swift b/JamfSync/Model/DistributionPoint.swift index a41bcf8..22ad48b 100644 --- a/JamfSync/Model/DistributionPoint.swift +++ b/JamfSync/Model/DistributionPoint.swift @@ -239,7 +239,9 @@ class DistributionPoint: Identifiable { LogManager.shared.logMessage(message: "Deleting \(file.name) from \(selectionName())", level: .verbose, dryRun: dryRun) if !dryRun { try await deleteFile(file: file, progress: progress) - dpFiles.files.removeAll(where: { $0.name == file.name } ) // Update the list of files so it accurately reflects the change + await MainActor.run { + dpFiles.files.removeAll(where: { $0.name == file.name }) // Update the list of files so it accurately reflects the change + } } } } @@ -254,13 +256,15 @@ class DistributionPoint: Identifiable { let directoryContents = try fileManager.contentsOfDirectory(at: URL(fileURLWithPath: localPath), includingPropertiesForKeys: nil ) - dpFiles.files.removeAll() + var newFiles: [DpFile] = [] for url in directoryContents { if !limitFileTypes || isAcceptableForDp(url: url) { - let dpFile = DpFile(name: url.lastPathComponent, fileUrl: url, size: sizeOfFile(fileUrl: url)) - dpFiles.files.append(dpFile) + newFiles.append(DpFile(name: url.lastPathComponent, fileUrl: url, size: sizeOfFile(fileUrl: url))) } } + await MainActor.run { + dpFiles.files = newFiles + } filesLoaded = true } @@ -387,7 +391,7 @@ class DistributionPoint: Identifiable { someFileSucceeded = true if !dryRun { - addOrUpdateInDstList(dpFile: dpFile, dstDp: dstDp) + await addOrUpdateInDstList(dpFile: dpFile, dstDp: dstDp) } if !dstDp.updatePackageInfoBeforeTransfer { try await addOrUpdatePackageInJamfPro(dpFile: dpFile, jamfProInstance: jamfProInstance, dryRun: dryRun) @@ -488,11 +492,11 @@ class DistributionPoint: Identifiable { return filesToSync } - private func addOrUpdateInDstList(dpFile: DpFile, dstDp: DistributionPoint) { - if dstDp.dpFiles.files.contains(where: { $0.name == dpFile.name }) { + private func addOrUpdateInDstList(dpFile: DpFile, dstDp: DistributionPoint) async { + await MainActor.run { dstDp.dpFiles.files.removeAll(where: { $0.name == dpFile.name }) + dstDp.dpFiles.files.append(dpFile) } - dstDp.dpFiles.files.append(dpFile) } private func addOrUpdatePackageInJamfPro(dpFile: DpFile, jamfProInstance: JamfProInstance?, dryRun: Bool) async throws { diff --git a/JamfSync/Model/FileShareDp.swift b/JamfSync/Model/FileShareDp.swift index 8c31327..9487f3e 100644 --- a/JamfSync/Model/FileShareDp.swift +++ b/JamfSync/Model/FileShareDp.swift @@ -165,6 +165,9 @@ class FileShareDp: DistributionPoint { } private func loadKeychainData() { + // Skip keychain access during tests + guard !TestingUtility.isRunningTests else { return } + guard let address, let readWriteUsername else { return } let keychainHelper = KeychainHelper() var serviceName = keychainHelper.fileShareServiceName(username: readWriteUsername, urlString: address) diff --git a/JamfSync/Model/GeneralCloudDp.swift b/JamfSync/Model/GeneralCloudDp.swift index e31309f..b0fdddf 100644 --- a/JamfSync/Model/GeneralCloudDp.swift +++ b/JamfSync/Model/GeneralCloudDp.swift @@ -28,12 +28,15 @@ class GeneralCloudDp: DistributionPoint { guard let jamfProInstanceId, let jamfProInstance = findJamfProInstance(id: jamfProInstanceId) else { throw ServerCommunicationError.noJamfProUrl } // Can't currently read the file list in non JCDS2 cloud instances, so we have to assume that the packages in Jamf Pro are present - dpFiles.removeAll() + var newFiles: [DpFile] = [] for package in jamfProInstance.packages { if !limitFileTypes || isAcceptableForDp(url: URL(fileURLWithPath: package.fileName)) { - dpFiles.files.append(DpFile(name: package.fileName, size: package.size, checksums: package.checksums)) + newFiles.append(DpFile(name: package.fileName, size: package.size, checksums: package.checksums)) } } + await MainActor.run { + dpFiles.files = newFiles + } filesLoaded = true } diff --git a/JamfSync/Model/JamfProInstance.swift b/JamfSync/Model/JamfProInstance.swift index 61e7ee7..aa1c9a0 100644 --- a/JamfSync/Model/JamfProInstance.swift +++ b/JamfSync/Model/JamfProInstance.swift @@ -232,6 +232,9 @@ class JamfProInstance: SavableItem { /// Loads data from the keychain func loadKeychainData() async { + // Skip keychain access during tests + guard !TestingUtility.isRunningTests else { return } + guard let urlHost = url?.host(), !usernameOrClientId.isEmpty else { return } let keychainHelper = KeychainHelper() let serviceName = keychainHelper.jamfProServiceName(urlString: urlHost) diff --git a/JamfSync/Model/Jcds2Dp.swift b/JamfSync/Model/Jcds2Dp.swift index 8861f87..b9f0c3e 100644 --- a/JamfSync/Model/Jcds2Dp.swift +++ b/JamfSync/Model/Jcds2Dp.swift @@ -25,9 +25,8 @@ class Jcds2Dp: DistributionPoint, RenewTokenProtocol { guard let jamfProInstanceId, let jamfProInstance = findJamfProInstance(id: jamfProInstanceId), let url = jamfProInstance.url else { throw ServerCommunicationError.noJamfProUrl } let cloudFilesUrl = url.appendingPathComponent("/api/v1/jcds/files") - dpFiles.files.removeAll() - let response = try await jamfProInstance.dataRequest(url: cloudFilesUrl, httpMethod: "GET") + var newFiles: [DpFile] = [] if let data = response.data { let dataString = String(data: data, encoding: .utf8) let decoder = JSONDecoder() @@ -52,13 +51,14 @@ class Jcds2Dp: DistributionPoint, RenewTokenProtocol { if let md5 = cloudFile.md5 { checksums.updateChecksum(Checksum(type: .MD5, value: md5)) } - let dpFile = DpFile(name: filename, size: cloudFile.length ?? 0, checksums: checksums) - - dpFiles.files.append(dpFile) + newFiles.append(DpFile(name: filename, size: cloudFile.length ?? 0, checksums: checksums)) } } } } + await MainActor.run { + dpFiles.files = newFiles + } filesLoaded = true } diff --git a/JamfSync/Multipart/MultipartUpload.swift b/JamfSync/Multipart/MultipartUpload.swift index 2a7dfd9..b3547c4 100644 --- a/JamfSync/Multipart/MultipartUpload.swift +++ b/JamfSync/Multipart/MultipartUpload.swift @@ -9,6 +9,13 @@ protocol RenewTokenProtocol { func renewUploadToken() async throws } +protocol URLSessionProtocol { + func data(for request: URLRequest) async throws -> (Data, URLResponse) + func upload(for request: URLRequest, from bodyData: Data) async throws -> (Data, URLResponse) +} + +extension URLSession: URLSessionProtocol {} + class MultipartUpload { var initiateUploadData: JsonInitiateUpload let renewTokenObject: RenewTokenProtocol @@ -22,11 +29,13 @@ class MultipartUpload { let operationQueue = OperationQueue() var urlSession: URLSession? - - init(initiateUploadData: JsonInitiateUpload, renewTokenObject: RenewTokenProtocol, progress: SynchronizationProgress) { + var sharedSession: URLSessionProtocol + + init(initiateUploadData: JsonInitiateUpload, renewTokenObject: RenewTokenProtocol, progress: SynchronizationProgress, sharedSession: URLSessionProtocol = URLSession.shared) { self.initiateUploadData = initiateUploadData self.renewTokenObject = renewTokenObject self.progress = progress + self.sharedSession = sharedSession } func createUrlSession(sessionDelegate: CloudSessionDelegate) -> URLSession { @@ -52,7 +61,7 @@ class MultipartUpload { URLCache.shared.removeAllCachedResponses() uploadTime.start = Date().timeIntervalSince1970 - let (responseData, response) = try await URLSession.shared.data(for: request) + let (responseData, response) = try await sharedSession.data(for: request) let responseDataString = String(data: responseData, encoding: .utf8) ?? "" if let httpResponse = response as? HTTPURLResponse { if !(200...299).contains(httpResponse.statusCode) { @@ -76,7 +85,7 @@ class MultipartUpload { } } - private func tagValue(xmlString:String, startTag:String, endTag:String) -> String { + func tagValue(xmlString:String, startTag:String, endTag:String) -> String { var rawValue = "" if let start = xmlString.range(of: startTag), let end = xmlString.range(of: endTag, range: start.upperBound.. URLRequest { + func createMultipartUploadRequest(fileUrl: URL, httpMethod: String, urlQuery: String? = nil, start: Bool = false, contentType: String? = nil) throws -> URLRequest { let filename = fileUrl.lastPathComponent var urlHostAllowedPlus = CharacterSet.urlHostAllowed urlHostAllowedPlus.remove(charactersIn: "+") @@ -178,7 +187,7 @@ class MultipartUpload { urlSession = nil } - private func uploadChunk(whichChunk: Int, uploadId: String, fileUrl: URL, progress: SynchronizationProgress) async throws { + func uploadChunk(whichChunk: Int, uploadId: String, fileUrl: URL, progress: SynchronizationProgress) async throws { LogManager.shared.logMessage(message: "Start processing part \(whichChunk)", level: .debug) let chunkData = try getChunkData(fileUrl: fileUrl, part: whichChunk) @@ -220,7 +229,7 @@ class MultipartUpload { URLCache.shared.removeAllCachedResponses() - let (responseData, response) = try await URLSession.shared.data(for: request) + let (responseData, response) = try await sharedSession.data(for: request) if let httpResponse = response as? HTTPURLResponse { if !(200...299).contains(httpResponse.statusCode) { LogManager.shared.logMessage(message: "Failed to complete upload for \(packageToUpload). Status code: \(httpResponse.statusCode)", level: .error) @@ -234,7 +243,7 @@ class MultipartUpload { LogManager.shared.logMessage(message: "Upload of \(packageToUpload) completed in \(uploadTime.total())", level: .info) } - private func createCompletedPartsXml() -> String { + func createCompletedPartsXml() -> String { var completionArray = "" for thePart in partNumberEtagList.sorted(by: {$0.partNumber < $1.partNumber}) { let currentPart = """ @@ -252,7 +261,7 @@ class MultipartUpload { """ } - private func getChunkData(fileUrl: URL, part: Int) throws -> Data { + func getChunkData(fileUrl: URL, part: Int) throws -> Data { let fileHandle = try FileHandle(forReadingFrom: fileUrl) fileHandle.seek(toFileOffset: UInt64((part - 1) * chunkSize)) @@ -263,7 +272,7 @@ class MultipartUpload { return data } - private func awsSignature256(for resource: String, httpMethod: String, date: String, accessKeyId: String, secretKey: String, bucket: String, key: String, queryParameters: String = "", region: String/*, fileUrl: URL, contentType: String*/, currentDate: String) -> String { + func awsSignature256(for resource: String, httpMethod: String, date: String, accessKeyId: String, secretKey: String, bucket: String, key: String, queryParameters: String = "", region: String, currentDate: String) -> String { var requestHeaders = [String:String]() requestHeaders["date"] = currentDate @@ -316,7 +325,7 @@ class MultipartUpload { return hexOfFinalSignature } - private func contentType(filename: String) -> String? { + func contentType(filename: String) -> String? { let ext = URL(fileURLWithPath: filename).pathExtension switch ext { case "pkg", "mpkg": @@ -330,7 +339,7 @@ class MultipartUpload { } } - private func hmac_sha256(date: String, secretKey: String, key: String, region: String, stringToSign: String) -> String { + func hmac_sha256(date: String, secretKey: String, key: String, region: String, stringToSign: String) -> String { let aws4SecretKey = Data("AWS4\(secretKey)".utf8) let dateStampData = Data(date.utf8) let regionNameData = Data(region.utf8) diff --git a/JamfSync/Utility/ArgumentParser.swift b/JamfSync/Utility/ArgumentParser.swift index 49fdd0b..d7526a5 100644 --- a/JamfSync/Utility/ArgumentParser.swift +++ b/JamfSync/Utility/ArgumentParser.swift @@ -21,7 +21,7 @@ class ArgumentParser: NSObject { fileprivate func processStringArg(_ i: inout Int) -> String? { var stringArg: String? - if i < CommandLine.arguments.count - 1 { + if i < arguments.count - 1 { stringArg = arguments[i + 1] i = i + 1 } @@ -119,7 +119,17 @@ class ArgumentParser: NSObject { func validateArgs() -> Bool { // Either none or both, but not one or the other - if (srcDp == nil && dstDp == nil) || (srcDp != nil && dstDp != nil) { + if srcDp == nil && dstDp == nil { + // If both are nil but arguments were passed, that's an error + // (e.g., -s without a value) + if someArgumentsPassed { + print("Both the source and the destination arguments are required.") + print("") + displayHelp() + return false + } + return true + } else if srcDp != nil && dstDp != nil { return true } else { print("Both the source and the destination arguments are required.") diff --git a/JamfSync/Utility/TestingUtility.swift b/JamfSync/Utility/TestingUtility.swift new file mode 100644 index 0000000..43364c9 --- /dev/null +++ b/JamfSync/Utility/TestingUtility.swift @@ -0,0 +1,12 @@ +// +// Copyright 2024, Jamf +// + +import Foundation + +struct TestingUtility { + static var isRunningTests: Bool { + return ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil || + NSClassFromString("XCTestCase") != nil + } +} diff --git a/JamfSyncTests/Mocks/MockFileManager.swift b/JamfSyncTests/Mocks/MockFileManager.swift index 2a2e8e8..1c79dc5 100644 --- a/JamfSyncTests/Mocks/MockFileManager.swift +++ b/JamfSyncTests/Mocks/MockFileManager.swift @@ -21,6 +21,7 @@ class MockFileManager: FileManager { var fileExistsResponseProvider: ((String, UnsafeMutablePointer?) -> Bool)? var directoryCreated: URL? var createDirectoryError: Error? + var attributesSet: [String: [FileAttributeKey: Any]] = [:] override func contentsOfDirectory(at url: URL, includingPropertiesForKeys keys: [URLResourceKey]?, options mask: FileManager.DirectoryEnumerationOptions = []) throws -> [URL] { if let contentsOfDirectoryError { @@ -88,4 +89,8 @@ class MockFileManager: FileManager { } directoryCreated = url } + + override func setAttributes(_ attributes: [FileAttributeKey : Any], ofItemAtPath path: String) throws { + attributesSet[path] = attributes + } } diff --git a/JamfSyncTests/Mocks/MockURLSession.swift b/JamfSyncTests/Mocks/MockURLSession.swift new file mode 100644 index 0000000..8a0dcfb --- /dev/null +++ b/JamfSyncTests/Mocks/MockURLSession.swift @@ -0,0 +1,22 @@ +// +// Copyright 2026, Jamf +// + +@testable import Jamf_Sync +import Foundation + +class MockURLSession: URLSessionProtocol { + var dataResult: (Data, URLResponse)? + var uploadResult: (Data, URLResponse)? + var errorToThrow: Error? + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + if let error = errorToThrow { throw error } + return dataResult! + } + + func upload(for request: URLRequest, from bodyData: Data) async throws -> (Data, URLResponse) { + if let error = errorToThrow { throw error } + return uploadResult! + } +} diff --git a/JamfSyncTests/Model/ChecksumsTests.swift b/JamfSyncTests/Model/ChecksumsTests.swift new file mode 100644 index 0000000..f6248a5 --- /dev/null +++ b/JamfSyncTests/Model/ChecksumsTests.swift @@ -0,0 +1,380 @@ +// +// Copyright 2026, Jamf +// + +@testable import Jamf_Sync +import XCTest + +final class ChecksumsTests: XCTestCase { + + // MARK: - updateChecksum tests + + func test_updateChecksum_addsNewChecksum() throws { + // Given + let checksums = Checksums() + let checksum = Checksum(type: .MD5, value: "abc123") + + // When + checksums.updateChecksum(checksum) + + // Then + XCTAssertEqual(checksums.checksums.count, 1) + XCTAssertEqual(checksums.checksums[0].type, .MD5) + XCTAssertEqual(checksums.checksums[0].value, "abc123") + } + + func test_updateChecksum_replacesExistingChecksum() throws { + // Given + let checksums = Checksums() + checksums.updateChecksum(Checksum(type: .MD5, value: "oldValue")) + let newChecksum = Checksum(type: .MD5, value: "newValue") + + // When + checksums.updateChecksum(newChecksum) + + // Then + XCTAssertEqual(checksums.checksums.count, 1, "Should still have only one checksum") + XCTAssertEqual(checksums.checksums[0].type, .MD5) + XCTAssertEqual(checksums.checksums[0].value, "newValue", "Should have updated value") + } + + func test_updateChecksum_addsMultipleTypes() throws { + // Given + let checksums = Checksums() + let md5 = Checksum(type: .MD5, value: "md5Value") + let sha256 = Checksum(type: .SHA_256, value: "sha256Value") + let sha512 = Checksum(type: .SHA_512, value: "sha512Value") + + // When + checksums.updateChecksum(md5) + checksums.updateChecksum(sha256) + checksums.updateChecksum(sha512) + + // Then + XCTAssertEqual(checksums.checksums.count, 3) + } + + // MARK: - hasMatchingChecksumType tests + + func test_hasMatchingChecksumType_withMatchingType() throws { + // Given + let checksums1 = Checksums() + checksums1.updateChecksum(Checksum(type: .MD5, value: "value1")) + checksums1.updateChecksum(Checksum(type: .SHA_256, value: "value2")) + + let checksums2 = Checksums() + checksums2.updateChecksum(Checksum(type: .SHA_256, value: "differentValue")) + + // When + let result = checksums1.hasMatchingChecksumType(checksums: checksums2) + + // Then + XCTAssertTrue(result, "Should find matching SHA_256 type") + } + + func test_hasMatchingChecksumType_withNoMatchingType() throws { + // Given + let checksums1 = Checksums() + checksums1.updateChecksum(Checksum(type: .MD5, value: "value1")) + + let checksums2 = Checksums() + checksums2.updateChecksum(Checksum(type: .SHA_512, value: "value2")) + + // When + let result = checksums1.hasMatchingChecksumType(checksums: checksums2) + + // Then + XCTAssertFalse(result, "Should not find any matching types") + } + + func test_hasMatchingChecksumType_withEmptyChecksums() throws { + // Given + let checksums1 = Checksums() + let checksums2 = Checksums() + + // When + let result = checksums1.hasMatchingChecksumType(checksums: checksums2) + + // Then + XCTAssertFalse(result, "Empty checksums should return false") + } + + func test_hasMatchingChecksumType_withMultipleMatchingTypes() throws { + // Given + let checksums1 = Checksums() + checksums1.updateChecksum(Checksum(type: .MD5, value: "md5Value")) + checksums1.updateChecksum(Checksum(type: .SHA_512, value: "sha512Value")) + + let checksums2 = Checksums() + checksums2.updateChecksum(Checksum(type: .MD5, value: "differentMd5")) + checksums2.updateChecksum(Checksum(type: .SHA_512, value: "differentSha512")) + + // When + let result = checksums1.hasMatchingChecksumType(checksums: checksums2) + + // Then + XCTAssertTrue(result, "Should find at least one matching type") + } + + // MARK: - removeChecksum tests + + func test_removeChecksum_removesExistingChecksum() throws { + // Given + let checksums = Checksums() + checksums.updateChecksum(Checksum(type: .MD5, value: "md5Value")) + checksums.updateChecksum(Checksum(type: .SHA_256, value: "sha256Value")) + + // When + let result = checksums.removeChecksum(type: .MD5) + + // Then + XCTAssertTrue(result, "Should return true when checksum was removed") + XCTAssertEqual(checksums.checksums.count, 1) + XCTAssertEqual(checksums.checksums[0].type, .SHA_256) + } + + func test_removeChecksum_returnsFalseForNonExistentChecksum() throws { + // Given + let checksums = Checksums() + checksums.updateChecksum(Checksum(type: .MD5, value: "md5Value")) + + // When + let result = checksums.removeChecksum(type: .SHA_512) + + // Then + XCTAssertFalse(result, "Should return false when no checksum was removed") + XCTAssertEqual(checksums.checksums.count, 1) + } + + func test_removeChecksum_handlesEmptyChecksums() throws { + // Given + let checksums = Checksums() + + // When + let result = checksums.removeChecksum(type: .MD5) + + // Then + XCTAssertFalse(result, "Should return false for empty checksums") + XCTAssertEqual(checksums.checksums.count, 0) + } + + // MARK: - findChecksum tests + + func test_findChecksum_findsExistingChecksum() throws { + // Given + let checksums = Checksums() + checksums.updateChecksum(Checksum(type: .MD5, value: "md5Value")) + checksums.updateChecksum(Checksum(type: .SHA_256, value: "sha256Value")) + + // When + let result = checksums.findChecksum(type: .SHA_256) + + // Then + XCTAssertNotNil(result) + XCTAssertEqual(result?.type, .SHA_256) + XCTAssertEqual(result?.value, "sha256Value") + } + + func test_findChecksum_returnsNilForNonExistentChecksum() throws { + // Given + let checksums = Checksums() + checksums.updateChecksum(Checksum(type: .MD5, value: "md5Value")) + + // When + let result = checksums.findChecksum(type: .SHA_512) + + // Then + XCTAssertNil(result) + } + + func test_findChecksum_handlesEmptyChecksums() throws { + // Given + let checksums = Checksums() + + // When + let result = checksums.findChecksum(type: .MD5) + + // Then + XCTAssertNil(result) + } + + // MARK: - bestChecksum tests + + func test_bestChecksum_prefersSHA512() throws { + // Given + let checksums = Checksums() + checksums.updateChecksum(Checksum(type: .MD5, value: "md5Value")) + checksums.updateChecksum(Checksum(type: .SHA_256, value: "sha256Value")) + checksums.updateChecksum(Checksum(type: .SHA_512, value: "sha512Value")) + + // When + let result = checksums.bestChecksum() + + // Then + XCTAssertNotNil(result) + XCTAssertEqual(result?.type, .SHA_512, "Should prefer SHA_512 over other types") + XCTAssertEqual(result?.value, "sha512Value") + } + + func test_bestChecksum_fallsBackToSHA256() throws { + // Given + let checksums = Checksums() + checksums.updateChecksum(Checksum(type: .MD5, value: "md5Value")) + checksums.updateChecksum(Checksum(type: .SHA_256, value: "sha256Value")) + + // When + let result = checksums.bestChecksum() + + // Then + XCTAssertNotNil(result) + XCTAssertEqual(result?.type, .SHA_256, "Should use SHA_256 when SHA_512 not available") + XCTAssertEqual(result?.value, "sha256Value") + } + + func test_bestChecksum_fallsBackToMD5() throws { + // Given + let checksums = Checksums() + checksums.updateChecksum(Checksum(type: .MD5, value: "md5Value")) + + // When + let result = checksums.bestChecksum() + + // Then + XCTAssertNotNil(result) + XCTAssertEqual(result?.type, .MD5, "Should use MD5 when no stronger algorithms available") + XCTAssertEqual(result?.value, "md5Value") + } + + func test_bestChecksum_returnsNilForEmpty() throws { + // Given + let checksums = Checksums() + + // When + let result = checksums.bestChecksum() + + // Then + XCTAssertNil(result, "Should return nil when no checksums available") + } + + // MARK: - equality operator tests + + func test_equality_matchingSHA512() throws { + // Given + let checksums1 = Checksums() + checksums1.updateChecksum(Checksum(type: .SHA_512, value: "sha512Value")) + checksums1.updateChecksum(Checksum(type: .MD5, value: "md5Value1")) + + let checksums2 = Checksums() + checksums2.updateChecksum(Checksum(type: .SHA_512, value: "sha512Value")) + checksums2.updateChecksum(Checksum(type: .MD5, value: "md5Value2")) + + // When + let result = (checksums1 == checksums2) + + // Then + XCTAssertTrue(result, "Should be equal when SHA_512 values match (regardless of MD5)") + } + + func test_equality_mismatchedSHA512() throws { + // Given + let checksums1 = Checksums() + checksums1.updateChecksum(Checksum(type: .SHA_512, value: "sha512Value1")) + + let checksums2 = Checksums() + checksums2.updateChecksum(Checksum(type: .SHA_512, value: "sha512Value2")) + + // When + let result = (checksums1 == checksums2) + + // Then + XCTAssertFalse(result, "Should not be equal when SHA_512 values differ") + } + + func test_equality_matchingMD5WithoutSHA512() throws { + // Given + let checksums1 = Checksums() + checksums1.updateChecksum(Checksum(type: .MD5, value: "md5Value")) + + let checksums2 = Checksums() + checksums2.updateChecksum(Checksum(type: .MD5, value: "md5Value")) + + // When + let result = (checksums1 == checksums2) + + // Then + XCTAssertTrue(result, "Should be equal when MD5 values match and no SHA_512 present") + } + + func test_equality_mismatchedMD5WithoutSHA512() throws { + // Given + let checksums1 = Checksums() + checksums1.updateChecksum(Checksum(type: .MD5, value: "md5Value1")) + + let checksums2 = Checksums() + checksums2.updateChecksum(Checksum(type: .MD5, value: "md5Value2")) + + // When + let result = (checksums1 == checksums2) + + // Then + XCTAssertFalse(result, "Should not be equal when MD5 values differ") + } + + func test_equality_noComparableChecksums() throws { + // Given + let checksums1 = Checksums() + checksums1.updateChecksum(Checksum(type: .SHA_256, value: "sha256Value")) + + let checksums2 = Checksums() + checksums2.updateChecksum(Checksum(type: .SHA_256, value: "sha256Value")) + + // When + let result = (checksums1 == checksums2) + + // Then + XCTAssertFalse(result, "Should return false when neither SHA_512 nor MD5 are present for comparison") + } + + func test_equality_emptyChecksums() throws { + // Given + let checksums1 = Checksums() + let checksums2 = Checksums() + + // When + let result = (checksums1 == checksums2) + + // Then + XCTAssertFalse(result, "Empty checksums should not be equal") + } + + func test_equality_oneEmptyOnePopulated() throws { + // Given + let checksums1 = Checksums() + checksums1.updateChecksum(Checksum(type: .MD5, value: "md5Value")) + + let checksums2 = Checksums() + + // When + let result = (checksums1 == checksums2) + + // Then + XCTAssertFalse(result, "Populated and empty checksums should not be equal") + } + + func test_equality_SHA512TakesPrecedenceOverMD5() throws { + // Given + let checksums1 = Checksums() + checksums1.updateChecksum(Checksum(type: .SHA_512, value: "matchingSha512")) + checksums1.updateChecksum(Checksum(type: .MD5, value: "differentMd5_1")) + + let checksums2 = Checksums() + checksums2.updateChecksum(Checksum(type: .SHA_512, value: "matchingSha512")) + checksums2.updateChecksum(Checksum(type: .MD5, value: "differentMd5_2")) + + // When + let result = (checksums1 == checksums2) + + // Then + XCTAssertTrue(result, "SHA_512 comparison should take precedence, ignoring MD5 mismatch") + } +} diff --git a/JamfSyncTests/Model/DistributionPointTests.swift b/JamfSyncTests/Model/DistributionPointTests.swift index 6eaac4c..d622063 100644 --- a/JamfSyncTests/Model/DistributionPointTests.swift +++ b/JamfSyncTests/Model/DistributionPointTests.swift @@ -776,35 +776,6 @@ final class DistributionPointTests: XCTestCase { XCTAssertEqual(synchronizationProgress.currentTotalSizeTransferred, 123458023) } - // TODO: Figure out how to mock the extension method and put this test back in -// func test_transferLocal_move() throws { -// // Given -// let localPath = "/dst/path" -// let fileName = "fileName.pkg" -// let srcFile = DpFile(name: fileName, size: 123456789) -// let synchronizationProgress = SynchronizationProgress() -// let dstDp = MockDistributionPoint(name: "TestDstDp", fileManager: mockFileManager) -// synchronizationProgress.printToConsole = true // So it will update before the test is over -// synchronizationProgress.currentTotalSizeTransferred = 1234 -// -// let expectationCompleted = XCTestExpectation() -// Task { -// // When -// try await dstDp.transferLocal(localPath: localPath, srcFile: srcFile, moveFrom: URL(fileURLWithPath: "/src/path/fileName.pkg"), progress: synchronizationProgress) -// -// // For this type of DP, nothing happens, but need to make sure it returns without error. -// expectationCompleted.fulfill() -// } -// wait(for: [expectationCompleted], timeout: 5) -// -// // Then -// XCTAssertEqual(mockFileManager.itemRemoved, URL(fileURLWithPath: "/dst/path/fileName.pkg")) -// XCTAssertEqual(mockFileManager.srcItemMoved, URL(fileURLWithPath: "/src/path/fileName.pkg")) -// XCTAssertEqual(mockFileManager.dstItemMoved, URL(fileURLWithPath: "/dst/path/fileName.pkg")) -// XCTAssertEqual(synchronizationProgress.currentFileSizeTransferred, 123456789) -// XCTAssertEqual(synchronizationProgress.currentTotalSizeTransferred, 123458023) -// } - func test_transferLocal_removeFailed() throws { // Given let localPath = "/dst/path" diff --git a/JamfSyncTests/Model/DpFileTests.swift b/JamfSyncTests/Model/DpFileTests.swift new file mode 100644 index 0000000..e09cc10 --- /dev/null +++ b/JamfSyncTests/Model/DpFileTests.swift @@ -0,0 +1,133 @@ +// +// Copyright 2026, Jamf +// + +@testable import Jamf_Sync +import XCTest + +final class DpFileTests: XCTestCase { + + // MARK: - init tests + + func test_init_setsAllProperties() { + let url = URL(fileURLWithPath: "/tmp/test.pkg") + let checksums = Checksums() + checksums.updateChecksum(Checksum(type: .MD5, value: "abc123")) + + let file = DpFile(name: "test.pkg", fileUrl: url, size: 1024, checksums: checksums) + + XCTAssertEqual(file.name, "test.pkg") + XCTAssertEqual(file.fileUrl, url) + XCTAssertEqual(file.size, 1024) + XCTAssertEqual(file.checksums.findChecksum(type: .MD5)?.value, "abc123") + } + + func test_init_withNilOptionals() { + let file = DpFile(name: "test.pkg", size: nil) + + XCTAssertNil(file.fileUrl) + XCTAssertNil(file.size) + XCTAssertTrue(file.checksums.checksums.isEmpty) + } + + func test_copyInit_copiesAllProperties() { + let original = DpFile(name: "file.pkg", fileUrl: URL(fileURLWithPath: "/tmp/file.pkg"), size: 512) + original.checksums.updateChecksum(Checksum(type: .SHA_512, value: "sha512val")) + + let copy = DpFile(dpFile: original) + + XCTAssertEqual(copy.id, original.id) + XCTAssertEqual(copy.name, original.name) + XCTAssertEqual(copy.fileUrl, original.fileUrl) + XCTAssertEqual(copy.size, original.size) + XCTAssertEqual(copy.checksums.findChecksum(type: .SHA_512)?.value, "sha512val") + } + + // MARK: - sizeString tests + + func test_sizeString_returnsFormattedSize() { + let file = DpFile(name: "test.pkg", size: 2048) + XCTAssertEqual(file.sizeString, "2048") + } + + func test_sizeString_returnsDoubleDashWhenNil() { + let file = DpFile(name: "test.pkg", size: nil) + XCTAssertEqual(file.sizeString, "--") + } + + // MARK: - equality operator tests + + func test_equality_usesChecksumWhenBothHaveMatchingType() { + let checksums1 = Checksums() + checksums1.updateChecksum(Checksum(type: .MD5, value: "sameHash")) + let file1 = DpFile(name: "a.pkg", size: 100, checksums: checksums1) + + let checksums2 = Checksums() + checksums2.updateChecksum(Checksum(type: .MD5, value: "sameHash")) + let file2 = DpFile(name: "b.pkg", size: 200, checksums: checksums2) + + XCTAssertTrue(file1 == file2, "Files with matching checksums should be equal regardless of size or name") + } + + func test_equality_checksumMismatchMeansNotEqual() { + let checksums1 = Checksums() + checksums1.updateChecksum(Checksum(type: .MD5, value: "hash1")) + let file1 = DpFile(name: "a.pkg", size: 100, checksums: checksums1) + + let checksums2 = Checksums() + checksums2.updateChecksum(Checksum(type: .MD5, value: "hash2")) + let file2 = DpFile(name: "a.pkg", size: 100, checksums: checksums2) + + XCTAssertFalse(file1 == file2, "Files with differing checksums should not be equal") + } + + func test_equality_fallsBackToSizeWhenNoMatchingChecksumType() { + let checksums1 = Checksums() + checksums1.updateChecksum(Checksum(type: .MD5, value: "md5Hash")) + let file1 = DpFile(name: "a.pkg", size: 500, checksums: checksums1) + + let checksums2 = Checksums() + checksums2.updateChecksum(Checksum(type: .SHA_512, value: "sha512Hash")) + let file2 = DpFile(name: "a.pkg", size: 500, checksums: checksums2) + + XCTAssertTrue(file1 == file2, "When checksum types don't match, equal sizes should mean equal files") + } + + func test_equality_sizeNotEqualWhenNoMatchingChecksumType() { + let checksums1 = Checksums() + checksums1.updateChecksum(Checksum(type: .MD5, value: "md5Hash")) + let file1 = DpFile(name: "a.pkg", size: 100, checksums: checksums1) + + let checksums2 = Checksums() + checksums2.updateChecksum(Checksum(type: .SHA_512, value: "sha512Hash")) + let file2 = DpFile(name: "a.pkg", size: 200, checksums: checksums2) + + XCTAssertFalse(file1 == file2, "When checksum types don't match, differing sizes should mean not equal") + } + + func test_equality_noChecksumsComparesSize() { + let file1 = DpFile(name: "a.pkg", size: 1024) + let file2 = DpFile(name: "b.pkg", size: 1024) + + XCTAssertTrue(file1 == file2, "Files with no checksums and equal sizes should be equal") + } + + func test_equality_noChecksumsNilSizesAreEqual() { + let file1 = DpFile(name: "a.pkg", size: nil) + let file2 = DpFile(name: "b.pkg", size: nil) + + XCTAssertTrue(file1 == file2, "Files with no checksums and both nil sizes should be equal") + } + + func test_equality_sha512TakesPrecedenceOverSize() { + let checksums1 = Checksums() + checksums1.updateChecksum(Checksum(type: .SHA_512, value: "matchingSha512")) + let file1 = DpFile(name: "a.pkg", size: 100, checksums: checksums1) + + let checksums2 = Checksums() + checksums2.updateChecksum(Checksum(type: .SHA_512, value: "matchingSha512")) + let file2 = DpFile(name: "b.pkg", size: 999, checksums: checksums2) + + XCTAssertTrue(file1 == file2, "Matching SHA_512 should make files equal regardless of size") + } +} diff --git a/JamfSyncTests/Model/DpFilesTests.swift b/JamfSyncTests/Model/DpFilesTests.swift new file mode 100644 index 0000000..f93eae6 --- /dev/null +++ b/JamfSyncTests/Model/DpFilesTests.swift @@ -0,0 +1,115 @@ +// +// Copyright 2026, Jamf +// + +@testable import Jamf_Sync +import XCTest + +final class DpFilesTests: XCTestCase { + + var dpFiles: DpFiles! + + override func setUp() { + super.setUp() + dpFiles = DpFiles() + } + + // MARK: - findDpFile(id:) tests + + func test_findDpFileById_returnsMatchingFile() { + let file = DpFile(name: "test.pkg", size: 100) + dpFiles.files.append(file) + + let found = dpFiles.findDpFile(id: file.id) + + XCTAssertNotNil(found) + XCTAssertEqual(found?.id, file.id) + } + + func test_findDpFileById_returnsNilWhenNotFound() { + dpFiles.files.append(DpFile(name: "test.pkg", size: 100)) + + let found = dpFiles.findDpFile(id: UUID()) + + XCTAssertNil(found) + } + + func test_findDpFileById_returnsNilWhenEmpty() { + let found = dpFiles.findDpFile(id: UUID()) + XCTAssertNil(found) + } + + func test_findDpFileById_returnsCorrectFileAmongMultiple() { + let file1 = DpFile(name: "a.pkg", size: 100) + let file2 = DpFile(name: "b.pkg", size: 200) + let file3 = DpFile(name: "c.pkg", size: 300) + dpFiles.files = [file1, file2, file3] + + let found = dpFiles.findDpFile(id: file2.id) + + XCTAssertEqual(found?.name, "b.pkg") + } + + // MARK: - findDpFile(name:) tests + + func test_findDpFileByName_returnsMatchingFile() { + let file = DpFile(name: "installer.pkg", size: 512) + dpFiles.files.append(file) + + let found = dpFiles.findDpFile(name: "installer.pkg") + + XCTAssertNotNil(found) + XCTAssertEqual(found?.name, "installer.pkg") + } + + func test_findDpFileByName_returnsNilWhenNotFound() { + dpFiles.files.append(DpFile(name: "installer.pkg", size: 512)) + + let found = dpFiles.findDpFile(name: "other.pkg") + + XCTAssertNil(found) + } + + func test_findDpFileByName_returnsNilWhenEmpty() { + let found = dpFiles.findDpFile(name: "test.pkg") + XCTAssertNil(found) + } + + func test_findDpFileByName_isCaseSensitive() { + dpFiles.files.append(DpFile(name: "Test.pkg", size: 100)) + + let found = dpFiles.findDpFile(name: "test.pkg") + + XCTAssertNil(found, "Name lookup should be case-sensitive") + } + + func test_findDpFileByName_returnsCorrectFileAmongMultiple() { + dpFiles.files = [ + DpFile(name: "a.pkg", size: 100), + DpFile(name: "b.pkg", size: 200), + DpFile(name: "c.pkg", size: 300) + ] + + let found = dpFiles.findDpFile(name: "c.pkg") + + XCTAssertEqual(found?.size, 300) + } + + // MARK: - removeAll tests + + func test_removeAll_clearsAllFiles() { + dpFiles.files = [ + DpFile(name: "a.pkg", size: 100), + DpFile(name: "b.pkg", size: 200) + ] + + dpFiles.removeAll() + + XCTAssertTrue(dpFiles.files.isEmpty) + } + + func test_removeAll_onEmptyCollectionSucceeds() { + dpFiles.removeAll() + XCTAssertTrue(dpFiles.files.isEmpty) + } +} diff --git a/JamfSyncTests/Model/GeneralCloudDpTests.swift b/JamfSyncTests/Model/GeneralCloudDpTests.swift new file mode 100644 index 0000000..883b78a --- /dev/null +++ b/JamfSyncTests/Model/GeneralCloudDpTests.swift @@ -0,0 +1,506 @@ +// +// Copyright 2026, Jamf +// + +@testable import Jamf_Sync +import XCTest + +final class GeneralCloudDpTests: XCTestCase { + var generalCloudDp: GeneralCloudDp! + var mockJamfProInstance: MockJamfProInstance! + let jamfProInstanceId = UUID() + let jamfProInstanceName = "TestJamfPro" + + override func setUpWithError() throws { + generalCloudDp = GeneralCloudDp(jamfProInstanceId: jamfProInstanceId, jamfProInstanceName: jamfProInstanceName) + mockJamfProInstance = MockJamfProInstance() + mockJamfProInstance.id = jamfProInstanceId + mockJamfProInstance.name = jamfProInstanceName + mockJamfProInstance.url = URL(string: "https://test.jamfcloud.com") + mockJamfProInstance.token = "test-token" + + // Add mock instance to DataModel for findJamfProInstance to work + DataModel.shared.savableItems.items.removeAll() + DataModel.shared.savableItems.items.append(mockJamfProInstance) + } + + override func tearDownWithError() throws { + generalCloudDp = nil + mockJamfProInstance = nil + DataModel.shared.savableItems.items.removeAll() + } + + // MARK: - Initialization tests + + func test_init_setsPropertiesCorrectly() throws { + // Given/When - from setUp + + // Then + XCTAssertEqual(generalCloudDp.name, "Cloud") + XCTAssertEqual(generalCloudDp.readWrite, .writeOnly) + XCTAssertEqual(generalCloudDp.jamfProInstanceId, jamfProInstanceId) + XCTAssertEqual(generalCloudDp.jamfProInstanceName, jamfProInstanceName) + XCTAssertTrue(generalCloudDp.updatePackageInfoBeforeTransfer) + XCTAssertTrue(generalCloudDp.willDownloadFiles) + XCTAssertTrue(generalCloudDp.deleteByRemovingPackage) + } + + func test_init_withNoParameters() throws { + // Given/When + let cloudDp = GeneralCloudDp() + + // Then + XCTAssertEqual(cloudDp.name, "Cloud") + XCTAssertNil(cloudDp.jamfProInstanceId) + XCTAssertNil(cloudDp.jamfProInstanceName) + } + + // MARK: - retrieveFileList tests + + func test_retrieveFileList_withPackages() throws { + // Given + let package1 = Package(jamfProId: 1, displayName: "Package1", fileName: "package1.pkg", category: "Test", size: 1000, checksums: Checksums()) + let package2 = Package(jamfProId: 2, displayName: "Package2", fileName: "package2.dmg", category: "Test", size: 2000, checksums: Checksums()) + mockJamfProInstance.packages = [package1, package2] + + let expectation = XCTestExpectation() + Task { + // When + try await generalCloudDp.retrieveFileList() + + // Then + XCTAssertTrue(generalCloudDp.filesLoaded) + XCTAssertEqual(generalCloudDp.dpFiles.files.count, 2) + XCTAssertEqual(generalCloudDp.dpFiles.files[0].name, "package1.pkg") + XCTAssertEqual(generalCloudDp.dpFiles.files[0].size, 1000) + XCTAssertEqual(generalCloudDp.dpFiles.files[1].name, "package2.dmg") + XCTAssertEqual(generalCloudDp.dpFiles.files[1].size, 2000) + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + } + + func test_retrieveFileList_withNoPackages() throws { + // Given + mockJamfProInstance.packages = [] + + let expectation = XCTestExpectation() + Task { + // When + try await generalCloudDp.retrieveFileList() + + // Then + XCTAssertTrue(generalCloudDp.filesLoaded) + XCTAssertEqual(generalCloudDp.dpFiles.files.count, 0) + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + } + + func test_retrieveFileList_limitFileTypes() throws { + // Given + let package1 = Package(jamfProId: 1, displayName: "Package1", fileName: "package1.pkg", category: "Test", size: 1000, checksums: Checksums()) + let package2 = Package(jamfProId: 2, displayName: "Package2", fileName: "package2.txt", category: "Test", size: 2000, checksums: Checksums()) + mockJamfProInstance.packages = [package1, package2] + + let expectation = XCTestExpectation() + Task { + // When + try await generalCloudDp.retrieveFileList(limitFileTypes: true) + + // Then - Only .pkg should be included when limiting file types + XCTAssertTrue(generalCloudDp.filesLoaded) + XCTAssertEqual(generalCloudDp.dpFiles.files.count, 1) + XCTAssertEqual(generalCloudDp.dpFiles.files[0].name, "package1.pkg") + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + } + + func test_retrieveFileList_noLimitFileTypes() throws { + // Given + let package1 = Package(jamfProId: 1, displayName: "Package1", fileName: "package1.pkg", category: "Test", size: 1000, checksums: Checksums()) + let package2 = Package(jamfProId: 2, displayName: "Package2", fileName: "package2.txt", category: "Test", size: 2000, checksums: Checksums()) + mockJamfProInstance.packages = [package1, package2] + + let expectation = XCTestExpectation() + Task { + // When + try await generalCloudDp.retrieveFileList(limitFileTypes: false) + + // Then - Both should be included when not limiting file types + XCTAssertTrue(generalCloudDp.filesLoaded) + XCTAssertEqual(generalCloudDp.dpFiles.files.count, 2) + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + } + + func test_retrieveFileList_noJamfProInstance() throws { + // Given + generalCloudDp = GeneralCloudDp(jamfProInstanceId: UUID(), jamfProInstanceName: "NonExistent") + + let expectation = XCTestExpectation() + Task { + do { + // When + try await generalCloudDp.retrieveFileList() + + // Then + XCTFail("Should have thrown ServerCommunicationError.noJamfProUrl") + } catch ServerCommunicationError.noJamfProUrl { + // Expected + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + } + + func test_retrieveFileList_nilJamfProInstanceId() throws { + // Given + generalCloudDp = GeneralCloudDp() + + let expectation = XCTestExpectation() + Task { + do { + // When + try await generalCloudDp.retrieveFileList() + + // Then + XCTFail("Should have thrown ServerCommunicationError.noJamfProUrl") + } catch ServerCommunicationError.noJamfProUrl { + // Expected + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + } + + // MARK: - downloadFile tests + + func test_downloadFile_throwsNotSupported() throws { + // Given + let dpFile = DpFile(name: "test.pkg", size: 1000) + let progress = SynchronizationProgress() + + let expectation = XCTestExpectation() + Task { + do { + // When + _ = try await generalCloudDp.downloadFile(file: dpFile, progress: progress) + + // Then + XCTFail("Should have thrown DistributionPointError.downloadingNotSupported") + } catch DistributionPointError.downloadingNotSupported { + // Expected + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + } + + // MARK: - transferFile tests + + func test_transferFile_noJamfProInstance() throws { + // Given + generalCloudDp = GeneralCloudDp(jamfProInstanceId: UUID(), jamfProInstanceName: "NonExistent") + let srcFile = DpFile(name: "test.pkg", fileUrl: URL(fileURLWithPath: "/tmp/test.pkg"), size: 1000) + let progress = SynchronizationProgress() + + let expectation = XCTestExpectation() + Task { + do { + // When + try await generalCloudDp.transferFile(srcFile: srcFile, moveFrom: nil, progress: progress) + + // Then + XCTFail("Should have thrown ServerCommunicationError.noJamfProUrl") + } catch ServerCommunicationError.noJamfProUrl { + // Expected + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + } + + func test_transferFile_packageNotFound() throws { + // Given + mockJamfProInstance.packages = [] + let srcFile = DpFile(name: "test.pkg", fileUrl: URL(fileURLWithPath: "/tmp/test.pkg"), size: 1000) + let progress = SynchronizationProgress() + + let expectation = XCTestExpectation() + Task { + do { + // When + try await generalCloudDp.transferFile(srcFile: srcFile, moveFrom: nil, progress: progress) + + // Then + XCTFail("Should have thrown DistributionPointError.uploadFailure") + } catch DistributionPointError.uploadFailure { + // Expected - package not found + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + } + + func test_transferFile_packageMissingJamfProId() throws { + // Given + let package = Package(jamfProId: nil, displayName: "test", fileName: "test.pkg", category: "Test", size: 1000, checksums: Checksums()) + mockJamfProInstance.packages = [package] + let srcFile = DpFile(name: "test.pkg", fileUrl: URL(fileURLWithPath: "/tmp/test.pkg"), size: 1000) + let progress = SynchronizationProgress() + + let expectation = XCTestExpectation() + Task { + do { + // When + try await generalCloudDp.transferFile(srcFile: srcFile, moveFrom: nil, progress: progress) + + // Then + XCTFail("Should have thrown DistributionPointError.uploadFailure") + } catch DistributionPointError.uploadFailure { + // Expected - no Jamf Pro ID + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + } + + func test_transferFile_noFileUrl() throws { + // Given + let package = Package(jamfProId: 1, displayName: "test", fileName: "test.pkg", category: "Test", size: 1000, checksums: Checksums()) + mockJamfProInstance.packages = [package] + let srcFile = DpFile(name: "test.pkg", size: 1000) // No fileUrl + let progress = SynchronizationProgress() + + let expectation = XCTestExpectation() + Task { + do { + // When + try await generalCloudDp.transferFile(srcFile: srcFile, moveFrom: nil, progress: progress) + + // Then + XCTFail("Should have thrown DistributionPointError.badFileUrl") + } catch DistributionPointError.badFileUrl { + // Expected + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + } + + // MARK: - deleteFile tests + + func test_deleteFile_doesNothing() throws { + // Given + let dpFile = DpFile(name: "test.pkg", size: 1000) + let progress = SynchronizationProgress() + + let expectation = XCTestExpectation() + Task { + // When + try await generalCloudDp.deleteFile(file: dpFile, progress: progress) + + // Then - Should complete without error + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + } + + // MARK: - cancel tests + + func test_cancel_clearsState() throws { + // Given + generalCloudDp.urlSession = URLSession(configuration: .default) + generalCloudDp.dispatchGroup = DispatchGroup() + generalCloudDp.dispatchGroup?.enter() // Must enter before cancel() calls leave() + + // When + generalCloudDp.cancel() + + // Then + XCTAssertTrue(generalCloudDp.isCanceled) + XCTAssertNil(generalCloudDp.downloadTask) + XCTAssertNil(generalCloudDp.urlSession) + } + + func test_cancel_withDownloadTask() throws { + // Given + let session = URLSession(configuration: .default) + let url = URL(string: "https://test.jamfcloud.com/test.pkg")! + generalCloudDp.urlSession = session + generalCloudDp.downloadTask = session.downloadTask(with: url) + generalCloudDp.dispatchGroup = DispatchGroup() + generalCloudDp.dispatchGroup?.enter() + + // When + generalCloudDp.cancel() + + // Then + XCTAssertTrue(generalCloudDp.isCanceled) + XCTAssertNil(generalCloudDp.downloadTask) + XCTAssertNil(generalCloudDp.urlSession) + } + + // MARK: - createBoundary tests + + func test_createBoundary_generatesValidBoundary() throws { + // When + let boundary = generalCloudDp.createBoundary() + + // Then + XCTAssertTrue(boundary.hasPrefix("------------------------")) + XCTAssertEqual(boundary.count, 46) // 24 dashes + 22 random chars + + // Verify it only contains alphanumeric characters after the dashes + let randomPart = String(boundary.dropFirst(24)) + XCTAssertTrue(randomPart.allSatisfy { $0.isLetter || $0.isNumber }) + } + + func test_createBoundary_generatesUniqueBoundaries() throws { + // When + let boundary1 = generalCloudDp.createBoundary() + let boundary2 = generalCloudDp.createBoundary() + let boundary3 = generalCloudDp.createBoundary() + + // Then - Should be highly unlikely to generate same boundary twice + XCTAssertNotEqual(boundary1, boundary2) + XCTAssertNotEqual(boundary2, boundary3) + XCTAssertNotEqual(boundary1, boundary3) + } + + // MARK: - createUrlSession tests + + func test_createUrlSession_returnsValidSession() throws { + // Given + let progress = SynchronizationProgress() + let sessionDelegate = CloudSessionDelegate(progress: progress) + + // When + let session = generalCloudDp.createUrlSession(sessionDelegate: sessionDelegate) + + // Then + XCTAssertNotNil(session) + XCTAssertNotNil(session.configuration) + } + + // MARK: - Static property tests + + func test_overheadPerFile_isCorrectValue() throws { + // Then + XCTAssertEqual(GeneralCloudDp.overheadPerFile, 112) + } + + // MARK: - Integration test concepts (without actual upload) + + func test_transferFile_setsCorrectOverheadSize() throws { + // Given + let package = Package(jamfProId: 1, displayName: "test", fileName: "test.pkg", category: "Test", size: 1000, checksums: Checksums()) + mockJamfProInstance.packages = [package] + + // Create a real temporary file for testing + let tempDir = FileManager.default.temporaryDirectory + let testFile = tempDir.appendingPathComponent("test_transfer.pkg") + let testData = "test data".data(using: .utf8)! + try testData.write(to: testFile) + + defer { + try? FileManager.default.removeItem(at: testFile) + } + + let srcFile = DpFile(name: "test.pkg", fileUrl: testFile, size: Int64(testData.count)) + let progress = SynchronizationProgress() + progress.printToConsole = true + + // Note: This test will fail at upload attempt since we don't have a real server + // But it should get far enough to verify boundary and overhead calculation + let expectation = XCTestExpectation() + Task { + do { + // When + try await generalCloudDp.transferFile(srcFile: srcFile, moveFrom: nil, progress: progress) + + // If we somehow succeed (shouldn't with mock), that's fine too + expectation.fulfill() + } catch { + // Expected to fail at network call, but we can verify progress was set up + XCTAssertGreaterThan(progress.overheadSizePerFile, GeneralCloudDp.overheadPerFile) + expectation.fulfill() + } + } + wait(for: [expectation], timeout: 5) + } + + // MARK: - Edge case tests + + func test_retrieveFileList_withPackagesMissingSize() throws { + // Given + let package1 = Package(jamfProId: 1, displayName: "Package1", fileName: "package1.pkg", category: "Test", size: nil, checksums: Checksums()) + mockJamfProInstance.packages = [package1] + + let expectation = XCTestExpectation() + Task { + // When + try await generalCloudDp.retrieveFileList() + + // Then + XCTAssertTrue(generalCloudDp.filesLoaded) + XCTAssertEqual(generalCloudDp.dpFiles.files.count, 1) + XCTAssertNil(generalCloudDp.dpFiles.files[0].size) + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + } + + func test_retrieveFileList_withPackagesHavingChecksums() throws { + // Given + let checksums = Checksums() + checksums.updateChecksum(Checksum(type: .MD5, value: "abc123")) + checksums.updateChecksum(Checksum(type: .SHA_256, value: "def456")) + + let package1 = Package(jamfProId: 1, displayName: "Package1", fileName: "package1.pkg", category: "Test", size: 1000, checksums: checksums) + mockJamfProInstance.packages = [package1] + + let expectation = XCTestExpectation() + Task { + // When + try await generalCloudDp.retrieveFileList() + + // Then + XCTAssertTrue(generalCloudDp.filesLoaded) + XCTAssertEqual(generalCloudDp.dpFiles.files.count, 1) + XCTAssertNotNil(generalCloudDp.dpFiles.files[0].checksums.findChecksum(type: .MD5)) + XCTAssertNotNil(generalCloudDp.dpFiles.files[0].checksums.findChecksum(type: .SHA_256)) + XCTAssertEqual(generalCloudDp.dpFiles.files[0].checksums.findChecksum(type: .MD5)?.value, "abc123") + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + } + + func test_cancel_whenAlreadyCanceled() throws { + // Given + generalCloudDp.cancel() + XCTAssertTrue(generalCloudDp.isCanceled) + + // When - Cancel again + generalCloudDp.cancel() + + // Then - Should still be in canceled state without error + XCTAssertTrue(generalCloudDp.isCanceled) + } + + func test_cancel_withNilProperties() throws { + // Given - Everything is nil by default after init + XCTAssertNil(generalCloudDp.urlSession) + XCTAssertNil(generalCloudDp.downloadTask) + XCTAssertNil(generalCloudDp.dispatchGroup) + + // When + generalCloudDp.cancel() + + // Then - Should handle nil gracefully + XCTAssertTrue(generalCloudDp.isCanceled) + } +} diff --git a/JamfSyncTests/Model/Jcds2DpAdditionalTests.swift b/JamfSyncTests/Model/Jcds2DpAdditionalTests.swift new file mode 100644 index 0000000..3266e5b --- /dev/null +++ b/JamfSyncTests/Model/Jcds2DpAdditionalTests.swift @@ -0,0 +1,563 @@ +// +// Copyright 2026, Jamf +// + +@testable import Jamf_Sync +import XCTest + +final class Jcds2DpAdditionalTests: XCTestCase { + var jcds2Dp: PartialMockJcds2Dp! + var mockJamfProInstance: MockJamfProInstance! + let jamfProInstanceId = UUID() + + override func setUpWithError() throws { + jcds2Dp = PartialMockJcds2Dp() + mockJamfProInstance = MockJamfProInstance() + mockJamfProInstance.id = jamfProInstanceId + mockJamfProInstance.url = URL(string: "https://test.jamfcloud.com") + mockJamfProInstance.token = "test-token" + + jcds2Dp.jamfProInstanceId = jamfProInstanceId + jcds2Dp.mockJamfProInstance = mockJamfProInstance + + // Add to DataModel for findJamfProInstance + DataModel.shared.savableItems.items.removeAll() + DataModel.shared.savableItems.items.append(mockJamfProInstance) + } + + override func tearDownWithError() throws { + jcds2Dp = nil + mockJamfProInstance = nil + DataModel.shared.savableItems.items.removeAll() + } + + // MARK: - Initialization tests + + func test_init_setsPropertiesCorrectly() throws { + // Given/When + let cloudDp = Jcds2Dp(jamfProInstanceId: jamfProInstanceId, jamfProInstanceName: "Test") + + // Then + XCTAssertEqual(cloudDp.name, "JCDS") + XCTAssertEqual(cloudDp.jamfProInstanceId, jamfProInstanceId) + XCTAssertEqual(cloudDp.jamfProInstanceName, "Test") + XCTAssertTrue(cloudDp.willDownloadFiles) + XCTAssertEqual(cloudDp.expirationBuffer, 60) + } + + func test_init_withNoParameters() throws { + // Given/When + let cloudDp = Jcds2Dp() + + // Then + XCTAssertEqual(cloudDp.name, "JCDS") + XCTAssertNil(cloudDp.jamfProInstanceId) + XCTAssertNil(cloudDp.jamfProInstanceName) + XCTAssertTrue(cloudDp.willDownloadFiles) + } + + // MARK: - retrieveFileList additional tests + + func test_retrieveFileList_withFileTypeFiltering() throws { + // Given + let filesRequestResponse = """ + [ { + "fileName" : "test.pkg", + "length" : 1000, + "md5" : "abc123", + "sha3" : "def456" + }, { + "fileName" : "test.txt", + "length" : 100, + "md5" : "xyz789", + "sha3" : "uvw012" + } ] + """ + let url: URL = mockJamfProInstance.url!.appendingPathComponent("/api/v1/jcds/files") + mockJamfProInstance.mockRequestsAndResponses.append( + MockDataRequestResponse(url: url, httpMethod: "GET", contentType: "application/json", + returnData: filesRequestResponse.data(using: .utf8)) + ) + + let expectation = XCTestExpectation() + Task { + // When - limit file types + try await jcds2Dp.retrieveFileList(limitFileTypes: true) + + // Then - only .pkg should be included + XCTAssertTrue(jcds2Dp.filesLoaded) + XCTAssertEqual(jcds2Dp.dpFiles.files.count, 1) + XCTAssertEqual(jcds2Dp.dpFiles.files[0].name, "test.pkg") + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + } + + func test_retrieveFileList_withoutFileTypeFiltering() throws { + // Given + let filesRequestResponse = """ + [ { + "fileName" : "test.pkg", + "length" : 1000, + "md5" : "abc123", + "sha3" : "def456" + }, { + "fileName" : "test.txt", + "length" : 100, + "md5" : "xyz789", + "sha3" : "uvw012" + } ] + """ + let url: URL = mockJamfProInstance.url!.appendingPathComponent("/api/v1/jcds/files") + mockJamfProInstance.mockRequestsAndResponses.append( + MockDataRequestResponse(url: url, httpMethod: "GET", contentType: "application/json", + returnData: filesRequestResponse.data(using: .utf8)) + ) + + let expectation = XCTestExpectation() + Task { + // When - don't limit file types + try await jcds2Dp.retrieveFileList(limitFileTypes: false) + + // Then - both should be included + XCTAssertTrue(jcds2Dp.filesLoaded) + XCTAssertEqual(jcds2Dp.dpFiles.files.count, 2) + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + } + + func test_retrieveFileList_emptyArray() throws { + // Given + let filesRequestResponse = "[]" + let url: URL = mockJamfProInstance.url!.appendingPathComponent("/api/v1/jcds/files") + mockJamfProInstance.mockRequestsAndResponses.append( + MockDataRequestResponse(url: url, httpMethod: "GET", contentType: "application/json", + returnData: filesRequestResponse.data(using: .utf8)) + ) + + let expectation = XCTestExpectation() + Task { + // When + try await jcds2Dp.retrieveFileList() + + // Then + XCTAssertTrue(jcds2Dp.filesLoaded) + XCTAssertEqual(jcds2Dp.dpFiles.files.count, 0) + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + } + + func test_retrieveFileList_withOnlyMd5() throws { + // Given + let filesRequestResponse = """ + [ { + "fileName" : "test.pkg", + "length" : 1000, + "md5" : "abc123" + } ] + """ + let url: URL = mockJamfProInstance.url!.appendingPathComponent("/api/v1/jcds/files") + mockJamfProInstance.mockRequestsAndResponses.append( + MockDataRequestResponse(url: url, httpMethod: "GET", contentType: "application/json", + returnData: filesRequestResponse.data(using: .utf8)) + ) + + let expectation = XCTestExpectation() + Task { + // When + try await jcds2Dp.retrieveFileList() + + // Then + XCTAssertTrue(jcds2Dp.filesLoaded) + XCTAssertEqual(jcds2Dp.dpFiles.files.count, 1) + XCTAssertNotNil(jcds2Dp.dpFiles.files[0].checksums.findChecksum(type: .MD5)) + XCTAssertNil(jcds2Dp.dpFiles.files[0].checksums.findChecksum(type: .SHA3_512)) + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + } + + func test_retrieveFileList_withOnlySha3() throws { + // Given + let filesRequestResponse = """ + [ { + "fileName" : "test.pkg", + "length" : 1000, + "sha3" : "def456" + } ] + """ + let url: URL = mockJamfProInstance.url!.appendingPathComponent("/api/v1/jcds/files") + mockJamfProInstance.mockRequestsAndResponses.append( + MockDataRequestResponse(url: url, httpMethod: "GET", contentType: "application/json", + returnData: filesRequestResponse.data(using: .utf8)) + ) + + let expectation = XCTestExpectation() + Task { + // When + try await jcds2Dp.retrieveFileList() + + // Then + XCTAssertTrue(jcds2Dp.filesLoaded) + XCTAssertEqual(jcds2Dp.dpFiles.files.count, 1) + XCTAssertNil(jcds2Dp.dpFiles.files[0].checksums.findChecksum(type: .MD5)) + XCTAssertNotNil(jcds2Dp.dpFiles.files[0].checksums.findChecksum(type: .SHA3_512)) + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + } + + func test_retrieveFileList_nilJamfProInstanceId() throws { + // Given + jcds2Dp.jamfProInstanceId = nil + + let expectation = XCTestExpectation() + Task { + do { + // When + try await jcds2Dp.retrieveFileList() + + // Then + XCTFail("Should have thrown ServerCommunicationError.noJamfProUrl") + } catch ServerCommunicationError.noJamfProUrl { + // Expected + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + } + + // MARK: - deleteFile tests + + func test_deleteFile_success() throws { + // Given + let dpFile = DpFile(name: "test.pkg", size: 1000) + let progress = SynchronizationProgress() + + let deleteUrl = mockJamfProInstance.url!.appendingPathComponent("/api/v1/jcds/files/test.pkg") + mockJamfProInstance.mockRequestsAndResponses.append( + MockDataRequestResponse(url: deleteUrl, httpMethod: "DELETE", contentType: "application/json", + returnData: Data()) + ) + + let expectation = XCTestExpectation() + Task { + // When + try await jcds2Dp.deleteFile(file: dpFile, progress: progress) + + // Then - Should complete without error + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + } + + func test_deleteFile_noJamfProInstance() throws { + // Given + jcds2Dp.mockJamfProInstance = nil // No instance found + let dpFile = DpFile(name: "test.pkg", size: 1000) + let progress = SynchronizationProgress() + + let expectation = XCTestExpectation() + Task { + do { + // When + try await jcds2Dp.deleteFile(file: dpFile, progress: progress) + + // Then + XCTFail("Should have thrown ServerCommunicationError.noJamfProUrl") + } catch ServerCommunicationError.noJamfProUrl { + // Expected + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + } + + func test_deleteFile_nilJamfProUrl() throws { + // Given + mockJamfProInstance.url = nil + let dpFile = DpFile(name: "test.pkg", size: 1000) + let progress = SynchronizationProgress() + + let expectation = XCTestExpectation() + Task { + do { + // When + try await jcds2Dp.deleteFile(file: dpFile, progress: progress) + + // Then + XCTFail("Should have thrown ServerCommunicationError.noJamfProUrl") + } catch ServerCommunicationError.noJamfProUrl { + // Expected + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + } + + // MARK: - cancel tests + + func test_cancel_clearsState() throws { + // Given + jcds2Dp.urlSession = URLSession(configuration: .default) + jcds2Dp.dispatchGroup = DispatchGroup() + jcds2Dp.dispatchGroup?.enter() // Must enter before leave() is called + + // When + jcds2Dp.cancel() + + // Then + XCTAssertTrue(jcds2Dp.isCanceled) + XCTAssertNil(jcds2Dp.downloadTask) + XCTAssertNil(jcds2Dp.urlSession) + } + + func test_cancel_withDownloadTask() throws { + // Given + let session = URLSession(configuration: .default) + let url = URL(string: "https://test.jamfcloud.com/test.pkg")! + jcds2Dp.urlSession = session + jcds2Dp.downloadTask = session.downloadTask(with: url) + jcds2Dp.dispatchGroup = DispatchGroup() + jcds2Dp.dispatchGroup?.enter() + + // When + jcds2Dp.cancel() + + // Then + XCTAssertTrue(jcds2Dp.isCanceled) + XCTAssertNil(jcds2Dp.downloadTask) + XCTAssertNil(jcds2Dp.urlSession) + } + + func test_cancel_multipartUploadIsNilAfterCancel() throws { + // Given + // Note: We can't easily construct a MultipartUpload in tests, so we just verify + // that cancel() properly nils it out when it exists + jcds2Dp.dispatchGroup = DispatchGroup() + jcds2Dp.dispatchGroup?.enter() + + // When + jcds2Dp.cancel() + + // Then + XCTAssertTrue(jcds2Dp.isCanceled) + // MultipartUpload would be nil after cancel if it existed + } + + func test_cancel_whenAlreadyCanceled() throws { + // Given + jcds2Dp.cancel() + XCTAssertTrue(jcds2Dp.isCanceled) + + // When - Cancel again + jcds2Dp.cancel() + + // Then - Should still be in canceled state without error + XCTAssertTrue(jcds2Dp.isCanceled) + } + + func test_cancel_withNilProperties() throws { + // Given - Everything is nil by default + XCTAssertNil(jcds2Dp.urlSession) + XCTAssertNil(jcds2Dp.downloadTask) + XCTAssertNil(jcds2Dp.dispatchGroup) + + // When + jcds2Dp.cancel() + + // Then - Should handle nil gracefully + XCTAssertTrue(jcds2Dp.isCanceled) + } + + // MARK: - createUrlSession tests + + func test_createUrlSession_returnsValidSession() throws { + // Given + let progress = SynchronizationProgress() + let sessionDelegate = CloudSessionDelegate(progress: progress) + + // When + let session = jcds2Dp.createUrlSession(sessionDelegate: sessionDelegate) + + // Then + XCTAssertNotNil(session) + XCTAssertNotNil(session.configuration) + XCTAssertNotNil(session.delegate) + } + + // MARK: - downloadFile tests + + func test_downloadFile_noJamfProInstance() throws { + // Given + jcds2Dp.jamfProInstanceId = UUID() // Non-existent + let dpFile = DpFile(name: "test.pkg", size: 1000) + let progress = SynchronizationProgress() + + let expectation = XCTestExpectation() + Task { + do { + // When + _ = try await jcds2Dp.downloadFile(file: dpFile, progress: progress) + + // Then + XCTFail("Should have thrown an error") + } catch { + // Expected - will fail trying to get cloud URI + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + } + + func test_downloadFile_nilJamfProUrl() throws { + // Given + mockJamfProInstance.url = nil + let dpFile = DpFile(name: "test.pkg", size: 1000) + let progress = SynchronizationProgress() + + let expectation = XCTestExpectation() + Task { + do { + // When + _ = try await jcds2Dp.downloadFile(file: dpFile, progress: progress) + + // Then + XCTFail("Should have thrown ServerCommunicationError.noJamfProUrl") + } catch ServerCommunicationError.noJamfProUrl { + // Expected + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + } + + // MARK: - transferFile tests + + func test_transferFile_noJamfProInstance() throws { + // Given + jcds2Dp.mockJamfProInstance = nil // No instance found + let srcFile = DpFile(name: "test.pkg", fileUrl: URL(fileURLWithPath: "/tmp/test.pkg"), size: 1000) + let progress = SynchronizationProgress() + + let expectation = XCTestExpectation() + Task { + do { + // When + try await jcds2Dp.transferFile(srcFile: srcFile, moveFrom: nil, progress: progress) + + // Then + XCTFail("Should have thrown ServerCommunicationError.noJamfProUrl") + } catch ServerCommunicationError.noJamfProUrl { + // Expected - will fail trying to initiate upload + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + } + + func test_transferFile_noFileUrl() throws { + // Given + let initiateUploadResponse = """ + { + "region": "us-east-1", + "bucketName": "test-bucket", + "path": "test/path", + "uuid": "test-uuid", + "chunkSize": 5242880, + "accessKeyID": "test-access-key", + "secretAccessKey": "test-secret-key", + "sessionToken": "test-session-token", + "expiration": "2026-12-31T23:59:59Z" + } + """ + let initiateUploadUrl = mockJamfProInstance.url!.appendingPathComponent("/api/v1/jcds/files") + mockJamfProInstance.mockRequestsAndResponses.append( + MockDataRequestResponse(url: initiateUploadUrl, httpMethod: "POST", contentType: "application/json", + returnData: initiateUploadResponse.data(using: .utf8)) + ) + + let srcFile = DpFile(name: "test.pkg", size: 1000) // No fileUrl + let progress = SynchronizationProgress() + + let expectation = XCTestExpectation() + Task { + do { + // When + try await jcds2Dp.transferFile(srcFile: srcFile, moveFrom: nil, progress: progress) + + // Then + XCTFail("Should have thrown an error") + } catch { + // Expected - should throw error when fileUrl is nil + // Most likely badFileUrl or failedToInitiateCloudUpload + } + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + } + + // MARK: - finalizeTransfer tests + + // test_finalizeTransfer_withMultipartUpload removed - requires complex multipart upload setup + + func test_finalizeTransfer_withoutMultipartUpload() throws { + // Given + jcds2Dp.multipartUpload = nil + + let expectation = XCTestExpectation() + Task { + // When + try await jcds2Dp.finalizeTransfer() + + // Then - Should complete without error (no-op) + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + } + + // MARK: - Edge case tests + + func test_expirationBuffer_hasCorrectValue() throws { + // Then + XCTAssertEqual(jcds2Dp.expirationBuffer, 60) + } + + func test_operationQueue_isInitialized() throws { + // Then + XCTAssertNotNil(jcds2Dp.operationQueue) + } + + func test_retrieveFileList_clearsExistingFiles() throws { + // Given - Pre-populate with files + jcds2Dp.dpFiles.files.append(DpFile(name: "old.pkg", size: 999)) + XCTAssertEqual(jcds2Dp.dpFiles.files.count, 1) + + let filesRequestResponse = """ + [ { + "fileName" : "new.pkg", + "length" : 1000, + "md5" : "abc123" + } ] + """ + let url: URL = mockJamfProInstance.url!.appendingPathComponent("/api/v1/jcds/files") + mockJamfProInstance.mockRequestsAndResponses.append( + MockDataRequestResponse(url: url, httpMethod: "GET", contentType: "application/json", + returnData: filesRequestResponse.data(using: .utf8)) + ) + + let expectation = XCTestExpectation() + Task { + // When + try await jcds2Dp.retrieveFileList() + + // Then - Old files should be cleared + XCTAssertEqual(jcds2Dp.dpFiles.files.count, 1) + XCTAssertEqual(jcds2Dp.dpFiles.files[0].name, "new.pkg") + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + } +} diff --git a/JamfSyncTests/Model/SynchronizationProgressTests.swift b/JamfSyncTests/Model/SynchronizationProgressTests.swift new file mode 100644 index 0000000..ec9afd6 --- /dev/null +++ b/JamfSyncTests/Model/SynchronizationProgressTests.swift @@ -0,0 +1,179 @@ +// +// Copyright 2024, Jamf +// + +@testable import Jamf_Sync +import XCTest + +final class SynchronizationProgressTests: XCTestCase { + var progress: SynchronizationProgress! + + override func setUpWithError() throws { + progress = SynchronizationProgress() + progress.printToConsole = true // Avoids MainActor dispatching so calls are synchronous + } + + // MARK: - fileProgress tests + + func testFileProgress_returnsNil_whenNoCurrentFile() { + XCTAssertNil(progress.fileProgress()) + } + + func testFileProgress_returnsNil_whenCurrentFileHasNilSize() { + progress.currentFile = DpFile(name: "test.pkg", size: nil) + progress.currentFileSizeTransferred = 500 + XCTAssertNil(progress.fileProgress()) + } + + func testFileProgress_returnsNil_whenCurrentFileSizeTransferredIsNil() { + progress.currentFile = DpFile(name: "test.pkg", size: 1000) + progress.currentFileSizeTransferred = nil + XCTAssertNil(progress.fileProgress()) + } + + func testFileProgress_returnsZero_whenNothingTransferred() { + progress.currentFile = DpFile(name: "test.pkg", size: 1000) + progress.currentFileSizeTransferred = 0 + XCTAssertEqual(progress.fileProgress(), 0.0) + } + + func testFileProgress_returnsHalf_whenHalfTransferred() { + progress.currentFile = DpFile(name: "test.pkg", size: 1000) + progress.currentFileSizeTransferred = 500 + XCTAssertEqual(try XCTUnwrap(progress.fileProgress()), 0.5, accuracy: 0.001) + } + + func testFileProgress_returnsOne_whenFullyTransferred() { + progress.currentFile = DpFile(name: "test.pkg", size: 1000) + progress.currentFileSizeTransferred = 1000 + XCTAssertEqual(try XCTUnwrap(progress.fileProgress()), 1.0, accuracy: 0.001) + } + + func testFileProgress_includesOverhead() { + progress.currentFile = DpFile(name: "test.pkg", size: 900) + progress.overheadSizePerFile = 100 // effective size = 1000 + progress.currentFileSizeTransferred = 500 + XCTAssertEqual(try XCTUnwrap(progress.fileProgress()), 0.5, accuracy: 0.001) + } + + func testFileProgress_returnsNil_whenFileSizeIsZero() { + progress.currentFile = DpFile(name: "test.pkg", size: 0) + progress.currentFileSizeTransferred = 0 + XCTAssertNil(progress.fileProgress()) + } + + // MARK: - totalProgress tests + + func testTotalProgress_returnsNil_whenTotalSizeIsNil() { + progress.currentTotalSizeTransferred = 500 + XCTAssertNil(progress.totalProgress()) + } + + func testTotalProgress_returnsNil_whenTotalSizeIsZero() { + progress.totalSize = 0 + XCTAssertNil(progress.totalProgress()) + } + + func testTotalProgress_returnsZero_whenNothingTransferred() { + progress.totalSize = 1000 + progress.currentTotalSizeTransferred = 0 + XCTAssertEqual(progress.totalProgress(), 0.0) + } + + func testTotalProgress_returnsHalf_whenHalfTransferred() { + progress.totalSize = 1000 + progress.currentTotalSizeTransferred = 500 + XCTAssertEqual(try XCTUnwrap(progress.totalProgress()), 0.5, accuracy: 0.001) + } + + func testTotalProgress_returnsOne_whenFullyTransferred() { + progress.totalSize = 1000 + progress.currentTotalSizeTransferred = 1000 + XCTAssertEqual(try XCTUnwrap(progress.totalProgress()), 1.0, accuracy: 0.001) + } + + // MARK: - initializeFileTransferInfoForFile tests + + func testInitializeFileTransferInfo_setsOperation() { + let file = DpFile(name: "test.pkg", size: 1000) + progress.initializeFileTransferInfoForFile(operation: "Uploading", currentFile: file, currentTotalSizeTransferred: 0) + XCTAssertEqual(progress.operation, "Uploading") + } + + func testInitializeFileTransferInfo_setsCurrentFile() { + let file = DpFile(name: "test.pkg", size: 1000) + progress.initializeFileTransferInfoForFile(operation: "Uploading", currentFile: file, currentTotalSizeTransferred: 0) + XCTAssertEqual(progress.currentFile?.name, "test.pkg") + } + + func testInitializeFileTransferInfo_setsTotalSizeTransferred() { + let file = DpFile(name: "test.pkg", size: 1000) + progress.initializeFileTransferInfoForFile(operation: "Uploading", currentFile: file, currentTotalSizeTransferred: 250) + XCTAssertEqual(progress.currentTotalSizeTransferred, 250) + } + + func testInitializeFileTransferInfo_resetsFileSizeTransferred() { + progress.currentFileSizeTransferred = 999 + let file = DpFile(name: "test.pkg", size: 1000) + progress.initializeFileTransferInfoForFile(operation: "Uploading", currentFile: file, currentTotalSizeTransferred: 0) + XCTAssertEqual(progress.currentFileSizeTransferred, 0) + } + + func testInitializeFileTransferInfo_nilOperation() { + let file = DpFile(name: "test.pkg", size: 1000) + progress.initializeFileTransferInfoForFile(operation: nil, currentFile: file, currentTotalSizeTransferred: 0) + XCTAssertNil(progress.operation) + } + + // MARK: - updateFileTransferInfo tests + + func testUpdateFileTransferInfo_uploading_accumulatesBytesTransferred() { + let file = DpFile(name: "test.pkg", size: 1000) + progress.initializeFileTransferInfoForFile(operation: "Uploading", currentFile: file, currentTotalSizeTransferred: 0) + progress.updateFileTransferInfo(totalBytesTransferred: 300, bytesTransferred: 300) + progress.updateFileTransferInfo(totalBytesTransferred: 600, bytesTransferred: 300) + XCTAssertEqual(progress.currentFileSizeTransferred, 600) + } + + func testUpdateFileTransferInfo_uploading_incrementsTotalTransferred() { + let file = DpFile(name: "test.pkg", size: 1000) + progress.initializeFileTransferInfoForFile(operation: "Uploading", currentFile: file, currentTotalSizeTransferred: 100) + progress.updateFileTransferInfo(totalBytesTransferred: 500, bytesTransferred: 500) + XCTAssertEqual(progress.currentTotalSizeTransferred, 600) + } + + func testUpdateFileTransferInfo_downloading_usesTotalBytesTransferred() { + let file = DpFile(name: "test.pkg", size: 1000) + progress.initializeFileTransferInfoForFile(operation: "Downloading", currentFile: file, currentTotalSizeTransferred: 0) + progress.updateFileTransferInfo(totalBytesTransferred: 400, bytesTransferred: 400) + XCTAssertEqual(progress.currentFileSizeTransferred, 400) + } + + func testUpdateFileTransferInfo_downloading_resetsFileSizeAt100Percent() { + let file = DpFile(name: "test.pkg", size: 1000) + progress.initializeFileTransferInfoForFile(operation: "Downloading", currentFile: file, currentTotalSizeTransferred: 0) + // Transfer 100% — isAt100Percent() returns true, so currentFileSizeTransferred should reset to 0 + progress.updateFileTransferInfo(totalBytesTransferred: 1000, bytesTransferred: 1000) + XCTAssertEqual(progress.currentFileSizeTransferred, 0) + } + + // MARK: - finalProgressValues tests + + func testFinalProgressValues_setsFileSizeTransferred() { + progress.finalProgressValues(totalBytesTransferred: 1000, currentTotalSizeTransferred: 5000) + XCTAssertEqual(progress.currentFileSizeTransferred, 1000) + } + + func testFinalProgressValues_setsTotalSizeTransferred() { + progress.finalProgressValues(totalBytesTransferred: 1000, currentTotalSizeTransferred: 5000) + XCTAssertEqual(progress.currentTotalSizeTransferred, 5000) + } + + func testFinalProgressValues_overwritesPreviousValues() { + progress.currentFileSizeTransferred = 100 + progress.currentTotalSizeTransferred = 200 + progress.finalProgressValues(totalBytesTransferred: 1000, currentTotalSizeTransferred: 5000) + XCTAssertEqual(progress.currentFileSizeTransferred, 1000) + XCTAssertEqual(progress.currentTotalSizeTransferred, 5000) + } +} diff --git a/JamfSyncTests/Multipart/MultipartUploadTests.swift b/JamfSyncTests/Multipart/MultipartUploadTests.swift new file mode 100644 index 0000000..12330df --- /dev/null +++ b/JamfSyncTests/Multipart/MultipartUploadTests.swift @@ -0,0 +1,367 @@ +// +// Copyright 2026, Jamf +// + +@testable import Jamf_Sync +import XCTest + +// MARK: - Helpers + +class MockRenewToken: RenewTokenProtocol { + var renewCalled = false + func renewUploadToken() async throws { + renewCalled = true + } +} + +/// Subclass that overrides uploadChunk so processMultipartUpload can be tested without network +class MockMultipartUpload: MultipartUpload { + var chunkResults: [Int: Error?] = [:] // partNumber -> nil means success, non-nil means throw + var uploadedChunks: [Int] = [] + + override func uploadChunk(whichChunk: Int, uploadId: String, fileUrl: URL, progress: SynchronizationProgress) async throws { + if let result = chunkResults[whichChunk], let error = result { + throw error + } + uploadedChunks.append(whichChunk) + } +} + +// MARK: - MultipartUploadTests + +final class MultipartUploadTests: XCTestCase { + + var upload: MultipartUpload! + var mockSession: MockURLSession! + var mockRenew: MockRenewToken! + var progress: SynchronizationProgress! + + override func setUp() { + super.setUp() + mockSession = MockURLSession() + mockRenew = MockRenewToken() + progress = SynchronizationProgress() + upload = MultipartUpload( + initiateUploadData: makeInitiateUpload(), + renewTokenObject: mockRenew, + progress: progress, + sharedSession: mockSession + ) + } + + // MARK: - tagValue tests + + func test_tagValue_extractsValueBetweenTags() { + let xml = "abc-123" + XCTAssertEqual(upload.tagValue(xmlString: xml, startTag: "", endTag: ""), "abc-123") + } + + func test_tagValue_returnsEmptyWhenTagMissing() { + let xml = "value" + XCTAssertEqual(upload.tagValue(xmlString: xml, startTag: "", endTag: ""), "") + } + + func test_tagValue_returnsEmptyForEmptyString() { + XCTAssertEqual(upload.tagValue(xmlString: "", startTag: "", endTag: ""), "") + } + + func test_tagValue_handlesMultipleTags() { + let xml = "firstsecond" + XCTAssertEqual(upload.tagValue(xmlString: xml, startTag: "", endTag: ""), "second") + } + + // MARK: - contentType tests + + func test_contentType_pkg() { + XCTAssertEqual(upload.contentType(filename: "installer.pkg"), "application/x-newton-compatible-pkg") + } + + func test_contentType_mpkg() { + XCTAssertEqual(upload.contentType(filename: "installer.mpkg"), "application/x-newton-compatible-pkg") + } + + func test_contentType_dmg() { + XCTAssertEqual(upload.contentType(filename: "disk.dmg"), "application/octet-stream") + } + + func test_contentType_zip() { + XCTAssertEqual(upload.contentType(filename: "archive.zip"), "application/zip") + } + + func test_contentType_unknown() { + XCTAssertNil(upload.contentType(filename: "script.sh")) + } + + // MARK: - createCompletedPartsXml tests + + func test_createCompletedPartsXml_emptyList() { + let xml = upload.createCompletedPartsXml() + XCTAssertTrue(xml.contains("")) + XCTAssertFalse(xml.contains("")) + } + + func test_createCompletedPartsXml_singlePart() { + upload.partNumberEtagList = [CompletedChunk(partNumber: 1, eTag: "etag1")] + let xml = upload.createCompletedPartsXml() + XCTAssertTrue(xml.contains("1")) + XCTAssertTrue(xml.contains("etag1")) + } + + func test_createCompletedPartsXml_multiplePartsSortedByPartNumber() { + upload.partNumberEtagList = [ + CompletedChunk(partNumber: 3, eTag: "etag3"), + CompletedChunk(partNumber: 1, eTag: "etag1"), + CompletedChunk(partNumber: 2, eTag: "etag2") + ] + let xml = upload.createCompletedPartsXml() + let pos1 = xml.range(of: "1")!.lowerBound + let pos2 = xml.range(of: "2")!.lowerBound + let pos3 = xml.range(of: "3")!.lowerBound + XCTAssertTrue(pos1 < pos2 && pos2 < pos3, "Parts should be sorted in ascending order") + } + + // MARK: - hmac_sha256 tests + + func test_hmac_sha256_returns64CharHex() { + let result = upload.hmac_sha256(date: "20240101", secretKey: "secret", key: "packages/test.pkg", region: "us-east-1", stringToSign: "AWS4-HMAC-SHA256\ntest\nscope\nhash") + XCTAssertEqual(result.count, 64) + XCTAssertTrue(result.allSatisfy { $0.isHexDigit }, "Result should be lowercase hex") + } + + func test_hmac_sha256_deterministicOutput() { + let args = ("20240101", "secret", "packages/test.pkg", "us-east-1", "stringToSign") + let result1 = upload.hmac_sha256(date: args.0, secretKey: args.1, key: args.2, region: args.3, stringToSign: args.4) + let result2 = upload.hmac_sha256(date: args.0, secretKey: args.1, key: args.2, region: args.3, stringToSign: args.4) + XCTAssertEqual(result1, result2) + } + + func test_hmac_sha256_differentInputsProduceDifferentOutput() { + let result1 = upload.hmac_sha256(date: "20240101", secretKey: "secret1", key: "packages/test.pkg", region: "us-east-1", stringToSign: "data") + let result2 = upload.hmac_sha256(date: "20240101", secretKey: "secret2", key: "packages/test.pkg", region: "us-east-1", stringToSign: "data") + XCTAssertNotEqual(result1, result2) + } + + // MARK: - awsSignature256 tests + + func test_awsSignature256_returns64CharHex() { + let result = upload.awsSignature256( + for: "sessionToken", httpMethod: "PUT", date: "20240101T000000Z", + accessKeyId: "AKID", secretKey: "secret", bucket: "mybucket", + key: "packages/test.pkg", queryParameters: "", region: "us-east-1", + currentDate: "2024-01-01 00:00:00 +0000" + ) + XCTAssertEqual(result.count, 64) + XCTAssertTrue(result.allSatisfy { $0.isHexDigit }) + } + + func test_awsSignature256_deterministicOutput() { + let sig1 = upload.awsSignature256(for: "token", httpMethod: "PUT", date: "20240101T000000Z", accessKeyId: "AKID", secretKey: "secret", bucket: "bucket", key: "key", queryParameters: "", region: "us-east-1", currentDate: "2024-01-01 00:00:00 +0000") + let sig2 = upload.awsSignature256(for: "token", httpMethod: "PUT", date: "20240101T000000Z", accessKeyId: "AKID", secretKey: "secret", bucket: "bucket", key: "key", queryParameters: "", region: "us-east-1", currentDate: "2024-01-01 00:00:00 +0000") + XCTAssertEqual(sig1, sig2) + } + + func test_awsSignature256_differentMethodProducesDifferentSignature() { + let sig1 = upload.awsSignature256(for: "token", httpMethod: "PUT", date: "20240101T000000Z", accessKeyId: "AKID", secretKey: "secret", bucket: "bucket", key: "key", queryParameters: "", region: "us-east-1", currentDate: "2024-01-01 00:00:00 +0000") + let sig2 = upload.awsSignature256(for: "token", httpMethod: "POST", date: "20240101T000000Z", accessKeyId: "AKID", secretKey: "secret", bucket: "bucket", key: "key", queryParameters: "", region: "us-east-1", currentDate: "2024-01-01 00:00:00 +0000") + XCTAssertNotEqual(sig1, sig2) + } + + // MARK: - startMultipartUpload tests + + func test_startMultipartUpload_throwsWhenFileSizeExceedsMax() async { + let fileUrl = URL(fileURLWithPath: "/tmp/large.pkg") + let oversizedBytes: Int64 = 32_212_255_001 + + do { + _ = try await upload.startMultipartUpload(fileUrl: fileUrl, fileSize: oversizedBytes) + XCTFail("Should have thrown maxUploadSizeExceeded") + } catch DistributionPointError.maxUploadSizeExceeded { + // expected + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func test_startMultipartUpload_setsCorrectTotalChunks() async throws { + let chunkSize = upload.chunkSize + let fileSize = Int64(chunkSize * 3 + 1) // 4 chunks + let uploadIdXml = "uid-abc" + mockSession.dataResult = makeHTTPResponse(body: uploadIdXml, statusCode: 200) + + _ = try await upload.startMultipartUpload(fileUrl: URL(fileURLWithPath: "/tmp/test.pkg"), fileSize: fileSize) + + XCTAssertEqual(upload.totalChunks, 4) + } + + func test_startMultipartUpload_returnsUploadId() async throws { + let uploadIdXml = "uid-abc" + mockSession.dataResult = makeHTTPResponse(body: uploadIdXml, statusCode: 200) + + let uploadId = try await upload.startMultipartUpload(fileUrl: URL(fileURLWithPath: "/tmp/test.pkg"), fileSize: 1024) + + XCTAssertEqual(uploadId, "uid-abc") + } + + func test_startMultipartUpload_throwsOnHTTPError() async { + mockSession.dataResult = makeHTTPResponse(body: "Service Unavailable", statusCode: 503) + + do { + _ = try await upload.startMultipartUpload(fileUrl: URL(fileURLWithPath: "/tmp/test.pkg"), fileSize: 1024) + XCTFail("Should have thrown") + } catch ServerCommunicationError.uploadFailed(let statusCode, _) { + XCTAssertEqual(statusCode, 503) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func test_startMultipartUpload_throwsWhenUploadIdMissing() async { + mockSession.dataResult = makeHTTPResponse(body: "Access Denied", statusCode: 200) + + do { + _ = try await upload.startMultipartUpload(fileUrl: URL(fileURLWithPath: "/tmp/test.pkg"), fileSize: 1024) + XCTFail("Should have thrown uploadFailure") + } catch DistributionPointError.uploadFailure { + // expected + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + // MARK: - processMultipartUpload tests + + func test_processMultipartUpload_allChunksSucceed() async throws { + let mockUpload = makeMockUpload() + mockUpload.totalChunks = 3 + + try await mockUpload.processMultipartUpload(whichChunk: 1, uploadId: "uid", fileUrl: URL(fileURLWithPath: "/tmp/test.pkg")) + + XCTAssertEqual(Set(mockUpload.uploadedChunks), Set([1, 2, 3])) + } + + func test_processMultipartUpload_retriesChunkOnFirstFailure() async throws { + let mockUpload = makeMockUpload() + mockUpload.totalChunks = 2 + var callCount = 0 + // Chunk 1 fails on first attempt only + mockUpload.chunkResults[1] = nil // will be overridden per-call in subclass... need a stateful mock + // Use a stateful approach: override to fail first call then succeed + let statefulUpload = StatefulMockMultipartUpload( + initiateUploadData: makeInitiateUpload(), + renewTokenObject: mockRenew, + progress: progress, + sharedSession: mockSession + ) + statefulUpload.totalChunks = 2 + statefulUpload.failChunk = 1 + statefulUpload.failCount = 1 + + try await statefulUpload.processMultipartUpload(whichChunk: 1, uploadId: "uid", fileUrl: URL(fileURLWithPath: "/tmp/test.pkg")) + + XCTAssertEqual(Set(statefulUpload.uploadedChunks), Set([1, 2])) + } + + func test_processMultipartUpload_throwsWhenChunkFailsTwice() async { + let statefulUpload = StatefulMockMultipartUpload( + initiateUploadData: makeInitiateUpload(), + renewTokenObject: mockRenew, + progress: progress, + sharedSession: mockSession + ) + statefulUpload.totalChunks = 2 + statefulUpload.failChunk = 1 + statefulUpload.failCount = 2 // always fail chunk 1 + + do { + try await statefulUpload.processMultipartUpload(whichChunk: 1, uploadId: "uid", fileUrl: URL(fileURLWithPath: "/tmp/test.pkg")) + XCTFail("Should have thrown uploadFailure") + } catch DistributionPointError.uploadFailure { + // expected + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func test_processMultipartUpload_stopWhenCanceled() async throws { + let mockUpload = makeMockUpload() + mockUpload.totalChunks = 3 + mockUpload.isCanceled = true + + try await mockUpload.processMultipartUpload(whichChunk: 1, uploadId: "uid", fileUrl: URL(fileURLWithPath: "/tmp/test.pkg")) + + XCTAssertTrue(mockUpload.uploadedChunks.isEmpty, "No chunks should upload when already canceled") + } + + // MARK: - completeMultipartUpload tests + + func test_completeMultipartUpload_succeeds() async throws { + upload.partNumberEtagList = [CompletedChunk(partNumber: 1, eTag: "etag1")] + mockSession.dataResult = makeHTTPResponse(body: "", statusCode: 200) + + try await upload.completeMultipartUpload(fileUrl: URL(fileURLWithPath: "/tmp/test.pkg"), uploadId: "uid-abc") + // No throw = success + } + + func test_completeMultipartUpload_throwsOnHTTPError() async { + upload.partNumberEtagList = [CompletedChunk(partNumber: 1, eTag: "etag1")] + mockSession.dataResult = makeHTTPResponse(body: "Internal Server Error", statusCode: 500) + + do { + try await upload.completeMultipartUpload(fileUrl: URL(fileURLWithPath: "/tmp/test.pkg"), uploadId: "uid-abc") + XCTFail("Should have thrown") + } catch ServerCommunicationError.uploadFailed(let statusCode, _) { + XCTAssertEqual(statusCode, 500) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + // MARK: - Helpers + + private func makeInitiateUpload() -> JsonInitiateUpload { + let json = """ + {"accessKeyID":"AKID","secretAccessKey":"secret","sessionToken":"token", + "region":"us-east-1","bucketName":"mybucket","path":"packages/","uuid":"uuid1"} + """ + let decoder = JSONDecoder() + return try! decoder.decode(JsonInitiateUpload.self, from: Data(json.utf8)) + } + + private func makeMockUpload() -> MockMultipartUpload { + MockMultipartUpload( + initiateUploadData: makeInitiateUpload(), + renewTokenObject: mockRenew, + progress: progress, + sharedSession: mockSession + ) + } + + private func makeHTTPResponse(body: String, statusCode: Int) -> (Data, URLResponse) { + let data = Data(body.utf8) + let response = HTTPURLResponse( + url: URL(string: "https://mybucket.s3.amazonaws.com/")!, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil + )! + return (data, response) + } +} + +// MARK: - StatefulMockMultipartUpload + +/// Fails a specific chunk N times then succeeds, to test retry logic +class StatefulMockMultipartUpload: MultipartUpload { + var failChunk: Int = 0 + var failCount: Int = 0 + var uploadedChunks: [Int] = [] + private var failCallsSoFar = 0 + + override func uploadChunk(whichChunk: Int, uploadId: String, fileUrl: URL, progress: SynchronizationProgress) async throws { + if whichChunk == failChunk && failCallsSoFar < failCount { + failCallsSoFar += 1 + throw DistributionPointError.uploadFailure + } + uploadedChunks.append(whichChunk) + } +} diff --git a/JamfSyncTests/Utility/ArgumentParserTests.swift b/JamfSyncTests/Utility/ArgumentParserTests.swift new file mode 100644 index 0000000..4c83115 --- /dev/null +++ b/JamfSyncTests/Utility/ArgumentParserTests.swift @@ -0,0 +1,580 @@ +// +// Copyright 2026, Jamf +// + +@testable import Jamf_Sync +import XCTest + +final class ArgumentParserTests: XCTestCase { + + // MARK: - processArgs tests with help and version + + func test_processArgs_help_shortForm() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "-h"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertFalse(result, "Should return false to indicate program should exit") + XCTAssertTrue(parser.someArgumentsPassed) + XCTAssertNil(parser.srcDp) + XCTAssertNil(parser.dstDp) + } + + func test_processArgs_help_longForm() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "--help"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertFalse(result, "Should return false to indicate program should exit") + XCTAssertTrue(parser.someArgumentsPassed) + } + + func test_processArgs_help_noPrefix() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "-help"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertFalse(result, "Should return false to indicate program should exit") + XCTAssertTrue(parser.someArgumentsPassed) + } + + func test_processArgs_version_shortForm() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "-v"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertFalse(result, "Should return false to indicate program should exit") + XCTAssertTrue(parser.someArgumentsPassed) + } + + func test_processArgs_version_longForm() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "--version"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertFalse(result, "Should return false to indicate program should exit") + XCTAssertTrue(parser.someArgumentsPassed) + } + + func test_processArgs_version_noPrefix() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "-version"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertFalse(result, "Should return false to indicate program should exit") + XCTAssertTrue(parser.someArgumentsPassed) + } + + // MARK: - processArgs tests with source and destination + + func test_processArgs_sourceAndDestination_shortForm() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "-s", "SourceDP", "-d", "DestDP"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertTrue(result, "Should return true for valid arguments") + XCTAssertEqual(parser.srcDp, "SourceDP") + XCTAssertEqual(parser.dstDp, "DestDP") + XCTAssertTrue(parser.someArgumentsPassed) + } + + func test_processArgs_sourceAndDestination_longForm() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "--srcDp", "SourceDP", "--dstDp", "DestDP"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertTrue(result, "Should return true for valid arguments") + XCTAssertEqual(parser.srcDp, "SourceDP") + XCTAssertEqual(parser.dstDp, "DestDP") + XCTAssertTrue(parser.someArgumentsPassed) + } + + func test_processArgs_sourceAndDestination_noPrefix() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "-srcDp", "SourceDP", "-dstDp", "DestDP"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertTrue(result, "Should return true for valid arguments") + XCTAssertEqual(parser.srcDp, "SourceDP") + XCTAssertEqual(parser.dstDp, "DestDP") + XCTAssertTrue(parser.someArgumentsPassed) + } + + func test_processArgs_sourceAndDestination_withColons() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "-s", "JCDS:Stage", "-d", "JCDS:Prod"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertTrue(result, "Should return true for valid arguments") + XCTAssertEqual(parser.srcDp, "JCDS:Stage") + XCTAssertEqual(parser.dstDp, "JCDS:Prod") + } + + // MARK: - processArgs tests with flags + + func test_processArgs_forceSync_shortForm() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "-s", "Src", "-d", "Dst", "-f"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertTrue(result) + XCTAssertTrue(parser.forceSync) + } + + func test_processArgs_forceSync_longForm() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "-s", "Src", "-d", "Dst", "--forceSync"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertTrue(result) + XCTAssertTrue(parser.forceSync) + } + + func test_processArgs_removeFilesNotOnSrc_shortForm() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "-s", "Src", "-d", "Dst", "-r"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertTrue(result) + XCTAssertTrue(parser.removeFilesNotOnSrc) + } + + func test_processArgs_removeFilesNotOnSrc_longForm() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "-s", "Src", "-d", "Dst", "--removeFilesNotOnSource"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertTrue(result) + XCTAssertTrue(parser.removeFilesNotOnSrc) + } + + func test_processArgs_removePackagesNotOnSrc_shortForm() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "-s", "Src", "-d", "Dst", "-rp"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertTrue(result) + XCTAssertTrue(parser.removePackagesNotOnSrc) + } + + func test_processArgs_removePackagesNotOnSrc_longForm() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "-s", "Src", "-d", "Dst", "--removePackagesNotOnSource"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertTrue(result) + XCTAssertTrue(parser.removePackagesNotOnSrc) + } + + func test_processArgs_showProgress_shortForm() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "-s", "Src", "-d", "Dst", "-p"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertTrue(result) + XCTAssertTrue(parser.showProgress) + } + + func test_processArgs_showProgress_longForm() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "-s", "Src", "-d", "Dst", "--progress"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertTrue(result) + XCTAssertTrue(parser.showProgress) + } + + func test_processArgs_dryRun_shortForm() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "-s", "Src", "-d", "Dst", "-dr"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertTrue(result) + XCTAssertTrue(parser.dryRun) + } + + func test_processArgs_dryRun_longForm() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "-s", "Src", "-d", "Dst", "--dryRun"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertTrue(result) + XCTAssertTrue(parser.dryRun) + } + + func test_processArgs_allFlags() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "-s", "Src", "-d", "Dst", "-f", "-r", "-rp", "-p", "-dr"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertTrue(result) + XCTAssertTrue(parser.forceSync) + XCTAssertTrue(parser.removeFilesNotOnSrc) + XCTAssertTrue(parser.removePackagesNotOnSrc) + XCTAssertTrue(parser.showProgress) + XCTAssertTrue(parser.dryRun) + } + + // MARK: - processArgs validation tests + + func test_processArgs_sourceOnly_fails() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "-s", "SourceDP"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertFalse(result, "Should fail when only source is provided") + XCTAssertEqual(parser.srcDp, "SourceDP") + XCTAssertNil(parser.dstDp) + } + + func test_processArgs_destinationOnly_fails() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "-d", "DestDP"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertFalse(result, "Should fail when only destination is provided") + XCTAssertNil(parser.srcDp) + XCTAssertEqual(parser.dstDp, "DestDP") + } + + func test_processArgs_noArguments_succeeds() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertTrue(result, "Should succeed with no arguments (launches UI)") + XCTAssertNil(parser.srcDp) + XCTAssertNil(parser.dstDp) + XCTAssertFalse(parser.someArgumentsPassed) + } + + func test_processArgs_sourceMissingValue() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "-s"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertFalse(result, "Should fail validation") + XCTAssertNil(parser.srcDp, "Source should be nil when no value provided") + XCTAssertTrue(parser.someArgumentsPassed) + } + + func test_processArgs_destinationMissingValue() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "-s", "Src", "-d"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertFalse(result, "Should fail validation") + XCTAssertEqual(parser.srcDp, "Src") + XCTAssertNil(parser.dstDp, "Destination should be nil when no value provided") + } + + // MARK: - processArgs tests with unknown arguments + + func test_processArgs_unknownArgument_returnsTrue() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "-unknown"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertTrue(result, "Should return true for unknown arguments to allow UI to start (for preview support)") + } + + func test_processArgs_NSDocumentRevisionsDebugMode_ignored() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "-NSDocumentRevisionsDebugMode", "YES"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertTrue(result, "Should ignore NSDocumentRevisionsDebugMode argument") + XCTAssertFalse(parser.someArgumentsPassed) + } + + // MARK: - validateArgs tests + + func test_validateArgs_bothSourceAndDestination() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName"]) + parser.srcDp = "Src" + parser.dstDp = "Dst" + + // When + let result = parser.validateArgs() + + // Then + XCTAssertTrue(result) + } + + func test_validateArgs_neitherSourceNorDestination() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName"]) + + // When + let result = parser.validateArgs() + + // Then + XCTAssertTrue(result, "Should be valid when neither are provided") + } + + func test_validateArgs_sourceOnlyWithoutDestination() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName"]) + parser.srcDp = "Src" + + // When + let result = parser.validateArgs() + + // Then + XCTAssertFalse(result, "Should be invalid with only source") + } + + func test_validateArgs_destinationOnlyWithoutSource() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName"]) + parser.dstDp = "Dst" + + // When + let result = parser.validateArgs() + + // Then + XCTAssertFalse(result, "Should be invalid with only destination") + } + + // MARK: - Complex argument order tests + + func test_processArgs_argumentsInDifferentOrder() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "-p", "-d", "Dst", "-r", "-s", "Src", "-f"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertTrue(result) + XCTAssertEqual(parser.srcDp, "Src") + XCTAssertEqual(parser.dstDp, "Dst") + XCTAssertTrue(parser.showProgress) + XCTAssertTrue(parser.removeFilesNotOnSrc) + XCTAssertTrue(parser.forceSync) + } + + func test_processArgs_flagsBeforeSourceAndDestination() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "-f", "-r", "-rp", "-s", "Src", "-d", "Dst"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertTrue(result) + XCTAssertTrue(parser.forceSync) + XCTAssertTrue(parser.removeFilesNotOnSrc) + XCTAssertTrue(parser.removePackagesNotOnSrc) + } + + func test_processArgs_flagsAfterSourceAndDestination() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "-s", "Src", "-d", "Dst", "-f", "-r", "-rp"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertTrue(result) + XCTAssertTrue(parser.forceSync) + XCTAssertTrue(parser.removeFilesNotOnSrc) + XCTAssertTrue(parser.removePackagesNotOnSrc) + } + + // MARK: - Edge cases + + func test_processArgs_emptyStringValues() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "-s", "", "-d", ""]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertTrue(result, "Should accept empty strings") + XCTAssertEqual(parser.srcDp, "") + XCTAssertEqual(parser.dstDp, "") + } + + func test_processArgs_spaceInValues() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "-s", "Source DP", "-d", "Dest DP"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertTrue(result) + XCTAssertEqual(parser.srcDp, "Source DP") + XCTAssertEqual(parser.dstDp, "Dest DP") + } + + func test_processArgs_specialCharactersInValues() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName", "-s", "Source@#$%", "-d", "Dest!&*()"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertTrue(result) + XCTAssertEqual(parser.srcDp, "Source@#$%") + XCTAssertEqual(parser.dstDp, "Dest!&*()") + } + + // MARK: - Default values tests + + func test_initialState_allFlagsFalse() throws { + // Given + let parser = ArgumentParser(arguments: ["ProgramName"]) + + // Then + XCTAssertFalse(parser.forceSync) + XCTAssertFalse(parser.removeFilesNotOnSrc) + XCTAssertFalse(parser.removePackagesNotOnSrc) + XCTAssertFalse(parser.showProgress) + XCTAssertFalse(parser.dryRun) + XCTAssertFalse(parser.someArgumentsPassed) + XCTAssertNil(parser.srcDp) + XCTAssertNil(parser.dstDp) + } + + // MARK: - Real-world example tests + + func test_processArgs_realWorldExample1() throws { + // Given - Example from the help text + let parser = ArgumentParser(arguments: ["ProgramName", "-srcDp", "localSourceName", "-dstDp", "destinationSourceName", "--removeFilesNotOnSource", "--progress"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertTrue(result) + XCTAssertEqual(parser.srcDp, "localSourceName") + XCTAssertEqual(parser.dstDp, "destinationSourceName") + XCTAssertTrue(parser.removeFilesNotOnSrc) + XCTAssertTrue(parser.showProgress) + XCTAssertFalse(parser.forceSync) + XCTAssertFalse(parser.removePackagesNotOnSrc) + } + + func test_processArgs_realWorldExample2() throws { + // Given - Example from the help text + let parser = ArgumentParser(arguments: ["ProgramName", "-s", "JCDS:Stage", "-d", "JCDS:Prod", "-r", "-rp", "-p"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertTrue(result) + XCTAssertEqual(parser.srcDp, "JCDS:Stage") + XCTAssertEqual(parser.dstDp, "JCDS:Prod") + XCTAssertTrue(parser.removeFilesNotOnSrc) + XCTAssertTrue(parser.removePackagesNotOnSrc) + XCTAssertTrue(parser.showProgress) + } + + func test_processArgs_realWorldExample3() throws { + // Given - Example from the help text + let parser = ArgumentParser(arguments: ["ProgramName", "-s", "localSourceName", "-d", "destinationSourceName"]) + + // When + let result = parser.processArgs() + + // Then + XCTAssertTrue(result) + XCTAssertEqual(parser.srcDp, "localSourceName") + XCTAssertEqual(parser.dstDp, "destinationSourceName") + XCTAssertFalse(parser.removeFilesNotOnSrc) + XCTAssertFalse(parser.removePackagesNotOnSrc) + XCTAssertFalse(parser.showProgress) + XCTAssertFalse(parser.forceSync) + XCTAssertFalse(parser.dryRun) + } +}