diff --git a/app-ios/Core/Tests/PresentationTests/ProfileProviderTests.swift b/app-ios/Core/Tests/PresentationTests/ProfileProviderTests.swift new file mode 100644 index 000000000..71321aa73 --- /dev/null +++ b/app-ios/Core/Tests/PresentationTests/ProfileProviderTests.swift @@ -0,0 +1,427 @@ +import ConcurrencyExtras +import Dependencies +import Foundation +import Model +@testable import Presentation +import Testing +import UseCase + +@Suite(.serialized) +struct ProfileProviderTests { + @MainActor + @Test("Should successfully subscribe and load profile") + func testSubscribeProfileSuccess() async throws { + // Arrange + let expectedProfile = ProfileTestData.createSampleProfile() + let clock = TestClock() + let provider = withDependencies { + $0.profileUseCase.load = { + AsyncStream { continuation in + Task { + try await clock.sleep(for: .milliseconds(100)) + continuation.yield(expectedProfile) + continuation.finish() + } + } + } + } operation: { + ProfileProvider() + } + + // Act + provider.subscribeProfileIfNeeded() + + // Assert + await clock.advance(by: .milliseconds(50)) + #expect(provider.profile == nil) + #expect(provider.isLoading) + + await clock.advance(by: .milliseconds(100)) + #expect(provider.profile != nil) + #expect(provider.profile?.name == expectedProfile.name) + #expect(provider.profile?.occupation == expectedProfile.occupation) + #expect(provider.profile?.url == expectedProfile.url) + #expect(provider.profile?.cardVariant == expectedProfile.cardVariant) + #expect(!provider.isLoading) + } + + @MainActor + @Test("Should handle loading state correctly") + func testLoadingStateManagement() async throws { + // Arrange + let expectedProfile = ProfileTestData.createSampleProfile() + let clock = TestClock() + let provider = withDependencies { + $0.profileUseCase.load = { + AsyncStream { continuation in + Task { + try await clock.sleep(for: .milliseconds(100)) + continuation.yield(expectedProfile) + continuation.finish() + } + } + } + } operation: { + ProfileProvider() + } + + #expect(provider.isLoading == false) + #expect(provider.profile == nil) + + // Act + provider.subscribeProfileIfNeeded() + + // Assert + await clock.advance(by: .milliseconds(50)) + #expect(provider.profile == nil) + #expect(provider.isLoading) + + await clock.advance(by: .milliseconds(100)) + #expect(!provider.isLoading) + #expect(provider.profile != nil) + + await clock.advance(by: .milliseconds(100)) + #expect(!provider.isLoading) + #expect(provider.profile != nil) + } + + @MainActor + @Test("Should handle nil profile correctly") + func testNilProfileHandling() async throws { + // Arrange + let clock = TestClock() + let provider = withDependencies { + $0.profileUseCase.load = { + AsyncStream { continuation in + Task { + try await clock.sleep(for: .milliseconds(100)) + continuation.yield(nil) + continuation.finish() + } + } + } + } operation: { + ProfileProvider() + } + + // Act + provider.subscribeProfileIfNeeded() + + // Assert + await clock.advance(by: .milliseconds(50)) + #expect(provider.profile == nil) + #expect(provider.isLoading) + + await clock.advance(by: .milliseconds(100)) + #expect(provider.profile == nil) + #expect(!provider.isLoading) + } + + @MainActor + @Test("Should prevent multiple subscriptions") + func testPreventMultipleSubscriptions() async throws { + // Arrange + let subscriptionCountContainer = LockIsolated(0) + let expectedProfile = ProfileTestData.createSampleProfile() + let clock = TestClock() + + let provider = withDependencies { + $0.profileUseCase.load = { + subscriptionCountContainer.withValue { $0 += 1 } + return AsyncStream { continuation in + Task { + try await clock.sleep(for: .milliseconds(100)) + continuation.yield(expectedProfile) + continuation.finish() + } + } + } + } operation: { + ProfileProvider() + } + + // Act + provider.subscribeProfileIfNeeded() + provider.subscribeProfileIfNeeded() // Try to subscribe again + provider.subscribeProfileIfNeeded() // And again + + // Assert + await clock.advance(by: .milliseconds(50)) + #expect(provider.profile == nil) + #expect(provider.isLoading) + + await clock.advance(by: .milliseconds(100)) + let subscriptionCount = subscriptionCountContainer.value + #expect(subscriptionCount == 1) // Should only subscribe once + #expect(provider.profile != nil) + #expect(!provider.isLoading) + } + + @MainActor + @Test("Should handle profile updates via AsyncSequence") + func testProfileUpdatesHandling() async throws { + // Arrange + let firstProfile = ProfileTestData.createSampleProfile(name: "First User") + let secondProfile = ProfileTestData.createSampleProfile(name: "Second User") + let thirdProfile = ProfileTestData.createSampleProfile(name: "Third User") + let clock = TestClock() + + let provider = withDependencies { + $0.profileUseCase.load = { + AsyncStream { continuation in + Task { + try await clock.sleep(for: .milliseconds(100)) + continuation.yield(firstProfile) + try await clock.sleep(for: .milliseconds(100)) + continuation.yield(secondProfile) + try await clock.sleep(for: .milliseconds(100)) + continuation.yield(thirdProfile) + continuation.finish() + } + } + } + } operation: { + ProfileProvider() + } + + // Act + provider.subscribeProfileIfNeeded() + + // Assert + await clock.advance(by: .milliseconds(50)) + #expect(provider.profile == nil) + #expect(provider.isLoading) + + await clock.advance(by: .milliseconds(100)) + #expect(provider.profile?.name == "First User") + #expect(!provider.isLoading) + + await clock.advance(by: .milliseconds(100)) + #expect(provider.profile?.name == "Second User") + #expect(!provider.isLoading) + + await clock.advance(by: .milliseconds(100)) + #expect(provider.profile?.name == "Third User") + #expect(!provider.isLoading) + } + + @MainActor + @Test("Should save profile successfully") + func testSaveProfile() async throws { + // Arrange + let profileToSave = ProfileTestData.createSampleProfile() + let savedProfileContainer = LockIsolated(nil) + let clock = TestClock() + + let provider = withDependencies { + $0.profileUseCase.save = { profile in + Task { + savedProfileContainer.setValue(profile) + } + } + } operation: { + ProfileProvider() + } + + // Act + provider.saveProfile(profileToSave) + + // Assert + #expect(savedProfileContainer.value == nil) + + await clock.advance(by: .milliseconds(100)) + let savedProfile = savedProfileContainer.value + #expect(savedProfile != nil) + #expect(savedProfile?.name == profileToSave.name) + #expect(savedProfile?.occupation == profileToSave.occupation) + #expect(savedProfile?.url == profileToSave.url) + #expect(savedProfile?.cardVariant == profileToSave.cardVariant) + } + + @MainActor + @Test("Should handle save and reload flow") + func testSaveAndReloadFlow() async throws { + // Arrange + let initialProfile = ProfileTestData.createSampleProfile(name: "Initial User") + let updatedProfile = ProfileTestData.createSampleProfile(name: "Updated User") + let savedProfileContainer = LockIsolated(nil) + let clock = TestClock() + + let provider = withDependencies { + $0.profileUseCase.load = { + AsyncStream { continuation in + Task { + try await clock.sleep(for: .milliseconds(100)) + continuation.yield(initialProfile) + try await clock.sleep(for: .milliseconds(100)) + // Simulate profile update after save + let saved = savedProfileContainer.value + if saved != nil { + continuation.yield(saved) + } + continuation.finish() + } + } + } + $0.profileUseCase.save = { profile in + savedProfileContainer.withValue { $0 = profile } + } + } operation: { + ProfileProvider() + } + + // Act + provider.subscribeProfileIfNeeded() + + // Assert + await clock.advance(by: .milliseconds(50)) + #expect(provider.profile == nil) + #expect(provider.isLoading) + + await clock.advance(by: .milliseconds(100)) + #expect(provider.profile?.name == "Initial User") + #expect(!provider.isLoading) + + // Save updated profile + provider.saveProfile(updatedProfile) + + // Wait for reload + await clock.advance(by: .milliseconds(100)) + + let savedProfile = savedProfileContainer.value + #expect(savedProfile?.name == "Updated User") + #expect(provider.profile?.name == "Updated User") + #expect(!provider.isLoading) + } + + @MainActor + @Test("Should update Observable properties correctly") + func testObservablePropertiesUpdate() async throws { + // Arrange + let firstProfile = ProfileTestData.createSampleProfile( + name: "First", + occupation: "Developer" + ) + let secondProfile = ProfileTestData.createSampleProfile( + name: "Second", + occupation: "Designer" + ) + let clock = TestClock() + + let provider = withDependencies { + $0.profileUseCase.load = { + AsyncStream { continuation in + Task { + // nil state + try await clock.sleep(for: .milliseconds(100)) + continuation.yield(nil) + + // first profile + try await clock.sleep(for: .milliseconds(100)) + continuation.yield(firstProfile) + + // second profile + try await clock.sleep(for: .milliseconds(100)) + continuation.yield(secondProfile) + + // back to nil + try await clock.sleep(for: .milliseconds(100)) + continuation.yield(nil) + + continuation.finish() + } + } + } + } operation: { + ProfileProvider() + } + + // Act + provider.subscribeProfileIfNeeded() + + // Assert + await clock.advance(by: .milliseconds(50)) + #expect(provider.profile == nil) + #expect(provider.isLoading) + + await clock.advance(by: .milliseconds(100)) + #expect(provider.profile == nil) + #expect(!provider.isLoading) + + await clock.advance(by: .milliseconds(100)) + #expect(provider.profile?.name == "First") + #expect(provider.profile?.occupation == "Developer") + #expect(!provider.isLoading) + + await clock.advance(by: .milliseconds(100)) + #expect(provider.profile?.name == "Second") + #expect(provider.profile?.occupation == "Designer") + #expect(!provider.isLoading) + + await clock.advance(by: .milliseconds(100)) + #expect(provider.profile == nil) + #expect(!provider.isLoading) + } + + @MainActor + @Test("Should handle all card variants correctly") + func testAllCardVariantsHandling() async throws { + for variant in ProfileCardVariant.allCases { + // Arrange + let profile = ProfileTestData.createProfileWithVariant(variant) + let clock = TestClock() + + let provider = withDependencies { + $0.profileUseCase.load = { + AsyncStream { continuation in + Task { + try await clock.sleep(for: .milliseconds(50)) + continuation.yield(profile) + continuation.finish() + } + } + } + } operation: { + ProfileProvider() + } + + // Act + provider.subscribeProfileIfNeeded() + await clock.advance(by: .milliseconds(100)) + + // Assert + #expect(provider.profile?.cardVariant == variant) + } + } +} + +// MARK: - Test Data Helpers + +private enum ProfileTestData { + static func createSampleProfile( + name: String = "Test User", + occupation: String = "iOS Developer" + ) -> Profile { + Profile( + name: name, + occupation: occupation, + url: URL(string: "https://example.com/user")!, + image: createProfileImageData(), + cardVariant: .nightPill + ) + } + + static func createProfileWithVariant(_ variant: ProfileCardVariant) -> Profile { + Profile( + name: "Test User", + occupation: "iOS Developer", + url: URL(string: "https://example.com/user")!, + image: createProfileImageData(), + cardVariant: variant + ) + } + + static func createProfileImageData() -> Data { + Data([0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46]) + } +}