diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3b7a87b..d61b442 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,7 +35,7 @@ jobs: xcode: 15.3 env: - SCHEME: "PactSwift-iOS" + SCHEME: ${{ matrix.scheme }} DESTINATION: ${{ matrix.destination }} concurrency: diff --git a/PactSwift.xcfilelist b/PactSwift.xcfilelist index e6d8c33..2be46f3 100644 --- a/PactSwift.xcfilelist +++ b/PactSwift.xcfilelist @@ -24,5 +24,6 @@ $(SRCROOT)/Sources/Matchers/Matcher+V1.swift $(SRCROOT)/Sources/Matchers/Matcher+V3.swift $(SRCROOT)/Sources/Matchers/Matcher.swift $(SRCROOT)/Sources/Matchers/HTTPStatus.swift +$(SRCROOT)/Sources/Matchers/ArrayContainsMatcher.swift $(SRCROOT)/Sources/Matchers/AnyMatcher.swift $(SRCROOT)/Sources/ProviderVerifier.swift diff --git a/PactSwift.xcodeproj/project.pbxproj b/PactSwift.xcodeproj/project.pbxproj index a254cc0..fa64bcc 100644 --- a/PactSwift.xcodeproj/project.pbxproj +++ b/PactSwift.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + F13100010000000000000000 /* ArrayContainsMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = F13100100000000000000000 /* ArrayContainsMatcher.swift */; }; + F13100020000000000000000 /* ArrayContainsMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = F13100100000000000000000 /* ArrayContainsMatcher.swift */; }; + F13100030000000000000000 /* MatcherArrayContainsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F13100110000000000000000 /* MatcherArrayContainsTests.swift */; }; + F13100040000000000000000 /* MatcherArrayContainsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F13100110000000000000000 /* MatcherArrayContainsTests.swift */; }; A7096C332974C73A0020BBD4 /* UUIDFormatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7096C322974C73A0020BBD4 /* UUIDFormatTests.swift */; }; A7096C342974C73A0020BBD4 /* UUIDFormatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7096C322974C73A0020BBD4 /* UUIDFormatTests.swift */; }; A7F1851F296BC525003AE3F2 /* Matcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F1851E296BC525003AE3F2 /* Matcher.swift */; }; @@ -169,6 +173,8 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + F13100100000000000000000 /* ArrayContainsMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayContainsMatcher.swift; sourceTree = ""; }; + F13100110000000000000000 /* MatcherArrayContainsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatcherArrayContainsTests.swift; sourceTree = ""; }; A7096C322974C73A0020BBD4 /* UUIDFormatTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UUIDFormatTests.swift; sourceTree = ""; }; A7F1851E296BC525003AE3F2 /* Matcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Matcher.swift; sourceTree = ""; }; A7F18521296BC55A003AE3F2 /* GenericMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericMatcher.swift; sourceTree = ""; }; @@ -309,6 +315,7 @@ AD01C9392432A31F00B75C9D /* Matchers */ = { isa = PBXGroup; children = ( + F13100100000000000000000 /* ArrayContainsMatcher.swift */, A7F185A5296FDCDB003AE3F2 /* AnyMatcher.swift */, A7F18524296BC5C1003AE3F2 /* GeneratorType.swift */, A7F18521296BC55A003AE3F2 /* GenericMatcher.swift */, @@ -362,6 +369,7 @@ AD7891B8244150FF0014BCC8 /* Matchers */ = { isa = PBXGroup; children = ( + F13100110000000000000000 /* MatcherArrayContainsTests.swift */, ADF959C926C8F6EB00C35536 /* FromProviderStateTests.swift */, A7F1853E296BD168003AE3F2 /* MatcherBooleanTests.swift */, A7F18541296BD1D8003AE3F2 /* MatcherContentTypeTests.swift */, @@ -868,6 +876,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + F13100010000000000000000 /* ArrayContainsMatcher.swift in Sources */, AD879B9D258242AC00F85B0B /* PactSwiftVersion.swift in Sources */, A7F18582296BE726003AE3F2 /* BodyBuilder+Extensions.swift in Sources */, AD48EC5826CF90B40017E071 /* ProviderVerifier.swift in Sources */, @@ -902,6 +911,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + F13100030000000000000000 /* MatcherArrayContainsTests.swift in Sources */, A7F18542296BD1D8003AE3F2 /* MatcherContentTypeTests.swift in Sources */, AD7891C024415C710014BCC8 /* MatcherIntegerTests.swift in Sources */, ADBC3E5326DB322C006908E0 /* VersionSelectorTests.swift in Sources */, @@ -951,6 +961,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + F13100020000000000000000 /* ArrayContainsMatcher.swift in Sources */, AD879B9E258242AC00F85B0B /* PactSwiftVersion.swift in Sources */, A7F18581296BE725003AE3F2 /* BodyBuilder+Extensions.swift in Sources */, AD8FC7E32463BB9F00361854 /* ErrorReporter.swift in Sources */, @@ -985,6 +996,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + F13100040000000000000000 /* MatcherArrayContainsTests.swift in Sources */, A7F18543296BD1D8003AE3F2 /* MatcherContentTypeTests.swift in Sources */, AD8FC8032463BBD000361854 /* MatcherDecimalTests.swift in Sources */, A7F1855B296BDB58003AE3F2 /* MatcherRandomUUIDTests.swift in Sources */, diff --git a/Sources/Matchers/ArrayContainsMatcher.swift b/Sources/Matchers/ArrayContainsMatcher.swift new file mode 100644 index 0000000..cf04f84 --- /dev/null +++ b/Sources/Matchers/ArrayContainsMatcher.swift @@ -0,0 +1,47 @@ +// +// Created by José Jeria on 8/7/2026. +// Copyright © 2026 José Jeria. All rights reserved. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +// IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +// + +import Foundation + +/// A ``Matcher`` for the `arrayContains` matching rule. +/// +/// It serialises to the integration JSON format expected by the Pact FFI: +/// +/// ```json +/// { +/// "pact:matcher:type": "arrayContains", +/// "variants": [ ... ] +/// } +/// ``` +/// +/// Each variant is an example value (typically an object) that may contain nested +/// matchers inline. The FFI extracts those nested matchers into per-variant rules. +struct ArrayContainsMatcher: Matcher { + + var variants: [Variant] + + enum CodingKeys: String, CodingKey { + case type = "pact:matcher:type" + case variants + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode("arrayContains", forKey: .type) + try container.encode(variants, forKey: .variants) + } +} diff --git a/Sources/Matchers/Matcher+V4.swift b/Sources/Matchers/Matcher+V4.swift index 72eadfe..1974d82 100644 --- a/Sources/Matchers/Matcher+V4.swift +++ b/Sources/Matchers/Matcher+V4.swift @@ -20,11 +20,50 @@ import Foundation // MARK: - Pact Specification v4 matchers public extension Matcher { - // TODO: "arrayContains" - /// A matcher that matches the response status code. + /// A matcher that checks that the actual array contains an element matching every provided + /// variant. + /// + /// Each variant is an example object whose values may themselves be matchers. Variant order + /// does not matter, and the actual array may contain additional elements. + /// + /// For example, to assert that a HAL `actions` array contains both an "add-item" and a + /// "delete-item" action: + /// + /// ```swift + /// .arrayContains([ + /// ["name": .regex("add\\-item", example: "add-item"), "method": .regex("POST", example: "POST")], + /// ["name": .regex("delete\\-item", example: "delete-item"), "method": .regex("DELETE", example: "DELETE")], + /// ]) + /// ``` /// /// - Note: Requires `Pact.Specification.v4`. + /// - Parameters: + /// - variants: Example objects describing the variants that the actual array must satisfy. + /// + static func arrayContains(_ variants: [[String: AnyMatcher]]) -> AnyMatcher { + ArrayContainsMatcher(variants: variants).asAny() + } + + /// A matcher that checks that the actual array contains an element matching every provided + /// variant. + /// + /// Variant order does not matter, and the actual array may contain additional elements. Use + /// this overload for matcher-wrapped scalar, array, or otherwise prebuilt variants. + /// + /// - Note: Requires `Pact.Specification.v4`. + /// - Parameters: + /// - variants: Matchers describing the variants that the actual array must satisfy. + /// + static func arrayContains(_ variants: [AnyMatcher]) -> AnyMatcher { + ArrayContainsMatcher(variants: variants).asAny() + } + + /// A matcher that checks the response status against a status class or explicit set of codes. + /// + /// - Note: Requires `Pact.Specification.v4`. + /// - Parameters: + /// - statusCode: The status class or explicit status codes that are accepted. /// static func statusCode(_ statusCode: HTTPStatus) -> AnyMatcher { switch statusCode { @@ -47,7 +86,7 @@ public extension Matcher { } } - /// A matcher that matches a value that must be present and not empty (not null or the empty string). + /// A matcher that requires a value to be present, non-null, and not an empty string. /// /// - Note: Requires `Pact.Specification.v4`. /// @@ -55,12 +94,12 @@ public extension Matcher { GenericMatcher(type: "notEmpty", value: "non-empty").asAny() } - /// A matcher that matches a value that must be valid based on the `semver` specification. + /// A matcher that requires the string representation of a value to be a valid semantic version. /// /// - Note: Requires `Pact.Specification.v4`. /// /// - Parameters: - /// - value: An example value (eg: `"1.2.3"`) + /// - value: An example semantic version, such as `"1.2.3"`. /// static func semver(_ value: String) -> AnyMatcher { GenericMatcher(type: "semver", value: value).asAny() diff --git a/Tests/Matchers/MatcherArrayContainsTests.swift b/Tests/Matchers/MatcherArrayContainsTests.swift new file mode 100644 index 0000000..52353ea --- /dev/null +++ b/Tests/Matchers/MatcherArrayContainsTests.swift @@ -0,0 +1,115 @@ +// +// Created by José Jeria on 8/7/2026. +// Copyright © 2026 José Jeria. All rights reserved. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +// IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +// + +import XCTest + +@testable import PactSwift + +class MatcherArrayContainsTests: MatcherTestCase { + + func testMatcher_ArrayContainsObjectVariants_SerializesToJSON() throws { + let json = try jsonString( + for: .arrayContains([ + [ + "name": .regex("add\\-item", example: "add-item"), + "method": .regex("POST", example: "POST"), + ], + [ + "name": .regex("delete\\-item", example: "delete-item"), + "method": .regex("DELETE", example: "DELETE"), + ], + ]) + ) + + XCTAssertEqual( + json, + #""" + { + "pact:matcher:type" : "arrayContains", + "variants" : [ + { + "method" : { + "pact:matcher:type" : "regex", + "regex" : "POST", + "value" : "POST" + }, + "name" : { + "pact:matcher:type" : "regex", + "regex" : "add\\-item", + "value" : "add-item" + } + }, + { + "method" : { + "pact:matcher:type" : "regex", + "regex" : "DELETE", + "value" : "DELETE" + }, + "name" : { + "pact:matcher:type" : "regex", + "regex" : "delete\\-item", + "value" : "delete-item" + } + } + ] + } + """# + ) + } + + func testMatcher_ArrayContainsScalarVariants_SerializesToJSON() throws { + let json = try jsonString( + for: .arrayContains([.equals("Thing1"), .equals("Thing2")]) + ) + + XCTAssertEqual( + json, + #""" + { + "pact:matcher:type" : "arrayContains", + "variants" : [ + { + "pact:matcher:type" : "equality", + "value" : "Thing1" + }, + { + "pact:matcher:type" : "equality", + "value" : "Thing2" + } + ] + } + """# + ) + } + + func testMatcher_ArrayContainsEmptyVariants_SerializesToJSON() throws { + let json = try jsonString(for: .arrayContains([AnyMatcher]())) + + XCTAssertEqual( + json, + #""" + { + "pact:matcher:type" : "arrayContains", + "variants" : [ + + ] + } + """# + ) + } + +}