├── .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 |
--------------------------------------------------------------------------------