diff --git a/Sources/HTTPMock/Internal/HTTPMockMatcher+ExpressionStorage.swift b/Sources/HTTPMock/Internal/HTTPMockMatcher+ExpressionStorage.swift new file mode 100644 index 0000000..962b618 --- /dev/null +++ b/Sources/HTTPMock/Internal/HTTPMockMatcher+ExpressionStorage.swift @@ -0,0 +1,93 @@ +import Foundation + +extension HTTPMockMatcher { + /// Shared cache for compiled wildcard regular expressions. + /// Compiles lazily on first use and reuses across instances and namespaces. + class ExpressionStorage { + + // MARK: - Internal properties + + var hostExpressions = [String: NSRegularExpression]() + var pathExpressions = [String: NSRegularExpression]() + + // MARK: - Private properties + + private let lock = NSLock() + + // MARK: - Init + + init() {} + + // MARK: - Internal methods + + /// Returns (and caches) a compiled `NSRegularExpression` for the given glob pattern. + /// + /// For path patterns, the `/**/` segment is treated as optional to allow matching zero segments. + /// This means `/api/**/users` will match both `/api/users` and `/api/x/users`. + /// - Parameters: + /// - pattern: The glob pattern to compile. + /// - kind: The URL component kind to create regex for. + /// - Throws: Any compilation error from `NSRegularExpression` if the pattern is invalid. + func regex(for pattern: String, kind: MatchKind) throws -> NSRegularExpression { + lock.lock() + defer { lock.unlock() } + + // Check if we have a cached regex already + switch kind { + case .host: + if let cached = hostExpressions[pattern] { + return cached + } + case .path: + if let cached = pathExpressions[pattern] { + return cached + } + } + + let compiled = try compile(pattern: pattern, kind: kind) + + switch kind { + case .host: hostExpressions[pattern] = compiled + case .path: pathExpressions[pattern] = compiled + } + + return compiled + } + + // MARK: - Private methods + + /// Compiles a glob pattern into a regular expression. + /// `*` matches a single segment, `**` matches across segments. + /// + /// For path patterns, `/**/` is made optional to allow zero segments between slashes. + /// - Parameters: + /// - pattern: The glob pattern. + /// - kind: The URL component kind to create regex for. + /// - Returns: A compiled regular expression anchored to the start and end of the string. + private func compile(pattern: String, kind: MatchKind) throws -> NSRegularExpression { + // Escape regex meta characters first. + var escaped = NSRegularExpression.escapedPattern(for: pattern) + + if case .path = kind { + // Make "/**/" optional so `**` can match zero segments between slashes. + // Example: `/api/**/users` should match both `/api/users` and `/api/v1/users`. + escaped = escaped.replacingOccurrences(of: "\\/\\*\\*\\/", with: "(?:/.*)?") + + // If the pattern starts with `**/`, make the leading prefix optional. + escaped = escaped.replacingOccurrences(of: "\\*\\*\\/", with: "(?:.*/)?") + + // If the pattern ends with `/**`, make the trailing suffix optional. + escaped = escaped.replacingOccurrences(of: "\\/\\*\\*", with: "(?:/.*)?") + } + + // Replace glob tokens. Order matters: handle `**` before `*`. + escaped = escaped.replacingOccurrences(of: "\\*\\*", with: ".*") + escaped = escaped.replacingOccurrences(of: "\\*", with: kind.singleSegmentClass) + + return try NSRegularExpression( + pattern: "^" + escaped + "$", + options: kind.regexOptions + ) + } + } +} diff --git a/Sources/HTTPMock/Internal/HTTPMockMatcher+MatchKind.swift b/Sources/HTTPMock/Internal/HTTPMockMatcher+MatchKind.swift new file mode 100644 index 0000000..3be3df9 --- /dev/null +++ b/Sources/HTTPMock/Internal/HTTPMockMatcher+MatchKind.swift @@ -0,0 +1,22 @@ +import Foundation + +extension HTTPMockMatcher { + enum MatchKind { + case host + case path + + var singleSegmentClass: String { + switch self { + case .host: "[^.]*" // Segment delimiter is `.` + case .path: "[^/]*" // Segment delimiter is `/` + } + } + + var regexOptions: NSRegularExpression.Options { + switch self { + case .host: [.caseInsensitive] + case .path: [] + } + } + } +} diff --git a/Sources/HTTPMock/Internal/HTTPMockMatcher.swift b/Sources/HTTPMock/Internal/HTTPMockMatcher.swift new file mode 100644 index 0000000..eaf0e7e --- /dev/null +++ b/Sources/HTTPMock/Internal/HTTPMockMatcher.swift @@ -0,0 +1,124 @@ +import Foundation + +/// Responsible for matching incoming requests against registered mock keys. +/// Supports exact matches and wildcard patterns (`*` for a single segment, `**` for multiple segments (zero or more)). +/// Also compares query parameters and applies query matching rules (`.exact` / `.contains`). +struct HTTPMockMatcher { + + // MARK: - Internal properties + + let expressionStorage = ExpressionStorage() + + // MARK: - Internal methods + + /// Finds the most specific key that matches the given request values. + /// - Parameters: + /// - host: The request host string (lowercased where applicable). + /// - path: The request path string (leading "/" guaranteed). + /// - queryItems: Dictionary of query params from the request. + /// - candidates: The set of registered keys to match against (within a namespace). + /// - Returns: A matching key if found, preferring exact matches first and then the most specific wildcard pattern. + func match( + host: String, + path: String, + queryItems: [String: String], + in candidates: Set + ) -> HTTPMockURLProtocol.Key? { + let exactMatch = candidates.first { candidate in + candidate.host == host && + candidate.path == path && + queryMatches(candidate, requestQueryItems: queryItems) + } + + // Return early if we have an exact key match + if let exactMatch { return exactMatch } + + // Find wildcard candidates + let wildcardCandidates = candidates.filter { candidate in + queryMatches(candidate, requestQueryItems: queryItems) && + wildcardMatch(pattern: candidate.host, value: host, kind: .host) && + wildcardMatch(pattern: candidate.path, value: path, kind: .path) + } + + // Prefer the most specific candidate, aka. fewest wildcards, then longest literal length + return wildcardCandidates + .map { (key: $0, score: specificityScore(for: $0)) } + .sorted { $0.score < $1.score } + .first? + .key + } + + /// Checks whether a candidate key's query requirements match the request's query items. + /// - Parameters: + /// - candidate: The stored key we compare against. + /// - requestQueryItems: The request's query items as a dictionary. + /// - Returns: `true` if the request query satisfies the candidate's rule, otherwise `false`. + func queryMatches( + _ candidate: HTTPMockURLProtocol.Key, + requestQueryItems: [String: String] + ) -> Bool { + guard let expected = candidate.queryItems else { + return true + } + + switch candidate.queryMatching { + case .exact: + return expected == requestQueryItems + case .contains: + return expected.allSatisfy { key, value in + requestQueryItems[key] == value + } + } + } + + /// Matches a concrete string value against a glob pattern. + /// - Parameters: + /// - pattern: A literal or glob string (`*` single segment, `**` multi segment (zero or more)). + /// - value: The actual host or path string to test. + /// - kind: The URL component kind to compare against. + /// - Returns: `true` if the value matches the pattern, otherwise `false`. + func wildcardMatch( + pattern: String, + value: String, + kind: MatchKind + ) -> Bool { + // If pattern doesn't include any wildcards we just check if the strings match. + if !pattern.contains("*") { + return pattern == value + } + + // Check if we have a cached pattern already, or create one if not. + // If regex compilation fails: fail silently and return `false`. + guard let regularExpression = try? expressionStorage.regex(for: pattern, kind: kind) else { + return false + } + + let searchRange = NSRange(value.startIndex.. (Int, Int) { + func score(for pattern: String) -> (wildcardCount: Int, literalCount: Int) { + let wildcardCount = pattern.filter { $0 == "*" }.count + let literalCount = pattern.replacingOccurrences(of: "*", with: "").count + return (wildcardCount, literalCount) + } + + let hostScore = score(for: key.host) + let pathScore = score(for: key.path) + + // Fewer wildcards first. If equal: prefer longer literals. + // Returns tuple: (wildcardCount, -literalCount) for lexicographical comparison. + let wildcardScore = hostScore.wildcardCount + pathScore.wildcardCount + let literalScore = hostScore.literalCount + pathScore.literalCount + + return (wildcardScore, -literalScore) + } +} diff --git a/Sources/HTTPMock/Internal/HTTPMockURLProtocol+Key.swift b/Sources/HTTPMock/Internal/HTTPMockURLProtocol+Key.swift index c5f4c73..ba628cd 100644 --- a/Sources/HTTPMock/Internal/HTTPMockURLProtocol+Key.swift +++ b/Sources/HTTPMock/Internal/HTTPMockURLProtocol+Key.swift @@ -6,5 +6,12 @@ extension HTTPMockURLProtocol { let path: String let queryItems: [String: String]? let queryMatching: QueryMatching + + init(host: String, path: String, queryItems: [String : String]?, queryMatching: QueryMatching) { + self.host = host.lowercased() + self.path = path + self.queryItems = queryItems + self.queryMatching = queryMatching + } } } diff --git a/Sources/HTTPMock/Internal/HTTPMockURLProtocol+MockMatch.swift b/Sources/HTTPMock/Internal/HTTPMockURLProtocol+MockMatch.swift new file mode 100644 index 0000000..80fbfda --- /dev/null +++ b/Sources/HTTPMock/Internal/HTTPMockURLProtocol+MockMatch.swift @@ -0,0 +1,8 @@ +import Foundation + +extension HTTPMockURLProtocol { + struct MockMatch { + let key: Key + let response: MockResponse + } +} diff --git a/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift b/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift index cc80b7e..3ba354b 100644 --- a/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift +++ b/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift @@ -3,12 +3,13 @@ import Foundation final class HTTPMockURLProtocol: URLProtocol { private static var queues: [UUID: [Key: [MockResponse]]] = [:] private static var unmockedPolicyStorage: [UUID: UnmockedPolicy] = [:] + private static let matcher = HTTPMockMatcher() private static let handledKey = "HTTPMockHandled" private static let queueLock = DispatchQueue(label: "MockURLProtocol.queueLock") private static let unmockedPolicyLock = DispatchQueue(label: "MockURLProtocol.unmockedPolicyLock") /// A plain session without `HTTPMockURLProtocol` to support passthrough of requests when policy requires it. - private lazy var passthroughSession: URLSession = URLSession(configuration: .ephemeral) + private lazy var passthroughSession = URLSession(configuration: .ephemeral) // MARK: - Internal methods @@ -123,7 +124,7 @@ final class HTTPMockURLProtocol: URLProtocol { guard let url = request.url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let host = components.host + let host = components.host?.lowercased() else { client?.urlProtocol(self, didFailWithError: URLError(.badURL)) return @@ -136,12 +137,22 @@ final class HTTPMockURLProtocol: URLProtocol { HTTPMockLog.trace("Handling request → \(requestDescription)") // Look for, and pop, the next queued response mathing host, path and query params. - if let mock = Self.pop(mockIdentifier: mockIdentifier, host: host, path: path, query: queryDict) { + let match = Self.findAndPopNextMock( + for: mockIdentifier, + host: host, + path: path, + query: queryDict + ) + + if let match { + let key = match.key + let mock = match.response + let sendResponse = { [weak self] in guard let self else { return } do { HTTPMockLog.info("Serving mock for \(host)\(path) (\(self.statusCode(of: mock)))") - HTTPMockLog.debug("Remaining queue for \(requestDescription): \(Self.queueSize(mockIdentifier: mockIdentifier, host: host, path: path, query: queryDict))") + HTTPMockLog.debug("Remaining queue for \(requestDescription): \(Self.queueSize(for: mockIdentifier, key: key))") let response = HTTPURLResponse( url: url, @@ -213,18 +224,16 @@ final class HTTPMockURLProtocol: URLProtocol { // MARK: - Private methods - private static func pop( - mockIdentifier: UUID, + private static func findAndPopNextMock( + for mockIdentifier: UUID, host: String, path: String, query: [String: String] - ) -> MockResponse? { + ) -> MockMatch? { var mockQueues = getQueue(for: mockIdentifier) // Find the first key matching host+path(+query). - let matchingKey = mockQueues.keys.first { - matches($0, host: host, path: path, query: query) - } + let matchingKey = matcher.match(host: host, path: path, queryItems: query, in: Set(mockQueues.keys)) if let matchingKey { guard var queue = mockQueues[matchingKey], !queue.isEmpty else { @@ -251,42 +260,20 @@ final class HTTPMockURLProtocol: URLProtocol { mockQueues[matchingKey] = [copy] + queue setQueue(for: mockIdentifier, mockQueues) HTTPMockLog.info("Mock response will be used \(count) more time(s) for \(mockKeyDescription(matchingKey))") - return copy + return MockMatch(key: matchingKey, response: copy) } case .eternal: - return first + return MockMatch(key: matchingKey, response: first) } if queue.isEmpty { HTTPMockLog.info("Queue now depleted for \(mockKeyDescription(matchingKey))") } - return first + return MockMatch(key: matchingKey, response: first) } return nil } - - private static func matches( - _ key: Key, - host: String, - path: String, - query: [String: String] - ) -> Bool { - guard key.host == host, key.path == path else { - return false - } - - guard let requiredQueryItems = key.queryItems, !requiredQueryItems.isEmpty else { - return true - } - - switch key.queryMatching { - case .exact: - return requiredQueryItems == query - case .contains: - return requiredQueryItems.allSatisfy { (k, v) in query[k] == v } - } - } } // MARK: - Helper utils for logging @@ -304,16 +291,9 @@ extension HTTPMockURLProtocol { "\(key.host)\(key.path) \(describeQuery(key.queryItems, key.queryMatching))" } - private static func queueSize( - mockIdentifier: UUID, - host: String, - path: String, - query: [String: String] - ) -> Int { - getQueue(for: mockIdentifier) - .filter { matches($0.key, host: host, path: path, query: query) } - .map(\.value.count) - .first ?? 0 + private static func queueSize(for mockIdentifier: UUID, key: Key) -> Int { + let queue = getQueue(for: mockIdentifier) + return queue[key]?.count ?? 0 } private static func describeQuery( diff --git a/Tests/HTTPMockTests/HTTPMockMatcherTests.swift b/Tests/HTTPMockTests/HTTPMockMatcherTests.swift new file mode 100644 index 0000000..2694abb --- /dev/null +++ b/Tests/HTTPMockTests/HTTPMockMatcherTests.swift @@ -0,0 +1,394 @@ +import Testing +import Foundation +@testable import HTTPMock + +struct HTTPMockMatcherTests { + let matcher = HTTPMockMatcher() + + // MARK: - Exact vs wildcard + + @Test + func exactMatchIsPreferredOverWildcard() { + let exact = makeKey(host: "api.example.com") + let wildcard = makeKey(host: "*.example.com") + + let match = checkMatch(in: [exact, wildcard]) + #expect(match == exact) + } + + // MARK: - Host wildcards + + @Test + func singleSegmentHostWildcardMatchesOneLabelOnly() { + let single = makeKey(host: "*.example.com") + let deep = makeKey(host: "**.example.com") + + // api.example.com will match both. Specificity will pick the one with fewer wildcards. + let match1 = checkMatch(in: [single, deep]) + #expect(match1 == single) + + // a.b.example.com should only match the ** variant + let match2 = checkMatch(host: "a.b.example.com", in: [single, deep]) + #expect(match2 == deep) + } + + @Test + func hostMatchingIsCaseInsensitive() { + let keyUpper = makeKey(host: "API.EXAMPLE.COM") + + let match = checkMatch(in: [keyUpper]) + #expect(match == keyUpper) + } + + @Test + func hostMatchingWildcardInsteadOfSegmentDelimiter() { + let key = makeKey(host: "api*.example.com") + + let match1 = checkMatch(host: "api.example.com", in: [key]) + #expect(match1 == key) + + let match2 = checkMatch(host: "api-test.example.com", in: [key]) + #expect(match2 == key) + + let bad = checkMatch(host: "api.test.example.com", in: [key]) + #expect(bad == nil) + } + + @Test + func hostWildcardWithPort() { + let key = makeKey(host: "*.example.com:8080") + + let match = checkMatch(host: "api.example.com:8080", in: [key]) + #expect(match == key) + + let noMatch = checkMatch(host: "api.example.com:9090", in: [key]) + #expect(noMatch == nil) + + // Test without port should not match + let noPortMatch = checkMatch(host: "api.example.com", in: [key]) + #expect(noPortMatch == nil) + } + + // MARK: - Path wildcards + + @Test + func singleSegmentPathWildcardDoesNotCrossSlash() { + let key = makeKey(path: "/api/*/users") + + let match = checkMatch(path: "/api/v1/users", in: [key]) + #expect(match == key) + + let bad = checkMatch(path: "/api/v1/x/users", in: [key]) + #expect(bad == nil) + } + + @Test + func multiSegmentPathWildcardCrossesSlash() { + let key = makeKey(path: "/api/**/users") + + let match1 = checkMatch(path: "/api/users", in: [key]) + #expect(match1 == key) + + let match2 = checkMatch(path: "/api/v1/users", in: [key]) + #expect(match2 == key) + + let match3 = checkMatch(path: "/api/v1/x/users", in: [key]) + #expect(match3 == key) + } + + @Test + func wildcardMatchesEmptyPath() { + let key = makeKey(path: "/**") + + let match1 = checkMatch(path: "/", in: [key]) + #expect(match1 == key) + + let match2 = checkMatch(path: "/anything", in: [key]) + #expect(match2 == key) + } + + @Test + func multipleWildcardsInSingleSegment() { + let key = makeKey(path: "/api/*-*") + + let match = checkMatch(path: "/api/v1-test", in: [key]) + #expect(match == key) + + let noMatch = checkMatch(path: "/api/v1", in: [key]) + #expect(noMatch == nil) + } + + @Test + func doubleStarAtBoundaries() { + let startKey = makeKey(path: "**/users") + let endKey = makeKey(path: "/api/**") + let bothKey = makeKey(path: "**/api/**") + + let match1 = checkMatch(path: "/users", in: [startKey]) + #expect(match1 == startKey) + + let match2 = checkMatch(path: "users", in: [startKey]) + #expect(match2 == startKey) + + let match3 = checkMatch(path: "/api/", in: [endKey]) + #expect(match3 == endKey) + + let match4 = checkMatch(path: "/api/v1/something", in: [endKey]) + #expect(match4 == endKey) + + let match5 = checkMatch(path: "api", in: [bothKey]) + #expect(match5 == bothKey) + + let match6 = checkMatch(path: "/some/api/v1", in: [bothKey]) + #expect(match6 == bothKey) + } + + @Test + func pathSlashHandling() { + let keyWithSlash = makeKey(path: "/api/*/") + let keyWithoutSlash = makeKey(path: "/api/*") + + // Test that both can match similar requests + let match1 = checkMatch(path: "/api/users", in: [keyWithoutSlash]) + #expect(match1 == keyWithoutSlash) + + let match2 = checkMatch(path: "/api/users/", in: [keyWithSlash]) + #expect(match2 == keyWithSlash) + + // Test specificity when both could match + let match3 = checkMatch(path: "/api/users/", in: [keyWithSlash, keyWithoutSlash]) + #expect(match3 == keyWithSlash) // Exact match should win + } + + // MARK: - Specificity tie-breakers + + @Test + func fewerWildcardsBeatMoreWildcards_thenLongerLiteralsWin() { + let twoStars = makeKey(path: "/api/**/users") + let threeStars = makeKey(path: "/api/*/users/**") + let longerLiteral = makeKey(path: "/api/*/users/active") + + // For "/api/x/users": `twoStars` and `threeStars` both match. + // `twoStars` wins because of fewer wildcards, which contributes to a higher specificity. + let match1 = checkMatch(path: "/api/x/users", in: [twoStars, threeStars, longerLiteral]) + #expect(match1 == twoStars) + + // For "/api/x/users/active": `threeStars` and `longerLiteral` both match. + // `longerLiteral` wins because it has a longer literal, which contributes to a higher specificity. + let match2 = checkMatch(path: "/api/x/users/active", in: [twoStars, threeStars, longerLiteral]) + #expect(match2 == longerLiteral) + } + + // MARK: - Specificity ordering (comprehensive) + + @Test + func specificity_exactBeatsSingleAndDoubleWildcards_host() { + let exact = makeKey(host: "api.example.com") + let single = makeKey(host: "*.example.com") + let multi = makeKey(host: "**.example.com") + + let match = checkMatch(in: [multi, single, exact]) + #expect(match == exact) + } + + @Test + func specificity_exactPathBeatsSingleAndDoubleWildcards_path() { + let exact = makeKey(path: "/api/users") + let single = makeKey(path: "/api/*") + let multi = makeKey(path: "/api/**") + + let match = checkMatch(path: "/api/users", in: [multi, single, exact]) + #expect(match == exact) + } + + @Test + func specificity_tieOnWildcardCount_prefersLongerLiteral_path() { + // Both have one wildcard, but one has a longer literal segment and should win when both match + let shorter = makeKey(path: "/api/*/users/*") // literals: "/api//users" (shorter) + let longer = makeKey(path: "/api/*/users/active") // literals include "/active" (longer) + + let match = checkMatch(path: "/api/v1/users/active", in: [shorter, longer]) + #expect(match == longer) + } + + @Test + func specificity_hostVsPath_whenWildcardCountsEqual_prefersCandidateWithMoreLiteralsTotal() { + // Candidate A: exact host + wildcard path (1 wildcard) + let candidateA = makeKey(host: "api.example.com", path: "/p/*") + // Candidate B: wildcard host (1 wildcard) + exact path + let candidateB = makeKey(host: "*.example.com", path: "/products/list/details") + // For this URL, both match; total wildcard count is 1 for both. + // Candidate B has a much longer literal path, so it should win. + let match = checkMatch(host: "api.example.com", path: "/products/list/details", in: [candidateA, candidateB]) + #expect(match == candidateB) + } + + // MARK: - Query parameters + + @Test + func queryExactRequiresAllAndOnlySpecifiedPairs() { + let key = makeKey(queryItems: ["q": "swift", "page": "1"]) + + let match = checkMatch(queryItems: ["page": "1", "q": "swift"], in: [key]) + #expect(match == key) + + let bad = checkMatch(queryItems: ["page": "1", "q": "swift", "foo": "bar"], in: [key]) + #expect(bad == nil) + } + + @Test + func queryContainsRequiresSpecifiedPairs_only() { + let key = makeKey(queryItems: ["q": "swift"], queryMatching: .contains) + + let match1 = checkMatch(queryItems: ["q": "swift"], in: [key]) + #expect(match1 == key) + + let match2 = checkMatch(queryItems: ["q": "swift", "page": "2"], in: [key]) + #expect(match2 == key) + + let bad = checkMatch(queryItems: ["q": "swif"], in: [key]) + #expect(bad == nil) + } + + // MARK: - Error handling + + @Test + func invalidRegexPatternHandling() { + // Test with potentially problematic patterns alongside valid ones + let candidates = [ + makeKey(host: "*.example.com"), // valid wildcard pattern + makeKey(host: "api.example.com"), // exact match + ] + + let match = checkMatch(in: candidates) + + // Should prefer exact match over wildcard + #expect(match?.host == "api.example.com") + + // Test with wildcard match + let wildcardMatch = checkMatch(host: "test.example.com", in: candidates) + #expect(wildcardMatch?.host == "*.example.com") + } + + // MARK: - Specificity Score Tests + + @Test + func specificityScore_exactHostAndPath() { + let key = makeKey(host: "api.example.com", path: "/users/list") + let score = matcher.specificityScore(for: key) + + // No wildcards. Literal count is length of host + path + let literalCount = "api.example.com".count + "/users/list".count + #expect(score == (0, -literalCount)) + } + + @Test + func specificityScore_singleWildcardInPath() { + let key = makeKey(host: "api.example.com", path: "/users/*") + let score = matcher.specificityScore(for: key) + + // 1 wildcard. Literal count is host + "/users/" + let literalCount = "api.example.com".count + "/users/".count + #expect(score == (1, -literalCount)) + } + + @Test + func specificityScore_doubleWildcardInPath() { + let key = makeKey(host: "api.example.com", path: "/users/**") + let score = matcher.specificityScore(for: key) + let literalCount = "api.example.com".count + "/users/".count + #expect(score == (2, -literalCount)) // '**' counts as two wildcards + } + + @Test + func specificityScore_singleWildcardInHost() { + let key = makeKey(host: "*.example.com", path: "/users") + let score = matcher.specificityScore(for: key) + let literalCount = ".example.com".count + "/users".count + #expect(score == (1, -literalCount)) + } + + @Test + func specificityScore_doubleWildcardInHost() { + let key = makeKey(host: "**.example.com", path: "/users") + let score = matcher.specificityScore(for: key) + let literalCount = ".example.com".count + "/users".count + #expect(score == (2, -literalCount)) + } + + @Test + func specificityScore_multipleWildcards_hostAndPath() { + let key = makeKey(host: "*.example.com", path: "/users/*") + let score = matcher.specificityScore(for: key) + let literalCount = ".example.com".count + "/users/".count + #expect(score == (2, -literalCount)) + } + + @Test + func specificityScore_mixedWildcardsAndLiterals() { + let key = makeKey(host: "api.*.com", path: "/users/*/details") + let score = matcher.specificityScore(for: key) + + // host: "api.*.com" => 1 wildcard. Literals: "api.", ".com" + // path: "/users/*/details" => 1 wildcard. Literals: "/users/", "/details" + let literalCount = "api.".count + ".com".count + "/users/".count + "/details".count + #expect(score == (2, -literalCount)) + } + + @Test + func specificityScore_allWildcards() { + let key = makeKey(host: "**", path: "/**") + let score = matcher.specificityScore(for: key) + + // 2 wildcards in host, 2 in path, the single slash in path counts as 1 literal + #expect(score == (4, -1)) + } + + // MARK: - Expression storage caching + + @Test + func regexCaching() throws { + let storage = HTTPMockMatcher.ExpressionStorage() + + // First call should compile + let regex1 = try storage.regex(for: "*/test", kind: .host) + + // Second call should use cache (same object reference) + let regex2 = try storage.regex(for: "*/test", kind: .host) + + #expect(regex1 === regex2) + + // Different kind should create different regex + let regex3 = try storage.regex(for: "*/test", kind: .path) + #expect(regex1 !== regex3) + + // Different pattern should create different regex + let regex4 = try storage.regex(for: "**/test", kind: .host) + #expect(regex1 !== regex4) + } + + // MARK: - Helpers + + func makeKey( + host: String = "api.example.com", + path: String = "/search", + queryItems: [String: String]? = nil, + queryMatching: QueryMatching = .exact + ) -> HTTPMockURLProtocol.Key { + HTTPMockURLProtocol.Key( + host: host, + path: path, + queryItems: queryItems, + queryMatching: queryMatching + ) + } + + func checkMatch( + host: String = "api.example.com", + path: String = "/search", + queryItems: [String: String] = [:], + in candidates: [HTTPMockURLProtocol.Key] + ) -> HTTPMockURLProtocol.Key? { + matcher.match(host: host, path: path, queryItems: queryItems, in: Set(candidates)) + } +}