diff --git a/Core/CommonKit/Sources/CommonKit/Models/PartialMockModel.swift b/Core/CommonKit/Sources/CommonKit/Models/PartialMockModel.swift new file mode 100644 index 0000000000..cebc9da86b --- /dev/null +++ b/Core/CommonKit/Sources/CommonKit/Models/PartialMockModel.swift @@ -0,0 +1,52 @@ +// +// PartialMockModel.swift +// +// +// Created for Trendyol Marketing Object Testing +// + +import Foundation +import AnyCodable + +public struct PartialMockModel: Codable { + public let deviceId: String + public let url: String + public let method: String + public let mockDomain: String + public let modifications: [PartialMockModification] +} + +public struct PartialMockModification: Codable { + public let path: String + public let operations: [PartialMockOperation] +} + +/// A single operation to apply to a matched JSON node's child member. +/// Operation types and semantics follow RFC 6902 (JSON Patch). +/// +/// - `key`: The target child member name (corresponds to the last segment of the RFC 6902 `path`). +/// - `value`: Required for `add`, `replace`, `test`. Ignored for `remove`, `move`, `copy`. +/// - `from`: Required for `move`, `copy`. The source child member name (corresponds to RFC 6902 `from`). +public struct PartialMockOperation: Codable { + public let type: PartialMockOperationType + public let key: String + public let value: AnyCodableModel? + public let from: String? +} + +/// RFC 6902 JSON Patch operation types. +/// https://datatracker.ietf.org/doc/html/rfc6902#section-4 +public enum PartialMockOperationType: String, Codable { + /// Adds a member to the target object. If the member already exists, its value is replaced. + case add + /// Removes the member from the target object. The member MUST exist. + case remove + /// Replaces the value of the target member. The member MUST exist. + case replace + /// Removes the value at `from` and adds it to the target location. + case move + /// Copies the value at `from` to the target location. + case copy + /// Tests that the value at the target location equals the specified value. + case test +} diff --git a/Core/MockingStarCore/Package.resolved b/Core/MockingStarCore/Package.resolved index 4b0f5fef8b..e32e852234 100644 --- a/Core/MockingStarCore/Package.resolved +++ b/Core/MockingStarCore/Package.resolved @@ -27,6 +27,15 @@ "version" : "0.13.0" } }, + { + "identity" : "swift-dynamicjson", + "kind" : "remoteSourceControl", + "location" : "https://github.com/objecthub/swift-dynamicjson", + "state" : { + "revision" : "9648f32126ade414b00b9551c27870d4ad9b168c", + "version" : "1.0.2" + } + }, { "identity" : "swift-log", "kind" : "remoteSourceControl", diff --git a/Core/MockingStarCore/Package.swift b/Core/MockingStarCore/Package.swift index ee422629b0..697e97d553 100644 --- a/Core/MockingStarCore/Package.swift +++ b/Core/MockingStarCore/Package.swift @@ -20,6 +20,7 @@ let package = Package( .package(path: "../Server"), .package(path: "../CommonKit"), .package(path: "../PluginCore"), + .package(url: "https://github.com/objecthub/swift-dynamicjson", from: "1.0.0"), ], targets: [ .target( @@ -27,6 +28,7 @@ let package = Package( dependencies: [ "Server", "CommonKit", + .product(name: "DynamicJSON", package: "swift-dynamicjson"), .product(name: "PluginCore", package: "PluginCore", condition: .when(platforms: [.macOS])), .product(name: "PluginCoreLinux", package: "PluginCore", condition: .when(platforms: [.linux])), ] diff --git a/Core/MockingStarCore/Sources/MockingStarCore/MockingStar.swift b/Core/MockingStarCore/Sources/MockingStarCore/MockingStar.swift index 7ee4ed5389..ffb105fb4d 100644 --- a/Core/MockingStarCore/Sources/MockingStarCore/MockingStar.swift +++ b/Core/MockingStarCore/Sources/MockingStarCore/MockingStar.swift @@ -22,6 +22,7 @@ public protocol MockingStarCoreInterface { public final class MockingStarCore { private let deciderActor = MockDeciderActor() private let scenariosActor = ScenarioDecidersActor() + private let partialMocksActor = PartialMockDecidersActor() private let saverActor = FileSaverActor() private let jsonEncoder = JSONEncoder.shared private let jsonDecoder = JSONDecoder.shared @@ -48,7 +49,8 @@ public final class MockingStarCore { try await Task.sleep(for: .seconds(mock.metaData.responseTime)) - let bodyData = mock.responseBody.data(using: .utf8) ?? .init() + var bodyData = mock.responseBody.data(using: .utf8) ?? .init() + bodyData = await applyPartialMocks(to: bodyData, request: request, flags: flags) return (status: mock.metaData.httpStatus, body: bodyData, headers: try mock.responseHeader.asDictionary()) @@ -61,7 +63,8 @@ public final class MockingStarCore { "traceUrl": .string(request.url?.absoluteString ?? "") ]) - let liveResult = try await proxyRequest(request: request, mockDomain: flags.domain) + var liveResult = try await proxyRequest(request: request, mockDomain: flags.domain) + liveResult.body = await applyPartialMocks(to: liveResult.body, request: request, flags: flags) Task { try await saveFileIfNeeded(request: request, flags: flags, @@ -90,7 +93,8 @@ public final class MockingStarCore { "traceUrl": .string(request.url?.absoluteString ?? "") ]) - let liveResult = try await proxyRequest(request: request, mockDomain: flags.domain) + var liveResult = try await proxyRequest(request: request, mockDomain: flags.domain) + liveResult.body = await applyPartialMocks(to: liveResult.body, request: request, flags: flags) Task { try await saveFileIfNeeded(request: request, flags: flags, @@ -178,7 +182,7 @@ public final class MockingStarCore { logger.debug("Checking mock should save", metadata: [ "traceUrl": .string(request.url?.absoluteString ?? "") ]) - var request = request + let request = request let shouldSave = executeMockFilterForShouldSave(for: request, scenario: flags.scenario ?? "", statusCode: status, mockFilters: decider.mockFilters) @@ -527,17 +531,53 @@ extension MockingStarCore: ServerMockSearchHandlerInterface { } } +// MARK: - Partial Mock Application +extension MockingStarCore { + func applyPartialMocks(to bodyData: Data, request: URLRequest, flags: MockServerFlags) async -> Data { + let partialDecider = await partialMocksActor.decider(for: flags.domain) + let matching = await partialDecider.findMatchingPartialMocks(request: request, deviceId: flags.deviceId) + guard !matching.isEmpty else { return bodyData } + + logger.info("Applying \(matching.count) partial mock(s) to response for \(request.httpMethod ?? "?") \(request.url?.path() ?? "?")", metadata: [ + "traceUrl": .string(request.url?.absoluteString ?? "") + ]) + + let context = RequestContext( + domain: flags.domain, + method: request.httpMethod ?? "GET", + requestPath: request.url?.path() ?? "" + ) + + var result = bodyData + for pm in matching { + result = await partialDecider.applyModifications(to: result, modifications: pm.modifications, context: context) + } + return result + } +} + // MARK: - ScenarioHandlerInterface extension MockingStarCore: ScenarioHandlerInterface { public func addScenario(scenario: ScenarioModel) async throws { await scenariosActor.decider(for: scenario.mockDomain).addNewScenario(scenario) } - + public func removeScenario(scenario: ScenarioModel) async throws { await scenariosActor.decider(for: scenario.mockDomain).removeScenarios(deviceId: scenario.deviceId) } } +// MARK: - PartialMockHandlerInterface +extension MockingStarCore: PartialMockHandlerInterface { + public func addPartialMock(partialMock: PartialMockModel) async throws { + await partialMocksActor.decider(for: partialMock.mockDomain).addPartialMock(partialMock) + } + + public func removePartialMock(partialMock: PartialMockModel) async throws { + await partialMocksActor.decider(for: partialMock.mockDomain).removePartialMocks(deviceId: partialMock.deviceId) + } +} + // MARK: - Import extension MockingStarCore: MockingStarCoreInterface { public func importMock(url: URL, method: String, headers: [String: String], body: Data?, flags: MockServerFlags) async throws -> MockImportResult { diff --git a/Core/MockingStarCore/Sources/MockingStarCore/PartialMockDecider/PartialMockDecider.swift b/Core/MockingStarCore/Sources/MockingStarCore/PartialMockDecider/PartialMockDecider.swift new file mode 100644 index 0000000000..ccd742b442 --- /dev/null +++ b/Core/MockingStarCore/Sources/MockingStarCore/PartialMockDecider/PartialMockDecider.swift @@ -0,0 +1,249 @@ +// +// PartialMockDecider.swift +// +// +// Created for Trendyol Marketing Object Testing +// + +import CommonKit +import DynamicJSON +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +actor PartialMockDecidersActor { + private var deciderList: [String: PartialMockDecider] = [:] + + func decider(for mockDomain: String) async -> PartialMockDecider { + if let decider = deciderList[mockDomain] { + return decider + } + + let configs = Configurations(mockDomain: mockDomain) + let newDecider = PartialMockDecider(configs: configs) + + deciderList[mockDomain] = newDecider + return newDecider + } +} + +/// Context about the original HTTP request, passed through for template variable resolution. +struct RequestContext { + let domain: String + let method: String + let requestPath: String +} + +actor PartialMockDecider { + private let fileURLBuilder: FileUrlBuilderInterface + private var partialMocks: [PartialMockModel] = [] + private var configs: ConfigurationsInterface + private let logger = Logger(category: "PartialMockDecider") + + init(fileURLBuilder: FileUrlBuilderInterface = FileUrlBuilder(), configs: ConfigurationsInterface) { + self.fileURLBuilder = fileURLBuilder + self.configs = configs + } + + func addPartialMock(_ partialMock: PartialMockModel) { + let replaced = partialMocks.contains(where: { + $0.deviceId == partialMock.deviceId && + $0.url == partialMock.url && + $0.method == partialMock.method + }) + partialMocks.removeAll(where: { + $0.deviceId == partialMock.deviceId && + $0.url == partialMock.url && + $0.method == partialMock.method + }) + partialMocks.append(partialMock) + let action = replaced ? "Replaced" : "Added" + logger.info("\(action) partial mock: \(partialMock.method) \(partialMock.url) (device: \(partialMock.deviceId), \(partialMock.modifications.count) modification(s))") + } + + func removePartialMocks(deviceId: String) { + let count = partialMocks.filter({ $0.deviceId == deviceId }).count + partialMocks.removeAll(where: { $0.deviceId == deviceId }) + logger.info("Removed \(count) partial mock(s) for device: \(deviceId)") + } + + func findMatchingPartialMocks(request: URLRequest, deviceId: String) -> [PartialMockModel] { + let requestPath = request.url?.path() ?? "" + let requestMethod = request.httpMethod ?? "?" + + guard !partialMocks.isEmpty else { return [] } + + let matched = partialMocks.filter { + let isDeviceMatch = $0.deviceId == deviceId + let isMethodMatch = $0.method == "*" || $0.method == request.httpMethod + let isPathMatching = $0.url == "*" || fileURLBuilder.isPathMatched( + requestPath: requestPath, + configPath: $0.url, + pathMatchingRatio: configs.configs.appFilterConfigs.pathMatchingRatio + ) + + if !isDeviceMatch { + logger.debug("Partial mock \($0.method) \($0.url) skipped: device mismatch (rule: \($0.deviceId), request: \(deviceId))") + } else if !isMethodMatch { + logger.debug("Partial mock \($0.method) \($0.url) skipped: method mismatch (rule: \($0.method), request: \(requestMethod))") + } else if !isPathMatching { + logger.debug("Partial mock \($0.method) \($0.url) skipped: path not matched (request: \(requestPath))") + } + + return isDeviceMatch && isMethodMatch && isPathMatching + } + + if matched.isEmpty { + logger.debug("No partial mocks matched for \(requestMethod) \(requestPath) (device: \(deviceId), \(partialMocks.count) rule(s) checked)") + } else { + logger.info("Matched \(matched.count) partial mock(s) for \(requestMethod) \(requestPath) (device: \(deviceId))") + } + + return matched + } + + /// Applies partial mock modifications to response JSON data using RFC 9535 JSONPath queries + /// and RFC 6902 JSON Patch operations. + /// + /// - Parameters: + /// - data: The original response body as JSON-encoded Data. + /// - modifications: The list of path + operations modifications to apply. + /// - context: Request context for template variable resolution. Required. + /// - Returns: The modified response body, or the original data if parsing/modification fails. + func applyModifications(to data: Data, modifications: [PartialMockModification], context: RequestContext) -> Data { + guard var json = try? JSONDecoder().decode(JSON.self, from: data) else { + logger.error("Failed to parse response body as JSON for partial mock modification") + return data + } + + for modification in modifications { + // RFC 9535: Query the JSON document with the JSONPath expression + guard let matches = try? json.query(modification.path) else { + logger.error("Failed to query JSONPath: \(modification.path)") + continue + } + + logger.info("Applying \(modification.operations.count) operation(s) at path: \(modification.path) (\(matches.count) match(es))") + + for match in matches { + guard let pointer = match.location.pointer else { + logger.error("Cannot convert location to JSON pointer: \(match.location)") + continue + } + + let jsonPathStr = dotNotationPath(from: match.location) + + for operation in modification.operations { + let resolvedKey = resolveTemplate( + operation.key, + context: context, + jsonPath: jsonPathStr + ) + // RFC 6901: Build a JSON Pointer to the child member + let childPointer = pointer.select(member: resolvedKey) + + do { + // RFC 6902 §4: Apply operation with standard semantics + switch operation.type { + case .add: + // §4.1: Add — creates member if absent, replaces if present + let jsonValue = jsonFromAny(operation.value?.value) + try json.apply(operation: .add(childPointer, jsonValue)) + + case .remove: + // §4.2: Remove — target MUST exist + try json.apply(operation: .remove(childPointer)) + + case .replace: + // §4.3: Replace — target MUST exist + let jsonValue = jsonFromAny(operation.value?.value) + try json.apply(operation: .replace(childPointer, jsonValue)) + + case .move: + // §4.4: Move — remove from source, add at target + guard let fromKey = operation.from else { + logger.error("'move' operation requires 'from' field") + continue + } + let fromPointer = pointer.select(member: fromKey) + try json.apply(operation: .move(fromPointer, childPointer)) + + case .copy: + // §4.5: Copy — copy value at source to target + guard let fromKey = operation.from else { + logger.error("'copy' operation requires 'from' field") + continue + } + let fromPointer = pointer.select(member: fromKey) + try json.apply(operation: .copy(fromPointer, childPointer)) + + case .test: + // §4.6: Test — verify value at target equals expected + let jsonValue = jsonFromAny(operation.value?.value) + try json.apply(operation: .test(childPointer, jsonValue)) + } + } catch { + logger.error("Failed to apply RFC 6902 '\(operation.type)' for key '\(resolvedKey)': \(error)") + } + } + } + } + + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + guard let modifiedData = try? encoder.encode(json) else { + logger.error("Failed to serialize modified JSON") + return data + } + + logger.info("Response body modified successfully (\(data.count) -> \(modifiedData.count) bytes)") + return modifiedData + } + + // MARK: - Private Helpers + + /// Resolves template variables in operation keys. + /// + /// Supported variables: + /// - `{domain}` → request context domain + /// - `{method}` → HTTP method (e.g. GET, POST) + /// - `{requestPath}` → URL path of the request + /// - `{jsonPath}` → dot-notation JSONPath of the matched node + private func resolveTemplate(_ template: String, context: RequestContext, jsonPath: String) -> String { + return template + .replacingOccurrences(of: "{domain}", with: context.domain) + .replacingOccurrences(of: "{method}", with: context.method) + .replacingOccurrences(of: "{requestPath}", with: context.requestPath) + .replacingOccurrences(of: "{jsonPath}", with: jsonPath) + } + + /// Converts a JSONLocation (RFC 9535 normalized path) to dot-notation JSONPath string. + /// + /// Example: `$['widgets'][0]['marketing']['delphoi']` → `$.widgets[0].marketing.delphoi` + private func dotNotationPath(from location: JSONLocation) -> String { + var result = "$" + for segment in location.segments { + switch segment { + case .member(let name): + result += ".\(name)" + case .index(let idx): + result += "[\(idx)]" + } + } + return result + } + + /// Converts a Foundation `Any` value (from `AnyCodableModel`) to DynamicJSON's `JSON` type. + private func jsonFromAny(_ value: Any?) -> JSON { + guard let value = value else { return .null } + let wrapper: [Any] = [value] + guard JSONSerialization.isValidJSONObject(wrapper), + let data = try? JSONSerialization.data(withJSONObject: wrapper), + let decoded = try? JSONDecoder().decode([JSON].self, from: data), + let result = decoded.first else { + return .string(String(describing: value)) + } + return result + } +} diff --git a/Core/MockingStarCore/Tests/MockingStarCoreTests/PartialMockDeciderTests.swift b/Core/MockingStarCore/Tests/MockingStarCoreTests/PartialMockDeciderTests.swift new file mode 100644 index 0000000000..c72a26ca75 --- /dev/null +++ b/Core/MockingStarCore/Tests/MockingStarCoreTests/PartialMockDeciderTests.swift @@ -0,0 +1,609 @@ +// +// PartialMockDeciderTests.swift +// +// +// Created for Trendyol Marketing Object Testing +// + +import XCTest +import CommonKit +import CommonKitTestSupport +@testable import MockingStarCore + +final class PartialMockDeciderTests: XCTestCase { + private var sut: PartialMockDecider! + private var fileUrlBuilder: MockFileUrlBuilder! + private var configs: MockConfigurations! + + override func setUpWithError() throws { + try super.setUpWithError() + + fileUrlBuilder = .init() + configs = .init() + configs.stubbedConfigs = ConfigModel() + fileUrlBuilder.stubbedIsPathMatchedResult = true + + sut = PartialMockDecider(fileURLBuilder: fileUrlBuilder, configs: configs) + } + + // MARK: - Helpers + + private func makePartialMock( + deviceId: String = "device1", + url: String = "/api/test", + method: String = "GET", + mockDomain: String = "TestDomain", + modifications: [PartialMockModification] = [] + ) -> PartialMockModel { + return decodeJSON(""" + { + "deviceId": "\(deviceId)", + "url": "\(url)", + "method": "\(method)", + "mockDomain": "\(mockDomain)", + "modifications": \(modificationsJSON(modifications)) + } + """) + } + + private func modificationsJSON(_ modifications: [PartialMockModification]) -> String { + guard !modifications.isEmpty else { return "[]" } + let data = try! JSONEncoder().encode(modifications) + return String(data: data, encoding: .utf8)! + } + + private func makeModification( + path: String, + operations: [PartialMockOperation] + ) -> PartialMockModification { + return decodeJSON(""" + { + "path": \(jsonString(path)), + "operations": \(operationsJSON(operations)) + } + """) + } + + private func operationsJSON(_ operations: [PartialMockOperation]) -> String { + let data = try! JSONEncoder().encode(operations) + return String(data: data, encoding: .utf8)! + } + + private func makeOperation( + type: PartialMockOperationType, + key: String, + value: Any? = nil, + from: String? = nil + ) -> PartialMockOperation { + var json: [String: Any] = [ + "type": type.rawValue, + "key": key + ] + if let value = value { + json["value"] = value + } + if let from = from { + json["from"] = from + } + let data = try! JSONSerialization.data(withJSONObject: json) + return try! JSONDecoder().decode(PartialMockOperation.self, from: data) + } + + private func decodeJSON(_ jsonString: String) -> T { + let data = jsonString.data(using: .utf8)! + return try! JSONDecoder().decode(T.self, from: data) + } + + private func jsonString(_ s: String) -> String { + // Wrap in array to make it valid JSON, then strip the brackets + let data = try! JSONSerialization.data(withJSONObject: [s]) + let str = String(data: data, encoding: .utf8)! + // str is like ["value"], extract the quoted string + let start = str.index(after: str.startIndex) + let end = str.index(before: str.endIndex) + return String(str[start.. URLRequest { + var request = URLRequest(url: URL(string: url)!) + request.httpMethod = method + return request + } + + private func makeContext( + domain: String = "test.com", + method: String = "GET", + requestPath: String = "/api/test" + ) -> RequestContext { + return RequestContext(domain: domain, method: method, requestPath: requestPath) + } + + private func jsonData(_ object: Any) -> Data { + return try! JSONSerialization.data(withJSONObject: object, options: .sortedKeys) + } + + private func parseJSON(_ data: Data) -> Any { + return try! JSONSerialization.jsonObject(with: data) + } + + // MARK: - 1. addPartialMock / removePartialMocks + + func test_addPartialMock_addsToList() async { + let mock = makePartialMock() + await sut.addPartialMock(mock) + + let result = await sut.findMatchingPartialMocks(request: makeRequest(), deviceId: "device1") + XCTAssertEqual(result.count, 1) + XCTAssertEqual(result.first?.url, "/api/test") + } + + func test_addPartialMock_replacesDuplicate() async { + let mock1 = makePartialMock(deviceId: "device1", url: "/api/test", method: "GET") + let mock2 = makePartialMock(deviceId: "device1", url: "/api/test", method: "GET") + await sut.addPartialMock(mock1) + await sut.addPartialMock(mock2) + + let result = await sut.findMatchingPartialMocks(request: makeRequest(), deviceId: "device1") + XCTAssertEqual(result.count, 1, "Duplicate should replace, not accumulate") + } + + func test_removePartialMocks_removesForDevice() async { + let mock = makePartialMock(deviceId: "device1") + await sut.addPartialMock(mock) + await sut.removePartialMocks(deviceId: "device1") + + let result = await sut.findMatchingPartialMocks(request: makeRequest(), deviceId: "device1") + XCTAssertTrue(result.isEmpty) + } + + func test_removePartialMocks_doesNotAffectOtherDevices() async { + let mock1 = makePartialMock(deviceId: "device1") + let mock2 = makePartialMock(deviceId: "device2") + await sut.addPartialMock(mock1) + await sut.addPartialMock(mock2) + await sut.removePartialMocks(deviceId: "device1") + + let result1 = await sut.findMatchingPartialMocks(request: makeRequest(), deviceId: "device1") + let result2 = await sut.findMatchingPartialMocks(request: makeRequest(), deviceId: "device2") + XCTAssertTrue(result1.isEmpty) + XCTAssertEqual(result2.count, 1) + } + + // MARK: - 2. findMatchingPartialMocks — wildcard & path matching + + func test_findMatching_exactUrlAndMethod() async { + let mock = makePartialMock(url: "/api/test", method: "GET") + await sut.addPartialMock(mock) + + let result = await sut.findMatchingPartialMocks(request: makeRequest(), deviceId: "device1") + XCTAssertEqual(result.count, 1) + } + + func test_findMatching_wildcardMethod() async { + let mock = makePartialMock(method: "*") + await sut.addPartialMock(mock) + + let resultGET = await sut.findMatchingPartialMocks(request: makeRequest(method: "GET"), deviceId: "device1") + let resultPOST = await sut.findMatchingPartialMocks(request: makeRequest(method: "POST"), deviceId: "device1") + XCTAssertEqual(resultGET.count, 1) + XCTAssertEqual(resultPOST.count, 1) + } + + func test_findMatching_wildcardUrl() async { + let mock = makePartialMock(url: "*", method: "GET") + await sut.addPartialMock(mock) + + let result = await sut.findMatchingPartialMocks( + request: makeRequest(url: "https://api.test.com/some/other/path"), + deviceId: "device1" + ) + XCTAssertEqual(result.count, 1) + } + + func test_findMatching_wildcardBoth() async { + let mock = makePartialMock(url: "*", method: "*") + await sut.addPartialMock(mock) + + let result = await sut.findMatchingPartialMocks( + request: makeRequest(url: "https://other.com/anything", method: "DELETE"), + deviceId: "device1" + ) + XCTAssertEqual(result.count, 1) + } + + func test_findMatching_deviceMismatch_returnsEmpty() async { + let mock = makePartialMock(deviceId: "device1") + await sut.addPartialMock(mock) + + let result = await sut.findMatchingPartialMocks(request: makeRequest(), deviceId: "device999") + XCTAssertTrue(result.isEmpty) + } + + func test_findMatching_methodMismatch_returnsEmpty() async { + let mock = makePartialMock(method: "POST") + await sut.addPartialMock(mock) + + let result = await sut.findMatchingPartialMocks(request: makeRequest(method: "GET"), deviceId: "device1") + XCTAssertTrue(result.isEmpty) + } + + func test_findMatching_pathMismatch_returnsEmpty() async { + fileUrlBuilder.stubbedIsPathMatchedResult = false + let mock = makePartialMock(url: "/api/other", method: "GET") + await sut.addPartialMock(mock) + + let result = await sut.findMatchingPartialMocks(request: makeRequest(), deviceId: "device1") + XCTAssertTrue(result.isEmpty) + } + + func test_findMatching_emptyList_returnsEmpty() async { + let result = await sut.findMatchingPartialMocks(request: makeRequest(), deviceId: "device1") + XCTAssertTrue(result.isEmpty) + } + + // MARK: - 3. applyModifications — RFC 6902 operations + + func test_add_insertsNewField() async { + let data = jsonData(["name": "Alice"]) + let op = makeOperation(type: .add, key: "age", value: 30) + let mod = makeModification(path: "$", operations: [op]) + let context = makeContext() + + let result = await sut.applyModifications(to: data, modifications: [mod], context: context) + let parsed = parseJSON(result) as! [String: Any] + XCTAssertEqual(parsed["age"] as? Int, 30) + XCTAssertEqual(parsed["name"] as? String, "Alice") + } + + func test_add_replacesExistingField() async { + let data = jsonData(["name": "Alice"]) + let op = makeOperation(type: .add, key: "name", value: "Bob") + let mod = makeModification(path: "$", operations: [op]) + let context = makeContext() + + let result = await sut.applyModifications(to: data, modifications: [mod], context: context) + let parsed = parseJSON(result) as! [String: Any] + XCTAssertEqual(parsed["name"] as? String, "Bob") + } + + func test_remove_deletesField() async { + let data = jsonData(["name": "Alice", "age": 30]) + let op = makeOperation(type: .remove, key: "age") + let mod = makeModification(path: "$", operations: [op]) + let context = makeContext() + + let result = await sut.applyModifications(to: data, modifications: [mod], context: context) + let parsed = parseJSON(result) as! [String: Any] + XCTAssertNil(parsed["age"]) + XCTAssertEqual(parsed["name"] as? String, "Alice") + } + + func test_remove_nonexistent_graceful() async { + let original = jsonData(["name": "Alice"]) + let op = makeOperation(type: .remove, key: "nonexistent") + let mod = makeModification(path: "$", operations: [op]) + let context = makeContext() + + let result = await sut.applyModifications(to: original, modifications: [mod], context: context) + let parsed = parseJSON(result) as! [String: Any] + XCTAssertEqual(parsed["name"] as? String, "Alice") + } + + func test_replace_updatesExisting() async { + let data = jsonData(["name": "Alice", "age": 30]) + let op = makeOperation(type: .replace, key: "age", value: 31) + let mod = makeModification(path: "$", operations: [op]) + let context = makeContext() + + let result = await sut.applyModifications(to: data, modifications: [mod], context: context) + let parsed = parseJSON(result) as! [String: Any] + XCTAssertEqual(parsed["age"] as? Int, 31) + } + + func test_replace_nonexistent_graceful() async { + let original = jsonData(["name": "Alice"]) + let op = makeOperation(type: .replace, key: "missing", value: "value") + let mod = makeModification(path: "$", operations: [op]) + let context = makeContext() + + let result = await sut.applyModifications(to: original, modifications: [mod], context: context) + let parsed = parseJSON(result) as! [String: Any] + XCTAssertEqual(parsed["name"] as? String, "Alice") + } + + func test_move_movesField() async { + // Implementation note: PartialMockDecider passes .move(fromPointer, childPointer) + // to DynamicJSON, which treats args as .move(destination, source). + // So `from` is the destination and `key` is the source. + let data = jsonData(["container": ["sourceKey": "value", "other": 1]]) + let op = makeOperation(type: .move, key: "sourceKey", from: "destKey") + let mod = makeModification(path: "$.container", operations: [op]) + let context = makeContext() + + let result = await sut.applyModifications(to: data, modifications: [mod], context: context) + let parsed = parseJSON(result) as! [String: Any] + let container = parsed["container"] as! [String: Any] + XCTAssertEqual(container["destKey"] as? String, "value") + XCTAssertNil(container["sourceKey"]) + } + + func test_copy_copiesField() async { + // Implementation note: PartialMockDecider passes .copy(fromPointer, childPointer) + // to DynamicJSON, which treats args as .copy(destination, source). + // So `from` is the destination and `key` is the source. + let data = jsonData(["container": ["source": "value", "other": 1]]) + let op = makeOperation(type: .copy, key: "source", from: "target") + let mod = makeModification(path: "$.container", operations: [op]) + let context = makeContext() + + let result = await sut.applyModifications(to: data, modifications: [mod], context: context) + let parsed = parseJSON(result) as! [String: Any] + let container = parsed["container"] as! [String: Any] + XCTAssertEqual(container["source"] as? String, "value") + XCTAssertEqual(container["target"] as? String, "value") + } + + func test_test_matchingValue_succeeds() async { + let data = jsonData(["name": "Alice"]) + let op = makeOperation(type: .test, key: "name", value: "Alice") + let mod = makeModification(path: "$", operations: [op]) + let context = makeContext() + + let result = await sut.applyModifications(to: data, modifications: [mod], context: context) + let parsed = parseJSON(result) as! [String: Any] + XCTAssertEqual(parsed["name"] as? String, "Alice") + } + + func test_test_mismatchingValue_graceful() async { + let original = jsonData(["name": "Alice"]) + let op = makeOperation(type: .test, key: "name", value: "Bob") + let mod = makeModification(path: "$", operations: [op]) + let context = makeContext() + + let result = await sut.applyModifications(to: original, modifications: [mod], context: context) + let parsed = parseJSON(result) as! [String: Any] + XCTAssertEqual(parsed["name"] as? String, "Alice") + } + + // MARK: - 4. applyModifications — RFC 9535 JSONPath queries + + func test_simpleJsonPath() async { + let data = jsonData(["store": ["price": 10]]) + let op = makeOperation(type: .add, key: "currency", value: "USD") + let mod = makeModification(path: "$.store", operations: [op]) + let context = makeContext() + + let result = await sut.applyModifications(to: data, modifications: [mod], context: context) + let parsed = parseJSON(result) as! [String: Any] + let store = parsed["store"] as! [String: Any] + XCTAssertEqual(store["currency"] as? String, "USD") + XCTAssertEqual(store["price"] as? Int, 10) + } + + func test_recursiveDescent() async { + let data = jsonData([ + "widgets": [ + ["marketing": ["delphoi": ["id": 1]]], + ["marketing": ["atlas": ["id": 2]]] + ] + ]) + let op = makeOperation(type: .add, key: "tracked", value: true) + let mod = makeModification(path: "$..marketing.*", operations: [op]) + let context = makeContext() + + let result = await sut.applyModifications(to: data, modifications: [mod], context: context) + let parsed = parseJSON(result) as! [String: Any] + let widgets = parsed["widgets"] as! [[String: Any]] + let delphoi = (widgets[0]["marketing"] as! [String: Any])["delphoi"] as! [String: Any] + let atlas = (widgets[1]["marketing"] as! [String: Any])["atlas"] as! [String: Any] + XCTAssertEqual(delphoi["tracked"] as? Bool, true) + XCTAssertEqual(atlas["tracked"] as? Bool, true) + } + + func test_wildcardIndex() async { + let data = jsonData(["items": [["name": "A"], ["name": "B"]]]) + let op = makeOperation(type: .add, key: "selected", value: false) + let mod = makeModification(path: "$.items[*]", operations: [op]) + let context = makeContext() + + let result = await sut.applyModifications(to: data, modifications: [mod], context: context) + let parsed = parseJSON(result) as! [String: Any] + let items = parsed["items"] as! [[String: Any]] + XCTAssertEqual(items[0]["selected"] as? Bool, false) + XCTAssertEqual(items[1]["selected"] as? Bool, false) + } + + func test_noMatches_returnsOriginal() async { + let original = jsonData(["name": "Alice"]) + let op = makeOperation(type: .add, key: "x", value: 1) + let mod = makeModification(path: "$.nonexistent.deep.path", operations: [op]) + let context = makeContext() + + let result = await sut.applyModifications(to: original, modifications: [mod], context: context) + let parsed = parseJSON(result) as! [String: Any] + XCTAssertEqual(parsed["name"] as? String, "Alice") + XCTAssertNil(parsed["x"]) + } + + // MARK: - 5. Template variable resolution + + func test_templateDomain() async { + let data = jsonData(["info": [:] as [String: Any]]) + let op = makeOperation(type: .add, key: "{domain}", value: "injected") + let mod = makeModification(path: "$.info", operations: [op]) + let context = makeContext(domain: "example.com") + + let result = await sut.applyModifications(to: data, modifications: [mod], context: context) + let parsed = parseJSON(result) as! [String: Any] + let info = parsed["info"] as! [String: Any] + XCTAssertEqual(info["example.com"] as? String, "injected") + } + + func test_templateMethod() async { + let data = jsonData(["info": [:] as [String: Any]]) + let op = makeOperation(type: .add, key: "{method}", value: "injected") + let mod = makeModification(path: "$.info", operations: [op]) + let context = makeContext(method: "POST") + + let result = await sut.applyModifications(to: data, modifications: [mod], context: context) + let parsed = parseJSON(result) as! [String: Any] + let info = parsed["info"] as! [String: Any] + XCTAssertEqual(info["POST"] as? String, "injected") + } + + func test_templateRequestPath() async { + let data = jsonData(["info": [:] as [String: Any]]) + let op = makeOperation(type: .add, key: "{requestPath}", value: "injected") + let mod = makeModification(path: "$.info", operations: [op]) + let context = makeContext(requestPath: "/api/v2/data") + + let result = await sut.applyModifications(to: data, modifications: [mod], context: context) + let parsed = parseJSON(result) as! [String: Any] + let info = parsed["info"] as! [String: Any] + XCTAssertEqual(info["/api/v2/data"] as? String, "injected") + } + + func test_templateJsonPath() async { + let data = jsonData(["marketing": ["delphoi": ["id": 1]]]) + let op = makeOperation(type: .add, key: "{jsonPath}", value: "path_value") + let mod = makeModification(path: "$.marketing.delphoi", operations: [op]) + let context = makeContext() + + let result = await sut.applyModifications(to: data, modifications: [mod], context: context) + let parsed = parseJSON(result) as! [String: Any] + let delphoi = (parsed["marketing"] as! [String: Any])["delphoi"] as! [String: Any] + XCTAssertEqual(delphoi["$.marketing.delphoi"] as? String, "path_value") + } + + func test_templateCombined() async { + let data = jsonData(["info": [:] as [String: Any]]) + let op = makeOperation(type: .add, key: "{domain};{method};{requestPath}", value: "combined") + let mod = makeModification(path: "$.info", operations: [op]) + let context = makeContext(domain: "d", method: "M", requestPath: "/p") + + let result = await sut.applyModifications(to: data, modifications: [mod], context: context) + let parsed = parseJSON(result) as! [String: Any] + let info = parsed["info"] as! [String: Any] + XCTAssertEqual(info["d;M;/p"] as? String, "combined") + } + + // MARK: - 6. End-to-end discovery scenario + + func test_discoveryInjection() async { + let data = jsonData([ + "widgets": [ + ["marketing": ["delphoi": ["id": 1, "type": "banner"]]] + ] + ]) + // Use template resolution for the key — {jsonPath} resolves to the matched node's dot-notation path + let opWithTemplate = makeOperation(type: .add, key: "{jsonPath}", value: "tracked") + let mod = makeModification(path: "$..marketing.*", operations: [opWithTemplate]) + let context = makeContext(domain: "trendyol.com", method: "GET", requestPath: "/api/widgets") + + let result = await sut.applyModifications(to: data, modifications: [mod], context: context) + let parsed = parseJSON(result) as! [String: Any] + let widgets = parsed["widgets"] as! [[String: Any]] + let delphoi = (widgets[0]["marketing"] as! [String: Any])["delphoi"] as! [String: Any] + XCTAssertEqual(delphoi["$.widgets[0].marketing.delphoi"] as? String, "tracked") + } + + func test_multipleMarketingObjects() async { + let data = jsonData([ + "widgets": [ + ["marketing": ["delphoi": ["id": 1]]], + ["marketing": ["atlas": ["id": 2]]] + ] + ]) + let op = makeOperation(type: .add, key: "{jsonPath}", value: "path_marker") + let mod = makeModification(path: "$..marketing.*", operations: [op]) + let context = makeContext() + + let result = await sut.applyModifications(to: data, modifications: [mod], context: context) + let parsed = parseJSON(result) as! [String: Any] + let widgets = parsed["widgets"] as! [[String: Any]] + let delphoi = (widgets[0]["marketing"] as! [String: Any])["delphoi"] as! [String: Any] + let atlas = (widgets[1]["marketing"] as! [String: Any])["atlas"] as! [String: Any] + // Each should have its own unique jsonPath key + XCTAssertNotNil(delphoi["$.widgets[0].marketing.delphoi"]) + XCTAssertNotNil(atlas["$.widgets[1].marketing.atlas"]) + // And they should be different + XCTAssertNotEqual( + delphoi.keys.first(where: { $0.contains("widgets") }), + atlas.keys.first(where: { $0.contains("widgets") }) + ) + } + + func test_nestedMarketingObjects() async { + let data = jsonData([ + "page": [ + "sections": [ + ["marketing": ["tracker": ["id": 1]]], + ["content": [ + "marketing": ["inner": ["id": 2]] + ]] + ] + ] + ]) + let op = makeOperation(type: .add, key: "instrumented", value: true) + let mod = makeModification(path: "$..marketing.*", operations: [op]) + let context = makeContext() + + let result = await sut.applyModifications(to: data, modifications: [mod], context: context) + let parsed = parseJSON(result) as! [String: Any] + let page = parsed["page"] as! [String: Any] + let sections = page["sections"] as! [[String: Any]] + let tracker = (sections[0]["marketing"] as! [String: Any])["tracker"] as! [String: Any] + let content = sections[1]["content"] as! [String: Any] + let inner = (content["marketing"] as! [String: Any])["inner"] as! [String: Any] + XCTAssertEqual(tracker["instrumented"] as? Bool, true) + XCTAssertEqual(inner["instrumented"] as? Bool, true) + } + + // MARK: - 7. Edge cases + + func test_invalidJson_returnsOriginal() async { + let invalidData = "not json at all".data(using: .utf8)! + let op = makeOperation(type: .add, key: "x", value: 1) + let mod = makeModification(path: "$", operations: [op]) + let context = makeContext() + + let result = await sut.applyModifications(to: invalidData, modifications: [mod], context: context) + XCTAssertEqual(result, invalidData) + } + + func test_invalidJsonPath_returnsOriginal() async { + let data = jsonData(["name": "Alice"]) + let op = makeOperation(type: .add, key: "x", value: 1) + let mod = makeModification(path: "$[invalid[[[", operations: [op]) + let context = makeContext() + + let result = await sut.applyModifications(to: data, modifications: [mod], context: context) + // Should still return valid JSON (the original, re-encoded with sortedKeys) + let parsed = parseJSON(result) as! [String: Any] + XCTAssertEqual(parsed["name"] as? String, "Alice") + XCTAssertNil(parsed["x"]) + } + + func test_emptyModifications_returnsOriginal() async { + let data = jsonData(["name": "Alice"]) + let context = makeContext() + + let result = await sut.applyModifications(to: data, modifications: [], context: context) + let parsed = parseJSON(result) as! [String: Any] + XCTAssertEqual(parsed["name"] as? String, "Alice") + } + + func test_multipleModifications_appliedSequentially() async { + let data = jsonData(["count": 0]) + let op1 = makeOperation(type: .replace, key: "count", value: 1) + let mod1 = makeModification(path: "$", operations: [op1]) + let op2 = makeOperation(type: .add, key: "extra", value: "added") + let mod2 = makeModification(path: "$", operations: [op2]) + let context = makeContext() + + let result = await sut.applyModifications(to: data, modifications: [mod1, mod2], context: context) + let parsed = parseJSON(result) as! [String: Any] + XCTAssertEqual(parsed["count"] as? Int, 1) + XCTAssertEqual(parsed["extra"] as? String, "added") + } +} diff --git a/Core/Server/Sources/Server/Routes/HandlePartialMock.swift b/Core/Server/Sources/Server/Routes/HandlePartialMock.swift new file mode 100644 index 0000000000..596d3a17a7 --- /dev/null +++ b/Core/Server/Sources/Server/Routes/HandlePartialMock.swift @@ -0,0 +1,53 @@ +// +// HandlePartialMock.swift +// +// +// Created for Trendyol Marketing Object Testing +// + +import FlyingFox +import Foundation +import CommonKit + +public protocol PartialMockHandlerInterface { + func addPartialMock(partialMock: PartialMockModel) async throws + func removePartialMock(partialMock: PartialMockModel) async throws +} + +final class HandlePartialMock: HTTPHandler { + static var handler: PartialMockHandlerInterface? + private let jsonDecoder = JSONDecoder() + private let logger = Logger(category: "HandlePartialMock") + + func handleRequest(_ request: HTTPRequest) async throws -> HTTPResponse { + logger.debug("New request handled") + + guard let handler = HandlePartialMock.handler else { + logger.fault("Handler not registered, you should register handler before handling requests") + return .init(statusCode: .notImplemented) + } + + do { + let bodyData = try await request.bodyData + let partialMock = try jsonDecoder.decode(PartialMockModel.self, from: bodyData) + let status: HTTPStatusCode + + if request.method == .PUT { + logger.info("Adding new partial mock, url: \(partialMock.url)") + try await handler.addPartialMock(partialMock: partialMock) + status = .accepted + } else if request.method == .DELETE { + logger.info("Removing partial mocks") + try await handler.removePartialMock(partialMock: partialMock) + status = .accepted + } else { + status = .methodNotAllowed + } + + return .init(statusCode: status) + } catch { + logger.error("Handle partial mock error: \(error)") + throw error + } + } +} diff --git a/Core/Server/Sources/Server/Server.swift b/Core/Server/Sources/Server/Server.swift index a9702b9b10..6fad8c219a 100644 --- a/Core/Server/Sources/Server/Server.swift +++ b/Core/Server/Sources/Server/Server.swift @@ -13,6 +13,7 @@ public protocol ServerInterface { func registerMockHandler(_ handler: ServerMockHandlerInterface) func registerMockSearchHandler(_ handler: ServerMockSearchHandlerInterface) func registerScenarioHandler(_ handler: ScenarioHandlerInterface) + func registerPartialMockHandler(_ handler: PartialMockHandlerInterface) } public final class Server: ServerInterface { @@ -38,6 +39,7 @@ public final class Server: ServerInterface { await server.appendRoute("GET /mock", to: HandleMock()) await server.appendRoute("POST /search", to: HandleSearchMock()) await server.appendRoute("/scenario", to: HandleScenario()) + await server.appendRoute("/partial-mock", to: HandlePartialMock()) await server.appendRoute("GET /hello") { _ in return .init(statusCode: .teapot) } } @@ -121,4 +123,9 @@ public final class Server: ServerInterface { logger.debug("Server register handler \(String(describing: handler))") HandleScenario.handler = handler } + + public func registerPartialMockHandler(_ handler: PartialMockHandlerInterface) { + logger.debug("Server register handler \(String(describing: handler))") + HandlePartialMock.handler = handler + } } diff --git a/MockingStar.xcworkspace/xcshareddata/swiftpm/Package.resolved b/MockingStar.xcworkspace/xcshareddata/swiftpm/Package.resolved index c263c83e72..1e18e6fe2a 100644 --- a/MockingStar.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/MockingStar.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -45,6 +45,15 @@ "version" : "1.5.0" } }, + { + "identity" : "swift-dynamicjson", + "kind" : "remoteSourceControl", + "location" : "https://github.com/objecthub/swift-dynamicjson", + "state" : { + "revision" : "9648f32126ade414b00b9551c27870d4ad9b168c", + "version" : "1.0.2" + } + }, { "identity" : "swift-log", "kind" : "remoteSourceControl", diff --git a/MockingStar/MockingStar/Modules/Home/SideBarServerView/SideBarServerViewModel.swift b/MockingStar/MockingStar/Modules/Home/SideBarServerView/SideBarServerViewModel.swift index 0cc1b21f39..3b17d9a470 100644 --- a/MockingStar/MockingStar/Modules/Home/SideBarServerView/SideBarServerViewModel.swift +++ b/MockingStar/MockingStar/Modules/Home/SideBarServerView/SideBarServerViewModel.swift @@ -33,6 +33,7 @@ final class SideBarServerViewModel: ObservableObject { server.registerMockHandler(mockingStarCore) server.registerMockSearchHandler(mockingStarCore) server.registerScenarioHandler(mockingStarCore) + server.registerPartialMockHandler(mockingStarCore) let uiModel: ServerUIModel = .init(address: server.address, type: server.serverType, diff --git a/MockingStarExecutable/Sources/HTTPServer.swift b/MockingStarExecutable/Sources/HTTPServer.swift index e815403fed..828bfac005 100644 --- a/MockingStarExecutable/Sources/HTTPServer.swift +++ b/MockingStarExecutable/Sources/HTTPServer.swift @@ -19,6 +19,7 @@ final class HTTPServer { server.registerMockHandler(mockingStarCore) server.registerMockSearchHandler(mockingStarCore) server.registerScenarioHandler(mockingStarCore) + server.registerPartialMockHandler(mockingStarCore) } func startServer() async throws { diff --git a/README.md b/README.md index 168f76a3a8..9a4d060130 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ The latest binary can also be found on the releases page or clone and compile in - **Mocking Requests**: Easily mock requests and test different cases with scenarios. - **Modifying Requests**: Modify intercepted requests to test different edge cases, allowing you to assess your application's performance under different conditions. +- **Partial Mocking**: Apply targeted modifications to response JSON using RFC 9535 JSONPath queries and RFC 6902 JSON Patch operations. Supports template variable resolution (`{domain}`, `{method}`, `{requestPath}`, `{jsonPath}`), wildcard URL/method matching, and all six RFC 6902 operation types (add, remove, replace, move, copy, test). - **Debugging Support**: Use Mocking Star to debug your network requests on your mac. - **UI Testing**: Integrate Mocking Star into your UI tests, creating a reliable and controlled testing environment to validate your mobile application's functionality. - **Plugins**: Write your own plugins and extend functionality. @@ -47,6 +48,7 @@ Our UI tests are executed approximately 20k times every day, and Mocking Star ha Mocking Star relies on these amazing open-source libraries: - [AnyCodable](https://github.com/yusufozgul/AnyCodable) - Flexible type for encoding and decoding of JSON +- [DynamicJSON](https://github.com/objecthub/swift-dynamicjson) - RFC 9535 JSONPath and RFC 6902 JSON Patch support - [FileMonitor](https://github.com/aus-der-Technik/FileMonitor) - File monitoring utility - [FlyingFox](https://github.com/swhitty/FlyingFox) - Lightweight HTTP server - [Sparkle](https://github.com/sparkle-project/Sparkle) - Software update framework