diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index 57265de33..026c0f197 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -11,11 +11,13 @@ 0E7A224A082FA2DA35706CC7 /* MotionServiceResolvePositionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8192B8B7043EF74D05B36B /* MotionServiceResolvePositionTests.swift */; }; 0E7A224A082FA2DA35706CC8 /* MotionServiceShakeToEditTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8192B8B7043EF74D05B36C /* MotionServiceShakeToEditTests.swift */; }; 1E3BD63AB3F1521C253CB818 /* MBNetworkFetcherResponseHandlingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97FEDDEB5F71A67F1C4C675F /* MBNetworkFetcherResponseHandlingTests.swift */; }; + F3BA5E000130A000C0000005 /* OperationsURLRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BA5E000130A000C0000006 /* OperationsURLRoutingTests.swift */; }; 302E35788CBDA959283569F4 /* MotionServiceBehaviorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0DB93A7997961CA7C2BE917 /* MotionServiceBehaviorTests.swift */; }; 313B233A25ADEA0F00A1CB72 /* Mindbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 313B233025ADEA0F00A1CB72 /* Mindbox.framework */; }; 313B233F25ADEA0F00A1CB72 /* MindboxTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 313B233E25ADEA0F00A1CB72 /* MindboxTests.swift */; }; 313B234125ADEA0F00A1CB72 /* Mindbox.h in Headers */ = {isa = PBXBuildFile; fileRef = 313B233325ADEA0F00A1CB72 /* Mindbox.h */; settings = {ATTRIBUTES = (Public, ); }; }; 314B38FD25AEE8B200E947B9 /* MBConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B38FC25AEE8B200E947B9 /* MBConfiguration.swift */; }; + F3BA5E000130A000C0000003 /* BaseAddressesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BA5E000130A000C0000004 /* BaseAddressesModel.swift */; }; 314B390025AEE96F00E947B9 /* CoreController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B38FF25AEE96F00E947B9 /* CoreController.swift */; }; 317054CB25AF189800AE624C /* PersistenceStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317054CA25AF189800AE624C /* PersistenceStorage.swift */; }; 317AF8FC25B844DB006348FA /* UtilitiesFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317AF8FB25B844DB006348FA /* UtilitiesFetcher.swift */; }; @@ -26,7 +28,8 @@ 31A20D4E25B6EFB600AAA0A3 /* MindboxDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31A20D4D25B6EFB600AAA0A3 /* MindboxDelegate.swift */; }; 31EB907325C402F900368FFB /* TestConfig3.plist in Resources */ = {isa = PBXBuildFile; fileRef = 31EB907125C402F900368FFB /* TestConfig3.plist */; }; 31EB907425C402F900368FFB /* TestConfig2.plist in Resources */ = {isa = PBXBuildFile; fileRef = 31EB907225C402F900368FFB /* TestConfig2.plist */; }; - 31ED2DEC25C444C400301FAD /* MBConfigurationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31ED2DEB25C444C400301FAD /* MBConfigurationTestCase.swift */; }; + F3CD20262F600A800065392A /* MBConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CD20272F600A800065392A /* MBConfigurationTests.swift */; }; + F3CD202B2F600A800065392A /* HostNormalizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CD202C2F600A800065392A /* HostNormalizerTests.swift */; }; 31ED2DF225C4456600301FAD /* TestConfig_Invalid_2.plist in Resources */ = {isa = PBXBuildFile; fileRef = 31ED2DEF25C4456600301FAD /* TestConfig_Invalid_2.plist */; }; 31ED2DF325C4456600301FAD /* TestConfig_Invalid_1.plist in Resources */ = {isa = PBXBuildFile; fileRef = 31ED2DF025C4456600301FAD /* TestConfig_Invalid_1.plist */; }; 31ED2DF425C4456600301FAD /* TestConfig_Invalid_3.plist in Resources */ = {isa = PBXBuildFile; fileRef = 31ED2DF125C4456600301FAD /* TestConfig_Invalid_3.plist */; }; @@ -55,6 +58,7 @@ 3333C1B22681D42000B60D84 /* Payload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3333C1B12681D42000B60D84 /* Payload.swift */; }; 3333C1B42681D43C00B60D84 /* ImageFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3333C1B32681D43C00B60D84 /* ImageFormat.swift */; }; 3333C1DE2681E9F300B60D84 /* URLRequestBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3333C1DD2681E9F300B60D84 /* URLRequestBuilder.swift */; }; + F3CD20292F600A800065392A /* HostNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CD202A2F600A800065392A /* HostNormalizer.swift */; }; 3333C1E12681EA4D00B60D84 /* NotificationsPayloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3333C1E02681EA4C00B60D84 /* NotificationsPayloads.swift */; }; 3333D7BE265E56F2004279B0 /* OperationResponseType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3333D7BD265E56F2004279B0 /* OperationResponseType.swift */; }; 3337E6A3265FAB39006949EB /* BaseResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3337E6A2265FAB39006949EB /* BaseResponse.swift */; }; @@ -309,6 +313,7 @@ 84B625E425C988FA00AB6228 /* URLValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B625E325C988FA00AB6228 /* URLValidator.swift */; }; 84B625E925C989C100AB6228 /* UDIDValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B625E825C989C100AB6228 /* UDIDValidator.swift */; }; 84B625F025C98B1200AB6228 /* ValidatorsTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B625EF25C98B1200AB6228 /* ValidatorsTestCase.swift */; }; + F3CD202D2F600A800065392A /* URLValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CD202E2F600A800065392A /* URLValidatorTests.swift */; }; 84BAEF8225D54919002E8A26 /* BodyDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84BAEF8125D54919002E8A26 /* BodyDecoder.swift */; }; 84C65E5E25D4FBA3008996FA /* MobileApplicationInstalled.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C65E5D25D4FBA3008996FA /* MobileApplicationInstalled.swift */; }; 84C65E6425D4FBBB008996FA /* MobileApplicationInfoUpdated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C65E6325D4FBBB008996FA /* MobileApplicationInfoUpdated.swift */; }; @@ -477,6 +482,7 @@ F31470962B96681F00E01E5C /* 27-TargetingRequests.json in Resources */ = {isa = PBXBuildFile; fileRef = F31470952B96681F00E01E5C /* 27-TargetingRequests.json */; }; F31470982B9668F100E01E5C /* 31-TargetingRequests.json in Resources */ = {isa = PBXBuildFile; fileRef = F31470972B9668F100E01E5C /* 31-TargetingRequests.json */; }; F315503F2BBB24E20072A071 /* TTLValidationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F315503E2BBB24E20072A071 /* TTLValidationService.swift */; }; + F3BA5E000130A000C0000007 /* OperationsDomainConfigPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3BA5E000130A000C0000008 /* OperationsDomainConfigPolicy.swift */; }; F31909992E979D9E00373E2F /* MindboxAppDelegateProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31909982E979D9E00373E2F /* MindboxAppDelegateProxy.swift */; }; F31A94782BC6995500E6C978 /* InappFrequency.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31A94772BC6995500E6C978 /* InappFrequency.swift */; }; F31A947C2BC69E3900E6C978 /* PeriodicFrequency.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31A947B2BC69E3900E6C978 /* PeriodicFrequency.swift */; }; @@ -558,6 +564,10 @@ F34A10462F455C5B0065392A /* SettingsFeatureTogglesError.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A103E2F455C5B0065392A /* SettingsFeatureTogglesError.json */; }; F34A10472F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A10412F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json */; }; F34A10482F455C5B0065392A /* SettingsFeatureTogglesTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A10422F455C5B0065392A /* SettingsFeatureTogglesTypeError.json */; }; + F3BA10552F500A800065392A /* SettingsBaseAddressesError.json in Resources */ = {isa = PBXBuildFile; fileRef = F3BA10512F500A800065392A /* SettingsBaseAddressesError.json */; }; + F3BA10562F500A800065392A /* SettingsBaseAddressesTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = F3BA10522F500A800065392A /* SettingsBaseAddressesTypeError.json */; }; + F3BA10572F500A800065392A /* SettingsBaseAddressesOperationsError.json in Resources */ = {isa = PBXBuildFile; fileRef = F3BA10532F500A800065392A /* SettingsBaseAddressesOperationsError.json */; }; + F3BA10582F500A800065392A /* SettingsBaseAddressesOperationsTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = F3BA10542F500A800065392A /* SettingsBaseAddressesOperationsTypeError.json */; }; F34A45AE2B7628B700634C8B /* MBPushNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = F34A45AD2B7628B700634C8B /* MBPushNotification.swift */; }; F34A45B02B762A6100634C8B /* MindboxPushValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F34A45AF2B762A6100634C8B /* MindboxPushValidator.swift */; }; F351F1C02CE380A40053423E /* InappMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = F351F1BF2CE380A40053423E /* InappMapper.swift */; }; @@ -739,6 +749,7 @@ 313B233E25ADEA0F00A1CB72 /* MindboxTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindboxTests.swift; sourceTree = ""; }; 313B234025ADEA0F00A1CB72 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 314B38FC25AEE8B200E947B9 /* MBConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MBConfiguration.swift; sourceTree = ""; }; + F3BA5E000130A000C0000004 /* BaseAddressesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseAddressesModel.swift; sourceTree = ""; }; 314B38FF25AEE96F00E947B9 /* CoreController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreController.swift; sourceTree = ""; }; 317054CA25AF189800AE624C /* PersistenceStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceStorage.swift; sourceTree = ""; }; 317AF8FB25B844DB006348FA /* UtilitiesFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UtilitiesFetcher.swift; sourceTree = ""; }; @@ -749,7 +760,8 @@ 31A20D4D25B6EFB600AAA0A3 /* MindboxDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindboxDelegate.swift; sourceTree = ""; }; 31EB907125C402F900368FFB /* TestConfig3.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig3.plist; sourceTree = ""; }; 31EB907225C402F900368FFB /* TestConfig2.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig2.plist; sourceTree = ""; }; - 31ED2DEB25C444C400301FAD /* MBConfigurationTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MBConfigurationTestCase.swift; sourceTree = ""; }; + F3CD20272F600A800065392A /* MBConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MBConfigurationTests.swift; sourceTree = ""; }; + F3CD202C2F600A800065392A /* HostNormalizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostNormalizerTests.swift; sourceTree = ""; }; 31ED2DEF25C4456600301FAD /* TestConfig_Invalid_2.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig_Invalid_2.plist; sourceTree = ""; }; 31ED2DF025C4456600301FAD /* TestConfig_Invalid_1.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig_Invalid_1.plist; sourceTree = ""; }; 31ED2DF125C4456600301FAD /* TestConfig_Invalid_3.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig_Invalid_3.plist; sourceTree = ""; }; @@ -781,6 +793,7 @@ 3333C1B12681D42000B60D84 /* Payload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Payload.swift; sourceTree = ""; }; 3333C1B32681D43C00B60D84 /* ImageFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageFormat.swift; sourceTree = ""; }; 3333C1DD2681E9F300B60D84 /* URLRequestBuilder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLRequestBuilder.swift; sourceTree = ""; }; + F3CD202A2F600A800065392A /* HostNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostNormalizer.swift; sourceTree = ""; }; 3333C1E02681EA4C00B60D84 /* NotificationsPayloads.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationsPayloads.swift; sourceTree = ""; }; 3333D7BD265E56F2004279B0 /* OperationResponseType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationResponseType.swift; sourceTree = ""; }; 3337E6A2265FAB39006949EB /* BaseResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseResponse.swift; sourceTree = ""; }; @@ -1027,6 +1040,7 @@ 84B625E325C988FA00AB6228 /* URLValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLValidator.swift; sourceTree = ""; }; 84B625E825C989C100AB6228 /* UDIDValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UDIDValidator.swift; sourceTree = ""; }; 84B625EF25C98B1200AB6228 /* ValidatorsTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidatorsTestCase.swift; sourceTree = ""; }; + F3CD202E2F600A800065392A /* URLValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLValidatorTests.swift; sourceTree = ""; }; 84BAEF8125D54919002E8A26 /* BodyDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BodyDecoder.swift; sourceTree = ""; }; 84C65E5D25D4FBA3008996FA /* MobileApplicationInstalled.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileApplicationInstalled.swift; sourceTree = ""; }; 84C65E6325D4FBBB008996FA /* MobileApplicationInfoUpdated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileApplicationInfoUpdated.swift; sourceTree = ""; }; @@ -1056,6 +1070,7 @@ 84FCD3BC25CA10F600D1E574 /* SuccessResponse.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = SuccessResponse.json; sourceTree = ""; }; 9778038796A8426ABDED1E97 /* FeatureTogglesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureTogglesModel.swift; sourceTree = ""; }; 97FEDDEB5F71A67F1C4C675F /* MBNetworkFetcherResponseHandlingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MBNetworkFetcherResponseHandlingTests.swift; sourceTree = ""; }; + F3BA5E000130A000C0000006 /* OperationsURLRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationsURLRoutingTests.swift; sourceTree = ""; }; 9B24FAAB28C74B8300F10B5D /* InAppConfigurationRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppConfigurationRepository.swift; sourceTree = ""; }; 9B24FAAD28C74BA500F10B5D /* InAppCoreManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppCoreManager.swift; sourceTree = ""; }; 9B24FAB028C74BD200F10B5D /* InAppConfigurationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppConfigurationManager.swift; sourceTree = ""; }; @@ -1203,6 +1218,7 @@ F31470952B96681F00E01E5C /* 27-TargetingRequests.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "27-TargetingRequests.json"; sourceTree = ""; }; F31470972B9668F100E01E5C /* 31-TargetingRequests.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "31-TargetingRequests.json"; sourceTree = ""; }; F315503E2BBB24E20072A071 /* TTLValidationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTLValidationService.swift; sourceTree = ""; }; + F3BA5E000130A000C0000008 /* OperationsDomainConfigPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationsDomainConfigPolicy.swift; sourceTree = ""; }; F31909982E979D9E00373E2F /* MindboxAppDelegateProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindboxAppDelegateProxy.swift; sourceTree = ""; }; F31A94772BC6995500E6C978 /* InappFrequency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InappFrequency.swift; sourceTree = ""; }; F31A947B2BC69E3900E6C978 /* PeriodicFrequency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeriodicFrequency.swift; sourceTree = ""; }; @@ -1284,6 +1300,10 @@ F34A10402F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorMissing.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesShouldSendInAppShowErrorMissing.json; sourceTree = ""; }; F34A10412F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json; sourceTree = ""; }; F34A10422F455C5B0065392A /* SettingsFeatureTogglesTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesTypeError.json; sourceTree = ""; }; + F3BA10512F500A800065392A /* SettingsBaseAddressesError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsBaseAddressesError.json; sourceTree = ""; }; + F3BA10522F500A800065392A /* SettingsBaseAddressesTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsBaseAddressesTypeError.json; sourceTree = ""; }; + F3BA10532F500A800065392A /* SettingsBaseAddressesOperationsError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsBaseAddressesOperationsError.json; sourceTree = ""; }; + F3BA10542F500A800065392A /* SettingsBaseAddressesOperationsTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsBaseAddressesOperationsTypeError.json; sourceTree = ""; }; F34A45AD2B7628B700634C8B /* MBPushNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MBPushNotification.swift; sourceTree = ""; }; F34A45AF2B762A6100634C8B /* MindboxPushValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindboxPushValidator.swift; sourceTree = ""; }; F351F1BF2CE380A40053423E /* InappMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InappMapper.swift; sourceTree = ""; }; @@ -1610,7 +1630,7 @@ 84FCD3B325CA0FD300D1E574 /* Mock */, 84B625F525C98EE000AB6228 /* DI */, 84B625EE25C98A8000AB6228 /* Validators */, - 31ED2DEB25C444C400301FAD /* MBConfigurationTestCase.swift */, + F3CD20282F600A800065392A /* Configuration */, 313B233E25ADEA0F00A1CB72 /* MindboxTests.swift */, 84DC49D525D185A600D5D758 /* Supporting Files */, 313B234025ADEA0F00A1CB72 /* Info.plist */, @@ -2052,6 +2072,7 @@ 4731A7E72F447C3100CBE1E5 /* SettingsJsonStubs */ = { isa = PBXGroup; children = ( + F3BA10502F500A800065392A /* BaseAddressesError */, F34A10432F455C5B0065392A /* FeatureTogglesError */, 4731A7CB2F447C3100CBE1E5 /* InappError */, 4731A7D92F447C3100CBE1E5 /* OperationsErrors */, @@ -2256,6 +2277,8 @@ isa = PBXGroup; children = ( 97FEDDEB5F71A67F1C4C675F /* MBNetworkFetcherResponseHandlingTests.swift */, + F3BA5E000130A000C0000006 /* OperationsURLRoutingTests.swift */, + F3CD202C2F600A800065392A /* HostNormalizerTests.swift */, ); name = Network; path = Network; @@ -2374,6 +2397,7 @@ isa = PBXGroup; children = ( 3333C1DD2681E9F300B60D84 /* URLRequestBuilder.swift */, + F3CD202A2F600A800065392A /* HostNormalizer.swift */, 84EAEDFB25C8B18B00726063 /* DeviceModelHelper.swift */, ); path = Helpers; @@ -2429,6 +2453,7 @@ F35E0C4D2DF0535E00E8A768 /* InAppTrackingServiceTests.swift */, F3A961D52DE9C5220016D5D3 /* InAppPresentationValidatorTests.swift */, 84B625EF25C98B1200AB6228 /* ValidatorsTestCase.swift */, + F3CD202E2F600A800065392A /* URLValidatorTests.swift */, F3A8B9972A3A421C00E9C055 /* SDKVersionValidatorTests.swift */, F30629192BD27D7500EF6609 /* InappFrequencyTests.swift */, ); @@ -3111,6 +3136,7 @@ isa = PBXGroup; children = ( F315503E2BBB24E20072A071 /* TTLValidationService.swift */, + F3BA5E000130A000C0000008 /* OperationsDomainConfigPolicy.swift */, ); path = Services; sourceTree = ""; @@ -3509,6 +3535,25 @@ path = FeatureTogglesError; sourceTree = ""; }; + F3CD20282F600A800065392A /* Configuration */ = { + isa = PBXGroup; + children = ( + F3CD20272F600A800065392A /* MBConfigurationTests.swift */, + ); + path = Configuration; + sourceTree = ""; + }; + F3BA10502F500A800065392A /* BaseAddressesError */ = { + isa = PBXGroup; + children = ( + F3BA10512F500A800065392A /* SettingsBaseAddressesError.json */, + F3BA10522F500A800065392A /* SettingsBaseAddressesTypeError.json */, + F3BA10532F500A800065392A /* SettingsBaseAddressesOperationsError.json */, + F3BA10542F500A800065392A /* SettingsBaseAddressesOperationsTypeError.json */, + ); + path = BaseAddressesError; + sourceTree = ""; + }; F34A45AC2B76286D00634C8B /* PublicModels */ = { isa = PBXGroup; children = ( @@ -3715,6 +3760,7 @@ F3A8B9AA2A3A719C00E9C055 /* ABTestModel.swift */, F3A4EFDB2D5224C700DB96A8 /* SlidingExpirationModel.swift */, 9778038796A8426ABDED1E97 /* FeatureTogglesModel.swift */, + F3BA5E000130A000C0000004 /* BaseAddressesModel.swift */, ); path = Config; sourceTree = ""; @@ -4070,6 +4116,10 @@ F34A10462F455C5B0065392A /* SettingsFeatureTogglesError.json in Resources */, F34A10472F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json in Resources */, F34A10482F455C5B0065392A /* SettingsFeatureTogglesTypeError.json in Resources */, + F3BA10552F500A800065392A /* SettingsBaseAddressesError.json in Resources */, + F3BA10562F500A800065392A /* SettingsBaseAddressesTypeError.json in Resources */, + F3BA10572F500A800065392A /* SettingsBaseAddressesOperationsError.json in Resources */, + F3BA10582F500A800065392A /* SettingsBaseAddressesOperationsTypeError.json in Resources */, 4731A7FD2F447C3100CBE1E5 /* InAppDelayTimeTypeError.json in Resources */, 4731A7FE2F447C3100CBE1E5 /* MonitoringLogsTypeError.json in Resources */, 4731A7FF2F447C3100CBE1E5 /* SettingsInAppSettingsMissingMaxInappsPerDay.json in Resources */, @@ -4384,7 +4434,9 @@ 9B24FAB528C751E400F10B5D /* InAppImagesStorage.swift in Sources */, F3A8B9A32A3A6E6900E9C055 /* SdkVersionModel.swift in Sources */, 3333C1DE2681E9F300B60D84 /* URLRequestBuilder.swift in Sources */, + F3CD20292F600A800065392A /* HostNormalizer.swift in Sources */, F315503F2BBB24E20072A071 /* TTLValidationService.swift in Sources */, + F3BA5E000130A000C0000007 /* OperationsDomainConfigPolicy.swift in Sources */, 334F3AF5264C199900A6AC00 /* CodableDictionary.swift in Sources */, F3F5BB8A2B79F2600022AC3F /* PushNotificationFormatter.swift in Sources */, 334F3AF3264C199900A6AC00 /* AreaRequest.swift in Sources */, @@ -4554,6 +4606,7 @@ 6FDD1445266F7C2200A50C35 /* CouponResponse.swift in Sources */, F78E92EF282E63320003B4A3 /* DispatchSemaphore.swift in Sources */, 314B38FD25AEE8B200E947B9 /* MBConfiguration.swift in Sources */, + F3BA5E000130A000C0000003 /* BaseAddressesModel.swift in Sources */, F331DD0C2A83A56500222120 /* ViewFactoryProtocol.swift in Sources */, 6FDD1447266F7C2B00A50C35 /* LimitResponse.swift in Sources */, 847F580725C88C7A00147A9A /* NetworkFetcher.swift in Sources */, @@ -4626,6 +4679,7 @@ 9B9C9538292111A700BB29DA /* MockUUIDDebugService.swift in Sources */, 47A4FA782E73741700569870 /* LoggerDatabaseLoaderTests.swift in Sources */, 84B625F025C98B1200AB6228 /* ValidatorsTestCase.swift in Sources */, + F3CD202D2F600A800065392A /* URLValidatorTests.swift in Sources */, 4741425E2E8A688300839AD8 /* DataBaseLoading_StubDatabaseLoaderContractTests.swift in Sources */, 847F580325C88BBF00147A9A /* HTTPMethod.swift in Sources */, F351F1C22CE5F23A0053423E /* InappMapperTests.swift in Sources */, @@ -4674,7 +4728,7 @@ 313B233F25ADEA0F00A1CB72 /* MindboxTests.swift in Sources */, F39116EE2AA53EE400852298 /* VariantImageUrlExtractorServiceTests.swift in Sources */, A154E334299E110E00F8F074 /* EventRepositoryMock.swift in Sources */, - 31ED2DEC25C444C400301FAD /* MBConfigurationTestCase.swift in Sources */, + F3CD20262F600A800065392A /* MBConfigurationTests.swift in Sources */, F3D925AD2A1236F400135C87 /* URLSessionImageDownloaderTests.swift in Sources */, 4741DAC42E85C49F00EB2497 /* DatabaseLoaderFlowTests.swift in Sources */, F3A8B9A02A3A52F400E9C055 /* ABTestValidatorTests.swift in Sources */, @@ -4722,6 +4776,8 @@ 0E7A224A082FA2DA35706CC8 /* MotionServiceShakeToEditTests.swift in Sources */, 302E35788CBDA959283569F4 /* MotionServiceBehaviorTests.swift in Sources */, 1E3BD63AB3F1521C253CB818 /* MBNetworkFetcherResponseHandlingTests.swift in Sources */, + F3BA5E000130A000C0000005 /* OperationsURLRoutingTests.swift in Sources */, + F3CD202B2F600A800065392A /* HostNormalizerTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift b/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift index 606c4cc9b..09f17ebe6 100644 --- a/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift +++ b/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift @@ -86,11 +86,7 @@ class InAppConfigurationManager: InAppConfigurationManagerProtocol { do { let config = try jsonDecoder.decode(ConfigResponse.self, from: data) configResponse = config - saveConfigToCache(data) - setupSettingsFromConfig(config.settings) - if let monitoring = config.monitoring, let logsManager = DI.inject(SDKLogsManagerProtocol.self) { - logsManager.sendLogs(logs: monitoring.logs.elements) - } + applyDownloadedConfig(config, rawData: data) } catch { applyConfigFromCache() Logger.common(message: "Failed to parse downloaded config file. Error: \(error)", level: .error, category: .inAppMessages) @@ -104,11 +100,25 @@ class InAppConfigurationManager: InAppConfigurationManagerProtocol { applyConfigFromCache() Logger.common(message: "Failed to download InApp configuration. Error: \(error.localizedDescription)", level: .error, category: .inAppMessages) } - + self.delegate?.didPreparedConfiguration() sendNotification(with: configResponse?.settings?.slidingExpiration?.pushTokenKeepalive) } + private func applyDownloadedConfig(_ config: ConfigResponse, rawData: Data) { + saveConfigToCache(rawData) + setupSettingsFromConfig(config.settings) + sendMonitoringLogsIfNeeded(config.monitoring) + } + + private func sendMonitoringLogsIfNeeded(_ monitoring: Monitoring?) { + guard let monitoring = monitoring, + let logsManager = DI.inject(SDKLogsManagerProtocol.self) else { + return + } + logsManager.sendLogs(logs: monitoring.logs.elements) + } + private func applyConfigFromCache() { guard var cachedConfig = self.fetchConfigFromCache() else { Logger.common(message: "Failed to apply configuration from cache: No cached configuration found.") @@ -149,21 +159,44 @@ class InAppConfigurationManager: InAppConfigurationManagerProtocol { return } + applySessionStorageSettings(settings) + featureToggleManager.applyFeatureToggles(settings.featureToggles) + persistOperationsDomain(from: settings.baseAddresses) + saveConfigSessionToCache(settings.slidingExpiration?.config) + } + + private func applySessionStorageSettings(_ settings: Settings) { + let storage = SessionTemporaryStorage.shared + if let viewCategory = settings.operations?.viewCategory { - SessionTemporaryStorage.shared.viewCategoryOperation = viewCategory.systemName.lowercased() + storage.viewCategoryOperation = viewCategory.systemName.lowercased() } if let viewProduct = settings.operations?.viewProduct { - SessionTemporaryStorage.shared.viewProductOperation = viewProduct.systemName.lowercased() + storage.viewProductOperation = viewProduct.systemName.lowercased() } - + if let inappSettings = settings.inapp { - SessionTemporaryStorage.shared.inAppSettings = inappSettings + storage.inAppSettings = inappSettings } + } - featureToggleManager.applyFeatureToggles(settings.featureToggles) - - saveConfigSessionToCache(settings.slidingExpiration?.config) + private func persistOperationsDomain(from baseAddresses: Settings.BaseAddresses?) { + let current = persistenceStorage.operationsDomainFromConfig + let raw = baseAddresses?.operations + + switch OperationsDomainConfigPolicy.action(for: raw, currentlyStored: current) { + case .keep: + break + case .clear: + persistenceStorage.operationsDomainFromConfig = nil + Logger.common(message: "[OperationsDomain] Cleared — config has no value.", level: .info, category: .inAppMessages) + case .save(let value): + persistenceStorage.operationsDomainFromConfig = value + Logger.common(message: "[OperationsDomain] Updated from config. [Value]: \(value)", level: .info, category: .inAppMessages) + case .rejected(let value): + Logger.common(message: "[OperationsDomain] Invalid domain from config — ignored, previous value kept. [Value]: \(value)", level: .error, category: .inAppMessages) + } } private func createTTLValidationService() -> TTLValidationProtocol { diff --git a/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift new file mode 100644 index 000000000..f1b456c5f --- /dev/null +++ b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift @@ -0,0 +1,43 @@ +// +// OperationsDomainConfigPolicy.swift +// Mindbox +// +// Created by Sergei Semko on 4/27/26. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation + +/// Decides save / clear / keep for the operations host coming from JSON config. +/// Extracted from `InAppConfigurationManager` so it can be unit-tested in isolation. +enum OperationsDomainConfigPolicy { + + enum Action: Equatable { + case save(String) + /// Config explicitly cleared the value (null / missing / empty). + /// Caller falls back through the priority chain (init → domain). + case clear + /// No-op: nothing stored and nothing came, or canonicalized incoming + /// value already equals the stored one. + case keep + /// Incoming value is format-broken — previous value kept intact + /// (one bad push must not destroy a working config). Carries the raw + /// input so the caller can log it. + case rejected(String) + } + + static func action(for raw: String?, currentlyStored: String?) -> Action { + guard let value = raw, !value.isEmpty else { + return currentlyStored == nil ? .keep : .clear + } + + guard URLValidator.isValidHost(HostNormalizer.extractHost(value)) else { + return .rejected(value) + } + + // Store canonical `scheme://host` so backend's choice of `http`/`https` + // is preserved across restarts and trailing slashes don't cause re-saves. + let normalized = HostNormalizer.toBaseURLString(value) + return normalized == currentlyStored ? .keep : .save(normalized) + } +} diff --git a/Mindbox/InAppMessages/Models/Config/BaseAddressesModel.swift b/Mindbox/InAppMessages/Models/Config/BaseAddressesModel.swift new file mode 100644 index 000000000..410d2e02c --- /dev/null +++ b/Mindbox/InAppMessages/Models/Config/BaseAddressesModel.swift @@ -0,0 +1,28 @@ +// +// BaseAddressesModel.swift +// Mindbox +// +// Created by Sergei Semko on 4/27/26. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation + +extension Settings { + /// DTO for `settings.baseAddresses` in the mobile JSON config + /// (`/mobile/byendpoint/{endpointId}.json`). + struct BaseAddresses: Decodable, Equatable { + let operations: String? + + enum CodingKeys: CodingKey { + case operations + } + } +} + +extension Settings.BaseAddresses { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.operations = try? container.decodeIfPresent(String.self, forKey: .operations) + } +} diff --git a/Mindbox/InAppMessages/Models/Config/SettingsModel.swift b/Mindbox/InAppMessages/Models/Config/SettingsModel.swift index d8c51260c..120cc814f 100644 --- a/Mindbox/InAppMessages/Models/Config/SettingsModel.swift +++ b/Mindbox/InAppMessages/Models/Config/SettingsModel.swift @@ -14,9 +14,10 @@ struct Settings: Decodable, Equatable { let slidingExpiration: SlidingExpiration? let inapp: InAppSettings? let featureToggles: FeatureToggles? + let baseAddresses: BaseAddresses? enum CodingKeys: CodingKey { - case operations, ttl, slidingExpiration, inapp, featureToggles + case operations, ttl, slidingExpiration, inapp, featureToggles, baseAddresses } } @@ -28,5 +29,6 @@ extension Settings { self.slidingExpiration = try? container.decodeIfPresent(SlidingExpiration.self, forKey: .slidingExpiration) self.inapp = try? container.decodeIfPresent(InAppSettings.self, forKey: .inapp) self.featureToggles = try? container.decodeIfPresent(FeatureToggles.self, forKey: .featureToggles) + self.baseAddresses = try? container.decodeIfPresent(BaseAddresses.self, forKey: .baseAddresses) } } diff --git a/Mindbox/MBConfiguration.swift b/Mindbox/MBConfiguration.swift index f4490f6c5..ed73d63dd 100644 --- a/Mindbox/MBConfiguration.swift +++ b/Mindbox/MBConfiguration.swift @@ -15,6 +15,7 @@ import MindboxLogger public struct MBConfiguration: Codable { public let endpoint: String public let domain: String + public var operationsDomain: String? public var previousInstallationId: String? public var previousDeviceUUID: String? public var subscribeCustomerIfCreated: Bool @@ -26,6 +27,8 @@ public struct MBConfiguration: Codable { /// /// - Parameter endpoint: Used for app identification /// - Parameter domain: Used for generating baseurl for REST + /// - Parameter operationsDomain: Optional host for sending operations. Overridden by + /// the value from the mobile JSON config when present. Default `nil` (use `domain`). /// - Parameter previousInstallationId: Used to create tracking continuity by uuid /// - Parameter previousDeviceUUID: Used instead of the generated value /// - Parameter subscribeCustomerIfCreated: Flag which determines subscription status of the user. Default value is `false`. @@ -36,6 +39,7 @@ public struct MBConfiguration: Codable { public init( endpoint: String, domain: String, + operationsDomain: String? = nil, previousInstallationId: String? = nil, previousDeviceUUID: String? = nil, subscribeCustomerIfCreated: Bool = false, @@ -46,7 +50,7 @@ public struct MBConfiguration: Codable { self.endpoint = endpoint self.domain = domain - guard let url = URL(string: "https://" + domain), URLValidator(url: url).evaluate() else { + guard URLValidator.isValidHost(HostNormalizer.extractHost(domain)) else { let error = MindboxError(.init(errorKey: .invalidConfiguration, reason: "Invalid domain. Domain is unreachable. [Domain]: \(domain)")) Logger.error(error.asLoggerError()) throw error @@ -58,6 +62,17 @@ public struct MBConfiguration: Codable { throw error } + if let operationsDomain = operationsDomain, !operationsDomain.isEmpty { + guard URLValidator.isValidHost(HostNormalizer.extractHost(operationsDomain)) else { + let error = MindboxError(.init(errorKey: .invalidConfiguration, reason: "Invalid operationsDomain. Host is unreachable. [OperationsDomain]: \(operationsDomain)")) + Logger.error(error.asLoggerError()) + throw error + } + self.operationsDomain = operationsDomain + } else { + self.operationsDomain = nil + } + if let previousInstallationId = previousInstallationId, !previousInstallationId.isEmpty { if UUID(uuidString: previousInstallationId) != nil && UDIDValidator(udid: previousInstallationId).evaluate() { self.previousInstallationId = previousInstallationId @@ -137,6 +152,7 @@ public struct MBConfiguration: Codable { enum CodingKeys: String, CodingKey { case endpoint case domain + case operationsDomain case previousInstallationId case previousDeviceUUID case subscribeCustomerIfCreated @@ -148,6 +164,7 @@ public struct MBConfiguration: Codable { let values = try decoder.container(keyedBy: CodingKeys.self) let endpoint = try values.decode(String.self, forKey: .endpoint) let domain = try values.decode(String.self, forKey: .domain) + let operationsDomain = try values.decodeIfPresent(String.self, forKey: .operationsDomain) var previousInstallationId: String? if let value = try? values.decode(String.self, forKey: .previousInstallationId) { if !value.isEmpty { @@ -166,6 +183,7 @@ public struct MBConfiguration: Codable { try self.init( endpoint: endpoint, domain: domain, + operationsDomain: operationsDomain, previousInstallationId: previousInstallationId, previousDeviceUUID: previousDeviceUUID, subscribeCustomerIfCreated: subscribeCustomerIfCreated, @@ -191,6 +209,8 @@ struct ConfigValidation { var changedState: ChangedState = .none + // `operationsDomain` is intentionally not diffed: changing it must not re-fire + // `installed`. The new value is picked up at request time via MBNetworkFetcher. mutating func compare(_ lhs: MBConfiguration?, _ rhs: MBConfiguration?) { if !(lhs?.domain == rhs?.domain && lhs?.endpoint == rhs?.endpoint) { changedState = .rest diff --git a/Mindbox/MindboxLogger/SDKLogsRequest.swift b/Mindbox/MindboxLogger/SDKLogsRequest.swift index cf228db30..7530eaf08 100644 --- a/Mindbox/MindboxLogger/SDKLogsRequest.swift +++ b/Mindbox/MindboxLogger/SDKLogsRequest.swift @@ -12,19 +12,3 @@ struct SDKLogsRequest: Codable { let requestId: String let content: [String] } - -struct SDKLogsRoute: Route { - var method: HTTPMethod { .post } - var path: String { "/v3/operations/async/MobileSdk.Logs" } - var headers: HTTPHeaders? { nil } - var queryParameters: QueryParameters { .init() } - var body: Data? - - func makeBasicQueryParameters(with wrapper: EventWrapper) -> QueryParameters { - ["transactionId": wrapper.event.transactionId, - "deviceUUID": wrapper.deviceUUID, - "dateTimeOffset": wrapper.event.dateTimeOffset, - "operation": wrapper.event.type.rawValue, - "endpointId": wrapper.endpoint] - } -} diff --git a/Mindbox/Network/Abstract/Route.swift b/Mindbox/Network/Abstract/Route.swift index 9712ef439..88b4fa1b1 100644 --- a/Mindbox/Network/Abstract/Route.swift +++ b/Mindbox/Network/Abstract/Route.swift @@ -8,6 +8,12 @@ import Foundation +enum RouteBaseURL { + case domain + /// Falls back to `domain` when no operations host is configured. + case operations +} + protocol Route { var method: HTTPMethod { get } @@ -19,4 +25,10 @@ protocol Route { var queryParameters: QueryParameters { get } var body: Data? { get } + + var baseURLKind: RouteBaseURL { get } +} + +extension Route { + var baseURLKind: RouteBaseURL { .domain } } diff --git a/Mindbox/Network/Helpers/HostNormalizer.swift b/Mindbox/Network/Helpers/HostNormalizer.swift new file mode 100644 index 000000000..1dc2320b7 --- /dev/null +++ b/Mindbox/Network/Helpers/HostNormalizer.swift @@ -0,0 +1,47 @@ +// +// HostNormalizer.swift +// Mindbox +// +// Created by Sergei Semko on 4/27/26. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation + +/// Scheme-aware normalization for `domain` / `operationsDomain` inputs. +/// Accepts `host`, `https://host`, `http://host`, with or without trailing slash. +enum HostNormalizer { + + private static let httpsPrefix = "https://" + private static let httpPrefix = "http://" + + /// Strips scheme (case-insensitive), whitespace, and trailing slashes. + static func extractHost(_ raw: String) -> String { + var value = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if value.range(of: httpsPrefix, options: [.caseInsensitive, .anchored]) != nil { + value = String(value.dropFirst(httpsPrefix.count)) + } else if value.range(of: httpPrefix, options: [.caseInsensitive, .anchored]) != nil { + value = String(value.dropFirst(httpPrefix.count)) + } + while value.hasSuffix("/") { + value.removeLast() + } + return value + } + + /// Preserves an existing scheme, otherwise prepends `https://`. + static func toBaseURLString(_ raw: String) -> String { + var value = raw.trimmingCharacters(in: .whitespacesAndNewlines) + while value.hasSuffix("/") { value.removeLast() } + if hasSchemePrefix(value) { + return value + } + return httpsPrefix + value + } + + private static func hasSchemePrefix(_ value: String) -> Bool { + let options: String.CompareOptions = [.caseInsensitive, .anchored] + return value.range(of: httpsPrefix, options: options) != nil + || value.range(of: httpPrefix, options: options) != nil + } +} diff --git a/Mindbox/Network/Helpers/URLRequestBuilder.swift b/Mindbox/Network/Helpers/URLRequestBuilder.swift index 0b4d53b61..60ba12625 100644 --- a/Mindbox/Network/Helpers/URLRequestBuilder.swift +++ b/Mindbox/Network/Helpers/URLRequestBuilder.swift @@ -12,9 +12,15 @@ import MindboxLogger struct URLRequestBuilder { let domain: String + let operationsDomain: String? + + init(domain: String, operationsDomain: String? = nil) { + self.domain = domain + self.operationsDomain = operationsDomain + } func asURLRequest(route: Route) throws -> URLRequest { - let components = makeURLComponents(for: route) + let components = try makeURLComponents(for: route) guard let url = components.url else { Logger.common(message: "Bad url. [URL]: \(String(describing: components.url))", level: .error, category: .network) @@ -31,16 +37,32 @@ struct URLRequestBuilder { return urlRequest } - private func makeURLComponents(for route: Route) -> URLComponents { - var components = URLComponents() - components.scheme = "https" - components.host = domain + private func makeURLComponents(for route: Route) throws -> URLComponents { + let baseURL = HostNormalizer.toBaseURLString(resolvedHost(for: route)) + + // Fail fast: if the base URL is unparseable, we used to fall back to an + // empty `URLComponents()` — `components.url` then returned a relative URL + // (just the path), which silently sent the request to a bogus target. + guard var components = URLComponents(string: baseURL) else { + Logger.common(message: "Failed to build base URL components. [Base]: \(baseURL)", level: .error, category: .network) + throw URLError(.badURL) + } + components.path = route.path components.queryItems = makeQueryItems(for: route.queryParameters) return components } + private func resolvedHost(for route: Route) -> String { + switch route.baseURLKind { + case .domain: + return domain + case .operations: + return operationsDomain ?? domain + } + } + private func makeQueryItems(for parameters: QueryParameters?) -> [URLQueryItem]? { return parameters?.compactMap { URLQueryItem(name: $0.key, value: $0.value.description) } } diff --git a/Mindbox/Network/MBNetworkFetcher.swift b/Mindbox/Network/MBNetworkFetcher.swift index 9da733fda..123bee386 100644 --- a/Mindbox/Network/MBNetworkFetcher.swift +++ b/Mindbox/Network/MBNetworkFetcher.swift @@ -56,7 +56,10 @@ class MBNetworkFetcher: NetworkFetcher { return } - let builder = URLRequestBuilder(domain: configuration.domain) + let builder = URLRequestBuilder( + domain: configuration.domain, + operationsDomain: resolvedOperationsDomain(configuration: configuration) + ) do { let urlRequest = try builder.asURLRequest(route: route) Logger.network(request: urlRequest, httpAdditionalHeaders: session.configuration.httpAdditionalHeaders) @@ -102,7 +105,10 @@ class MBNetworkFetcher: NetworkFetcher { completion(.failure(error)) return } - let builder = URLRequestBuilder(domain: configuration.domain) + let builder = URLRequestBuilder( + domain: configuration.domain, + operationsDomain: resolvedOperationsDomain(configuration: configuration) + ) do { let urlRequest = try builder.asURLRequest(route: route) Logger.network(request: urlRequest, httpAdditionalHeaders: session.configuration.httpAdditionalHeaders) @@ -368,4 +374,23 @@ class MBNetworkFetcher: NetworkFetcher { tasks.forEach { $0.cancel() } } } + + private func resolvedOperationsDomain(configuration: MBConfiguration) -> String? { + Self.resolveOperationsDomain( + fromConfigJSON: persistenceStorage.operationsDomainFromConfig, + fromInit: configuration.operationsDomain + ) + } + + /// Priority: JSON config > init > nil. Empty strings count as "no value". + /// Static for unit-testing without a PersistenceStorage or MBConfiguration. + static func resolveOperationsDomain(fromConfigJSON: String?, fromInit: String?) -> String? { + if let fromConfig = fromConfigJSON, !fromConfig.isEmpty { + return fromConfig + } + if let fromInit = fromInit, !fromInit.isEmpty { + return fromInit + } + return nil + } } diff --git a/Mindbox/NetworkRepository/Event/EventRoute.swift b/Mindbox/NetworkRepository/Event/EventRoute.swift index 940962eb3..17e6c0995 100644 --- a/Mindbox/NetworkRepository/Event/EventRoute.swift +++ b/Mindbox/NetworkRepository/Event/EventRoute.swift @@ -21,6 +21,8 @@ enum EventRoute: Route { } } + var baseURLKind: RouteBaseURL { .operations } + var path: String { switch self { case .syncEvent: @@ -87,7 +89,7 @@ enum EventRoute: Route { json["endpointId"] = wrapper.endpoint - return try? JSONSerialization.data(withJSONObject: json, options: .prettyPrinted) + return try? JSONSerialization.data(withJSONObject: json) } } diff --git a/Mindbox/PersistenceStorage/MBPersistenceStorage.swift b/Mindbox/PersistenceStorage/MBPersistenceStorage.swift index a851dfd53..d35f5a39a 100644 --- a/Mindbox/PersistenceStorage/MBPersistenceStorage.swift +++ b/Mindbox/PersistenceStorage/MBPersistenceStorage.swift @@ -268,6 +268,13 @@ class MBPersistenceStorage: PersistenceStorage { @UserDefaultsWrapper(key: .webViewLocalStateVersion, defaultValue: nil) var webViewLocalStateVersion: Int? + @UserDefaultsWrapper(key: .operationsDomainFromConfig, defaultValue: nil) + var operationsDomainFromConfig: String? { + didSet { + onDidChange?() + } + } + // MARK: - Deprecated Properties // These properties are deprecated and will be removed in future versions. // Please use the recommended alternatives instead. @@ -306,6 +313,7 @@ extension MBPersistenceStorage { case applicationInfoUpdateVersion = "MBPersistenceStorage-applicationInfoUpdatedVersion" case applicationInstanceId = "MBPersistenceStorage-applicationInstanceId" case webViewLocalStateVersion = "MBPersistenceStorage-webViewLocalStateVersion" + case operationsDomainFromConfig = "MBPersistenceStorage-operationsDomainFromConfig" // MARK: - Deprecated Keys // These keys are deprecated and will be removed in future versions. diff --git a/Mindbox/PersistenceStorage/PersistenceStorage.swift b/Mindbox/PersistenceStorage/PersistenceStorage.swift index 95e268a68..df7d067a8 100644 --- a/Mindbox/PersistenceStorage/PersistenceStorage.swift +++ b/Mindbox/PersistenceStorage/PersistenceStorage.swift @@ -52,6 +52,14 @@ protocol PersistenceStorage: AnyObject { /// It is optional and can be set to `nil` if the configuration has not yet been downloaded yet or reset. var configDownloadDate: Date? { get set } + /// Operations host cached from `settings.baseAddresses.operations` in the mobile + /// JSON config. Persisted across launches; takes precedence over the init-time + /// `MBConfiguration.operationsDomain` at request time. + /// Excluded from `softReset()` on purpose: clearing it on a migration reset would + /// route operations to `domain` until the next config load, breaking the + /// PD-safety guarantee. + var operationsDomainFromConfig: String? { get set } + /// The version code used to track the current state of migrations. /// This value is compared to `Constants.Migration.sdkVersionCode` to determine /// if migrations need to be performed. If a migration fails, and the `versionCodeForMigration` @@ -119,6 +127,7 @@ extension PersistenceStorage { configuration = nil isNotificationsEnabled = nil configDownloadDate = nil + operationsDomainFromConfig = nil applicationInstanceId = nil applicationInfoUpdateVersion = nil } diff --git a/Mindbox/Validators/URLValidator.swift b/Mindbox/Validators/URLValidator.swift index cab0b22c9..eb973f5c1 100644 --- a/Mindbox/Validators/URLValidator.swift +++ b/Mindbox/Validators/URLValidator.swift @@ -8,26 +8,49 @@ import Foundation -// FIXME: Rewrite this struct in the future +/// Validates a bare hostname (e.g. `api.mindbox.ru`, `localhost`, `192.168.1.1`) +/// using RFC 1123 label structure. No TLD allow-list — new TLDs (`.app`, `.dev`, …) +/// are accepted automatically. Analogous to Android's `PatternsCompat.DOMAIN_NAME`, +/// including its IPv4 octet-range enforcement. +enum URLValidator { -struct URLValidator { + /// RFC 1035: full hostname max 253 chars. + private static let maxHostLength = 253 - let url: URL + /// RFC 1035: each label 1..63 chars. + private static let maxLabelLength = 63 - // swiftlint:disable:next line_length - let urlPattern = "^(http|https|ftp)\\://([a-zA-Z0-9\\.\\-]+(\\:[a-zA-Z0-9\\.&%\\$\\-]+)*@)*((25[0-5]|2[0-4][0-9]|[0-1]{1}[0-9]{2}|[1-9]{1}[0-9]{1}|[1-9])\\.(25[0-5]|2[0-4][0-9]|[0-1]{1}[0-9]{2}|[1-9]{1}[0-9]{1}|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1]{1}[0-9]{2}|[1-9]{1}[0-9]{1}|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1]{1}[0-9]{2}|[1-9]{1}[0-9]{1}|[0-9])|localhost|([a-zA-Z0-9\\-]+\\.)*[a-zA-Z0-9\\-]+\\.(com|cloud|edu|gov|int|mil|net|org|biz|arpa|info|name|pro|aero|coop|museum|tech|[a-zA-Z]{2}))(\\:[0-9]+)*(/($|[a-zA-Z0-9\\.\\,\\?\\'\\\\\\+&%\\$#\\=~_\\-]+))*$" + static func isValidHost(_ host: String) -> Bool { + guard !host.isEmpty, host.count <= maxHostLength else { return false } - func evaluate() -> Bool { - return matches(string: url.absoluteString, pattern: urlPattern) + let labels = host.split(separator: ".", omittingEmptySubsequences: false) + + // Four pure-digit labels = IPv4 literal — enforce octet ranges so + // `999.999.999.999` is rejected (matches Android's PatternsCompat). + if labels.count == 4, labels.allSatisfy({ $0.allSatisfy(\.isASCII) && $0.allSatisfy(\.isNumber) }) { + return labels.allSatisfy(isValidIPv4Octet) + } + + return labels.allSatisfy(isValidLabel) + } + + private static func isValidLabel(_ label: Substring) -> Bool { + guard (1...maxLabelLength).contains(label.count), + label.first != "-", + label.last != "-" + else { return false } + return label.unicodeScalars.allSatisfy(isAlnumOrHyphen) + } + + private static func isValidIPv4Octet(_ label: Substring) -> Bool { + guard (1...3).contains(label.count), let value = Int(label) else { return false } + return (0...255).contains(value) } - private func matches(string: String, pattern: String) -> Bool { - let regex = try! NSRegularExpression( // swiftlint:disable:this force_try - pattern: pattern, - options: [.caseInsensitive]) - return regex.firstMatch( - in: string, - options: [], - range: NSRange(location: 0, length: string.utf16.count)) != nil + private static func isAlnumOrHyphen(_ scalar: Unicode.Scalar) -> Bool { + ("a"..."z").contains(scalar) + || ("A"..."Z").contains(scalar) + || ("0"..."9").contains(scalar) + || scalar == "-" } } diff --git a/MindboxTests/ConfigParsing/Settings/SettingsConfigParsingTests.swift b/MindboxTests/ConfigParsing/Settings/SettingsConfigParsingTests.swift index 65879a365..9637445a1 100644 --- a/MindboxTests/ConfigParsing/Settings/SettingsConfigParsingTests.swift +++ b/MindboxTests/ConfigParsing/Settings/SettingsConfigParsingTests.swift @@ -65,6 +65,13 @@ fileprivate enum SettingsConfig: String, Configurable { case settingsInAppSettingsMissingMinIntervalBetweenShows = "SettingsInAppSettingsMissingMinIntervalBetweenShows" // Missing minIntervalBetweenShows case settingsInAppSettingsTypeErrors = "SettingsInAppSettingsTypeErrors" // All parameters have incorrect types + // BaseAddresses file names + + case settingsBaseAddressesError = "SettingsBaseAddressesError" // Key is `baseAddressesTest` instead of `baseAddresses` + case settingsBaseAddressesTypeError = "SettingsBaseAddressesTypeError" // Type of `baseAddresses` is Int instead of BaseAddresses + case settingsBaseAddressesOperationsError = "SettingsBaseAddressesOperationsError" // Key is `operationsTest` instead of `operations` + case settingsBaseAddressesOperationsTypeError = "SettingsBaseAddressesOperationsTypeError" // Type of `operations` is Int instead of String + } final class SettingsConfigParsingTests: XCTestCase { @@ -93,6 +100,9 @@ final class SettingsConfigParsingTests: XCTestCase { XCTAssertNotNil(config.featureToggles, "FeatureToggles must be successfully parsed") XCTAssertEqual(config.featureToggles?.shouldSendInAppShowError, true, "shouldSendInAppShowError must be parsed correctly") + + XCTAssertNotNil(config.baseAddresses, "BaseAddresses must be successfully parsed") + XCTAssertEqual(config.baseAddresses?.operations, "anonymizer-demo-api-regular.mindbox.ru", "operations must be parsed correctly") } // MARK: - Operations @@ -519,15 +529,63 @@ final class SettingsConfigParsingTests: XCTestCase { func test_SettingsConfig_withInAppSettingsTypeErrors_shouldSetAllValuesToNil() { // All parameters have incorrect types let config = try! SettingsConfig.settingsInAppSettingsTypeErrors.getConfig() - + XCTAssertNotNil(config.operations, "Operations must be successfully parsed") XCTAssertNotNil(config.ttl, "TTL must be successfully parsed") XCTAssertNotNil(config.slidingExpiration, "SlidingExpiration must be successfully parsed") - + XCTAssertNil(config.inapp, "InAppSettings must be nil") XCTAssertNil(config.inapp?.maxInappsPerSession, "maxInappsPerSession must be nil due to type error") XCTAssertNil(config.inapp?.maxInappsPerDay, "maxInappsPerDay must be nil due to type error") XCTAssertNil(config.inapp?.minIntervalBetweenShows, "minIntervalBetweenShows must be nil due to type error") } + // MARK: - BaseAddresses + + func test_SettingsConfig_withBaseAddressesError_shouldSetBaseAddressesToNil() { + // Key is `baseAddressesTest` instead of `baseAddresses` + let config = try! SettingsConfig.settingsBaseAddressesError.getConfig() + XCTAssertNil(config.baseAddresses, "BaseAddresses must be `nil` if the key `baseAddresses` is not found") + + XCTAssertNotNil(config.operations, "Operations must be successfully parsed") + XCTAssertNotNil(config.ttl, "TTL must be successfully parsed") + XCTAssertNotNil(config.slidingExpiration, "SlidingExpiration must be successfully parsed") + XCTAssertNotNil(config.inapp, "InAppSettings must be successfully parsed") + XCTAssertNotNil(config.featureToggles, "FeatureToggles must be successfully parsed") + } + + func test_SettingsConfig_withBaseAddressesTypeError_shouldSetBaseAddressesToNil() { + // Type of `baseAddresses` is Int instead of BaseAddresses + let config = try! SettingsConfig.settingsBaseAddressesTypeError.getConfig() + XCTAssertNil(config.baseAddresses, "BaseAddresses must be `nil` if the type of `baseAddresses` is not a `BaseAddresses`") + + XCTAssertNotNil(config.operations, "Operations must be successfully parsed") + XCTAssertNotNil(config.ttl, "TTL must be successfully parsed") + XCTAssertNotNil(config.slidingExpiration, "SlidingExpiration must be successfully parsed") + XCTAssertNotNil(config.inapp, "InAppSettings must be successfully parsed") + XCTAssertNotNil(config.featureToggles, "FeatureToggles must be successfully parsed") + } + + func test_SettingsConfig_withBaseAddressesOperationsError_shouldSetOperationsToNil() { + // Key is `operationsTest` instead of `operations` + let config = try! SettingsConfig.settingsBaseAddressesOperationsError.getConfig() + XCTAssertNotNil(config.baseAddresses, "BaseAddresses must be successfully parsed") + XCTAssertNil(config.baseAddresses?.operations, "operations must be `nil` if the key `operations` is not found") + + XCTAssertNotNil(config.operations, "Operations must be successfully parsed") + XCTAssertNotNil(config.ttl, "TTL must be successfully parsed") + XCTAssertNotNil(config.slidingExpiration, "SlidingExpiration must be successfully parsed") + } + + func test_SettingsConfig_withBaseAddressesOperationsTypeError_shouldSetOperationsToNil() { + // Type of `operations` is Int instead of String + let config = try! SettingsConfig.settingsBaseAddressesOperationsTypeError.getConfig() + XCTAssertNotNil(config.baseAddresses, "BaseAddresses must be successfully parsed") + XCTAssertNil(config.baseAddresses?.operations, "operations must be `nil` if the type of `operations` is not a `String`") + + XCTAssertNotNil(config.operations, "Operations must be successfully parsed") + XCTAssertNotNil(config.ttl, "TTL must be successfully parsed") + XCTAssertNotNil(config.slidingExpiration, "SlidingExpiration must be successfully parsed") + } + } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesError.json new file mode 100644 index 000000000..356641a57 --- /dev/null +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesError.json @@ -0,0 +1,31 @@ +{ + "operations": { + "viewProduct": { + "systemName": "viewProduct" + }, + "viewCategory": { + "systemName": "viewCategory" + }, + "setCart": { + "systemName": "setCart" + } + }, + "ttl": { + "inapps": "1.00:00:00" + }, + "slidingExpiration": { + "config": "0.00:00:23", + "pushTokenKeepalive": "14.00:00:00" + }, + "inapp": { + "maxInappsPerSession": 1, + "maxInappsPerDay": 1, + "minIntervalBetweenShows": "0.00:00:10" + }, + "featureToggles": { + "MobileSdkShouldSendInAppShowError": false + }, + "baseAddressesTest": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" + } +} diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesOperationsError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesOperationsError.json new file mode 100644 index 000000000..f145b4dcf --- /dev/null +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesOperationsError.json @@ -0,0 +1,31 @@ +{ + "operations": { + "viewProduct": { + "systemName": "viewProduct" + }, + "viewCategory": { + "systemName": "viewCategory" + }, + "setCart": { + "systemName": "setCart" + } + }, + "ttl": { + "inapps": "1.00:00:00" + }, + "slidingExpiration": { + "config": "0.00:00:23", + "pushTokenKeepalive": "14.00:00:00" + }, + "inapp": { + "maxInappsPerSession": 1, + "maxInappsPerDay": 1, + "minIntervalBetweenShows": "0.00:00:10" + }, + "featureToggles": { + "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operationsTest": "anonymizer-demo-api-regular.mindbox.ru" + } +} diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesOperationsTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesOperationsTypeError.json new file mode 100644 index 000000000..7220a111d --- /dev/null +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesOperationsTypeError.json @@ -0,0 +1,31 @@ +{ + "operations": { + "viewProduct": { + "systemName": "viewProduct" + }, + "viewCategory": { + "systemName": "viewCategory" + }, + "setCart": { + "systemName": "setCart" + } + }, + "ttl": { + "inapps": "1.00:00:00" + }, + "slidingExpiration": { + "config": "0.00:00:23", + "pushTokenKeepalive": "14.00:00:00" + }, + "inapp": { + "maxInappsPerSession": 1, + "maxInappsPerDay": 1, + "minIntervalBetweenShows": "0.00:00:10" + }, + "featureToggles": { + "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": 123 + } +} diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesTypeError.json new file mode 100644 index 000000000..8eae125af --- /dev/null +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/BaseAddressesError/SettingsBaseAddressesTypeError.json @@ -0,0 +1,29 @@ +{ + "operations": { + "viewProduct": { + "systemName": "viewProduct" + }, + "viewCategory": { + "systemName": "viewCategory" + }, + "setCart": { + "systemName": "setCart" + } + }, + "ttl": { + "inapps": "1.00:00:00" + }, + "slidingExpiration": { + "config": "0.00:00:23", + "pushTokenKeepalive": "14.00:00:00" + }, + "inapp": { + "maxInappsPerSession": 1, + "maxInappsPerDay": 1, + "minIntervalBetweenShows": "0.00:00:10" + }, + "featureToggles": { + "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": 123 +} diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesError.json index 333dfadae..c71f30eb9 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesError.json @@ -1,28 +1,28 @@ { - "operations": { - "viewProduct": { - "systemName": "ProductView" - }, - "viewCategory": { - "systemName": "CategoryView" - }, - "setCart": { - "systemName": "SetCart" - } + "operations": { + "viewProduct": { + "systemName": "ProductView" }, - "ttl": { - "inapps": "0.00:00:10" + "viewCategory": { + "systemName": "CategoryView" }, - "slidingExpiration": { - "config": "0.00:00:10", - "pushTokenKeepalive": "0.00:00:10" - }, - "inapp": { - "maxInappsPerSession": 1, - "maxInappsPerDay": 1, - "minIntervalBetweenShows": "0.00:00:10" - }, - "featureTogglesTest": { - "MobileSdkShouldSendInAppShowError": true + "setCart": { + "systemName": "SetCart" } + }, + "ttl": { + "inapps": "0.00:00:10" + }, + "slidingExpiration": { + "config": "0.00:00:10", + "pushTokenKeepalive": "0.00:00:10" + }, + "inapp": { + "maxInappsPerSession": 1, + "maxInappsPerDay": 1, + "minIntervalBetweenShows": "0.00:00:10" + }, + "featureTogglesTest": { + "MobileSdkShouldSendInAppShowError": true + } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorFalse.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorFalse.json index 119f59394..2e49e6cd9 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorFalse.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorFalse.json @@ -1,28 +1,28 @@ { - "operations": { - "viewProduct": { - "systemName": "ProductView" - }, - "viewCategory": { - "systemName": "CategoryView" - }, - "setCart": { - "systemName": "SetCart" - } + "operations": { + "viewProduct": { + "systemName": "ProductView" }, - "ttl": { - "inapps": "0.00:00:10" + "viewCategory": { + "systemName": "CategoryView" }, - "slidingExpiration": { - "config": "0.00:00:10", - "pushTokenKeepalive": "0.00:00:10" - }, - "inapp": { - "maxInappsPerSession": 1, - "maxInappsPerDay": 1, - "minIntervalBetweenShows": "0.00:00:10" - }, - "featureToggles": { - "MobileSdkShouldSendInAppShowError": false + "setCart": { + "systemName": "SetCart" } + }, + "ttl": { + "inapps": "0.00:00:10" + }, + "slidingExpiration": { + "config": "0.00:00:10", + "pushTokenKeepalive": "0.00:00:10" + }, + "inapp": { + "maxInappsPerSession": 1, + "maxInappsPerDay": 1, + "minIntervalBetweenShows": "0.00:00:10" + }, + "featureToggles": { + "MobileSdkShouldSendInAppShowError": false + } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorMissing.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorMissing.json index 7d8532fce..22ad2bb27 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorMissing.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorMissing.json @@ -1,27 +1,26 @@ { - "operations": { - "viewProduct": { - "systemName": "ProductView" - }, - "viewCategory": { - "systemName": "CategoryView" - }, - "setCart": { - "systemName": "SetCart" - } + "operations": { + "viewProduct": { + "systemName": "ProductView" }, - "ttl": { - "inapps": "0.00:00:10" + "viewCategory": { + "systemName": "CategoryView" }, - "slidingExpiration": { - "config": "0.00:00:10", - "pushTokenKeepalive": "0.00:00:10" - }, - "inapp": { - "maxInappsPerSession": 1, - "maxInappsPerDay": 1, - "minIntervalBetweenShows": "0.00:00:10" - }, - "featureToggles": { + "setCart": { + "systemName": "SetCart" } + }, + "ttl": { + "inapps": "0.00:00:10" + }, + "slidingExpiration": { + "config": "0.00:00:10", + "pushTokenKeepalive": "0.00:00:10" + }, + "inapp": { + "maxInappsPerSession": 1, + "maxInappsPerDay": 1, + "minIntervalBetweenShows": "0.00:00:10" + }, + "featureToggles": {} } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json index f8beeba06..962e6fcc4 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json @@ -1,28 +1,28 @@ { - "operations": { - "viewProduct": { - "systemName": "ProductView" - }, - "viewCategory": { - "systemName": "CategoryView" - }, - "setCart": { - "systemName": "SetCart" - } + "operations": { + "viewProduct": { + "systemName": "ProductView" }, - "ttl": { - "inapps": "0.00:00:10" + "viewCategory": { + "systemName": "CategoryView" }, - "slidingExpiration": { - "config": "0.00:00:10", - "pushTokenKeepalive": "0.00:00:10" - }, - "inapp": { - "maxInappsPerSession": 1, - "maxInappsPerDay": 1, - "minIntervalBetweenShows": "0.00:00:10" - }, - "featureToggles": { - "MobileSdkShouldSendInAppShowError": "yes" + "setCart": { + "systemName": "SetCart" } + }, + "ttl": { + "inapps": "0.00:00:10" + }, + "slidingExpiration": { + "config": "0.00:00:10", + "pushTokenKeepalive": "0.00:00:10" + }, + "inapp": { + "maxInappsPerSession": 1, + "maxInappsPerDay": 1, + "minIntervalBetweenShows": "0.00:00:10" + }, + "featureToggles": { + "MobileSdkShouldSendInAppShowError": "yes" + } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesTypeError.json index 2be0abea5..5f7534e0f 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/FeatureTogglesError/SettingsFeatureTogglesTypeError.json @@ -1,26 +1,26 @@ { - "operations": { - "viewProduct": { - "systemName": "ProductView" - }, - "viewCategory": { - "systemName": "CategoryView" - }, - "setCart": { - "systemName": "SetCart" - } + "operations": { + "viewProduct": { + "systemName": "ProductView" }, - "ttl": { - "inapps": "0.00:00:10" + "viewCategory": { + "systemName": "CategoryView" }, - "slidingExpiration": { - "config": "0.00:00:10", - "pushTokenKeepalive": "0.00:00:10" - }, - "inapp": { - "maxInappsPerSession": 1, - "maxInappsPerDay": 1, - "minIntervalBetweenShows": "0.00:00:10" - }, - "featureToggles": 123 + "setCart": { + "systemName": "SetCart" + } + }, + "ttl": { + "inapps": "0.00:00:10" + }, + "slidingExpiration": { + "config": "0.00:00:10", + "pushTokenKeepalive": "0.00:00:10" + }, + "inapp": { + "maxInappsPerSession": 1, + "maxInappsPerDay": 1, + "minIntervalBetweenShows": "0.00:00:10" + }, + "featureToggles": 123 } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsAllOperationsWithErrors.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsAllOperationsWithErrors.json index e2e0d7432..4f9f1c8c0 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsAllOperationsWithErrors.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsAllOperationsWithErrors.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsAllOperationsWithTypeErrors.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsAllOperationsWithTypeErrors.json index 2869f6137..2e84f20d9 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsAllOperationsWithTypeErrors.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsAllOperationsWithTypeErrors.json @@ -18,5 +18,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsError.json index 850a1020c..b3190668f 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsTypeError.json index 164da9a07..33325f1cd 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsTypeError.json @@ -14,5 +14,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartError.json index ee80de43f..85de3c662 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameError.json index f06b175e7..19750a703 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameMixedError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameMixedError.json index 0ebcc88ba..7b68e495b 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameMixedError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameMixedError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameTypeError.json index 2a8e4cf03..7688cda6a 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartSystemNameTypeError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartTypeError.json index 0f6f28c8a..1a5fd4d2d 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewCategoryAndSetCartTypeError.json @@ -20,5 +20,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductError.json index 1588843d6..d9e492797 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductSystemNameError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductSystemNameError.json index b5d2fc609..77b7561a6 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductSystemNameError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductSystemNameError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductSystemNameTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductSystemNameTypeError.json index e32116ea9..b9428f212 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductSystemNameTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductSystemNameTypeError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductTypeError.json index 232f4c94a..6e6cde150 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/OperationsErrors/SettingsOperationsViewProductTypeError.json @@ -22,5 +22,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SettingsConfig.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SettingsConfig.json index c03b23c2f..67893c3ad 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SettingsConfig.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SettingsConfig.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": true + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationConfigError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationConfigError.json index 335c80084..b30a726ed 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationConfigError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationConfigError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationConfigTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationConfigTypeError.json index 312cc0253..02849935a 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationConfigTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationConfigTypeError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationError.json index 9afa8c0a8..9e98f807c 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationPushTokenKeepaliveError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationPushTokenKeepaliveError.json index d8132522c..17a8fc3df 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationPushTokenKeepaliveError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationPushTokenKeepaliveError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationPushTokenKeepaliveTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationPushTokenKeepaliveTypeError.json index 577808a64..0aa6c7ed4 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationPushTokenKeepaliveTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationPushTokenKeepaliveTypeError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationTypeError.json index 64c11af8f..56e48d587 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/SlidingExpirationsError/SettingsSlidingExpirationTypeError.json @@ -21,5 +21,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlError.json index fbe1538d5..84285bb3c 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlInappsError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlInappsError.json index b6da818a9..56ae13299 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlInappsError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlInappsError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlInappsTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlInappsTypeError.json index 6b99a2362..f4c2f5087 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlInappsTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlInappsTypeError.json @@ -24,5 +24,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlTypeError.json b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlTypeError.json index 9d5422fc6..e21788dc6 100644 --- a/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlTypeError.json +++ b/MindboxTests/ConfigParsing/stubs/Settings/SettingsJsonStubs/TtlErrors/SettingsTtlTypeError.json @@ -22,5 +22,8 @@ }, "featureToggles": { "MobileSdkShouldSendInAppShowError": false + }, + "baseAddresses": { + "operations": "anonymizer-demo-api-regular.mindbox.ru" } } diff --git a/MindboxTests/Configuration/MBConfigurationTests.swift b/MindboxTests/Configuration/MBConfigurationTests.swift new file mode 100644 index 000000000..dc3425ccf --- /dev/null +++ b/MindboxTests/Configuration/MBConfigurationTests.swift @@ -0,0 +1,413 @@ +// +// MBConfigurationTests.swift +// MindboxTests +// +// Created by Sergei Semko on 4/27/26. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +import Testing +@testable import Mindbox + +@Suite("MBConfiguration", .tags(.mbConfiguration)) +struct MBConfigurationTests { + + private let domain = "api.mindbox.ru" + private let endpoint = "test-endpoint" + private let validUUID = "F47AC10B-58CC-4372-A567-0E02B2C3D479" + + // MARK: - Init: domain validation + + @Test("Valid domain is accepted") + func validDomainAccepted() throws { + let config = try MBConfiguration(endpoint: endpoint, domain: domain) + #expect(config.domain == domain) + } + + @Test("Empty domain throws") + func emptyDomainThrows() { + #expect(throws: MindboxError.self) { + _ = try MBConfiguration(endpoint: endpoint, domain: "") + } + } + + @Test("Domain with whitespace throws") + func domainWithWhitespaceThrows() { + #expect(throws: MindboxError.self) { + _ = try MBConfiguration(endpoint: endpoint, domain: "api mindbox ru") + } + } + + @Test("Domain accepts https:// prefix") + func domainAcceptsHttpsPrefix() throws { + let config = try MBConfiguration(endpoint: endpoint, domain: "https://api.mindbox.ru") + #expect(config.domain == "https://api.mindbox.ru") + } + + @Test("Domain accepts http:// prefix") + func domainAcceptsHttpPrefix() throws { + let config = try MBConfiguration(endpoint: endpoint, domain: "http://proxy.example.com") + #expect(config.domain == "http://proxy.example.com") + } + + @Test("Domain accepts trailing slash") + func domainAcceptsTrailingSlash() throws { + let config = try MBConfiguration(endpoint: endpoint, domain: "api.mindbox.ru/") + #expect(config.domain == "api.mindbox.ru/") + } + + @Test("operationsDomain accepts https:// prefix") + func operationsDomainAcceptsHttpsPrefix() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "https://anonymizer.client.ru" + ) + #expect(config.operationsDomain == "https://anonymizer.client.ru") + } + + @Test("operationsDomain accepts http:// prefix") + func operationsDomainAcceptsHttpPrefix() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "http://anonymizer-staging.client.ru" + ) + #expect(config.operationsDomain == "http://anonymizer-staging.client.ru") + } + + @Test("operationsDomain accepts trailing slash") + func operationsDomainAcceptsTrailingSlash() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "anonymizer.client.ru/" + ) + #expect(config.operationsDomain == "anonymizer.client.ru/") + } + + // MARK: - Init: endpoint validation + + @Test("Empty endpoint throws") + func emptyEndpointThrows() { + #expect(throws: MindboxError.self) { + _ = try MBConfiguration(endpoint: "", domain: domain) + } + } + + // MARK: - Init: operationsDomain validation + + @Test("nil operationsDomain stored as nil") + func nilOperationsDomainStoredAsNil() throws { + let config = try MBConfiguration(endpoint: endpoint, domain: domain) + #expect(config.operationsDomain == nil) + } + + @Test("Valid operationsDomain stored as-is") + func validOperationsDomainStored() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "anonymizer.client.ru" + ) + #expect(config.operationsDomain == "anonymizer.client.ru") + } + + @Test("Empty operationsDomain treated as nil (not throw)") + func emptyOperationsDomainTreatedAsNil() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "" + ) + #expect(config.operationsDomain == nil) + } + + @Test("Invalid operationsDomain throws") + func invalidOperationsDomainThrows() { + #expect(throws: MindboxError.self) { + _ = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "not a host with spaces" + ) + } + } + + // MARK: - Init: previousInstallationId / previousDeviceUUID UUID handling + + @Test("Valid previousInstallationId UUID is stored") + func validPreviousInstallationIdStored() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + previousInstallationId: validUUID + ) + #expect(config.previousInstallationId == validUUID) + } + + @Test("Invalid previousInstallationId is silently coerced to empty string") + func invalidPreviousInstallationIdCoercedToEmpty() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + previousInstallationId: "not-a-uuid" + ) + #expect(config.previousInstallationId == "") + } + + @Test("Empty previousInstallationId stays nil") + func emptyPreviousInstallationIdStaysNil() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + previousInstallationId: "" + ) + #expect(config.previousInstallationId == nil) + } + + @Test("Valid previousDeviceUUID is stored") + func validPreviousDeviceUUIDStored() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + previousDeviceUUID: validUUID + ) + #expect(config.previousDeviceUUID == validUUID) + } + + @Test("Invalid previousDeviceUUID is silently coerced to empty string") + func invalidPreviousDeviceUUIDCoercedToEmpty() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + previousDeviceUUID: "not-a-uuid" + ) + #expect(config.previousDeviceUUID == "") + } + + // MARK: - Init: defaults + + @Test("Default values match documented public API") + func defaultValues() throws { + let config = try MBConfiguration(endpoint: endpoint, domain: domain) + #expect(config.subscribeCustomerIfCreated == false) + #expect(config.shouldCreateCustomer == true) + #expect(config.imageLoadingMaxTimeInSeconds == nil) + #expect(config.previousInstallationId == nil) + #expect(config.previousDeviceUUID == nil) + #expect(config.operationsDomain == nil) + } + + // MARK: - Init: plist + + @Test("Plist init succeeds for valid configurations", .tags(.decoding)) + func plistInitSucceedsForValidConfigs() throws { + // TestConfig1/2/3 — full valid configurations. + // TestConfig_Invalid_3/4 — valid despite the filename (only previousIDs / domain + // edges are checked at the type level, not the file). + for plist in ["TestConfig1", "TestConfig2", "TestConfig3", "TestConfig_Invalid_3", "TestConfig_Invalid_4"] { + #expect(throws: Never.self) { try MBConfiguration(plistName: plist) } + } + } + + @Test("Plist init throws on empty domain or endpoint", .tags(.decoding)) + func plistInitThrowsOnInvalid() { + // TestConfig_Invalid_1 — empty domain. TestConfig_Invalid_2 — empty endpoint. + for plist in ["TestConfig_Invalid_1", "TestConfig_Invalid_2"] { + #expect(throws: (any Error).self) { try MBConfiguration(plistName: plist) } + } + } + + @Test("Plist init throws on missing file") + func plistInitThrowsOnMissingFile() { + #expect(throws: (any Error).self) { + try MBConfiguration(plistName: "definitely-does-not-exist") + } + } + + // MARK: - Codable + + @Test("Decodes legacy JSON without operationsDomain key", .tags(.decoding)) + func decodesLegacyJSONWithoutOperationsDomain() throws { + let legacyJSON = """ + { + "endpoint": "app-IOS", + "domain": "api.mindbox.ru", + "subscribeCustomerIfCreated": false, + "shouldCreateCustomer": true, + "uuidDebugEnabled": true + } + """.data(using: .utf8)! + + let config = try JSONDecoder().decode(MBConfiguration.self, from: legacyJSON) + #expect(config.endpoint == "app-IOS") + #expect(config.domain == "api.mindbox.ru") + #expect(config.operationsDomain == nil) + } + + @Test("Decodes JSON with operationsDomain", .tags(.decoding)) + func decodesJSONWithOperationsDomain() throws { + let json = """ + { + "endpoint": "app-IOS", + "domain": "api.mindbox.ru", + "operationsDomain": "anonymizer.client.ru" + } + """.data(using: .utf8)! + + let config = try JSONDecoder().decode(MBConfiguration.self, from: json) + #expect(config.operationsDomain == "anonymizer.client.ru") + } + + @Test("Decoder applies the same validation as the programmatic init", .tags(.decoding)) + func decoderEnforcesValidation() { + let invalid = """ + { "endpoint": "", "domain": "api.mindbox.ru" } + """.data(using: .utf8)! + + #expect(throws: (any Error).self) { + _ = try JSONDecoder().decode(MBConfiguration.self, from: invalid) + } + } + + // MARK: - ConfigValidation.compare — identity / nil handling + + @Test("Identical configs → none") + func identicalConfigsReturnNone() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: domain) + let rhs = try MBConfiguration(endpoint: endpoint, domain: domain) + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .none) + } + + @Test("Both sides nil → none") + func bothNilReturnNone() { + var validation = ConfigValidation() + validation.compare(nil, nil) + #expect(validation.changedState == .none) + } + + @Test("nil vs configured → rest (first init counts as REST change)") + func nilToConfiguredReturnsRest() throws { + let rhs = try MBConfiguration(endpoint: endpoint, domain: domain) + var validation = ConfigValidation() + validation.compare(nil, rhs) + #expect(validation.changedState == .rest) + } + + @Test("configured vs nil → rest") + func configuredToNilReturnsRest() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: domain) + var validation = ConfigValidation() + validation.compare(lhs, nil) + #expect(validation.changedState == .rest) + } + + // MARK: - ConfigValidation.compare — REST-affecting fields + + @Test("Domain change → rest") + func domainChangeReturnsRest() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: "a.mindbox.ru") + let rhs = try MBConfiguration(endpoint: endpoint, domain: "b.mindbox.ru") + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .rest) + } + + @Test("Endpoint change → rest") + func endpointChangeReturnsRest() throws { + let lhs = try MBConfiguration(endpoint: "endpoint-A", domain: domain) + let rhs = try MBConfiguration(endpoint: "endpoint-B", domain: domain) + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .rest) + } + + @Test("Domain and endpoint both change → rest (single classification)") + func bothRestFieldsChangeReturnRest() throws { + let lhs = try MBConfiguration(endpoint: "endpoint-A", domain: "a.mindbox.ru") + let rhs = try MBConfiguration(endpoint: "endpoint-B", domain: "b.mindbox.ru") + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .rest) + } + + // MARK: - ConfigValidation.compare — shouldCreateCustomer + + @Test("shouldCreateCustomer change → shouldCreateCustomer") + func shouldCreateCustomerChangeReturnsShouldCreateCustomer() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: domain, shouldCreateCustomer: true) + let rhs = try MBConfiguration(endpoint: endpoint, domain: domain, shouldCreateCustomer: false) + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .shouldCreateCustomer) + } + + @Test("rest change wins over shouldCreateCustomer change (priority)") + func restWinsOverShouldCreateCustomer() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: "a.mindbox.ru", shouldCreateCustomer: true) + let rhs = try MBConfiguration(endpoint: endpoint, domain: "b.mindbox.ru", shouldCreateCustomer: false) + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .rest) + } + + // MARK: - ConfigValidation.compare — fields that must NOT trigger any change + + @Test("operationsDomain change → none (new value applies without re-install)") + func operationsDomainChangeReturnsNone() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: domain, operationsDomain: "old.client.ru") + let rhs = try MBConfiguration(endpoint: endpoint, domain: domain, operationsDomain: "new.client.ru") + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .none) + } + + @Test("subscribeCustomerIfCreated change → none") + func subscribeCustomerIfCreatedChangeReturnsNone() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: domain, subscribeCustomerIfCreated: false) + let rhs = try MBConfiguration(endpoint: endpoint, domain: domain, subscribeCustomerIfCreated: true) + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .none) + } + + @Test("previousInstallationId change → none") + func previousInstallationIdChangeReturnsNone() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: domain) + let rhs = try MBConfiguration(endpoint: endpoint, domain: domain, previousInstallationId: validUUID) + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .none) + } + + @Test("previousDeviceUUID change → none") + func previousDeviceUUIDChangeReturnsNone() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: domain) + let rhs = try MBConfiguration(endpoint: endpoint, domain: domain, previousDeviceUUID: validUUID) + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .none) + } + + @Test("imageLoadingMaxTimeInSeconds change → none") + func imageLoadingMaxTimeInSecondsChangeReturnsNone() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: domain, imageLoadingMaxTimeInSeconds: 5) + let rhs = try MBConfiguration(endpoint: endpoint, domain: domain, imageLoadingMaxTimeInSeconds: 10) + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .none) + } + + @Test("uuidDebugEnabled change → none") + func uuidDebugEnabledChangeReturnsNone() throws { + let lhs = try MBConfiguration(endpoint: endpoint, domain: domain, uuidDebugEnabled: true) + let rhs = try MBConfiguration(endpoint: endpoint, domain: domain, uuidDebugEnabled: false) + var validation = ConfigValidation() + validation.compare(lhs, rhs) + #expect(validation.changedState == .none) + } +} diff --git a/MindboxTests/Extensions/Tag+Extensions.swift b/MindboxTests/Extensions/Tag+Extensions.swift index 27b506597..f9381ded6 100644 --- a/MindboxTests/Extensions/Tag+Extensions.swift +++ b/MindboxTests/Extensions/Tag+Extensions.swift @@ -25,4 +25,6 @@ extension Tag { @Tag static var geoTargeting: Self @Tag static var webView: Self @Tag static var trackVisit: Self + @Tag static var operationsRouting: Self + @Tag static var mbConfiguration: Self } diff --git a/MindboxTests/InApp/Tests/InAppConfigResponseTests/InAppConfigStub.swift b/MindboxTests/InApp/Tests/InAppConfigResponseTests/InAppConfigStub.swift index 1e54fa63c..be0e2dbd9 100644 --- a/MindboxTests/InApp/Tests/InAppConfigResponseTests/InAppConfigStub.swift +++ b/MindboxTests/InApp/Tests/InAppConfigResponseTests/InAppConfigStub.swift @@ -115,10 +115,11 @@ extension InAppConfigStub { viewCategory: operationType == .viewCategory ? .init(systemName: "Mobile.ViewCategory") : nil, setCart: nil ), - ttl: nil, + ttl: nil, slidingExpiration: nil, inapp: nil, - featureToggles: nil + featureToggles: nil, + baseAddresses: nil ) // Mock method setupSettingsFromConfig. diff --git a/MindboxTests/InApp/Tests/InAppConfigResponseTests/InappTTLTests.swift b/MindboxTests/InApp/Tests/InAppConfigResponseTests/InappTTLTests.swift index 9a8a77ca3..dc52be6dc 100644 --- a/MindboxTests/InApp/Tests/InAppConfigResponseTests/InappTTLTests.swift +++ b/MindboxTests/InApp/Tests/InAppConfigResponseTests/InappTTLTests.swift @@ -27,7 +27,7 @@ class InappTTLTests: XCTestCase { func testNeedResetInapps_WithTTL_Exceeds() throws { persistenceStorage.configDownloadDate = Calendar.current.date(byAdding: .hour, value: -2, to: Date()) - let settings = Settings(operations: nil, ttl: .init(inapps: "01:00:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil) + let settings = Settings(operations: nil, ttl: .init(inapps: "01:00:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil, baseAddresses: nil) let config = ConfigResponse(settings: settings) let result = service.needResetInapps(config: config) XCTAssertTrue(result, "Inapps должны быть сброшены, так как время ttl истекло.") @@ -35,7 +35,7 @@ class InappTTLTests: XCTestCase { func testNeedResetInapps_WithTTL_NotExceeded() throws { persistenceStorage.configDownloadDate = Calendar.current.date(byAdding: .second, value: -1, to: Date()) - let settings = Settings(operations: nil, ttl: .init(inapps: "00:00:02"), slidingExpiration: nil, inapp: nil, featureToggles: nil) + let settings = Settings(operations: nil, ttl: .init(inapps: "00:00:02"), slidingExpiration: nil, inapp: nil, featureToggles: nil, baseAddresses: nil) let config = ConfigResponse(settings: settings) let result = service.needResetInapps(config: config) XCTAssertFalse(result, "Inapps не должны быть сброшены, так как время ttl еще не истекло.") @@ -43,7 +43,7 @@ class InappTTLTests: XCTestCase { func testNeedResetInapps_WithoutTTL() throws { persistenceStorage.configDownloadDate = Date() - let settings = Settings(operations: nil, ttl: nil, slidingExpiration: nil, inapp: nil, featureToggles: nil) + let settings = Settings(operations: nil, ttl: nil, slidingExpiration: nil, inapp: nil, featureToggles: nil, baseAddresses: nil) let config = ConfigResponse(settings: settings) let result = service.needResetInapps(config: config) XCTAssertFalse(result, "Inapps не должны быть сброшены, так как в конфиге отсутствует TTL.") @@ -51,7 +51,7 @@ class InappTTLTests: XCTestCase { func testNeedResetInapps_WithTTLHalfHourAgo_NotExceeded() throws { persistenceStorage.configDownloadDate = Calendar.current.date(byAdding: .minute, value: -30, to: Date()) - let settings = Settings(operations: nil, ttl: .init(inapps: "01:00:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil) + let settings = Settings(operations: nil, ttl: .init(inapps: "01:00:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil, baseAddresses: nil) let config = ConfigResponse(settings: settings) let result = service.needResetInapps(config: config) XCTAssertFalse(result, "Inapps не должны быть сброшены, так как время TTL еще не истекло.") @@ -59,7 +59,7 @@ class InappTTLTests: XCTestCase { func testNeedResetInapps_WithTTLHalfMinutesAgo_NotExceeded() throws { persistenceStorage.configDownloadDate = Calendar.current.date(byAdding: .second, value: -30, to: Date()) - let settings = Settings(operations: nil, ttl: .init(inapps: "00:01:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil) + let settings = Settings(operations: nil, ttl: .init(inapps: "00:01:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil, baseAddresses: nil) let config = ConfigResponse(settings: settings) let result = service.needResetInapps(config: config) XCTAssertFalse(result, "Inapps не должны быть сброшены, так как время TTL еще не истекло.") @@ -67,7 +67,7 @@ class InappTTLTests: XCTestCase { func testNeedResetInapps_WithTTLOneDayAgo_NotExceeded() throws { persistenceStorage.configDownloadDate = Calendar.current.date(byAdding: .day, value: -1, to: Date()) - let settings = Settings(operations: nil, ttl: .init(inapps: "2.00:00:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil) + let settings = Settings(operations: nil, ttl: .init(inapps: "2.00:00:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil, baseAddresses: nil) let config = ConfigResponse(settings: settings) let result = service.needResetInapps(config: config) XCTAssertFalse(result, "Inapps не должны быть сброшены, так как время TTL еще не истекло.") @@ -75,7 +75,7 @@ class InappTTLTests: XCTestCase { func testNeedResetInapps_WithMinusTTL_NotExceeded() throws { persistenceStorage.configDownloadDate = Calendar.current.date(byAdding: .day, value: 1, to: Date()) - let settings = Settings(operations: nil, ttl: .init(inapps: "-2.00:00:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil) + let settings = Settings(operations: nil, ttl: .init(inapps: "-2.00:00:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil, baseAddresses: nil) let config = ConfigResponse(settings: settings) let result = service.needResetInapps(config: config) XCTAssertFalse(result, "Inapps не должны быть сброшены, так как время TTL еще не истекло.") @@ -83,7 +83,7 @@ class InappTTLTests: XCTestCase { func testNeedResetInapps_WithMinusOneDayTTL_NotExceeded() throws { persistenceStorage.configDownloadDate = Calendar.current.date(byAdding: .day, value: -2, to: Date()) - let settings = Settings(operations: nil, ttl: .init(inapps: "-1.00:00:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil) + let settings = Settings(operations: nil, ttl: .init(inapps: "-1.00:00:00"), slidingExpiration: nil, inapp: nil, featureToggles: nil, baseAddresses: nil) let config = ConfigResponse(settings: settings) let result = service.needResetInapps(config: config) XCTAssertFalse(result, "Inapps не должны быть сброшены, так как время TTL еще не истекло.") diff --git a/MindboxTests/MBConfigurationTestCase.swift b/MindboxTests/MBConfigurationTestCase.swift deleted file mode 100644 index b3f88629f..000000000 --- a/MindboxTests/MBConfigurationTestCase.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// MBConfigurationTest.swift -// MindboxTests -// -// Created by Mikhail Barilov on 29.01.2021. -// Copyright © 2021 Mindbox. All rights reserved. -// - -import XCTest -@testable import Mindbox - -class MBConfigurationTestCase: XCTestCase { - // Invalid - let emptyDomainFile = "TestConfig_Invalid_1" - let emptyEndpointFile = "TestConfig_Invalid_2" - // Valid - let emptyUUIDFile = "TestConfig_Invalid_3" - let emptyIDDomainFile = "TestConfig_Invalid_4" - - override func setUpWithError() throws { - } - - override func tearDownWithError() throws { - } - - func test_MBConfiguration_should_not_throw() throws { - try [ - emptyUUIDFile, - emptyIDDomainFile - ].forEach { file in - XCTAssertNoThrow(try MBConfiguration(plistName: file), "") - } - } - - func test_MBConfiguration_should_throw() throws { - try [ - emptyDomainFile, - emptyEndpointFile - ].forEach { file in - XCTAssertThrowsError(try MBConfiguration(plistName: file), "") { error in - if let localizedError = error as? LocalizedError { - XCTAssertNotNil(localizedError.errorDescription) - XCTAssertNotNil(localizedError.failureReason) - } - } - } - - XCTAssertNotNil(try? MBConfiguration(plistName: "TestConfig1")) - XCTAssertNotNil(try? MBConfiguration(plistName: "TestConfig2")) - XCTAssertNotNil(try? MBConfiguration(plistName: "TestConfig3")) - XCTAssertNil(try? MBConfiguration(plistName: "file_that_|never_exist№%:,.;()(;.,:%№")) - } -} diff --git a/MindboxTests/Mock/MockPersistenceStorage.swift b/MindboxTests/Mock/MockPersistenceStorage.swift index 3c3ebfdef..f7e587146 100644 --- a/MindboxTests/Mock/MockPersistenceStorage.swift +++ b/MindboxTests/Mock/MockPersistenceStorage.swift @@ -123,4 +123,10 @@ class MockPersistenceStorage: PersistenceStorage { var applicationInstanceId: String? var webViewLocalStateVersion: Int? + + var operationsDomainFromConfig: String? { + didSet { + onDidChange?() + } + } } diff --git a/MindboxTests/Network/HostNormalizerTests.swift b/MindboxTests/Network/HostNormalizerTests.swift new file mode 100644 index 000000000..a427bf848 --- /dev/null +++ b/MindboxTests/Network/HostNormalizerTests.swift @@ -0,0 +1,77 @@ +// +// HostNormalizerTests.swift +// MindboxTests +// +// Created by Sergei Semko on 4/27/26. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +import Testing +@testable import Mindbox + +@Suite("HostNormalizer") +struct HostNormalizerTests { + + // MARK: - extractHost + + @Test("Bare host is returned unchanged") + func bareHost() { + #expect(HostNormalizer.extractHost("api.mindbox.ru") == "api.mindbox.ru") + } + + @Test("https:// prefix is stripped") + func stripsHttps() { + #expect(HostNormalizer.extractHost("https://api.mindbox.ru") == "api.mindbox.ru") + } + + @Test("http:// prefix is stripped") + func stripsHttp() { + #expect(HostNormalizer.extractHost("http://api.mindbox.ru") == "api.mindbox.ru") + } + + @Test("Scheme stripping is case-insensitive") + func schemeCaseInsensitive() { + #expect(HostNormalizer.extractHost("HTTPS://api.mindbox.ru") == "api.mindbox.ru") + #expect(HostNormalizer.extractHost("HtTp://api.mindbox.ru") == "api.mindbox.ru") + } + + @Test("Trailing slashes are removed") + func stripsTrailingSlashes() { + #expect(HostNormalizer.extractHost("api.mindbox.ru/") == "api.mindbox.ru") + #expect(HostNormalizer.extractHost("api.mindbox.ru///") == "api.mindbox.ru") + } + + @Test("Whitespace is trimmed") + func trimsWhitespace() { + #expect(HostNormalizer.extractHost(" api.mindbox.ru ") == "api.mindbox.ru") + } + + @Test("Combined scheme + trailing slash + whitespace") + func combinedNormalization() { + #expect(HostNormalizer.extractHost(" https://api.mindbox.ru/ ") == "api.mindbox.ru") + } + + // MARK: - toBaseURLString + + @Test("Bare host gets https:// prepended") + func bareHostGetsHttps() { + #expect(HostNormalizer.toBaseURLString("api.mindbox.ru") == "https://api.mindbox.ru") + } + + @Test("https:// is preserved") + func httpsPreserved() { + #expect(HostNormalizer.toBaseURLString("https://api.mindbox.ru") == "https://api.mindbox.ru") + } + + @Test("http:// is preserved") + func httpPreserved() { + #expect(HostNormalizer.toBaseURLString("http://proxy.example.com") == "http://proxy.example.com") + } + + @Test("Trailing slash is stripped from base URL") + func baseURLStripsTrailingSlash() { + #expect(HostNormalizer.toBaseURLString("https://api.mindbox.ru/") == "https://api.mindbox.ru") + #expect(HostNormalizer.toBaseURLString("api.mindbox.ru/") == "https://api.mindbox.ru") + } +} diff --git a/MindboxTests/Network/OperationsURLRoutingTests.swift b/MindboxTests/Network/OperationsURLRoutingTests.swift new file mode 100644 index 000000000..2407c5428 --- /dev/null +++ b/MindboxTests/Network/OperationsURLRoutingTests.swift @@ -0,0 +1,374 @@ +// +// OperationsURLRoutingTests.swift +// MindboxTests +// +// Created by Sergei Semko on 4/27/26. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +import Testing +@testable import Mindbox + +@Suite("Operations URL routing", .tags(.operationsRouting)) +struct OperationsURLRoutingTests { + + private let domain = "api.mindbox.ru" + private let opsHost = "anonymizer-api-regular.client.ru" + + // MARK: - URLRequestBuilder host resolution + + @Test("Event routes use operationsDomain when configured") + func eventRoutesUseOperationsDomain() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: opsHost) + let wrapper = Self.makeEventWrapper(.installed) + + #expect(try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url?.host == opsHost) + #expect(try builder.asURLRequest(route: EventRoute.customAsyncEvent(wrapper)).url?.host == opsHost) + #expect(try builder.asURLRequest(route: EventRoute.trackVisit(wrapper)).url?.host == opsHost) + + let syncWrapper = Self.makeEventWrapper(.syncEvent, bodyJSON: #"{"name":"X","payload":"{}"}"#) + #expect(try builder.asURLRequest(route: EventRoute.syncEvent(syncWrapper)).url?.host == opsHost) + + // SDK logs flow through the same `EventRoute.asyncEvent` (see `MBEventRepository.makeRoute`). + let logsWrapper = Self.makeEventWrapper(.sdkLogs) + #expect(try builder.asURLRequest(route: EventRoute.asyncEvent(logsWrapper)).url?.host == opsHost) + } + + @Test("Config and geo routes always use domain") + func domainRoutesIgnoreOperationsDomain() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: opsHost) + + let geoURL = try builder.asURLRequest(route: FetchInAppGeoRoute()).url + #expect(geoURL?.host == domain) + #expect(geoURL?.path == "/geo") + } + + @Test("No operationsDomain → all routes fall back to domain (backwards compatibility)") + func noOperationsDomainFallsBackToDomain() throws { + let builder = URLRequestBuilder(domain: domain) + let wrapper = Self.makeEventWrapper(.installed) + let logsWrapper = Self.makeEventWrapper(.sdkLogs) + + #expect(try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url?.host == domain) + #expect(try builder.asURLRequest(route: EventRoute.trackVisit(wrapper)).url?.host == domain) + #expect(try builder.asURLRequest(route: EventRoute.asyncEvent(logsWrapper)).url?.host == domain) + #expect(try builder.asURLRequest(route: FetchInAppGeoRoute()).url?.host == domain) + } + + @Test("Path and query parameters survive host swap") + func pathAndQueryUnchangedOnHostSwap() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: opsHost) + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.path == "/v3/operations/async") + #expect(url?.query?.contains("operation=MobilePush.ApplicationInstalled") == true) + #expect(url?.query?.contains("endpointId=test-endpoint") == true) + } + + // MARK: - Scheme handling (host-with-scheme passthrough) + + @Test("Bare host gets default https:// scheme") + func bareHostUsesHttps() throws { + let builder = URLRequestBuilder(domain: "api.mindbox.ru") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.scheme == "https") + #expect(url?.host == "api.mindbox.ru") + } + + @Test("Explicit https:// in domain is preserved") + func explicitHttpsPreserved() throws { + let builder = URLRequestBuilder(domain: "https://api.mindbox.ru") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.scheme == "https") + #expect(url?.host == "api.mindbox.ru") + } + + @Test("Explicit http:// in domain is preserved (proxy/staging case)") + func explicitHttpPreserved() throws { + let builder = URLRequestBuilder(domain: "http://proxy.example.com") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.scheme == "http") + #expect(url?.host == "proxy.example.com") + } + + @Test("Bare operationsDomain gets default https:// scheme") + func bareOperationsDomainUsesHttps() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: "anonymizer.client.ru") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.scheme == "https") + #expect(url?.host == "anonymizer.client.ru") + } + + @Test("Explicit https:// in operationsDomain is preserved") + func explicitHttpsInOperationsDomainPreserved() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: "https://anonymizer.client.ru") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.scheme == "https") + #expect(url?.host == "anonymizer.client.ru") + } + + @Test("Explicit http:// in operationsDomain is preserved (proxy/staging case)") + func explicitHttpInOperationsDomainPreserved() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: "http://anonymizer-staging.client.ru") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.scheme == "http") + #expect(url?.host == "anonymizer-staging.client.ru") + } + + @Test("Trailing slash in domain is stripped before path append") + func trailingSlashInDomainStripped() throws { + let builder = URLRequestBuilder(domain: "api.mindbox.ru/") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.path == "/v3/operations/async") + #expect(url?.host == "api.mindbox.ru") + } + + @Test("Trailing slash in operationsDomain is stripped before path append") + func trailingSlashInOperationsDomainStripped() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: "https://anonymizer.client.ru/") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.scheme == "https") + #expect(url?.host == "anonymizer.client.ru") + #expect(url?.path == "/v3/operations/async") + } + + @Test("Canonical form stored by policy routes correctly end-to-end") + func canonicalStoredFormRoutesCorrectly() throws { + // Mirrors what `OperationsDomainConfigPolicy` writes to PersistenceStorage: + // canonical `scheme://host` form. URLRequestBuilder must accept it as-is. + let canonical = "https://anonymizer-api-regular.client.ru" + let builder = URLRequestBuilder(domain: domain, operationsDomain: canonical) + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.scheme == "https") + #expect(url?.host == "anonymizer-api-regular.client.ru") + #expect(url?.path == "/v3/operations/async") + } + + @Test("Fails fast when base URL is unparseable (no silent relative-URL request)") + func failsFastOnUnparseableBaseURL() { + // Embedded space defeats both `URLComponents(string:)` parsing and + // makes the prior fallback build a bogus relative URL silently. + let builder = URLRequestBuilder(domain: "bad host with spaces") + let wrapper = Self.makeEventWrapper(.installed) + + #expect(throws: URLError.self) { + _ = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)) + } + } + + // MARK: - Rollback signals from JSON config + // + // Happy-path and key/type errors live in `SettingsConfigParsingTests` + // (driven by the canonical `pkl-mobile-config` stubs). The two cases + // below stay here because they exercise the rollback channel that's + // specific to this feature and not modeled in the Pkl error stubs. + + @Test("Settings decodes explicit null as rollback signal", .tags(.decoding)) + func settingsDecodesNullOperationsAsRollback() throws { + let json = """ + { "settings": { "baseAddresses": { "operations": null } } } + """.data(using: .utf8)! + + let response = try JSONDecoder().decode(ConfigResponse.self, from: json) + #expect(response.settings?.baseAddresses != nil) + #expect(response.settings?.baseAddresses?.operations == nil) + } + + @Test("Settings decodes empty string as rollback signal", .tags(.decoding)) + func settingsDecodesEmptyOperationsAsRollback() throws { + let json = """ + { "settings": { "baseAddresses": { "operations": "" } } } + """.data(using: .utf8)! + + let response = try JSONDecoder().decode(ConfigResponse.self, from: json) + #expect(response.settings?.baseAddresses?.operations == "") + } + + // MARK: - Priority resolution (MBNetworkFetcher) + + @Test("Priority — JSON wins when both JSON and init are set") + func priorityJSONWinsOverInit() { + let resolved = MBNetworkFetcher.resolveOperationsDomain( + fromConfigJSON: "json.example.ru", + fromInit: "init.example.ru" + ) + #expect(resolved == "json.example.ru") + } + + @Test("Priority — init used when JSON has nothing") + func priorityInitUsedWhenJSONMissing() { + let resolved = MBNetworkFetcher.resolveOperationsDomain( + fromConfigJSON: nil, + fromInit: "init.example.ru" + ) + #expect(resolved == "init.example.ru") + } + + @Test("Priority — returns nil (→ domain fallback) when neither set") + func priorityNilWhenNeitherSet() { + let resolved = MBNetworkFetcher.resolveOperationsDomain( + fromConfigJSON: nil, + fromInit: nil + ) + #expect(resolved == nil) + } + + @Test("Priority — empty-string JSON treated as no value, falls through to init") + func priorityEmptyStringJSONFallsThrough() { + let resolved = MBNetworkFetcher.resolveOperationsDomain( + fromConfigJSON: "", + fromInit: "init.example.ru" + ) + #expect(resolved == "init.example.ru") + } + + @Test("Priority — empty-string init also treated as no value") + func priorityEmptyStringInitFallsThrough() { + let resolved = MBNetworkFetcher.resolveOperationsDomain( + fromConfigJSON: nil, + fromInit: "" + ) + #expect(resolved == nil) + } + + // MARK: - OperationsDomainConfigPolicy (decides save / clear / keep from JSON) + + @Test("Policy — saves a new valid value when storage is empty") + func policySavesNewValueFromEmpty() { + #expect(OperationsDomainConfigPolicy.action(for: "x.ru", currentlyStored: nil) == .save("https://x.ru")) + } + + @Test("Policy — saves when value changes") + func policySavesOnChange() { + #expect(OperationsDomainConfigPolicy.action(for: "new.ru", currentlyStored: "https://old.ru") == .save("https://new.ru")) + } + + @Test("Policy — keeps when incoming value equals stored") + func policyKeepsOnIdenticalValue() { + #expect(OperationsDomainConfigPolicy.action(for: "https://x.ru", currentlyStored: "https://x.ru") == .keep) + } + + @Test("Policy — clears on null/missing config when something is stored (rollback)") + func policyClearsOnNullWhenStored() { + #expect(OperationsDomainConfigPolicy.action(for: nil, currentlyStored: "https://old.ru") == .clear) + } + + @Test("Policy — clears on empty string when something is stored (rollback)") + func policyClearsOnEmptyWhenStored() { + #expect(OperationsDomainConfigPolicy.action(for: "", currentlyStored: "https://old.ru") == .clear) + } + + @Test("Policy — no-ops when nothing stored and nothing came") + func policyKeepsOnNothingToChange() { + #expect(OperationsDomainConfigPolicy.action(for: nil, currentlyStored: nil) == .keep) + #expect(OperationsDomainConfigPolicy.action(for: "", currentlyStored: nil) == .keep) + } + + @Test("Policy — rejects format-broken incoming value (previous kept intact)") + func policyRejectsInvalidFormat() { + #expect( + OperationsDomainConfigPolicy.action(for: "host with spaces", currentlyStored: "https://good.ru") + == .rejected("host with spaces") + ) + } + + @Test("Policy — does NOT spuriously reject when canonical form matches stored (legacy raw)") + func policyDoesNotRejectOnLegacyRawMatch() { + // Regression: `.keep` was previously logged as "rejected" whenever raw != stored, + // even when raw was valid and just normalized to the stored form. + #expect( + OperationsDomainConfigPolicy.action(for: "x.ru", currentlyStored: "https://x.ru") + == .keep + ) + } + + @Test("Policy — normalizes scheme + trailing slash to canonical form") + func policyNormalizesSchemeAndTrailingSlash() { + #expect( + OperationsDomainConfigPolicy.action( + for: "https://anonymizer-api-regular.client.ru/", + currentlyStored: nil + ) == .save("https://anonymizer-api-regular.client.ru") + ) + } + + @Test("Policy — preserves http scheme from config (does not force https)") + func policyPreservesHttpScheme() { + #expect( + OperationsDomainConfigPolicy.action(for: "http://x.ru/", currentlyStored: nil) + == .save("http://x.ru") + ) + } + + @Test("Policy — keeps when canonical form equals stored despite raw differences") + func policyKeepsWhenCanonicalFormMatches() { + #expect( + OperationsDomainConfigPolicy.action( + for: "https://x.ru/", + currentlyStored: "https://x.ru" + ) == .keep + ) + } + + @Test("Policy — upgrade path: legacy bare-host stored value re-saves once as canonical") + func policyUpgradesLegacyStoredValue() { + #expect( + OperationsDomainConfigPolicy.action(for: "x.ru", currentlyStored: "x.ru") + == .save("https://x.ru") + ) + } + + // MARK: - Persistence lifecycle + + @Test("softReset preserves operationsDomainFromConfig (no PD leak on migration reset)") + func softResetPreservesOperationsDomain() { + let storage = MockPersistenceStorage() + storage.operationsDomainFromConfig = "cached-anonymizer.ru" + storage.configDownloadDate = Date() + + storage.softReset() + + #expect(storage.operationsDomainFromConfig == "cached-anonymizer.ru") + #expect(storage.configDownloadDate == nil) + } + + @Test("reset clears operationsDomainFromConfig (test-only hard reset)") + func hardResetClearsOperationsDomain() { + let storage = MockPersistenceStorage() + storage.operationsDomainFromConfig = "cached.ru" + + storage.reset() + + #expect(storage.operationsDomainFromConfig == nil) + } + + // MARK: - Helpers + + private static func makeEventWrapper( + _ type: Event.Operation, + bodyJSON: String = "{}" + ) -> EventWrapper { + let event = Event(type: type, body: bodyJSON) + return EventWrapper(event: event, endpoint: "test-endpoint", deviceUUID: "F47AC10B-58CC-4372-A567-0E02B2C3D479") + } +} diff --git a/MindboxTests/Validators/URLValidatorTests.swift b/MindboxTests/Validators/URLValidatorTests.swift new file mode 100644 index 000000000..3c6eca02f --- /dev/null +++ b/MindboxTests/Validators/URLValidatorTests.swift @@ -0,0 +1,180 @@ +// +// URLValidatorTests.swift +// MindboxTests +// +// Created by Sergei Semko on 4/27/26. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +import Testing +@testable import Mindbox + +@Suite("URLValidator.isValidHost") +struct URLValidatorTests { + + @Test("Common multi-label hosts pass") + func multiLabelHosts() { + #expect(URLValidator.isValidHost("api.mindbox.ru")) + #expect(URLValidator.isValidHost("anonymizer.client.ru")) + #expect(URLValidator.isValidHost("a.b.c.d.example.com")) + } + + @Test("Modern TLDs pass (no allow-list)") + func modernTLDs() { + #expect(URLValidator.isValidHost("example.app")) + #expect(URLValidator.isValidHost("example.dev")) + #expect(URLValidator.isValidHost("example.io")) + #expect(URLValidator.isValidHost("example.xyz")) + } + + @Test("Single-label host passes (localhost)") + func singleLabelHost() { + #expect(URLValidator.isValidHost("localhost")) + } + + @Test("Valid IPv4 literals pass") + func ipv4Literal() { + #expect(URLValidator.isValidHost("192.168.1.1")) + #expect(URLValidator.isValidHost("10.0.0.1")) + #expect(URLValidator.isValidHost("0.0.0.0")) + #expect(URLValidator.isValidHost("255.255.255.255")) + } + + @Test("IPv4 octet > 255 fails (parity with Android PatternsCompat)") + func ipv4OctetOverflowFails() { + #expect(!URLValidator.isValidHost("999.999.999.999")) + #expect(!URLValidator.isValidHost("256.0.0.0")) + #expect(!URLValidator.isValidHost("192.168.1.256")) + } + + @Test("Three numeric labels are NOT treated as IPv4 — fall through to hostname rules") + func threeNumericLabelsAreHostname() { + // 1.2.3 is not IPv4 (3 labels, not 4) and remains structurally a valid hostname. + #expect(URLValidator.isValidHost("1.2.3")) + } + + @Test("Five numeric labels are NOT treated as IPv4 — fall through to hostname rules") + func fiveNumericLabelsAreHostname() { + // 5 labels of digits are structurally a valid hostname even though not IPv4. + #expect(URLValidator.isValidHost("1.2.3.4.5")) + } + + @Test("Hyphens inside labels pass") + func hyphensInside() { + #expect(URLValidator.isValidHost("host-with-dash.com")) + #expect(URLValidator.isValidHost("a-b-c.example.com")) + } + + @Test("Empty input fails") + func emptyFails() { + #expect(!URLValidator.isValidHost("")) + } + + @Test("Whitespace inside fails") + func whitespaceFails() { + #expect(!URLValidator.isValidHost("api mindbox ru")) + #expect(!URLValidator.isValidHost("\thost\t")) + } + + @Test("Underscore fails (RFC 1123)") + func underscoreFails() { + #expect(!URLValidator.isValidHost("host_name.com")) + } + + @Test("Leading/trailing hyphen fails") + func edgeHyphenFails() { + #expect(!URLValidator.isValidHost("-leading.com")) + #expect(!URLValidator.isValidHost("trailing-.com")) + } + + @Test("Empty labels fail") + func emptyLabelsFail() { + #expect(!URLValidator.isValidHost(".com")) + #expect(!URLValidator.isValidHost("api..mindbox.ru")) + #expect(!URLValidator.isValidHost("api.mindbox.ru.")) + } + + @Test("Embedded scheme fails (caller must strip first)") + func schemeFails() { + #expect(!URLValidator.isValidHost("https://api.mindbox.ru")) + } + + @Test("Path/query in host fails") + func pathFails() { + #expect(!URLValidator.isValidHost("api.mindbox.ru/path")) + #expect(!URLValidator.isValidHost("api.mindbox.ru?q=1")) + } + + @Test("Total length over 253 fails") + func tooLongFails() { + let label = String(repeating: "a", count: 60) // 60 chars per label, well-formed + let host = (1...5).map { _ in label }.joined(separator: ".") // 5*60 + 4 = 304 chars + #expect(!URLValidator.isValidHost(host)) + } + + @Test("63-char label is the max accepted") + func labelLengthBoundary() { + let valid = String(repeating: "a", count: 63) + ".com" + #expect(URLValidator.isValidHost(valid)) + let invalid = String(repeating: "a", count: 64) + ".com" + #expect(!URLValidator.isValidHost(invalid)) + } + + @Test("253-char total length is the max accepted") + func totalLengthBoundary() { + // 4 × 63-char labels + 3 dots = 255 → too long + let label63 = String(repeating: "a", count: 63) + let invalid = (1...4).map { _ in label63 }.joined(separator: ".") + #expect(invalid.count == 255) + #expect(!URLValidator.isValidHost(invalid)) + + // 3 × 63-char labels + 1 × 61-char label + 3 dots = 253 → valid + let label61 = String(repeating: "b", count: 61) + let valid = [label63, label63, label63, label61].joined(separator: ".") + #expect(valid.count == 253) + #expect(URLValidator.isValidHost(valid)) + } + + @Test("Single-character labels pass") + func singleCharLabels() { + #expect(URLValidator.isValidHost("a.b")) + #expect(URLValidator.isValidHost("x.y.z")) + } + + @Test("Mixed-case hosts pass") + func mixedCasePasses() { + #expect(URLValidator.isValidHost("API.Mindbox.RU")) + #expect(URLValidator.isValidHost("LocalHost")) + } + + @Test("Punycode IDN host passes (looks like alnum + hyphen)") + func punycodePasses() { + #expect(URLValidator.isValidHost("xn--80aswg.xn--p1ai")) + } + + @Test("Unicode literal IDN host fails (ASCII-only contract)") + func unicodeLiteralFails() { + #expect(!URLValidator.isValidHost("мойсайт.рф")) + } + + @Test("Special characters fail") + func specialCharsFail() { + #expect(!URLValidator.isValidHost("host!.com")) + #expect(!URLValidator.isValidHost("host*.com")) + #expect(!URLValidator.isValidHost("host%.com")) + #expect(!URLValidator.isValidHost("host:.com")) + #expect(!URLValidator.isValidHost("host@.com")) + } + + @Test("Single label with edge hyphen fails") + func singleLabelEdgeHyphen() { + #expect(!URLValidator.isValidHost("-host")) + #expect(!URLValidator.isValidHost("host-")) + } + + @Test("Single dot fails") + func singleDotFails() { + #expect(!URLValidator.isValidHost(".")) + } +} diff --git a/MindboxTests/Validators/ValidatorsTestCase.swift b/MindboxTests/Validators/ValidatorsTestCase.swift index 429b7306f..b6d2639a9 100644 --- a/MindboxTests/Validators/ValidatorsTestCase.swift +++ b/MindboxTests/Validators/ValidatorsTestCase.swift @@ -9,32 +9,8 @@ import XCTest @testable import Mindbox -// swiftlint:disable line_length - class ValidatorsTestCase: XCTestCase { - func testURLValidator() { - [ - "https://www.google.com/search?rlz=1C5CHFA_enRU848RU848&ei=GMYTYIKCK9SSwPAP8cWjiAM&q=umbrella+it&oq=umbrella+it&gs_lcp=CgZwc3ktYWIQAzIICAAQxwEQrwEyCAgAEMcBEK8BMgIIADICCAAyAggAMggIABDHARCvATICCAA6BQgAELEDOggIABCxAxCDAToICAAQxwEQowI6BggAEAoQAToOCAAQxwEQrwEQChABECo6BAgAEAo6BAgAEB46CgguELEDEEMQkwI6BAgAEEM6BwguELEDEEM6CggAEMcBEK8BEAo6BwgAELEDEAo6CwgAELEDEMcBEKMCOgUILhCxAzoOCAAQsQMQgwEQxwEQowI6CggAEAoQARBDECo6BwgAELEDEENQolhYx7IBYM61AWgJcAB4AIABcIgBqA2SAQQxNi40mAEAoAEBqgEHZ3dzLXdperABAMABAQ&sclient=psy-ab&ved=0ahUKEwiC7tnL28DuAhVUCRAIHfHiCDEQ4dUDCA0&uact=5", - - "http://www.google.com" - ] - .compactMap({ URL(string: $0) }) - .forEach { - XCTAssertTrue(URLValidator(url: $0).evaluate()) - } - - [ - "", - "https://www google com/", - "www.google.com" - ] - .compactMap { URL(string: $0) } - .forEach { - XCTAssertFalse(URLValidator(url: $0).evaluate()) - } - } - func testUDIDValidator() { XCTAssertFalse(UDIDValidator(udid: "00000000-0000-0000-0000-000000000000").evaluate()) XCTAssertFalse(UDIDValidator(udid: "00000000-0000-0000-0000").evaluate())