diff --git a/.gitignore b/.gitignore index cd4f8f9..dd0d7c1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,11 @@ build/ *.gcno *.DS_STORE *.xcuserstate + +# User-specific configuration files +settings.local.json +.claude/settings.local.json +.env +.env.local +*.local +secrets.* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5aacf2f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,21 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- CoreData contains the data model +- UI contains Swift UI views +- Model contains non-UI code that is used with the views +- Multipart contains code used for a multipart upload process +- Utility contains code that can be used separately from views +- JamfSyncTests contain the unit tests +- JamfSyncUITests contains the UI tests +- User Guide contains a pages document that is used to create the pdf in the Resources folder, and contains the images used in that document in a folder called Images. +- Resources contain the user guide pdf, assets used by the program + +## Coding Style & Naming Conventions +- Use camel case variables, and when using acronyms, treat them as a single word (so not all upper case). +- Avoid abbreviations except when words are very long or the abbreviations are very common. + +## Commit & Pull Request Guidelines +- Follow the existing history: concise, sentence-case summaries in present tense (e.g., `Update feature animation`) and reference issues with `(#ID)` when relevant. +- Squash small work into cohesive commits; avoid committing build artifacts or local server outputs. +- Pull requests should state the goal, highlight visual changes with screenshots or GIFs, and list manual verification steps. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b22f80b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,97 @@ +# Claude Code Development Instructions + +## Main Rules + +You are an expert AI programming assistant that primarily focuses on Swift and SwiftUI. + +You always use the latest versions of these technologies, and you are familiar with the latest features and best practices. You know that you can search documentation when needed. + +### This Project + +This project is an open source utility created by Jamf to allow users to transfer files between distribution points for use with Jamf Pro. The README.md file contains information about the structure of the program. + +### MAIN COMMANDS + +1) If implementation details are not obvious, first ask questions to decide the details. Then, implement. +2) Consult the file structure and read important files for context, but avoid reading unnecessary files. Use README.md to get information about the high level structure, and consider updating it if you find discrepancies. + +Keep comments up to date. Comment at the file level and at the function level. Prefer using well named functions over comments. But add comments for anything that's not clear from the code. + +### Run terminal commands + +You HAVE access to terminal, so you should RUN TERMINAL COMMANDS when needed. + +### Testing Rules +- Refactor the classes under test as necessary to make them more easily testable, but retain the original functionality. +- Have reasonable test coverage, but 100% coverage is not strictly necessary when tests would add little or no value. +- Do not add tests for mock classes. +- Keep the groups for JamfSyncTests in sync with the groups for JamfSync so the tests are easier to find. + +### Additional Rules + +- Follow the user's requirements carefully & to the letter. +- Confirm, then write code! +- Suggest solutions that I didn't think about—anticipate my needs. +- Treat me as an expert. +- Always write correct, up to date, bug free, fully functional, secure, performant, and efficient code. +- Focus on readability over being performant. +- Fully implement all requested functionality. +- Leave NO todos, placeholders, or missing pieces. +- Be concise. Minimize any other prose. +- Consider new technologies and contrarian ideas, not just the conventional wisdom. +- If you think there might not be a correct answer, you say so. If you do not know the answer, say so instead of guessing. +- If I ask for adjustments to code, do not repeat all of my code unnecessarily. Instead, try to keep the answer brief by giving just a couple of lines before/after any changes you make. + +## Personal Guidelines + +I am an expert in Swift and SwiftUI development. I have 40 years of software experience. Treat me like an expert, don't be overly verbose. But don't shy away from suggesting better ways to do things and challenging my thinking. +Since this is an open source project, it's possible that developers of any skill level may try to contribute using Claude Code. + +Here are my opinions... + +## Implementation Details + +### Important: Ask about implementation details + +Before writing code, if there is an important implementation detail that you are deciding, ask me about it and let's decide together. + +For example, if there are multiple paths, stop and ask me which one I would prefer + +For example, if you're deciding about a particular abstraction, stop and ask me. + +The assistant will ask clarifying questions about implementation details before generating any code. This includes: + +- Understanding the specific requirements and constraints +- Clarifying technical approach and architecture decisions +- Confirming integration points with existing systems +- Validating assumptions about data models and relationships +- Determining appropriate error handling and edge cases +- Identifying potential performance considerations +- Confirming testing requirements and strategy + +Only after gathering sufficient implementation context will the assistant proceed with code generation. + +## File Directory Structure + +- CoreData contains the data model +- JamfSync contains the main code for the app +- JamfSyncTests contain the unit tests +- JamfSyncUITests contains the UI tests +- User Guide contains a pages document that is used to create the pdf in the Resources folder, and contains the images used in that document in a folder called Images. + +### JamfSync directories +- UI contains Swift UI views +- Model contains non-UI code that is used with the views +- Multipart contains code used for a multipart upload process +- Utility contains code that can be used separately from views +- Resources contain the user guide pdf, assets used by the program + +## Documentation Structure + +- README.md: Contains details about the code +- User Guide: Instructions for end users of the program + +## Design Patterns to Follow + +## Anti-Patterns to Avoid + diff --git a/Jamf Sync.xcodeproj/project.pbxproj b/Jamf Sync.xcodeproj/project.pbxproj index 3b0ad79..17696d9 100644 --- a/Jamf Sync.xcodeproj/project.pbxproj +++ b/Jamf Sync.xcodeproj/project.pbxproj @@ -14,6 +14,10 @@ 8412279F2BEADBB20097B83E /* XmlErrorParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8412279E2BEADBB20097B83E /* XmlErrorParser.swift */; }; 841227A12BEADD6E0097B83E /* XmlErrorParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841227A02BEADD6E0097B83E /* XmlErrorParserTests.swift */; }; 841DE9D92BA395900092DBE7 /* Jamf Sync User Guide.pdf in Resources */ = {isa = PBXBuildFile; fileRef = 841DE9D82BA395900092DBE7 /* Jamf Sync User Guide.pdf */; }; + 8422B1212F6494FD0019C2DE /* CommandLineProcessingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CMDLINE123456789ABCDEF0 /* CommandLineProcessingTests.swift */; }; + 8422B1222F649B8B0019C2DE /* TemporaryFileManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TEMPFILE123456789ABCDEF0 /* TemporaryFileManagerTests.swift */; }; + 8422B1252F64B50E0019C2DE /* FileHashTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4154F4CC0D4B5B8025ECF9D1 /* FileHashTests.swift */; }; + 8422B1262F64B7A60019C2DE /* FileManagerMoveRetainingPermissionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1829A6AA7D2850AF23277749 /* FileManagerMoveRetainingPermissionsTests.swift */; }; 843BE0F62AEFE6350053431B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 843BE0F52AEFE6350053431B /* Assets.xcassets */; }; 84413C972CA7586900363CFA /* MultipartUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84413C962CA7586300363CFA /* MultipartUpload.swift */; }; 84413CA12CA75A7E00363CFA /* UploadTime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84413CA02CA75A7B00363CFA /* UploadTime.swift */; }; @@ -64,7 +68,7 @@ 84BC6E492AC380FD00CF6D39 /* FolderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84BC6E482AC380FD00CF6D39 /* FolderView.swift */; }; 84BC6E4D2AC38C6200CF6D39 /* FileShare.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84BC6E4A2AC38C6200CF6D39 /* FileShare.swift */; }; 84BC6E552AC4933500CF6D39 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 84BC6E542AC4933500CF6D39 /* README.md */; }; - 84BF5E3A2CC15FD3008B07A1 /* TemporaryFiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84BF5E392CC15FB4008B07A1 /* TemporaryFiles.swift */; }; + 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 */; }; 84CAB0E52C25C47400582D59 /* FileManager+moveRetainingPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84CAB0E42C25C47400582D59 /* FileManager+moveRetainingPermissions.swift */; }; @@ -122,6 +126,8 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 1829A6AA7D2850AF23277749 /* FileManagerMoveRetainingPermissionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManagerMoveRetainingPermissionsTests.swift; sourceTree = ""; }; + 4154F4CC0D4B5B8025ECF9D1 /* FileHashTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileHashTests.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 = ""; }; @@ -130,6 +136,8 @@ 841227A02BEADD6E0097B83E /* XmlErrorParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XmlErrorParserTests.swift; sourceTree = ""; }; 841DE9D82BA395900092DBE7 /* Jamf Sync User Guide.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = "Jamf Sync User Guide.pdf"; sourceTree = ""; }; 841DE9DA2BA395F00092DBE7 /* User Guide */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "User Guide"; sourceTree = ""; }; + 8422AF602F6076BA0019C2DE /* AGENTS.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = AGENTS.md; sourceTree = ""; }; + 8422AF612F6076BA0019C2DE /* CLAUDE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CLAUDE.md; sourceTree = ""; }; 843BE0F52AEFE6350053431B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 84413C962CA7586300363CFA /* MultipartUpload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipartUpload.swift; sourceTree = ""; }; 84413CA02CA75A7B00363CFA /* UploadTime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadTime.swift; sourceTree = ""; }; @@ -185,7 +193,7 @@ 84BC6E482AC380FD00CF6D39 /* FolderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderView.swift; sourceTree = ""; }; 84BC6E4A2AC38C6200CF6D39 /* FileShare.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileShare.swift; sourceTree = ""; }; 84BC6E542AC4933500CF6D39 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; - 84BF5E392CC15FB4008B07A1 /* TemporaryFiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryFiles.swift; sourceTree = ""; }; + 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 = ""; }; 84CAB0E42C25C47400582D59 /* FileManager+moveRetainingPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+moveRetainingPermissions.swift"; sourceTree = ""; }; @@ -221,6 +229,8 @@ 84FC418D2ADD89AD00DCB033 /* DataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataManager.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 = ""; }; + TEMPFILE123456789ABCDEF0 /* TemporaryFileManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryFileManagerTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -250,6 +260,38 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 8422AF622F60846E0019C2DE /* Model */ = { + isa = PBXGroup; + children = ( + 846499D42B64268A00A8EA7B /* DistributionPointTests.swift */, + 846499E12B6AA7C300A8EA7B /* FileShareDpTests.swift */, + 846499CE2B630D7700A8EA7B /* FolderDpTests.swift */, + 846499E72B6BFCA900A8EA7B /* Jcds2DpTests.swift */, + 846499E32B6B080B00A8EA7B /* SynchronizeTaskTests.swift */, + ); + path = Model; + sourceTree = ""; + }; + 8422B1232F64AE9B0019C2DE /* Utility */ = { + isa = PBXGroup; + children = ( + CMDLINE123456789ABCDEF0 /* CommandLineProcessingTests.swift */, + TEMPFILE123456789ABCDEF0 /* TemporaryFileManagerTests.swift */, + 841227A02BEADD6E0097B83E /* XmlErrorParserTests.swift */, + 4154F4CC0D4B5B8025ECF9D1 /* FileHashTests.swift */, + 1829A6AA7D2850AF23277749 /* FileManagerMoveRetainingPermissionsTests.swift */, + ); + path = Utility; + sourceTree = ""; + }; + 8422B1242F64AEEB0019C2DE /* Multipart */ = { + isa = PBXGroup; + children = ( + 849809DA2CB8575B001F94C9 /* UploadTimeTests.swift */, + ); + path = Multipart; + sourceTree = ""; + }; 846499DC2B699F5E00A8EA7B /* Mocks */ = { isa = PBXGroup; children = ( @@ -264,6 +306,8 @@ 84BC6DE72AC3122100CF6D39 = { isa = PBXGroup; children = ( + 8422AF602F6076BA0019C2DE /* AGENTS.md */, + 8422AF612F6076BA0019C2DE /* CLAUDE.md */, 841DE9DA2BA395F00092DBE7 /* User Guide */, 84F258B62B76E09F00B8F401 /* CHANGELOG.md */, 84F258B22B76BF7E00B8F401 /* LICENSE */, @@ -312,15 +356,11 @@ 84BC6E042AC3122200CF6D39 /* JamfSyncTests */ = { isa = PBXGroup; children = ( - 846499D42B64268A00A8EA7B /* DistributionPointTests.swift */, - 846499E12B6AA7C300A8EA7B /* FileShareDpTests.swift */, - 846499CE2B630D7700A8EA7B /* FolderDpTests.swift */, - 846499E72B6BFCA900A8EA7B /* Jcds2DpTests.swift */, 846499DC2B699F5E00A8EA7B /* Mocks */, - 846499E32B6B080B00A8EA7B /* SynchronizeTaskTests.swift */, + 8422AF622F60846E0019C2DE /* Model */, + 8422B1242F64AEEB0019C2DE /* Multipart */, 846499D22B64165E00A8EA7B /* TestErrors.swift */, - 841227A02BEADD6E0097B83E /* XmlErrorParserTests.swift */, - 849809DA2CB8575B001F94C9 /* UploadTimeTests.swift */, + 8422B1232F64AE9B0019C2DE /* Utility */, ); path = JamfSyncTests; sourceTree = ""; @@ -397,7 +437,6 @@ 84BC6E202AC312BF00CF6D39 /* Utility */ = { isa = PBXGroup; children = ( - 84BF5E392CC15FB4008B07A1 /* TemporaryFiles.swift */, 84E4899D2B5C623500FFFE59 /* ArgumentParser.swift */, 848C2D252BC47B3300036999 /* CharacterSet_rfc3986Unreserved.swift */, 84AB59C42B20D569007333AD /* CloudSessionDelegate.swift */, @@ -408,6 +447,7 @@ 84F3331C2B48BEEF0037E4E5 /* FileShares.swift */, 84FC41652AD895F400DCB033 /* KeychainHelper.swift */, 8468917D2BCEC4BB00B9FCA4 /* OutputStream_write.swift */, + 84BF5E392CC15FB4008B07A1 /* TemporaryFileManager.swift */, 84DD583A2BC5C2A700E8DA23 /* URL+isDirectory.swift */, 84E489982B5AC80600FFFE59 /* UserSettings.swift */, 849FC3402BD06A43008BAC02 /* VersionInfo.swift */, @@ -450,8 +490,8 @@ 84FC418C2ADD897C00DCB033 /* CoreData */ = { isa = PBXGroup; children = ( - 84FC41892ADD7B1000DCB033 /* StoredSettings.xcdatamodeld */, 84FC418D2ADD89AD00DCB033 /* DataManager.swift */, + 84FC41892ADD7B1000DCB033 /* StoredSettings.xcdatamodeld */, ); path = CoreData; sourceTree = ""; @@ -459,10 +499,10 @@ CE9899AF2C9A6A20000C36B7 /* Multipart */ = { isa = PBXGroup; children = ( - 84413CA02CA75A7B00363CFA /* UploadTime.swift */, - 84413C962CA7586300363CFA /* MultipartUpload.swift */, CE9899B62C9A8C4B000C36B7 /* CompletedChunk.swift */, CE9899B22C9A7A77000C36B7 /* KeepAwake.swift */, + 84413C962CA7586300363CFA /* MultipartUpload.swift */, + 84413CA02CA75A7B00363CFA /* UploadTime.swift */, ); path = Multipart; sourceTree = ""; @@ -535,7 +575,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1500; - LastUpgradeCheck = 1600; + LastUpgradeCheck = 2630; TargetAttributes = { 84BC6DEF2AC3122100CF6D39 = { CreatedOnToolsVersion = 15.0; @@ -657,7 +697,7 @@ 84E489972B59BBFD00FFFE59 /* PackageAnimationView.swift in Sources */, 84EBFE9B2B348A77008E502C /* DataPersistence.swift in Sources */, 84BC6E382AC3631400CF6D39 /* JsonObjects.swift in Sources */, - 84BF5E3A2CC15FD3008B07A1 /* TemporaryFiles.swift in Sources */, + 84BF5E3A2CC15FD3008B07A1 /* TemporaryFileManager.swift in Sources */, 84BC6E432AC3659600CF6D39 /* PackageListView.swift in Sources */, 84FC41602AD5A78C00DCB033 /* View+NSWindow.swift in Sources */, 84E2AE742AF85DFE00AE8284 /* LogManager.swift in Sources */, @@ -690,16 +730,20 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 8422B1252F64B50E0019C2DE /* FileHashTests.swift in Sources */, 841227A12BEADD6E0097B83E /* XmlErrorParserTests.swift in Sources */, 846499E02B699FB500A8EA7B /* MockJamfProInstance.swift in Sources */, 846499DE2B699F8E00A8EA7B /* MockDistributionPoint.swift in Sources */, + 8422B1212F6494FD0019C2DE /* CommandLineProcessingTests.swift in Sources */, 846499D52B64268A00A8EA7B /* DistributionPointTests.swift in Sources */, 846499E42B6B080B00A8EA7B /* SynchronizeTaskTests.swift in Sources */, 846499E82B6BFCA900A8EA7B /* Jcds2DpTests.swift in Sources */, 846499E22B6AA7C300A8EA7B /* FileShareDpTests.swift in Sources */, 849809DB2CB8575B001F94C9 /* UploadTimeTests.swift in Sources */, 846499D32B64165E00A8EA7B /* TestErrors.swift in Sources */, + 8422B1262F64B7A60019C2DE /* FileManagerMoveRetainingPermissionsTests.swift in Sources */, 846499D12B631B6A00A8EA7B /* MockFileManager.swift in Sources */, + 8422B1222F649B8B0019C2DE /* TemporaryFileManagerTests.swift in Sources */, 846499CF2B630D7700A8EA7B /* FolderDpTests.swift in Sources */, 846499E62B6B089200A8EA7B /* MockDistributionPointSync.swift in Sources */, ); @@ -765,6 +809,7 @@ COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 483DWKW443; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -788,6 +833,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -829,6 +875,7 @@ COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 483DWKW443; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -845,6 +892,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; }; name = Release; @@ -860,7 +908,6 @@ CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"JamfSync/Resources/Preview Content\""; - DEVELOPMENT_TEAM = 483DWKW443; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; @@ -892,7 +939,6 @@ CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"JamfSync/Resources/Preview Content\""; - DEVELOPMENT_TEAM = 483DWKW443; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; @@ -920,7 +966,6 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 483DWKW443; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; @@ -939,7 +984,6 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 483DWKW443; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; @@ -957,7 +1001,6 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 483DWKW443; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; @@ -976,7 +1019,6 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 483DWKW443; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; diff --git a/Jamf Sync.xcodeproj/xcshareddata/xcschemes/Jamf Sync.xcscheme b/Jamf Sync.xcodeproj/xcshareddata/xcschemes/Jamf Sync.xcscheme index 82912c7..e5d9e56 100644 --- a/Jamf Sync.xcodeproj/xcshareddata/xcschemes/Jamf Sync.xcscheme +++ b/Jamf Sync.xcodeproj/xcshareddata/xcschemes/Jamf Sync.xcscheme @@ -1,6 +1,6 @@ ?) -> Bool)? var directoryCreated: URL? var createDirectoryError: Error? @@ -63,6 +64,21 @@ class MockFileManager: FileManager { } override func fileExists(atPath path: String) -> Bool { + if let provider = fileExistsResponseProvider { + return provider(path, nil) + } + return fileExistsResponse + } + + override func fileExists(atPath path: String, isDirectory: UnsafeMutablePointer?) -> Bool { + if let provider = fileExistsResponseProvider { + return provider(path, isDirectory) + } + + // Default behavior + if let isDirectory { + isDirectory.pointee = false + } return fileExistsResponse } diff --git a/JamfSyncTests/DistributionPointTests.swift b/JamfSyncTests/Model/DistributionPointTests.swift similarity index 94% rename from JamfSyncTests/DistributionPointTests.swift rename to JamfSyncTests/Model/DistributionPointTests.swift index e929245..6eaac4c 100644 --- a/JamfSyncTests/DistributionPointTests.swift +++ b/JamfSyncTests/Model/DistributionPointTests.swift @@ -601,6 +601,36 @@ final class DistributionPointTests: XCTestCase { // MARK: - transferLocalFiles tests + func test_sizeOfFile_withMockFileManager() throws { + // Simple test to verify sizeOfFile works with mockFileManager + let path = "/test/file.pkg" + mockFileManager.fileAttributes = [path: [.size: Int64(999999)]] + + let testDp = DistributionPoint(name: "Test", fileManager: mockFileManager) + let url = URL(fileURLWithPath: path) + + let size = testDp.sizeOfFile(fileUrl: url) + XCTAssertEqual(size, 999999, "sizeOfFile should return the size from mockFileManager") + } + + func test_convertFileUrlsToDpFiles_withMockFileManager() throws { + // Test that convertFileUrlsToDpFiles correctly gets sizes from mockFileManager + let path1 = "/source/directory/file1.pkg" + let path2 = "/source/directory/file2.pkg" + mockFileManager.fileAttributes = [path1: [.size: Int64(12345678)], path2: [.size: Int64(456)]] + + let testDp = MockDistributionPoint(name: "Test", fileManager: mockFileManager) + let urls = [URL(fileURLWithPath: path1), URL(fileURLWithPath: path2)] + + let dpFiles = testDp.convertFileUrlsToDpFiles(fileUrls: urls) + + XCTAssertEqual(dpFiles.count, 2) + XCTAssertEqual(dpFiles[0].name, "file1.pkg") + XCTAssertEqual(dpFiles[0].size, 12345678, "First file should have correct size") + XCTAssertEqual(dpFiles[1].name, "file2.pkg") + XCTAssertEqual(dpFiles[1].size, 456, "Second file should have correct size") + } + func test_transferLocalFiles_withFiles() throws { // Given let path1 = "/source/directory/file1.pkg" @@ -624,7 +654,17 @@ final class DistributionPointTests: XCTestCase { wait(for: [expectationCompleted], timeout: 5) // Then - XCTAssertNotNil(findLogMessage(messageString: "Finished synchronizing from Selected local files to TestDstDp (local)")) + print("Test results:") + print(" dstDp.transferItems.count: \(dstDp.transferItems.count)") + for (i, item) in dstDp.transferItems.enumerated() { + print(" Item \(i): \(item.srcFile.name), size=\(item.srcFile.size ?? -1)") + } + print(" totalSize: \(synchronizationProgress.totalSize)") + print(" currentFileSizeTransferred: \(String(describing: synchronizationProgress.currentFileSizeTransferred))") + print(" currentTotalSizeTransferred: \(synchronizationProgress.currentTotalSizeTransferred)") + + // XCTAssertNotNil(findLogMessage(messageString: "Finished synchronizing from Selected local files to TestDstDp (local)")) + XCTAssertEqual(dstDp.transferItems.count, 3, "Should have transferred 3 files") XCTAssertEqual(synchronizationProgress.totalSize, 12347245) XCTAssertEqual(synchronizationProgress.currentFileSizeTransferred, 1111) XCTAssertEqual(synchronizationProgress.currentTotalSizeTransferred, 12347245) diff --git a/JamfSyncTests/FileShareDpTests.swift b/JamfSyncTests/Model/FileShareDpTests.swift similarity index 97% rename from JamfSyncTests/FileShareDpTests.swift rename to JamfSyncTests/Model/FileShareDpTests.swift index 2b9badb..7a08e06 100644 --- a/JamfSyncTests/FileShareDpTests.swift +++ b/JamfSyncTests/Model/FileShareDpTests.swift @@ -400,6 +400,17 @@ final class FileShareDpTests: XCTestCase { let superSadDmg = "SuperSad.dmg" mockFileManager.directoryContents = [URL(fileURLWithPath: path + happyFunPackage), URL(fileURLWithPath: path + notSoGoodToUploadFile), URL(fileURLWithPath: path + superSadDmg)] mockFileManager.fileAttributes = [path + happyFunPackage : [.size : Int64(12345678)], path + superSadDmg : [.size : Int64(456)]] + // Configure fileExists to return false for .zip files (so .pkg files aren't filtered out) + // and false for directory checks (so .pkg files are treated as flat packages, not fluffy) + mockFileManager.fileExistsResponseProvider = { path, isDirectory in + if path.hasSuffix(".zip") { + return false // No .zip file exists + } + if let isDirectory { + isDirectory.pointee = false // Treat as flat package, not directory + } + return true + } let expectationCompleted = XCTestExpectation() Task { diff --git a/JamfSyncTests/FolderDpTests.swift b/JamfSyncTests/Model/FolderDpTests.swift similarity index 100% rename from JamfSyncTests/FolderDpTests.swift rename to JamfSyncTests/Model/FolderDpTests.swift diff --git a/JamfSyncTests/Jcds2DpTests.swift b/JamfSyncTests/Model/Jcds2DpTests.swift similarity index 100% rename from JamfSyncTests/Jcds2DpTests.swift rename to JamfSyncTests/Model/Jcds2DpTests.swift diff --git a/JamfSyncTests/SynchronizeTaskTests.swift b/JamfSyncTests/Model/SynchronizeTaskTests.swift similarity index 100% rename from JamfSyncTests/SynchronizeTaskTests.swift rename to JamfSyncTests/Model/SynchronizeTaskTests.swift diff --git a/JamfSyncTests/UploadTimeTests.swift b/JamfSyncTests/Multipart/UploadTimeTests.swift similarity index 100% rename from JamfSyncTests/UploadTimeTests.swift rename to JamfSyncTests/Multipart/UploadTimeTests.swift diff --git a/JamfSyncTests/Utility/CommandLineProcessingTests.swift b/JamfSyncTests/Utility/CommandLineProcessingTests.swift new file mode 100644 index 0000000..85e061a --- /dev/null +++ b/JamfSyncTests/Utility/CommandLineProcessingTests.swift @@ -0,0 +1,368 @@ +// +// Copyright 2026, Jamf +// + +@testable import Jamf_Sync +import XCTest + +final class CommandLineProcessingTests: XCTestCase { + var commandLineProcessing: CommandLineProcessing! + var mockDataModel: MockDataModel! + var mockDataPersistence: MockDataPersistence! + var srcDp: MockDistributionPointSync! + var dstDp: MockDistributionPointSync! + var jamfProInstance: MockJamfProInstance! + + override func setUp() { + super.setUp() + + // Create mock distribution points + srcDp = MockDistributionPointSync(name: "Source DP") + dstDp = MockDistributionPointSync(name: "Destination DP") + + // Create mock Jamf Pro instance + jamfProInstance = MockJamfProInstance() + jamfProInstance.id = UUID() + dstDp.jamfProInstanceId = jamfProInstance.id + + // Create mock data persistence + mockDataPersistence = MockDataPersistence() + + // Create mock data model + mockDataModel = MockDataModel() + mockDataModel.distributionPoints = [srcDp, dstDp] + mockDataModel.jamfProInstances = [jamfProInstance] + + // Create command line processing + commandLineProcessing = CommandLineProcessing(dataModel: mockDataModel, dataPersistence: mockDataPersistence) + } + + override func tearDown() { + commandLineProcessing = nil + mockDataModel = nil + mockDataPersistence = nil + srcDp = nil + dstDp = nil + jamfProInstance = nil + super.tearDown() + } + + // MARK: - Happy Path Tests + + func test_process_happyPath() throws { + // Given + let argumentParser = ArgumentParser(arguments: ["JamfSync", "-s", "Source DP", "-d", "Destination DP"]) + _ = argumentParser.processArgs() + + // When + let result = commandLineProcessing.process(argumentParser: argumentParser) + + // Then + XCTAssertTrue(result, "Process should return true on success") + XCTAssertTrue(mockDataModel.loadCalled, "DataModel.load should be called") + XCTAssertTrue(mockDataModel.loadingInProgressGroupWasCalled, "DispatchGroup.wait() should be called") + XCTAssertTrue(srcDp.prepareDpCalled, "Source DP prepareDp should be called") + XCTAssertTrue(dstDp.prepareDpCalled, "Destination DP prepareDp should be called") + } + + func test_process_withForceSync() throws { + // Given + let argumentParser = ArgumentParser(arguments: ["JamfSync", "-s", "Source DP", "-d", "Destination DP", "-f"]) + _ = argumentParser.processArgs() + + // When + let result = commandLineProcessing.process(argumentParser: argumentParser) + + // Then + XCTAssertTrue(result, "Process should return true on success") + XCTAssertTrue(argumentParser.forceSync, "forceSync should be true") + XCTAssertTrue(srcDp.copyFilesCalled, "copyFiles should be called") + } + + func test_process_withProgress() throws { + // Given + let argumentParser = ArgumentParser(arguments: ["JamfSync", "-s", "Source DP", "-d", "Destination DP", "-p"]) + _ = argumentParser.processArgs() + + // When + let result = commandLineProcessing.process(argumentParser: argumentParser) + + // Then + XCTAssertTrue(result, "Process should return true on success") + XCTAssertTrue(argumentParser.showProgress, "showProgress should be true") + } + + func test_process_withRemoveFilesNotOnSource() throws { + // Given + let argumentParser = ArgumentParser(arguments: ["JamfSync", "-s", "Source DP", "-d", "Destination DP", "-r"]) + _ = argumentParser.processArgs() + + // When + let result = commandLineProcessing.process(argumentParser: argumentParser) + + // Then + XCTAssertTrue(result, "Process should return true on success") + XCTAssertTrue(argumentParser.removeFilesNotOnSrc, "removeFilesNotOnSrc should be true") + } + + func test_process_withRemovePackagesNotOnSource() throws { + // Given + let argumentParser = ArgumentParser(arguments: ["JamfSync", "-s", "Source DP", "-d", "Destination DP", "-rp"]) + _ = argumentParser.processArgs() + + // When + let result = commandLineProcessing.process(argumentParser: argumentParser) + + // Then + XCTAssertTrue(result, "Process should return true on success") + XCTAssertTrue(argumentParser.removePackagesNotOnSrc, "removePackagesNotOnSrc should be true") + } + + func test_process_withDryRun() throws { + // Given + let argumentParser = ArgumentParser(arguments: ["JamfSync", "-s", "Source DP", "-d", "Destination DP", "-dr"]) + _ = argumentParser.processArgs() + + // When + let result = commandLineProcessing.process(argumentParser: argumentParser) + + // Then + XCTAssertTrue(result, "Process should return true on success") + XCTAssertTrue(argumentParser.dryRun, "dryRun should be true") + } + + func test_process_withAllOptions() throws { + // Given + let argumentParser = ArgumentParser(arguments: ["JamfSync", "-s", "Source DP", "-d", "Destination DP", "-f", "-r", "-rp", "-p", "-dr"]) + _ = argumentParser.processArgs() + + // When + let result = commandLineProcessing.process(argumentParser: argumentParser) + + // Then + XCTAssertTrue(result, "Process should return true on success") + XCTAssertTrue(argumentParser.forceSync, "forceSync should be true") + XCTAssertTrue(argumentParser.removeFilesNotOnSrc, "removeFilesNotOnSrc should be true") + XCTAssertTrue(argumentParser.removePackagesNotOnSrc, "removePackagesNotOnSrc should be true") + XCTAssertTrue(argumentParser.showProgress, "showProgress should be true") + XCTAssertTrue(argumentParser.dryRun, "dryRun should be true") + } + + func test_process_withCombinedDpName() throws { + // Given + let srcDpWithServer = MockDistributionPointSync(name: "JCDS") + srcDpWithServer.jamfProInstanceName = "Stage" + let dstDpWithServer = MockDistributionPointSync(name: "JCDS") + dstDpWithServer.jamfProInstanceName = "Prod" + + mockDataModel.distributionPoints = [srcDpWithServer, dstDpWithServer] + + let argumentParser = ArgumentParser(arguments: ["JamfSync", "-s", "JCDS:Stage", "-d", "JCDS:Prod"]) + _ = argumentParser.processArgs() + + // When + let result = commandLineProcessing.process(argumentParser: argumentParser) + + // Then + XCTAssertTrue(result, "Process should return true on success") + } + + // MARK: - Missing Parameters Tests + + func test_process_missingSourceDp() throws { + // Given + let argumentParser = ArgumentParser(arguments: ["JamfSync", "-d", "Destination DP"]) + _ = argumentParser.processArgs() + + // When + let result = commandLineProcessing.process(argumentParser: argumentParser) + + // Then + XCTAssertFalse(result, "Process should return false when source DP is missing") + XCTAssertFalse(mockDataModel.loadCalled, "DataModel.load should not be called") + } + + func test_process_missingDestinationDp() throws { + // Given + let argumentParser = ArgumentParser(arguments: ["JamfSync", "-s", "Source DP"]) + _ = argumentParser.processArgs() + + // When + let result = commandLineProcessing.process(argumentParser: argumentParser) + + // Then + XCTAssertFalse(result, "Process should return false when destination DP is missing") + XCTAssertFalse(mockDataModel.loadCalled, "DataModel.load should not be called") + } + + func test_process_missingBothDps() throws { + // Given + let argumentParser = ArgumentParser(arguments: ["JamfSync"]) + _ = argumentParser.processArgs() + + // When + let result = commandLineProcessing.process(argumentParser: argumentParser) + + // Then + XCTAssertFalse(result, "Process should return false when both DPs are missing") + XCTAssertFalse(mockDataModel.loadCalled, "DataModel.load should not be called") + } + + // MARK: - DP Not Found Tests + + func test_process_sourceDpNotFound() throws { + // Given + let argumentParser = ArgumentParser(arguments: ["JamfSync", "-s", "NonExistent DP", "-d", "Destination DP"]) + _ = argumentParser.processArgs() + + // When + let result = commandLineProcessing.process(argumentParser: argumentParser) + + // Then + XCTAssertFalse(result, "Process should return false when source DP is not found") + XCTAssertTrue(mockDataModel.loadCalled, "DataModel.load should be called") + } + + func test_process_destinationDpNotFound() throws { + // Given + let argumentParser = ArgumentParser(arguments: ["JamfSync", "-s", "Source DP", "-d", "NonExistent DP"]) + _ = argumentParser.processArgs() + + // When + let result = commandLineProcessing.process(argumentParser: argumentParser) + + // Then + XCTAssertFalse(result, "Process should return false when destination DP is not found") + XCTAssertTrue(mockDataModel.loadCalled, "DataModel.load should be called") + } + + func test_process_bothDpsNotFound() throws { + // Given + let argumentParser = ArgumentParser(arguments: ["JamfSync", "-s", "NonExistent Source", "-d", "NonExistent Dest"]) + _ = argumentParser.processArgs() + + // When + let result = commandLineProcessing.process(argumentParser: argumentParser) + + // Then + XCTAssertFalse(result, "Process should return false when both DPs are not found") + XCTAssertTrue(mockDataModel.loadCalled, "DataModel.load should be called") + } + + // MARK: - Synchronization Flags Tests + + func test_process_synchronizationInProgressFlag() throws { + // Given + let argumentParser = ArgumentParser(arguments: ["JamfSync", "-s", "Source DP", "-d", "Destination DP"]) + _ = argumentParser.processArgs() + + XCTAssertFalse(mockDataModel.synchronizationInProgress, "synchronizationInProgress should initially be false") + + // When + _ = commandLineProcessing.process(argumentParser: argumentParser) + + // Then - synchronizationInProgress is set to true during processing and false after + XCTAssertFalse(mockDataModel.synchronizationInProgress, "synchronizationInProgress should be false after completion") + } + + // MARK: - Edge Cases + + func test_process_emptyDpNames() throws { + // Given + let argumentParser = ArgumentParser(arguments: ["JamfSync", "-s", "", "-d", ""]) + _ = argumentParser.processArgs() + + // When + let result = commandLineProcessing.process(argumentParser: argumentParser) + + // Then + XCTAssertFalse(result, "Process should return false with empty DP names") + } + + func test_process_dpNameWithSpecialCharacters() throws { + // Given + let specialDp = MockDistributionPointSync(name: "DP-Name_With.Special@Chars") + mockDataModel.distributionPoints = [specialDp, dstDp] + + let argumentParser = ArgumentParser(arguments: ["JamfSync", "-s", "DP-Name_With.Special@Chars", "-d", "Destination DP"]) + _ = argumentParser.processArgs() + + // When + let result = commandLineProcessing.process(argumentParser: argumentParser) + + // Then + XCTAssertTrue(result, "Process should handle DP names with special characters") + } + + func test_process_dpNameWithSpaces() throws { + // Given + let spaceDp = MockDistributionPointSync(name: "DP With Spaces") + mockDataModel.distributionPoints = [spaceDp, dstDp] + + let argumentParser = ArgumentParser(arguments: ["JamfSync", "-s", "DP With Spaces", "-d", "Destination DP"]) + _ = argumentParser.processArgs() + + // When + let result = commandLineProcessing.process(argumentParser: argumentParser) + + // Then + XCTAssertTrue(result, "Process should handle DP names with spaces") + } +} + +// MARK: - Mock Classes + +class MockDataModel: DataModel { + var distributionPoints: [DistributionPoint] = [] + var jamfProInstances: [JamfProInstance] = [] + var loadCalled = false + var loadingInProgressGroupWasCalled = false + + override func load(dataPersistence: DataPersistence, isProcessingCommandLine: Bool = false) { + loadCalled = true + + // Populate savableItems with jamfProInstances + savableItems = SavableItems() + for instance in jamfProInstances { + savableItems.items.append(instance) + } + + // Simulate the async loading behavior + // The real DataModel.loadDps() enters the group, does async work, then leaves + // We need to match this pattern: enter() then leave() + if let group = loadingInProgressGroup { + loadingInProgressGroupWasCalled = true + group.enter() // Must enter before we can leave + group.leave() // Immediately leave since we have no async work + } + } + + override func findDpByCombinedName(name: String) -> DistributionPoint? { + let nameParts = name.components(separatedBy: ":") + guard nameParts.count > 0 else { return nil } + + if nameParts.count == 1 { + return distributionPoints.first { $0.name == name } + } + + let dpName = nameParts[0] + let serverName = nameParts[1] + return distributionPoints.first { $0.name == dpName && $0.jamfProInstanceName == serverName } + } + + override func findJamfProInstance(id: UUID?) -> JamfProInstance? { + guard let id else { return nil } + return jamfProInstances.first { $0.id == id } + } +} + +class MockDataPersistence: DataPersistence { + init() { + let dataManager = DataManager() + super.init(dataManager: dataManager) + } + + override func loadSavableItems() -> SavableItems { + return SavableItems() + } +} diff --git a/JamfSyncTests/Utility/FileHashTests.swift b/JamfSyncTests/Utility/FileHashTests.swift new file mode 100644 index 0000000..0b04cf2 --- /dev/null +++ b/JamfSyncTests/Utility/FileHashTests.swift @@ -0,0 +1,360 @@ +// +// Copyright 2026, Jamf +// + +@testable import Jamf_Sync +import XCTest +import CryptoKit + +final class FileHashTests: XCTestCase { + var fileHash: FileHash! + var testDirectory: URL! + + override func setUp() async throws { + try await super.setUp() + fileHash = FileHash.shared + + // Create a unique test directory + testDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("FileHashTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: testDirectory, withIntermediateDirectories: true) + } + + override func tearDown() async throws { + // Clean up test directory + if FileManager.default.fileExists(atPath: testDirectory.path) { + try? FileManager.default.removeItem(at: testDirectory) + } + testDirectory = nil + try await super.tearDown() + } + + // MARK: - createSHA512Hash Tests + + func test_createSHA512Hash_withSimpleContent() async throws { + // Given + let testContent = "Hello, World!" + let testFile = testDirectory.appendingPathComponent("test.txt") + try testContent.write(to: testFile, atomically: true, encoding: .utf8) + + // Calculate expected hash + let expectedHash = SHA512.hash(data: testContent.data(using: .utf8)!) + let expectedHashString = Data(expectedHash).hexEncodedString() + + // When + let result = try await fileHash.createSHA512Hash(filePath: testFile.path) + + // Then + XCTAssertNotNil(result) + XCTAssertEqual(result, expectedHashString) + XCTAssertEqual(result?.count, 128, "SHA512 hash should be 128 characters (64 bytes in hex)") + } + + func test_createSHA512Hash_withEmptyFile() async throws { + // Given + let testFile = testDirectory.appendingPathComponent("empty.txt") + try "".write(to: testFile, atomically: true, encoding: .utf8) + + // Calculate expected hash for empty data + let expectedHash = SHA512.hash(data: Data()) + let expectedHashString = Data(expectedHash).hexEncodedString() + + // When + let result = try await fileHash.createSHA512Hash(filePath: testFile.path) + + // Then + XCTAssertNotNil(result) + XCTAssertEqual(result, expectedHashString) + } + + func test_createSHA512Hash_withLargeFile() async throws { + // Given - Create a file larger than the buffer size (1024 bytes) + let largeContent = String(repeating: "A", count: 10000) + let testFile = testDirectory.appendingPathComponent("large.txt") + try largeContent.write(to: testFile, atomically: true, encoding: .utf8) + + // Calculate expected hash + let expectedHash = SHA512.hash(data: largeContent.data(using: .utf8)!) + let expectedHashString = Data(expectedHash).hexEncodedString() + + // When + let result = try await fileHash.createSHA512Hash(filePath: testFile.path) + + // Then + XCTAssertNotNil(result) + XCTAssertEqual(result, expectedHashString) + } + + func test_createSHA512Hash_withBinaryData() async throws { + // Given - Create a file with binary data + let binaryData = Data([0x00, 0x01, 0x02, 0xFF, 0xFE, 0xFD]) + let testFile = testDirectory.appendingPathComponent("binary.dat") + try binaryData.write(to: testFile) + + // Calculate expected hash + let expectedHash = SHA512.hash(data: binaryData) + let expectedHashString = Data(expectedHash).hexEncodedString() + + // When + let result = try await fileHash.createSHA512Hash(filePath: testFile.path) + + // Then + XCTAssertNotNil(result) + XCTAssertEqual(result, expectedHashString) + } + + func test_createSHA512Hash_withNonExistentFile() async throws { + // Given + let nonExistentFile = testDirectory.appendingPathComponent("nonexistent.txt") + + // When + let result = try await fileHash.createSHA512Hash(filePath: nonExistentFile.path) + + // Then + // FileHash.createSHA512Hash(filePath:) returns nil when the InputStream + // cannot be created (e.g., when the file does not exist). + XCTAssertNil(result, "Non-existent file should return nil hash") + } + + func test_createSHA512Hash_withDirectory() async throws { + // Given - Try to hash a directory instead of a file + let directory = testDirectory.appendingPathComponent("subdir") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + // When/Then + // InputStream throws an error when trying to read from a directory + do { + _ = try await fileHash.createSHA512Hash(filePath: directory.path) + XCTFail("Should throw an error when hashing a directory") + } catch { + // Expected - verify the error message contains "directory" + let errorMessage = (error as NSError).localizedDescription.lowercased() + XCTAssertTrue(errorMessage.contains("directory"), "Error should indicate it's a directory") + } + } + + func test_createSHA512Hash_withMultipleFiles_sameContent() async throws { + // Given - Two files with identical content + let content = "Identical content" + let file1 = testDirectory.appendingPathComponent("file1.txt") + let file2 = testDirectory.appendingPathComponent("file2.txt") + try content.write(to: file1, atomically: true, encoding: .utf8) + try content.write(to: file2, atomically: true, encoding: .utf8) + + // When + let hash1 = try await fileHash.createSHA512Hash(filePath: file1.path) + let hash2 = try await fileHash.createSHA512Hash(filePath: file2.path) + + // Then + XCTAssertNotNil(hash1) + XCTAssertNotNil(hash2) + XCTAssertEqual(hash1, hash2, "Files with identical content should have identical hashes") + } + + func test_createSHA512Hash_withMultipleFiles_differentContent() async throws { + // Given - Two files with different content + let content1 = "Content A" + let content2 = "Content B" + let file1 = testDirectory.appendingPathComponent("fileA.txt") + let file2 = testDirectory.appendingPathComponent("fileB.txt") + try content1.write(to: file1, atomically: true, encoding: .utf8) + try content2.write(to: file2, atomically: true, encoding: .utf8) + + // When + let hash1 = try await fileHash.createSHA512Hash(filePath: file1.path) + let hash2 = try await fileHash.createSHA512Hash(filePath: file2.path) + + // Then + XCTAssertNotNil(hash1) + XCTAssertNotNil(hash2) + XCTAssertNotEqual(hash1, hash2, "Files with different content should have different hashes") + } + + func test_createSHA512Hash_withUnicodeContent() async throws { + // Given + let unicodeContent = "Hello 世界 🌍 café" + let testFile = testDirectory.appendingPathComponent("unicode.txt") + try unicodeContent.write(to: testFile, atomically: true, encoding: .utf8) + + // Calculate expected hash + let expectedHash = SHA512.hash(data: unicodeContent.data(using: .utf8)!) + let expectedHashString = Data(expectedHash).hexEncodedString() + + // When + let result = try await fileHash.createSHA512Hash(filePath: testFile.path) + + // Then + XCTAssertNotNil(result) + XCTAssertEqual(result, expectedHashString) + } + + func test_createSHA512Hash_withNewlineVariations() async throws { + // Given - Test that different newline styles produce different hashes + let unixNewline = "Line 1\nLine 2" + let windowsNewline = "Line 1\r\nLine 2" + + let file1 = testDirectory.appendingPathComponent("unix.txt") + let file2 = testDirectory.appendingPathComponent("windows.txt") + try unixNewline.write(to: file1, atomically: true, encoding: .utf8) + try windowsNewline.write(to: file2, atomically: true, encoding: .utf8) + + // When + let hash1 = try await fileHash.createSHA512Hash(filePath: file1.path) + let hash2 = try await fileHash.createSHA512Hash(filePath: file2.path) + + // Then + XCTAssertNotNil(hash1) + XCTAssertNotNil(hash2) + XCTAssertNotEqual(hash1, hash2, "Different newline styles should produce different hashes") + } + + func test_createSHA512Hash_bufferBoundary() async throws { + // Given - Create a file exactly at the buffer size (1024 bytes) + let exactBufferContent = String(repeating: "X", count: 1024) + let testFile = testDirectory.appendingPathComponent("buffer.txt") + try exactBufferContent.write(to: testFile, atomically: true, encoding: .utf8) + + // Calculate expected hash + let expectedHash = SHA512.hash(data: exactBufferContent.data(using: .utf8)!) + let expectedHashString = Data(expectedHash).hexEncodedString() + + // When + let result = try await fileHash.createSHA512Hash(filePath: testFile.path) + + // Then + XCTAssertNotNil(result) + XCTAssertEqual(result, expectedHashString) + } + + func test_createSHA512Hash_multipleBuffers() async throws { + // Given - Create a file that requires multiple buffer reads (3000 bytes = ~3 buffers) + let multiBufferContent = String(repeating: "M", count: 3000) + let testFile = testDirectory.appendingPathComponent("multibuffer.txt") + try multiBufferContent.write(to: testFile, atomically: true, encoding: .utf8) + + // Calculate expected hash + let expectedHash = SHA512.hash(data: multiBufferContent.data(using: .utf8)!) + let expectedHashString = Data(expectedHash).hexEncodedString() + + // When + let result = try await fileHash.createSHA512Hash(filePath: testFile.path) + + // Then + XCTAssertNotNil(result) + XCTAssertEqual(result, expectedHashString) + } + + // MARK: - Data Extension Tests + + func test_hexEncodedString_withSimpleData() { + // Given + let data = Data([0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]) + + // When + let result = data.hexEncodedString() + + // Then + XCTAssertEqual(result, "0123456789abcdef") + } + + func test_hexEncodedString_withEmptyData() { + // Given + let data = Data() + + // When + let result = data.hexEncodedString() + + // Then + XCTAssertEqual(result, "") + } + + func test_hexEncodedString_withSingleByte() { + // Given + let data = Data([0xFF]) + + // When + let result = data.hexEncodedString() + + // Then + XCTAssertEqual(result, "ff") + } + + func test_hexEncodedString_withLeadingZeros() { + // Given + let data = Data([0x00, 0x01, 0x0F]) + + // When + let result = data.hexEncodedString() + + // Then + XCTAssertEqual(result, "00010f", "Leading zeros should be preserved") + } + + func test_hexEncodedString_withAllZeros() { + // Given + let data = Data([0x00, 0x00, 0x00]) + + // When + let result = data.hexEncodedString() + + // Then + XCTAssertEqual(result, "000000") + } + + func test_hexEncodedString_withAllOnes() { + // Given + let data = Data([0xFF, 0xFF, 0xFF]) + + // When + let result = data.hexEncodedString() + + // Then + XCTAssertEqual(result, "ffffff") + } + + func test_hexEncodedString_lengthCorrect() { + // Given + let data = Data([0x12, 0x34, 0x56, 0x78]) + + // When + let result = data.hexEncodedString() + + // Then + XCTAssertEqual(result.count, data.count * 2, "Hex string should be twice the length of data") + } + + // MARK: - Concurrent Access Tests + + func test_concurrentHashCalculation() async throws { + // Given - Create multiple test files + let fileCount = 5 + var files: [URL] = [] + for i in 0..