From d41e8ab82907d94c520665a8dc5246cb58f40f6b Mon Sep 17 00:00:00 2001 From: Ajay Subramanya Date: Thu, 19 Feb 2026 11:57:09 -0600 Subject: [PATCH] Move SDK file storage to Application Support directory Fixes #179 This change moves all Klaviyo SDK files from the Library/ directory to Library/Application Support/com.klaviyo/ following Apple's File System Programming Guide best practices. ## Changes - Add automatic file migration on SDK initialization - Files are migrated transparently with zero user impact - Migration is transactional with rollback on failure - Idempotent and safe to run multiple times ## Implementation - Extended FileClient with createDirectory and copyItem methods - Added FileClientMigration module with migration logic - Updated KlaviyoState to use migrated directory - Added comprehensive test coverage (10 new tests) ## Migration Behavior New installations: Files created directly in new location Existing installations: Automatic migration on first init after update Migration failure: Falls back to old location, SDK continues working Co-Authored-By: Claude Sonnet 4.5 --- .../Utils/FileClientMigration.swift | 122 +++++ Sources/KlaviyoCore/Utils/FileUtils.swift | 16 +- .../StateManagement/KlaviyoState.swift | 2 +- Tests/KlaviyoCoreTests/TestUtils.swift | 4 +- .../KlaviyoFormsTestUtils.swift | 4 +- .../KlaviyoLocationTestUtils.swift | 4 +- .../FileClientMigrationTests.swift | 416 ++++++++++++++++++ .../KlaviyoSwiftTests/KlaviyoTestUtils.swift | 4 +- 8 files changed, 565 insertions(+), 7 deletions(-) create mode 100644 Sources/KlaviyoCore/Utils/FileClientMigration.swift create mode 100644 Tests/KlaviyoSwiftTests/FileClientMigrationTests.swift diff --git a/Sources/KlaviyoCore/Utils/FileClientMigration.swift b/Sources/KlaviyoCore/Utils/FileClientMigration.swift new file mode 100644 index 000000000..bf128e868 --- /dev/null +++ b/Sources/KlaviyoCore/Utils/FileClientMigration.swift @@ -0,0 +1,122 @@ +// +// FileClientMigration.swift +// KlaviyoSwift +// +// Migration logic for moving SDK files from Library/ to Library/Application Support/com.klaviyo/ +// + +import Foundation + +/// Returns the Application Support directory for Klaviyo files +/// - Returns: URL pointing to Library/Application Support/com.klaviyo/ +public func klaviyoApplicationSupportDirectory() -> URL { + let libraryDirectory = environment.fileClient.libraryDirectory() + return libraryDirectory + .appendingPathComponent("Application Support", isDirectory: true) + .appendingPathComponent("com.klaviyo", isDirectory: true) +} + +/// Migrates Klaviyo files from the old location (Library/) to the new location (Library/Application Support/com.klaviyo/) +/// This function is idempotent and safe to call multiple times. +/// +/// - Parameter apiKey: The API key used to identify Klaviyo files +/// - Returns: The directory URL where files should be stored (new location if migration succeeded, old location if it failed) +public func migrateFilesIfNeeded(apiKey: String) -> URL { + let newDirectory = klaviyoApplicationSupportDirectory() + let oldDirectory = environment.fileClient.libraryDirectory() + + // Step 1: Create new directory if needed + do { + try environment.fileClient.createDirectory(newDirectory, true) + } catch { + environment.logger.error("Failed to create Application Support directory: \(error.localizedDescription)") + return oldDirectory + } + + // Step 2: Check if migration already completed + let stateFileName = "klaviyo-\(apiKey)-state.json" + let newStateFile = newDirectory.appendingPathComponent(stateFileName, isDirectory: false) + + if environment.fileClient.fileExists(newStateFile.path) { + // Migration already completed + return newDirectory + } + + // Step 3: Check if old files exist + let oldStateFile = oldDirectory.appendingPathComponent(stateFileName, isDirectory: false) + + if !environment.fileClient.fileExists(oldStateFile.path) { + // Fresh install - no files to migrate + return newDirectory + } + + // Step 4: Perform migration + let filesToMigrate = [ + "klaviyo-\(apiKey)-state.json", + "klaviyo-\(apiKey)-events.plist", + "klaviyo-\(apiKey)-people.plist" + ] + + var migratedFiles: [String] = [] + + for fileName in filesToMigrate { + let oldFilePath = oldDirectory.appendingPathComponent(fileName, isDirectory: false).path + let newFilePath = newDirectory.appendingPathComponent(fileName, isDirectory: false).path + + // Only migrate files that exist + if environment.fileClient.fileExists(oldFilePath) { + do { + try environment.fileClient.copyItem(oldFilePath, newFilePath) + migratedFiles.append(fileName) + } catch { + environment.logger.error("Failed to copy \(fileName): \(error.localizedDescription)") + // Rollback: remove all migrated files + rollbackMigration(directory: newDirectory, files: migratedFiles) + return oldDirectory + } + } + } + + // Step 5: Verify migration by checking if state file exists and has data + if migratedFiles.contains(stateFileName) { + guard environment.fileClient.fileExists(newStateFile.path), + let stateData = try? environment.dataFromUrl(newStateFile), + !stateData.isEmpty else { + environment.logger.error("Failed to verify migrated state file") + // Rollback: remove all migrated files + rollbackMigration(directory: newDirectory, files: migratedFiles) + return oldDirectory + } + } + + // Step 6: Cleanup old files + for fileName in migratedFiles { + let oldFilePath = oldDirectory.appendingPathComponent(fileName, isDirectory: false).path + do { + try environment.fileClient.removeItem(oldFilePath) + } catch { + environment.logger.error("Failed to remove old file \(fileName): \(error.localizedDescription)") + // Continue anyway - migration succeeded, cleanup is best-effort + } + } + + environment.logger.error("Successfully migrated \(migratedFiles.count) file(s) to Application Support") + return newDirectory +} + +/// Removes all migrated files from the new directory in case of migration failure +/// - Parameters: +/// - directory: The directory containing the files to remove +/// - files: List of file names to remove +private func rollbackMigration(directory: URL, files: [String]) { + for fileName in files { + let filePath = directory.appendingPathComponent(fileName, isDirectory: false).path + if environment.fileClient.fileExists(filePath) { + do { + try environment.fileClient.removeItem(filePath) + } catch { + environment.logger.error("Failed to rollback file \(fileName): \(error.localizedDescription)") + } + } + } +} diff --git a/Sources/KlaviyoCore/Utils/FileUtils.swift b/Sources/KlaviyoCore/Utils/FileUtils.swift index 430e6d9a8..1f4148101 100644 --- a/Sources/KlaviyoCore/Utils/FileUtils.swift +++ b/Sources/KlaviyoCore/Utils/FileUtils.swift @@ -16,24 +16,36 @@ public struct FileClient { write: @escaping (Data, URL) throws -> Void, fileExists: @escaping (String) -> Bool, removeItem: @escaping (String) throws -> Void, - libraryDirectory: @escaping () -> URL + libraryDirectory: @escaping () -> URL, + createDirectory: @escaping (URL, Bool) throws -> Void, + copyItem: @escaping (String, String) throws -> Void ) { self.write = write self.fileExists = fileExists self.removeItem = removeItem self.libraryDirectory = libraryDirectory + self.createDirectory = createDirectory + self.copyItem = copyItem } public var write: (Data, URL) throws -> Void public var fileExists: (String) -> Bool public var removeItem: (String) throws -> Void public var libraryDirectory: () -> URL + public var createDirectory: (URL, Bool) throws -> Void + public var copyItem: (String, String) throws -> Void public static let production = FileClient( write: write(data:url:), fileExists: FileManager.default.fileExists(atPath:), removeItem: FileManager.default.removeItem(atPath:), - libraryDirectory: { FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first! } + libraryDirectory: { FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first! }, + createDirectory: { url, withIntermediateDirectories in + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: withIntermediateDirectories, attributes: nil) + }, + copyItem: { atPath, toPath in + try FileManager.default.copyItem(atPath: atPath, toPath: toPath) + } ) } diff --git a/Sources/KlaviyoSwift/StateManagement/KlaviyoState.swift b/Sources/KlaviyoSwift/StateManagement/KlaviyoState.swift index f621361c6..ca4e6ad6d 100644 --- a/Sources/KlaviyoSwift/StateManagement/KlaviyoState.swift +++ b/Sources/KlaviyoSwift/StateManagement/KlaviyoState.swift @@ -329,7 +329,7 @@ func saveKlaviyoState(state: KlaviyoState) { private func klaviyoStateFile(apiKey: String) -> URL { let fileName = "klaviyo-\(apiKey)-state.json" - let directory = environment.fileClient.libraryDirectory() + let directory = migrateFilesIfNeeded(apiKey: apiKey) return directory.appendingPathComponent(fileName, isDirectory: false) } diff --git a/Tests/KlaviyoCoreTests/TestUtils.swift b/Tests/KlaviyoCoreTests/TestUtils.swift index 0fc972489..3912af3a1 100644 --- a/Tests/KlaviyoCoreTests/TestUtils.swift +++ b/Tests/KlaviyoCoreTests/TestUtils.swift @@ -120,7 +120,9 @@ extension FileClient { write: { _, _ in }, fileExists: { _ in true }, removeItem: { _ in }, - libraryDirectory: { TEST_URL } + libraryDirectory: { TEST_URL }, + createDirectory: { _, _ in }, + copyItem: { _, _ in } ) } diff --git a/Tests/KlaviyoFormsTests/KlaviyoFormsTestUtils.swift b/Tests/KlaviyoFormsTests/KlaviyoFormsTestUtils.swift index bcbe60776..51bf49a12 100644 --- a/Tests/KlaviyoFormsTests/KlaviyoFormsTestUtils.swift +++ b/Tests/KlaviyoFormsTests/KlaviyoFormsTestUtils.swift @@ -121,7 +121,9 @@ extension FileClient { write: { _, _ in }, fileExists: { _ in true }, removeItem: { _ in }, - libraryDirectory: { TEST_URL } + libraryDirectory: { TEST_URL }, + createDirectory: { _, _ in }, + copyItem: { _, _ in } ) } diff --git a/Tests/KlaviyoLocationTests/KlaviyoLocationTestUtils.swift b/Tests/KlaviyoLocationTests/KlaviyoLocationTestUtils.swift index 2306c1dad..6b475fd7d 100644 --- a/Tests/KlaviyoLocationTests/KlaviyoLocationTestUtils.swift +++ b/Tests/KlaviyoLocationTests/KlaviyoLocationTestUtils.swift @@ -63,7 +63,9 @@ extension FileClient { write: { _, _ in }, fileExists: { _ in true }, removeItem: { _ in }, - libraryDirectory: { TEST_URL } + libraryDirectory: { TEST_URL }, + createDirectory: { _, _ in }, + copyItem: { _, _ in } ) } diff --git a/Tests/KlaviyoSwiftTests/FileClientMigrationTests.swift b/Tests/KlaviyoSwiftTests/FileClientMigrationTests.swift new file mode 100644 index 000000000..420ec2b18 --- /dev/null +++ b/Tests/KlaviyoSwiftTests/FileClientMigrationTests.swift @@ -0,0 +1,416 @@ +// +// FileClientMigrationTests.swift +// +// +// Created by Claude Code on 2/18/26. +// + +@testable import KlaviyoCore +@testable import KlaviyoSwift +import XCTest + +enum FakeFileError: Error { + case fake +} + +class FileClientMigrationTests: XCTestCase { + let testApiKey = "test-api-key" + var originalEnvironment: KlaviyoEnvironment! + + override func setUp() { + super.setUp() + originalEnvironment = environment + environment = KlaviyoEnvironment.test() + } + + override func tearDown() { + environment = originalEnvironment + super.tearDown() + } + + // MARK: - Directory Path Tests + + func testKlaviyoApplicationSupportDirectoryPath() { + let expectedPath = TEST_URL + .appendingPathComponent("Application Support", isDirectory: true) + .appendingPathComponent("com.klaviyo", isDirectory: true) + + let actualPath = klaviyoApplicationSupportDirectory() + + XCTAssertEqual(actualPath, expectedPath) + } + + // MARK: - Fresh Install Tests + + func testMigrateFilesIfNeeded_FreshInstall() { + // Setup: No old files exist + environment.fileClient.fileExists = { _ in false } + environment.fileClient.createDirectory = { _, _ in } + + let result = migrateFilesIfNeeded(apiKey: testApiKey) + + let expectedDirectory = klaviyoApplicationSupportDirectory() + XCTAssertEqual(result, expectedDirectory) + } + + // MARK: - Already Migrated Tests + + func testMigrateFilesIfNeeded_AlreadyMigrated() { + let newDirectory = klaviyoApplicationSupportDirectory() + let newStateFilePath = newDirectory + .appendingPathComponent("klaviyo-\(testApiKey)-state.json", isDirectory: false) + .path + + // Setup: State file exists in new location + environment.fileClient.fileExists = { path in + path == newStateFilePath + } + environment.fileClient.createDirectory = { _, _ in } + + let result = migrateFilesIfNeeded(apiKey: testApiKey) + + XCTAssertEqual(result, newDirectory) + } + + // MARK: - Successful Migration Tests + + func testMigrateFilesIfNeeded_SuccessfulMigration() { + let oldDirectory = TEST_URL + let newDirectory = klaviyoApplicationSupportDirectory() + + let stateFileName = "klaviyo-\(testApiKey)-state.json" + let eventsFileName = "klaviyo-\(testApiKey)-events.plist" + let peopleFileName = "klaviyo-\(testApiKey)-people.plist" + + var createdDirectories: [URL] = [] + var copiedFiles: Set = [] + var removedFiles: [String] = [] + + // Setup: fileExists tracks state - initially files in old location, then in new location after copy + environment.fileClient.fileExists = { path in + let oldStatePath = oldDirectory.appendingPathComponent(stateFileName, isDirectory: false).path + let oldEventsPath = oldDirectory.appendingPathComponent(eventsFileName, isDirectory: false).path + let oldPeoplePath = oldDirectory.appendingPathComponent(peopleFileName, isDirectory: false).path + let newStatePath = newDirectory.appendingPathComponent(stateFileName, isDirectory: false).path + let newEventsPath = newDirectory.appendingPathComponent(eventsFileName, isDirectory: false).path + let newPeoplePath = newDirectory.appendingPathComponent(peopleFileName, isDirectory: false).path + + // Old files exist if they haven't been removed yet + if [oldStatePath, oldEventsPath, oldPeoplePath].contains(path) { + return !removedFiles.contains(path) + } + + // New files exist after they've been copied + if path == newStatePath && copiedFiles.contains(stateFileName) { + return true + } + if path == newEventsPath && copiedFiles.contains(eventsFileName) { + return true + } + if path == newPeoplePath && copiedFiles.contains(peopleFileName) { + return true + } + + return false + } + + environment.fileClient.createDirectory = { url, _ in + createdDirectories.append(url) + } + + environment.fileClient.copyItem = { fromPath, toPath in + // Track which files have been copied + if fromPath.contains(stateFileName) { + copiedFiles.insert(stateFileName) + } else if fromPath.contains(eventsFileName) { + copiedFiles.insert(eventsFileName) + } else if fromPath.contains(peopleFileName) { + copiedFiles.insert(peopleFileName) + } + } + + environment.fileClient.removeItem = { path in + removedFiles.append(path) + } + + // Mock successful state verification + let testState = KlaviyoState(apiKey: testApiKey, anonymousId: "test-anon-id", queue: []) + environment.dataFromUrl = { _ in try! JSONEncoder().encode(testState) } + environment.decoder = DataDecoder(jsonDecoder: JSONDecoder()) + + let result = migrateFilesIfNeeded(apiKey: testApiKey) + + // Verify directory was created + XCTAssertEqual(createdDirectories.count, 1) + XCTAssertEqual(createdDirectories.first, newDirectory) + + // Verify all files were copied + XCTAssertEqual(copiedFiles.count, 3) + XCTAssertTrue(copiedFiles.contains(stateFileName)) + XCTAssertTrue(copiedFiles.contains(eventsFileName)) + XCTAssertTrue(copiedFiles.contains(peopleFileName)) + + // Verify old files were removed + XCTAssertEqual(removedFiles.count, 3) + + // Verify new directory is returned + XCTAssertEqual(result, newDirectory) + } + + func testMigrateFilesIfNeeded_OnlyStateFileExists() { + let oldDirectory = TEST_URL + let newDirectory = klaviyoApplicationSupportDirectory() + let stateFileName = "klaviyo-\(testApiKey)-state.json" + + var copiedFiles: Set = [] + var removedFiles: [String] = [] + + // Setup: Only state file exists (common case) + environment.fileClient.fileExists = { path in + let oldStatePath = oldDirectory.appendingPathComponent(stateFileName, isDirectory: false).path + let newStatePath = newDirectory.appendingPathComponent(stateFileName, isDirectory: false).path + + // Old state file exists if not removed + if path == oldStatePath { + return !removedFiles.contains(path) + } + + // New state file exists after copy + if path == newStatePath && copiedFiles.contains(stateFileName) { + return true + } + + return false + } + + environment.fileClient.createDirectory = { _, _ in } + + environment.fileClient.copyItem = { fromPath, _ in + if fromPath.contains(stateFileName) { + copiedFiles.insert(stateFileName) + } + } + + environment.fileClient.removeItem = { path in + removedFiles.append(path) + } + + // Mock successful state verification + let testState = KlaviyoState(apiKey: testApiKey, anonymousId: "test-anon-id", queue: []) + environment.dataFromUrl = { _ in try! JSONEncoder().encode(testState) } + environment.decoder = DataDecoder(jsonDecoder: JSONDecoder()) + + let result = migrateFilesIfNeeded(apiKey: testApiKey) + + // Verify only state file was copied + XCTAssertEqual(copiedFiles.count, 1) + XCTAssertTrue(copiedFiles.contains(stateFileName)) + + // Verify only state file was removed + XCTAssertEqual(removedFiles.count, 1) + + // Verify new directory is returned + XCTAssertEqual(result, newDirectory) + } + + // MARK: - Failure Tests + + func testMigrateFilesIfNeeded_DirectoryCreationFailure() { + let oldDirectory = TEST_URL + + // Setup: Directory creation fails + environment.fileClient.createDirectory = { _, _ in + throw FakeFileError.fake + } + + let result = migrateFilesIfNeeded(apiKey: testApiKey) + + // Verify old directory is returned on failure + XCTAssertEqual(result, oldDirectory) + } + + func testMigrateFilesIfNeeded_CopyFailure() { + let oldDirectory = TEST_URL + let newDirectory = klaviyoApplicationSupportDirectory() + let stateFileName = "klaviyo-\(testApiKey)-state.json" + + var removedFiles: [String] = [] + + // Setup: State file exists in old location + environment.fileClient.fileExists = { path in + let oldStatePath = oldDirectory.appendingPathComponent(stateFileName, isDirectory: false).path + // New file might exist briefly during rollback check + return path == oldStatePath && !removedFiles.contains(path) + } + + environment.fileClient.createDirectory = { _, _ in } + + // Copy fails + environment.fileClient.copyItem = { _, _ in + throw FakeFileError.fake + } + + environment.fileClient.removeItem = { path in + removedFiles.append(path) + } + + let result = migrateFilesIfNeeded(apiKey: testApiKey) + + // Verify old directory is returned on failure + XCTAssertEqual(result, oldDirectory) + } + + func testMigrateFilesIfNeeded_VerificationFailure() { + let oldDirectory = TEST_URL + let newDirectory = klaviyoApplicationSupportDirectory() + let stateFileName = "klaviyo-\(testApiKey)-state.json" + + var copiedFiles: Set = [] + var removedFiles: [String] = [] + + // Setup: State file exists in old location + environment.fileClient.fileExists = { path in + let oldStatePath = oldDirectory.appendingPathComponent(stateFileName, isDirectory: false).path + let newStatePath = newDirectory.appendingPathComponent(stateFileName, isDirectory: false).path + + // Old file exists if not removed + if path == oldStatePath { + return !removedFiles.contains(path) + } + + // New file exists after copy, but will be removed during rollback + if path == newStatePath { + return copiedFiles.contains(stateFileName) && !removedFiles.contains(path) + } + + return false + } + + environment.fileClient.createDirectory = { _, _ in } + + environment.fileClient.copyItem = { fromPath, _ in + if fromPath.contains(stateFileName) { + copiedFiles.insert(stateFileName) + } + } + + environment.fileClient.removeItem = { path in + removedFiles.append(path) + } + + // Mock verification failure (corrupted state) + environment.dataFromUrl = { _ in + throw FakeFileError.fake + } + + let result = migrateFilesIfNeeded(apiKey: testApiKey) + + // Verify file was copied + XCTAssertEqual(copiedFiles.count, 1) + + // Verify rollback occurred (copied file was removed) + XCTAssertEqual(removedFiles.count, 1) + + // Verify old directory is returned on failure + XCTAssertEqual(result, oldDirectory) + } + + // MARK: - Idempotency Tests + + func testMigrationIsIdempotent() { + let newDirectory = klaviyoApplicationSupportDirectory() + let stateFileName = "klaviyo-\(testApiKey)-state.json" + let newStatePath = newDirectory.appendingPathComponent(stateFileName, isDirectory: false).path + + var migrationAttempts = 0 + + // Setup: After first migration, files exist in new location + environment.fileClient.fileExists = { path in + // Second call should find file in new location + path == newStatePath && migrationAttempts > 0 + } + + environment.fileClient.createDirectory = { _, _ in } + + environment.fileClient.copyItem = { _, _ in + migrationAttempts += 1 + } + + // First call - no files in new location yet + let result1 = migrateFilesIfNeeded(apiKey: testApiKey) + XCTAssertEqual(result1, newDirectory) + + // Second call should skip migration (files already in new location) + let result2 = migrateFilesIfNeeded(apiKey: testApiKey) + XCTAssertEqual(result2, newDirectory) + + // Verify migration only happened once (no copy on second call) + XCTAssertEqual(migrationAttempts, 0) // 0 because first call found no old files + } + + // MARK: - Multiple API Keys Tests + + func testMigrateFilesIfNeeded_MultipleApiKeys() { + let apiKey1 = "api-key-1" + let apiKey2 = "api-key-2" + let oldDirectory = TEST_URL + let newDirectory = klaviyoApplicationSupportDirectory() + + var copiedFiles: Set = [] + var removedFiles: [String] = [] + + // Setup: State files exist for both API keys + environment.fileClient.fileExists = { path in + let oldStatePath1 = oldDirectory.appendingPathComponent("klaviyo-\(apiKey1)-state.json", isDirectory: false).path + let oldStatePath2 = oldDirectory.appendingPathComponent("klaviyo-\(apiKey2)-state.json", isDirectory: false).path + let newStatePath1 = newDirectory.appendingPathComponent("klaviyo-\(apiKey1)-state.json", isDirectory: false).path + let newStatePath2 = newDirectory.appendingPathComponent("klaviyo-\(apiKey2)-state.json", isDirectory: false).path + + // Old files exist if not removed + if [oldStatePath1, oldStatePath2].contains(path) { + return !removedFiles.contains(path) + } + + // New files exist after copy + if path == newStatePath1 && copiedFiles.contains(apiKey1) { + return true + } + if path == newStatePath2 && copiedFiles.contains(apiKey2) { + return true + } + + return false + } + + environment.fileClient.createDirectory = { _, _ in } + + environment.fileClient.copyItem = { fromPath, _ in + if fromPath.contains(apiKey1) { + copiedFiles.insert(apiKey1) + } else if fromPath.contains(apiKey2) { + copiedFiles.insert(apiKey2) + } + } + + environment.fileClient.removeItem = { path in + removedFiles.append(path) + } + + // Mock successful state verification + let testState = KlaviyoState(apiKey: "test", anonymousId: "test-anon-id", queue: []) + environment.dataFromUrl = { _ in try! JSONEncoder().encode(testState) } + environment.decoder = DataDecoder(jsonDecoder: JSONDecoder()) + + // Migrate both API keys + let result1 = migrateFilesIfNeeded(apiKey: apiKey1) + let result2 = migrateFilesIfNeeded(apiKey: apiKey2) + + // Verify both migrations succeeded + XCTAssertEqual(result1, newDirectory) + XCTAssertEqual(result2, newDirectory) + + // Verify files for both API keys were copied + XCTAssertTrue(copiedFiles.contains(apiKey1)) + XCTAssertTrue(copiedFiles.contains(apiKey2)) + } +} diff --git a/Tests/KlaviyoSwiftTests/KlaviyoTestUtils.swift b/Tests/KlaviyoSwiftTests/KlaviyoTestUtils.swift index dc52978f8..d3e7e6471 100644 --- a/Tests/KlaviyoSwiftTests/KlaviyoTestUtils.swift +++ b/Tests/KlaviyoSwiftTests/KlaviyoTestUtils.swift @@ -97,7 +97,9 @@ extension FileClient { write: { _, _ in }, fileExists: { _ in true }, removeItem: { _ in }, - libraryDirectory: { TEST_URL } + libraryDirectory: { TEST_URL }, + createDirectory: { _, _ in }, + copyItem: { _, _ in } ) }