From d7458b8303b788e568101bff33c8c47aac7cd8b5 Mon Sep 17 00:00:00 2001 From: Danis Ziganshin Date: Sat, 1 Aug 2026 22:16:36 +0300 Subject: [PATCH 1/3] Add async person gateway APIs --- .../Cache/CachePersonsGateway.swift | 95 +++++++++++-------- Core/Gateways/PersonsGateway.swift | 51 ++++++++++ Core/UseCases/AddPersonUseCase.swift | 5 + Core/UseCases/EditPersonUseCase.swift | 5 + Core/UseCases/FetchPersonsUseCase.swift | 10 ++ Core/UseCases/RemovePersonUseCase.swift | 5 + .../Gateways/CachePersonsGatewayTests.swift | 17 ++++ .../UserCases/AddPersonUseCaseSpy.swift | 6 ++ .../UserCases/DisplayPersonsUseCaseSpy.swift | 9 ++ .../UserCases/EditPersonUseCaseSpy.swift | 6 ++ .../UserCases/FetchPersonsUseCaseSpy.swift | 8 ++ .../UserCases/RemovePersonUseCaseSpy.swift | 5 + .../UseCases/AddPersonUseCaseTests.swift | 11 +++ .../UseCases/FetchPersonsUseCaseTests.swift | 11 +++ 14 files changed, 203 insertions(+), 41 deletions(-) diff --git a/Core/EntityGateway/Cache/CachePersonsGateway.swift b/Core/EntityGateway/Cache/CachePersonsGateway.swift index 761748d..b47aa63 100644 --- a/Core/EntityGateway/Cache/CachePersonsGateway.swift +++ b/Core/EntityGateway/Cache/CachePersonsGateway.swift @@ -21,57 +21,91 @@ public final class CachePersonsGateway: PersonsGateway { public func add(parameters: AddPersonParameters, completionHandler: @escaping AddPersonEntityGatewayCompletionHandler) { Task { do { - let savedImages = try await save(newImages(in: parameters)) - let result = await addToCoreData(parameters: parameters) - if case .failure = result { - await deleteBestEffort(savedImages) - } - await complete(result, using: completionHandler) + await complete(.success(try await add(parameters: parameters)), using: completionHandler) + } catch let error as CoreError { + await complete(.failure(error), using: completionHandler) } catch { await complete(.failure(CoreError(error: error)), using: completionHandler) } } } + public func add(parameters: AddPersonParameters) async throws -> Person { + try Task.checkCancellation() + let savedImages = try await save(newImages(in: parameters)) + do { + try Task.checkCancellation() + return try await coreDataGateway.add(parameters: parameters) + } catch { + await deleteBestEffort(savedImages) + throw error + } + } + public func fetchPersons(completionHandler: @escaping FetchPersonsEntityGatewayCompletionHandler) { coreDataGateway.fetchPersons(completionHandler: completionHandler) } + public func fetchPersons() async throws -> [Person] { + try await coreDataGateway.fetchPersons() + } + public func fetchWidgetPersons(completion: @escaping FetchPersonsEntityGatewayCompletionHandler) { coreDataGateway.fetchWidgetPersons(completion: completion) } + public func fetchWidgetPersons() async throws -> [Person] { + try await coreDataGateway.fetchWidgetPersons() + } + public func edit( person: Person, with parameters: AddPersonParameters, completionHandler: @escaping EditPersonEntityGatewayCompletionHandler ) { Task { do { - let savedImages = try await save(newImages(in: parameters)) - let result = await editInCoreData(person: person, parameters: parameters) - switch result { - case .success: - await deleteBestEffort(outdatedImages(for: person, parameters: parameters)) - case .failure: - await deleteBestEffort(savedImages) - } - await complete(result, using: completionHandler) + await complete(.success(try await edit(person: person, with: parameters)), using: completionHandler) + } catch let error as CoreError { + await complete(.failure(error), using: completionHandler) } catch { await complete(.failure(CoreError(error: error)), using: completionHandler) } } } + public func edit(person: Person, with parameters: AddPersonParameters) async throws -> Person { + try Task.checkCancellation() + let savedImages = try await save(newImages(in: parameters)) + do { + try Task.checkCancellation() + let updatedPerson = try await coreDataGateway.edit(person: person, with: parameters) + await deleteBestEffort(outdatedImages(for: person, parameters: parameters)) + return updatedPerson + } catch { + await deleteBestEffort(savedImages) + throw error + } + } + public func remove(person: Person, completionHandler: @escaping RemovePersonEntityGatewayCompletionHandler) { Task { - let result = await removeFromCoreData(person: person) - if case .success = result { - await deleteBestEffort(storedImages(for: person)) + do { + try await remove(person: person) + await complete(.success(()), using: completionHandler) + } catch let error as CoreError { + await complete(.failure(error), using: completionHandler) + } catch { + await complete(.failure(CoreError(error: error)), using: completionHandler) } - await complete(result, using: completionHandler) } } + public func remove(person: Person) async throws { + try Task.checkCancellation() + try await coreDataGateway.remove(person: person) + await deleteBestEffort(storedImages(for: person)) + } + // MARK: - Images private func newImages(in parameters: AddPersonParameters) -> [PersonImage] { @@ -100,6 +134,7 @@ public final class CachePersonsGateway: PersonsGateway { var savedImages = [PersonImage]() do { for image in images { + try Task.checkCancellation() try await imageStore.save(image) savedImages.append(image) } @@ -120,28 +155,6 @@ public final class CachePersonsGateway: PersonsGateway { } } - // MARK: - Core Data bridge - - private func addToCoreData(parameters: AddPersonParameters) async -> Result { - await withCheckedContinuation { continuation in - coreDataGateway.add(parameters: parameters) { continuation.resume(returning: $0) } - } - } - - private func editInCoreData( - person: Person, parameters: AddPersonParameters - ) async -> Result { - await withCheckedContinuation { continuation in - coreDataGateway.edit(person: person, with: parameters) { continuation.resume(returning: $0) } - } - } - - private func removeFromCoreData(person: Person) async -> Result { - await withCheckedContinuation { continuation in - coreDataGateway.remove(person: person) { continuation.resume(returning: $0) } - } - } - private func complete( _ result: Result, using completion: @escaping (Result) -> Void ) async { diff --git a/Core/Gateways/PersonsGateway.swift b/Core/Gateways/PersonsGateway.swift index 169e3eb..53ade85 100644 --- a/Core/Gateways/PersonsGateway.swift +++ b/Core/Gateways/PersonsGateway.swift @@ -15,10 +15,61 @@ public typealias RemovePersonEntityGatewayCompletionHandler = (_ result: Result< public protocol PersonsGateway { func add(parameters: AddPersonParameters, completionHandler: @escaping AddPersonEntityGatewayCompletionHandler) + func add(parameters: AddPersonParameters) async throws -> Person func fetchPersons(completionHandler: @escaping FetchPersonsEntityGatewayCompletionHandler) + func fetchPersons() async throws -> [Person] func edit( person: Person, with parameters: AddPersonParameters, completionHandler: @escaping EditPersonEntityGatewayCompletionHandler) + func edit(person: Person, with parameters: AddPersonParameters) async throws -> Person func remove(person: Person, completionHandler: @escaping RemovePersonEntityGatewayCompletionHandler) + func remove(person: Person) async throws func fetchWidgetPersons(completion: @escaping FetchPersonsEntityGatewayCompletionHandler) + func fetchWidgetPersons() async throws -> [Person] +} + +public extension PersonsGateway { + + func add(parameters: AddPersonParameters) async throws -> Person { + try Task.checkCancellation() + let person = try await withCheckedThrowingContinuation { continuation in + add(parameters: parameters) { continuation.resume(with: $0) } + } + try Task.checkCancellation() + return person + } + + func fetchPersons() async throws -> [Person] { + try Task.checkCancellation() + let persons = try await withCheckedThrowingContinuation { continuation in + fetchPersons { continuation.resume(with: $0) } + } + try Task.checkCancellation() + return persons + } + + func edit(person: Person, with parameters: AddPersonParameters) async throws -> Person { + try Task.checkCancellation() + let person = try await withCheckedThrowingContinuation { continuation in + edit(person: person, with: parameters) { continuation.resume(with: $0) } + } + try Task.checkCancellation() + return person + } + + func remove(person: Person) async throws { + try Task.checkCancellation() + try await withCheckedThrowingContinuation { continuation in + remove(person: person) { continuation.resume(with: $0) } + } + } + + func fetchWidgetPersons() async throws -> [Person] { + try Task.checkCancellation() + let persons = try await withCheckedThrowingContinuation { continuation in + fetchWidgetPersons { continuation.resume(with: $0) } + } + try Task.checkCancellation() + return persons + } } diff --git a/Core/UseCases/AddPersonUseCase.swift b/Core/UseCases/AddPersonUseCase.swift index 6e224e0..99a7f13 100644 --- a/Core/UseCases/AddPersonUseCase.swift +++ b/Core/UseCases/AddPersonUseCase.swift @@ -12,6 +12,7 @@ public typealias AddPersonUseCaseCompletionHandler = (_ person: Result Person } public final class AddPersonUseCaseImplementation: AddPersonUseCase { @@ -27,4 +28,8 @@ public final class AddPersonUseCaseImplementation: AddPersonUseCase { } } + public func add(parameters: AddPersonParameters) async throws -> Person { + try await personsGateway.add(parameters: parameters) + } + } diff --git a/Core/UseCases/EditPersonUseCase.swift b/Core/UseCases/EditPersonUseCase.swift index 77ba5da..8df0c99 100644 --- a/Core/UseCases/EditPersonUseCase.swift +++ b/Core/UseCases/EditPersonUseCase.swift @@ -14,6 +14,7 @@ public protocol EditPersonUseCase { func edit( person: Person, with parameters: AddPersonParameters, completionHandler: @escaping EditPersonUseCaseCompletionHandler) + func edit(person: Person, with parameters: AddPersonParameters) async throws -> Person } public final class EditPersonUseCaseImplementation: EditPersonUseCase { @@ -32,4 +33,8 @@ public final class EditPersonUseCaseImplementation: EditPersonUseCase { } } + public func edit(person: Person, with parameters: AddPersonParameters) async throws -> Person { + try await personsGateway.edit(person: person, with: parameters) + } + } diff --git a/Core/UseCases/FetchPersonsUseCase.swift b/Core/UseCases/FetchPersonsUseCase.swift index 8929dbd..034bdaf 100644 --- a/Core/UseCases/FetchPersonsUseCase.swift +++ b/Core/UseCases/FetchPersonsUseCase.swift @@ -12,7 +12,9 @@ public typealias FetchPersonsUseCaseCompletionHandler = (_ persons: Result<[Pers public protocol FetchPersonsUseCase { func fetchPersons(completionHandler: @escaping FetchPersonsUseCaseCompletionHandler) + func fetchPersons() async throws -> [Person] func fetchWidgetPersons(completion: @escaping FetchPersonsUseCaseCompletionHandler) + func fetchWidgetPersons() async throws -> [Person] } public final class FetchPersonsUseCaseImplementation: FetchPersonsUseCase { @@ -33,4 +35,12 @@ public final class FetchPersonsUseCaseImplementation: FetchPersonsUseCase { public func fetchWidgetPersons(completion: @escaping FetchPersonsUseCaseCompletionHandler) { personsGateway.fetchWidgetPersons(completion: completion) } + + public func fetchPersons() async throws -> [Person] { + try await personsGateway.fetchPersons() + } + + public func fetchWidgetPersons() async throws -> [Person] { + try await personsGateway.fetchWidgetPersons() + } } diff --git a/Core/UseCases/RemovePersonUseCase.swift b/Core/UseCases/RemovePersonUseCase.swift index 26a1416..d7ca3ea 100644 --- a/Core/UseCases/RemovePersonUseCase.swift +++ b/Core/UseCases/RemovePersonUseCase.swift @@ -12,6 +12,7 @@ public typealias RemovePersonUseCaseCompletionHandler = (_ result: Result AddPersonParameters { AddPersonParameters( name: "John", dayOfBirth: Date(), timeOfBirth: Date(), diff --git a/GrowingUpTests/Helpers/UserCases/AddPersonUseCaseSpy.swift b/GrowingUpTests/Helpers/UserCases/AddPersonUseCaseSpy.swift index f5bcae4..948e72b 100644 --- a/GrowingUpTests/Helpers/UserCases/AddPersonUseCaseSpy.swift +++ b/GrowingUpTests/Helpers/UserCases/AddPersonUseCaseSpy.swift @@ -26,6 +26,12 @@ class AddPersonUseCaseSpy: AddPersonUseCase { } } + func add(parameters: AddPersonParameters) async throws -> Person { + try await withCheckedThrowingContinuation { continuation in + add(parameters: parameters) { continuation.resume(with: $0) } + } + } + func callCompletionHandler() { self.completionHandler?(resultToBeReturned) } diff --git a/GrowingUpTests/Helpers/UserCases/DisplayPersonsUseCaseSpy.swift b/GrowingUpTests/Helpers/UserCases/DisplayPersonsUseCaseSpy.swift index 2fd06f9..b716e9a 100644 --- a/GrowingUpTests/Helpers/UserCases/DisplayPersonsUseCaseSpy.swift +++ b/GrowingUpTests/Helpers/UserCases/DisplayPersonsUseCaseSpy.swift @@ -24,4 +24,13 @@ class DisplayPersonsUseCaseSpy: FetchPersonsUseCase { func fetchWidgetPersons(completion: @escaping FetchPersonsUseCaseCompletionHandler) { completion(resultToBeReturned) } + + func fetchPersons() async throws -> [Person] { + displayPersonsCalled = true + return try resultToBeReturned.get() + } + + func fetchWidgetPersons() async throws -> [Person] { + try resultToBeReturned.get() + } } diff --git a/GrowingUpTests/Helpers/UserCases/EditPersonUseCaseSpy.swift b/GrowingUpTests/Helpers/UserCases/EditPersonUseCaseSpy.swift index 32e24c2..41b74e7 100644 --- a/GrowingUpTests/Helpers/UserCases/EditPersonUseCaseSpy.swift +++ b/GrowingUpTests/Helpers/UserCases/EditPersonUseCaseSpy.swift @@ -29,6 +29,12 @@ class EditPersonUseCaseSpy: EditPersonUseCase { } } + func edit(person: Person, with parameters: AddPersonParameters) async throws -> Person { + try await withCheckedThrowingContinuation { continuation in + edit(person: person, with: parameters) { continuation.resume(with: $0) } + } + } + func callCompletionHandler() { self.completionHandler?(resultToBeReturned) } diff --git a/GrowingUpTests/Helpers/UserCases/FetchPersonsUseCaseSpy.swift b/GrowingUpTests/Helpers/UserCases/FetchPersonsUseCaseSpy.swift index 6b77160..0d9d919 100644 --- a/GrowingUpTests/Helpers/UserCases/FetchPersonsUseCaseSpy.swift +++ b/GrowingUpTests/Helpers/UserCases/FetchPersonsUseCaseSpy.swift @@ -21,4 +21,12 @@ final class FetchPersonsUseCaseSpy: FetchPersonsUseCase { func fetchWidgetPersons(completion: @escaping FetchPersonsUseCaseCompletionHandler) { completion(completionResult) } + + func fetchPersons() async throws -> [Person] { + try completionResult.get() + } + + func fetchWidgetPersons() async throws -> [Person] { + try completionResult.get() + } } diff --git a/GrowingUpTests/Helpers/UserCases/RemovePersonUseCaseSpy.swift b/GrowingUpTests/Helpers/UserCases/RemovePersonUseCaseSpy.swift index cfaccd1..540343e 100644 --- a/GrowingUpTests/Helpers/UserCases/RemovePersonUseCaseSpy.swift +++ b/GrowingUpTests/Helpers/UserCases/RemovePersonUseCaseSpy.swift @@ -19,4 +19,9 @@ final class RemovePersonUseCaseSpy: RemovePersonUseCase { didCallRemovePerson = true completionHandler(resultToBeReturned) } + + func remove(person: Person) async throws { + didCallRemovePerson = true + try resultToBeReturned.get() + } } diff --git a/GrowingUpTests/UseCases/AddPersonUseCaseTests.swift b/GrowingUpTests/UseCases/AddPersonUseCaseTests.swift index b1e5fab..0e78284 100644 --- a/GrowingUpTests/UseCases/AddPersonUseCaseTests.swift +++ b/GrowingUpTests/UseCases/AddPersonUseCaseTests.swift @@ -57,4 +57,15 @@ class AddPersonUseCaseTests: XCTestCase { } waitForExpectations(timeout: 1, handler: nil) } + + func testAsyncAddReturnsPerson() async throws { + let parameters = AddPersonParameters.createParameters() + let expectedPerson = Person.createPerson() + personsGatewaySpy.addPersonResultToBeReturned = .success(expectedPerson) + + let person = try await sut.add(parameters: parameters) + + XCTAssertEqual(person, expectedPerson) + XCTAssertEqual(personsGatewaySpy.addPersonParameters, parameters) + } } diff --git a/GrowingUpTests/UseCases/FetchPersonsUseCaseTests.swift b/GrowingUpTests/UseCases/FetchPersonsUseCaseTests.swift index 14b0daa..9d5d31d 100644 --- a/GrowingUpTests/UseCases/FetchPersonsUseCaseTests.swift +++ b/GrowingUpTests/UseCases/FetchPersonsUseCaseTests.swift @@ -50,4 +50,15 @@ class FetchPersonsUseCaseTests: XCTestCase { } waitForExpectations(timeout: 0.1) } + + func testAsyncFetchPropagatesError() async { + gatewaySpy.fetchPersonsResultToBeReturned = .failure(.coreDataFetchFailed) + + do { + _ = try await sut.fetchPersons() + XCTFail("Expected fetch failure") + } catch { + XCTAssertEqual(error as? CoreError, .coreDataFetchFailed) + } + } } From f23350eed9d9e1737ecbf5ea06005c3ed4ce2af5 Mon Sep 17 00:00:00 2001 From: Danis Ziganshin Date: Sat, 1 Aug 2026 22:17:38 +0300 Subject: [PATCH 2/3] Avoid cancellation after mutation commits --- Core/Gateways/PersonsGateway.swift | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/Core/Gateways/PersonsGateway.swift b/Core/Gateways/PersonsGateway.swift index 53ade85..8aaca96 100644 --- a/Core/Gateways/PersonsGateway.swift +++ b/Core/Gateways/PersonsGateway.swift @@ -32,11 +32,9 @@ public extension PersonsGateway { func add(parameters: AddPersonParameters) async throws -> Person { try Task.checkCancellation() - let person = try await withCheckedThrowingContinuation { continuation in + return try await withCheckedThrowingContinuation { continuation in add(parameters: parameters) { continuation.resume(with: $0) } } - try Task.checkCancellation() - return person } func fetchPersons() async throws -> [Person] { @@ -50,11 +48,9 @@ public extension PersonsGateway { func edit(person: Person, with parameters: AddPersonParameters) async throws -> Person { try Task.checkCancellation() - let person = try await withCheckedThrowingContinuation { continuation in + return try await withCheckedThrowingContinuation { continuation in edit(person: person, with: parameters) { continuation.resume(with: $0) } } - try Task.checkCancellation() - return person } func remove(person: Person) async throws { From 20d0701d16cd62e2730aa9f8aab5430a1d217b4e Mon Sep 17 00:00:00 2001 From: Danis Ziganshin Date: Sat, 1 Aug 2026 22:26:47 +0300 Subject: [PATCH 3/3] Avoid blocking async gateway completion test --- GrowingUpTests/Gateways/CachePersonsGatewayTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/GrowingUpTests/Gateways/CachePersonsGatewayTests.swift b/GrowingUpTests/Gateways/CachePersonsGatewayTests.swift index af1db00..66cca94 100644 --- a/GrowingUpTests/Gateways/CachePersonsGatewayTests.swift +++ b/GrowingUpTests/Gateways/CachePersonsGatewayTests.swift @@ -94,7 +94,7 @@ final class CachePersonsGatewayTests: XCTestCase { waitForExpectations(timeout: 1) } - func testRemoveReportsCoreDataSuccessAfterBestEffortImageCleanup() { + func testRemoveReportsCoreDataSuccessAfterBestEffortImageCleanup() async { let person = Person.createPerson() coreDataGatewaySpy.removePersonResultToBeReturned = .success(()) imageStoreSpy.deleteError = CoreError.unknownError @@ -113,7 +113,7 @@ final class CachePersonsGatewayTests: XCTestCase { completed.fulfill() } - waitForExpectations(timeout: 1) + await fulfillment(of: [completed], timeout: 1) XCTAssertEqual(completionCount, 1) }