// MARK: - File System public protocol FileSystemProviding: Sendable { func containerURL(for purpose: SyncPurpose) -> URL? func documentsURL() -> URL } // MARK: - File Access public protocol FileAccessorProviding: Sendable { func read(from url: URL) throws -> Data func write(_ data: Data, to url: URL) throws func fileExists(at url: URL) -> Bool } // MARK: - Network (Nutritionix API) public protocol NetworkProviding: Sendable { func fetch(from url: URL) async throws -> Data func post(_ data: Data, to url: URL) async throws -> Data } // MARK: - Errors enum SyncError: Error { case containerNotAvailable case dataCorrupted case networkUnavailable case encodingFailed }
// Producción: FileManager real public struct DefaultFileSystemProvider: FileSystemProviding { public init() {} public func containerURL(for _: SyncPurpose) -> URL? { FileManager.default .url(forUbiquityContainerIdentifier: nil) } public func documentsURL() -> URL { FileManager.default .urls(for: .documentDirectory, in: .userDomainMask)[0] } } // Producción: URLSession real public struct DefaultNetworkProvider: NetworkProviding { let session: URLSession public init(session: URLSession = .shared) { self.session = session } public func fetch(from url: URL) async throws -> Data { let (data, _) = try await session.data(from: url) return data } public func post(_ data: Data, to url: URL) async throws -> Data { var request = URLRequest(url: url) request.httpMethod = "POST" request.httpBody = data let (resp, _) = try await session.data(for: request) return resp } }
public actor SyncManager { private let fileSystem: FileSystemProviding private let fileAccessor: FileAccessorProviding private let network: NetworkProviding private let decoder = JSONDecoder() public init( fileSystem: FileSystemProviding = DefaultFileSystemProvider(), fileAccessor: FileAccessorProviding = DefaultFileAccessor(), network: NetworkProviding = DefaultNetworkProvider() ) { self.fileSystem = fileSystem self.fileAccessor = fileAccessor self.network = network } /// Sincroniza el diario con iCloud public func syncDiary() async throws -> [MealEntry] { guard let url = fileSystem.containerURL(for: .diary) else { throw SyncError.containerNotAvailable } let data = try fileAccessor.read(from: url) return try decoder.decode([MealEntry].self, from: data) } /// Busca alimento en Nutritionix API public func searchFood(_ query: String) async throws -> [FoodItem] { let url = NutriAPI.searchURL(query: query) let data = try await network.fetch(from: url) return try decoder.decode([FoodItem].self, from: data) } }
public final class MockFileSystemProvider: FileSystemProviding, @unchecked Sendable { public var containerURL: URL? public init(containerURL: URL? = URL(string: "file:///tmp/test")) { self.containerURL = containerURL } public func containerURL(for _: SyncPurpose) -> URL? { containerURL } public func documentsURL() -> URL { URL(string: "file:///tmp/docs")! } } public final class MockFileAccessor: FileAccessorProviding, @unchecked Sendable { public var files: [URL: Data] = [:] public var readError: Error? public var writeError: Error? public func read(from url: URL) throws -> Data { if let e = readError { throw e } guard let d = files[url] else { throw CocoaError(.fileReadNoSuchFile) } return d } public func write(_ data: Data, to url: URL) throws { if let e = writeError { throw e } files[url] = data } public func fileExists(at url: URL) -> Bool { files[url] != nil } } public final class MockNetworkProvider: NetworkProviding, @unchecked Sendable { public var responses: [URL: Data] = [:] public var fetchError: Error? public func fetch(from url: URL) async throws -> Data { if let e = fetchError { throw e } return responses[url] ?? Data() } public func post(_ d: Data, to u: URL) async throws -> Data { d } }
import Testing @testable import NutriTrackCore @Suite("SyncManager — Protocol DI") struct SyncManagerTests { // ✅ iCloud no disponible → lanza containerNotAvailable @Test("Falla cuando iCloud no está disponible") func testMissingContainer() async { let mockFS = MockFileSystemProvider(containerURL: nil) let manager = SyncManager(fileSystem: mockFS) await #expect(throws: SyncError.containerNotAvailable) { try await manager.syncDiary() } } // ✅ Lee datos correctamente desde mock @Test("Sincroniza el diario correctamente") func testSyncDiarySuccess() async throws { let entries = [MealEntry(id: "1", name: "Avena", kcal: 320)] let data = try JSONEncoder().encode(entries) let mockFS = MockFileSystemProvider() let mockFA = MockFileAccessor() mockFA.files[mockFS.containerURL(for: .diary)!] = data let result = try await SyncManager(fileSystem: mockFS, fileAccessor: mockFA).syncDiary() #expect(result.count == 1) #expect(result[0].name == "Avena") } // ✅ Datos corruptos → lanza dataCorrupted @Test("Maneja datos corruptos sin crashear") func testCorruptData() async { let mockFS = MockFileSystemProvider() let mockFA = MockFileAccessor() mockFA.readError = CocoaError(.fileReadCorruptFile) await #expect(throws: (any Error).self) { try await SyncManager(fileSystem: mockFS, fileAccessor: mockFA).syncDiary() } } // ✅ Búsqueda de alimento exitosa @Test("Busca alimentos en la API correctamente") func testSearchFoodSuccess() async throws { let items = [FoodItem(id: "f1", name: "Plátano", kcal: 89)] let data = try JSONEncoder().encode(items) let mockNet = MockNetworkProvider() mockNet.responses[NutriAPI.searchURL(query: "platano")] = data let results = try await SyncManager(network: mockNet) .searchFood("platano") #expect(results[0].name == "Plátano") #expect(results[0].kcal == 89) } // ✅ Red no disponible → lanza networkUnavailable @Test("Maneja red no disponible") func testNetworkError() async { let mockNet = MockNetworkProvider() mockNet.fetchError = URLError(.notConnectedToInternet) await #expect(throws: (any Error).self) { try await SyncManager(network: mockNet).searchFood("manzana") } } }
NutriTrackProviding con 15 métodos mezclando ficheros, red y BD.
→ 3 protocolos focalizados
#if DEBUG en el código de producción para intercambiar implementaciones.
→ Default params en init()
MealEntry (tipo de datos interno sin I/O) — solo mockear fronteras externas.
Sendable en protocolos usados dentro de actors — el compilador lanzará warning/error.
→ Todos los protocolos heredan Sendable
NutrientCalculator (lógica pura sin I/O externo).