diff --git a/Tests/HTTPServerTests/HTTPRequestConcludingAsyncReaderTests.swift b/Tests/HTTPServerTests/HTTPRequestConcludingAsyncReaderTests.swift new file mode 100644 index 0000000..3bf8bc6 --- /dev/null +++ b/Tests/HTTPServerTests/HTTPRequestConcludingAsyncReaderTests.swift @@ -0,0 +1,188 @@ +@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) + } + } + } + + @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 { + 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..a0586a2 --- /dev/null +++ b/Tests/HTTPServerTests/HTTPResponseConcludingAsyncWriterTests.swift @@ -0,0 +1,129 @@ +@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("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 { + 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/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 +}