diff --git a/Package@swift-6.swift b/Package@swift-6.swift index 7b5b81394..bdfb5131b 100644 --- a/Package@swift-6.swift +++ b/Package@swift-6.swift @@ -72,6 +72,10 @@ let packageDependencies: [Package.Dependency] = [ url: "https://github.com/apple/swift-distributed-tracing.git", from: "1.0.0" ), + .package( + url: "https://github.com/swiftlang/swift-testing.git", + branch: "release/6.0" + ), ].appending( .package( url: "https://github.com/apple/swift-nio-ssl.git", @@ -147,6 +151,13 @@ extension Target.Dependency { static var dequeModule: Self { .product(name: "DequeModule", package: "swift-collections") } static var atomics: Self { .product(name: "Atomics", package: "swift-atomics") } static var tracing: Self { .product(name: "Tracing", package: "swift-distributed-tracing") } + static var testing: Self { + .product( + name: "Testing", + package: "swift-testing", + condition: .when(platforms: [.linux]) // Already included in the toolchain on Darwin + ) + } static var grpcCore: Self { .target(name: "GRPCCore") } static var grpcInProcessTransport: Self { .target(name: "GRPCInProcessTransport") } @@ -402,6 +413,10 @@ extension Target { .grpcInProcessTransport, .dequeModule, .protobuf, + .testing, + ], + resources: [ + .copy("Configuration/Inputs") ], swiftSettings: [.swiftLanguageMode(.v6), .enableUpcomingFeature("ExistentialAny")] ) diff --git a/Sources/GRPCCore/Configuration/MethodConfig.swift b/Sources/GRPCCore/Configuration/MethodConfig.swift index b02d25f0e..d99c892df 100644 --- a/Sources/GRPCCore/Configuration/MethodConfig.swift +++ b/Sources/GRPCCore/Configuration/MethodConfig.swift @@ -453,6 +453,7 @@ extension MethodConfig: Codable { public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(self.names, forKey: .name) + try container.encodeIfPresent(self.waitForReady, forKey: .waitForReady) try container.encodeIfPresent( self.timeout.map { GoogleProtobufDuration(duration: $0) }, forKey: .timeout diff --git a/Sources/GRPCCore/RPCError.swift b/Sources/GRPCCore/RPCError.swift index b9147007d..7354a7b83 100644 --- a/Sources/GRPCCore/RPCError.swift +++ b/Sources/GRPCCore/RPCError.swift @@ -107,6 +107,25 @@ extension RPCError { public var description: String { String(describing: self.wrapped) } + + package static let all: [Self] = [ + .cancelled, + .unknown, + .invalidArgument, + .deadlineExceeded, + .notFound, + .alreadyExists, + .permissionDenied, + .resourceExhausted, + .failedPrecondition, + .aborted, + .outOfRange, + .unimplemented, + .internalError, + .unavailable, + .dataLoss, + .unauthenticated, + ] } } diff --git a/Sources/GRPCCore/Status.swift b/Sources/GRPCCore/Status.swift index 738969c0c..d1c9e087b 100644 --- a/Sources/GRPCCore/Status.swift +++ b/Sources/GRPCCore/Status.swift @@ -166,6 +166,26 @@ extension Status { public var description: String { String(describing: self.wrapped) } + + package static let all: [Self] = [ + .ok, + .cancelled, + .unknown, + .invalidArgument, + .deadlineExceeded, + .notFound, + .alreadyExists, + .permissionDenied, + .resourceExhausted, + .failedPrecondition, + .aborted, + .outOfRange, + .unimplemented, + .internalError, + .unavailable, + .dataLoss, + .unauthenticated, + ] } } diff --git a/Tests/GRPCCoreTests/Configuration/Inputs/method_config.hedging_policy.invalid.max_attempts.json b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.hedging_policy.invalid.max_attempts.json new file mode 100644 index 000000000..9436d324b --- /dev/null +++ b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.hedging_policy.invalid.max_attempts.json @@ -0,0 +1,5 @@ +{ + "maxAttempts": 1, + "hedgingDelay": "1s", + "nonFatalStatusCodes": ["ABORTED"] +} diff --git a/Tests/GRPCCoreTests/Configuration/Inputs/method_config.hedging_policy.json b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.hedging_policy.json new file mode 100644 index 000000000..8dd9ed8e9 --- /dev/null +++ b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.hedging_policy.json @@ -0,0 +1,5 @@ +{ + "maxAttempts": 3, + "hedgingDelay": "1s", + "nonFatalStatusCodes": ["ABORTED"] +} diff --git a/Tests/GRPCCoreTests/Configuration/Inputs/method_config.json b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.json new file mode 100644 index 000000000..71c9c2cab --- /dev/null +++ b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.json @@ -0,0 +1,12 @@ +{ + "name": [ + { + "service": "echo.Echo", + "method": "Get" + } + ], + "waitForReady": true, + "timeout": "1s", + "maxRequestMessageBytes": 1024, + "maxResponseMessageBytes": 2048 +} diff --git a/Tests/GRPCCoreTests/Configuration/Inputs/method_config.name.empty.json b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.name.empty.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.name.empty.json @@ -0,0 +1 @@ +{} diff --git a/Tests/GRPCCoreTests/Configuration/Inputs/method_config.name.full.json b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.name.full.json new file mode 100644 index 000000000..ed21cc360 --- /dev/null +++ b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.name.full.json @@ -0,0 +1,4 @@ +{ + "service": "foo.bar", + "method": "baz" +} diff --git a/Tests/GRPCCoreTests/Configuration/Inputs/method_config.name.service_only.json b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.name.service_only.json new file mode 100644 index 000000000..beb50d5e3 --- /dev/null +++ b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.name.service_only.json @@ -0,0 +1,3 @@ +{ + "service": "foo.bar" +} diff --git a/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.backoff_multiplier.json b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.backoff_multiplier.json new file mode 100644 index 000000000..a43451a94 --- /dev/null +++ b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.backoff_multiplier.json @@ -0,0 +1,7 @@ +{ + "maxAttempts": 3, + "initialBackoff": "1s", + "maxBackoff": "3s", + "backoffMultiplier": -1.6, + "retryableStatusCodes": ["ABORTED", "UNAVAILABLE"] +} diff --git a/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.initial_backoff.json b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.initial_backoff.json new file mode 100644 index 000000000..bb9691bbb --- /dev/null +++ b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.initial_backoff.json @@ -0,0 +1,7 @@ +{ + "maxAttempts": 3, + "initialBackoff": "0s", + "maxBackoff": "3s", + "backoffMultiplier": 1.6, + "retryableStatusCodes": ["ABORTED", "UNAVAILABLE"] +} diff --git a/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.max_attempts.json b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.max_attempts.json new file mode 100644 index 000000000..454ba94e9 --- /dev/null +++ b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.max_attempts.json @@ -0,0 +1,7 @@ +{ + "maxAttempts": 1, + "initialBackoff": "1s", + "maxBackoff": "3s", + "backoffMultiplier": 1.6, + "retryableStatusCodes": ["ABORTED", "UNAVAILABLE"] +} diff --git a/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.max_backoff.json b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.max_backoff.json new file mode 100644 index 000000000..6059280be --- /dev/null +++ b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.max_backoff.json @@ -0,0 +1,7 @@ +{ + "maxAttempts": 3, + "initialBackoff": "1s", + "maxBackoff": "0s", + "backoffMultiplier": 1.6, + "retryableStatusCodes": ["ABORTED", "UNAVAILABLE"] +} diff --git a/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.retryable_status_codes.json b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.retryable_status_codes.json new file mode 100644 index 000000000..d437878f0 --- /dev/null +++ b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.retryable_status_codes.json @@ -0,0 +1,7 @@ +{ + "maxAttempts": 3, + "initialBackoff": "1s", + "maxBackoff": "3s", + "backoffMultiplier": 1.6, + "retryableStatusCodes": [] +} diff --git a/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.json b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.json new file mode 100644 index 000000000..ef8744c2e --- /dev/null +++ b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.json @@ -0,0 +1,7 @@ +{ + "maxAttempts": 3, + "initialBackoff": "1s", + "maxBackoff": "3s", + "backoffMultiplier": 1.6, + "retryableStatusCodes": ["ABORTED", "UNAVAILABLE"] +} diff --git a/Tests/GRPCCoreTests/Configuration/Inputs/method_config.with_hedging.json b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.with_hedging.json new file mode 100644 index 000000000..1d9ecc6b7 --- /dev/null +++ b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.with_hedging.json @@ -0,0 +1,20 @@ +{ + "name": [ + { + "service": "echo.Echo", + "method": "Get" + } + ], + "waitForReady": true, + "timeout": "1s", + "maxRequestMessageBytes": 1024, + "maxResponseMessageBytes": 2048, + "hedgingPolicy": { + "maxAttempts": 3, + "hedgingDelay": "42s", + "nonFatalStatusCodes": [ + "ABORTED", + "UNIMPLEMENTED" + ] + } +} diff --git a/Tests/GRPCCoreTests/Configuration/Inputs/method_config.with_retries.json b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.with_retries.json new file mode 100644 index 000000000..41556e185 --- /dev/null +++ b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.with_retries.json @@ -0,0 +1,22 @@ +{ + "name": [ + { + "service": "echo.Echo", + "method": "Get" + } + ], + "waitForReady": true, + "timeout": "1s", + "maxRequestMessageBytes": 1024, + "maxResponseMessageBytes": 2048, + "retryPolicy": { + "maxAttempts": 3, + "initialBackoff": "1s", + "maxBackoff": "3s", + "backoffMultiplier": 1.6, + "retryableStatusCodes": [ + "ABORTED", + "UNIMPLEMENTED" + ] + } +} diff --git a/Tests/GRPCCoreTests/Configuration/MethodConfigCodingTests.swift b/Tests/GRPCCoreTests/Configuration/MethodConfigCodingTests.swift index a0d309c25..c65fe1c54 100644 --- a/Tests/GRPCCoreTests/Configuration/MethodConfigCodingTests.swift +++ b/Tests/GRPCCoreTests/Configuration/MethodConfigCodingTests.swift @@ -16,415 +16,397 @@ import Foundation import SwiftProtobuf -import XCTest +import Testing @testable import GRPCCore -@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) -internal final class MethodConfigCodingTests: XCTestCase { - private let encoder = JSONEncoder() - private let decoder = JSONDecoder() - - private func testDecodeThrowsRuntimeError(json: String, as: D.Type) throws { - XCTAssertThrowsError( - ofType: RuntimeError.self, - try self.decoder.decode(D.self, from: Data(json.utf8)) - ) { error in - XCTAssertEqual(error.code, .invalidArgument) +@Suite("MethodConfig coding tests") +struct MethodConfigCodingTests { + @Suite("Encoding") + struct Encoding { + private func encodeToJSON(_ value: some Encodable) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let encoded = try encoder.encode(value) + let json = String(decoding: encoded, as: UTF8.self) + return json } - } - - func testDecodeMethodConfigName() throws { - let inputs: [(String, MethodConfig.Name)] = [ - (#"{"service": "foo.bar", "method": "baz"}"#, .init(service: "foo.bar", method: "baz")), - (#"{"service": "foo.bar"}"#, .init(service: "foo.bar", method: "")), - (#"{}"#, .init(service: "", method: "")), - ] - for (json, expected) in inputs { - let decoded = try self.decoder.decode(MethodConfig.Name.self, from: Data(json.utf8)) - XCTAssertEqual(decoded, expected) + @Test( + "Name", + arguments: [ + ( + MethodConfig.Name(service: "foo.bar", method: "baz"), + #"{"method":"baz","service":"foo.bar"}"# + ), + (MethodConfig.Name(service: "foo.bar", method: ""), #"{"method":"","service":"foo.bar"}"#), + (MethodConfig.Name(service: "", method: ""), #"{"method":"","service":""}"#), + ] as [(MethodConfig.Name, String)] + ) + func methodConfigName(name: MethodConfig.Name, expected: String) throws { + let json = try self.encodeToJSON(name) + #expect(json == expected) } - } - func testEncodeDecodeMethodConfigName() throws { - let inputs: [MethodConfig.Name] = [ - MethodConfig.Name(service: "foo.bar", method: "baz"), - MethodConfig.Name(service: "foo.bar", method: ""), - MethodConfig.Name(service: "", method: ""), - ] - - // We can't do encode-only tests as the output is non-deterministic (the ordering of - // service/method in the JSON object) - for name in inputs { - let encoded = try self.encoder.encode(name) - let decoded = try self.decoder.decode(MethodConfig.Name.self, from: encoded) - XCTAssertEqual(decoded, name) + @Test( + "GoogleProtobufDuration", + arguments: [ + (.seconds(1), #""1.0s""#), + (.zero, #""0.0s""#), + (.milliseconds(100_123), #""100.123s""#), + ] as [(Duration, String)] + ) + func protobufDuration(duration: Duration, expected: String) throws { + let json = try self.encodeToJSON(GoogleProtobufDuration(duration: duration)) + #expect(json == expected) } - } - func testDecodeProtobufDuration() throws { - let inputs: [(String, Duration)] = [ - ("1.0s", .seconds(1)), - ("1s", .seconds(1)), - ("1.000000s", .seconds(1)), - ("0s", .zero), - ("100.123s", .milliseconds(100_123)), - ] + @Test( + "GoogleRPCCode", + arguments: [ + (.ok, #""OK""#), + (.cancelled, #""CANCELLED""#), + (.unknown, #""UNKNOWN""#), + (.invalidArgument, #""INVALID_ARGUMENT""#), + (.deadlineExceeded, #""DEADLINE_EXCEEDED""#), + (.notFound, #""NOT_FOUND""#), + (.alreadyExists, #""ALREADY_EXISTS""#), + (.permissionDenied, #""PERMISSION_DENIED""#), + (.resourceExhausted, #""RESOURCE_EXHAUSTED""#), + (.failedPrecondition, #""FAILED_PRECONDITION""#), + (.aborted, #""ABORTED""#), + (.outOfRange, #""OUT_OF_RANGE""#), + (.unimplemented, #""UNIMPLEMENTED""#), + (.internalError, #""INTERNAL""#), + (.unavailable, #""UNAVAILABLE""#), + (.dataLoss, #""DATA_LOSS""#), + (.unauthenticated, #""UNAUTHENTICATED""#), + ] as [(Status.Code, String)] + ) + func rpcCode(code: Status.Code, expected: String) throws { + let json = try self.encodeToJSON(GoogleRPCCode(code: code)) + #expect(json == expected) + } - for (input, expected) in inputs { - let json = "\"\(input)\"" - let protoDuration = try self.decoder.decode( - GoogleProtobufDuration.self, - from: Data(json.utf8) + @Test("RetryPolicy") + func retryPolicy() throws { + let policy = RetryPolicy( + maximumAttempts: 3, + initialBackoff: .seconds(1), + maximumBackoff: .seconds(3), + backoffMultiplier: 1.6, + retryableStatusCodes: [.aborted] ) - let components = protoDuration.duration.components - - // Conversion is lossy as we go from floating point seconds to integer seconds and - // attoseconds. Allow for millisecond precision. - let divisor: Int64 = 1_000_000_000_000_000 - XCTAssertEqual(components.seconds, expected.components.seconds) - XCTAssertEqual(components.attoseconds / divisor, expected.components.attoseconds / divisor) + let json = try self.encodeToJSON(policy) + let expected = + #"{"backoffMultiplier":1.6,"initialBackoff":"1.0s","maxAttempts":3,"maxBackoff":"3.0s","retryableStatusCodes":["ABORTED"]}"# + #expect(json == expected) } - } - func testEncodeProtobufDuration() throws { - let inputs: [(Duration, String)] = [ - (.seconds(1), "\"1.0s\""), - (.zero, "\"0.0s\""), - (.milliseconds(100_123), "\"100.123s\""), - ] + @Test("HedgingPolicy") + func hedgingPolicy() throws { + let policy = HedgingPolicy( + maximumAttempts: 3, + hedgingDelay: .seconds(1), + nonFatalStatusCodes: [.aborted] + ) - for (input, expected) in inputs { - let duration = GoogleProtobufDuration(duration: input) - let encoded = try self.encoder.encode(duration) - let json = String(decoding: encoded, as: UTF8.self) - XCTAssertEqual(json, expected) + let json = try self.encodeToJSON(policy) + let expected = #"{"hedgingDelay":"1.0s","maxAttempts":3,"nonFatalStatusCodes":["ABORTED"]}"# + #expect(json == expected) } } - func testDecodeInvalidProtobufDuration() throws { - for timestamp in ["1", "1ss", "1S", "1.0S"] { - let json = "\"\(timestamp)\"" - try self.testDecodeThrowsRuntimeError(json: json, as: GoogleProtobufDuration.self) - } - } + @Suite("Decoding") + struct Decoding { + private func decodeFromFile( + _ name: String, + as: Decoded.Type + ) throws -> Decoded { + let input = Bundle.module.url( + forResource: name, + withExtension: "json", + subdirectory: "Inputs" + ) - func testDecodeRPCCodeFromCaseName() throws { - let inputs: [(String, Status.Code)] = [ - ("OK", .ok), - ("CANCELLED", .cancelled), - ("UNKNOWN", .unknown), - ("INVALID_ARGUMENT", .invalidArgument), - ("DEADLINE_EXCEEDED", .deadlineExceeded), - ("NOT_FOUND", .notFound), - ("ALREADY_EXISTS", .alreadyExists), - ("PERMISSION_DENIED", .permissionDenied), - ("RESOURCE_EXHAUSTED", .resourceExhausted), - ("FAILED_PRECONDITION", .failedPrecondition), - ("ABORTED", .aborted), - ("OUT_OF_RANGE", .outOfRange), - ("UNIMPLEMENTED", .unimplemented), - ("INTERNAL", .internalError), - ("UNAVAILABLE", .unavailable), - ("DATA_LOSS", .dataLoss), - ("UNAUTHENTICATED", .unauthenticated), - ] + let url = try #require(input) + let data = try Data(contentsOf: url) - for (name, expected) in inputs { - let json = "\"\(name)\"" - let code = try self.decoder.decode(GoogleRPCCode.self, from: Data(json.utf8)) - XCTAssertEqual(code.code, expected) + let decoder = JSONDecoder() + return try decoder.decode(Decoded.self, from: data) } - } - func testDecodeRPCCodeFromRawValue() throws { - let inputs: [(Int, Status.Code)] = [ - (0, .ok), - (1, .cancelled), - (2, .unknown), - (3, .invalidArgument), - (4, .deadlineExceeded), - (5, .notFound), - (6, .alreadyExists), - (7, .permissionDenied), - (8, .resourceExhausted), - (9, .failedPrecondition), - (10, .aborted), - (11, .outOfRange), - (12, .unimplemented), - (13, .internalError), - (14, .unavailable), - (15, .dataLoss), - (16, .unauthenticated), - ] - - for (rawValue, expected) in inputs { - let json = "\(rawValue)" - let code = try self.decoder.decode(GoogleRPCCode.self, from: Data(json.utf8)) - XCTAssertEqual(code.code, expected) + private func decodeFromJSONString( + _ json: String, + as: Decoded.Type + ) throws -> Decoded { + let data = Data(json.utf8) + let decoder = JSONDecoder() + return try decoder.decode(Decoded.self, from: data) } - } - func testEncodeDecodeRPCCode() throws { - let codes: [Status.Code] = [ - .ok, - .cancelled, - .unknown, - .invalidArgument, - .deadlineExceeded, - .notFound, - .alreadyExists, - .permissionDenied, - .resourceExhausted, - .failedPrecondition, - .aborted, - .outOfRange, - .unimplemented, - .internalError, - .unavailable, - .dataLoss, - .unauthenticated, + private static let codeNames: [String] = [ + "OK", + "CANCELLED", + "UNKNOWN", + "INVALID_ARGUMENT", + "DEADLINE_EXCEEDED", + "NOT_FOUND", + "ALREADY_EXISTS", + "PERMISSION_DENIED", + "RESOURCE_EXHAUSTED", + "FAILED_PRECONDITION", + "ABORTED", + "OUT_OF_RANGE", + "UNIMPLEMENTED", + "INTERNAL", + "UNAVAILABLE", + "DATA_LOSS", + "UNAUTHENTICATED", ] - for code in codes { - let encoded = try self.encoder.encode(GoogleRPCCode(code: code)) - let decoded = try self.decoder.decode(GoogleRPCCode.self, from: encoded) - XCTAssertEqual(decoded.code, code) + @Test( + "Name", + arguments: [ + ("method_config.name.full", MethodConfig.Name(service: "foo.bar", method: "baz")), + ("method_config.name.service_only", MethodConfig.Name(service: "foo.bar", method: "")), + ("method_config.name.empty", MethodConfig.Name(service: "", method: "")), + ] as [(String, MethodConfig.Name)] + ) + func name(_ fileName: String, expected: MethodConfig.Name) throws { + let decoded = try self.decodeFromFile(fileName, as: MethodConfig.Name.self) + #expect(decoded == expected) } - } - func testDecodeRetryPolicy() throws { - let json = """ - { - "maxAttempts": 3, - "initialBackoff": "1s", - "maxBackoff": "3s", - "backoffMultiplier": 1.6, - "retryableStatusCodes": ["ABORTED", "UNAVAILABLE"] - } - """ - - let expected = RetryPolicy( - maximumAttempts: 3, - initialBackoff: .seconds(1), - maximumBackoff: .seconds(3), - backoffMultiplier: 1.6, - retryableStatusCodes: [.aborted, .unavailable] + @Test( + "GoogleProtobufDuration", + arguments: [ + ("1.0s", .seconds(1)), + ("1s", .seconds(1)), + ("1.000000s", .seconds(1)), + ("0s", .zero), + ("100.123s", .milliseconds(100_123)), + ] as [(String, Duration)] ) + func googleProtobufDuration(duration: String, expectedDuration: Duration) throws { + let json = "\"\(duration)\"" + let decoded = try self.decodeFromJSONString(json, as: GoogleProtobufDuration.self) - let decoded = try self.decoder.decode(RetryPolicy.self, from: Data(json.utf8)) - XCTAssertEqual(decoded, expected) - } + // Conversion is lossy as we go from floating point seconds to integer seconds and + // attoseconds. Allow for millisecond precision. + let divisor: Int64 = 1_000_000_000_000_000 - func testEncodeDecodeRetryPolicy() throws { - let policy = RetryPolicy( - maximumAttempts: 3, - initialBackoff: .seconds(1), - maximumBackoff: .seconds(3), - backoffMultiplier: 1.6, - retryableStatusCodes: [.aborted] - ) + let duration = decoded.duration.components + let expected = expectedDuration.components - let encoded = try self.encoder.encode(policy) - let decoded = try self.decoder.decode(RetryPolicy.self, from: encoded) - XCTAssertEqual(decoded, policy) - } + #expect(duration.seconds == expected.seconds) + #expect(duration.attoseconds / divisor == expected.attoseconds / divisor) + } - func testDecodeRetryPolicyWithInvalidRetryMaxAttempts() throws { - let cases = ["-1", "0", "1"] - for maxAttempts in cases { - let json = """ - { - "maxAttempts": \(maxAttempts), - "initialBackoff": "1s", - "maxBackoff": "3s", - "backoffMultiplier": 1.6, - "retryableStatusCodes": ["ABORTED"] - } - """ - - try self.testDecodeThrowsRuntimeError(json: json, as: RetryPolicy.self) + @Test("Invalid GoogleProtobufDuration", arguments: ["1", "1ss", "1S", "1.0S"]) + func googleProtobufDuration(invalidDuration: String) throws { + let json = "\"\(invalidDuration)\"" + #expect { + try self.decodeFromJSONString(json, as: GoogleProtobufDuration.self) + } throws: { error in + guard let error = error as? RuntimeError else { return false } + return error.code == .invalidArgument + } } - } - func testDecodeRetryPolicyWithInvalidInitialBackoff() throws { - let cases = ["0s", "-1s"] - for backoff in cases { - let json = """ - { - "maxAttempts": 3, - "initialBackoff": "\(backoff)", - "maxBackoff": "3s", - "backoffMultiplier": 1.6, - "retryableStatusCodes": ["ABORTED"] - } - """ - try self.testDecodeThrowsRuntimeError(json: json, as: RetryPolicy.self) + @Test("GoogleRPCCode from case name", arguments: zip(Self.codeNames, Status.Code.all)) + func rpcCode(name: String, expected: Status.Code) throws { + let json = "\"\(name)\"" + let decoded = try self.decodeFromJSONString(json, as: GoogleRPCCode.self) + #expect(decoded.code == expected) } - } - func testDecodeRetryPolicyWithInvalidMaxBackoff() throws { - let cases = ["0s", "-1s"] - for backoff in cases { - let json = """ - { - "maxAttempts": 3, - "initialBackoff": "1s", - "maxBackoff": "\(backoff)", - "backoffMultiplier": 1.6, - "retryableStatusCodes": ["ABORTED"] - } - """ - try self.testDecodeThrowsRuntimeError(json: json, as: RetryPolicy.self) + @Test("GoogleRPCCode from rawValue", arguments: zip(0 ... 16, Status.Code.all)) + func rpcCode(rawValue: Int, expected: Status.Code) throws { + let json = "\(rawValue)" + let decoded = try self.decodeFromJSONString(json, as: GoogleRPCCode.self) + #expect(decoded.code == expected) } - } - func testDecodeRetryPolicyWithInvalidBackoffMultiplier() throws { - let cases = ["0", "-1.5"] - for multiplier in cases { - let json = """ - { - "maxAttempts": 3, - "initialBackoff": "1s", - "maxBackoff": "3s", - "backoffMultiplier": \(multiplier), - "retryableStatusCodes": ["ABORTED"] - } - """ - try self.testDecodeThrowsRuntimeError(json: json, as: RetryPolicy.self) + @Test("RetryPolicy") + func retryPolicy() throws { + let decoded = try self.decodeFromFile("method_config.retry_policy", as: RetryPolicy.self) + let expected = RetryPolicy( + maximumAttempts: 3, + initialBackoff: .seconds(1), + maximumBackoff: .seconds(3), + backoffMultiplier: 1.6, + retryableStatusCodes: [.aborted, .unavailable] + ) + #expect(decoded == expected) } - } - func testDecodeRetryPolicyWithEmptyRetryableStatusCodes() throws { - let json = """ - { - "maxAttempts": 3, - "initialBackoff": "1s", - "maxBackoff": "3s", - "backoffMultiplier": 1, - "retryableStatusCodes": [] + @Test( + "RetryPolicy with invalid values", + arguments: [ + "method_config.retry_policy.invalid.backoff_multiplier", + "method_config.retry_policy.invalid.initial_backoff", + "method_config.retry_policy.invalid.max_backoff", + "method_config.retry_policy.invalid.max_attempts", + "method_config.retry_policy.invalid.retryable_status_codes", + ] + ) + func invalidRetryPolicy(fileName: String) throws { + #expect(throws: RuntimeError.self) { + try self.decodeFromFile(fileName, as: RetryPolicy.self) } - """ - try self.testDecodeThrowsRuntimeError(json: json, as: RetryPolicy.self) - } + } - func testDecodeHedgingPolicy() throws { - let json = """ - { - "maxAttempts": 3, - "hedgingDelay": "1s", - "nonFatalStatusCodes": ["ABORTED"] - } - """ + @Test("HedgingPolicy") + func hedgingPolicy() throws { + let decoded = try self.decodeFromFile("method_config.hedging_policy", as: HedgingPolicy.self) + let expected = HedgingPolicy( + maximumAttempts: 3, + hedgingDelay: .seconds(1), + nonFatalStatusCodes: [.aborted] + ) + #expect(decoded == expected) + } - let expected = HedgingPolicy( - maximumAttempts: 3, - hedgingDelay: .seconds(1), - nonFatalStatusCodes: [.aborted] + @Test( + "HedgingPolicy with invalid values", + arguments: [ + "method_config.hedging_policy.invalid.max_attempts" + ] ) + func invalidHedgingPolicy(fileName: String) throws { + #expect(throws: RuntimeError.self) { + try self.decodeFromFile(fileName, as: HedgingPolicy.self) + } + } - let decoded = try self.decoder.decode(HedgingPolicy.self, from: Data(json.utf8)) - XCTAssertEqual(decoded, expected) - } - - func testEncodeDecodeHedgingPolicy() throws { - let policy = HedgingPolicy( - maximumAttempts: 3, - hedgingDelay: .seconds(1), - nonFatalStatusCodes: [.aborted] - ) + @Test("MethodConfig") + func methodConfig() throws { + let expected = MethodConfig( + names: [ + MethodConfig.Name( + service: "echo.Echo", + method: "Get" + ) + ], + waitForReady: true, + timeout: .seconds(1), + maxRequestMessageBytes: 1024, + maxResponseMessageBytes: 2048 + ) - let encoded = try self.encoder.encode(policy) - let decoded = try self.decoder.decode(HedgingPolicy.self, from: encoded) - XCTAssertEqual(decoded, policy) - } + let decoded = try self.decodeFromFile("method_config", as: MethodConfig.self) + #expect(decoded == expected) + } - func testMethodConfigDecodeFromJSON() throws { - let config = Grpc_ServiceConfig_MethodConfig.with { - $0.name = [ - .with { - $0.service = "echo.Echo" - $0.method = "Get" - } - ] + @Test("MethodConfig with hedging") + func methodConfigWithHedging() throws { + let expected = MethodConfig( + names: [ + MethodConfig.Name( + service: "echo.Echo", + method: "Get" + ) + ], + waitForReady: true, + timeout: .seconds(1), + maxRequestMessageBytes: 1024, + maxResponseMessageBytes: 2048, + executionPolicy: .hedge( + HedgingPolicy( + maximumAttempts: 3, + hedgingDelay: .seconds(42), + nonFatalStatusCodes: [.aborted, .unimplemented] + ) + ) + ) - $0.waitForReady = true + let decoded = try self.decodeFromFile("method_config.with_hedging", as: MethodConfig.self) + #expect(decoded == expected) + } - $0.timeout = .with { - $0.seconds = 1 - $0.nanos = 0 - } + @Test("MethodConfig with retries") + func methodConfigWithRetries() throws { + let expected = MethodConfig( + names: [ + MethodConfig.Name( + service: "echo.Echo", + method: "Get" + ) + ], + waitForReady: true, + timeout: .seconds(1), + maxRequestMessageBytes: 1024, + maxResponseMessageBytes: 2048, + executionPolicy: .retry( + RetryPolicy( + maximumAttempts: 3, + initialBackoff: .seconds(1), + maximumBackoff: .seconds(3), + backoffMultiplier: 1.6, + retryableStatusCodes: [.aborted, .unimplemented] + ) + ) + ) - $0.maxRequestMessageBytes = 1024 - $0.maxResponseMessageBytes = 2048 + let decoded = try self.decodeFromFile("method_config.with_retries", as: MethodConfig.self) + #expect(decoded == expected) } + } - // Test the 'regular' config. - do { - let jsonConfig = try config.jsonUTF8Data() - let decoded = try self.decoder.decode(MethodConfig.self, from: jsonConfig) - XCTAssertEqual(decoded.names, [MethodConfig.Name(service: "echo.Echo", method: "Get")]) - XCTAssertEqual(decoded.waitForReady, true) - XCTAssertEqual(decoded.timeout, Duration(secondsComponent: 1, attosecondsComponent: 0)) - XCTAssertEqual(decoded.maxRequestMessageBytes, 1024) - XCTAssertEqual(decoded.maxResponseMessageBytes, 2048) - XCTAssertNil(decoded.executionPolicy) - } + @Suite("Round-trip tests") + struct RoundTrip { + private func decodeFromFile( + _ name: String, + as: Decoded.Type + ) throws -> Decoded { + let input = Bundle.module.url( + forResource: name, + withExtension: "json", + subdirectory: "Inputs" + ) - // Test the hedging policy. - do { - var config = config - config.hedgingPolicy = .with { - $0.maxAttempts = 3 - $0.hedgingDelay = .with { $0.seconds = 42 } - $0.nonFatalStatusCodes = [ - .aborted, - .unimplemented, - ] - } + let url = try #require(input) + let data = try Data(contentsOf: url) - let jsonConfig = try config.jsonUTF8Data() - let decoded = try self.decoder.decode(MethodConfig.self, from: jsonConfig) + let decoder = JSONDecoder() + return try decoder.decode(Decoded.self, from: data) + } - switch decoded.executionPolicy?.wrapped { - case let .some(.hedge(policy)): - XCTAssertEqual(policy.maximumAttempts, 3) - XCTAssertEqual(policy.hedgingDelay, .seconds(42)) - XCTAssertEqual(policy.nonFatalStatusCodes, [.aborted, .unimplemented]) - default: - XCTFail("Expected hedging policy") - } + private func decodeFromJSONString( + _ json: String, + as: Decoded.Type + ) throws -> Decoded { + let data = Data(json.utf8) + let decoder = JSONDecoder() + return try decoder.decode(Decoded.self, from: data) } - // Test the retry policy. - do { - var config = config - config.retryPolicy = .with { - $0.maxAttempts = 3 - $0.initialBackoff = .with { $0.seconds = 1 } - $0.maxBackoff = .with { $0.seconds = 3 } - $0.backoffMultiplier = 1.6 - $0.retryableStatusCodes = [ - .aborted, - .unimplemented, - ] - } + private func encodeToJSON(_ value: some Encodable) throws -> String { + let encoder = JSONEncoder() + let encoded = try encoder.encode(value) + let json = String(decoding: encoded, as: UTF8.self) + return json + } - let jsonConfig = try config.jsonUTF8Data() - let decoded = try self.decoder.decode(MethodConfig.self, from: jsonConfig) - - switch decoded.executionPolicy?.wrapped { - case let .some(.retry(policy)): - XCTAssertEqual(policy.maximumAttempts, 3) - XCTAssertEqual(policy.initialBackoff, .seconds(1)) - XCTAssertEqual(policy.maximumBackoff, .seconds(3)) - XCTAssertEqual(policy.backoffMultiplier, 1.6) - XCTAssertEqual(policy.retryableStatusCodes, [.aborted, .unimplemented]) - default: - XCTFail("Expected hedging policy") - } + private func roundTrip(type: T.Type = T.self, fileName: String) throws { + let decoded = try self.decodeFromFile(fileName, as: T.self) + let encoded = try self.encodeToJSON(decoded) + let decodedAgain = try self.decodeFromJSONString(encoded, as: T.self) + #expect(decoded == decodedAgain) + } + + @Test( + "MethodConfig", + arguments: [ + "method_config", + "method_config.with_retries", + "method_config.with_hedging", + ] + ) + func roundTripCodingAndDecoding(fileName: String) throws { + try self.roundTrip(type: MethodConfig.self, fileName: fileName) } } } diff --git a/Tests/GRPCCoreTests/Configuration/MethodConfigTests.swift b/Tests/GRPCCoreTests/Configuration/MethodConfigTests.swift index 0b3406efb..eca712cf5 100644 --- a/Tests/GRPCCoreTests/Configuration/MethodConfigTests.swift +++ b/Tests/GRPCCoreTests/Configuration/MethodConfigTests.swift @@ -13,12 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import GRPCCore -import XCTest +import Testing -@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) -final class MethodConfigTests: XCTestCase { - func testRetryPolicyClampsMaxAttempts() { +struct MethodConfigTests { + @Test("RetryPolicy clamps max attempts") + func retryPolicyClampsMaxAttempts() { var policy = RetryPolicy( maximumAttempts: 10, initialBackoff: .seconds(1), @@ -28,13 +29,14 @@ final class MethodConfigTests: XCTestCase { ) // Should be clamped on init - XCTAssertEqual(policy.maximumAttempts, 5) + #expect(policy.maximumAttempts == 5) // and when modifying policy.maximumAttempts = 10 - XCTAssertEqual(policy.maximumAttempts, 5) + #expect(policy.maximumAttempts == 5) } - func testHedgingPolicyClampsMaxAttempts() { + @Test("HedgingPolicy clamps max attempts") + func hedgingPolicyClampsMaxAttempts() { var policy = HedgingPolicy( maximumAttempts: 10, hedgingDelay: .seconds(1), @@ -42,9 +44,9 @@ final class MethodConfigTests: XCTestCase { ) // Should be clamped on init - XCTAssertEqual(policy.maximumAttempts, 5) + #expect(policy.maximumAttempts == 5) // and when modifying policy.maximumAttempts = 10 - XCTAssertEqual(policy.maximumAttempts, 5) + #expect(policy.maximumAttempts == 5) } } diff --git a/Tests/GRPCCoreTests/MetadataTests.swift b/Tests/GRPCCoreTests/MetadataTests.swift index 40fb2a47b..f0b29df04 100644 --- a/Tests/GRPCCoreTests/MetadataTests.swift +++ b/Tests/GRPCCoreTests/MetadataTests.swift @@ -13,11 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import GRPCCore -import XCTest +import Testing -final class MetadataTests: XCTestCase { - func testInitFromSequence() { +@Suite("Metadata") +struct MetadataTests { + @Test("Initialize from Sequence") + func initFromSequence() { let elements: [Metadata.Element] = [ (key: "key1", value: "value1"), (key: "key2", value: "value2"), @@ -26,218 +29,227 @@ final class MetadataTests: XCTestCase { let metadata = Metadata(elements) let expected: Metadata = ["key1": "value1", "key2": "value2", "key3": "value3"] - - XCTAssertEqual(metadata, expected) + #expect(metadata == expected) } - func testAddStringValue() { + @Test("Add string Value") + func addStringValue() { var metadata = Metadata() - XCTAssertTrue(metadata.isEmpty) + #expect(metadata.isEmpty) metadata.addString("testValue", forKey: "testString") - XCTAssertEqual(metadata.count, 1) + #expect(metadata.count == 1) let sequence = metadata[stringValues: "testString"] var iterator = sequence.makeIterator() - XCTAssertEqual(iterator.next(), "testValue") - XCTAssertNil(iterator.next()) + #expect(iterator.next() == "testValue") + #expect(iterator.next() == nil) } - func testAddBinaryValue() { + @Test("Add binary value") + func addBinaryValue() { var metadata = Metadata() - XCTAssertTrue(metadata.isEmpty) + #expect(metadata.isEmpty) metadata.addBinary(Array("base64encodedString".utf8), forKey: "testBinary-bin") - XCTAssertEqual(metadata.count, 1) + #expect(metadata.count == 1) let sequence = metadata[binaryValues: "testBinary-bin"] var iterator = sequence.makeIterator() - XCTAssertEqual(iterator.next(), Array("base64encodedString".utf8)) - XCTAssertNil(iterator.next()) + #expect(iterator.next() == Array("base64encodedString".utf8)) + #expect(iterator.next() == nil) } - func testCreateFromDictionaryLiteral() { + @Test("Initialize from dictionary literal") + func initFromDictionaryLiteral() { let metadata: Metadata = [ "testKey": "stringValue", "testKey-bin": .binary(Array("base64encodedString".utf8)), ] - XCTAssertEqual(metadata.count, 2) + #expect(metadata.count == 2) let stringSequence = metadata[stringValues: "testKey"] var stringIterator = stringSequence.makeIterator() - XCTAssertEqual(stringIterator.next(), "stringValue") - XCTAssertNil(stringIterator.next()) + #expect(stringIterator.next() == "stringValue") + #expect(stringIterator.next() == nil) let binarySequence = metadata[binaryValues: "testKey-bin"] var binaryIterator = binarySequence.makeIterator() - XCTAssertEqual(binaryIterator.next(), Array("base64encodedString".utf8)) - XCTAssertNil(binaryIterator.next()) + #expect(binaryIterator.next() == Array("base64encodedString".utf8)) + #expect(binaryIterator.next() == nil) } - func testReplaceOrAddValue() { - var metadata: Metadata = [ - "testKey": "value1", - "testKey": "value2", - ] - XCTAssertEqual(metadata.count, 2) + @Suite("Replace or add value") + struct ReplaceOrAdd { + @Suite("String") + struct StringValues { + var metadata: Metadata = [ + "key1": "value1", + "key1": "value2", + ] + + @Test("Add different key") + mutating func addNewKey() async throws { + self.metadata.replaceOrAddString("value3", forKey: "key2") + #expect(Array(self.metadata[stringValues: "key1"]) == ["value1", "value2"]) + #expect(Array(self.metadata[stringValues: "key2"]) == ["value3"]) + #expect(self.metadata.count == 3) + } + + @Test("Replace values for existing key") + mutating func replaceValues() async throws { + self.metadata.replaceOrAddString("value3", forKey: "key1") + #expect(Array(self.metadata[stringValues: "key1"]) == ["value3"]) + #expect(self.metadata.count == 1) + } + } - var sequence = metadata[stringValues: "testKey"] - var iterator = sequence.makeIterator() - XCTAssertEqual(iterator.next(), "value1") - XCTAssertEqual(iterator.next(), "value2") - XCTAssertNil(iterator.next()) - - metadata.replaceOrAddString("anotherValue", forKey: "testKey2") - XCTAssertEqual(metadata.count, 3) - sequence = metadata[stringValues: "testKey"] - iterator = sequence.makeIterator() - XCTAssertEqual(iterator.next(), "value1") - XCTAssertEqual(iterator.next(), "value2") - XCTAssertNil(iterator.next()) - sequence = metadata[stringValues: "testKey2"] - iterator = sequence.makeIterator() - XCTAssertEqual(iterator.next(), "anotherValue") - XCTAssertNil(iterator.next()) - - metadata.replaceOrAddString("newValue", forKey: "testKey") - XCTAssertEqual(metadata.count, 2) - sequence = metadata[stringValues: "testKey"] - iterator = sequence.makeIterator() - XCTAssertEqual(iterator.next(), "newValue") - XCTAssertNil(iterator.next()) - sequence = metadata[stringValues: "testKey2"] - iterator = sequence.makeIterator() - XCTAssertEqual(iterator.next(), "anotherValue") - XCTAssertNil(iterator.next()) + @Suite("Binary") + struct BinaryValues { + var metadata: Metadata = [ + "key1-bin": [0], + "key1-bin": [1], + ] + + @Test("Add different key") + mutating func addNewKey() async throws { + self.metadata.replaceOrAddBinary([2], forKey: "key2-bin") + #expect(Array(self.metadata[binaryValues: "key1-bin"]) == [[0], [1]]) + #expect(Array(self.metadata[binaryValues: "key2-bin"]) == [[2]]) + #expect(self.metadata.count == 3) + } + + @Test("Replace values for existing key") + mutating func replaceValues() async throws { + self.metadata.replaceOrAddBinary([2], forKey: "key1-bin") + #expect(Array(self.metadata[binaryValues: "key1-bin"]) == [[2]]) + #expect(self.metadata.count == 1) + } + } } - func testReserveCapacity() { + @Test("Reserve more capacity increases capacity") + func reserveMoreCapacity() { var metadata = Metadata() - XCTAssertEqual(metadata.capacity, 0) + #expect(metadata.capacity == 0) + + metadata.reserveCapacity(10) + #expect(metadata.capacity == 10) + } + @Test("Reserve less capacity doesn't reduce capacity") + func reserveCapacity() { + var metadata = Metadata() + #expect(metadata.capacity == 0) metadata.reserveCapacity(10) - XCTAssertEqual(metadata.capacity, 10) + #expect(metadata.capacity == 10) + metadata.reserveCapacity(0) + #expect(metadata.capacity == 10) } - func testValuesIteration() { + @Test("Iterate over all values for a key") + func iterateOverValuesForKey() { let metadata: Metadata = [ - "testKey-bin": "string1", - "testKey-bin": .binary(.init("data1".utf8)), - "testKey-bin": "string2", - "testKey-bin": .binary(.init("data2".utf8)), - "testKey-bin": "string3", - "testKey-bin": .binary(.init("data3".utf8)), + "key-bin": "1", + "key-bin": [1], + "key-bin": "2", + "key-bin": [2], + "key-bin": "3", + "key-bin": [3], ] - XCTAssertEqual(metadata.count, 6) - let sequence = metadata["testKey-bin"] - var iterator = sequence.makeIterator() - XCTAssertEqual(iterator.next(), .string("string1")) - XCTAssertEqual(iterator.next(), .binary(.init("data1".utf8))) - XCTAssertEqual(iterator.next(), .string("string2")) - XCTAssertEqual(iterator.next(), .binary(.init("data2".utf8))) - XCTAssertEqual(iterator.next(), .string("string3")) - XCTAssertEqual(iterator.next(), .binary(.init("data3".utf8))) - XCTAssertNil(iterator.next()) + #expect(Array(metadata["key-bin"]) == ["1", [1], "2", [2], "3", [3]]) } - func testStringValuesIteration() { + @Test("Iterate over string values for a key") + func iterateOverStringsForKey() { let metadata: Metadata = [ - "testKey-bin": "string1", - "testKey-bin": .binary(.init("data1".utf8)), - "testKey-bin": "string2", - "testKey-bin": .binary(.init("data2".utf8)), - "testKey-bin": "string3", - "testKey-bin": .binary(.init("data3".utf8)), + "key-bin": "1", + "key-bin": [1], + "key-bin": "2", + "key-bin": [2], + "key-bin": "3", + "key-bin": [3], ] - XCTAssertEqual(metadata.count, 6) - let stringSequence = metadata[stringValues: "testKey-bin"] - var stringIterator = stringSequence.makeIterator() - XCTAssertEqual(stringIterator.next(), "string1") - XCTAssertEqual(stringIterator.next(), "string2") - XCTAssertEqual(stringIterator.next(), "string3") - XCTAssertNil(stringIterator.next()) + #expect(Array(metadata[stringValues: "key-bin"]) == ["1", "2", "3"]) } - func testBinaryValuesIteration_InvalidBase64EncodedStrings() { + @Test("Iterate over binary values for a key") + func iterateOverBinaryForKey() { let metadata: Metadata = [ - "testKey-bin": "invalidBase64-1", - "testKey-bin": .binary(.init("data1".utf8)), - "testKey-bin": "invalidBase64-2", - "testKey-bin": .binary(.init("data2".utf8)), - "testKey-bin": "invalidBase64-3", - "testKey-bin": .binary(.init("data3".utf8)), + "key-bin": "1", + "key-bin": [1], + "key-bin": "2", + "key-bin": [2], + "key-bin": "3", + "key-bin": [3], ] - XCTAssertEqual(metadata.count, 6) - let binarySequence = metadata[binaryValues: "testKey-bin"] - var binaryIterator = binarySequence.makeIterator() - XCTAssertEqual(binaryIterator.next(), Array("data1".utf8)) - XCTAssertEqual(binaryIterator.next(), Array("data2".utf8)) - XCTAssertEqual(binaryIterator.next(), Array("data3".utf8)) - XCTAssertNil(binaryIterator.next()) + #expect(Array(metadata[binaryValues: "key-bin"]) == [[1], [2], [3]]) } - func testBinaryValuesIteration_ValidBase64EncodedStrings() { + @Test("Iterate over base64 encoded binary values for a key") + func iterateOverBase64BinaryEncodedValuesForKey() { let metadata: Metadata = [ - "testKey-bin": "c3RyaW5nMQ==", - "testKey-bin": .binary(.init("data1".utf8)), - "testKey-bin": "c3RyaW5nMg==", - "testKey-bin": .binary(.init("data2".utf8)), - "testKey-bin": "c3RyaW5nMw==", - "testKey-bin": .binary(.init("data3".utf8)), + "key-bin": "c3RyaW5nMQ==", + "key-bin": .binary(.init("data1".utf8)), + "key-bin": "c3RyaW5nMg==", + "key-bin": .binary(.init("data2".utf8)), + "key-bin": "c3RyaW5nMw==", + "key-bin": .binary(.init("data3".utf8)), ] - XCTAssertEqual(metadata.count, 6) - let binarySequence = metadata[binaryValues: "testKey-bin"] - var binaryIterator = binarySequence.makeIterator() - XCTAssertEqual(binaryIterator.next(), Array("string1".utf8)) - XCTAssertEqual(binaryIterator.next(), Array("data1".utf8)) - XCTAssertEqual(binaryIterator.next(), Array("string2".utf8)) - XCTAssertEqual(binaryIterator.next(), Array("data2".utf8)) - XCTAssertEqual(binaryIterator.next(), Array("string3".utf8)) - XCTAssertEqual(binaryIterator.next(), Array("data3".utf8)) - XCTAssertNil(binaryIterator.next()) + let expected: [[UInt8]] = [ + Array("string1".utf8), + Array("data1".utf8), + Array("string2".utf8), + Array("data2".utf8), + Array("string3".utf8), + Array("data3".utf8), + ] + + #expect(Array(metadata[binaryValues: "key-bin"]) == expected) } - func testKeysAreCaseInsensitive() { + @Test("Subscripts are case-insensitive") + func subscriptIsCaseInsensitive() { let metadata: Metadata = [ - "testkey1": "value1", - "TESTKEY2": "value2", + "key1": "value1", + "KEY2": "value2", ] - XCTAssertEqual(metadata.count, 2) - var stringSequence = metadata[stringValues: "TESTKEY1"] - var stringIterator = stringSequence.makeIterator() - XCTAssertEqual(stringIterator.next(), "value1") - XCTAssertNil(stringIterator.next()) + #expect(Array(metadata[stringValues: "key1"]) == ["value1"]) + #expect(Array(metadata[stringValues: "KEY1"]) == ["value1"]) - stringSequence = metadata[stringValues: "testkey2"] - stringIterator = stringSequence.makeIterator() - XCTAssertEqual(stringIterator.next(), "value2") - XCTAssertNil(stringIterator.next()) + #expect(Array(metadata[stringValues: "key2"]) == ["value2"]) + #expect(Array(metadata[stringValues: "KEY2"]) == ["value2"]) } - func testRemoveAllWhere() { - let metadata: Metadata = [ - "testKey1": "value1", - "testKey2": "value2", - "testKey3": "value1", + @Suite("Remove all") + struct RemoveAll { + var metadata: Metadata = [ + "key1": "value1", + "key2": "value2", + "key3": "value1", ] - var metadata1 = metadata - metadata1.removeAll { _, value in - value == "value1" + @Test("Where value matches") + mutating func removeAllWhereValueMatches() async throws { + self.metadata.removeAll { _, value in + value == "value1" + } + + #expect(self.metadata == ["key2": "value2"]) } - XCTAssertEqual(metadata1, ["testKey2": "value2"]) + @Test("Where key matches") + mutating func removeAllWhereKeyMatches() async throws { + self.metadata.removeAll { key, _ in + key == "key2" + } - var metadata2 = metadata - metadata2.removeAll { key, _ in - key == "testKey2" + #expect(self.metadata == ["key1": "value1", "key3": "value1"]) } - - XCTAssertEqual(metadata2, ["testKey1": "value1", "testKey3": "value1"]) } } diff --git a/Tests/GRPCCoreTests/StatusTests.swift b/Tests/GRPCCoreTests/StatusTests.swift index 98d114934..936ff8e41 100644 --- a/Tests/GRPCCoreTests/StatusTests.swift +++ b/Tests/GRPCCoreTests/StatusTests.swift @@ -13,105 +13,60 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import GRPCCore -import XCTest - -final class StatusTests: XCTestCase { - private static let statusCodeRawValue: [(Status.Code, Int)] = [ - (.ok, 0), - (.cancelled, 1), - (.unknown, 2), - (.invalidArgument, 3), - (.deadlineExceeded, 4), - (.notFound, 5), - (.alreadyExists, 6), - (.permissionDenied, 7), - (.resourceExhausted, 8), - (.failedPrecondition, 9), - (.aborted, 10), - (.outOfRange, 11), - (.unimplemented, 12), - (.internalError, 13), - (.unavailable, 14), - (.dataLoss, 15), - (.unauthenticated, 16), - ] - func testCustomStringConvertible() { - XCTAssertDescription(Status(code: .ok, message: ""), #"ok: """#) - XCTAssertDescription(Status(code: .dataLoss, message: "message"), #"dataLoss: "message""#) - XCTAssertDescription(Status(code: .unknown, message: "message"), #"unknown: "message""#) - XCTAssertDescription(Status(code: .aborted, message: "message"), #"aborted: "message""#) - } +import GRPCCore +import Testing - func testStatusCodeRawValues() { - for (code, expected) in Self.statusCodeRawValue { - XCTAssertEqual(code.rawValue, expected, "\(code) had unexpected raw value") +@Suite("Status") +struct StatusTests { + @Suite("Code") + struct Code { + @Test("rawValue", arguments: zip(Status.Code.all, 0 ... 16)) + func rawValueOfStatusCodes(code: Status.Code, expected: Int) { + #expect(code.rawValue == expected) } - } - - func testStatusCodeFromErrorCode() throws { - XCTAssertEqual(Status.Code(RPCError.Code.cancelled), .cancelled) - XCTAssertEqual(Status.Code(RPCError.Code.unknown), .unknown) - XCTAssertEqual(Status.Code(RPCError.Code.invalidArgument), .invalidArgument) - XCTAssertEqual(Status.Code(RPCError.Code.deadlineExceeded), .deadlineExceeded) - XCTAssertEqual(Status.Code(RPCError.Code.notFound), .notFound) - XCTAssertEqual(Status.Code(RPCError.Code.alreadyExists), .alreadyExists) - XCTAssertEqual(Status.Code(RPCError.Code.permissionDenied), .permissionDenied) - XCTAssertEqual(Status.Code(RPCError.Code.resourceExhausted), .resourceExhausted) - XCTAssertEqual(Status.Code(RPCError.Code.failedPrecondition), .failedPrecondition) - XCTAssertEqual(Status.Code(RPCError.Code.aborted), .aborted) - XCTAssertEqual(Status.Code(RPCError.Code.outOfRange), .outOfRange) - XCTAssertEqual(Status.Code(RPCError.Code.unimplemented), .unimplemented) - XCTAssertEqual(Status.Code(RPCError.Code.internalError), .internalError) - XCTAssertEqual(Status.Code(RPCError.Code.unavailable), .unavailable) - XCTAssertEqual(Status.Code(RPCError.Code.dataLoss), .dataLoss) - XCTAssertEqual(Status.Code(RPCError.Code.unauthenticated), .unauthenticated) - } - func testStatusCodeFromValidRawValue() { - for (expected, rawValue) in Self.statusCodeRawValue { - XCTAssertEqual( - Status.Code(rawValue: rawValue), - expected, - "\(rawValue) didn't convert to expected code \(expected)" + @Test( + "Initialize from RPCError.Code", + arguments: zip( + RPCError.Code.all, + Status.Code.all.dropFirst() // Drop '.ok', there is no '.ok' error code. ) + ) + func initFromRPCErrorCode(errorCode: RPCError.Code, expected: Status.Code) { + #expect(Status.Code(errorCode) == expected) } - } - func testStatusCodeFromInvalidRawValue() { - // Internally represented as a `UInt8`; try all other values. - for rawValue in UInt8(17) ... UInt8.max { - XCTAssertNil(Status.Code(rawValue: Int(rawValue))) + @Test("Initialize from rawValue", arguments: zip(0 ... 16, Status.Code.all)) + func initFromRawValue(rawValue: Int, expected: Status.Code) { + #expect(Status.Code(rawValue: rawValue) == expected) } - // API accepts `Int` so try invalid `Int` values too. - XCTAssertNil(Status.Code(rawValue: -1)) - XCTAssertNil(Status.Code(rawValue: 1000)) - XCTAssertNil(Status.Code(rawValue: .max)) + @Test("Initialize from invalid rawValue", arguments: [-1, 17, 100, .max]) + func initFromInvalidRawValue(rawValue: Int) { + #expect(Status.Code(rawValue: rawValue) == nil) + } } - func testEquatableConformance() { - XCTAssertEqual(Status(code: .ok, message: ""), Status(code: .ok, message: "")) - XCTAssertEqual(Status(code: .ok, message: "message"), Status(code: .ok, message: "message")) - - XCTAssertNotEqual( - Status(code: .ok, message: ""), - Status(code: .ok, message: "message") - ) + @Test("CustomStringConvertible conformance") + func customStringConvertible() { + #expect("\(Status(code: .ok, message: ""))" == #"ok: """#) + #expect("\(Status(code: .dataLoss, message: "oh no"))" == #"dataLoss: "oh no""#) + } - XCTAssertNotEqual( - Status(code: .ok, message: "message"), - Status(code: .internalError, message: "message") - ) + @Test("Equatable conformance") + func equatable() { + let ok = Status(code: .ok, message: "") + let okWithMessage = Status(code: .ok, message: "message") + let internalError = Status(code: .internalError, message: "") - XCTAssertNotEqual( - Status(code: .ok, message: "message"), - Status(code: .ok, message: "different message") - ) + #expect(ok == ok) + #expect(ok != okWithMessage) + #expect(ok != internalError) } - func testFitsInExistentialContainer() { - XCTAssertLessThanOrEqual(MemoryLayout.size, 24) + @Test("Fits in existential container") + func fitsInExistentialContainer() { + #expect(MemoryLayout.size <= 24) } } diff --git a/Tests/GRPCCoreTests/TimeoutTests.swift b/Tests/GRPCCoreTests/TimeoutTests.swift index 80be5ea1f..a22bb32be 100644 --- a/Tests/GRPCCoreTests/TimeoutTests.swift +++ b/Tests/GRPCCoreTests/TimeoutTests.swift @@ -13,193 +13,72 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import XCTest -@testable import GRPCCore - -@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) -final class TimeoutTests: XCTestCase { - func testDecodeInvalidTimeout_Empty() { - let timeoutHeader = "" - XCTAssertNil(Timeout(decoding: timeoutHeader)) - } - - func testDecodeInvalidTimeout_NoAmount() { - let timeoutHeader = "H" - XCTAssertNil(Timeout(decoding: timeoutHeader)) - } - - func testDecodeInvalidTimeout_NoUnit() { - let timeoutHeader = "123" - XCTAssertNil(Timeout(decoding: timeoutHeader)) - } - - func testDecodeInvalidTimeout_TooLongAmount() { - let timeoutHeader = "100000000S" - XCTAssertNil(Timeout(decoding: timeoutHeader)) - } - - func testDecodeInvalidTimeout_InvalidUnit() { - let timeoutHeader = "123j" - XCTAssertNil(Timeout(decoding: timeoutHeader)) - } - - func testDecodeValidTimeout_Hours() throws { - let timeoutHeader = "123H" - let timeout = Timeout(decoding: timeoutHeader) - let unwrappedTimeout = try XCTUnwrap(timeout) - XCTAssertEqual(unwrappedTimeout.duration, Duration.hours(123)) - XCTAssertEqual(unwrappedTimeout.wireEncoding, timeoutHeader) - } - - func testDecodeValidTimeout_Minutes() throws { - let timeoutHeader = "123M" - let timeout = Timeout(decoding: timeoutHeader) - let unwrappedTimeout = try XCTUnwrap(timeout) - XCTAssertEqual(unwrappedTimeout.duration, Duration.minutes(123)) - XCTAssertEqual(unwrappedTimeout.wireEncoding, timeoutHeader) - } - - func testDecodeValidTimeout_Seconds() throws { - let timeoutHeader = "123S" - let timeout = Timeout(decoding: timeoutHeader) - let unwrappedTimeout = try XCTUnwrap(timeout) - XCTAssertEqual(unwrappedTimeout.duration, Duration.seconds(123)) - XCTAssertEqual(unwrappedTimeout.wireEncoding, timeoutHeader) - } - - func testDecodeValidTimeout_Milliseconds() throws { - let timeoutHeader = "123m" - let timeout = Timeout(decoding: timeoutHeader) - let unwrappedTimeout = try XCTUnwrap(timeout) - XCTAssertEqual(unwrappedTimeout.duration, Duration.milliseconds(123)) - XCTAssertEqual(unwrappedTimeout.wireEncoding, timeoutHeader) - } - - func testDecodeValidTimeout_Microseconds() throws { - let timeoutHeader = "123u" - let timeout = Timeout(decoding: timeoutHeader) - let unwrappedTimeout = try XCTUnwrap(timeout) - XCTAssertEqual(unwrappedTimeout.duration, Duration.microseconds(123)) - XCTAssertEqual(unwrappedTimeout.wireEncoding, timeoutHeader) - } - - func testDecodeValidTimeout_Nanoseconds() throws { - let timeoutHeader = "123n" - let timeout = Timeout(decoding: timeoutHeader) - let unwrappedTimeout = try XCTUnwrap(timeout) - XCTAssertEqual(unwrappedTimeout.duration, Duration.nanoseconds(123)) - XCTAssertEqual(unwrappedTimeout.wireEncoding, timeoutHeader) - } +import Testing - func testEncodeValidTimeout_Hours() { - let duration = Duration.hours(123) - let timeout = Timeout(duration: duration) - XCTAssertEqual(timeout.duration.components.seconds, duration.components.seconds) - XCTAssertEqual(timeout.duration.components.attoseconds, duration.components.attoseconds) - } - - func testEncodeValidTimeout_Minutes() { - let duration = Duration.minutes(43) - let timeout = Timeout(duration: duration) - XCTAssertEqual(timeout.duration.components.seconds, duration.components.seconds) - XCTAssertEqual(timeout.duration.components.attoseconds, duration.components.attoseconds) - } - - func testEncodeValidTimeout_Seconds() { - let duration = Duration.seconds(12345) - let timeout = Timeout(duration: duration) - XCTAssertEqual(timeout.duration.components.seconds, duration.components.seconds) - XCTAssertEqual(timeout.duration.components.attoseconds, duration.components.attoseconds) - } - - func testEncodeValidTimeout_Seconds_TooLong_Minutes() { - let duration = Duration.seconds(111_111_111) - let timeout = Timeout(duration: duration) - // The conversion from seconds to minutes results in a loss of precision. - // 111,111,111 seconds / 60 = 1,851,851.85 minutes -rounding up-> 1,851,852 minutes * 60 = 111,111,120 seconds - let expectedRoundedDuration = Duration.minutes(1_851_852) - XCTAssertEqual(timeout.duration.components.seconds, expectedRoundedDuration.components.seconds) - XCTAssertEqual( - timeout.duration.components.attoseconds, - expectedRoundedDuration.components.attoseconds - ) - } - - func testEncodeValidTimeout_Seconds_TooLong_Hours() { - let duration = Duration.seconds(9_999_999_999 as Int64) - let timeout = Timeout(duration: duration) - // The conversion from seconds to hours results in a loss of precision. - // 9,999,999,999 seconds / 60 = 166,666,666.65 minutes -rounding up-> - // 166,666,667 minutes / 60 = 2,777,777.78 hours -rounding up-> - // 2,777,778 hours * 60 -> 166,666,680 minutes * 60 = 10,000,000,800 seconds - let expectedRoundedDuration = Duration.hours(2_777_778) - XCTAssertEqual(timeout.duration.components.seconds, expectedRoundedDuration.components.seconds) - XCTAssertEqual( - timeout.duration.components.attoseconds, - expectedRoundedDuration.components.attoseconds - ) - } - - func testEncodeValidTimeout_Seconds_TooLong_MaxAmount() { - let duration = Duration.seconds(999_999_999_999 as Int64) - let timeout = Timeout(duration: duration) - // The conversion from seconds to hours results in a number that still has - // more than the maximum allowed 8 digits, so we must clamp it. - // Make sure that `Timeout.maxAmount` is the amount used for the resulting timeout. - let expectedRoundedDuration = Duration.hours(Timeout.maxAmount) - XCTAssertEqual(timeout.duration.components.seconds, expectedRoundedDuration.components.seconds) - XCTAssertEqual( - timeout.duration.components.attoseconds, - expectedRoundedDuration.components.attoseconds - ) - } - - func testEncodeValidTimeout_SecondsAndMilliseconds() { - let duration = Duration(secondsComponent: 100, attosecondsComponent: Int64(1e+17)) - let timeout = Timeout(duration: duration) - XCTAssertEqual(timeout.duration.components.seconds, duration.components.seconds) - XCTAssertEqual(timeout.duration.components.attoseconds, duration.components.attoseconds) - } - - func testEncodeValidTimeout_SecondsAndMicroseconds() { - let duration = Duration(secondsComponent: 1, attosecondsComponent: Int64(1e+14)) - let timeout = Timeout(duration: duration) - XCTAssertEqual(timeout.duration.components.seconds, duration.components.seconds) - XCTAssertEqual(timeout.duration.components.attoseconds, duration.components.attoseconds) - } - - func testEncodeValidTimeout_SecondsAndNanoseconds() { - let duration = Duration(secondsComponent: 1, attosecondsComponent: Int64(1e+11)) - let timeout = Timeout(duration: duration) - // We can't convert seconds to nanoseconds because that would require at least - // 9 digits, and the maximum allowed is 8: we expect to simply drop the nanoseconds. - let expectedRoundedDuration = Duration.seconds(1) - XCTAssertEqual(timeout.duration.components.seconds, expectedRoundedDuration.components.seconds) - XCTAssertEqual( - timeout.duration.components.attoseconds, - expectedRoundedDuration.components.attoseconds - ) - } - - func testEncodeValidTimeout_Milliseconds() { - let duration = Duration.milliseconds(100) - let timeout = Timeout(duration: duration) - XCTAssertEqual(timeout.duration.components.seconds, duration.components.seconds) - XCTAssertEqual(timeout.duration.components.attoseconds, duration.components.attoseconds) - } - - func testEncodeValidTimeout_Microseconds() { - let duration = Duration.microseconds(100) - let timeout = Timeout(duration: duration) - XCTAssertEqual(timeout.duration.components.seconds, duration.components.seconds) - XCTAssertEqual(timeout.duration.components.attoseconds, duration.components.attoseconds) - } +@testable import GRPCCore - func testEncodeValidTimeout_Nanoseconds() { - let duration = Duration.nanoseconds(100) - let timeout = Timeout(duration: duration) - XCTAssertEqual(timeout.duration.components.seconds, duration.components.seconds) - XCTAssertEqual(timeout.duration.components.attoseconds, duration.components.attoseconds) +struct TimeoutTests { + @Test("Initialize from invalid String value", arguments: ["", "H", "123", "100000000S", "123j"]) + func initFromStringWithInvalidValue(_ value: String) throws { + #expect(Timeout(decoding: value) == nil) + } + + @Test( + "Initialize from String", + arguments: [ + ("123H", .hours(123)), + ("123M", .minutes(123)), + ("123S", .seconds(123)), + ("123m", .milliseconds(123)), + ("123u", .microseconds(123)), + ("123n", .nanoseconds(123)), + ] as [(String, Duration)] + ) + func initFromString(_ value: String, expected: Duration) throws { + let timeout = try #require(Timeout(decoding: value)) + #expect(timeout.duration == expected) + } + + @Test( + "Initialize from Duration", + arguments: [ + .hours(123), + .minutes(43), + .seconds(12345), + .milliseconds(100), + .microseconds(100), + .nanoseconds(100), + ] as [Duration] + ) + func initFromDuration(_ value: Duration) { + let timeout = Timeout(duration: value) + #expect(timeout.duration == value) + } + + @Test( + "Initialize from Duration with loss of precision", + arguments: [ + // 111,111,111 seconds / 60 = 1,851,851.85 minutes -rounding up-> 1,851,852 minutes * 60 = 111,111,120 seconds + (.seconds(111_111_111), .minutes(1_851_852)), + + // 9,999,999,999 seconds / 60 = 166,666,666.65 minutes -rounding up-> + // 166,666,667 minutes / 60 = 2,777,777.78 hours -rounding up-> + // 2,777,778 hours * 60 -> 166,666,680 minutes * 60 = 10,000,000,800 seconds + (.seconds(9_999_999_999 as Int64), .hours(2_777_778)), + + // The conversion from seconds to hours results in a number that still has + // more than the maximum allowed 8 digits, so we must clamp it. + // Make sure that `Timeout.maxAmount` is the amount used for the resulting timeout. + (.seconds(999_999_999_999 as Int64), .hours(Timeout.maxAmount)), + + // We can't convert seconds to nanoseconds because that would require at least + // 9 digits, and the maximum allowed is 8: we expect to simply drop the nanoseconds. + (Duration(secondsComponent: 1, attosecondsComponent: Int64(1e11)), .seconds(1)), + ] as [(Duration, Duration)] + ) + func initFromDurationWithLossOfPrecision(original: Duration, rounded: Duration) { + let timeout = Timeout(duration: original) + #expect(timeout.duration == rounded) } }