From 00f2c83c5f5fa2e62f0f11ec648770c4c6a5edff Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Fri, 12 Dec 2025 10:54:47 +0000 Subject: [PATCH 1/2] Add basic tests --- ...TTPRequestConcludingAsyncReaderTests.swift | 159 ++++++++++++++++++ ...TPResponseConcludingAsyncWriterTests.swift | 105 ++++++++++++ .../HTTPResponseSenderTests.swift | 74 ++++++++ Tests/HTTPServerTests/HTTPServerTests.swift | 57 ++++--- 4 files changed, 371 insertions(+), 24 deletions(-) create mode 100644 Tests/HTTPServerTests/HTTPRequestConcludingAsyncReaderTests.swift create mode 100644 Tests/HTTPServerTests/HTTPResponseConcludingAsyncWriterTests.swift create mode 100644 Tests/HTTPServerTests/HTTPResponseSenderTests.swift diff --git a/Tests/HTTPServerTests/HTTPRequestConcludingAsyncReaderTests.swift b/Tests/HTTPServerTests/HTTPRequestConcludingAsyncReaderTests.swift new file mode 100644 index 0000000..ecff507 --- /dev/null +++ b/Tests/HTTPServerTests/HTTPRequestConcludingAsyncReaderTests.swift @@ -0,0 +1,159 @@ +@testable import HTTPServer +import HTTPTypes +import NIOCore +import NIOHTTP1 +import NIOHTTPTypes +import NIOPosix +import Testing + +@Suite +struct HTTPRequestConcludingAsyncReaderTests { + @Test("Head request not allowed") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testWriteHeadRequestPartFatalError() async throws { + // The request body reader should fatal error if it receives a head part + await #expect(processExitsWith: .failure) { + let (stream, source) = NIOAsyncChannelInboundStream.makeTestingStream() + + // Write just a request head + source.yield(.head(.init(method: .get, scheme: "http", authority: "", path: ""))) + source.finish() + + let requestReader = HTTPRequestConcludingAsyncReader( + iterator: stream.makeAsyncIterator(), + readerState: .init() + ) + + _ = try await requestReader.consumeAndConclude { bodyReader in + var bodyReader = bodyReader + try await bodyReader.read { element in () } + } + } + } + + @Test( + "Request with concluding element", + arguments: [ByteBuffer(repeating: 1, count: 100), ByteBuffer()], + [ + HTTPFields([.init(name: .cookie, value: "test_cookie")]), + HTTPFields([.init(name: .cookie, value: "first_cookie"), .init(name: .cookie, value: "second_cookie")]) + ] + ) + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testRequestWithConcludingElement(body: ByteBuffer, trailers: HTTPFields) async throws { + let (stream, source) = NIOAsyncChannelInboundStream.makeTestingStream() + + // First write the request + source.yield(.body(body)) + source.yield(.end(trailers)) + source.finish() + + // Then start reading the request + let requestReader = HTTPRequestConcludingAsyncReader(iterator: stream.makeAsyncIterator(), readerState: .init()) + let (requestBody, finalElement) = try await requestReader.consumeAndConclude { bodyReader in + var bodyReader = bodyReader + + var buffer = ByteBuffer() + // Read just once: we only sent one body chunk + try await bodyReader.read { element in + if let element { + buffer.writeBytes(element.bytes) + } else { + Issue.record("Unexpectedly failed to read the client's request body") + } + } + + // Attempting to read again should result in a `nil` element (we only sent one body chunk) + try await bodyReader.read { element in + if element != nil { + Issue.record("Received a non-nil value after the request body was completely read") + } + } + + return buffer + } + + #expect(requestBody == body) + #expect(finalElement == trailers) + } + + @Test( + "Streamed request with concluding element", + arguments: [ + (0..<10).map { i in ByteBuffer() }, // 10 empty ByteBuffers + (0..<100).map { i in ByteBuffer(bytes: [i]) } // 100 single-byte ByteBuffers + ], + [ + HTTPFields([.init(name: .cookie, value: "test")]), + HTTPFields([.init(name: .cookie, value: "first_cookie"), .init(name: .cookie, value: "second_cookie")]), + ] + ) + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testStreamedRequestBody(bodyChunks: [ByteBuffer], trailers: HTTPFields) async throws { + let (stream, source) = NIOAsyncChannelInboundStream.makeTestingStream() + + // Execute the writer and reader tasks concurrently + await withThrowingTaskGroup { group in + group.addTask { + for chunk in bodyChunks { + source.yield(.body(chunk)) + } + source.yield(.end(trailers)) + source.finish() + } + + group.addTask { + let requestReader = HTTPRequestConcludingAsyncReader( + iterator: stream.makeAsyncIterator(), + readerState: .init() + ) + let finalElement = try await requestReader.consumeAndConclude { bodyReader in + var bodyReader = bodyReader + + for chunk in bodyChunks { + try await bodyReader.read { element in + if let element { + var buffer = ByteBuffer() + buffer.writeBytes(element.bytes) + #expect(chunk == buffer) + } else { + Issue.record("Received a nil element before the request body was completely read") + } + } + } + + try await bodyReader.read { element in + if element != nil { + Issue.record("Received a non-nil element after the request body was completely read") + } + } + } + + #expect(finalElement == trailers) + } + } + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test("More bytes available than consumption limit") + func testCollectMoreBytesThanAvailable() async throws { + let (stream, source) = NIOAsyncChannelInboundStream.makeTestingStream() + + // Write 10 bytes + source.yield(.body(.init(repeating: 5, count: 10))) + source.finish() + + let requestReader = HTTPRequestConcludingAsyncReader(iterator: stream.makeAsyncIterator(), readerState: .init()) + + _ = try await requestReader.consumeAndConclude { requestBodyReader in + var requestBodyReader = requestBodyReader + + // Attempting to collect a maximum of 9 bytes should result in a LimitExceeded error. + await #expect(throws: LimitExceeded.self) { + try await requestBodyReader.collect(upTo: 9) { element in + () + } + } + } + } +} diff --git a/Tests/HTTPServerTests/HTTPResponseConcludingAsyncWriterTests.swift b/Tests/HTTPServerTests/HTTPResponseConcludingAsyncWriterTests.swift new file mode 100644 index 0000000..eb0fb11 --- /dev/null +++ b/Tests/HTTPServerTests/HTTPResponseConcludingAsyncWriterTests.swift @@ -0,0 +1,105 @@ +@testable import HTTPServer +import HTTPTypes +import NIOCore +import NIOHTTPTypes +import Testing + +@Suite +struct HTTPResponseConcludingAsyncWriterTests { + let bodySampleOne: [UInt8] = [1, 2] + let bodySampleTwo: [UInt8] = [3, 4] + let trailerSampleOne: HTTPFields = [.serverTiming: "test"] + let trailerSampleTwo: HTTPFields = [.serverTiming: "test", .cookie: "cookie"] + + @Test("Write single body element") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testSingleWriteAndConclude() async throws { + let (writer, sink) = NIOAsyncChannelOutboundWriter.makeTestingWriter() + let responseWriter = HTTPResponseConcludingAsyncWriter(writer: writer, writerState: .init()) + + try await responseWriter.writeAndConclude(element: self.bodySampleOne.span, finalElement: self.trailerSampleOne) + + // Now read the response + var responseIterator = sink.makeAsyncIterator() + + let element = try #require(await responseIterator.next()) + #expect(element == .body(.init(bytes: self.bodySampleOne))) + let trailer = try #require(await responseIterator.next()) + #expect(trailer == .end(self.trailerSampleOne)) + } + + @Test("Write multiple body elements") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testProduceMultipleElementsAndSingleTrailer() async throws { + let (writer, sink) = NIOAsyncChannelOutboundWriter.makeTestingWriter() + let responseWriter = HTTPResponseConcludingAsyncWriter(writer: writer, writerState: .init()) + + try await responseWriter.produceAndConclude { bodyWriter in + var bodyWriter = bodyWriter + + // Write multiple elements + try await bodyWriter.write(self.bodySampleOne.span) + try await bodyWriter.write(self.bodySampleTwo.span) + + return self.trailerSampleOne + } + + var responseIterator = sink.makeAsyncIterator() + + let firstElement = try #require(await responseIterator.next()) + let secondElement = try #require(await responseIterator.next()) + #expect(firstElement == .body(.init(bytes: self.bodySampleOne))) + #expect(secondElement == .body(.init(bytes: self.bodySampleTwo))) + + let trailer = try #require(await responseIterator.next()) + #expect(trailer == .end(self.trailerSampleOne)) + } + + @Test("Write multiple elements and multiple trailers") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testProduceMultipleElementsAndMultipleTrailers() async throws { + let (writer, sink) = NIOAsyncChannelOutboundWriter.makeTestingWriter() + let responseWriter = HTTPResponseConcludingAsyncWriter(writer: writer, writerState: .init()) + + try await responseWriter.produceAndConclude { bodyWriter in + var bodyWriter = bodyWriter + + // Write multiple elements + try await bodyWriter.write(self.bodySampleOne.span) + try await bodyWriter.write(self.bodySampleTwo.span) + + return self.trailerSampleTwo + } + + var responseIterator = sink.makeAsyncIterator() + + let firstElement = try #require(await responseIterator.next()) + let secondElement = try #require(await responseIterator.next()) + #expect(firstElement == .body(.init(bytes: self.bodySampleOne))) + #expect(secondElement == .body(.init(bytes: self.bodySampleTwo))) + + let trailer = try #require(await responseIterator.next()) + #expect(trailer == .end(self.trailerSampleTwo)) + } + + @Test("No body, just trailers") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testNoBodyJustTrailers() async throws { + let (writer, sink) = NIOAsyncChannelOutboundWriter.makeTestingWriter() + let responseWriter = HTTPResponseConcludingAsyncWriter(writer: writer, writerState: .init()) + + try await responseWriter.produceAndConclude { bodyWriter in + return self.trailerSampleTwo + } + + var responseIterator = sink.makeAsyncIterator() + let trailer = try #require(await responseIterator.next()) + #expect(trailer == .end(self.trailerSampleTwo)) + } +} + +extension HTTPField.Name { + static var serverTiming: Self { + Self("Server-Timing")! + } +} diff --git a/Tests/HTTPServerTests/HTTPResponseSenderTests.swift b/Tests/HTTPServerTests/HTTPResponseSenderTests.swift new file mode 100644 index 0000000..311c248 --- /dev/null +++ b/Tests/HTTPServerTests/HTTPResponseSenderTests.swift @@ -0,0 +1,74 @@ +@testable import HTTPServer +import NIOCore +import NIOHTTPTypes +import HTTPTypes +import Testing + +@Suite +struct HTTPResponseSenderTests { + @Test("Informational header without informational status code") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testInformationalResponseStatusCodePrecondition() async throws { + // Sending an informational header with a non-1xx status code shouldn't be allowed + try await #require(processExitsWith: .failure) { + let (outboundWriter, _) = NIOAsyncChannelOutboundWriter.makeTestingWriter() + let sender = HTTPResponseSender { response in + try await outboundWriter.write(.head(response)) + return HTTPResponseConcludingAsyncWriter( + writer: outboundWriter, + writerState: .init() + ) + } sendInformational: { response in + try await outboundWriter.write(.head(response)) + } + + try await sender.sendInformational(.init(status: .ok, headerFields: [.contentType: "application/json"])) + } + } + + @Test("Multiple informational responses before final response") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testSendMultipleInformationalResponses() async throws { + let (outboundWriter, sink) = NIOAsyncChannelOutboundWriter.makeTestingWriter() + let sender = HTTPResponseSender { response in + try await outboundWriter.write(.head(response)) + return HTTPResponseConcludingAsyncWriter( + writer: outboundWriter, + writerState: .init() + ) + } sendInformational: { response in + try await outboundWriter.write(.head(response)) + } + + // Send two informational responses + let firstInfoHead = HTTPResponse(status: .continue, headerFields: [.contentType: "application/json"]) + let secondInfoHead = HTTPResponse(status: .earlyHints, headerFields: [.contentType: "application/json"]) + try await sender.sendInformational(firstInfoHead) + try await sender.sendInformational(secondInfoHead) + + // Then send the final response + let finalResponseHead = HTTPResponse(status: .ok, headerFields: [:]) + let finalResponseBody = [UInt8]([1, 2]) + let finalResponseTrailer: HTTPFields = [.cookie: "cookie"] + + let responseWriter = try await sender.send(.init(status: .ok, headerFields: [:])) + try await responseWriter.produceAndConclude { bodyTrailerWriter in + var bodyTrailerWriter = bodyTrailerWriter + try await bodyTrailerWriter.write(finalResponseBody.span) + return finalResponseTrailer + } + + var responseIterator = sink.makeAsyncIterator() + let firstHead = try #require(await responseIterator.next()) + let secondHead = try #require(await responseIterator.next()) + let finalHead = try #require(await responseIterator.next()) + let body = try #require(await responseIterator.next()) + let trailer = try #require(await responseIterator.next()) + + #expect(firstHead == .head(firstInfoHead)) + #expect(secondHead == .head(secondInfoHead)) + #expect(finalHead == .head(finalResponseHead)) + #expect(body == .body(ByteBuffer(bytes: finalResponseBody))) + #expect(trailer == .end(finalResponseTrailer)) + } +} diff --git a/Tests/HTTPServerTests/HTTPServerTests.swift b/Tests/HTTPServerTests/HTTPServerTests.swift index dc210d1..17393af 100644 --- a/Tests/HTTPServerTests/HTTPServerTests.swift +++ b/Tests/HTTPServerTests/HTTPServerTests.swift @@ -25,32 +25,41 @@ struct HTTPServerTests { logger: Logger(label: "Test"), configuration: .init(bindTarget: .hostAndPort(host: "127.0.0.1", port: 0)) ) - try await server.serve { request, context, requestBodyAndTrailers, responseSender in - _ = try await requestBodyAndTrailers.collect(upTo: 100) { _ in } - // Uncommenting this would cause a "requestBodyAndTrailers consumed more than once" error. -// _ = try await requestBodyAndTrailers.collect(upTo: 100) { _ in } - - let responseConcludingWriter = try await responseSender.send(HTTPResponse(status: .ok)) - // Uncommenting this would cause a "responseSender consumed more than once" error. -// let responseConcludingWriter2 = try await responseSender.send(HTTPResponse(status: .ok)) - - // Uncommenting this would cause a "requestBodyAndTrailers consumed more than once" error. -// _ = try await requestBodyAndTrailers.consumeAndConclude { reader in -// var reader = reader -// try await reader.read { elem in } -// } - - try await responseConcludingWriter.produceAndConclude { writer in - var writer = writer - try await writer.write([1,2].span) - return nil + + try await withThrowingTaskGroup { group in + group.addTask { + try await server.serve { request, context, requestBodyAndTrailers, responseSender in + _ = try await requestBodyAndTrailers.collect(upTo: 100) { _ in } + // Uncommenting this would cause a "requestBodyAndTrailers consumed more than once" error. + // _ = try await requestBodyAndTrailers.collect(upTo: 100) { _ in } + + let responseConcludingWriter = try await responseSender.send(HTTPResponse(status: .ok)) + // Uncommenting this would cause a "responseSender consumed more than once" error. + // let responseConcludingWriter2 = try await responseSender.send(HTTPResponse(status: .ok)) + + // Uncommenting this would cause a "requestBodyAndTrailers consumed more than once" error. + // _ = try await requestBodyAndTrailers.consumeAndConclude { reader in + // var reader = reader + // try await reader.read { elem in } + // } + + try await responseConcludingWriter.produceAndConclude { writer in + var writer = writer + try await writer.write([1, 2].span) + return nil + } + + // Uncommenting this would cause a "responseConcludingWriter consumed more than once" error. + // try await responseConcludingWriter.writeAndConclude( + // element: [1, 2].span, + // finalElement: HTTPFields(dictionaryLiteral: (.acceptEncoding, "Encoding")) + // ) + } } - // Uncommenting this would cause a "responseConcludingWriter consumed more than once" error. -// try await responseConcludingWriter.writeAndConclude( -// element: [1, 2].span, -// finalElement: HTTPFields(dictionaryLiteral: (.acceptEncoding, "Encoding")) -// ) + _ = try await server.listeningAddress + + group.cancelAll() } } } From 970d9298136295cebf01f8f5432f96af9eb7ab52 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Tue, 16 Dec 2025 16:10:00 +0000 Subject: [PATCH 2/2] Test error propagation when throwing during reading/writing --- ...TTPRequestConcludingAsyncReaderTests.swift | 29 +++++++++++++++++++ ...TPResponseConcludingAsyncWriterTests.swift | 24 +++++++++++++++ .../HTTPServerTests/Utilities/TestError.swift | 5 ++++ 3 files changed, 58 insertions(+) create mode 100644 Tests/HTTPServerTests/Utilities/TestError.swift diff --git a/Tests/HTTPServerTests/HTTPRequestConcludingAsyncReaderTests.swift b/Tests/HTTPServerTests/HTTPRequestConcludingAsyncReaderTests.swift index ecff507..3bf8bc6 100644 --- a/Tests/HTTPServerTests/HTTPRequestConcludingAsyncReaderTests.swift +++ b/Tests/HTTPServerTests/HTTPRequestConcludingAsyncReaderTests.swift @@ -134,6 +134,35 @@ struct HTTPRequestConcludingAsyncReaderTests { } } + @Test("Throw while reading request") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testThrowingWhileReadingRequest() async throws { + let (stream, source) = NIOAsyncChannelInboundStream.makeTestingStream() + + let bodyChunks = (0..<10).map { i in ByteBuffer(bytes: [i]) } + for chunk in bodyChunks { + source.yield(.body(chunk)) + } + source.yield(.end([.cookie: "test"])) + source.finish() + + // Check that the read error is propagated + try await #require(throws: TestError.errorWhileReading) { + let requestReader = HTTPRequestConcludingAsyncReader( + iterator: stream.makeAsyncIterator(), + readerState: .init() + ) + + _ = try await requestReader.consumeAndConclude { bodyReader in + var bodyReader = bodyReader + + try await bodyReader.read { element in + throw TestError.errorWhileReading + } + } + } + } + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) @Test("More bytes available than consumption limit") func testCollectMoreBytesThanAvailable() async throws { diff --git a/Tests/HTTPServerTests/HTTPResponseConcludingAsyncWriterTests.swift b/Tests/HTTPServerTests/HTTPResponseConcludingAsyncWriterTests.swift index eb0fb11..a0586a2 100644 --- a/Tests/HTTPServerTests/HTTPResponseConcludingAsyncWriterTests.swift +++ b/Tests/HTTPServerTests/HTTPResponseConcludingAsyncWriterTests.swift @@ -55,6 +55,30 @@ struct HTTPResponseConcludingAsyncWriterTests { #expect(trailer == .end(self.trailerSampleOne)) } + @Test("Throw while writing response") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testThrowWhileProducing() async throws { + let (writer, sink) = NIOAsyncChannelOutboundWriter.makeTestingWriter() + + // Check that the write error is propagated + try await #require(throws: TestError.errorWhileWriting) { + let responseWriter = HTTPResponseConcludingAsyncWriter(writer: writer, writerState: .init()) + try await responseWriter.produceAndConclude { bodyWriter in + var bodyWriter = bodyWriter + + // Write an element + try await bodyWriter.write(self.bodySampleOne.span) + // Then throw + throw TestError.errorWhileWriting + } + } + + var responseIterator = sink.makeAsyncIterator() + + let firstElement = try #require(await responseIterator.next()) + #expect(firstElement == .body(.init(bytes: self.bodySampleOne))) + } + @Test("Write multiple elements and multiple trailers") @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) func testProduceMultipleElementsAndMultipleTrailers() async throws { diff --git a/Tests/HTTPServerTests/Utilities/TestError.swift b/Tests/HTTPServerTests/Utilities/TestError.swift new file mode 100644 index 0000000..b88e361 --- /dev/null +++ b/Tests/HTTPServerTests/Utilities/TestError.swift @@ -0,0 +1,5 @@ +// An error type for use in tests +enum TestError: Error { + case errorWhileReading + case errorWhileWriting +}