├── .gitignore ├── .swiftpm └── xcode │ ├── package.xcworkspace │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ └── xcschemes │ └── LetSee.xcscheme ├── .travis.yml ├── .vscode └── launch.json ├── Example └── CatAPIProject │ ├── CatAPIProject.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ └── xcschemes │ │ └── CatAPIProject.xcscheme │ ├── CatAPIProject │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ ├── CatAPIProjectApp.swift │ ├── ContentView.swift │ ├── Mocks │ │ ├── Mocks │ │ │ ├── .ls.global.json │ │ │ ├── Breeds │ │ │ │ ├── success_breedListAll.json │ │ │ │ ├── success_breedSingleItem.json │ │ │ │ └── success_breedTwoItem.json │ │ │ └── General │ │ │ │ └── error_empty.json │ │ └── Scenarios │ │ │ ├── List_Two_Single_Error.plist │ │ │ └── Single_Two_List.plist │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── model │ │ ├── Breed.swift │ │ └── BreedImage.swift │ ├── networking │ │ ├── APIError.swift │ │ ├── APIMockService.swift │ │ ├── APIService.swift │ │ ├── APIServiceProtocol.swift │ │ └── BreedFetcher.swift │ └── view │ │ ├── BreedDetailView.swift │ │ ├── BreedListView.swift │ │ ├── BreedRow.swift │ │ ├── ErrorView.swift │ │ └── LoadingView.swift │ ├── CatAPIProjectTests │ └── CatAPIProjectTests.swift │ └── CatAPIProjectUITests │ ├── CatAPIProjectUITests.swift │ └── CatAPIProjectUITestsLaunchTests.swift ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── LetSee │ ├── Core │ ├── DirectoryProcessor │ │ ├── DefaultMockProcessor.swift │ │ ├── DefaultScenarioProcessor.swift │ │ ├── DirectoryProcessing.swift │ │ ├── DirectoryProcessor.swift │ │ ├── DirectoryRequestPath.swift │ │ ├── FileDirectoryProcessor.swift │ │ ├── FileInformation.swift │ │ ├── FileInformationBasic.swift │ │ ├── FileNameParsing.swift │ │ ├── FileProcessError.swift │ │ ├── JSONFileNameParser.swift │ │ ├── MockDirectoryProcessor.swift │ │ ├── MockFileInformation.swift │ │ ├── MockProcessing.swift │ │ ├── PathConfig.swift │ │ ├── RawDirectoryProcessor.swift │ │ └── ScenarioProcessing.swift │ ├── Interceptor │ │ ├── InterceptorContainer.swift │ │ ├── LetSee+InterceptorExtensions.swift │ │ ├── LetSee+URLProtocol.swift │ │ ├── LetSeeMock.swift │ │ ├── LetSeeMockProviding.swift │ │ ├── LetSeeMockResponse.swift │ │ ├── LetSeeRequest.swift │ │ ├── LetSeeRequestStatus.swift │ │ └── RequestInterceptor.swift │ ├── LetSee+LetSeeProtocol.swift │ ├── LetSee.swift │ └── Services │ │ ├── CategorisedMocks.swift │ │ ├── Configuration.swift │ │ ├── FileToLetSeeMockMapping.swift │ │ ├── KeyValue.swift │ │ ├── LetSee+Interceptor.swift │ │ ├── LetSeeError.swift │ │ ├── LetSeeProtocol.swift │ │ ├── LetSeeUrlRequest.swift │ │ ├── Scenario.swift │ │ ├── SocketEmitableContent.swift │ │ ├── String+Extensions.swift │ │ ├── URLRequest+Extensions.swift │ │ └── Utility.swift │ └── InAppView │ ├── ActivityIndicatorView.swift │ ├── Color+Extensions.swift │ ├── JSONHighlighter.swift │ ├── JsonViewerView.swift │ ├── LetSeeButton.swift │ ├── LetSeeConfigurationKey.swift │ ├── LetSeeRequestListViewModel.swift │ ├── LetSeeScenariosListViewModel.swift │ ├── LetSeeView.swift │ ├── LetSeeWindow.swift │ ├── MocksListView.swift │ ├── MultilineTextView.swift │ ├── RequestsListView.swift │ ├── ScenariosListView.swift │ └── View+Extensions.swift └── Tests └── LetSeeTests ├── DefaultScenarioProcessorTests.swift ├── DirectoryProcessorTests.swift ├── FileDirectoryProcessorTests.swift ├── FileToLetSeeMockMappingTests.swift ├── JSONFileInformation └── JSONFileInformationTests.swift ├── LetSeeTests.swift ├── LetSeeURLProtocolTests.swift ├── MockFileManager.swift ├── MockScenarios ├── HappyFlow.plist └── SuccessfulSinglePayment.plist ├── Mocks ├── .ls.global.json ├── Arrangements │ ├── InnerPath │ │ ├── TheLowestPath │ │ │ └── success_arrangementItemsList.json │ │ └── success_arrangementItemsList.json │ ├── success_arrangementItemsList.json │ └── success_arrangementSingleItem.json ├── FolderWith2Configs │ ├── details │ │ └── error_rejectedPayment.json │ ├── orders │ │ └── error_rejectedPayment.json │ └── success_arrangementSingleItem.json ├── FolderWithConfig │ ├── orders │ │ ├── error_rejectedPayment.json │ │ └── success_validatedPayment.json │ └── success_arrangementSingleItem.json ├── General │ ├── error_401_rejectedPayment.json │ └── error_failed.json └── Payment-orders │ ├── error_100ms_rejectedPaymentHasDelay.json │ ├── error_401_rejectedPayment.json │ └── success_200_200ms_validatedPayment.json ├── RawDirectoryProcessorTests.swift └── ScenarioFileInformationTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/LetSee.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 43 | 49 | 50 | 51 | 57 | 63 | 64 | 65 | 66 | 67 | 72 | 73 | 75 | 81 | 82 | 83 | 84 | 85 | 95 | 96 | 102 | 103 | 109 | 110 | 111 | 112 | 114 | 115 | 118 | 119 | 120 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Let-See/iOS/564323b8d678c7b819e9be6103b35ebfecfa28c1/.travis.yml -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Chrome", 6 | "type": "chrome", 7 | "request": "launch", 8 | "url": "http://localhost:3001", 9 | "webRoot": "${workspaceFolder}/src", 10 | "sourceMapPathOverrides": { 11 | "webpack:///src/*": "${webRoot}/*" 12 | } 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /Example/CatAPIProject/CatAPIProject.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/CatAPIProject/CatAPIProject.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/CatAPIProject/CatAPIProject.xcodeproj/xcshareddata/xcschemes/CatAPIProject.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 43 | 49 | 50 | 51 | 52 | 53 | 63 | 65 | 71 | 72 | 73 | 74 | 80 | 82 | 88 | 89 | 90 | 91 | 93 | 94 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /Example/CatAPIProject/CatAPIProject/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Example/CatAPIProject/CatAPIProject/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Example/CatAPIProject/CatAPIProject/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/CatAPIProject/CatAPIProject/CatAPIProjectApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CatAPIProjectApp.swift 3 | // CatAPIProject 4 | // 5 | // Created by Karin Prater on 20.08.21. 6 | // 7 | 8 | import SwiftUI 9 | import LetSee 10 | import LetSeeInAppView 11 | @main 12 | struct CatAPIProjectApp: App { 13 | @State var letSeeWindow: UIWindow? { 14 | didSet { 15 | LetSee.shared.config(LetSee.Configuration.init(baseURL: URL(string: "https://api.thecatapi.com/")!, 16 | isMockEnabled: false, 17 | shouldCutBaseURLFromURLsTitle: true)) 18 | LetSee.shared.addMocks(from: Bundle.main.bundlePath + "/Mocks/Mocks") 19 | LetSee.shared.addScenarios(from: Bundle.main.bundlePath + "/Mocks/Scenarios") 20 | LetSee.shared.interceptor.liveToServer = { request, completion in 21 | URLSession.shared.dataTask(with: request, completionHandler: { data, res, err in 22 | completion?(res, data, err) 23 | }) 24 | .resume() 25 | } 26 | } 27 | } 28 | var window: UIWindow? { 29 | guard let scene = UIApplication.shared.connectedScenes.first, 30 | let windowSceneDelegate = scene.delegate as? UIWindowSceneDelegate, 31 | let window = windowSceneDelegate.window else { 32 | return nil 33 | } 34 | return window 35 | } 36 | var body: some Scene { 37 | WindowGroup { 38 | ContentView() 39 | .onAppear { 40 | guard let window = window else { 41 | return 42 | } 43 | let letSeeWindow = LetSeeWindow(frame: window.frame) 44 | letSeeWindow.windowScene = window.windowScene 45 | self.letSeeWindow = letSeeWindow 46 | } 47 | } 48 | 49 | } 50 | } 51 | 52 | -------------------------------------------------------------------------------- /Example/CatAPIProject/CatAPIProject/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // CatAPIProject 4 | // 5 | // Created by Karin Prater on 20.08.21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ContentView: View { 11 | @StateObject var breedFetcher = BreedFetcher() 12 | var body: some View { 13 | if breedFetcher.isLoading { 14 | LoadingView() 15 | }else if breedFetcher.errorMessage != nil { 16 | ErrorView(breedFetcher: breedFetcher) 17 | }else { 18 | BreedListView(breeds: breedFetcher.breeds) 19 | .refreshable { 20 | breedFetcher.fetchAllBreeds() 21 | } 22 | } 23 | } 24 | } 25 | 26 | struct ContentView_Previews: PreviewProvider { 27 | static var previews: some View { 28 | ContentView() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Example/CatAPIProject/CatAPIProject/Mocks/Mocks/.ls.global.json: -------------------------------------------------------------------------------- 1 | { 2 | "maps": [ 3 | { 4 | "folder": "/breeds/", 5 | "to": "/v1" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /Example/CatAPIProject/CatAPIProject/Mocks/Mocks/Breeds/success_breedSingleItem.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "weight": { 4 | "imperial": "7 - 10", 5 | "metric": "3 - 5" 6 | }, 7 | "id": "abys", 8 | "name": "Abyssinian", 9 | "cfa_url": "http://cfa.org/Breeds/BreedsAB/Abyssinian.aspx", 10 | "vetstreet_url": "http://www.vetstreet.com/cats/abyssinian", 11 | "vcahospitals_url": "https://vcahospitals.com/know-your-pet/cat-breeds/abyssinian", 12 | "temperament": "Active, Energetic, Independent, Intelligent, Gentle", 13 | "origin": "Egypt", 14 | "country_codes": "EG", 15 | "country_code": "EG", 16 | "description": "The Abyssinian is easy to care for, and a joy to have in your home. They’re affectionate cats and love both people and other animals.", 17 | "life_span": "14 - 15", 18 | "indoor": 0, 19 | "lap": 1, 20 | "alt_names": "", 21 | "adaptability": 5, 22 | "affection_level": 5, 23 | "child_friendly": 3, 24 | "dog_friendly": 4, 25 | "energy_level": 5, 26 | "grooming": 1, 27 | "health_issues": 2, 28 | "intelligence": 5, 29 | "shedding_level": 2, 30 | "social_needs": 5, 31 | "stranger_friendly": 5, 32 | "vocalisation": 1, 33 | "experimental": 0, 34 | "hairless": 0, 35 | "natural": 1, 36 | "rare": 0, 37 | "rex": 0, 38 | "suppressed_tail": 0, 39 | "short_legs": 0, 40 | "wikipedia_url": "https://en.wikipedia.org/wiki/Abyssinian_(cat)", 41 | "hypoallergenic": 0, 42 | "reference_image_id": "0XYvRd7oD" 43 | } 44 | ] 45 | -------------------------------------------------------------------------------- /Example/CatAPIProject/CatAPIProject/Mocks/Mocks/Breeds/success_breedTwoItem.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "weight": { 4 | "imperial": "7 - 10", 5 | "metric": "3 - 5" 6 | }, 7 | "id": "abys", 8 | "name": "Abyssinian", 9 | "cfa_url": "http://cfa.org/Breeds/BreedsAB/Abyssinian.aspx", 10 | "vetstreet_url": "http://www.vetstreet.com/cats/abyssinian", 11 | "vcahospitals_url": "https://vcahospitals.com/know-your-pet/cat-breeds/abyssinian", 12 | "temperament": "Active, Energetic, Independent, Intelligent, Gentle", 13 | "origin": "Egypt", 14 | "country_codes": "EG", 15 | "country_code": "EG", 16 | "description": "The Abyssinian is easy to care for, and a joy to have in your home. They’re affectionate cats and love both people and other animals.", 17 | "life_span": "14 - 15", 18 | "indoor": 0, 19 | "lap": 1, 20 | "alt_names": "", 21 | "adaptability": 5, 22 | "affection_level": 5, 23 | "child_friendly": 3, 24 | "dog_friendly": 4, 25 | "energy_level": 5, 26 | "grooming": 1, 27 | "health_issues": 2, 28 | "intelligence": 5, 29 | "shedding_level": 2, 30 | "social_needs": 5, 31 | "stranger_friendly": 5, 32 | "vocalisation": 1, 33 | "experimental": 0, 34 | "hairless": 0, 35 | "natural": 1, 36 | "rare": 0, 37 | "rex": 0, 38 | "suppressed_tail": 0, 39 | "short_legs": 0, 40 | "wikipedia_url": "https://en.wikipedia.org/wiki/Abyssinian_(cat)", 41 | "hypoallergenic": 0, 42 | "reference_image_id": "0XYvRd7oD" 43 | }, 44 | { 45 | "weight": { 46 | "imperial": "7 - 10", 47 | "metric": "3 - 5" 48 | }, 49 | "id": "aege", 50 | "name": "Aegean", 51 | "vetstreet_url": "http://www.vetstreet.com/cats/aegean-cat", 52 | "temperament": "Affectionate, Social, Intelligent, Playful, Active", 53 | "origin": "Greece", 54 | "country_codes": "GR", 55 | "country_code": "GR", 56 | "description": "Native to the Greek islands known as the Cyclades in the Aegean Sea, these are natural cats, meaning they developed without humans getting involved in their breeding. As a breed, Aegean Cats are rare, although they are numerous on their home islands. They are generally friendly toward people and can be excellent cats for families with children.", 57 | "life_span": "9 - 12", 58 | "indoor": 0, 59 | "alt_names": "", 60 | "adaptability": 5, 61 | "affection_level": 4, 62 | "child_friendly": 4, 63 | "dog_friendly": 4, 64 | "energy_level": 3, 65 | "grooming": 3, 66 | "health_issues": 1, 67 | "intelligence": 3, 68 | "shedding_level": 3, 69 | "social_needs": 4, 70 | "stranger_friendly": 4, 71 | "vocalisation": 3, 72 | "experimental": 0, 73 | "hairless": 0, 74 | "natural": 0, 75 | "rare": 0, 76 | "rex": 0, 77 | "suppressed_tail": 0, 78 | "short_legs": 0, 79 | "wikipedia_url": "https://en.wikipedia.org/wiki/Aegean_cat", 80 | "hypoallergenic": 0, 81 | "reference_image_id": "ozEvzdVM-" 82 | } 83 | ] 84 | -------------------------------------------------------------------------------- /Example/CatAPIProject/CatAPIProject/Mocks/Mocks/General/error_empty.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Let-See/iOS/564323b8d678c7b819e9be6103b35ebfecfa28c1/Example/CatAPIProject/CatAPIProject/Mocks/Mocks/General/error_empty.json -------------------------------------------------------------------------------- /Example/CatAPIProject/CatAPIProject/Mocks/Scenarios/List_Two_Single_Error.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | steps 6 | 7 | 8 | folder 9 | breeds 10 | responseFileName 11 | success_breedListAll.json 12 | 13 | 14 | folder 15 | breeds 16 | responseFileName 17 | success_breedTwoItem.json 18 | 19 | 20 | folder 21 | breeds 22 | responseFileName 23 | success_breedSingleItem.json 24 | 25 | 26 | folder 27 | general 28 | responseFileName 29 | error_empty.json 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /Example/CatAPIProject/CatAPIProject/Mocks/Scenarios/Single_Two_List.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | steps 6 | 7 | 8 | folder 9 | breeds 10 | responseFileName 11 | success_breedSingleItem.json 12 | 13 | 14 | folder 15 | breeds 16 | responseFileName 17 | success_breedTwoItem.json 18 | 19 | 20 | folder 21 | breeds 22 | responseFileName 23 | success_breedListAll.json 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /Example/CatAPIProject/CatAPIProject/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/CatAPIProject/CatAPIProject/model/Breed.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CatBreed.swift 3 | // CatBreed 4 | // 5 | // Created by Karin Prater on 20.08.21. 6 | // 7 | 8 | import Foundation 9 | import LetSee 10 | 11 | /* 12 | [{"weight":{"imperial":"7 - 10","metric":"3 - 5"},"id":"abys","name":"Abyssinian","cfa_url":"http://cfa.org/Breeds/BreedsAB/Abyssinian.aspx","vetstreet_url":"http://www.vetstreet.com/cats/abyssinian","vcahospitals_url":"https://vcahospitals.com/know-your-pet/cat-breeds/abyssinian","temperament":"Active, Energetic, Independent, Intelligent, Gentle","origin":"Egypt","country_codes":"EG","country_code":"EG","description":"The Abyssinian is easy to care for, and a joy to have in your home. They’re affectionate cats and love both people and other animals.","life_span":"14 - 15","indoor":0,"lap":1,"alt_names":"","adaptability":5,"affection_level":5,"child_friendly":3,"dog_friendly":4,"energy_level":5,"grooming":1,"health_issues":2,"intelligence":5,"shedding_level":2,"social_needs":5,"stranger_friendly":5,"vocalisation":1,"experimental":0,"hairless":0,"natural":1,"rare":0,"rex":0,"suppressed_tail":0,"short_legs":0,"wikipedia_url":"https://en.wikipedia.org/wiki/Abyssinian_(cat)","hypoallergenic":0,"reference_image_id":"0XYvRd7oD","image":{"id":"0XYvRd7oD","width":1204,"height":1445,"url":"https://cdn2.thecatapi.com/images/0XYvRd7oD.jpg"}}] 13 | 14 | */ 15 | 16 | 17 | public struct Breed: Codable, CustomStringConvertible, Identifiable { 18 | public let id: String 19 | public let name: String 20 | public let temperament: String 21 | public let breedExplaination: String 22 | public let energyLevel: Int 23 | public let isHairless: Bool 24 | public let image: BreedImage? 25 | 26 | public var description: String { 27 | return "breed with name: \(name) and id \(id), energy level: \(energyLevel) isHairless: \(isHairless ? "YES" : "NO")" 28 | } 29 | 30 | enum CodingKeys: String, CodingKey { 31 | case id 32 | case name 33 | case temperament 34 | case breedExplaination = "description" 35 | case energyLevel = "energy_level" 36 | case isHairless = "hairless" 37 | case image 38 | } 39 | 40 | public init(from decoder: Decoder) throws { 41 | let values = try decoder.container(keyedBy: CodingKeys.self) 42 | 43 | id = try values.decode(String.self, forKey: .id) 44 | name = try values.decode(String.self, forKey: .name) 45 | temperament = try values.decode(String.self, forKey: .temperament) 46 | breedExplaination = try values.decode(String.self, forKey: .breedExplaination) 47 | energyLevel = try values.decode(Int.self, forKey: .energyLevel) 48 | 49 | let hairless = try values.decode(Int.self, forKey: .isHairless) 50 | isHairless = hairless == 1 51 | 52 | image = try values.decodeIfPresent(BreedImage.self, forKey: .image) 53 | } 54 | 55 | init(name: String, id: String, explaination: String, temperament: String, 56 | energyLevel: Int, isHairless: Bool, image: BreedImage?){ 57 | self.name = name 58 | self.id = id 59 | self.breedExplaination = explaination 60 | self.energyLevel = energyLevel 61 | self.temperament = temperament 62 | self.image = image 63 | self.isHairless = isHairless 64 | } 65 | 66 | 67 | static func example1() -> Breed { 68 | return Breed(name: "Abyssinian", 69 | id: "abys", 70 | explaination: "The Abyssinian is easy to care for, and a joy to have in your home. They’re affectionate cats and love both people and other animals.", 71 | temperament: "Active, Energetic, Independent, Intelligent, Gentle", 72 | energyLevel: 5, 73 | isHairless: false, image: BreedImage(height: 100, id: "i", url: "https://cdn2.thecatapi.com/images/unX21IBVB.jpg", width: 100)) 74 | 75 | } 76 | 77 | static func example2() -> Breed { 78 | return Breed(name: "Cyprus", 79 | id: "cypr", 80 | explaination: "Loving, loyal, social and inquisitive, the Cyprus cat forms strong ties with their families and love nothing more than to be involved in everything that goes on in their surroundings. They are not overly active by nature which makes them the perfect companion for people who would like to share their homes with a laid-back relaxed feline companion.", 81 | temperament: "Affectionate, Social", 82 | energyLevel: 4, 83 | isHairless: false, 84 | image: BreedImage(height: 100, id: "i", url: "https://cdn2.thecatapi.com/images/unX21IBVB.jpg", width: 100)) 85 | 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Example/CatAPIProject/CatAPIProject/model/BreedImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BreedImage.swift 3 | // BreedImage 4 | // 5 | // Created by Karin Prater on 20.08.21. 6 | // 7 | 8 | import Foundation 9 | 10 | /* 11 | "image": { 12 | "height": 1445, 13 | "id": "0XYvRd7oD", 14 | "url": "https://cdn2.thecatapi.com/images/0XYvRd7oD.jpg", 15 | "width": 1204 16 | }, 17 | */ 18 | 19 | public struct BreedImage: Codable { 20 | public let height: Int? 21 | public let id: String? 22 | public let url: String? 23 | public let width: Int? 24 | 25 | } 26 | -------------------------------------------------------------------------------- /Example/CatAPIProject/CatAPIProject/networking/APIError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIError.swift 3 | // APIError 4 | // 5 | // Created by Karin Prater on 20.08.21. 6 | // 7 | 8 | import Foundation 9 | 10 | enum APIError: Error, CustomStringConvertible { 11 | case badURL 12 | case badResponse(statusCode: Int) 13 | case url(URLError?) 14 | case parsing(DecodingError?) 15 | case unknown 16 | 17 | var localizedDescription: String { 18 | // user feedback 19 | switch self { 20 | case .badURL, .parsing, .unknown: 21 | return "Sorry, something went wrong." 22 | case .badResponse(_): 23 | return "Sorry, the connection to our server failed." 24 | case .url(let error): 25 | return error?.localizedDescription ?? "Something went wrong." 26 | } 27 | } 28 | 29 | var description: String { 30 | //info for debugging 31 | switch self { 32 | case .unknown: return "unknown error" 33 | case .badURL: return "invalid URL" 34 | case .url(let error): 35 | return error?.localizedDescription ?? "url session error" 36 | case .parsing(let error): 37 | return "parsing error \(error?.localizedDescription ?? "")" 38 | case .badResponse(statusCode: let statusCode): 39 | return "bad response with status code \(statusCode)" 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Example/CatAPIProject/CatAPIProject/networking/APIMockService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIMockService.swift 3 | // APIMockService 4 | // 5 | // Created by Karin Prater on 20.08.21. 6 | // 7 | 8 | import Foundation 9 | 10 | struct APIMockService: APIServiceProtocol { 11 | 12 | var result: Result<[Breed], APIError> 13 | 14 | func fetchBreeds(url: URL?, completion: @escaping (Result<[Breed], APIError>) -> Void) { 15 | completion(result) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Example/CatAPIProject/CatAPIProject/networking/APIService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIService.swift 3 | // APIService 4 | // 5 | // Created by Karin Prater on 20.08.21. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import LetSee 11 | 12 | struct APIService: APIServiceProtocol { 13 | func fetchBreeds(url: URL?, completion: @escaping(Result<[Breed], APIError>) -> Void) { 14 | guard let url = url else { 15 | let error = APIError.badURL 16 | completion(Result.failure(error)) 17 | return 18 | } 19 | let request = URLRequest(url: url) 20 | let completionHandler: ((Data?, URLResponse?, Error?) -> Void) = {(data , response, error) in 21 | if let error = error as? URLError { 22 | completion(Result.failure(APIError.url(error))) 23 | } else if let response = response as? HTTPURLResponse, !(200...299).contains(response.statusCode) { 24 | completion(Result.failure(APIError.badResponse(statusCode: response.statusCode))) 25 | } else if let data = data { 26 | let decoder = JSONDecoder() 27 | do { 28 | let breeds = try decoder.decode([Breed].self, from: data) 29 | completion(Result.success(breeds)) 30 | 31 | }catch { 32 | completion(Result.failure(APIError.parsing(error as? DecodingError))) 33 | } 34 | } 35 | } 36 | 37 | let task: URLSessionDataTask 38 | #if RELEASE 39 | task = URLSession.shared.runDataTask(with: request, completionHandler: completionHandler) 40 | #else 41 | if LetSee.shared.configuration.isMockEnabled { 42 | task = LetSee.shared.runDataTask(using: .shared, with: request, completionHandler: completionHandler) 43 | } else { 44 | task = URLSession.shared.dataTask(with: request, completionHandler: completionHandler) 45 | } 46 | #endif 47 | 48 | task.resume() 49 | } 50 | } 51 | 52 | -------------------------------------------------------------------------------- /Example/CatAPIProject/CatAPIProject/networking/APIServiceProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIServiceProtocol.swift 3 | // APIServiceProtocol 4 | // 5 | // Created by Karin Prater on 20.08.21. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | protocol APIServiceProtocol { 12 | func fetchBreeds(url: URL?, completion: @escaping(Result<[Breed], APIError>) -> Void) 13 | } 14 | -------------------------------------------------------------------------------- /Example/CatAPIProject/CatAPIProject/networking/BreedFetcher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BreedFetcher.swift 3 | // BreedFetcher 4 | // 5 | // Created by Karin Prater on 20.08.21. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | class BreedFetcher: ObservableObject { 12 | 13 | @Published var breeds = [Breed]() 14 | @Published var isLoading: Bool = false 15 | @Published var errorMessage: String? = nil 16 | 17 | let service: APIServiceProtocol 18 | 19 | init(service: APIServiceProtocol = APIService()) { 20 | self.service = service 21 | self.fetchAllBreeds() 22 | } 23 | 24 | func fetchAllBreeds() { 25 | 26 | isLoading = true 27 | errorMessage = nil 28 | 29 | let url = URL(string: "https://api.thecatapi.com/v1/breeds") 30 | service.fetchBreeds(url: url) { [unowned self] result in 31 | 32 | DispatchQueue.main.async { 33 | self.isLoading = false 34 | switch result { 35 | case .failure(let error): 36 | self.errorMessage = error.localizedDescription 37 | // print(error.description) 38 | print(error) 39 | case .success(let breeds): 40 | print("--- sucess with \(breeds.count)") 41 | self.breeds = breeds 42 | } 43 | } 44 | } 45 | 46 | } 47 | 48 | 49 | //MARK: preview helpers 50 | 51 | static func errorState() -> BreedFetcher { 52 | let fetcher = BreedFetcher() 53 | fetcher.errorMessage = APIError.url(URLError.init(.notConnectedToInternet)).localizedDescription 54 | return fetcher 55 | } 56 | 57 | static func successState() -> BreedFetcher { 58 | let fetcher = BreedFetcher() 59 | fetcher.breeds = [Breed.example1(), Breed.example2()] 60 | 61 | return fetcher 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Example/CatAPIProject/CatAPIProject/view/BreedDetailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BreedDetailView.swift 3 | // BreedDetailView 4 | // 5 | // Created by Karin Prater on 20.08.21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BreedDetailView: View { 11 | let breed: Breed 12 | let imageSize: CGFloat = 300 13 | 14 | var body: some View { 15 | ScrollView { 16 | VStack { 17 | if breed.image?.url != nil { 18 | AsyncImage(url: URL(string: breed.image!.url!)) { phase in 19 | if let image = phase.image { 20 | image.resizable() 21 | .scaledToFit() 22 | .frame( height: imageSize) 23 | .clipped() 24 | 25 | } else if phase.error != nil { 26 | 27 | Text(phase.error?.localizedDescription ?? "error") 28 | .foregroundColor(Color.pink) 29 | .frame(width: imageSize, height: imageSize) 30 | } else { 31 | ProgressView() 32 | .frame(width: imageSize, height: imageSize) 33 | } 34 | 35 | } 36 | }else { 37 | Color.gray.frame(height: imageSize) 38 | } 39 | 40 | VStack(alignment: .leading, spacing: 15) { 41 | 42 | Text(breed.name) 43 | .font(.headline) 44 | Text(breed.temperament) 45 | .font(.footnote) 46 | Text(breed.breedExplaination) 47 | if breed.isHairless { 48 | Text("hairless") 49 | } 50 | 51 | HStack { 52 | Text("Energy level") 53 | Spacer() 54 | ForEach(1..<6) { id in 55 | Image(systemName: "star.fill") 56 | .foregroundColor(breed.energyLevel > id ? Color.accentColor : Color.gray ) 57 | } 58 | } 59 | 60 | Spacer() 61 | }.padding() 62 | .navigationBarTitleDisplayMode(.inline) 63 | } 64 | } 65 | } 66 | } 67 | 68 | struct BreedDetailView_Previews: PreviewProvider { 69 | static var previews: some View { 70 | BreedDetailView(breed: Breed.example1()) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Example/CatAPIProject/CatAPIProject/view/BreedListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorView.swift 3 | // ErrorView 4 | // 5 | // Created by Karin Prater on 20.08.21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BreedListView: View { 11 | let breeds: [Breed] 12 | 13 | @State private var searchText: String = "" 14 | 15 | var filteredBreeds: [Breed] { 16 | if searchText.count == 0 { 17 | return breeds 18 | } else { 19 | return breeds.filter { $0.name.lowercased().contains(searchText.lowercased()) 20 | } 21 | } 22 | } 23 | 24 | var body: some View { 25 | NavigationView { 26 | List { 27 | ForEach(filteredBreeds) { breed in 28 | NavigationLink { 29 | BreedDetailView(breed: breed) 30 | } label: { 31 | BreedRow(breed: breed) 32 | } 33 | } 34 | } 35 | .listStyle(PlainListStyle()) 36 | .navigationTitle("Find Your Perfect Cat") 37 | .searchable(text: $searchText) 38 | } 39 | } 40 | } 41 | 42 | struct BreedListView_Previews: PreviewProvider { 43 | static var previews: some View { 44 | BreedListView(breeds: BreedFetcher.successState().breeds) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Example/CatAPIProject/CatAPIProject/view/BreedRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BreedRow.swift 3 | // BreedRow 4 | // 5 | // Created by Karin Prater on 20.08.21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BreedRow: View { 11 | let breed: Breed 12 | let imageSize: CGFloat = 100 13 | var body: some View { 14 | HStack { 15 | 16 | if breed.image?.url != nil { 17 | AsyncImage(url: URL(string: breed.image!.url!)) { phase in 18 | if let image = phase.image { 19 | image.resizable() 20 | .scaledToFill() 21 | .frame(width: imageSize, height: imageSize) 22 | .clipped() 23 | 24 | } else if phase.error != nil { 25 | 26 | Text(phase.error?.localizedDescription ?? "error") 27 | .foregroundColor(Color.pink) 28 | .frame(width: imageSize, height: imageSize) 29 | } else { 30 | ProgressView() 31 | .frame(width: imageSize, height: imageSize) 32 | } 33 | 34 | } 35 | }else { 36 | Color.gray.frame(width: imageSize, height: imageSize) 37 | } 38 | 39 | VStack(alignment: .leading, spacing: 5) { 40 | Text(breed.name) 41 | .font(.headline) 42 | Text(breed.temperament) 43 | } 44 | } 45 | 46 | } 47 | } 48 | 49 | struct BreedRow_Previews: PreviewProvider { 50 | static var previews: some View { 51 | BreedRow(breed: Breed.example1()) 52 | .previewLayout(.fixed(width: 400, height: 200)) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Example/CatAPIProject/CatAPIProject/view/ErrorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorView.swift 3 | // ErrorView 4 | // 5 | // Created by Karin Prater on 20.08.21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ErrorView: View { 11 | @ObservedObject var breedFetcher: BreedFetcher 12 | var body: some View { 13 | VStack { 14 | 15 | Text("😿") 16 | .font(.system(size: 80)) 17 | 18 | Text(breedFetcher.errorMessage ?? "") 19 | 20 | Button { 21 | breedFetcher.fetchAllBreeds() 22 | } label: { 23 | Text("Try again") 24 | } 25 | 26 | 27 | } 28 | } 29 | } 30 | 31 | struct ErrorView_Previews: PreviewProvider { 32 | static var previews: some View { 33 | ErrorView(breedFetcher: BreedFetcher()) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Example/CatAPIProject/CatAPIProject/view/LoadingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoadingView.swift 3 | // LoadingView 4 | // 5 | // Created by Karin Prater on 20.08.21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct LoadingView: View { 11 | var body: some View { 12 | VStack(spacing: 20) { 13 | Text("😸") 14 | .font(.system(size: 80)) 15 | ProgressView() 16 | Text("Getting the cats ...") 17 | .foregroundColor(.gray) 18 | 19 | } 20 | } 21 | } 22 | 23 | struct LoadingView_Previews: PreviewProvider { 24 | static var previews: some View { 25 | LoadingView() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Example/CatAPIProject/CatAPIProjectTests/CatAPIProjectTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CatAPIProjectTests.swift 3 | // CatAPIProjectTests 4 | // 5 | // Created by Karin Prater on 20.08.21. 6 | // 7 | 8 | import XCTest 9 | import Combine 10 | @testable import CatAPIProject 11 | 12 | 13 | class CatAPIProjectTests: XCTestCase { 14 | 15 | override func setUp() { 16 | 17 | } 18 | 19 | override func tearDown() { 20 | subscriptions = [] 21 | } 22 | 23 | var subscriptions = Set() 24 | 25 | func test_getting_breeds_success() { 26 | let result = Result<[Breed], APIError>.success([Breed.example1()]) 27 | 28 | let fetcher = BreedFetcher(service: APIMockService(result: result)) 29 | 30 | let promise = expectation(description: "getting breeds") 31 | 32 | fetcher.$breeds.sink { breeds in 33 | if breeds.count > 0 { 34 | promise.fulfill() 35 | } 36 | }.store(in: &subscriptions) 37 | 38 | 39 | wait(for: [promise], timeout: 2) 40 | } 41 | 42 | 43 | func test_loading_error() { 44 | 45 | let result = Result<[Breed], APIError>.failure(APIError.badURL) 46 | let fetcher = BreedFetcher(service: APIMockService(result: result)) 47 | 48 | let promise = expectation(description: "show error message") 49 | fetcher.$breeds.sink { breeds in 50 | if !breeds.isEmpty { 51 | XCTFail() 52 | } 53 | }.store(in: &subscriptions) 54 | 55 | 56 | fetcher.$errorMessage.sink { message in 57 | if message != nil { 58 | promise.fulfill() 59 | } 60 | }.store(in: &subscriptions) 61 | 62 | wait(for: [promise], timeout: 2) 63 | 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /Example/CatAPIProject/CatAPIProjectUITests/CatAPIProjectUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CatAPIProjectUITests.swift 3 | // CatAPIProjectUITests 4 | // 5 | // Created by Karin Prater on 20.08.21. 6 | // 7 | 8 | import XCTest 9 | 10 | class CatAPIProjectUITests: XCTestCase { 11 | 12 | override func setUpWithError() throws { 13 | // Put setup code here. This method is called before the invocation of each test method in the class. 14 | 15 | // In UI tests it is usually best to stop immediately when a failure occurs. 16 | continueAfterFailure = false 17 | 18 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 19 | } 20 | 21 | override func tearDownWithError() throws { 22 | // Put teardown code here. This method is called after the invocation of each test method in the class. 23 | } 24 | 25 | func testExample() throws { 26 | // UI tests must launch the application that they test. 27 | let app = XCUIApplication() 28 | app.launch() 29 | 30 | // Use recording to get started writing UI tests. 31 | // Use XCTAssert and related functions to verify your tests produce the correct results. 32 | } 33 | 34 | func testLaunchPerformance() throws { 35 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { 36 | // This measures how long it takes to launch your application. 37 | measure(metrics: [XCTApplicationLaunchMetric()]) { 38 | XCUIApplication().launch() 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Example/CatAPIProject/CatAPIProjectUITests/CatAPIProjectUITestsLaunchTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CatAPIProjectUITestsLaunchTests.swift 3 | // CatAPIProjectUITests 4 | // 5 | // Created by Karin Prater on 20.08.21. 6 | // 7 | 8 | import XCTest 9 | 10 | class CatAPIProjectUITestsLaunchTests: XCTestCase { 11 | 12 | override class var runsForEachTargetApplicationUIConfiguration: Bool { 13 | true 14 | } 15 | 16 | override func setUpWithError() throws { 17 | continueAfterFailure = false 18 | } 19 | 20 | func testLaunch() throws { 21 | let app = XCUIApplication() 22 | app.launch() 23 | 24 | // Insert steps here to perform after app launch but before taking a screenshot, 25 | // such as logging into a test account or navigating somewhere in the app 26 | 27 | let attachment = XCTAttachment(screenshot: app.screenshot()) 28 | attachment.name = "Launch Screen" 29 | attachment.lifetime = .keepAlways 30 | add(attachment) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 farshad jahanmanesh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.6 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "LetSee", 8 | platforms: [.iOS(.v14)], 9 | products: [ 10 | // Products define the executables and libraries a package produces, and make them visible to other packages. 11 | .library( 12 | name: "LetSee", 13 | targets: ["LetSee", "LetSeeInAppView"]), 14 | ], 15 | dependencies: [ 16 | // Dependencies declare other packages that this package depends on. 17 | ], 18 | targets: [ 19 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 20 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 21 | .target( 22 | name: "LetSee", 23 | path: "./Sources/LetSee/Core"), 24 | 25 | .target(name: "LetSeeInAppView",dependencies: [.target(name: "LetSee")], path: "./Sources/LetSee/InAppView"), 26 | 27 | .testTarget( 28 | name: "LetSeeTests", 29 | dependencies: ["LetSee", "LetSeeInAppView"], resources: [.copy("Mocks"), .copy("MockScenarios")]), 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/DirectoryProcessor/DefaultMockProcessor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DefaultMockProcessor.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 14/01/2023. 6 | // 7 | 8 | import Foundation 9 | struct DefaultMockProcessor: MockProcessing { 10 | private let _process: (String) throws -> Dictionary 11 | init(directoryProcessor: DS = MockDirectoryProcessor()) 12 | where DS: DirectoryProcessing, DS.Information == Self.Information { 13 | self._process = directoryProcessor.process 14 | } 15 | func process(_ path: String) throws -> Dictionary { 16 | try _process(path) 17 | } 18 | 19 | func buildMocks(_ path: String) throws -> Dictionary> { 20 | try self.process(path) 21 | .reduce(into: Dictionary>(), { partialResult, item in 22 | let mocks: [LetSeeMock] = item.value.compactMap { mockFile -> LetSeeMock? in 23 | guard let jsonData = try? Data(contentsOf: mockFile.fileInformation.filePath) else {return nil} 24 | if mockFile.status == .success { 25 | return .success(name: mockFile.displayName, response: .init(stateCode: mockFile.statusCode ?? 200, header: [:]), data: jsonData) 26 | } else { 27 | return .failure(name: mockFile.displayName, response: .init(rawValue: mockFile.statusCode ?? 400), data: jsonData) 28 | } 29 | } 30 | partialResult.updateValue(Set(mocks), forKey: item.key.relativePath) 31 | }) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/DirectoryProcessor/DefaultScenarioProcessor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DefaultScenarioProcessor.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 14/01/2023. 6 | // 7 | 8 | import Foundation 9 | struct DefaultScenarioProcessor: ScenarioProcessing { 10 | func buildScenarios(for path: String, 11 | requestToMockMapper: (String) -> CategorisedMocks?, 12 | globalConfigs: GlobalMockDirectoryConfig?) throws -> [Scenario] { 13 | return try self.process(path) 14 | .flatMap({$0.value}) 15 | .reduce(into: []) { partialResult, scenarioFile in 16 | guard let scenarioData = try? Data(contentsOf: scenarioFile.filePath), 17 | let scenarioFileInformation: ScenarioFileInformation = try? PropertyListDecoder().decode(ScenarioFileInformation.self, from: scenarioData) 18 | else { 19 | return 20 | } 21 | var scenarioMocks: [LetSeeMock] = [] 22 | scenarioFileInformation.steps.forEach { item in 23 | let overriddenPath = globalConfigs?.hasMap(for: item.folder)?.to 24 | let mockKey = overriddenPath != nil ? overriddenPath! + item.folder : item.folder 25 | guard let cleanedName = try? mockFileNameParse.parse(.init(name: item.responseFileName, filePath: URL(string: "/api/")!, relativePath: "")), 26 | let mocks = requestToMockMapper(mockKey), 27 | let mock = mocks.mocks.first(where: {$0.name.caseInsensitiveCompare(cleanedName.displayName) == .orderedSame}) 28 | else { 29 | return 30 | } 31 | // append the mock 32 | scenarioMocks.append(mock) 33 | } 34 | let name = scenarioFile.name.replacingOccurrences(of: ".plist", with: "") 35 | partialResult.append(Scenario(name: name, mocks: scenarioMocks)) 36 | } 37 | 38 | } 39 | 40 | private let _process: (String) throws -> Dictionary 41 | private let scenarioDecoder: PropertyListDecoder 42 | private let mockFileNameParse: FileNameParsing 43 | init(directoryProcessor: DS = FileDirectoryProcessor(), 44 | scenarioDecoder: PropertyListDecoder = PropertyListDecoder(), 45 | mockFileNameParse: FileNameParsing = JSONFileNameParser() 46 | ) 47 | 48 | where DS: DirectoryProcessing, DS.Information == Self.Information { 49 | self._process = directoryProcessor.process 50 | self.scenarioDecoder = scenarioDecoder 51 | self.mockFileNameParse = mockFileNameParse 52 | } 53 | 54 | func process(_ path: String) throws -> Dictionary { 55 | try _process(path) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/DirectoryProcessor/DirectoryProcessing.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DirectoryProcessing.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 14/01/2023. 6 | // 7 | 8 | import Foundation 9 | public protocol DirectoryProcessing { 10 | associatedtype Information: FileInformationBasic, Comparable 11 | /// Analysed the directory and sub directories and creates a dictionary of them 12 | func process(_ path: String) throws -> Dictionary 13 | } 14 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/DirectoryProcessor/DirectoryProcessor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DirectoryProcessor.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 12/01/2023. 6 | // 7 | 8 | import Foundation 9 | protocol DirectoryProcessor { 10 | /// Analysed the directory and sub directories and creates a dictionary of them 11 | func process() throws -> Dictionary 12 | } 13 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/DirectoryProcessor/DirectoryRequestPath.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DirectoryRequestPath.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 13/01/2023. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct DirectoryRequestPath: Hashable, Comparable { 11 | public static func < (lhs: DirectoryRequestPath, rhs: DirectoryRequestPath) -> Bool { 12 | lhs.path.absoluteString < rhs.path.absoluteString 13 | } 14 | 15 | let path: URL 16 | let relativePath: String 17 | } 18 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/DirectoryProcessor/FileDirectoryProcessor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileDirectoryProcessor.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 14/01/2023. 6 | // 7 | 8 | import Foundation 9 | struct FileDirectoryProcessor: DirectoryProcessing { 10 | typealias Information = FileInformation 11 | let rawFileProcessor: any DirectoryProcessing 12 | init(rawFileProcessor: any DirectoryProcessing = RawDirectoryProcessor()) { 13 | self.rawFileProcessor = rawFileProcessor 14 | } 15 | 16 | func process(_ path: String) throws -> Dictionary { 17 | let mocksTopDirectory = path 18 | var partialResult: Dictionary = [:] 19 | guard let rawFiles = try? rawFileProcessor.process(path), !rawFiles.isEmpty else { 20 | return partialResult 21 | } 22 | 23 | var orderedItem = rawFiles.keys 24 | .sorted(by: {$0 < $1}) 25 | let globalConfigs = GlobalMockDirectoryConfig.isExists(in: orderedItem.first!.path) 26 | 27 | // if globalConfigs is available it means that this folder should be the main folder and no file should be inside it, 28 | // so we can remove it from the results 29 | if globalConfigs != nil { 30 | orderedItem.removeFirst() 31 | } 32 | 33 | orderedItem.forEach { key in 34 | let directoryPath = key 35 | guard let filesInsideDirectory = rawFiles[key] else { 36 | return 37 | } 38 | let relativePath = self.makeRelativePath(for: directoryPath.path, relativeTo: mocksTopDirectory) 39 | let overriddenPath: String? = globalConfigs?.hasMap(for: relativePath)?.to 40 | let fileInformations: [FileInformation] = filesInsideDirectory.map { file in 41 | let relativePath = self.makeRelativePath(for: file.url, relativeTo: mocksTopDirectory) 42 | return .init(name: file.url.lastPathComponent, filePath: file.url, relativePath: relativePath) 43 | } 44 | partialResult.updateValue(fileInformations, forKey: DirectoryRequestPath(path: directoryPath.path, relativePath: overriddenPath == nil ? relativePath : overriddenPath! + relativePath)) 45 | } 46 | return partialResult 47 | } 48 | 49 | func makeRelativePath(for path: URL, relativeTo: String) -> String { 50 | var result = path.absoluteString 51 | if path.isFileURL { 52 | result.removeFirst(7) 53 | } 54 | return result.replacingOccurrences(of: relativeTo, with: "") 55 | .lowercased() 56 | } 57 | 58 | func parseConfigFile(_ configsPath: URL) -> PathConfig? { 59 | guard let jsonData = try? Data(contentsOf: configsPath), 60 | let configs = try? JSONDecoder().decode(PathConfig.self, from: jsonData) else { 61 | return nil 62 | } 63 | return configs 64 | } 65 | } 66 | 67 | public struct GlobalMockDirectoryConfig: Decodable, Equatable { 68 | struct Map: Decodable, Equatable { 69 | let folder: String 70 | let to: String 71 | init(folder: String, to: String) { 72 | self.folder = folder.lowercased() 73 | self.to = to.lowercased() 74 | } 75 | init(from decoder: Decoder) throws { 76 | let container: KeyedDecodingContainer = try decoder.container(keyedBy: Self.CodingKeys.self) 77 | self.folder = try container.decode(String.self, forKey: Self.CodingKeys.folder).lowercased() 78 | self.to = try container.decode(String.self, forKey: Self.CodingKeys.to).lowercased() 79 | } 80 | 81 | public enum CodingKeys: CodingKey { 82 | case folder 83 | case to 84 | } 85 | } 86 | 87 | let maps: [Map] 88 | enum CodingKeys: CodingKey { 89 | case maps 90 | } 91 | 92 | public init(from decoder: Decoder) throws { 93 | let container = try decoder.container(keyedBy: CodingKeys.self) 94 | self.maps = try container.decode([GlobalMockDirectoryConfig.Map].self, forKey: .maps).sorted(by: {$0.folder > $1.folder}) 95 | } 96 | 97 | func hasMap(for relativePath: String) -> Map? { 98 | self.maps.first(where:{ relativePath.hasPrefix($0.folder)}) 99 | } 100 | } 101 | 102 | extension GlobalMockDirectoryConfig { 103 | static let globalConfigFileName = ".ls.global.json" 104 | static func isExists(in path: URL) -> Self? { 105 | guard let data = try? Data(contentsOf: path.appendingPathComponent(GlobalMockDirectoryConfig.globalConfigFileName)) else {return nil} 106 | return try? JSONDecoder().decode(GlobalMockDirectoryConfig.self, from: data) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/DirectoryProcessor/FileInformation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileInformation.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 14/01/2023. 6 | // 7 | 8 | import Foundation 9 | public struct FileInformation: Equatable, Comparable, FileInformationBasic { 10 | public var url: URL { 11 | self.filePath 12 | } 13 | 14 | public static func < (lhs: FileInformation, rhs: FileInformation) -> Bool { 15 | lhs.filePath.absoluteString < rhs.filePath.absoluteString 16 | } 17 | 18 | /// File name 19 | public let name: String 20 | /// Original path 21 | public let filePath: URL 22 | /// Relative to the top mock folder 23 | public let relativePath: String 24 | } 25 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/DirectoryProcessor/FileInformationBasic.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileInformationBasic.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 14/01/2023. 6 | // 7 | 8 | import Foundation 9 | public protocol FileInformationBasic { 10 | var url: URL {get} 11 | } 12 | 13 | struct FileURL: Comparable, FileInformationBasic { 14 | var url: URL 15 | init(url: URL) { 16 | self.url = url 17 | } 18 | 19 | static func < (lhs: Self, rhs: Self) -> Bool { 20 | lhs.url.absoluteString < rhs.url.absoluteString 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/DirectoryProcessor/FileNameParsing.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileNameParsing.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 11/01/2023. 6 | // 7 | 8 | import Foundation 9 | public protocol FileNameParsing { 10 | func parse(_ filePath: FileInformation) throws -> MockFileInformation 11 | } 12 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/DirectoryProcessor/FileProcessError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileProcessError.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 14/01/2023. 6 | // 7 | 8 | import Foundation 9 | enum FileProcessError: Error { 10 | case fileNameIsNotValid 11 | } 12 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/DirectoryProcessor/JSONFileNameParser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JSONFileNameParser.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 14/01/2023. 6 | // 7 | 8 | import Foundation 9 | struct JSONFileNameParser: FileNameParsing { 10 | func parse(_ file: FileInformation) throws -> MockFileInformation { 11 | let components = file.name.components(separatedBy: "_") 12 | guard components.count >= 2 else { 13 | throw FileProcessError.fileNameIsNotValid 14 | } 15 | let firstComponentIndex = 0 16 | let lastComponentIndex = components.count - 1 17 | let fileName = components.last!.components(separatedBy: ".") 18 | let name = fileName.first!.capitalizingFirstLetter() 19 | let type = components.first! 20 | 21 | var statusCode: Int? 22 | var delay: Double? 23 | 24 | if components.count > 2 { 25 | let fileNameComponents = Array(components[firstComponentIndex+1.. Double? = { delay in 27 | return Double(delay.dropLast(2)) 28 | } 29 | 30 | if let status = fileNameComponents[exist: 0]{ 31 | if status.hasSuffix("ms") { 32 | delay = parseDelay(status) 33 | } else { 34 | statusCode = Int(fileNameComponents[exist: 0] ?? "") 35 | } 36 | } 37 | 38 | if let delayString = fileNameComponents[exist: 1] { 39 | delay = parseDelay(delayString) 40 | } 41 | } 42 | let fileInformation = MockFileInformation(fileInformation: file, 43 | statusCode: statusCode, 44 | delay: delay, 45 | status: type == "error" ? .failure : .success, 46 | displayName: name) 47 | return fileInformation 48 | } 49 | } 50 | 51 | fileprivate extension Collection where Indices.Iterator.Element == Index { 52 | subscript (exist index: Index) -> Iterator.Element? { 53 | return indices.contains(index) ? self[index] : nil 54 | } 55 | } 56 | 57 | fileprivate extension String { 58 | func capitalizingFirstLetter() -> String { 59 | return prefix(1).uppercased() + self.dropFirst() 60 | } 61 | 62 | mutating func capitalizeFirstLetter() { 63 | self = self.capitalizingFirstLetter() 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/DirectoryProcessor/MockDirectoryProcessor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockDirectoryProcessor.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 13/01/2023. 6 | // 7 | 8 | import Foundation 9 | 10 | struct MockDirectoryProcessor: DirectoryProcessing where DS: DirectoryProcessing, DS.Information == FileInformation { 11 | typealias Information = MockFileInformation 12 | let fileProcessor: DS 13 | let fileNameParser: FileNameParsing 14 | init(fileProcessor: DS = FileDirectoryProcessor(), 15 | fileNameParser: FileNameParsing = JSONFileNameParser()) { 16 | self.fileProcessor = fileProcessor 17 | self.fileNameParser = fileNameParser 18 | } 19 | 20 | func process(_ path: String) throws -> Dictionary { 21 | guard let files = try? self.fileProcessor.process(path) else { 22 | return [:] 23 | } 24 | return files.reduce(into: [:]) { partialResult, item in 25 | let mocks = item.value.compactMap { file in 26 | try? self.fileNameParser.parse(file) 27 | } 28 | partialResult.updateValue(mocks, forKey: item.key) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/DirectoryProcessor/MockFileInformation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockFileInformation.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 14/01/2023. 6 | // 7 | 8 | import Foundation 9 | public struct MockFileInformation: Equatable, Comparable, FileInformationBasic { 10 | public static func < (lhs: MockFileInformation, rhs: MockFileInformation) -> Bool { 11 | lhs.fileInformation < rhs.fileInformation 12 | } 13 | 14 | public var url: URL { 15 | fileInformation.url 16 | } 17 | 18 | public enum MockStatus { 19 | case success 20 | case failure 21 | } 22 | /// Information about the raw file 23 | let fileInformation: FileInformation 24 | /// Status code 25 | let statusCode: Int? 26 | /// Optional delay option, it specifies a delay in millisecond 27 | let delay: TimeInterval? 28 | /// Status: the `statusCode` has a higher priority so if `statusCode` is not nil, the value of this variable will be sets based on that, **200-299 indicates success status, and any thing else means failure** 29 | let status: MockStatus? 30 | 31 | let displayName: String 32 | 33 | public init(fileInformation: FileInformation, statusCode: Int?, delay: TimeInterval?, status: MockStatus?, displayName: String) { 34 | self.fileInformation = fileInformation 35 | self.statusCode = statusCode 36 | self.delay = delay 37 | self.status = status 38 | self.displayName = displayName 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/DirectoryProcessor/MockProcessing.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockProcessing.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 14/01/2023. 6 | // 7 | 8 | import Foundation 9 | public protocol MockProcessing: DirectoryProcessing where Information == MockFileInformation { 10 | func process(_ path: String) throws -> Dictionary 11 | func buildMocks(_ path: String) throws -> Dictionary> 12 | } 13 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/DirectoryProcessor/PathConfig.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PathConfig.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 14/01/2023. 6 | // 7 | 8 | import Foundation 9 | struct PathConfig: Decodable { 10 | let path: String 11 | } 12 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/DirectoryProcessor/RawDirectoryProcessor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RawDirectoryProcessor.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 14/01/2023. 6 | // 7 | 8 | import Foundation 9 | struct RawDirectoryProcessor: DirectoryProcessing { 10 | typealias Information = FileURL 11 | let fileManager: FileManager 12 | 13 | init(fileManager: FileManager = .default) { 14 | self.fileManager = fileManager 15 | } 16 | 17 | func process(_ path: String) throws -> Dictionary { 18 | let url = URL(fileURLWithPath: path) 19 | var files = Dictionary() 20 | if let enumerator = fileManager.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey, .parentDirectoryURLKey], options: [ .skipsPackageDescendants]) { 21 | for case let fileURL as URL in enumerator { 22 | do { 23 | let fileAttributes = try fileURL.resourceValues(forKeys:[.isRegularFileKey, .parentDirectoryURLKey]) 24 | if fileAttributes.isRegularFile!, let parentDirectory = fileAttributes.parentDirectory { 25 | let directory = DirectoryRequestPath(path: parentDirectory, relativePath: "") 26 | let fileURLs = (files[directory] ?? []) + [FileURL(url: fileURL) ] 27 | files.updateValue(fileURLs , forKey: directory) 28 | } 29 | } catch { print(error, fileURL) } 30 | } 31 | } 32 | return files 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/DirectoryProcessor/ScenarioProcessing.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScenarioProcessing.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 14/01/2023. 6 | // 7 | 8 | import Foundation 9 | public protocol ScenarioProcessing: DirectoryProcessing where Information == FileInformation { 10 | func process(_ path: String) throws -> Dictionary 11 | func buildScenarios(for path: String, 12 | requestToMockMapper: (String) -> CategorisedMocks?, 13 | globalConfigs: GlobalMockDirectoryConfig?) throws -> [Scenario] 14 | } 15 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/Interceptor/InterceptorContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InterceptorContainer.swift 3 | // 4 | // 5 | // Created by Farshad Macbook M1 Pro on 7/25/22. 6 | // 7 | 8 | import Foundation 9 | protocol InterceptorContainer { 10 | func addLetSeeProtocol(to config : URLSessionConfiguration) -> URLSessionConfiguration 11 | var interceptor: LetSeeInterceptor {get} 12 | var sessionConfiguration: URLSessionConfiguration {get} 13 | } 14 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/Interceptor/LetSee+InterceptorExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LetSee+InterceptorExtensions.swift 3 | // 4 | // 5 | // Created by Farshad Macbook M1 Pro on 5/4/22. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | /// This is the main class that manages the request interception and response handling in the 12 | public final class LetSeeInterceptor: ObservableObject { 13 | 14 | /// A closure that is called whenever a request is added to the interceptor. 15 | public var onRequestAdded: ((URLRequest)-> Void)? = nil 16 | 17 | /// A closure that is called whenever a request is removed from the interceptor. 18 | public var onRequestRemoved: ((URLRequest)-> Void)? = nil 19 | 20 | /// The currently active scenario. 21 | @Published private(set) public var _scenario: Scenario? 22 | 23 | /// liveToServer is a function that is used to send a request to the server and retrieve a response. It takes a URLRequest as input and a completion block as an optional parameter. 24 | /// The completion block takes three parameters: Data?, URLResponse?, and Error?. The function returns void. 25 | public var liveToServer: ((_ request: URLRequest, _ completion: ((URLResponse?, Data?, Error?) -> Void)?) -> Void)? 26 | 27 | /// The queue of requests that have been intercepted. 28 | @Published private(set) public var _requestQueue: [LetSeeUrlRequest] = [] 29 | 30 | /// The configurations for the `LetSee` instance. 31 | @Published var configurations: LetSee.Configuration = .default 32 | } 33 | 34 | extension LetSeeInterceptor: RequestInterceptor { 35 | /** 36 | The `scenario` property gets the current scenario in use, if there is no scenario active it returns `nil`. 37 | */ 38 | public var scenario: Published.Publisher { 39 | self.$_scenario 40 | } 41 | 42 | /** 43 | The `isScenarioActive` property gets a boolean value indicating whether there is a scenario active or not. 44 | */ 45 | public var isScenarioActive: Bool { 46 | _scenario?.currentStep != nil 47 | } 48 | 49 | /** 50 | The `activateScenario(_:)` function activates a scenario. 51 | 52 | - Parameters: 53 | - scenario: The scenario to be activated. 54 | */ 55 | public func activateScenario(_ scenario: Scenario) { 56 | self._scenario = scenario 57 | } 58 | 59 | /** 60 | The `deactivateScenario()` function deactivates the current scenario. 61 | */ 62 | public func deactivateScenario() { 63 | self._scenario = nil 64 | } 65 | 66 | /** 67 | The `indexOf(request:)` function gets the index of a request in the queue. 68 | 69 | - Parameters: 70 | - request: The request to search for. 71 | 72 | - Returns: The index of the request in the queue if it exists, otherwise `nil`. 73 | */ 74 | public func indexOf(request: URLRequest) -> Int? { 75 | self._requestQueue.firstIndex(where: {$0.request.url == request.url}) 76 | } 77 | 78 | /** 79 | The `requestQueue` property gets the current queue of requests. 80 | */ 81 | public var requestQueue: Published<[LetSeeUrlRequest]>.Publisher { 82 | self.$_requestQueue 83 | } 84 | 85 | /** 86 | The `respondUsingScenario(request:)` function responds to a request using the current scenario. 87 | 88 | - Parameters: 89 | - request: The request to be responded to. 90 | */ 91 | private func respondUsingScenario(request: URLRequest) { 92 | guard let mock = _scenario?.currentStep else { 93 | return 94 | } 95 | respond(request: request, with: mock) 96 | _scenario?.nextStep() 97 | if _scenario?.currentStep == nil { 98 | _scenario = nil 99 | } 100 | } 101 | 102 | /** 103 | Intercepts a given URL request and stores it in a queue for later processing. 104 | 105 | - Parameters: 106 | - request: The URL request to be intercepted. 107 | - mocks: An optional categorised list of mock data that can be used to respond to the request. If no mocks are provided, a default set of mock responses will be available (e.g. live, cancel, success, failure). 108 | */ 109 | public func intercept(request: URLRequest, availableMocks mocks: CategorisedMocks?) { 110 | let mocks = appendSystemMocks(mocks) 111 | onRequestAdded?(request) 112 | self._requestQueue.append(.init(request: request, mocks: mocks,status: .idle)) 113 | } 114 | 115 | /** 116 | Appends a default set of mock responses to the provided list of mocks. 117 | 118 | - Parameters: 119 | - mocks: An optional categorised list of mock data. 120 | 121 | - Returns: An array of categorised mocks, containing the provided mocks and the default set of mock responses. 122 | */ 123 | private func appendSystemMocks(_ mocks: CategorisedMocks?) -> Array { 124 | let generalMocks = CategorisedMocks(category: .general, 125 | mocks: [.live, 126 | .cancel, 127 | .defaultSuccess(name: "Custom Success", data: "{}".data(using: .utf8)!), 128 | .defaultFailure(name: "Custom Failure", data: "{}".data(using: .utf8)!)]) 129 | if let mocks { 130 | return [mocks, generalMocks] 131 | } else { 132 | return [generalMocks] 133 | } 134 | } 135 | 136 | /** 137 | Prepares a request in the queue for later processing. 138 | 139 | - Parameters: 140 | - request: The URL request to be prepared. 141 | - resultHandler: An optional closure that will be called when the request is responded to, with the result of the response. 142 | */ 143 | public func prepare(request: URLRequest, resultHandler: ((Result)->Void)?) { 144 | guard let index = self.indexOf(request: request) else { 145 | return 146 | } 147 | var item = self._requestQueue[index] 148 | item.response = resultHandler 149 | self._requestQueue[index] = item 150 | if self.isScenarioActive { 151 | respondUsingScenario(request: request) 152 | return 153 | } 154 | } 155 | 156 | /** 157 | Responds to a request in the queue with the given mock response. 158 | 159 | - Parameters: 160 | - request: The URL request to be responded to. 161 | - response: The mock response to use for the request. 162 | */ 163 | public func respond(request: URLRequest, with response: LetSeeMock) { 164 | switch response { 165 | case .failure(_, let error, let json): 166 | self.respond(request: request, with: .failure(LetSeeError(error: error, data: json))) 167 | case .error(_, let error): 168 | self.respond(request: request, with: .failure(LetSeeError(error: error, data: nil))) 169 | case .success(_, let res, let jSON): 170 | self.respond(request: request, with: .success((HTTPURLResponse(url: URL(string: "www.letsee.com")!, statusCode: res?.stateCode ?? 200, httpVersion: nil, headerFields: res?.header), jSON))) 171 | case .live: 172 | self.respond(request: request) 173 | case .cancel: 174 | self.cancel(request: request) 175 | } 176 | } 177 | 178 | /** 179 | Responds to a request in the queue with the given result. 180 | 181 | - Parameters: 182 | - request: The URL request to be responded to. 183 | - result: The result of the response, either a success or an error. 184 | */ 185 | public func respond(request: URLRequest, with result: Result) { 186 | guard let index = self.indexOf(request: request) else { 187 | return 188 | } 189 | self.update(request: request, status: .loading) 190 | self._requestQueue[index].response?(result) 191 | } 192 | 193 | /** 194 | Updates the status of a given request in the request queue. 195 | 196 | - Parameters: 197 | - request: The request whose status needs to be updated. 198 | - status: The new status of the request. 199 | */ 200 | public func update(request: URLRequest, status: LetSeeRequestStatus) { 201 | guard let index = self.indexOf(request: request) else { 202 | return 203 | } 204 | var item = self._requestQueue[index] 205 | item.status = status 206 | self._requestQueue[index] = item 207 | } 208 | 209 | /** 210 | Makes a request live by sending it to the server. 211 | 212 | - Parameters: 213 | - request: The request that needs to be made live. 214 | */ 215 | public func respond(request: URLRequest) { 216 | guard self.indexOf(request: request) != nil else { 217 | return 218 | } 219 | self.update(request: request, status: .loading) 220 | liveToServer?(request){[weak self] response, data, error in 221 | guard let self = self else {return} 222 | 223 | guard let index = self.indexOf(request: request) else { 224 | return 225 | } 226 | guard error == nil else { 227 | self.respond(request: self._requestQueue[index].request, with: .failure(.init(error: error!, data: data))) 228 | return 229 | } 230 | 231 | self._requestQueue[index].response?(.success((response, data))) 232 | } 233 | } 234 | 235 | /** 236 | Cancels a request. 237 | 238 | - Parameters: 239 | - request: The request that needs to be cancelled. 240 | */ 241 | public func cancel(request: URLRequest) { 242 | guard let index = self.indexOf(request: request) else { 243 | return 244 | } 245 | self.update(request: request, status: .loading) 246 | self._requestQueue[index].response?(.failure(LetSeeError(error: URLError.cancelled, data: nil))) 247 | } 248 | 249 | /** 250 | Removes a request from the request queue. 251 | 252 | - Parameters: 253 | - request: The request that needs to be removed. 254 | */ 255 | public func finish(request: URLRequest) { 256 | guard let index = self.indexOf(request: request) else { 257 | return 258 | } 259 | self._requestQueue.remove(at: index) 260 | onRequestRemoved?(request) 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/Interceptor/LetSee+URLProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LetSee+URLProtocol.swift 3 | // 4 | // 5 | // Created by Farshad Macbook M1 Pro on 5/1/22. 6 | // 7 | 8 | import Foundation 9 | /** 10 | A custom URL protocol for intercepting network requests and responses and applying mock scenarios. 11 | */ 12 | public final class LetSeeURLProtocol: URLProtocol { 13 | 14 | // MARK: - Properties 15 | 16 | /// The `RequestInterceptor` object used to intercept network requests and responses. 17 | public static unowned var letSee: RequestInterceptor! 18 | 19 | // MARK: - URLProtocol 20 | 21 | /** 22 | Returns the canonical version of the specified request. 23 | 24 | - Parameters: 25 | - request: The request to canonicalize. 26 | - Returns: The canonical version of the request. 27 | */ 28 | public override class func canonicalRequest(for request: URLRequest) -> URLRequest { 29 | var _request = request 30 | // Set the timeout interval to a large value 31 | _request.timeoutInterval = 3600 32 | return _request 33 | } 34 | 35 | /** 36 | Determines whether the URL protocol can handle the specified request. 37 | 38 | - Parameters: 39 | - request: The request to handle. 40 | - Returns: `true` if the URL protocol can handle the request, `false` otherwise. 41 | */ 42 | public override class func canInit(with request: URLRequest) -> Bool { 43 | // Return whether mock is enabled in the configuration 44 | LetSee.shared.configuration.isMockEnabled 45 | } 46 | 47 | /** 48 | Starts loading the request. 49 | */ 50 | public override func startLoading() { 51 | let client = self.client 52 | // Prepare the request using the RequestInterceptor 53 | Self.letSee.prepare(request: self.request, resultHandler: {[weak self] result in 54 | guard let self = self else {return} 55 | 56 | // Handle the result of the request preparation 57 | switch result { 58 | case .success((let response, let data)): 59 | 60 | // If the request was successful, send the response and data to the client 61 | client?.urlProtocol(self, didReceive: response!, cacheStoragePolicy: .notAllowed) 62 | client?.urlProtocol(self, didLoad: data!) 63 | case .failure(let error): 64 | // If the request failed, send the error to the client 65 | client?.urlProtocol(self, didFailWithError: error.error) 66 | } 67 | // Finish loading the request 68 | client?.urlProtocolDidFinishLoading(self) 69 | }) 70 | } 71 | 72 | /** 73 | Stops loading the request. 74 | */ 75 | public override func stopLoading() { 76 | // Finish the request using the RequestInterceptor 77 | Self.letSee.finish(request: self.request) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/Interceptor/LetSeeMock.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LetSeeMock.swift 3 | // LetSee 4 | // 5 | // Created by Farshad Macbook M1 Pro on 5/3/22. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension LetSeeMock { 11 | 12 | /** 13 | An enumeration of categories that LetSeeMock objects can belong to. 14 | 15 | The category of a LetSeeMock object is used to group similar mock objects together. There are three categories: general, specific, and suggested. 16 | 17 | - general: Mocks that can be used for any type of network request. 18 | - specific: Mocks that are tailored to specific types of network requests. 19 | - suggested: Mocks that are generated automatically by the LetSee framework based on real network requests. 20 | */ 21 | enum Category: Int { 22 | case general = 0 23 | case specific 24 | case suggested 25 | 26 | /** 27 | The name of the category as a string. 28 | 29 | - Returns: The name of the category. 30 | */ 31 | public var name: String { 32 | switch self { 33 | case .general: return "General" 34 | case .specific: return "Specific" 35 | case .suggested: return "Suggested" 36 | } 37 | } 38 | } 39 | } 40 | 41 | 42 | /// This enumeration represents a mock response that can be used to mock network requests in an iOS app. It has five cases: 43 | /// 44 | /// **failure**: represents a mock response that represents a failed network request. It includes the name of the mock response, the response code for the request, and the data for the response. 45 | /// 46 | /// **success**: represents a mock response that represents a successful network request. It includes the name of the mock response, the response code for the request, and the data for the response. 47 | /// 48 | /// **error**: represents a mock response that represents a network request that resulted in an error. It includes the name of the mock response and the error that occurred. 49 | /// 50 | /// **live**: represents a mock response that indicates that network requests should be sent live (i.e., not mocked). 51 | /// 52 | /// **cancel**: represents a mock response that indicates that network requests should be cancelled. 53 | /// 54 | /// The enumeration also includes several functions and properties that can be used to access and manipulate the data of a LetSeeMock object. These include functions to convert the data of a LetSeeMock object to and from different formats (e.g., Data, JSON, and String), and properties to access the name, data, and response code of a LetSeeMock object. 55 | public enum LetSeeMock: Hashable, Comparable { 56 | /// This function is an implementation of the Comparable protocol for the LetSeeMock enumeration. It defines a comparison operator that allows LetSeeMock objects to be compared to each other using the < operator. 57 | /// 58 | /// The function defines a specific ordering for the different cases of the LetSeeMock enumeration: 59 | /// .live is greater than all other cases. 60 | /// .cancel is greater than .failure, .error, and .success. 61 | /// .success is greater than .failure and .error. 62 | /// .error is greater than .failure. 63 | /// 64 | /// This ordering is used to determine the relative position of two LetSeeMock objects in a list or other ordered collection. For example, if a list of LetSeeMock objects is sorted in ascending order, the .live cases will appear at the end of the list, the .cancel cases will appear before the .live cases, and so on. 65 | public static func < (lhs: LetSeeMock, rhs: LetSeeMock) -> Bool { 66 | switch (lhs, rhs) { 67 | case (_, .live): 68 | return true 69 | case (.live, _): 70 | return false 71 | case (_, .cancel): 72 | return true 73 | case (.cancel, _): 74 | return false 75 | case (.success, _): 76 | return true 77 | case (_, .success): 78 | return false 79 | case (.failure, .error): 80 | return true 81 | default: return true 82 | } 83 | } 84 | 85 | public func hash(into hasher: inout Hasher) { 86 | hasher.combine(data) 87 | hasher.combine(name) 88 | } 89 | public var hashValue: Int { 90 | var hasher = Hasher() 91 | self.hash(into: &hasher) 92 | return hasher.finalize() 93 | } 94 | 95 | /// **failure**: represents a mock response that represents a failed network request. It includes the name of the mock response, the response code for the request, and the data for the response. 96 | case failure(name: String, response: URLError.Code, data: Data) 97 | 98 | /// **success**: represents a mock response that represents a successful network request. It includes the name of the mock response, the response code for the request, and the data for the response. 99 | case success(name: String, response: LetSeeMockResponse? , data: Data) 100 | 101 | /// **error**: represents a mock response that represents a network request that resulted in an error. It includes the name of the mock response and the error that occurred. 102 | case error(name: String, URLError) 103 | 104 | /// **live**: represents a mock response that indicates that network requests should be sent live (i.e., not mocked). 105 | case live 106 | 107 | /// **cancel**: represents a mock response that indicates that network requests should be cancelled. 108 | case cancel 109 | var data: Data? { 110 | switch self { 111 | case .failure(_, _, let jSON): 112 | return jSON 113 | case .success(_, _, let jSON): 114 | return jSON 115 | case .error, .live, .cancel: 116 | return nil 117 | } 118 | } 119 | /** 120 | Returns the name of the LetSeeMock object. 121 | 122 | The name is a unique string value that identifies the object within the context of the app. The name is determined based on the case of the LetSeeMock object. 123 | 124 | For .failure and .success cases, the name is the string value passed in as an argument when the object was created. For the .error case, the name is the string value passed in as an argument when the object was created. For the .live and .cancel cases, the name is a fixed string value of "Live" and "Cancel", respectively. 125 | 126 | - Returns: The name of the LetSeeMock object. 127 | */ 128 | public var name: String { 129 | switch self { 130 | case .failure(let name, _, _): 131 | return name 132 | case .success(let name, _, _): 133 | return name 134 | case .error(let name, _): 135 | return name 136 | case .live: 137 | return "Live" 138 | case .cancel: 139 | return "Cancel" 140 | } 141 | } 142 | 143 | public var type: String { 144 | switch self { 145 | case .failure: return "failure" 146 | case .success: return "success" 147 | case .error: return "error" 148 | case .live: return "live" 149 | case .cancel: return "cancel" 150 | } 151 | } 152 | /** 153 | Returns the raw data of the LetSeeMock object as a string, if possible. 154 | 155 | The raw data of the LetSeeMock object represents the response data that the object represents. It is expressed as a JSON object and can be modified using the `mapJson(_:)` function. 156 | 157 | For .failure and .success cases, the raw data is the JSON object passed in as an argument when the object was created. For the .error case, the raw data is the localized description of the error object passed in as an argument when the object was created. For the .live and .cancel cases, the raw data is `nil`. 158 | 159 | - Returns: The raw data of the LetSeeMock object as a string, if possible. 160 | */ 161 | public var string: String? { 162 | let result: String? 163 | switch self { 164 | case .failure(_, _, let jSON): 165 | result = String(data: jSON, encoding: .utf8) 166 | case .success(_, _, let jSON): 167 | result = String(data: jSON, encoding: .utf8) 168 | case .error(_, let error): 169 | result = error.localizedDescription 170 | case .live, .cancel: 171 | return nil 172 | } 173 | return result? 174 | .replacingOccurrences(of: "\n", with: "") 175 | .replacingOccurrences(of: "\'", with: "\"") 176 | } 177 | 178 | public var formatted: String? { 179 | let data = self.data 180 | guard let data = data else { 181 | return nil 182 | } 183 | 184 | do { 185 | let json = try JSONSerialization.jsonObject(with: data, options: .mutableContainers) 186 | let jsonData = try JSONSerialization.data(withJSONObject: json, options: .prettyPrinted) 187 | guard let jsonString = String(data: jsonData, encoding: .utf8) else { 188 | return nil 189 | } 190 | return jsonString 191 | } catch( let error) { 192 | print("LetSee couldn't format the json because", error.localizedDescription) 193 | return String(data: data, encoding: .utf8) 194 | } 195 | } 196 | 197 | public func mapJson(_ json: Data) -> LetSeeMock { 198 | switch self { 199 | case .failure(let name, let response, _): 200 | return .failure(name: name, response: response, data: json) 201 | case .success(let name, let response, _): 202 | return .success(name: name, response: response, data: json) 203 | case .error, .live, .cancel: 204 | return self 205 | } 206 | } 207 | } 208 | 209 | public extension LetSeeMock { 210 | static func defaultSuccess(name: String, data: Data) -> LetSeeMock { 211 | let response = LetSeeMockResponse(stateCode: 200, header: ["Content-Type": "application/json"]) 212 | return .success(name: name, response: response, data: data) 213 | } 214 | 215 | static func defaultFailure(name: String, data: Data) -> LetSeeMock { 216 | return .failure(name: name, response: .badServerResponse, data: data) 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/Interceptor/LetSeeMockProviding.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LetSeeMockProviding.swift 3 | // LetSee 4 | // 5 | // Created by Farshad Macbook M1 Pro on 5/3/22. 6 | // 7 | 8 | import Foundation 9 | public protocol LetSeeMockProviding { 10 | static var mocks:Set {get} 11 | var mocks:Set {get} 12 | } 13 | 14 | public extension LetSeeMockProviding { 15 | static var mocks: Set {[]} 16 | var mocks: Set {[]} 17 | } 18 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/Interceptor/LetSeeMockResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LetSeeMockResponse.swift 3 | // LetSee 4 | // 5 | // Created by Farshad Macbook M1 Pro on 5/3/22. 6 | // 7 | 8 | import Foundation 9 | public struct LetSeeMockResponse: Hashable { 10 | public let stateCode: Int 11 | public let header: [String: String] 12 | public init( 13 | stateCode: Int, 14 | header: [String: String] 15 | ) { 16 | self.stateCode = stateCode 17 | self.header = header 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/Interceptor/LetSeeRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LetSeeRequest.swift 3 | // 4 | // 5 | // Created by Farshad Macbook M1 Pro on 5/4/22. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol LetSeeRequest: AnyObject { 11 | var request: URLRequest{get} 12 | var mocks: [LetSeeMock]{get set} 13 | var id: String {get set} 14 | } 15 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/Interceptor/LetSeeRequestStatus.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LetSeeRequestStatus.swift 3 | // LetSee 4 | // 5 | // Created by Farshad Macbook M1 Pro on 5/3/22. 6 | // 7 | 8 | import Foundation 9 | /** 10 | This enumeration represents the different states a LetSeeRequest can be in. 11 | 12 | - `loading`: The request is currently being loaded. 13 | - `idle`: The request is not currently being processed. 14 | - `active`: The request is currently being processed. 15 | */ 16 | public enum LetSeeRequestStatus { 17 | case loading 18 | case idle 19 | case active 20 | } 21 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/Interceptor/RequestInterceptor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RequestInterceptor.swift 3 | // LetSee 4 | // 5 | // Created by Farshad Macbook M1 Pro on 5/3/22. 6 | // 7 | 8 | import Foundation 9 | /** 10 | This protocol defines the behavior of a request interceptor. A request interceptor is responsible for intercepting and queuing requests, preparing requests for incoming results, and responding to requests. 11 | 12 | Scenario: 13 | A scenario represents a set of rules and data for handling requests. Scenarios can be activated and deactivated. 14 | 15 | #### Properties 16 | 17 | - `scenario`: A publisher for the current active scenario. 18 | - `isScenarioActive`: A boolean value indicating whether a scenario is currently active. 19 | 20 | #### Methods 21 | 22 | - `activateScenario(_:)`: Activates a scenario. 23 | - `deactivateScenario()`: Deactivates the current scenario. 24 | - `intercept(request:availableMocks:)`: Intercepts and queues a request. The request will remain in the queue until the user selects a response for it. 25 | - `prepare(request:resultHandler:)`: Prepares the request for the incoming result. The user can select a mock or live request, and the result handler will be called when the data is ready. 26 | - `cancel(request:)`: Cancels the request. The request will be answered with an error. 27 | - `respond(request:)`: Sends the request to the live server. 28 | - `respond(request:with:)`: Answers the request with a result. 29 | - `update(request:status:)`: Updates the status of the request. 30 | - `finish(request:)`: Removes the request from the queue and cleans it up. 31 | */ 32 | public protocol RequestInterceptor: AnyObject { 33 | var scenario: Published.Publisher {get} 34 | var isScenarioActive: Bool {get} 35 | func activateScenario(_ scenario: Scenario) 36 | func deactivateScenario() 37 | /// Queued requests 38 | var requestQueue: Published<[LetSeeUrlRequest]>.Publisher {get} 39 | 40 | /// intercepts and queued a request. this requests will be remained in the queue until user selects a response for it 41 | /// 42 | /// - Parameter request: a url request. 43 | /// - Parameter availableMocks: mocks objects, these mocks will be provided to the user and she can select one of these mocks to answer the requests with them. All requests have two default mocks, **`Live Request`, `Error (400)`** 44 | /// 45 | func intercept(request: URLRequest, availableMocks mocks: CategorisedMocks?) 46 | 47 | /// Prepares the request for the incoming result. the user selects a mock either a preprovided json or live request, when the result is ready, the requests will be notified by using the result handler 48 | /// 49 | /// - Parameters: 50 | /// - request: the request, the function uses this request to find the queued request 51 | /// - resultHandler: this function will be called when the data is ready 52 | func prepare(request: URLRequest, resultHandler: ((Result)->Void)?) 53 | 54 | /// Cancels the request, the canceled request will be answered by an 400 error. 55 | func cancel(request: URLRequest) 56 | 57 | /// Sends the request to the live server 58 | func respond(request: URLRequest) 59 | 60 | /// Answers the request by a result 61 | func respond(request: URLRequest, with result: Result) 62 | func respond(request: URLRequest, with response: LetSeeMock) 63 | /// Changes the request status 64 | func update(request: URLRequest, status: LetSeeRequestStatus) 65 | 66 | /// it should be called when the request got it response and finished the processing it, this function cleans up the request from the queue. 67 | func finish(request: URLRequest) 68 | 69 | } 70 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/LetSee+LetSeeProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LetSeeBootStrap.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 07/01/2023. 6 | // 7 | 8 | import Foundation 9 | 10 | private var letSee = LetSee() 11 | public extension LetSee { 12 | static func injectLetSee(_ obj: LetSee) { 13 | letSee = obj 14 | } 15 | 16 | static var shared: LetSeeProtocol { 17 | letSee 18 | } 19 | } 20 | 21 | public extension LetSee { 22 | /// Sets the given `Configuration` for LetSee. 23 | /// 24 | /// - Parameters: 25 | /// - config: the `Configuration` to be used by LetSee. 26 | func config(_ config: Configuration) { 27 | self.configuration = config 28 | } 29 | 30 | /// Adds mock files from the given path to LetSee. 31 | /// 32 | /// - Parameters: 33 | /// - path: the path of the directory that contains the mock files. 34 | func addMocks(from path: String) { 35 | self.globalMockDirectoryConfigs = GlobalMockDirectoryConfig.isExists(in: URL(fileURLWithPath: path)) 36 | let processedMocks = try? self.mockProcessor.buildMocks(path) 37 | mocks = processedMocks ?? [:] 38 | } 39 | /** 40 | Adds the scenarios from the given directory path to the `scenarios` property of the `LetSee` instance. 41 | 42 | The `scenarios` property is a dictionary where each key is the name of the scenario file, and the value is an array of `LetSeeMock` objects that represent the mocks for each step of the scenario. 43 | 44 | The scenario files should be in the form of Property List (.plist) files, and should contain a top-level key called "steps" which is an array of dictionaries. Each dictionary should contain the following keys: 45 | - "folder": The name of the folder containing the mock data for this step. 46 | - "responseFileName": The name of the mock data file (with or without the "success" or "error" prefix). 47 | 48 | If the `LetSee` instance cannot find a mock data file with the given name and folder, it will print an error message and skip that step in the scenario. 49 | 50 | - Parameters: 51 | - path: The directory path where the scenario files are located. 52 | */ 53 | func addScenarios(from path: String) { 54 | let scenarios = try? self.scenarioProcessor.buildScenarios(for: path, requestToMockMapper: { path in 55 | DefaultRequestToMockMapper.transform(request: URL(string: "https://sample.com/" + path)!, using: mocks) 56 | }, globalConfigs: self.globalMockDirectoryConfigs) 57 | self.scenarios = scenarios ?? [] 58 | } 59 | /** 60 | Runs a data task with the given request and calls the completion handler with the received data, response, and error. 61 | 62 | - Parameters: 63 | - request: The request to run the data task with. 64 | - completion: The completion handler to call with the received data, response, and error. 65 | 66 | - Returns: The data task that was run. 67 | */ 68 | @discardableResult 69 | func runDataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask { 70 | self.runDataTask(using: URLSession.shared, with: request, completionHandler: completionHandler) 71 | } 72 | 73 | @discardableResult 74 | func runDataTask(using defaultSession: URLSession = URLSession.shared, with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask { 75 | let request = request.addLetSeeID() 76 | 77 | let session: URLSession 78 | if self.configuration.isMockEnabled { 79 | let configuration = self.addLetSeeProtocol(to: defaultSession.configuration) 80 | session = URLSession(configuration: configuration) 81 | var categoriezedMocks: CategorisedMocks? 82 | if let path = request.url { 83 | categoriezedMocks = requestToMockMapper(path, self.mocks) 84 | } 85 | self.interceptor.intercept(request: request, availableMocks: categoriezedMocks) 86 | } else { 87 | session = defaultSession 88 | } 89 | return session.dataTask(with: request, completionHandler: {(data , response, error) in 90 | completionHandler(data, response, error) 91 | }) 92 | } 93 | } 94 | 95 | struct DefaultRequestToMockMapper { 96 | static func transform(request: URL, using mocks: Dictionary>) -> CategorisedMocks? { 97 | let components = request.path 98 | .components(separatedBy: "/") 99 | .filter({!$0.isEmpty}) 100 | .joined(separator: "/") 101 | 102 | if let requestMocks = mocks[components.mockKeyNormalised] { 103 | return CategorisedMocks(category: .specific, mocks: Array(requestMocks)) 104 | } else { 105 | return nil 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/LetSee.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | extension LetSee { 3 | static let configsFileName = ".pathconfigs.json" 4 | } 5 | /// The LetSee object that serves as the entry point to the library and provides access to the features. 6 | final public class LetSee: LetSeeProtocol { 7 | 8 | internal(set) public var configuration: Configuration = .default 9 | /// All available mocks that LetSee have found on the given mock directory 10 | internal(set) public var mocks: Dictionary> = [:] 11 | /// All available scenarios that LetSee have found on the given scenario directory 12 | internal(set) public var scenarios: [Scenario] = [] 13 | /// a closure that is called when the mock state of the LetSee object changes. It takes a single argument, a Bool value indicating whether mock is enabled or not. It can be set or retrieved using the set and get functions. 14 | public var onMockStateChanged: ((Bool) -> Void)? 15 | public var jsonFileParser: FileNameParsing 16 | public let interceptor: LetSeeInterceptor 17 | let mockProcessor: any MockProcessing 18 | let fileManager: FileManager 19 | let requestToMockMapper: RequestToMockMapper 20 | var globalMockDirectoryConfigs: GlobalMockDirectoryConfig? 21 | let scenarioProcessor: any ScenarioProcessing 22 | public init(configuration: Configuration = .default, 23 | fileManager: FileManager = .default, 24 | jsonFileParser: (any FileNameParsing)? = nil, 25 | interceptor: LetSeeInterceptor? = nil, 26 | mockProcessor: (any MockProcessing)? = nil, 27 | scenarioProcessor: (any ScenarioProcessing)? = nil, 28 | requestToMockMapper: RequestToMockMapper? = nil 29 | ) { 30 | self.configuration = configuration 31 | self.fileManager = fileManager 32 | self.jsonFileParser = jsonFileParser ?? JSONFileNameParser() 33 | self.interceptor = interceptor ?? LetSeeInterceptor() 34 | self.mockProcessor = mockProcessor ?? DefaultMockProcessor(directoryProcessor: MockDirectoryProcessor(fileNameParser: self.jsonFileParser)) 35 | self.scenarioProcessor = scenarioProcessor ?? DefaultScenarioProcessor(mockFileNameParse: self.jsonFileParser) 36 | self.requestToMockMapper = requestToMockMapper ?? DefaultRequestToMockMapper.transform 37 | 38 | } 39 | } 40 | 41 | extension LetSee { 42 | static let headerKey: String = "LETSEE-LOGGER-ID" 43 | } 44 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/Services/CategorisedMocks.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CategorisedMocks.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 07/01/2023. 6 | // 7 | 8 | import Foundation 9 | public struct CategorisedMocks: Hashable { 10 | /// Category of the mocks, can be general or a specific scenario 11 | public var category: LetSeeMock.Category 12 | 13 | /// List of mocks belonging to the category 14 | public var mocks: [LetSeeMock] 15 | 16 | public init(category: LetSeeMock.Category, mocks: [LetSeeMock]) { 17 | self.category = category 18 | self.mocks = mocks 19 | } 20 | } 21 | 22 | public typealias RequestToMockMapper = ((_: URL, _ mocks: Dictionary>) -> CategorisedMocks?) 23 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/Services/Configuration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Configuration.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 05/01/2023. 6 | // 7 | 8 | import Foundation 9 | public extension LetSee { 10 | /// The `Configuration` struct represents the configuration for the `LetSee` instance. 11 | /// 12 | /// - isMockEnabled: A boolean value that determines whether mock data is enabled or not. If `true`, mock data will be used to respond to requests. If `false`, requests will be sent to the server. 13 | /// - shouldCutBaseURLFromURLsTitle: A boolean value that determines whether the base URL should be removed from the title of the request. 14 | /// - baseURL: An optional string that represents the base URL that should be used for requests. 15 | struct Configuration: Equatable { 16 | public var isMockEnabled: Bool 17 | public var shouldCutBaseURLFromURLsTitle: Bool 18 | public var baseURL: URL 19 | 20 | /// Initializes a new `Configuration` instance. 21 | /// 22 | /// - Parameters: 23 | /// - isMockEnabled: A boolean value that determines whether mock data is enabled or not. If `true`, mock data will be used to respond to requests. If `false`, requests will be sent to the server. 24 | /// - shouldCutBaseURLFromURLsTitle: A boolean value that determines whether the base URL should be removed from the title of the request. 25 | /// - baseURL: An optional string that represents the base URL that should be used for requests. 26 | public init(baseURL: URL, isMockEnabled: Bool, shouldCutBaseURLFromURLsTitle: Bool) { 27 | self.isMockEnabled = isMockEnabled 28 | self.shouldCutBaseURLFromURLsTitle = shouldCutBaseURLFromURLsTitle 29 | self.baseURL = baseURL 30 | } 31 | } 32 | } 33 | 34 | public extension LetSee.Configuration { 35 | /// A default `Configuration` instance that has `isMockEnabled` set to `false` and `shouldCutBaseURLFromURLsTitle` set to `false`. 36 | static var `default`: Self { 37 | .init(baseURL: URL(string: "https://letsee.com")!, isMockEnabled: false, shouldCutBaseURLFromURLsTitle: false) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/Services/FileToLetSeeMockMapping.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileToLetSeeMockMapping.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 08/01/2023. 6 | // 7 | 8 | import Foundation 9 | public protocol FileToLetSeeMockMapping { 10 | func map(fileName: String, jsonData: Data) -> LetSeeMock 11 | func sanitize(_ fileName: String) -> String 12 | } 13 | 14 | extension LetSee { 15 | struct DefaultFileToLetSeeMockMapping: FileToLetSeeMockMapping { 16 | func map(fileName: String, jsonData: Data) -> LetSeeMock { 17 | let sanitizedFileName = self.sanitize(fileName) 18 | 19 | if fileName.components(separatedBy: "/").last!.starts(with: "success_") { 20 | return LetSeeMock.success(name: sanitizedFileName, response: .init(stateCode: 200, header: [:]), data: jsonData) 21 | } else { 22 | return LetSeeMock.failure(name: sanitizedFileName, response: .badServerResponse, data: jsonData) 23 | } 24 | } 25 | 26 | func sanitize(_ fileName: String) -> String { 27 | fileName 28 | .replacingOccurrences(of: "error_", with: "") 29 | .replacingOccurrences(of: "success_", with: "") 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/Services/KeyValue.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyValue.swift 3 | // 4 | // 5 | // Created by Farshad Macbook M1 Pro on 4/19/22. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A model to handle all headers key values 11 | struct KeyValue: Codable where Key: Codable, Value: Codable { 12 | let key: Key 13 | let value: Value 14 | } 15 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/Services/LetSee+Interceptor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LetSee+Interceptor+Extension.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 07/01/2023. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Extension methods for `LetSee` class to provide session configuration and add `LetSeeURLProtocol` to a given configuration 11 | public extension LetSee { 12 | /// Creates and returns a new ephemeral `URLSessionConfiguration` object with `LetSeeURLProtocol` as one of its `protocolClasses`. 13 | var sessionConfiguration: URLSessionConfiguration { 14 | let configuration = URLSessionConfiguration.ephemeral 15 | LetSeeURLProtocol.letSee = self.interceptor 16 | configuration.timeoutIntervalForRequest = 3600 17 | configuration.timeoutIntervalForResource = 3600 18 | configuration.protocolClasses = [LetSeeURLProtocol.self] 19 | return configuration 20 | } 21 | 22 | /// Adds `LetSeeURLProtocol` as one of the `protocolClasses` of the given `URLSessionConfiguration` object. 23 | /// - Parameter config: The `URLSessionConfiguration` object to modify. 24 | /// - Returns: The modified `URLSessionConfiguration` object. 25 | func addLetSeeProtocol(to config : URLSessionConfiguration) -> URLSessionConfiguration { 26 | LetSeeURLProtocol.letSee = self.interceptor 27 | config.protocolClasses = [LetSeeURLProtocol.self] + (config.protocolClasses ?? []) 28 | return config 29 | } 30 | } 31 | 32 | /// Extension methods for `LetSee` class to conform to the `InterceptorContainer` protocol. 33 | extension LetSee: InterceptorContainer {} 34 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/Services/LetSeeError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LetSeeError.swift 3 | // LetSee 4 | // 5 | // Created by Farshad Macbook M1 Pro on 5/3/22. 6 | // 7 | 8 | import Foundation 9 | /** 10 | This type alias represents a successful response to a request, consisting of a `URLResponse` and the associated data. 11 | */ 12 | public typealias LetSeeSuccessResponse = (response: URLResponse?, data: Data?) 13 | 14 | /** 15 | This struct represents an error in a request. It contains the error itself and any associated data. 16 | */ 17 | public struct LetSeeError: Error { 18 | /** 19 | The data associated with the error. 20 | */ 21 | public var data: Data? 22 | 23 | /** 24 | The error. 25 | */ 26 | public var error: Error 27 | 28 | /** 29 | Initializes the `LetSeeError` struct with an error and optional data. 30 | 31 | - Parameters: 32 | - error: The error. 33 | - data: The associated data. 34 | */ 35 | public init(error: Error, data: Data?) { 36 | self.error = error 37 | self.data = data 38 | } 39 | 40 | /** 41 | Initializes the `LetSeeError` struct with a URL error code and optional data. 42 | 43 | - Parameters: 44 | - error: The URL error code. 45 | - data: The associated data. 46 | */ 47 | public init(error: URLError.Code, data: Data?) { 48 | self.error = NSError(domain: NSURLErrorDomain, code: error.rawValue, userInfo: ["data": data]) 49 | self.data = data 50 | } 51 | 52 | /** 53 | A localized description of the error. 54 | */ 55 | public var localizedDescription: String { 56 | error.localizedDescription 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/Services/LetSeeProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LetSeeProtocol.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 07/01/2023. 6 | // 7 | 8 | import Foundation 9 | /* 10 | The LetSeeProtocol protocol defines a set of methods and properties that allow the configuration and management of mock data and scenarios for a given session. 11 | 12 | The configuration property is a LetSee.Configuration struct that specifies the current configuration of the session. The mocks property is a dictionary of mock data organized by category. The scenarios property is an array of Scenario structs that represent combinations of mock data to use for a specific flow. The onMockStateChanged property is a closure that will be called when the mock data state changes. 13 | 14 | The config(_:) method allows you to update the session's configuration. The addMocks(from:) method adds mock data from the specified file path. The addScenarios(from:) method adds scenarios from the specified file path. The runDataTask(using:with:completionHandler:) method runs a data task using the specified session and request, and calls the completion handler with the data, response, and error when the task is completed. 15 | */ 16 | public protocol LetSeeProtocol: AnyObject { 17 | 18 | /// The `Configuration` to be used by LetSee. 19 | var configuration: LetSee.Configuration {get} 20 | 21 | /// All available mocks that LetSee have found on the given mock directory 22 | var mocks: Dictionary> {get} 23 | 24 | /// All available scenarios that LetSee have found on the given scenario directory 25 | var scenarios: [Scenario] {get} 26 | 27 | /// A closure that is called when the mock state of the LetSee object changes. It takes a single argument, a Bool value indicating whether mock is enabled or not. It can be set or retrieved using the set and get functions. 28 | var onMockStateChanged: ((Bool) -> Void)? {set get} 29 | var jsonFileParser: FileNameParsing {get} 30 | var interceptor: LetSeeInterceptor {get} 31 | 32 | /// Sets the given `Configuration` for LetSee. 33 | /// 34 | /// - Parameters: 35 | /// - config: the `Configuration` to be used by LetSee. 36 | func config(_ config: LetSee.Configuration) 37 | 38 | /// Adds mock files from the given path to LetSee. 39 | /// 40 | /// - Parameters: 41 | /// - path: the path of the directory that contains the mock files. 42 | func addMocks(from path: String) 43 | /** 44 | Adds the scenarios from the given directory path to the `scenarios` property of the `LetSee` instance. 45 | 46 | The `scenarios` property is a dictionary where each key is the name of the scenario file, and the value is an array of `LetSeeMock` objects that represent the mocks for each step of the scenario. 47 | 48 | The scenario files should be in the form of Property List (.plist) files, and should contain a top-level key called "steps" which is an array of dictionaries. Each dictionary should contain the following keys: 49 | - "folder": The name of the folder containing the mock data for this step. 50 | - "responseFileName": The name of the mock data file (with or without the "success" or "error" prefix). 51 | 52 | If the `LetSee` instance cannot find a mock data file with the given name and folder, it will print an error message and skip that step in the scenario. 53 | 54 | - Parameters: 55 | - path: The directory path where the scenario files are located. 56 | */ 57 | func addScenarios(from path: String) 58 | 59 | /** 60 | Runs a data task with the given request and calls the completion handler with the received data, response, and error. 61 | 62 | - Parameters: 63 | - request: The request to run the data task with. 64 | - completion: The completion handler to call with the received data, response, and error. 65 | 66 | - Returns: The data task that would be run. 67 | */ 68 | 69 | func runDataTask(using defaultSession: URLSession, with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask 70 | } 71 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/Services/LetSeeUrlRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LetSeeUrlRequest.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 07/01/2023. 6 | // 7 | 8 | import Foundation 9 | /* 10 | Represents a request in the LetSee library. It has the following properties: 11 | `request`: The original `URLRequest` object. 12 | `mocks`: An array of `CategorisedMocks` objects that contain the available mocks for the request. 13 | `response`: A closure that is called when a response is available for the request. 14 | `status`: An enum that represents the status of the request. It can be one of `idle`, `sendingToServer`, or `responded`. 15 | */ 16 | public struct LetSeeUrlRequest { 17 | 18 | /// The original `URLRequest` object. 19 | public var request: URLRequest 20 | 21 | /// An array of `CategorisedMocks` objects that contain the available mocks for the request. 22 | public var mocks: Array 23 | 24 | /// A closure that is called when a response is available for the request. 25 | public var response: ((Result)->Void)? 26 | 27 | /// An enum that represents the status of the request. It can be one of `idle`, `sendingToServer`, or `responded`. 28 | public var status: LetSeeRequestStatus 29 | 30 | /// This method returns the name of the request based on the given parameters. If removeString is set, it removes that string from the request name. 31 | /// This function is useful for the time we want to remove the baseURL from the request name 32 | /// - Parameters: 33 | /// - removeString: If it is provided, it removes this string from the name 34 | /// - Returns: request name in lowercase 35 | public func nameBuilder(remove cutBaseURL: String? = nil) -> String { 36 | guard let name = request.url?.absoluteString else {return ""} 37 | guard let cutBaseURL else {return name} 38 | return name.lowercased().replacingOccurrences(of: cutBaseURL, with: "") 39 | } 40 | 41 | public init(request: URLRequest, mocks: [CategorisedMocks]? = nil, response: ((Result) -> Void)? = nil, status: LetSeeRequestStatus) { 42 | self.request = request 43 | self.mocks = mocks ?? [] 44 | self.response = response 45 | self.status = status 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/Services/Scenario.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Scenario.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 07/01/2023. 6 | // 7 | 8 | import Foundation 9 | /// A Scenario is a combination of multiple mock data, each scenario has a name and a list of mock data. Scenarios are good to automate responding 10 | /// to requests like when we want to test a specific flow, if there is an active scenario all the requests will be responded by using the scenarios mocks one by one, when there are no other mock in scenario, the scenario will be deactivate automatically 11 | /// and the request will be suspended until you choose a response for them. 12 | /// 13 | public struct Scenario: Equatable { 14 | public static func == (lhs: Self, rhs: Self) -> Bool { 15 | lhs.name == rhs.name && rhs.mocks == lhs.mocks 16 | } 17 | public let name: String 18 | public let mocks: [LetSeeMock] 19 | public init(name: String, mocks: [LetSeeMock]) { 20 | self.name = name 21 | self.mocks = mocks 22 | } 23 | 24 | private var currentIndex: Int = 0 25 | 26 | /// Shows the current step of the flow, it means that the next request will be received this as it's response 27 | public var currentStep: LetSeeMock? { 28 | guard currentIndex < mocks.count else { 29 | return nil 30 | } 31 | return mocks[currentIndex] 32 | } 33 | 34 | /// Moves the cursor to the next mock, when the request received the current mock, this function should be called. 35 | @discardableResult 36 | mutating func nextStep() -> LetSeeMock? { 37 | guard currentIndex < mocks.count else { 38 | return nil 39 | } 40 | 41 | let mock = mocks[currentIndex] 42 | self.currentIndex += 1 43 | return mock 44 | } 45 | } 46 | 47 | struct ScenarioFileInformation: Decodable { 48 | struct Step: Decodable { 49 | let folder: String 50 | let responseFileName: String 51 | 52 | init(folder: String, responseFileName: String) { 53 | self.responseFileName = responseFileName.lowercased() 54 | self.folder = folder.mockKeyNormalised 55 | } 56 | 57 | enum CodingKeys: CodingKey { 58 | case folder 59 | case responseFileName 60 | } 61 | 62 | init(from decoder: Decoder) throws { 63 | let container: KeyedDecodingContainer = try decoder.container(keyedBy: ScenarioFileInformation.Step.CodingKeys.self) 64 | let folder = try container.decode(String.self, forKey: ScenarioFileInformation.Step.CodingKeys.folder) 65 | let responseFileName = try container.decode(String.self, forKey: ScenarioFileInformation.Step.CodingKeys.responseFileName) 66 | self.init(folder: folder, responseFileName: responseFileName) 67 | } 68 | } 69 | 70 | var steps: [Step] 71 | init(steps: [Step]) { 72 | self.steps = steps 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/Services/SocketEmitableContent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SocketEmitableContent.swift 3 | // 4 | // 5 | // Created by Farshad Macbook M1 Pro on 4/18/22. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A model which LetSee uses to encode the message that our web socket will receive, in a very neat and type safe way. 11 | struct SocketEmitableContent: Encodable { 12 | 13 | let type: Kind 14 | 15 | /// `Id` of this even, our library need this `Id` to be unique because it uses this ID as `HTML element's ID` and searches for the element for any further manipulation. 16 | let id: String 17 | 18 | /// Each event needs a request, either it is a Request event or it is a Response Error. In Response case, LetSee uses the provided id in request header to find the corresponding HTML element and replace it by its responded version 19 | let request: Content 20 | let response: Content? 21 | 22 | /// Waiting for response. Every new request which has no response, stays in Waiting mode til its response arrives. 23 | var waiting: Bool 24 | 25 | init(with request: URLRequest) { 26 | self.init(kind: .request, request: request, response: nil, body: request.httpBody) 27 | } 28 | 29 | init(kind: Kind, request: URLRequest, response: URLResponse?, body: Data?) { 30 | self.type = kind 31 | id = request.value(forHTTPHeaderField: "LETSEE-LOGGER-ID") ?? UUID().uuidString 32 | self.request = .init(activity: request, body: request.httpBody) 33 | if let response = response{ 34 | self.response = .init(activity: response, body: body) 35 | waiting = false 36 | } else { 37 | self.response = nil 38 | waiting = true 39 | } 40 | } 41 | } 42 | 43 | extension SocketEmitableContent { 44 | struct Content: Encodable { 45 | let headers: [KeyValue] = [] 46 | let tookTime: String = "-" 47 | let contentLength: Int = 0 48 | let statusCode: Int? = nil 49 | let activity: RequestResponse 50 | let body: Data? 51 | let method: String? = nil 52 | let url: String = "" 53 | enum CodingKeys: String, CodingKey { 54 | case headers = "headers" 55 | case tookTime = "took_time" 56 | case contentLength = "content_length" 57 | case statusCode = "status_code" 58 | case response = "response" 59 | case body = "body" 60 | case method, url 61 | } 62 | 63 | init(activity: RequestResponse, body: Data? = nil) { 64 | self.activity = activity 65 | self.body = body 66 | } 67 | 68 | func encode(to encoder: Encoder) throws { 69 | var container = encoder.container(keyedBy: Self.CodingKeys) 70 | 71 | if let httpRes = activity as? HTTPURLResponse { 72 | try container.encode(httpRes.allHeaderFields.asKeyValue, forKey: .headers) 73 | try container.encode(httpRes.statusCode, forKey: .statusCode) 74 | } else if let httpReq = activity as? URLRequest { 75 | try container.encode((httpReq.allHTTPHeaderFields?.asKeyValue ?? []), forKey: .headers) 76 | try container.encode(0, forKey: .statusCode) 77 | try container.encode(httpReq.httpMethod ?? "", forKey: .method) 78 | try container.encode(httpReq.url, forKey: .url) 79 | if let data = httpReq.httpBody, let stringified = String(data: data, encoding: .utf8) { 80 | try container.encode(stringified, forKey: .body) 81 | } 82 | } 83 | 84 | if let data = body, let stringified = String(data: data, encoding: .utf8) { 85 | try container.encode(stringified, forKey: .body) 86 | try container.encode(data.count, forKey: .contentLength) 87 | } else { 88 | try container.encode("{}", forKey: .body) 89 | try container.encode(0, forKey: .contentLength) 90 | } 91 | 92 | try container.encode("-", forKey: .tookTime) 93 | } 94 | } 95 | } 96 | 97 | extension SocketEmitableContent { 98 | enum Kind: String, Codable { 99 | case request, response 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/Services/String+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Extensions.swift 3 | // 4 | // 5 | // Created by Farshad Macbook M1 Pro on 4/18/22. 6 | // 7 | 8 | import Foundation 9 | extension String { 10 | static var empty: String { 11 | return "" 12 | } 13 | 14 | /// LowerCases and wraps the string between two backslash 15 | var mockKeyNormalised: String { 16 | var folder = self.lowercased() 17 | folder = folder.starts(with: "/") ? folder : "/" + folder 18 | folder = folder.last == "/" ? folder : folder + "/" 19 | return folder 20 | } 21 | } 22 | 23 | extension Dictionary where Key == AnyHashable, Value == Any { 24 | var asKeyValue: [KeyValue] { 25 | self.map({ (arg0) in 26 | return KeyValue(key: arg0.key as! String, value: (arg0.value as! String).replacingOccurrences(of: "\"",with: "'")) 27 | }) 28 | } 29 | } 30 | 31 | extension Dictionary where Key == String, Value == String { 32 | var asKeyValue: [KeyValue] { 33 | self.map({ (arg0) in 34 | return KeyValue(key: arg0.key, value: arg0.value) 35 | }) 36 | } 37 | } 38 | 39 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/Services/URLRequest+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLRequest+Extensions.swift 3 | // 4 | // 5 | // Created by Farshad Macbook M1 Pro on 4/24/22. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension URLRequest { 11 | func addLetSeeID() -> URLRequest { 12 | guard self.letSeeId == nil else {return self} 13 | var request = self 14 | request.letSeeId = UUID().uuidString 15 | return request 16 | } 17 | 18 | var letSeeId: String? { 19 | get{ 20 | guard let id = self.allHTTPHeaderFields?.first(where: {$0.key == LetSee.headerKey}) else { 21 | return nil 22 | } 23 | return id.value 24 | } 25 | 26 | set { 27 | if let newValue = newValue { 28 | self.addValue(newValue, forHTTPHeaderField: LetSee.headerKey) 29 | } else { 30 | self.allHTTPHeaderFields?.removeValue(forKey: LetSee.headerKey) 31 | } 32 | } 33 | } 34 | } 35 | 36 | -------------------------------------------------------------------------------- /Sources/LetSee/Core/Services/Utility.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Utility.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 07/01/2023. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Adds @LETSEE> at the beginning of the print statement 11 | /// 12 | /// - Parameters: 13 | /// - message: the print string 14 | @discardableResult 15 | func print(_ message: String) -> String { 16 | let printableMessage = "@LETSEE > \(message)" 17 | Swift.print(printableMessage) 18 | return printableMessage 19 | } 20 | -------------------------------------------------------------------------------- /Sources/LetSee/InAppView/ActivityIndicatorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActivityIndicatorView.swift 3 | // LetSee 4 | // 5 | // Created by Farshad Macbook M1 Pro on 5/2/22. 6 | // 7 | 8 | import SwiftUI 9 | struct ActivityIndicatorView: UIViewRepresentable { 10 | @Binding var isAnimating: Bool 11 | let style: UIActivityIndicatorView.Style 12 | 13 | func makeUIView(context: UIViewRepresentableContext) -> UIActivityIndicatorView { 14 | return UIActivityIndicatorView(style: style) 15 | } 16 | 17 | func updateUIView(_ uiView: UIActivityIndicatorView, context: UIViewRepresentableContext) { 18 | isAnimating ? uiView.startAnimating() : uiView.stopAnimating() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/LetSee/InAppView/Color+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Color+Extensions.swift 3 | // LetSee 4 | // 5 | // Created by Farshad Macbook M1 Pro on 5/2/22. 6 | // 7 | 8 | import SwiftUI 9 | extension Color { 10 | init(hex: String) { 11 | let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) 12 | var int: UInt64 = 0 13 | Scanner(string: hex).scanHexInt64(&int) 14 | let a, r, g, b: UInt64 15 | switch hex.count { 16 | case 3: // RGB (12-bit) 17 | (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) 18 | case 6: // RGB (24-bit) 19 | (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) 20 | case 8: // ARGB (32-bit) 21 | (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) 22 | default: 23 | (a, r, g, b) = (1, 1, 1, 0) 24 | } 25 | 26 | self.init( 27 | .sRGB, 28 | red: Double(r) / 255, 29 | green: Double(g) / 255, 30 | blue: Double(b) / 255, 31 | opacity: Double(a) / 255 32 | ) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/LetSee/InAppView/JSONHighlighter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JSONHighlighter.swift 3 | // 4 | // 5 | // Created by Farshad Macbook M1 Pro on 5/3/22. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | //systemFont/boldSystemFont 12 | let kJSONFont = UIFont.boldSystemFont(ofSize: 14) 13 | 14 | let kJSONKeyColor = UIColor.init(red: 146/255.0, green: 39/255.0, blue: 143/255.0, alpha: 1) 15 | let kJSONIndexColor = UIColor.init(red: 25/255.0, green: 25/255.0, blue: 112/255.0, alpha: 1) 16 | let kJSONSymbolColor = UIColor.init(red: 74/255.0, green: 85/255.0, blue: 96/255.0, alpha: 1) 17 | 18 | let kJSONNullValueColor = UIColor.init(red: 241/255.0, green: 89/255.0, blue: 42/255.0, alpha: 1) 19 | let kJSONBoolValueColor = UIColor.init(red: 249/255.0, green: 130/255.0, blue: 128/255.0, alpha: 1) 20 | let kJSONNumberValueColor = UIColor.init(red: 37/255.0, green: 170/255.0, blue: 226/255.0, alpha: 1) 21 | let kJSONStringValueColor = UIColor.init(red: 58/255.0, green: 181/255.0, blue: 74/255.0, alpha: 1) 22 | 23 | extension NSAttributedString { 24 | 25 | convenience init(string: String, font: UIFont = kJSONFont, color: UIColor , style: NSParagraphStyle? = nil) { 26 | var attributes = [NSAttributedString.Key.font: font, 27 | NSAttributedString.Key.foregroundColor: color] 28 | if let style = style { 29 | attributes[NSAttributedString.Key.paragraphStyle] = style 30 | } 31 | self.init(string: string, attributes: attributes) 32 | } 33 | } 34 | 35 | extension NSAttributedString { 36 | 37 | @objc class public func render(_ element: Any?) -> NSAttributedString { 38 | return render(element: element, level: 0, ext: 0) 39 | } 40 | 41 | private class func render(element: Any?, level: Int, ext: CGFloat) -> NSAttributedString { 42 | 43 | guard let element = element, element is NSNull == false else { 44 | return NSAttributedString.init(string: "null", color: kJSONNullValueColor) 45 | } 46 | 47 | switch element { 48 | case let dic as [String: Any]: 49 | return attributedString(dic: dic, level: level, ext: ext) 50 | case let arr as [Any]: 51 | return attributedString(arr: arr, level: level, ext: ext) 52 | case let number as NSNumber: 53 | if number.isBool { 54 | return NSAttributedString.init(string: number.boolValue ? "true":"false", color: kJSONBoolValueColor) 55 | } 56 | var string = "\(number)" 57 | if number.objCType.pointee == 100 { 58 | string = (Decimal.init(string: String.init(format: "%f", number.doubleValue))! as NSDecimalNumber).stringValue 59 | } 60 | return NSAttributedString.init(string: string, color: kJSONNumberValueColor) 61 | case let string as String: 62 | return NSAttributedString.init(string: "\"" + string + "\"", color: kJSONStringValueColor) 63 | default: 64 | return NSAttributedString.init(string: "\(element)", color: kJSONStringValueColor) 65 | } 66 | } 67 | 68 | private class func attributedString(dic: [String: Any], level: Int, ext: CGFloat) -> NSMutableAttributedString { 69 | 70 | let headPara = NSMutableParagraphStyle() 71 | headPara.firstLineHeadIndent = CGFloat(level * 10) 72 | 73 | let mattr = NSMutableAttributedString.init(string: "{", color: kJSONSymbolColor, style: headPara) 74 | 75 | if (dic.isEmpty == false) { 76 | mattr.append(NSAttributedString.init(string: "\n")) 77 | } 78 | 79 | for (idx, element) in dic.enumerated() { 80 | 81 | let key = "\"" + element.key + "\"" 82 | 83 | let width = (key as NSString).boundingRect(with: CGSize.init(width: CGFloat.infinity, height: kJSONFont.lineHeight), options: NSStringDrawingOptions.usesLineFragmentOrigin, attributes: [NSAttributedString.Key.font: kJSONFont], context: nil).size.width + 10 84 | 85 | let para = NSMutableParagraphStyle() 86 | para.firstLineHeadIndent = CGFloat((level + 1) * 10) + ext 87 | para.headIndent = CGFloat(level * 10) + width + ext + 5 88 | para.lineBreakMode = .byCharWrapping 89 | 90 | mattr.append(NSAttributedString.init(string: key, color: kJSONKeyColor, style: para)) 91 | 92 | mattr.append(NSAttributedString.init(string: ":", color: kJSONSymbolColor)) 93 | 94 | mattr.append(.render(element: element.value, level: level + 1, ext: width + ext)) 95 | 96 | if idx != dic.count - 1 { 97 | mattr.append(NSAttributedString.init(string: ",", color: kJSONSymbolColor)) 98 | } 99 | mattr.append(NSAttributedString.init(string: "\n")) 100 | } 101 | 102 | let tailPara = NSMutableParagraphStyle() 103 | tailPara.firstLineHeadIndent = CGFloat(level * 10) + ext 104 | 105 | mattr.append(NSAttributedString.init(string: "}", color: kJSONSymbolColor, style: tailPara)) 106 | 107 | return mattr 108 | } 109 | 110 | private class func attributedString(arr: [Any], level: Int, ext: CGFloat) -> NSMutableAttributedString { 111 | 112 | let headPara = NSMutableParagraphStyle() 113 | headPara.firstLineHeadIndent = CGFloat(level * 10) 114 | 115 | let mattr = NSMutableAttributedString.init(string: "[", color: kJSONSymbolColor, style: headPara) 116 | 117 | if (arr.isEmpty == false) { 118 | mattr.append(NSAttributedString.init(string: "\n")) 119 | } 120 | 121 | for (idx, element) in arr.enumerated() { 122 | 123 | let index = String(idx) 124 | 125 | let width = (index as NSString).boundingRect(with: CGSize.init(width: CGFloat.infinity, height: kJSONFont.lineHeight), options: NSStringDrawingOptions.usesLineFragmentOrigin, attributes: [NSAttributedString.Key.font: kJSONFont], context: nil).size.width + 10 126 | 127 | let para = NSMutableParagraphStyle() 128 | para.firstLineHeadIndent = CGFloat(level * 10) + ext + 5 129 | para.headIndent = CGFloat(level * 10) + width + ext + 5 130 | para.lineBreakMode = .byCharWrapping 131 | 132 | mattr.append(NSAttributedString.init(string: index, color: kJSONIndexColor, style: para)) 133 | 134 | mattr.append(NSAttributedString.init(string: ":", color: kJSONSymbolColor)) 135 | 136 | mattr.append(.render(element: element, level: level + 1, ext: width + ext)) 137 | 138 | if idx != arr.count - 1 { 139 | mattr.append(NSAttributedString.init(string: ",", color: kJSONSymbolColor)) 140 | } 141 | mattr.append(NSAttributedString.init(string: "\n")) 142 | } 143 | 144 | let tailPara = NSMutableParagraphStyle() 145 | tailPara.firstLineHeadIndent = CGFloat(level * 10) + ext 146 | 147 | mattr.append(NSAttributedString.init(string: "]", color: kJSONSymbolColor, style: tailPara)) 148 | 149 | return mattr 150 | } 151 | } 152 | 153 | private let trueNumber = NSNumber(value: true) 154 | private let falseNumber = NSNumber(value: false) 155 | private let trueObjCType = String(cString: trueNumber.objCType) 156 | private let falseObjCType = String(cString: falseNumber.objCType) 157 | 158 | extension NSNumber { 159 | fileprivate var isBool: Bool { 160 | let objCType = String(cString: self.objCType) 161 | if (self.compare(trueNumber) == .orderedSame && objCType == trueObjCType) || (self.compare(falseNumber) == .orderedSame && objCType == falseObjCType) { 162 | return true 163 | } else { 164 | return false 165 | } 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /Sources/LetSee/InAppView/JsonViewerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JsonViewerView.swift 3 | // LetSee 4 | // 5 | // Created by Farshad Macbook M1 Pro on 5/2/22. 6 | // 7 | 8 | import SwiftUI 9 | import LetSee 10 | struct JsonViewerView: View { 11 | var tap: ((LetSeeMock) -> Void) 12 | var mock: LetSeeMock 13 | @State var tapped: Bool = false 14 | @State var text: String = "" 15 | @State var isEditable: Bool = false 16 | @Environment(\.colorScheme) var colorScheme 17 | var foreColor: Color { 18 | get {colorScheme == .dark ? Color.white : Color.black} 19 | } 20 | var body: some View { 21 | VStack(alignment: .center, spacing: 16){ 22 | Button(action: { 23 | tap(mock.mapJson(text.data(using: .utf8)!)) 24 | tapped.toggle() 25 | }, label: { 26 | HStack { 27 | Text("Send") 28 | .font(.headline.weight(.medium)) 29 | .foregroundColor(foreColor) 30 | Image(systemName: "square.and.arrow.up.fill") 31 | .resizable() 32 | .scaledToFit() 33 | .foregroundColor(foreColor) 34 | .frame(width: 24, height: 24) 35 | } 36 | .frame(maxWidth: .infinity, alignment: .center) 37 | .padding() 38 | .background(foreColor.opacity(tapped ? 0.05 : 0.1)) 39 | .cornerRadius(15) 40 | }) 41 | .disabled(tapped) 42 | HStack { 43 | Button("Copy JSON") { 44 | UIPasteboard.general.string = mock.formatted 45 | } 46 | Spacer() 47 | Button("Past JSON") { 48 | text = UIPasteboard.general.string ?? "" 49 | } 50 | } 51 | ZStack(alignment: .topTrailing){ 52 | Group { 53 | MultilineTextView(text: $text, isEditingEnabled: $isEditable) 54 | } 55 | .multilineTextAlignment(.leading) 56 | .font(.body) 57 | .foregroundColor(foreColor.opacity(1)) 58 | .padding() 59 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) 60 | .cornerRadius(10) 61 | 62 | HStack(alignment: .center){ 63 | if isEditable { 64 | Text("editing...") 65 | .opacity(0.3) 66 | } else{ 67 | Text("Edit") 68 | .opacity(0.5) 69 | } 70 | Button(action: { 71 | self.isEditable.toggle() 72 | }, label: { 73 | if self.isEditable { 74 | Image(systemName: "arrow.down.circle.fill") 75 | .resizable() 76 | } else { 77 | Image(systemName: "pencil.circle.fill") 78 | .resizable() 79 | } 80 | }) 81 | .foregroundColor(!self.isEditable ? .blue : .green) 82 | .frame(width: 32, height: 32, alignment: .center) 83 | .opacity(0.8) 84 | 85 | 86 | }.padding([.top, .trailing], 8) 87 | } 88 | } 89 | .if({true}, { view in 90 | view 91 | .navigationTitle(mock.name) 92 | .navigationBarTitleDisplayMode(.automatic) 93 | }) 94 | .padding() 95 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) 96 | .onAppear { 97 | self.text = mock.formatted ?? "" 98 | } 99 | } 100 | } 101 | 102 | #if DEBUG 103 | //struct JsonViewer_Previews: PreviewProvider { 104 | // static var previews: some View { 105 | // JsonViewerView(tap: { mock in 106 | // print(mock) 107 | // }, mock: .success(name: "something", response: nil, data: "{\"name\": \"Salam sd sdfsd fds f sd f sd fs df sd fs dsd sdfs df\"}")) 108 | // } 109 | //} 110 | #endif 111 | -------------------------------------------------------------------------------- /Sources/LetSee/InAppView/LetSeeConfigurationKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LetSeeConfigurationKey.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 06/01/2023. 6 | // 7 | 8 | import LetSee 9 | import SwiftUI 10 | extension EnvironmentValues { 11 | var letSeeConfiguration: LetSee.Configuration { 12 | set { 13 | self[LetSeeConfigurationKey.self] = newValue 14 | } 15 | get { 16 | self[LetSeeConfigurationKey.self] 17 | } 18 | } 19 | } 20 | 21 | struct LetSeeConfigurationKey: EnvironmentKey { 22 | static let defaultValue: LetSee.Configuration = LetSee.shared.configuration 23 | } 24 | -------------------------------------------------------------------------------- /Sources/LetSee/InAppView/LetSeeRequestListViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LetSeeRequestListViewModel.swift 3 | // LetSee 4 | // 5 | // Created by Farshad Macbook M1 Pro on 5/2/22. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | import LetSee 11 | public final class LetSeeRequestsListViewModel: ObservableObject { 12 | private unowned var interceptor: RequestInterceptor 13 | private var bag: [AnyCancellable] = [] 14 | @Published var requestList: [LetSeeUrlRequest] = [] 15 | func response(request: URLRequest, _ response: LetSeeMock) { 16 | interceptor.respond(request: request, with: response) 17 | } 18 | public init(interceptor: RequestInterceptor) { 19 | self.interceptor = interceptor 20 | interceptor.requestQueue 21 | .receive(on: DispatchQueue.main) 22 | .sink {[weak self] list in 23 | guard let self else {return} 24 | self.requestList = list 25 | .reversed() 26 | .filter({$0.status == .idle}) 27 | } 28 | .store(in: &bag) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/LetSee/InAppView/LetSeeScenariosListViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LetSeeScenariosListViewModel.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 06/01/2023. 6 | // 7 | 8 | import Combine 9 | import LetSee 10 | public final class LetSeeScenariosListViewModel: ObservableObject { 11 | private unowned var interceptor: RequestInterceptor 12 | 13 | let scenarios: Array 14 | @Published var selectedScenario: Scenario? = nil 15 | func toggleScenario(_ scenario: Scenario) { 16 | if self.interceptor.isScenarioActive, selectedScenario == scenario { 17 | self.interceptor.deactivateScenario() 18 | selectedScenario = nil 19 | } else { 20 | self.interceptor.activateScenario(scenario) 21 | selectedScenario = scenario 22 | } 23 | } 24 | 25 | public init(scenarios: [Scenario], interceptor: RequestInterceptor) { 26 | self.scenarios = scenarios 27 | self.interceptor = interceptor 28 | 29 | interceptor.scenario 30 | .assign(to: &$selectedScenario) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/LetSee/InAppView/LetSeeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LetSeeView.swift 3 | // LetSee 4 | // 5 | // Created by Farshad Macbook M1 Pro on 5/2/22. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | import LetSee 11 | 12 | public class LetSeeViewModel: ObservableObject { 13 | @Published var configs: LetSee.Configuration = LetSee.shared.configuration { 14 | didSet { 15 | LetSee.shared.config(configs) 16 | LetSee.shared.onMockStateChanged?(configs.isMockEnabled) 17 | } 18 | } 19 | } 20 | 21 | public struct LetSeeView: View { 22 | @ObservedObject private var viewModel: LetSeeViewModel 23 | private unowned var interceptor: RequestInterceptor 24 | @State private var isSettingCollapsed: Bool = false 25 | 26 | public init(viewModel: LetSeeViewModel) { 27 | self.viewModel = viewModel 28 | self.interceptor = LetSee.shared.interceptor 29 | } 30 | 31 | public var body: some View { 32 | NavigationView { 33 | ScrollView{ 34 | VStack(spacing: 16) { 35 | Toggle(isOn: self.$viewModel.configs.isMockEnabled) { 36 | Text(self.viewModel.configs.isMockEnabled ? "Stop Mocking" : "Start Mocking") 37 | .font(.body.bold()) 38 | } 39 | .padding(.trailing) 40 | Divider() 41 | DisclosureGroup(isExpanded: $isSettingCollapsed, content: { 42 | Toggle(isOn: self.$viewModel.configs.shouldCutBaseURLFromURLsTitle) { 43 | VStack(alignment: .leading) { 44 | Text("Cut the BaseURL from URLs title") 45 | .font(.footnote.bold()) 46 | Text(viewModel.configs.baseURL.absoluteString) 47 | .font(.caption) 48 | 49 | } 50 | } 51 | .padding(.trailing) 52 | 53 | }, label: { 54 | DisclosureGroupTitleView(string: "Settings") 55 | }) 56 | 57 | if viewModel.configs.isMockEnabled { 58 | ScenariosListView(viewModel: .init(scenarios: LetSee.shared.scenarios, interceptor: interceptor)) 59 | } 60 | 61 | RequestsListView(viewModel: .init(interceptor: interceptor)) 62 | .frame(maxWidth: .infinity) 63 | Spacer() 64 | } 65 | } 66 | .frame(maxWidth: .infinity) 67 | .padding(.top, 8) 68 | .padding(.horizontal) 69 | .if({true}, { view in 70 | view 71 | .navigationTitle("LetSee") 72 | 73 | }) 74 | .navigationViewStyle(.stack) 75 | .environment(\.letSeeConfiguration, viewModel.configs) 76 | } 77 | } 78 | } 79 | #if DEBUG 80 | struct LetSeePreviewProvider_Previews: PreviewProvider { 81 | static var previews: some View { 82 | LetSeeView(viewModel: .init()) 83 | } 84 | } 85 | #endif 86 | -------------------------------------------------------------------------------- /Sources/LetSee/InAppView/LetSeeWindow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LetSeeWindow.swift 3 | // TestApp 4 | // 5 | // Created by Farshad Jahanmanesh on 03/01/2023. 6 | // 7 | 8 | import Foundation 9 | import LetSee 10 | import UIKit 11 | import Combine 12 | 13 | /** 14 | A custom `UIWindow` subclass that displays a `LetSeeButton` and manages its state and behavior. 15 | */ 16 | public class LetSeeWindow: UIWindow { 17 | 18 | // MARK: - Properties 19 | 20 | /// The `LetSeeButton` object displayed in the window. 21 | private var letSeeButton: LetSeeButton? 22 | 23 | // MARK: - Initialization 24 | 25 | /** 26 | Initializes a new `LetSeeWindow` object with the specified frame. 27 | 28 | - Parameters: 29 | - frame: The frame for the window. 30 | */ 31 | public override init(frame: CGRect) { 32 | super.init(frame: frame) 33 | prepareLetSee() 34 | } 35 | 36 | /// A `Cancellable` object used to manage the subscriptions to the `LetSee` objects. 37 | private var disposeBag: [AnyCancellable] = [] 38 | public override func layoutSubviews() { 39 | super.layoutSubviews() 40 | letSeeButton?.updateContainerPosition() 41 | } 42 | /** 43 | Performs setup for the `LetSeeWindow` object. 44 | */ 45 | private func prepareLetSee() { 46 | self.windowLevel = UIWindow.Level.alert + 1 47 | self.isHidden = false 48 | self.makeKeyAndVisible() 49 | self.backgroundColor = .clear 50 | self.rootViewController = UIViewController() 51 | letSeeButton = LetSee.shared.addLetSeeButton(on: self) 52 | 53 | LetSee.shared.onMockStateChanged = { [weak letSeeButton] isMockActive in 54 | letSeeButton?.updateState(to: isMockActive ? .active : .inactive) 55 | } 56 | 57 | LetSee 58 | .shared 59 | .interceptor 60 | .scenario 61 | .receive(on: DispatchQueue.main) 62 | .sink {[weak letSeeButton] scenario in 63 | if let scenario { 64 | letSeeButton?.updateState(to: .activeWithScenario(scenario)) 65 | } else { 66 | letSeeButton?.updateState(to: .active) 67 | } 68 | } 69 | .store(in: &disposeBag) 70 | 71 | LetSee 72 | .shared 73 | .interceptor 74 | .$_requestQueue 75 | .receive(on: DispatchQueue.main) 76 | .sink {[weak self] requests in 77 | let number = requests.count 78 | guard number > 0 else { 79 | self?.letSeeButton?.badge = nil 80 | if !LetSee.shared.interceptor.isScenarioActive { 81 | self?.letSeeButton?.updateState(to: .active) 82 | } 83 | 84 | return 85 | } 86 | 87 | self?.letSeeButton?.badge = "\(number)" 88 | if !LetSee.shared.interceptor.isScenarioActive, let lastRequest = requests.last { 89 | self?.letSeeButton?.updateState(to: .activeWithQuickAccess(lastRequest)) 90 | self?.letSeeButton?.onMockTapped = { mock in 91 | LetSee.shared.interceptor.respond(request: lastRequest.request, with: mock) 92 | } 93 | } else { 94 | self?.letSeeButton?.onMockTapped = nil 95 | } 96 | } 97 | .store(in: &disposeBag) 98 | } 99 | 100 | required init?(coder: NSCoder) { 101 | super.init(coder: coder) 102 | prepareLetSee() 103 | } 104 | 105 | public override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { 106 | guard let letSeeButton else { 107 | return false 108 | } 109 | if self.rootViewController?.presentedViewController != nil { 110 | return true 111 | } else { 112 | return letSeeButton.point(inside: point, with: event) 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /Sources/LetSee/InAppView/MocksListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MocksListView.swift 3 | // LetSee 4 | // 5 | // Created by Farshad Macbook M1 Pro on 5/2/22. 6 | // 7 | 8 | import SwiftUI 9 | import LetSee 10 | struct MocksListView: View { 11 | var tap: ((LetSeeMock) -> Void) 12 | var request: LetSeeUrlRequest 13 | @State private var isSectionCollapsed: Dictionary = [:] 14 | @Environment(\.letSeeConfiguration) var configs 15 | var body: some View { 16 | ScrollView{ 17 | VStack(alignment: .leading, spacing: 16) { 18 | VStack(alignment: .leading){ 19 | Text("for:") 20 | .font(.subheadline) 21 | 22 | Text((request.nameBuilder(remove: configs.shouldCutBaseURLFromURLsTitle ? configs.baseURL.absoluteString : nil))) 23 | .font(.headline) 24 | .multilineTextAlignment(.leading) 25 | } 26 | 27 | ForEach(request.mocks , id: \.category) { item in 28 | if item.category == .specific { 29 | Spacer() 30 | listView(item.mocks) 31 | } else { 32 | DisclosureGroup(isExpanded: .init(get: { 33 | isSectionCollapsed[item.category.name] ?? false 34 | }, set: { value in 35 | isSectionCollapsed[item.category.name] = value 36 | })) { 37 | listView(item.mocks) 38 | } label: { 39 | DisclosureGroupTitleView(string: item.category.name) 40 | } 41 | } 42 | } 43 | Spacer() 44 | } 45 | .if({true}, { view in 46 | view 47 | .navigationTitle("Mocks") 48 | .navigationBarTitleDisplayMode(.large) 49 | }) 50 | .padding() 51 | .frame(maxWidth: .infinity, alignment: .leading) 52 | } 53 | } 54 | 55 | @ViewBuilder 56 | func listView(_ items: [LetSeeMock]) -> some View{ 57 | ForEach(Array(items) , id: \.hashValue) { mock in 58 | VStack { 59 | switch mock { 60 | case .success, .error, .failure: 61 | NavigationLink { 62 | JsonViewerView(tap: self.tap, mock: mock) 63 | } label: { 64 | LetSeeMockLabel(mock: mock) 65 | } 66 | case .live, .cancel: 67 | LetSeeMockLabel(mock: mock) 68 | .onTapGesture { 69 | tap(mock) 70 | } 71 | } 72 | Divider() 73 | } 74 | .frame(maxWidth: .infinity, alignment: .leading) 75 | .clipped() 76 | } 77 | } 78 | } 79 | 80 | public struct LetSeeMockLabel: View { 81 | public let mock: LetSeeMock 82 | @Environment(\.colorScheme) var colorScheme 83 | public init(mock: LetSeeMock) { 84 | self.mock = mock 85 | } 86 | public var body: some View { 87 | HStack { 88 | Group { 89 | switch mock { 90 | case .success: 91 | Image(systemName: "checkmark.diamond") 92 | .resizable() 93 | .foregroundColor(Color(hex: "#339900")) 94 | case .failure, .error: 95 | Image(systemName: "xmark.diamond") 96 | .resizable() 97 | .foregroundColor(Color(hex: "#cc3300")) 98 | case .cancel: 99 | Image(systemName: "minus.diamond") 100 | .resizable() 101 | .foregroundColor(Color(hex: "#cc3300")) 102 | case .live: 103 | Image(systemName: "arrow.triangle.turn.up.right.diamond") 104 | .resizable() 105 | 106 | } 107 | } 108 | .scaledToFit() 109 | .frame(width: 24, height: 24) 110 | 111 | VStack{ 112 | Text(mock.name) 113 | .font(.subheadline.weight(.medium)) 114 | .foregroundColor((colorScheme == .dark ? Color.white : Color.black)) 115 | .frame(maxWidth: .infinity, alignment: .leading) 116 | Group { 117 | 118 | switch mock { 119 | case .success: 120 | Text("Success") 121 | .font(.caption.weight(.medium)) 122 | .foregroundColor(Color(hex: "#339900")) 123 | case .failure, .error, .cancel: 124 | Text("Error") 125 | .font(.caption.weight(.medium)) 126 | .foregroundColor(Color(hex: "#cc3300")) 127 | case .live: 128 | Text("Live To Server") 129 | .font(.caption.weight(.medium)) 130 | } 131 | } 132 | .font(.subheadline.weight(.medium)) 133 | .frame(maxWidth: .infinity, alignment: .leading) 134 | } 135 | Image(systemName: "chevron.right") 136 | .foregroundColor(.gray) 137 | } 138 | 139 | } 140 | } 141 | #if DEBUG 142 | struct LetSeeMockLabel_Previews: PreviewProvider { 143 | static var previews: some View { 144 | LetSeeMockLabel(mock: .defaultFailure(name: "xxxx", data: "{}".data(using: .utf8)!)) 145 | .preferredColorScheme(.dark) 146 | } 147 | } 148 | #endif 149 | struct DisclosureGroupTitleView: View { 150 | let string: String 151 | let showDivider: Bool 152 | init(string: String, 153 | showDivider: Bool = true 154 | ) { 155 | self.string = string 156 | self.showDivider = showDivider 157 | } 158 | var body: some View { 159 | VStack(alignment: .leading) { 160 | Text(string) 161 | .font(.headline.bold()) 162 | .foregroundColor(Color(UIColor.label)) 163 | if showDivider { 164 | Divider() 165 | } 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /Sources/LetSee/InAppView/MultilineTextView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MultilineTextView.swift 3 | // 4 | // 5 | // Created by Farshad Macbook M1 Pro on 5/3/22. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import Combine 11 | 12 | // first wrap a UITextView in a UIViewRepresentable 13 | struct MultilineTextView: View { 14 | @Binding var text: String 15 | @Binding var isEditingEnabled: Bool 16 | var body: some View { 17 | TextEditor(text: $text) 18 | .disabled(!isEditingEnabled) 19 | } 20 | } 21 | 22 | fileprivate extension NSMutableAttributedString { 23 | func append(_ element: Any?) { 24 | return append(.render(element)) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/LetSee/InAppView/RequestsListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LetSeeViewModel.swift 3 | // 4 | // 5 | // Created by Farshad Macbook M1 Pro on 5/2/22. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | import LetSee 11 | public struct RequestsListView: View { 12 | @ObservedObject private var viewModel: LetSeeRequestsListViewModel 13 | @Environment(\.colorScheme) var colorScheme 14 | @Environment(\.letSeeConfiguration) private var configs: LetSee.Configuration 15 | 16 | public init(viewModel: LetSeeRequestsListViewModel) { 17 | self.viewModel = viewModel 18 | } 19 | public var body: some View { 20 | VStack(alignment: .leading, spacing: 16) { 21 | HStack(spacing: 24){ 22 | Text("Requests List") 23 | .font(.headline.weight(.heavy)) 24 | 25 | if configs.isMockEnabled { 26 | ProgressView() 27 | } 28 | } 29 | if !self.viewModel.requestList.isEmpty { 30 | ForEach(self.viewModel.requestList, id: \.request) { item in 31 | NavigationLink { 32 | MocksListView(tap: {mock in 33 | self.viewModel.response(request: item.request, mock) 34 | }, request: item) 35 | } label: { 36 | HStack { 37 | Image(systemName: "link.circle.fill") 38 | .foregroundColor(.gray) 39 | Text(item.nameBuilder(remove: configs.baseURL.absoluteString)) 40 | .font(.subheadline) 41 | .foregroundColor((colorScheme == .dark ? Color.white : Color.black).opacity(0.7)) 42 | .multilineTextAlignment(.leading) 43 | Spacer() 44 | Image(systemName: "chevron.right") 45 | .foregroundColor(.gray) 46 | } 47 | } 48 | Divider() 49 | } 50 | 51 | } else { 52 | Spacer() 53 | Text("No Request Received Yet.") 54 | .font(.subheadline) 55 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) 56 | Spacer() 57 | } 58 | } 59 | .frame(maxWidth: .infinity, alignment: .leading) 60 | } 61 | } 62 | 63 | //struct SwiftUIView_Previews: PreviewProvider { 64 | // 65 | // static var previews: some View { 66 | // let letSee = LetSee() 67 | // let _ = letSee.handle(request: URLRequest(url: URL(string: "https://www.google.com")!), useMocks: Me.mocks) 68 | // RequestsListView(viewModel: .init(letSee: letSee)) 69 | // MocksListView(tap: { _ in 70 | // 71 | // }, request: (URLRequest(url: URL(string: "https://www.google.com")!), Me.mocks, nil)) 72 | // 73 | // JsonViewerView(tap: { _ in 74 | // 75 | // }, mock: Me.mocks.first!) 76 | // } 77 | //} 78 | -------------------------------------------------------------------------------- /Sources/LetSee/InAppView/ScenariosListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScenariosListView.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 06/01/2023. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | import LetSee 11 | 12 | struct ScenariosListView: View { 13 | @ObservedObject private var viewModel: LetSeeScenariosListViewModel 14 | @Environment(\.colorScheme) var colorScheme 15 | @Environment(\.letSeeConfiguration) private var configs: LetSee.Configuration 16 | @State private var isScenariosCollapsed: Bool = false 17 | public init(viewModel: LetSeeScenariosListViewModel) { 18 | self.viewModel = viewModel 19 | } 20 | public var body: some View { 21 | DisclosureGroup(isExpanded: $isScenariosCollapsed, content: { 22 | ScrollView{ 23 | VStack(alignment: .leading, spacing: 16) { 24 | HStack(spacing: 24){ 25 | if configs.isMockEnabled { 26 | // ProgressView() 27 | } 28 | } 29 | if !self.viewModel.scenarios.isEmpty { 30 | ForEach(self.viewModel.scenarios, id: \.name) { item in 31 | Button { 32 | self.viewModel.toggleScenario(item) 33 | } label: { 34 | ScenarioRow(isSelected: .constant(item == viewModel.selectedScenario), scenario: item) 35 | } 36 | 37 | Divider() 38 | } 39 | 40 | } else { 41 | Spacer() 42 | Text("No Scenario is available.") 43 | .font(.subheadline) 44 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) 45 | Spacer() 46 | } 47 | } 48 | .frame(maxWidth: .infinity, alignment: .leading) 49 | }.frame(maxHeight: 300) 50 | }, label: { 51 | VStack(alignment: .leading) { 52 | if let selectedScenario = self.viewModel.selectedScenario, !isScenariosCollapsed { 53 | DisclosureGroupTitleView(string: "Scenarios", showDivider: false) 54 | ScenarioRow(isSelected: .constant(true), scenario: selectedScenario) 55 | Divider() 56 | } else { 57 | DisclosureGroupTitleView(string: "Scenarios") 58 | } 59 | } 60 | }) 61 | } 62 | } 63 | 64 | struct ScenarioRow: View { 65 | @Binding private var isSelected: Bool 66 | @Environment(\.colorScheme) var colorScheme 67 | private var scenario: Scenario 68 | init(isSelected: Binding, scenario: Scenario) { 69 | self._isSelected = isSelected 70 | self.scenario = scenario 71 | } 72 | var body: some View { 73 | VStack(alignment: .leading) { 74 | HStack { 75 | Image(systemName: isSelected ? "s.square.fill" : "s.square") 76 | .foregroundColor(.black) 77 | Text(scenario.name) 78 | .font(.subheadline) 79 | .foregroundColor((colorScheme == .dark ? Color.white : Color.black).opacity(0.7)) 80 | .multilineTextAlignment(.leading) 81 | Spacer() 82 | } 83 | if isSelected, let currentStep = scenario.currentStep { 84 | HStack(spacing: 8) { 85 | Text("NextResponse:") 86 | .font(.caption) 87 | Text(currentStep.name) 88 | .font(.caption.bold()) 89 | } 90 | } 91 | } 92 | } 93 | } 94 | 95 | #if DEBUG 96 | struct ScenarioRow_Previews: PreviewProvider { 97 | static var previews: some View { 98 | ScenarioRow(isSelected: .constant(true), scenario: .init(name: "Salam", mocks: [.live])) 99 | } 100 | } 101 | #endif 102 | -------------------------------------------------------------------------------- /Sources/LetSee/InAppView/View+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+Extensions.swift 3 | // LetSee 4 | // 5 | // Created by Farshad Macbook M1 Pro on 5/2/22. 6 | // 7 | 8 | import SwiftUI 9 | extension View { 10 | @ViewBuilder 11 | func `if`(_ condition: ()->Bool, @ViewBuilder _ content: (Self)-> Content) -> some View { 12 | if condition() { 13 | content(self) 14 | } else { 15 | self 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Tests/LetSeeTests/DefaultScenarioProcessorTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DefaultScenarioProcessorTests.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 14/01/2023. 6 | // 7 | 8 | import Foundation 9 | import XCTest 10 | @testable import LetSee 11 | 12 | final class DefaultScenarioProcessorTests: XCTestCase { 13 | private var sut: DefaultScenarioProcessor! 14 | override func setUp() { 15 | sut = DefaultScenarioProcessor() 16 | } 17 | 18 | override func tearDown() { 19 | sut = nil 20 | } 21 | 22 | func testWhenDirectoryIsValid_getAllFilesFromDirectoryAndSubdirectories() { 23 | let configs = GlobalMockDirectoryConfig.isExists(in: URL(fileURLWithPath: MockFileManager.defaultMocksDirectoryPath))! 24 | let mockProcessor = DefaultMockProcessor() 25 | let mocks = try! mockProcessor.buildMocks(MockFileManager.defaultMocksDirectoryPath) 26 | var scenarios = try! sut.buildScenarios(for: MockFileManager.defaultMockScenariosDirectoryPath, requestToMockMapper: {path in 27 | DefaultRequestToMockMapper.transform(request: URL(string: "https://letsee.com/" + path)!, using: mocks) 28 | }, globalConfigs: configs) 29 | XCTAssertTrue(scenarios.count > 0) 30 | let successfulSinglePayment = scenarios.first(where: {$0.name == "SuccessfulSinglePayment"}) 31 | XCTAssertNotNil(successfulSinglePayment) 32 | XCTAssertEqual(successfulSinglePayment?.mocks.count, 3) 33 | let expectedMocksName = ["ArrangementSingleItem", "ArrangementItemsList", "ValidatedPayment"].sorted() 34 | XCTAssertEqual(successfulSinglePayment?.mocks.map(\.name).sorted(), expectedMocksName) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Tests/LetSeeTests/DirectoryProcessorTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DirectoryProcessorTests.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 12/01/2023. 6 | // 7 | 8 | import Foundation 9 | import XCTest 10 | @testable import LetSee 11 | 12 | final class DirectoryProcessorTests: XCTestCase { 13 | private var sut: MockDirectoryProcessor! 14 | override func setUp() { 15 | sut = MockDirectoryProcessor() 16 | } 17 | 18 | override func tearDown() { 19 | sut = nil 20 | } 21 | 22 | func testWhenDirectoryIsValid_getAllFilesFromDirectoryAndSubdirectories() { 23 | var expectedDirectoryNames = ["InnerPath", "Arrangements", "TheLowestPath"].sorted() 24 | let mocks = try! sut.process(MockFileManager.defaultMocksDirectoryPath + "/Arrangements") 25 | XCTAssertEqual(expectedDirectoryNames, mocks.map({$0.key.path.lastPathComponent}).sorted()) 26 | 27 | var expectedFileNames = ["success_arrangementItemsList.json", "success_arrangementItemsList.json", "success_arrangementItemsList.json", "success_arrangementSingleItem.json"].sorted() 28 | XCTAssertEqual(expectedFileNames, mocks.map({$0.value}).flatMap({$0}).map({$0.url.lastPathComponent}).sorted()) 29 | 30 | expectedDirectoryNames = ["FolderWithConfig", "orders"].sorted() 31 | let mockFolderWithConfigs = try! sut.process(MockFileManager.defaultMocksDirectoryPath + "/FolderWithConfig") 32 | XCTAssertEqual(expectedDirectoryNames, mockFolderWithConfigs.map({$0.key.path.lastPathComponent}).sorted()) 33 | 34 | expectedFileNames = ["error_rejectedPayment.json", "success_arrangementSingleItem.json", "success_validatedPayment.json"].sorted() 35 | XCTAssertEqual(expectedFileNames, mockFolderWithConfigs.flatMap({$0.value}).map({$0.fileInformation.name}).sorted()) 36 | } 37 | 38 | func testWhenDirectoryIsValid_getAllFilesFromDirectoryAndSubdirectoriesInRootFolder() { 39 | let expectedDirectoryNames = ["Arrangements", "FolderWith2Configs", "FolderWithConfig", "General", "InnerPath", "Payment-orders", "TheLowestPath", "details", "orders", "orders"].sorted() 40 | let mocks = try! sut.process(MockFileManager.defaultMocksDirectoryPath) 41 | XCTAssertEqual(expectedDirectoryNames, mocks.map({$0.key.path.lastPathComponent}).sorted()) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Tests/LetSeeTests/FileDirectoryProcessorTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileDirectoryProcessorTests.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 13/01/2023. 6 | // 7 | 8 | import Foundation 9 | import XCTest 10 | @testable import LetSee 11 | 12 | final class FileDirectoryProcessorTests: XCTestCase { 13 | private var sut: FileDirectoryProcessor! 14 | override func setUp() { 15 | sut = FileDirectoryProcessor(rawFileProcessor: RawDirectoryProcessor(fileManager: MockFileManager())) 16 | } 17 | 18 | override func tearDown() { 19 | sut = nil 20 | } 21 | 22 | func testWhenDirectoryIsValid_getAllFilesFromDirectoryAndSubdirectories() { 23 | var expectedDirectoryNames = ["InnerPath", "Arrangements", "TheLowestPath"].sorted() 24 | let mocks = try! sut.process(MockFileManager.defaultMocksDirectoryPath + "/Arrangements") 25 | XCTAssertEqual(expectedDirectoryNames, mocks.map({$0.key.path.lastPathComponent}).sorted()) 26 | 27 | var expectedFileNames = ["success_arrangementItemsList.json", "success_arrangementItemsList.json", "success_arrangementItemsList.json", "success_arrangementSingleItem.json"].sorted() 28 | XCTAssertEqual(expectedFileNames, mocks.map({$0.value}).flatMap({$0}).map({$0.url.lastPathComponent}).sorted()) 29 | 30 | expectedDirectoryNames = ["FolderWithConfig", "orders"].sorted() 31 | let mockFolderWithConfigs = try! sut.process(MockFileManager.defaultMocksDirectoryPath + "/FolderWithConfig") 32 | XCTAssertEqual(expectedDirectoryNames, mockFolderWithConfigs.map({$0.key.path.lastPathComponent}).sorted()) 33 | 34 | expectedFileNames = ["error_rejectedPayment.json", "success_arrangementSingleItem.json", "success_validatedPayment.json"].sorted() 35 | XCTAssertEqual(expectedFileNames, mockFolderWithConfigs.flatMap({$0.value}).map({$0.url.lastPathComponent}).sorted()) 36 | } 37 | 38 | func testWhenToFolderAreRelativeToEachOther_makeRelativePath_shouldReturnTheDifferenceBetweenThem() { 39 | let mockDirectory = MockFileManager.defaultMocksDirectoryPath 40 | let subPath = URL(string: MockFileManager.defaultMocksDirectoryPath + "/Arrangements/innerpath")! 41 | 42 | let expected = "/arrangements/innerpath" 43 | let result = sut.makeRelativePath(for: subPath, relativeTo: mockDirectory) 44 | XCTAssertEqual(expected, result) 45 | } 46 | 47 | func testWhenCofnigsAvailable_shouldOverridetheRelativePath() { 48 | let expectedOverriddenPaths = ["/api/arrangement-manager/client-api/v2/productsummary/context/arrangements/", "/api/arrangement-manager/client-api/v2/productsummary/context/arrangements/innerpath/", "/api/arrangement-manager/client-api/v2/productsummary/context/arrangements/innerpath/thelowestpath/"] 49 | let expectedOverriddenFiles = ["/arrangements/innerpath/success_arrangementitemslist.json", 50 | "/arrangements/innerpath/thelowestpath/success_arrangementitemslist.json", 51 | "/arrangements/success_arrangementitemslist.json", 52 | "/arrangements/success_arrangementsingleitem.json"].sorted() 53 | let mocks = try! sut.process(MockFileManager.defaultMocksDirectoryPath) 54 | let directories = mocks.map({$0.key}).filter({$0.path.absoluteString.contains("/Mocks/Arrangements/")}) 55 | let files = mocks.flatMap({$0.value}).filter({$0.relativePath.hasPrefix("/arrangements/")}) 56 | XCTAssertEqual(expectedOverriddenPaths, directories.compactMap({$0.relativePath}).sorted()) 57 | XCTAssertEqual(expectedOverriddenFiles, files.map({$0.relativePath}).sorted()) 58 | 59 | let expectedFileNames = ["success_arrangementItemsList.json", 60 | "success_arrangementItemsList.json", 61 | "success_arrangementItemsList.json", 62 | "success_arrangementSingleItem.json"].sorted() 63 | XCTAssertEqual(expectedFileNames, files.map(\.name).sorted()) 64 | } 65 | 66 | func testWhenCofnigsAvailable_shouldOverridetheRelativePath_InnerFolderShouldRespectsTheirOwnCondigFile() { 67 | let expectedOverriddenPaths = ["/api/arrangement-manager/v4/details/folderwith2configs/" , "/api/arrangement-manager/v3/orders/folderwith2configs/orders/", 68 | "/api/arrangement-manager/v4/details/folderwith2configs/details/"] 69 | .sorted() 70 | let mocks = try! sut.process(MockFileManager.defaultMocksDirectoryPath) 71 | let directories = mocks.map({$0.key}).filter({$0.path.absoluteString.contains("/Mocks/FolderWith2Configs/")}) 72 | XCTAssertEqual(expectedOverriddenPaths, directories.compactMap({$0.relativePath}).sorted()) 73 | } 74 | 75 | func testShouldCollectScenariosCorrectly() { 76 | let expectedOverriddenPaths = ["HappyFlowFirstSuccessThenReject.plist" , "SuccessfulSinglePayment.plist"] 77 | let mocks = try! sut.process(MockFileManager.defaultMockScenariosDirectoryPath) 78 | let directories = mocks.flatMap({$0.value}) 79 | XCTAssertEqual(expectedOverriddenPaths, directories.compactMap({$0.name}).sorted()) 80 | } 81 | 82 | func test_whenGlobalConfigIsAvailable_theMapsArrayShouldSortDescBasedOnFoldersName() { 83 | let json = GlobalMockDirectoryConfig.mockJSON 84 | let globalConfigs = try! JSONDecoder().decode(GlobalMockDirectoryConfig.self, from: json.data(using: .utf8)!) 85 | let expected = ["/folderwith2configs/orders/", "/folderwith2configs/", "/arrangements/"] 86 | let result = globalConfigs.maps.map({$0.folder}) 87 | XCTAssertEqual(expected, result) 88 | } 89 | 90 | func test_whenGlobalConfigsHasMap_ItShouldReturnACorrectMapForSpecificFolderPath() { 91 | let json = GlobalMockDirectoryConfig.mockJSON 92 | let globalConfigs = try! JSONDecoder().decode(GlobalMockDirectoryConfig.self, from: json.data(using: .utf8)!) 93 | let expected = GlobalMockDirectoryConfig.Map(folder: "/arrangements/", to: "/api/arrangement-manager/client-api/v2/productsummary/context") 94 | let result = globalConfigs.hasMap(for: expected.folder) 95 | XCTAssertNotNil(result) 96 | XCTAssertEqual(expected, result) 97 | } 98 | 99 | func test_whenGlobalConfigsHasMap_shouldLowerCasedAllValues_onDecoding() { 100 | let json = GlobalMockDirectoryConfig.mockJSON 101 | let globalConfigs = try! JSONDecoder().decode(GlobalMockDirectoryConfig.self, from: json.data(using: .utf8)!) 102 | let expected = GlobalMockDirectoryConfig.Map(folder: "/Arrangements/", to: "/Api/Arrangement-manager/client-api/v2/Productsummary/Context") 103 | let result = globalConfigs.hasMap(for: expected.folder) 104 | 105 | XCTAssertEqual(expected.folder.lowercased(), result?.folder) 106 | XCTAssertEqual(expected.to.lowercased(), result?.to) 107 | } 108 | 109 | func test_whenInitAMap_shouldLowerCasedAllValues() { 110 | let folder = "/Arrangements/" 111 | let to = "/Api/Arrangement-manager/client-api/v2/Productsummary/Context" 112 | let result = GlobalMockDirectoryConfig.Map(folder: folder, to: to) 113 | 114 | XCTAssertEqual(folder.lowercased(), result.folder) 115 | XCTAssertEqual(to.lowercased(), result.to) 116 | } 117 | } 118 | 119 | extension GlobalMockDirectoryConfig { 120 | static var mockJSON: String { """ 121 | { 122 | "maps": [ 123 | { 124 | "folder": "/arrangements/", 125 | "to": "/api/arrangement-manager/client-api/v2/productsummary/context" 126 | }, 127 | { 128 | "folder": "/folderWith2Configs/", 129 | "to": "/api/arrangement-manager/v4/details" 130 | }, 131 | { 132 | "folder": "/folderWith2Configs/orders/", 133 | "to": "/api/arrangement-manager/v3/orders" 134 | } 135 | ] 136 | } 137 | 138 | 139 | """} 140 | } 141 | -------------------------------------------------------------------------------- /Tests/LetSeeTests/FileToLetSeeMockMappingTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileToLetSeeMockMappingTests.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 30/01/2023. 6 | // 7 | 8 | import Foundation 9 | import XCTest 10 | @testable import LetSee 11 | final class FileToLetSeeMockMappingTests: XCTestCase { 12 | var fileToLetSeeMockMapping: LetSee.DefaultFileToLetSeeMockMapping! 13 | let fileName = "fileName" 14 | let jsonData = Data() 15 | 16 | override func setUp() { 17 | fileToLetSeeMockMapping = LetSee.DefaultFileToLetSeeMockMapping() 18 | } 19 | 20 | func testMapSuccess() { 21 | let fileName = "success_fileName" 22 | let result = fileToLetSeeMockMapping.map(fileName: fileName, jsonData: jsonData) 23 | switch result { 24 | case .success(let name, let response, let data): 25 | XCTAssertEqual(name, "fileName") 26 | XCTAssertEqual(response?.stateCode, 200) 27 | XCTAssertEqual(response?.header, [:]) 28 | XCTAssertEqual(data, jsonData) 29 | default: 30 | XCTFail("Unexpected result") 31 | } 32 | } 33 | 34 | func testMapFailure() { 35 | let fileName = "error_fileName" 36 | let result = fileToLetSeeMockMapping.map(fileName: fileName, jsonData: jsonData) 37 | switch result { 38 | case .failure(let name, let response, let data): 39 | XCTAssertEqual(name, "fileName") 40 | XCTAssertEqual(response, .badServerResponse) 41 | XCTAssertEqual(data, jsonData) 42 | default: 43 | XCTFail("Unexpected result") 44 | } 45 | } 46 | 47 | func testSanitize() { 48 | let fileName = "error_fileName" 49 | let result = fileToLetSeeMockMapping.sanitize(fileName) 50 | XCTAssertEqual(result, "fileName") 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Tests/LetSeeTests/JSONFileInformation/JSONFileInformationTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JSONFileInformationTests.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 11/01/2023. 6 | // 7 | 8 | import XCTest 9 | @testable import LetSee 10 | extension FileInformation { 11 | static let successWithDelay: Self = FileInformation(name: "success_200_50ms_fileName.json", filePath: URL(string: "https://google.com")!, relativePath: "") 12 | } 13 | final class JSONFileInformationTests: XCTestCase { 14 | var sut: JSONFileNameParser! 15 | override func setUp() { 16 | sut = JSONFileNameParser() 17 | } 18 | override func tearDown() { 19 | sut = nil 20 | } 21 | 22 | func testsParseFunction_whenCorrectName_shouldBeAbleToParseTheFileName() { 23 | let fileInformation = FileInformation.successWithDelay 24 | let expected = MockFileInformation(fileInformation: fileInformation, statusCode: 200, delay: 50, status: .success, displayName: "FileName") 25 | let result = try? sut.parse(fileInformation) 26 | XCTAssertNotNil(result) 27 | XCTAssertEqual(result, expected) 28 | } 29 | 30 | func testsParseFunction_whenCorrectName_shouldBeAbleToParseTheFileNameNoStatusCodeWithDelay() { 31 | let fileInformation = FileInformation(name: "success_50ms_fileName.json", filePath: URL(string: "https://google.com")!, relativePath: "") 32 | let expected = MockFileInformation(fileInformation: fileInformation, statusCode: nil, delay: 50, status: .success, displayName: "FileName") 33 | let result = try? sut.parse(fileInformation) 34 | XCTAssertNotNil(result) 35 | XCTAssertEqual(result, expected) 36 | } 37 | 38 | // func testsParseFunction_whenCorrectName_shouldCorrectlyParseFileName() { 39 | // let allJsonFilesInGivenMockDirectory = MockFileManager().recursivelyFindAllFiles(for: MockFileManager.defaultMocksDirectoryPath, ofType: "json") 40 | // let path = allJsonFilesInGivenMockDirectory.first! 41 | // let fileInformation = try! sut.parse(path) 42 | // XCTAssertTrue("\(fileInformation.type)_\(fileInformation.name).\(fileInformation.fileType)".caseInsensitiveCompare(path.lastPathComponent) == .orderedSame) 43 | // XCTAssertEqual(fileInformation.filePath, path) 44 | // XCTAssertEqual(fileInformation.filePath, path) 45 | // } 46 | // 47 | // func testsParseFunction_whenCorrectName_shouldCorrectlyParseDetailsFileName() { 48 | // let allJsonFilesInGivenMockDirectory = MockFileManager().recursivelyFindAllFiles(for: MockFileManager.defaultMocksDirectoryPath, ofType: "json") 49 | // var path = allJsonFilesInGivenMockDirectory.first! 50 | // var lastComponent = "success_200_50ms_fileName.json" 51 | // var updatedPath = path.absoluteString.replacingOccurrences(of: path.lastPathComponent, with: lastComponent) 52 | // let expectedDetails = FileInformation.Details(statusCode: 200, delay: 50, baseURL: nil) 53 | // let expectedResult = FileInformation(name: "FileName", type: "success", filePath: URL(fileURLWithPath: updatedPath), fileType: "json", details: expectedDetails) 54 | // let fileInformation = try! sut.parse(URL(fileURLWithPath: updatedPath)) 55 | // 56 | // XCTAssertEqual(fileInformation, expectedResult) 57 | // } 58 | // 59 | // func testsParseFunction_whenCorrectName_shouldReturnErrorIfFileNameIsNotParsable() { 60 | // let allJsonFilesInGivenMockDirectory = MockFileManager().recursivelyFindAllFiles(for: MockFileManager.defaultMocksDirectoryPath, ofType: "json") 61 | // var path = allJsonFilesInGivenMockDirectory.first! 62 | // var lastComponent = "fileName.json" 63 | // var updatedPath = path.absoluteString.replacingOccurrences(of: path.lastPathComponent, with: lastComponent) 64 | // XCTAssertThrowsError(try sut.parse(URL(fileURLWithPath: updatedPath))) 65 | // } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /Tests/LetSeeTests/LetSeeURLProtocolTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LetSeeURLProtocol.swift 3 | // 4 | // 5 | // Created by Farshad Macbook M1 Pro on 5/2/22. 6 | // 7 | 8 | import Foundation 9 | import XCTest 10 | @testable import LetSee 11 | 12 | public struct Me { 13 | public let name: String 14 | public let family: String 15 | public init( 16 | name: String, 17 | family: String) { 18 | self.name = name 19 | self.family = family 20 | 21 | } 22 | } 23 | 24 | extension Me: LetSeeMockProviding { 25 | public static var mocks: Set { 26 | [ 27 | .defaultSuccess(name: "Normal User", data: 28 | """ 29 | { 30 | 'name':'Farshad', 31 | 'family': 'Jahanmanesh' 32 | } 33 | """.data(using: .utf8)! 34 | ), 35 | 36 | .defaultFailure(name: "User Not Found", data: 37 | """ 38 | { 39 | 'message':'User not found.' 40 | } 41 | """.data(using: .utf8)! 42 | ), 43 | 44 | .defaultFailure(name: "User is Not Active", data: 45 | """ 46 | { 47 | 'message':'User is Not Active.' 48 | } 49 | """.data(using: .utf8)! 50 | ), 51 | 52 | .defaultSuccess(name: "Admin User", data: 53 | """ 54 | { 55 | 'name':'Farshad', 56 | 'family': 'Jahanmanesh' 57 | } 58 | """.data(using: .utf8)! 59 | ), 60 | ] 61 | } 62 | } 63 | 64 | final class LetSeeURLProtocolTest: XCTestCase { 65 | // var letSee: LetSee! 66 | // var session: URLSession! 67 | // override func setUpWithError() throws { 68 | // letSee = LetSee(mocksDirectoryName: "", on: .main) 69 | // let configuration = URLSessionConfiguration.ephemeral 70 | // LetSeeURLProtocol.letSee = letSee.interceptor 71 | // configuration.protocolClasses = [LetSeeURLProtocol.self] 72 | // session = URLSession(configuration: configuration) 73 | // } 74 | // 75 | // override func tearDownWithError() throws { 76 | // letSee = nil 77 | // session = nil 78 | // } 79 | // 80 | // func testAddingRequest() { 81 | // let url = URLRequest(url: URL(string: "https://google.com")!) 82 | // letSee.interceptor.intercept(request: url, availableMocks: Me.mocks) 83 | // XCTAssertNotNil(letSee.interceptor.indexOf(request: url)) 84 | // } 85 | // 86 | // func testSuccessResponseARequest() { 87 | // let waitForResponse = expectation(description: "Wait For Response") 88 | // let url = URLRequest(url: URL(string: "https://google.com")!) 89 | // letSee.interceptor.intercept(request: url, availableMocks: Me.mocks) 90 | // session.dataTask(with: url) { data, response, error in 91 | // waitForResponse.fulfill() 92 | // } 93 | // .resume() 94 | // sleep(1) 95 | // if case let .success(_, response, data) = LetSeeMock.defaultSuccess(name: "", data: "") { 96 | // letSee.interceptor.respond(request: url, with: .success((response?.asURLResponse, data.data(using: .utf8)))) 97 | // } 98 | // 99 | // wait(for: [waitForResponse], timeout: 10) 100 | // } 101 | // 102 | // func testRemoveRequestAfterResponse() { 103 | // let waitForResponse = expectation(description: "Wait For Response") 104 | // let url = URLRequest(url: URL(string: "https://google.com")!) 105 | // letSee.interceptor.intercept(request: url, availableMocks: Me.mocks) 106 | // session.dataTask(with: url) { data, response, error in 107 | // guard let _ = data else { 108 | // return 109 | // } 110 | // waitForResponse.fulfill() 111 | // } 112 | // .resume() 113 | // sleep(1) 114 | // if case let .success(_, response, data) = LetSeeMock.defaultSuccess(name: "", data: "") { 115 | // letSee.interceptor.respond(request: url, with: .success((response?.asURLResponse, data.data(using: .utf8)))) 116 | // } 117 | // sleep(1) 118 | // let index = letSee.interceptor.indexOf(request: url) 119 | // XCTAssertNil(index) 120 | // wait(for: [waitForResponse], timeout: 10) 121 | // } 122 | // 123 | // func testErrorResponseARequest() { 124 | // let waitForResponse = expectation(description: "Wait For Response") 125 | // let url = URLRequest(url: URL(string: "https://google.com")!) 126 | // letSee.interceptor.intercept(request: url, availableMocks: Me.mocks) 127 | // session.dataTask(with: url) { data, response, error in 128 | // guard let _ = error else { 129 | // return 130 | // } 131 | // 132 | // waitForResponse.fulfill() 133 | // } 134 | // .resume() 135 | // sleep(1) 136 | // letSee.interceptor.respond(request: url, with: .failure(LetSeeError(error: .badServerResponse, data: nil))) 137 | // wait(for: [waitForResponse], timeout: 10) 138 | // } 139 | } 140 | -------------------------------------------------------------------------------- /Tests/LetSeeTests/MockFileManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockFileManager.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 08/01/2023. 6 | // 7 | 8 | import Foundation 9 | class MockFileManager: FileManager { 10 | func recursivelyFindAllFiles(for path: String, ofType filterType: String? = nil) -> [URL] { 11 | let url = URL(fileURLWithPath: path) 12 | var files = [URL]() 13 | if let enumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) { 14 | for case let fileURL as URL in enumerator { 15 | do { 16 | let fileAttributes = try fileURL.resourceValues(forKeys:[.isRegularFileKey]) 17 | if fileAttributes.isRegularFile! { 18 | files.append(fileURL) 19 | } 20 | } catch { 21 | print(error, fileURL) 22 | } 23 | } 24 | } 25 | if let filterType { 26 | files = files.filter{$0.pathExtension == filterType} 27 | } 28 | return files 29 | } 30 | } 31 | 32 | -------------------------------------------------------------------------------- /Tests/LetSeeTests/MockScenarios/HappyFlow.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | steps 6 | 7 | 8 | folder 9 | arrangements 10 | responseFileName 11 | success_arrangementItemsList 12 | 13 | 14 | folder 15 | arrangements 16 | responseFileName 17 | success_arrangementItemsList 18 | 19 | 20 | folder 21 | arrangements 22 | responseFileName 23 | success_arrangementItemsList 24 | 25 | 26 | folder 27 | arrangements 28 | responseFileName 29 | success_arrangementItemsList 30 | 31 | 32 | folder 33 | Payment-orders 34 | responseFileName 35 | success_acceptedPayment 36 | 37 | 38 | folder 39 | arrangements 40 | responseFileName 41 | success_arrangementItemsList 42 | 43 | 44 | folder 45 | arrangements 46 | responseFileName 47 | success_arrangementItemsList 48 | 49 | 50 | folder 51 | Payment-orders 52 | responseFileName 53 | error_rejectedPayment 54 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /Tests/LetSeeTests/MockScenarios/SuccessfulSinglePayment.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | steps 6 | 7 | 8 | folder 9 | FolderWithConfig 10 | responseFileName 11 | success_arrangementSingleItem 12 | 13 | 14 | folder 15 | arrangements 16 | responseFileName 17 | success_arrangementItemsList 18 | 19 | 20 | folder 21 | payment-orders 22 | responseFileName 23 | success_200_200ms_validatedPayment 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /Tests/LetSeeTests/Mocks/.ls.global.json: -------------------------------------------------------------------------------- 1 | { 2 | "maps": [ 3 | { 4 | "folder": "/arrangements/", 5 | "to": "/api/arrangement-manager/client-api/v2/productsummary/context" 6 | }, 7 | { 8 | "folder": "/folderWith2Configs/", 9 | "to": "/api/arrangement-manager/v4/details" 10 | }, 11 | { 12 | "folder": "/folderWith2Configs/orders/", 13 | "to": "/api/arrangement-manager/v3/orders" 14 | } 15 | ] 16 | } 17 | 18 | -------------------------------------------------------------------------------- /Tests/LetSeeTests/Mocks/Arrangements/success_arrangementSingleItem.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "1cdb2224-8926-4b4d-a99f-1c9dfbbb4691", 4 | "externalArrangementId": "bcf10f4d-4b2f-4413-9bab-31ff693608b5", 5 | "externalLegalEntityId": "c7a382786d514262b75ab9531b749a2b", 6 | "externalProductId": "fade7867-533e-465e-90cb-e41675c54400", 7 | "name": "Mr and Mrs J. Smith", 8 | "bankAlias": "Our joined account", 9 | "sourceId": "LJBASI2XXXX", 10 | "bookedBalance": 1000.00, 11 | "availableBalance": 1500.00, 12 | "creditLimit": 442.12, 13 | "IBAN": "CY3887370130MJFTJ3B8Y9W7IGRO", 14 | "BBAN": "8873701303897", 15 | "currency": "AED", 16 | "externalTransferAllowed": true, 17 | "urgentTransferAllowed": true, 18 | "accruedInterest": 0.54, 19 | "number": "PANS", 20 | "principalAmount": 620.54, 21 | "currentInvestmentValue": 0.16, 22 | "legalEntityIds": ["257da57a-11e4-4553-9175-54baf755069b" , "cd83683b-13f2-43d8-882b-39c9ab27d499"], 23 | "productId": "36c8fc42-ec97-4f83-8a7c-d622625007f3", 24 | "productNumber": "ffdd939c-ac4a-4441-ae47-70a7259899e7", 25 | "productKindName": "Current Account", 26 | "productTypeName": "Current Account", 27 | "BIC": "AABAFI22", 28 | "bankBranchCode": "bankBranchCode", 29 | "visible": false, 30 | "accountOpeningDate": "2016-01-28T16:41:41.090Z", 31 | "accountInterestRate": 100.2, 32 | "valueDateBalance": 100.1, 33 | "creditLimitUsage": 100.3, 34 | "creditLimitInterestRate": 100.4, 35 | "creditLimitExpiryDate": "2019-09-28T16:41:41.090Z", 36 | "startDate": "2016-02-28T16:41:41.090Z", 37 | "termUnit": "Y", 38 | "termNumber": 50, 39 | "interestPaymentFrequencyUnit": "M", 40 | "interestPaymentFrequencyNumber": 15, 41 | "maturityDate": "2017-02-28T16:41:41.090Z", 42 | "maturityAmount": 99.5, 43 | "autoRenewalIndicator": true, 44 | "interestSettlementAccount": "interestSettlementAccount1", 45 | "outstandingPrincipalAmount": 100.2, 46 | "monthlyInstalmentAmount": 100.1, 47 | "amountInArrear": 100.3, 48 | "minimumRequiredBalance": 80.4, 49 | "creditCardAccountNumber": "123456", 50 | "validThru": "2019-02-28T16:41:41.090Z", 51 | "applicableInterestRate": 101.2, 52 | "remainingCredit": 50, 53 | "outstandingPayment": 105.5, 54 | "minimumPayment": 51.1, 55 | "minimumPaymentDueDate": "2018-02-28T16:41:41.090Z", 56 | "totalInvestmentValue": 110.2, 57 | "debitCards": [ 58 | { 59 | "number": "4578", 60 | "expiryDate": "2020-08-22", 61 | "cardId": "id1", 62 | "cardholderName": "John Doe", 63 | "cardType": "Visa Electron", 64 | "cardStatus": "Active" 65 | }, 66 | { 67 | "number": "3241", 68 | "expiryDate": "2019-01-14", 69 | "cardId": "id2", 70 | "cardholderName": "John Doe", 71 | "cardType": "Visa", 72 | "cardStatus": "Active" 73 | } 74 | ], 75 | "accountHolderAddressLine1": "accountHolderAddressLine11", 76 | "accountHolderAddressLine2": "accountHolderAddressLine12", 77 | "accountHolderStreetName": "accountHolderStreetName1", 78 | "town": "Paris", 79 | "postCode": "2000", 80 | "countrySubDivision": "countrySubDivision1", 81 | "accountHolderName": "Danthe Mohr", 82 | "accountHolderNames": "Danthe Mohr,Toso Malerot", 83 | "accountHolderCountry": "FR", 84 | "creditAccount": true, 85 | "debitAccount": true, 86 | "lastUpdateDate":"2016-01-28T16:41:41.090Z", 87 | "favorite": true, 88 | "userPreferences": { 89 | "arrangementId": "9aabca5b-63ae-47d9-8d63-c5ed0e831dfa", 90 | "alias": "Our joined account", 91 | "visible": true, 92 | "favorite": false 93 | }, 94 | "product": { 95 | "externalId": "externalProductidId", 96 | "externalTypeId": "externalProductTypeId", 97 | "typeName": "Current Account", 98 | "productKind": { 99 | "id": 1, 100 | "externalKindId": "kind1", 101 | "kindName": "Current Account", 102 | "kindUri": "current-account" 103 | } 104 | }, 105 | "state": { 106 | "externalStateId": "externalStateId1", 107 | "state": "Active" 108 | } 109 | } 110 | ] 111 | -------------------------------------------------------------------------------- /Tests/LetSeeTests/Mocks/FolderWith2Configs/details/error_rejectedPayment.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "436ffb31-7c89-463d-9200-779c99a0ec21", 3 | "status": "REJECTED", 4 | "bankStatus": "REJECTED", 5 | "reasonCode": "400", 6 | "reasonText": "{\"errors\":[{\"errorCode\":651,\"error", 7 | "errorDescription": "{\"errors\":[{\"errorCode\":651,\"errorReason\":\"CURRENCY_NOT_MATCHING\"}]}" 8 | } 9 | -------------------------------------------------------------------------------- /Tests/LetSeeTests/Mocks/FolderWith2Configs/orders/error_rejectedPayment.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "436ffb31-7c89-463d-9200-779c99a0ec21", 3 | "status": "REJECTED", 4 | "bankStatus": "REJECTED", 5 | "reasonCode": "400", 6 | "reasonText": "{\"errors\":[{\"errorCode\":651,\"error", 7 | "errorDescription": "{\"errors\":[{\"errorCode\":651,\"errorReason\":\"CURRENCY_NOT_MATCHING\"}]}" 8 | } 9 | -------------------------------------------------------------------------------- /Tests/LetSeeTests/Mocks/FolderWith2Configs/success_arrangementSingleItem.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "1cdb2224-8926-4b4d-a99f-1c9dfbbb4691", 4 | "externalArrangementId": "bcf10f4d-4b2f-4413-9bab-31ff693608b5", 5 | "externalLegalEntityId": "c7a382786d514262b75ab9531b749a2b", 6 | "externalProductId": "fade7867-533e-465e-90cb-e41675c54400", 7 | "name": "Mr and Mrs J. Smith", 8 | "bankAlias": "Our joined account", 9 | "sourceId": "LJBASI2XXXX", 10 | "bookedBalance": 1000.00, 11 | "availableBalance": 1500.00, 12 | "creditLimit": 442.12, 13 | "IBAN": "CY3887370130MJFTJ3B8Y9W7IGRO", 14 | "BBAN": "8873701303897", 15 | "currency": "AED", 16 | "externalTransferAllowed": true, 17 | "urgentTransferAllowed": true, 18 | "accruedInterest": 0.54, 19 | "number": "PANS", 20 | "principalAmount": 620.54, 21 | "currentInvestmentValue": 0.16, 22 | "legalEntityIds": ["257da57a-11e4-4553-9175-54baf755069b" , "cd83683b-13f2-43d8-882b-39c9ab27d499"], 23 | "productId": "36c8fc42-ec97-4f83-8a7c-d622625007f3", 24 | "productNumber": "ffdd939c-ac4a-4441-ae47-70a7259899e7", 25 | "productKindName": "Current Account", 26 | "productTypeName": "Current Account", 27 | "BIC": "AABAFI22", 28 | "bankBranchCode": "bankBranchCode", 29 | "visible": false, 30 | "accountOpeningDate": "2016-01-28T16:41:41.090Z", 31 | "accountInterestRate": 100.2, 32 | "valueDateBalance": 100.1, 33 | "creditLimitUsage": 100.3, 34 | "creditLimitInterestRate": 100.4, 35 | "creditLimitExpiryDate": "2019-09-28T16:41:41.090Z", 36 | "startDate": "2016-02-28T16:41:41.090Z", 37 | "termUnit": "Y", 38 | "termNumber": 50, 39 | "interestPaymentFrequencyUnit": "M", 40 | "interestPaymentFrequencyNumber": 15, 41 | "maturityDate": "2017-02-28T16:41:41.090Z", 42 | "maturityAmount": 99.5, 43 | "autoRenewalIndicator": true, 44 | "interestSettlementAccount": "interestSettlementAccount1", 45 | "outstandingPrincipalAmount": 100.2, 46 | "monthlyInstalmentAmount": 100.1, 47 | "amountInArrear": 100.3, 48 | "minimumRequiredBalance": 80.4, 49 | "creditCardAccountNumber": "123456", 50 | "validThru": "2019-02-28T16:41:41.090Z", 51 | "applicableInterestRate": 101.2, 52 | "remainingCredit": 50, 53 | "outstandingPayment": 105.5, 54 | "minimumPayment": 51.1, 55 | "minimumPaymentDueDate": "2018-02-28T16:41:41.090Z", 56 | "totalInvestmentValue": 110.2, 57 | "debitCards": [ 58 | { 59 | "number": "4578", 60 | "expiryDate": "2020-08-22", 61 | "cardId": "id1", 62 | "cardholderName": "John Doe", 63 | "cardType": "Visa Electron", 64 | "cardStatus": "Active" 65 | }, 66 | { 67 | "number": "3241", 68 | "expiryDate": "2019-01-14", 69 | "cardId": "id2", 70 | "cardholderName": "John Doe", 71 | "cardType": "Visa", 72 | "cardStatus": "Active" 73 | } 74 | ], 75 | "accountHolderAddressLine1": "accountHolderAddressLine11", 76 | "accountHolderAddressLine2": "accountHolderAddressLine12", 77 | "accountHolderStreetName": "accountHolderStreetName1", 78 | "town": "Paris", 79 | "postCode": "2000", 80 | "countrySubDivision": "countrySubDivision1", 81 | "accountHolderName": "Danthe Mohr", 82 | "accountHolderNames": "Danthe Mohr,Toso Malerot", 83 | "accountHolderCountry": "FR", 84 | "creditAccount": true, 85 | "debitAccount": true, 86 | "lastUpdateDate":"2016-01-28T16:41:41.090Z", 87 | "favorite": true, 88 | "userPreferences": { 89 | "arrangementId": "9aabca5b-63ae-47d9-8d63-c5ed0e831dfa", 90 | "alias": "Our joined account", 91 | "visible": true, 92 | "favorite": false 93 | }, 94 | "product": { 95 | "externalId": "externalProductidId", 96 | "externalTypeId": "externalProductTypeId", 97 | "typeName": "Current Account", 98 | "productKind": { 99 | "id": 1, 100 | "externalKindId": "kind1", 101 | "kindName": "Current Account", 102 | "kindUri": "current-account" 103 | } 104 | }, 105 | "state": { 106 | "externalStateId": "externalStateId1", 107 | "state": "Active" 108 | } 109 | } 110 | ] 111 | -------------------------------------------------------------------------------- /Tests/LetSeeTests/Mocks/FolderWithConfig/orders/error_rejectedPayment.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "436ffb31-7c89-463d-9200-779c99a0ec21", 3 | "status": "REJECTED", 4 | "bankStatus": "REJECTED", 5 | "reasonCode": "400", 6 | "reasonText": "{\"errors\":[{\"errorCode\":651,\"error", 7 | "errorDescription": "{\"errors\":[{\"errorCode\":651,\"errorReason\":\"CURRENCY_NOT_MATCHING\"}]}" 8 | } 9 | -------------------------------------------------------------------------------- /Tests/LetSeeTests/Mocks/FolderWithConfig/orders/success_validatedPayment.json: -------------------------------------------------------------------------------- 1 | { 2 | "originatorAccount": { 3 | "arrangementId": "729190df-a421-4937-94fd-5e1a3da132cc", 4 | "externalArrangementId": "729190421493794513132", 5 | "identification": { 6 | "identification": "NL53RABO0309349755", 7 | "schemeName": "IBAN" 8 | } 9 | }, 10 | "originator": { 11 | "name": "Credit Account", 12 | "postalAddress": { 13 | "addressLine1": "Jacob Bontiusplaats 9, 1018LL, Amsterdam" 14 | } 15 | }, 16 | "instructionPriority": "NORM", 17 | "requestedExecutionDate": "2017-07-16", 18 | "paymentType": "SEPA_CREDIT_TRANSFER", 19 | "isIntraLegalEntityPaymentOrder": false, 20 | "canApprove": false, 21 | "finalApprover": false, 22 | "transferTransactionInformation": { 23 | "instructedAmount": { 24 | "amount": "5000.55", 25 | "currencyCode": "EUR" 26 | }, 27 | "counterpartyAccount": { 28 | "identification": { 29 | "identification": "FR708933019952AUNHQNQ0KZ", 30 | "schemeName": "IBAN" 31 | }, 32 | "name": "ABN Amro" 33 | }, 34 | "counterparty": { 35 | "name": "Backbase", 36 | "postalAddress": { 37 | "addressLine1": "Jacob Bontiusplaats 9, 1018LL, Amsterdam", 38 | "country": "NL" 39 | } 40 | }, 41 | "remittanceInformation": { 42 | "type": "UNSTRUCTURED", 43 | "content": "Return a debt" 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Tests/LetSeeTests/Mocks/FolderWithConfig/success_arrangementSingleItem.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "1cdb2224-8926-4b4d-a99f-1c9dfbbb4691", 4 | "externalArrangementId": "bcf10f4d-4b2f-4413-9bab-31ff693608b5", 5 | "externalLegalEntityId": "c7a382786d514262b75ab9531b749a2b", 6 | "externalProductId": "fade7867-533e-465e-90cb-e41675c54400", 7 | "name": "Mr and Mrs J. Smith", 8 | "bankAlias": "Our joined account", 9 | "sourceId": "LJBASI2XXXX", 10 | "bookedBalance": 1000.00, 11 | "availableBalance": 1500.00, 12 | "creditLimit": 442.12, 13 | "IBAN": "CY3887370130MJFTJ3B8Y9W7IGRO", 14 | "BBAN": "8873701303897", 15 | "currency": "AED", 16 | "externalTransferAllowed": true, 17 | "urgentTransferAllowed": true, 18 | "accruedInterest": 0.54, 19 | "number": "PANS", 20 | "principalAmount": 620.54, 21 | "currentInvestmentValue": 0.16, 22 | "legalEntityIds": ["257da57a-11e4-4553-9175-54baf755069b" , "cd83683b-13f2-43d8-882b-39c9ab27d499"], 23 | "productId": "36c8fc42-ec97-4f83-8a7c-d622625007f3", 24 | "productNumber": "ffdd939c-ac4a-4441-ae47-70a7259899e7", 25 | "productKindName": "Current Account", 26 | "productTypeName": "Current Account", 27 | "BIC": "AABAFI22", 28 | "bankBranchCode": "bankBranchCode", 29 | "visible": false, 30 | "accountOpeningDate": "2016-01-28T16:41:41.090Z", 31 | "accountInterestRate": 100.2, 32 | "valueDateBalance": 100.1, 33 | "creditLimitUsage": 100.3, 34 | "creditLimitInterestRate": 100.4, 35 | "creditLimitExpiryDate": "2019-09-28T16:41:41.090Z", 36 | "startDate": "2016-02-28T16:41:41.090Z", 37 | "termUnit": "Y", 38 | "termNumber": 50, 39 | "interestPaymentFrequencyUnit": "M", 40 | "interestPaymentFrequencyNumber": 15, 41 | "maturityDate": "2017-02-28T16:41:41.090Z", 42 | "maturityAmount": 99.5, 43 | "autoRenewalIndicator": true, 44 | "interestSettlementAccount": "interestSettlementAccount1", 45 | "outstandingPrincipalAmount": 100.2, 46 | "monthlyInstalmentAmount": 100.1, 47 | "amountInArrear": 100.3, 48 | "minimumRequiredBalance": 80.4, 49 | "creditCardAccountNumber": "123456", 50 | "validThru": "2019-02-28T16:41:41.090Z", 51 | "applicableInterestRate": 101.2, 52 | "remainingCredit": 50, 53 | "outstandingPayment": 105.5, 54 | "minimumPayment": 51.1, 55 | "minimumPaymentDueDate": "2018-02-28T16:41:41.090Z", 56 | "totalInvestmentValue": 110.2, 57 | "debitCards": [ 58 | { 59 | "number": "4578", 60 | "expiryDate": "2020-08-22", 61 | "cardId": "id1", 62 | "cardholderName": "John Doe", 63 | "cardType": "Visa Electron", 64 | "cardStatus": "Active" 65 | }, 66 | { 67 | "number": "3241", 68 | "expiryDate": "2019-01-14", 69 | "cardId": "id2", 70 | "cardholderName": "John Doe", 71 | "cardType": "Visa", 72 | "cardStatus": "Active" 73 | } 74 | ], 75 | "accountHolderAddressLine1": "accountHolderAddressLine11", 76 | "accountHolderAddressLine2": "accountHolderAddressLine12", 77 | "accountHolderStreetName": "accountHolderStreetName1", 78 | "town": "Paris", 79 | "postCode": "2000", 80 | "countrySubDivision": "countrySubDivision1", 81 | "accountHolderName": "Danthe Mohr", 82 | "accountHolderNames": "Danthe Mohr,Toso Malerot", 83 | "accountHolderCountry": "FR", 84 | "creditAccount": true, 85 | "debitAccount": true, 86 | "lastUpdateDate":"2016-01-28T16:41:41.090Z", 87 | "favorite": true, 88 | "userPreferences": { 89 | "arrangementId": "9aabca5b-63ae-47d9-8d63-c5ed0e831dfa", 90 | "alias": "Our joined account", 91 | "visible": true, 92 | "favorite": false 93 | }, 94 | "product": { 95 | "externalId": "externalProductidId", 96 | "externalTypeId": "externalProductTypeId", 97 | "typeName": "Current Account", 98 | "productKind": { 99 | "id": 1, 100 | "externalKindId": "kind1", 101 | "kindName": "Current Account", 102 | "kindUri": "current-account" 103 | } 104 | }, 105 | "state": { 106 | "externalStateId": "externalStateId1", 107 | "state": "Active" 108 | } 109 | } 110 | ] 111 | -------------------------------------------------------------------------------- /Tests/LetSeeTests/Mocks/General/error_401_rejectedPayment.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "436ffb31-7c89-463d-9200-779c99a0ec21", 3 | "status": "REJECTED", 4 | "bankStatus": "REJECTED", 5 | "reasonCode": "400", 6 | "reasonText": "{\"errors\":[{\"errorCode\":651,\"error", 7 | "errorDescription": "{\"errors\":[{\"errorCode\":651,\"errorReason\":\"CURRENCY_NOT_MATCHING\"}]}" 8 | } 9 | -------------------------------------------------------------------------------- /Tests/LetSeeTests/Mocks/General/error_failed.json: -------------------------------------------------------------------------------- 1 | { 2 | } 3 | -------------------------------------------------------------------------------- /Tests/LetSeeTests/Mocks/Payment-orders/error_100ms_rejectedPaymentHasDelay.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "436ffb31-7c89-463d-9200-779c99a0ec21", 3 | "status": "REJECTED", 4 | "bankStatus": "REJECTED", 5 | "reasonCode": "400", 6 | "reasonText": "{\"errors\":[{\"errorCode\":651,\"error", 7 | "errorDescription": "{\"errors\":[{\"errorCode\":651,\"errorReason\":\"CURRENCY_NOT_MATCHING\"}]}" 8 | } 9 | -------------------------------------------------------------------------------- /Tests/LetSeeTests/Mocks/Payment-orders/error_401_rejectedPayment.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "436ffb31-7c89-463d-9200-779c99a0ec21", 3 | "status": "REJECTED", 4 | "bankStatus": "REJECTED", 5 | "reasonCode": "400", 6 | "reasonText": "{\"errors\":[{\"errorCode\":651,\"error", 7 | "errorDescription": "{\"errors\":[{\"errorCode\":651,\"errorReason\":\"CURRENCY_NOT_MATCHING\"}]}" 8 | } 9 | -------------------------------------------------------------------------------- /Tests/LetSeeTests/Mocks/Payment-orders/success_200_200ms_validatedPayment.json: -------------------------------------------------------------------------------- 1 | { 2 | "originatorAccount": { 3 | "arrangementId": "729190df-a421-4937-94fd-5e1a3da132cc", 4 | "externalArrangementId": "729190421493794513132", 5 | "identification": { 6 | "identification": "NL53RABO0309349755", 7 | "schemeName": "IBAN" 8 | } 9 | }, 10 | "originator": { 11 | "name": "Credit Account", 12 | "postalAddress": { 13 | "addressLine1": "Jacob Bontiusplaats 9, 1018LL, Amsterdam" 14 | } 15 | }, 16 | "instructionPriority": "NORM", 17 | "requestedExecutionDate": "2017-07-16", 18 | "paymentType": "SEPA_CREDIT_TRANSFER", 19 | "isIntraLegalEntityPaymentOrder": false, 20 | "canApprove": false, 21 | "finalApprover": false, 22 | "transferTransactionInformation": { 23 | "instructedAmount": { 24 | "amount": "5000.55", 25 | "currencyCode": "EUR" 26 | }, 27 | "counterpartyAccount": { 28 | "identification": { 29 | "identification": "FR708933019952AUNHQNQ0KZ", 30 | "schemeName": "IBAN" 31 | }, 32 | "name": "ABN Amro" 33 | }, 34 | "counterparty": { 35 | "name": "Backbase", 36 | "postalAddress": { 37 | "addressLine1": "Jacob Bontiusplaats 9, 1018LL, Amsterdam", 38 | "country": "NL" 39 | } 40 | }, 41 | "remittanceInformation": { 42 | "type": "UNSTRUCTURED", 43 | "content": "Return a debt" 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Tests/LetSeeTests/RawDirectoryProcessorTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RawDirectoryProcessorTests.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 13/01/2023. 6 | // 7 | import Foundation 8 | import XCTest 9 | @testable import LetSee 10 | 11 | final class RawDirectoryProcessorTests: XCTestCase { 12 | private var sut: RawDirectoryProcessor! 13 | override func setUp() { 14 | sut = RawDirectoryProcessor(fileManager: MockFileManager()) 15 | } 16 | 17 | override func tearDown() { 18 | sut = nil 19 | } 20 | 21 | func testWhenDirectoryIsValid_getAllFilesFromDirectoryAndSubdirectories() { 22 | var expectedDirectoryNames = ["InnerPath", "Arrangements", "TheLowestPath"].sorted() 23 | let mocks = try! sut.process(MockFileManager.defaultMocksDirectoryPath + "/Arrangements") 24 | XCTAssertEqual(expectedDirectoryNames, mocks.map({$0.key.path.lastPathComponent}).sorted()) 25 | 26 | var expectedFileNames = ["success_arrangementItemsList.json", "success_arrangementItemsList.json", "success_arrangementItemsList.json", "success_arrangementSingleItem.json"].sorted() 27 | XCTAssertEqual(expectedFileNames, mocks.map({$0.value}).flatMap({$0}).map({$0.url.lastPathComponent}).sorted()) 28 | 29 | expectedDirectoryNames = ["FolderWithConfig", "orders"].sorted() 30 | let mockFolderWithConfigs = try! sut.process(MockFileManager.defaultMocksDirectoryPath + "/FolderWithConfig") 31 | XCTAssertEqual(expectedDirectoryNames, mockFolderWithConfigs.map({$0.key.path.lastPathComponent}).sorted()) 32 | 33 | expectedFileNames = ["error_rejectedPayment.json", "success_arrangementSingleItem.json", "success_validatedPayment.json"].sorted() 34 | XCTAssertEqual(expectedFileNames, mockFolderWithConfigs.flatMap({$0.value}).map({$0.url.lastPathComponent}).sorted()) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Tests/LetSeeTests/ScenarioFileInformationTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScenarioFileInformationTests.swift 3 | // 4 | // 5 | // Created by Farshad Jahanmanesh on 14/01/2023. 6 | // 7 | 8 | import XCTest 9 | @testable import LetSee 10 | final class ScenarioFileInformationTests: XCTestCase { 11 | func test_whenInitFromDecoder_WrapFolderNameBetweenTwoForwardSlash() { 12 | let folder = "Arrangements" 13 | let responseFileName = "success_fullName" 14 | let result = ScenarioFileInformation.Step(folder: folder, responseFileName: responseFileName) 15 | XCTAssertEqual("/\(folder.lowercased())/", result.folder) 16 | XCTAssertEqual(responseFileName.lowercased(), result.responseFileName) 17 | } 18 | 19 | func test_whenInitFromDecoder_WrapFolderNameByFixEndSlash() { 20 | let folder = "/Arrangements" 21 | let responseFileName = "success_fullName" 22 | let result = ScenarioFileInformation.Step(folder: folder, responseFileName: responseFileName) 23 | XCTAssertEqual("\(folder.lowercased())/", result.folder) 24 | XCTAssertEqual(responseFileName.lowercased(), result.responseFileName) 25 | } 26 | 27 | func test_whenInitFromDecoder_WrapFolderNameByFixFirstSlash() { 28 | let folder = "Arrangements/" 29 | let responseFileName = "success_fullName" 30 | let result = ScenarioFileInformation.Step(folder: folder, responseFileName: responseFileName) 31 | XCTAssertEqual("/\(folder.lowercased())", result.folder) 32 | XCTAssertEqual(responseFileName.lowercased(), result.responseFileName) 33 | } 34 | } 35 | --------------------------------------------------------------------------------