diff --git a/Package.swift b/Package.swift index 0651bcaf..b903f252 100644 --- a/Package.swift +++ b/Package.swift @@ -53,6 +53,12 @@ let package = Package( .target( name: "ContainerizationError" ), + .testTarget( + name: "ContainerizationErrorTests", + dependencies: [ + "ContainerizationError" + ] + ), .target( name: "Containerization", dependencies: [ @@ -226,6 +232,13 @@ let package = Package( .product(name: "NIOFoundationCompat", package: "swift-nio"), ] ), + .testTarget( + name: "ContainerizationIOTests", + dependencies: [ + "ContainerizationIO", + .product(name: "NIO", package: "swift-nio"), + ] + ), .target( name: "ContainerizationExtras", dependencies: [ diff --git a/Sources/ContainerizationIO/ReadStream.swift b/Sources/ContainerizationIO/ReadStream.swift index b40e6a67..3e1eb1e7 100644 --- a/Sources/ContainerizationIO/ReadStream.swift +++ b/Sources/ContainerizationIO/ReadStream.swift @@ -117,7 +117,7 @@ public class ReadStream { extension ReadStream { /// Errors that can be encountered while using a `ReadStream`. - public enum Error: Swift.Error, CustomStringConvertible { + public enum Error: Swift.Error, CustomStringConvertible, Equatable { case failedToCreateStream case noSuchFileOrDirectory(_ p: URL) diff --git a/Tests/ContainerizationErrorTests/ContainerizationErrorTests.swift b/Tests/ContainerizationErrorTests/ContainerizationErrorTests.swift new file mode 100644 index 00000000..33578877 --- /dev/null +++ b/Tests/ContainerizationErrorTests/ContainerizationErrorTests.swift @@ -0,0 +1,269 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the Containerization project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import Testing + +@testable import ContainerizationError + +struct ContainerizationErrorTests { + + // MARK: - Initialization Tests + + @Test + func testInitWithCodeAndMessage() { + let error = ContainerizationError(.notFound, message: "Resource not found") + + #expect(error.code == .notFound) + #expect(error.message == "Resource not found") + #expect(error.cause == nil) + } + + @Test + func testInitWithCodeMessageAndCause() { + struct UnderlyingError: Error {} + let cause = UnderlyingError() + let error = ContainerizationError(.internalError, message: "Internal failure", cause: cause) + + #expect(error.code == .internalError) + #expect(error.message == "Internal failure") + #expect(error.cause != nil) + } + + @Test + func testInitWithRawCodeAndMessage() { + let error = ContainerizationError("timeout", message: "Operation timed out") + + #expect(error.code == .timeout) + #expect(error.message == "Operation timed out") + #expect(error.cause == nil) + } + + @Test + func testInitWithRawCodeMessageAndCause() { + struct TestError: Error {} + let cause = TestError() + let error = ContainerizationError("cancelled", message: "Request cancelled", cause: cause) + + #expect(error.code == .cancelled) + #expect(error.message == "Request cancelled") + #expect(error.cause != nil) + } + + // MARK: - Error Code Tests + + @Test + func testAllErrorCodes() { + let codes: [(ContainerizationError.Code, String)] = [ + (.unknown, "unknown"), + (.invalidArgument, "invalidArgument"), + (.internalError, "internalError"), + (.exists, "exists"), + (.notFound, "notFound"), + (.cancelled, "cancelled"), + (.invalidState, "invalidState"), + (.empty, "empty"), + (.timeout, "timeout"), + (.unsupported, "unsupported"), + (.interrupted, "interrupted"), + ] + + for (code, expectedDescription) in codes { + #expect(code.description == expectedDescription) + + // Test that raw value initialization works + let codeFromRaw = ContainerizationError.Code(rawValue: expectedDescription) + #expect(codeFromRaw == code) + } + } + + @Test + func testIsCodeMethod() { + let error = ContainerizationError(.notFound, message: "Test") + + #expect(error.isCode(.notFound)) + #expect(!error.isCode(.exists)) + #expect(!error.isCode(.timeout)) + } + + // MARK: - Equality and Hashing Tests + + @Test + func testEquality() { + let error1 = ContainerizationError(.notFound, message: "Resource not found") + let error2 = ContainerizationError(.notFound, message: "Resource not found") + let error3 = ContainerizationError(.exists, message: "Resource not found") + let error4 = ContainerizationError(.notFound, message: "Different message") + + // Same code and message should be equal + #expect(error1 == error2) + + // Different code should not be equal + #expect(!(error1 == error3)) + + // Different message should not be equal + #expect(!(error1 == error4)) + } + + @Test + func testEqualityWithCause() { + struct TestError: Error {} + let cause1 = TestError() + let cause2 = TestError() + + let error1 = ContainerizationError(.internalError, message: "Test", cause: cause1) + let error2 = ContainerizationError(.internalError, message: "Test", cause: cause2) + let error3 = ContainerizationError(.internalError, message: "Test") + + // Errors with same code and message should be equal regardless of cause + #expect(error1 == error2) + #expect(error1 == error3) + } + + @Test + func testHashing() { + let error1 = ContainerizationError(.timeout, message: "Operation timed out") + let error2 = ContainerizationError(.timeout, message: "Operation timed out") + let error3 = ContainerizationError(.cancelled, message: "Operation timed out") + + // Test that hashing works by creating hasher manually + var hasher1 = Hasher() + error1.hash(into: &hasher1) + let hash1 = hasher1.finalize() + + var hasher2 = Hasher() + error2.hash(into: &hasher2) + let hash2 = hasher2.finalize() + + var hasher3 = Hasher() + error3.hash(into: &hasher3) + let hash3 = hasher3.finalize() + + #expect(hash1 == hash2) + #expect(hash1 != hash3) + } + + // MARK: - Description Tests + + @Test + func testDescriptionWithoutCause() { + let error = ContainerizationError(.notFound, message: "Resource not available") + let expected = "notFound: \"Resource not available\"" + + #expect(error.description == expected) + } + + @Test + func testDescriptionWithCause() { + struct UnderlyingError: Error, CustomStringConvertible { + var description: String { "Underlying error occurred" } + } + + let cause = UnderlyingError() + let error = ContainerizationError(.internalError, message: "Processing failed", cause: cause) + let expected = "internalError: \"Processing failed\" (cause: \"Underlying error occurred\")" + + #expect(error.description == expected) + } + + @Test + func testDescriptionWithGenericCause() { + struct GenericError: Error {} + + let cause = GenericError() + let error = ContainerizationError(.interrupted, message: "Process interrupted", cause: cause) + + // Should contain the error and message, and indicate there's a cause + #expect(error.description.contains("interrupted")) + #expect(error.description.contains("Process interrupted")) + #expect(error.description.contains("cause:")) + } + + // MARK: - Code Validation Tests + + @Test + func testCodeEquality() { + let code1 = ContainerizationError.Code.unknown + let code2 = ContainerizationError.Code.unknown + let code3 = ContainerizationError.Code.notFound + + #expect(code1 == code2) + #expect(code1 != code3) + } + + @Test + func testCodeHashable() { + let code1 = ContainerizationError.Code.exists + let code2 = ContainerizationError.Code.exists + let code3 = ContainerizationError.Code.empty + + #expect(code1.hashValue == code2.hashValue) + #expect(code1.hashValue != code3.hashValue) + } + + // MARK: - Error Protocol Conformance Tests + + @Test + func testErrorProtocolConformance() { + let error = ContainerizationError(.unsupported, message: "Feature not supported") + let swiftError: Error = error + + // Should be able to cast back to ContainerizationError + let castError = swiftError as? ContainerizationError + #expect(castError != nil) + #expect(castError?.code == .unsupported) + #expect(castError?.message == "Feature not supported") + } + + @Test + func testSendableConformance() { + // This is a compile-time test - if ContainerizationError wasn't Sendable, + // this wouldn't compile in strict concurrency mode + let error = ContainerizationError(.timeout, message: "Timeout occurred") + + Task { + let _ = error // Should compile without warnings + } + } + + // MARK: - Edge Cases and Error Scenarios + + @Test + func testEmptyMessage() { + let error = ContainerizationError(.invalidArgument, message: "") + + #expect(error.message.isEmpty) + #expect(error.description == "invalidArgument: \"\"") + } + + @Test + func testMessageWithSpecialCharacters() { + let message = "Error with \"quotes\" and \n newlines and \t tabs" + let error = ContainerizationError(.internalError, message: message) + + #expect(error.message == message) + #expect(error.description.contains(message)) + } + + @Test + func testLongMessage() { + let longMessage = String(repeating: "This is a very long error message. ", count: 100) + let error = ContainerizationError(.unknown, message: longMessage) + + #expect(error.message == longMessage) + #expect(error.description.contains(longMessage)) + } +} diff --git a/Tests/ContainerizationIOTests/ReadStreamTests.swift b/Tests/ContainerizationIOTests/ReadStreamTests.swift new file mode 100644 index 00000000..c9952b23 --- /dev/null +++ b/Tests/ContainerizationIOTests/ReadStreamTests.swift @@ -0,0 +1,306 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the Containerization project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import NIO +import Testing + +@testable import ContainerizationIO + +struct ReadStreamTests { + + // MARK: - Initialization Tests + + @Test + func testEmptyInit() { + _ = ReadStream() + // Test passes if no exceptions are thrown + } + + @Test + func testDataInit() { + let testData = "Hello, World!".data(using: .utf8)! + _ = ReadStream(data: testData) + // Test passes if no exceptions are thrown + } + + @Test + func testDataInitWithCustomBufferSize() { + let testData = "Hello, World!".data(using: .utf8)! + let customBufferSize = 512 + _ = ReadStream(data: testData, bufferSize: customBufferSize) + // Test passes if no exceptions are thrown + } + + @Test + func testURLInitWithValidFile() throws { + // Create a temporary file + let tempURL = createTemporaryFile(content: "Test file content") + defer { try? FileManager.default.removeItem(at: tempURL) } + + _ = try ReadStream(url: tempURL) + // Test passes if no exceptions are thrown + } + + @Test + func testURLInitWithCustomBufferSize() throws { + // Create a temporary file + let tempURL = createTemporaryFile(content: "Test file content") + defer { try? FileManager.default.removeItem(at: tempURL) } + + let customBufferSize = 1024 + _ = try ReadStream(url: tempURL, bufferSize: customBufferSize) + // Test passes if no exceptions are thrown + } + + @Test + func testURLInitWithNonExistentFile() { + let nonExistentURL = URL(fileURLWithPath: "/tmp/nonexistent_file_\(UUID().uuidString)") + + #expect(throws: ReadStream.Error.noSuchFileOrDirectory(nonExistentURL)) { + _ = try ReadStream(url: nonExistentURL) + } + } + + // MARK: - Stream Reading Tests + + @Test + func testDataStreamReading() async throws { + let testContent = "Hello, World! This is a test string for streaming." + let testData = testContent.data(using: .utf8)! + let stream = ReadStream(data: testData) + + var receivedData = Data() + + for await chunk in stream.dataStream { + receivedData.append(chunk) + } + + let receivedString = String(data: receivedData, encoding: .utf8) + #expect(receivedString == testContent) + } + + @Test + func testByteBufferStreamReading() async throws { + let testContent = "Hello, World! This is a test string for streaming." + let testData = testContent.data(using: .utf8)! + let stream = ReadStream(data: testData) + + var receivedData = Data() + + for await buffer in stream.stream { + let bytes = buffer.readableBytesView + receivedData.append(contentsOf: bytes) + } + + let receivedString = String(data: receivedData, encoding: .utf8) + #expect(receivedString == testContent) + } + + @Test + func testFileStreamReading() async throws { + let testContent = "This is test file content for streaming.\nWith multiple lines.\nAnd some special characters: éñüñö" + let tempURL = createTemporaryFile(content: testContent) + defer { try? FileManager.default.removeItem(at: tempURL) } + + let stream = try ReadStream(url: tempURL) + + var receivedData = Data() + + for await chunk in stream.dataStream { + receivedData.append(chunk) + } + + let receivedString = String(data: receivedData, encoding: .utf8) + #expect(receivedString == testContent) + } + + @Test + func testEmptyDataStreaming() async throws { + let stream = ReadStream() + + var chunkCount = 0 + for await _ in stream.dataStream { + chunkCount += 1 + } + + // Empty stream should yield no chunks + #expect(chunkCount == 0) + } + + @Test + func testEmptyByteBufferStreaming() async throws { + let stream = ReadStream() + + var chunkCount = 0 + for await _ in stream.stream { + chunkCount += 1 + } + + // Empty stream should yield no chunks + #expect(chunkCount == 0) + } + + @Test + func testLargeFileStreaming() async throws { + // Create a larger test content that will definitely exceed the buffer size + let largeContent = String(repeating: "This is a test line with some content.\n", count: 5000) + let tempURL = createTemporaryFile(content: largeContent) + defer { try? FileManager.default.removeItem(at: tempURL) } + + // Use a smaller buffer size to force multiple chunks + let stream = try ReadStream(url: tempURL, bufferSize: 1024) + + var receivedData = Data() + var chunkCount = 0 + + for await chunk in stream.dataStream { + receivedData.append(chunk) + chunkCount += 1 + } + + let receivedString = String(data: receivedData, encoding: .utf8) + #expect(receivedString == largeContent) + #expect(chunkCount > 1) // Should be split into multiple chunks + } + + // MARK: - Reset Tests + + @Test + func testResetDataStream() async throws { + let testContent = "Hello, Reset Test!" + let testData = testContent.data(using: .utf8)! + let stream = ReadStream(data: testData) + + // Read once + var firstRead = Data() + for await chunk in stream.dataStream { + firstRead.append(chunk) + } + + // Reset and read again + try stream.reset() + + var secondRead = Data() + for await chunk in stream.dataStream { + secondRead.append(chunk) + } + + let firstString = String(data: firstRead, encoding: .utf8) + let secondString = String(data: secondRead, encoding: .utf8) + + #expect(firstString == testContent) + #expect(secondString == testContent) + #expect(firstString == secondString) + } + + @Test + func testResetFileStream() async throws { + let testContent = "File Reset Test Content" + let tempURL = createTemporaryFile(content: testContent) + defer { try? FileManager.default.removeItem(at: tempURL) } + + let stream = try ReadStream(url: tempURL) + + // Read once + var firstRead = Data() + for await chunk in stream.dataStream { + firstRead.append(chunk) + } + + // Reset and read again + try stream.reset() + + var secondRead = Data() + for await chunk in stream.dataStream { + secondRead.append(chunk) + } + + let firstString = String(data: firstRead, encoding: .utf8) + let secondString = String(data: secondRead, encoding: .utf8) + + #expect(firstString == testContent) + #expect(secondString == testContent) + #expect(firstString == secondString) + } + + @Test + func testResetEmptyStream() async throws { + let stream = ReadStream() + + // Reset should not throw for empty stream + try stream.reset() + + var chunkCount = 0 + for await _ in stream.dataStream { + chunkCount += 1 + } + + #expect(chunkCount == 0) + } + + // MARK: - Error Tests + + @Test + func testErrorDescriptions() { + let url = URL(fileURLWithPath: "/tmp/test") + let noFileError = ReadStream.Error.noSuchFileOrDirectory(url) + let streamError = ReadStream.Error.failedToCreateStream + + #expect(noFileError.description.contains("/tmp/test")) + #expect(streamError.description == "failed to create stream") + } + + // MARK: - Buffer Size Tests + + @Test + func testDefaultBufferSize() { + #expect(ReadStream.bufferSize == Int(1.mib())) + } + + @Test + func testSmallBufferSize() async throws { + let testContent = "This is a test with small buffer size that should be split into multiple chunks." + let testData = testContent.data(using: .utf8)! + let smallBufferSize = 10 + let stream = ReadStream(data: testData, bufferSize: smallBufferSize) + + var receivedData = Data() + var chunkCount = 0 + + for await chunk in stream.dataStream { + receivedData.append(chunk) + chunkCount += 1 + // Each chunk should be at most the buffer size + #expect(chunk.count <= smallBufferSize) + } + + let receivedString = String(data: receivedData, encoding: .utf8) + #expect(receivedString == testContent) + #expect(chunkCount > 1) // Should be split into multiple chunks + } + + // MARK: - Helper Methods + + private func createTemporaryFile(content: String) -> URL { + let tempDir = FileManager.default.temporaryDirectory + let tempURL = tempDir.appendingPathComponent("test_\(UUID().uuidString).txt") + + try! content.write(to: tempURL, atomically: true, encoding: .utf8) + + return tempURL + } +}