diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:18:32 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 12:18:32 -0300 |
| commit | 72a4b451c9d06f7a97cac2f43f1b7f52fafcd3b7 (patch) | |
| tree | 3e4d33a614b673bc8cd9de1e688245f340cd3e1e /Tests/CepisTests | |
| download | swift-0.1.0.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'Tests/CepisTests')
| -rw-r--r-- | Tests/CepisTests/CepisTests.swift | 459 | ||||
| -rw-r--r-- | Tests/CepisTests/TestHTTPServer.swift | 266 |
2 files changed, 725 insertions, 0 deletions
diff --git a/Tests/CepisTests/CepisTests.swift b/Tests/CepisTests/CepisTests.swift new file mode 100644 index 0000000..46699bc --- /dev/null +++ b/Tests/CepisTests/CepisTests.swift @@ -0,0 +1,459 @@ +import Foundation +import XCTest + +@testable import Cepis + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +let seAddressJSON = """ + {"cep":"01001000","logradouro":"Praça da Sé","complemento":"lado ímpar", + "unidade":"","bairro":"Sé","localidade":"São Paulo","uf":"SP", + "estado":"São Paulo","regiao":"Sudeste","ibge":"3550308","gia":"1004", + "ddd":"11","siafi":"7107"} + """ + +let csvHeader = + "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi" + +func csvBody(_ rows: [String]) -> String { + ([csvHeader] + rows).joined(separator: "\n") + "\n" +} + +class ServerBackedTestCase: XCTestCase { + var server: TestHTTPServer! + var client: Client! + + var routes: Router { server.router } + + override func setUpWithError() throws { + try super.setUpWithError() + server = try TestHTTPServer() + client = Client( + baseURL: server.baseURL, + timeout: 5, + backoffBase: 0.001, + backoffMax: 0.01 + ) + } + + override func tearDown() { + server?.stop() + server = nil + client = nil + super.tearDown() + } +} + +final class NormalisationTests: XCTestCase { + func testAcceptsMaskedAndUnmasked() throws { + for value in ["01001000", "01001-000", " 01001-000 ", "01001.000"] { + XCTAssertEqual(try normalizeCEP(value), "01001000") + } + } + + func testRejectsBadFormat() { + for value in ["", "123", "0100100a", "010010000", "abcdefgh"] { + XCTAssertThrowsError(try normalizeCEP(value)) { error in + guard case CepisError.invalidCEP = error else { + return XCTFail("expected invalidCEP for \"\(value)\", got \(error)") + } + } + } + } + + func testInvalidCEPIsNotRetriable() { + XCTAssertFalse(CepisError.invalidCEP("x").isRetriable) + XCTAssertFalse(CepisError.cepNotFound("x").isRetriable) + XCTAssertTrue(CepisError.networkError(message: "x").isRetriable) + } +} + +final class LookupTests: ServerBackedTestCase { + func testSuccessfulLookup() async throws { + routes.respond("/01001000", body: seAddressJSON) + let address = try await client.lookup("01001-000") + XCTAssertEqual(address.localidade, "São Paulo") + XCTAssertEqual(address.logradouro, "Praça da Sé") + XCTAssertEqual(address.uf, "SP") + XCTAssertEqual(address.formattedCEP, "01001-000") + XCTAssertNil(address.geo) + } + + func testReadsTheCoordinateWhenPresent() async throws { + routes.respond( + "/01310100", + body: """ + {"cep":"01310100","logradouro":"Avenida Paulista","localidade":"São Paulo", + "uf":"SP","geo":{"lat":-23.5,"lon":-46.6,"raio_m":120, + "precisao":"logradouro"}} + """) + let address = try await client.lookup("01310100") + let geo = try XCTUnwrap(address.geo) + XCTAssertEqual(geo.lat, -23.5, accuracy: 0.0001) + XCTAssertEqual(geo.lon, -46.6, accuracy: 0.0001) + XCTAssertEqual(geo.raioM, 120) + XCTAssertTrue(geo.isPrecise) + } + + func testMunicipalityCoordinateIsNotPrecise() async throws { + routes.respond( + "/01001000", + body: """ + {"cep":"01001000","localidade":"São Paulo","geo":{"lat":-23.5,"lon":-46.6, + "raio_m":22000,"precisao":"localidade"}} + """) + let address = try await client.lookup("01001000") + let geo = try XCTUnwrap(address.geo) + XCTAssertFalse(geo.isPrecise) + XCTAssertEqual(geo.raioM, 22000) + } + + func testMalformedGeoDoesNotLoseTheAddress() async throws { + routes.respond( + "/01001000", + body: #"{"cep":"01001000","localidade":"São Paulo","geo":{"lat":"not a number"}}"#) + let address = try await client.lookup("01001000") + XCTAssertNil(address.geo) + XCTAssertEqual(address.localidade, "São Paulo") + } + + func testNullGeoDoesNotLoseTheAddress() async throws { + routes.respond( + "/01001000", body: #"{"cep":"01001000","localidade":"São Paulo","geo":null}"#) + let address = try await client.lookup("01001000") + XCTAssertNil(address.geo) + XCTAssertEqual(address.localidade, "São Paulo") + } + + func testNewAPIFieldDoesNotBreak() async throws { + routes.respond( + "/01001000", + body: #"{"cep":"01001000","localidade":"São Paulo","campo_do_futuro":"value"}"#) + let address = try await client.lookup("01001000") + XCTAssertEqual(address.localidade, "São Paulo") + } + + func testNotFound() async throws { + routes.respond( + "/99999999", status: 404, + body: #"{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"}"#) + do { + _ = try await client.lookup("99999999") + XCTFail("expected cepNotFound") + } catch CepisError.cepNotFound(let message) { + XCTAssertTrue(message.contains("99999999")) + } + } + + func testNotFoundIsNotRetried() async throws { + routes.respond("/99999999", status: 404, body: #"{"erro":"cep_nao_encontrado"}"#) + do { + _ = try await client.lookup("99999999") + XCTFail("expected cepNotFound") + } catch CepisError.cepNotFound { + } + XCTAssertEqual(routes.count("/99999999"), 1) + } + + func testInvalidCEPNeverLeavesTheMachine() async throws { + do { + _ = try await client.lookup("123") + XCTFail("expected invalidCEP") + } catch CepisError.invalidCEP { + } + XCTAssertEqual(routes.calls.count, 0) + } + + func testAPI400BecomesInvalidCEP() async throws { + routes.respond( + "/00000000", status: 400, + body: #"{"erro":"cep_invalido","mensagem":"rejected by the API"}"#) + do { + _ = try await client.lookup("00000000") + XCTFail("expected invalidCEP") + } catch CepisError.invalidCEP(let message) { + XCTAssertEqual(message, "rejected by the API") + } + } + + func testResponseThatIsNotJSON() async throws { + routes.respond("/01001000", body: "<html>captive portal</html>") + do { + _ = try await client.lookup("01001000") + XCTFail("expected invalidResponse") + } catch CepisError.invalidResponse { + } + } +} + +final class RetryTests: ServerBackedTestCase { + func testRetriesOn500ThenSucceeds() async throws { + routes.respond( + "/01001000", + sequence: [ + StubResponse(status: 500, body: #"{"erro":"interno"}"#), + StubResponse(status: 200, body: seAddressJSON), + ]) + let address = try await client.lookup("01001000") + XCTAssertEqual(address.localidade, "São Paulo") + XCTAssertEqual(routes.count("/01001000"), 2) + } + + func testExhaustsAttemptsThenThrows() async throws { + routes.respond("/01001000", status: 503, body: #"{"erro":"interno"}"#) + do { + _ = try await client.lookup("01001000") + XCTFail("expected serverError") + } catch CepisError.serverError(_, let status) { + XCTAssertEqual(status, 503) + } + XCTAssertEqual(routes.count("/01001000"), 3) + } + + func testMaxAttemptsOneDisablesRetry() async throws { + let single = Client(baseURL: server.baseURL, timeout: 5, maxAttempts: 1) + routes.respond("/01001000", status: 500, body: "{}") + do { + _ = try await single.lookup("01001000") + XCTFail("expected serverError") + } catch CepisError.serverError { + } + XCTAssertEqual(routes.count("/01001000"), 1) + } + + func test429BecomesRateLimitedWithRetryAfter() async throws { + routes.respond("/01001000", status: 429, body: "{}", headers: ["Retry-After": "7"]) + do { + _ = try await client.lookup("01001000") + XCTFail("expected rateLimited") + } catch CepisError.rateLimited(_, let retryAfter) { + XCTAssertEqual(retryAfter, 7) + } + } + + func testRetryAfterHonoursTheCap() async throws { + routes.respond("/01001000", status: 429, body: "{}", headers: ["Retry-After": "3600"]) + let started = Date() + do { + _ = try await client.lookup("01001000") + XCTFail("expected rateLimited") + } catch CepisError.rateLimited { + } + XCTAssertLessThan(Date().timeIntervalSince(started), 5) + } + + func testRetryAfterAcceptsAnHTTPDate() throws { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "GMT") + formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" + let header = formatter.string(from: Date().addingTimeInterval(30)) + + let seconds = try XCTUnwrap(Client.retryAfterSeconds(header)) + XCTAssertGreaterThan(seconds, 20) + XCTAssertLessThan(seconds, 40) + } + + func testRetryAfterInThePastIsClampedToZero() { + XCTAssertEqual(Client.retryAfterSeconds("-5"), 0) + XCTAssertNil(Client.retryAfterSeconds(nil)) + XCTAssertNil(Client.retryAfterSeconds("not a date")) + } + + func testNetworkErrorWhenNothingListens() async throws { + let unreachable = Client( + baseURL: URL(string: "http://127.0.0.1:1")!, + timeout: 1, + maxAttempts: 2, + backoffBase: 0.001, + backoffMax: 0.01 + ) + do { + _ = try await unreachable.lookup("01001000") + XCTFail("expected networkError") + } catch CepisError.networkError { + } + } + + func testTotalTimeoutBoundsTheWholeCall() async throws { + let bounded = Client( + baseURL: server.baseURL, + timeout: 5, + maxAttempts: 50, + backoffBase: 0.05, + backoffMax: 0.05, + totalTimeout: 0.3 + ) + routes.respond("/01001000", status: 500, body: "{}") + let started = Date() + do { + _ = try await bounded.lookup("01001000") + XCTFail("expected serverError") + } catch CepisError.serverError { + } + XCTAssertLessThan(Date().timeIntervalSince(started), 5) + XCTAssertLessThan(routes.count("/01001000"), 50) + } +} + +final class BatchTests: ServerBackedTestCase { + func testPreservesOrderAndMarksMissing() async throws { + let body = csvBody([ + "01001000,Praça da Sé,lado ímpar,,Sé,São Paulo,SP,São Paulo," + + "Sudeste,3550308,1004,11,7107", + "99999999,,,,,,,,,,,,", + "20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ," + + "Rio de Janeiro,Sudeste,3304557,,21,6001", + ]) + routes.setHandler { _ in StubResponse(status: 200, body: body) } + + let result = try await client.lookupMany(["01001000", "99999999", "20040020"]) + XCTAssertEqual(result.count, 3) + XCTAssertEqual(result[0]?.localidade, "São Paulo") + XCTAssertNil(result[1]) + XCTAssertEqual(result[2]?.uf, "RJ") + XCTAssertNil(result[0]?.geo) + } + + func testFieldWithCommaIsParsed() async throws { + let body = csvBody([ + #"01001000,"Rua Um, Dois",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1"# + ]) + routes.setHandler { _ in StubResponse(status: 200, body: body) } + + let result = try await client.lookupMany(["01001000"]) + XCTAssertEqual(result[0]?.logradouro, "Rua Um, Dois") + } + + func testEscapedQuotesInAField() async throws { + let body = csvBody([ + #"01001000,"Rua ""Dois"" Irmãos",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1"# + ]) + routes.setHandler { _ in StubResponse(status: 200, body: body) } + + let result = try await client.lookupMany(["01001000"]) + XCTAssertEqual(result[0]?.logradouro, #"Rua "Dois" Irmãos"#) + } + + func testEmptyListMakesNoRequest() async throws { + let result = try await client.lookupMany([]) + XCTAssertTrue(result.isEmpty) + XCTAssertEqual(routes.calls.count, 0) + } + + func testChunksAboveTheAPILimit() async throws { + let ceps = (0..<501).map { String(format: "%08d", $0) } + let first = csvBody(ceps[0..<500].map { "\($0),,,,,X,SP,São Paulo,Sudeste,1,,11,1" }) + let second = csvBody(ceps[500...].map { "\($0),,,,,X,SP,São Paulo,Sudeste,1,,11,1" }) + + let served = NSLock() + var count = 0 + routes.setHandler { _ in + served.lock() + defer { served.unlock() } + count += 1 + return StubResponse(status: 200, body: count == 1 ? first : second) + } + + let result = try await client.lookupMany(ceps) + XCTAssertEqual(routes.count("/csv"), 2) + XCTAssertEqual(result.count, 501) + XCTAssertEqual(result[500]?.cep, "00000500") + } + + func testTruncatedResponseDoesNotShift() async throws { + let body = csvBody(["01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1"]) + routes.setHandler { _ in StubResponse(status: 200, body: body) } + + let result = try await client.lookupMany(["01001000", "20040020"]) + XCTAssertEqual(result.count, 2) + XCTAssertEqual(result[0]?.localidade, "São Paulo") + XCTAssertNil(result[1]) + } + + func testMalformedCEPBecomesNilRatherThanThrowing() async throws { + let body = csvBody([ + "01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1", + "abc,,,,,,,,,,,,", + ]) + routes.setHandler { _ in StubResponse(status: 200, body: body) } + + let result = try await client.lookupMany(["01001000", "abc"]) + XCTAssertEqual(result.count, 2) + XCTAssertNil(result[1]) + } +} + +final class HealthTests: ServerBackedTestCase { + func testReadsHealth() async throws { + routes.respond( + "/health", + body: """ + {"status":"ok","dne":"26071","ceps":1596168,"geo":894489, + "base_gerada_em":"2026-07-24T03:48:28Z"} + """) + let health = try await client.health() + XCTAssertTrue(health.ok) + XCTAssertEqual(health.dne, "26071") + XCTAssertEqual(health.ceps, 1_596_168) + XCTAssertEqual(health.geo, 894_489) + XCTAssertEqual(health.baseGeradaEm, "2026-07-24T03:48:28Z") + } + + func testDegradedDoesNotThrow() async throws { + routes.respond( + "/health", status: 503, + body: #"{"status":"degradado","dne":"","ceps":0,"geo":0}"#) + let health = try await client.health() + XCTAssertFalse(health.ok) + XCTAssertEqual(health.status, "degradado") + XCTAssertEqual(routes.count("/health"), 1) + } +} + +final class ConfigurationTests: XCTestCase { + func testTrailingSlashIsNotDuplicated() { + let client = Client(baseURL: URL(string: "http://example.test/")!) + XCTAssertEqual(client.baseURL.absoluteString, "http://example.test") + } + + func testUserAgentIdentifiesTheLibrary() { + XCTAssertTrue(Client().userAgent.hasPrefix("cepis-swift/")) + XCTAssertTrue(Client().userAgent.contains(Client.version)) + } + + func testDefaultBaseURL() { + XCTAssertEqual(Client.defaultBaseURL.absoluteString, "https://cep.is") + XCTAssertEqual(Client.maxBatch, 500) + } + + func testFormattedCEPLeavesOddLengthsAlone() { + XCTAssertEqual(Address(cep: "01001000").formattedCEP, "01001-000") + XCTAssertEqual(Address(cep: "123").formattedCEP, "123") + } +} + +final class CSVTests: XCTestCase { + func testParsesQuotedFieldsAndCRLF() { + let rows = CSV.parse("a,b\r\n\"x,1\",\"y\"\"z\"\r\n") + XCTAssertEqual(rows.count, 2) + XCTAssertEqual(rows[0], ["a", "b"]) + XCTAssertEqual(rows[1], ["x,1", "y\"z"]) + } + + func testParsesLastLineWithoutNewline() { + let rows = CSV.parse("a,b\n1,2") + XCTAssertEqual(rows.count, 2) + XCTAssertEqual(rows[1], ["1", "2"]) + } + + func testTrailingNewlineDoesNotProduceAnEmptyRow() { + XCTAssertEqual(CSV.parse("a,b\n1,2\n").count, 2) + } + + func testEmptyInput() { + XCTAssertTrue(CSV.parse("").isEmpty) + } +} diff --git a/Tests/CepisTests/TestHTTPServer.swift b/Tests/CepisTests/TestHTTPServer.swift new file mode 100644 index 0000000..66722d4 --- /dev/null +++ b/Tests/CepisTests/TestHTTPServer.swift @@ -0,0 +1,266 @@ +import Foundation + +#if canImport(Glibc) +import Glibc +private let socketStreamType = Int32(SOCK_STREAM.rawValue) +private let sendFlags = Int32(MSG_NOSIGNAL) +#elseif canImport(Darwin) +import Darwin +private let socketStreamType = SOCK_STREAM +private let sendFlags: Int32 = 0 +#endif + +struct StubResponse { + var status: Int = 200 + var body: String = "" + var headers: [String: String] = [:] +} + +final class Router: @unchecked Sendable { + private let lock = NSLock() + private var routes: [String: [StubResponse]] = [:] + private var handler: ((String) -> StubResponse)? + private var recorded: [String] = [] + + func respond(_ path: String, status: Int = 200, body: String, headers: [String: String] = [:]) { + lock.lock() + defer { lock.unlock() } + routes[path] = [StubResponse(status: status, body: body, headers: headers)] + } + + func respond(_ path: String, sequence: [StubResponse]) { + lock.lock() + defer { lock.unlock() } + routes[path] = sequence + } + + func setHandler(_ handler: @escaping (String) -> StubResponse) { + lock.lock() + defer { lock.unlock() } + self.handler = handler + } + + var calls: [String] { + lock.lock() + defer { lock.unlock() } + return recorded + } + + func count(_ prefix: String) -> Int { + calls.filter { $0.hasPrefix(prefix) }.count + } + + func next(_ path: String) -> StubResponse { + lock.lock() + defer { lock.unlock() } + recorded.append(path) + + if let handler { + return handler(path) + } + guard var queued = routes[path], !queued.isEmpty else { + return StubResponse(status: 404, body: #"{"erro":"cep_nao_encontrado"}"#) + } + if queued.count > 1 { + let head = queued.removeFirst() + routes[path] = queued + return head + } + return queued[0] + } +} + +/// A throwaway HTTP/1.1 server on a random loopback port, built on BSD +/// sockets because Foundation ships no server on Linux. +/// +/// Every response closes its connection, so there is no keep-alive state to +/// track and every request is a fresh accept. +final class TestHTTPServer: @unchecked Sendable { + let router = Router() + let port: UInt16 + + private let listener: Int32 + private let stopped = NSLock() + private var isStopped = false + + init() throws { + signal(SIGPIPE, SIG_IGN) + + let opened = try TestHTTPServer.openListener() + listener = opened.descriptor + port = opened.port + + let thread = Thread { [weak self] in self?.acceptLoop() } + thread.stackSize = 1 << 20 + thread.start() + } + + private static func openListener() throws -> (descriptor: Int32, port: UInt16) { + let descriptor = socket(AF_INET, socketStreamType, 0) + guard descriptor >= 0 else { + throw TestServerError.failed("socket() failed with errno \(errno)") + } + + var reuse: Int32 = 1 + setsockopt( + descriptor, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout<Int32>.size)) + + var address = sockaddr_in() + address.sin_family = sa_family_t(AF_INET) + address.sin_port = in_port_t(UInt16(0).bigEndian) + address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + + let bound = withUnsafePointer(to: &address) { pointer -> Int32 in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { generic in + bind(descriptor, generic, socklen_t(MemoryLayout<sockaddr_in>.size)) + } + } + guard bound == 0 else { + close(descriptor) + throw TestServerError.failed("bind() failed with errno \(errno)") + } + guard listen(descriptor, 32) == 0 else { + close(descriptor) + throw TestServerError.failed("listen() failed with errno \(errno)") + } + + var actual = sockaddr_in() + var length = socklen_t(MemoryLayout<sockaddr_in>.size) + let named = withUnsafeMutablePointer(to: &actual) { pointer -> Int32 in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { generic in + getsockname(descriptor, generic, &length) + } + } + guard named == 0 else { + close(descriptor) + throw TestServerError.failed("getsockname() failed with errno \(errno)") + } + return (descriptor, UInt16(bigEndian: actual.sin_port)) + } + + var baseURL: URL { + URL(string: "http://127.0.0.1:\(port)")! + } + + func stop() { + stopped.lock() + if isStopped { + stopped.unlock() + return + } + isStopped = true + stopped.unlock() + + shutdown(listener, Int32(SHUT_RDWR)) + close(listener) + } + + private var shuttingDown: Bool { + stopped.lock() + defer { stopped.unlock() } + return isStopped + } + + private func acceptLoop() { + while !shuttingDown { + let client = accept(listener, nil, nil) + if client < 0 { + if errno == EINTR { continue } + return + } + let thread = Thread { [weak self] in self?.handle(client) } + thread.stackSize = 1 << 20 + thread.start() + } + } + + private func handle(_ client: Int32) { + defer { close(client) } + + #if canImport(Darwin) + var noSigPipe: Int32 = 1 + setsockopt( + client, SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, socklen_t(MemoryLayout<Int32>.size)) + #endif + + guard let path = readRequestPath(client) else { return } + let stub = router.next(path) + write(client, response(for: stub)) + } + + private func readRequestPath(_ client: Int32) -> String? { + var received: [UInt8] = [] + var chunk = [UInt8](repeating: 0, count: 4096) + + while headerEnd(of: received) == nil { + let read = recv(client, &chunk, chunk.count, 0) + if read <= 0 { break } + received.append(contentsOf: chunk[0..<read]) + if received.count > 1 << 20 { break } + } + guard headerEnd(of: received) != nil else { return nil } + + var lineEnd = received.count + for index in 0..<max(0, received.count - 1) where received[index] == 0x0D { + lineEnd = index + break + } + let line = String(decoding: received[0..<lineEnd], as: UTF8.self) + let parts = line.split(separator: " ") + guard parts.count >= 2 else { return nil } + return String(parts[1]) + } + + private func headerEnd(of bytes: [UInt8]) -> Int? { + guard bytes.count >= 4 else { return nil } + for index in 0...(bytes.count - 4) { + if bytes[index] == 0x0D, bytes[index + 1] == 0x0A, bytes[index + 2] == 0x0D, + bytes[index + 3] == 0x0A + { + return index + } + } + return nil + } + + private func response(for stub: StubResponse) -> [UInt8] { + let body = Array(stub.body.utf8) + var head = "HTTP/1.1 \(stub.status) \(reason(stub.status))\r\n" + for (name, value) in stub.headers { + head += "\(name): \(value)\r\n" + } + head += "Content-Length: \(body.count)\r\n" + head += "Connection: close\r\n\r\n" + return Array(head.utf8) + body + } + + private func reason(_ status: Int) -> String { + switch status { + case 200: return "OK" + case 400: return "Bad Request" + case 404: return "Not Found" + case 429: return "Too Many Requests" + case 500: return "Internal Server Error" + case 502: return "Bad Gateway" + case 503: return "Service Unavailable" + case 504: return "Gateway Timeout" + default: return "Status" + } + } + + private func write(_ client: Int32, _ bytes: [UInt8]) { + var offset = 0 + while offset < bytes.count { + let written = bytes.withUnsafeBufferPointer { buffer -> Int in + guard let base = buffer.baseAddress else { return -1 } + return send(client, base + offset, bytes.count - offset, sendFlags) + } + if written <= 0 { return } + offset += written + } + } +} + +enum TestServerError: Error { + case failed(String) +} |