feat: Working on cli client and tests
Some checks failed
CI / Run Tests (push) Failing after 3m7s

This commit is contained in:
2024-11-17 22:23:44 -05:00
parent 6472d3cd1e
commit ce18c44363
12 changed files with 287 additions and 73 deletions

View File

@@ -17,7 +17,6 @@
},
"testTargets" : [
{
"parallelizable" : true,
"target" : {
"containerPath" : "container:",
"identifier" : "IntegrationTests",

View File

@@ -42,6 +42,15 @@ let package = Package(
.product(name: "MQTTNIO", package: "mqtt-nio")
]
),
.testTarget(
name: "CliClientTests",
dependencies: [
"CliClient"
],
resources: [
.copy("test.env")
]
),
.executableTarget(
name: "DewPointController",
dependencies: [

View File

@@ -14,25 +14,38 @@ public extension DependencyValues {
}
}
/// Represents the interface needed for the command line application.
///
///
@DependencyClient
public struct CliClient {
/// Parse a log level from the given `EnvVars`.
public var logLevel: @Sendable (EnvVars) -> Logger.Level = { _ in .debug }
/// Generate the `EnvVars` with the given parameters.
public var makeEnvVars: @Sendable (EnvVarsRequest) async throws -> EnvVars
/// Generate the `MQTTClient` with the given parameters.
public var makeClient: @Sendable (ClientRequest) throws -> MQTTClient
/// Attempt to parse a string to an `MQTTClient.Version`.
public var parseMqttClientVersion: @Sendable (String) -> MQTTClient.Version?
/// Represents the parameters needed to create an `MQTTClient`.
///
///
public struct ClientRequest: Sendable {
public let envVars: EnvVars
public let environment: EnvVars
public let eventLoopGroup: MultiThreadedEventLoopGroup
public let logger: Logger?
public init(
envVars: EnvVars,
environment: EnvVars,
eventLoopGroup: MultiThreadedEventLoopGroup,
logger: Logger?
) {
self.envVars = envVars
self.environment = environment
self.eventLoopGroup = eventLoopGroup
self.logger = logger
}
@@ -63,7 +76,7 @@ extension CliClient: DependencyKey {
Self(
logLevel: { Logger.Level.from(environment: $0) },
makeEnvVars: {
try EnvVars.load(
try await EnvVars.load(
dotEnvFile: $0.envFilePath,
logger: $0.logger,
version: $0.mqttClientVersion
@@ -71,7 +84,7 @@ extension CliClient: DependencyKey {
},
makeClient: {
MQTTClient(
envVars: $0.envVars,
environment: $0.environment,
eventLoopGroup: $0.eventLoopGroup,
logger: $0.logger
)
@@ -81,32 +94,44 @@ extension CliClient: DependencyKey {
}
}
// MARK: - Helpers
extension EnvironmentDependency {
func dotEnvDict(path: String?) async throws -> [String: String] {
guard let path,
let file = FileType(path: path)
else { return [:] }
return try await load(file)
}
}
extension EnvVars {
/// Load the `EnvVars` from the environment.
///
/// - Paramaters:
/// - dotEnvFile: An optional environment file to load.
/// - logger: An optional logger to use for debugging.
/// - version: A version that is specified from command line, ignoring any environment variable.
static func load(
dotEnvFile: String?,
logger: Logger?,
version: String?
) throws -> EnvVars {
) async throws -> EnvVars {
@Dependency(\.environment) var environment
let defaultEnvVars = EnvVars()
let encoder = JSONEncoder()
let decoder = JSONDecoder()
if let dotEnvFile {
try DotEnv.load(path: dotEnvFile)
}
let defaultEnvDict = (try? encoder.encode(defaultEnvVars))
.flatMap { try? decoder.decode([String: String].self, from: $0) }
?? [:]
let dotEnvDict = try await environment.dotEnvDict(path: dotEnvFile)
let envVarsDict = defaultEnvDict
.merging(ProcessInfo.processInfo.environment, uniquingKeysWith: { $1 })
.merging(environment.processInfo(), uniquingKeysWith: { $1 })
.merging(dotEnvDict, uniquingKeysWith: { $1 })
var envVars = (try? JSONSerialization.data(withJSONObject: envVarsDict))
.flatMap { try? decoder.decode(EnvVars.self, from: $0) }
@@ -125,7 +150,7 @@ extension EnvVars {
@_spi(Internal)
public extension MQTTClient {
convenience init(
envVars: EnvVars,
environment envVars: EnvVars,
eventLoopGroup: EventLoopGroup,
logger: Logger?
) {

View File

@@ -0,0 +1,71 @@
import Dependencies
import DependenciesMacros
import DotEnv
import Foundation
import Models
@_spi(Internal)
public extension DependencyValues {
var environment: EnvironmentDependency {
get { self[EnvironmentDependency.self] }
set { self[EnvironmentDependency.self] = newValue }
}
}
/// Responsible for loading environment variables and files.
///
///
@_spi(Internal)
@DependencyClient
public struct EnvironmentDependency: Sendable {
/// Load the variables based on the request.
public var load: @Sendable (FileType) async throws -> [String: String] = { _ in [:] }
public var processInfo: @Sendable () -> [String: String] = { [:] }
public enum FileType: Equatable {
case dotEnv(path: String)
case json(path: String)
init?(path: String) {
let strings = path.split(separator: ".")
guard let ext = strings.last else {
return nil
}
switch ext {
case "env":
self = .dotEnv(path: path)
case "json":
self = .json(path: path)
default:
return nil
}
}
}
}
@_spi(Internal)
extension EnvironmentDependency: DependencyKey {
public static let testValue: EnvironmentDependency = Self()
public static let liveValue: EnvironmentDependency = Self(
load: { file in
switch file {
case let .dotEnv(path: path):
let file = try DotEnv.read(path: path)
return file.lines.reduce(into: [String: String]()) { partialResult, line in
partialResult[line.key] = line.value
}
case let .json(path: path):
let url = URL(filePath: path)
return try JSONDecoder().decode(
[String: String].self,
from: Data(contentsOf: url)
)
}
},
processInfo: { ProcessInfo.processInfo.environment }
)
}

View File

@@ -13,11 +13,11 @@ extension Application {
static let configuration: CommandConfiguration = .init(
commandName: "debug",
abstract: "Debug the environment variables."
abstract: "Debug the environment variables and command line arguments."
)
@OptionGroup
var shared: SharedOptions
var options: SharedOptions
@Flag(
name: [.customLong("show-password")],
@@ -31,7 +31,7 @@ extension Application {
print("--------------------------")
print("Running debug command...")
if let envFile = shared.envFile {
if let envFile = options.envFile {
print("Reading env file: \(envFile)")
print("--------------------------")
} else {
@@ -41,12 +41,12 @@ extension Application {
print("Loading EnvVars")
print("--------------------------")
let envVars = try await client.makeEnvVars(shared.envVarsRequest(logger: logger))
let envVars = try await client.makeEnvVars(options.envVarsRequest(logger: logger))
printEnvVars(envVars: envVars, showPassword: showPassword)
print("--------------------------")
if let logLevel = shared.logLevel, let level = logLevel() {
print("Log Level option: \(level)")
if let logLevel = options.logLevel {
print("Log Level option: \(logLevel)")
print("--------------------------")
} else {
print("Log Level option: nil")

View File

@@ -23,14 +23,13 @@ extension Application {
)
@OptionGroup
var shared: SharedOptions
var options: SharedOptions
mutating func run() async throws {
@Dependency(\.cliClient) var cliClient
let eventloopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1)
var logger = Logger(label: "dewpoint-controller")
let mqtt = try await setup(eventLoopGroup: eventloopGroup, logger: &logger)
let (mqtt, logger) = try await cliClient.setupRun(options: options)
logger.info("Setting up environment...")
do {
try await withDependencies {
@@ -48,6 +47,9 @@ extension Application {
gracefulShutdownSignals: [.sigterm, .sigint],
logger: logger
)
// These settings prevent services from running forever after we've
// received a shutdown signal. In general it should not needed unless the
// services don't shutdown their async streams properly.
serviceGroupConfiguration.maximumCancellationDuration = .seconds(5)
serviceGroupConfiguration.maximumGracefulShutdownDuration = .seconds(10)
@@ -57,30 +59,32 @@ extension Application {
try await serviceGroup.run()
}
// Here we've received a shutdown signal and shutdown all the
// services.
try await mqtt.shutdown()
try await eventloopGroup.shutdownGracefully()
} catch {
try await eventloopGroup.shutdownGracefully()
// If something fails, shutdown the mqtt client.
try await mqtt.shutdown()
}
}
}
}
private func setup(
eventLoopGroup: MultiThreadedEventLoopGroup,
logger: inout Logger
) async throws -> MQTTClient {
@Dependency(\.cliClient) var cliClient
let environment = try await cliClient.makeEnvVars(shared.envVarsRequest(logger: logger))
logger.logLevel = cliClient.logLevel(environment)
return try cliClient.makeClient(.init(
envVars: environment,
private extension CliClient {
func setupRun(
eventLoopGroup: MultiThreadedEventLoopGroup = .init(numberOfThreads: 1),
loggerLabel: String = "dewpoint-controller",
options: Application.SharedOptions
) async throws -> (MQTTClient, Logger) {
var logger = Logger(label: loggerLabel)
let environment = try await makeEnvVars(options.envVarsRequest(logger: logger))
logger.logLevel = logLevel(environment)
let client = try makeClient(.init(
environment: environment,
eventLoopGroup: eventLoopGroup,
logger: logger
))
}
}
return (client, logger)
}
}
// MARK: - Helpers

View File

@@ -17,7 +17,7 @@ extension Application {
name: [.short, .customLong("log-level")],
help: "Set the logging level."
)
var logLevel: LogLevelContainer?
var logLevelContainer: LogLevelContainer?
@Option(
name: [.short, .long],
@@ -29,6 +29,8 @@ extension Application {
.init(envFilePath: envFile, logger: logger, version: version)
}
var logLevel: Logger.Level? { logLevelContainer?.logLevel }
}
}

View File

@@ -1,10 +0,0 @@
import Models
@_spi(Internal)
public extension Array where Element == TemperatureAndHumiditySensor {
static var live: Self {
TemperatureAndHumiditySensor.Location.allCases.map { location in
TemperatureAndHumiditySensor(location: location)
}
}
}

View File

@@ -139,3 +139,11 @@ public struct TemperatureAndHumiditySensor: Identifiable, Sendable {
}
}
}
public extension Array where Element == TemperatureAndHumiditySensor {
static var live: Self {
TemperatureAndHumiditySensor.Location.allCases.map {
TemperatureAndHumiditySensor(location: $0)
}
}
}

View File

@@ -0,0 +1,99 @@
@_spi(Internal) import CliClient
import Dependencies
import Foundation
import Logging
import Models
import MQTTNIO
import Testing
@Test
func checkTesting() {
#expect(Bool(true))
}
@Test(
arguments: [
(MQTTClient.Version.v3_1_1, ["3", "3.1", "3.1.1", "00367894"]),
(MQTTClient.Version.v5_0, ["5", "5.1", "5.1.1", "00000500012"]),
(nil, ["0", "0.1", "0.1.1", "0000000001267894", "blob"])
]
)
func checkParseMQTTVersion(
version: MQTTClient.Version?,
strings: [String]
) {
withDependencies {
$0.cliClient = .liveValue
} operation: {
@Dependency(\.cliClient) var cliClient
for string in strings {
#expect(cliClient.parseMqttClientVersion(string) == version)
#expect(cliClient.parseMqttClientVersion("v\(string)") == version)
}
}
}
@Test(
arguments: [
(Logger.Level.debug, EnvVars(appEnv: .staging, logLevel: nil)),
(Logger.Level.debug, EnvVars(appEnv: .development, logLevel: nil)),
(Logger.Level.info, EnvVars(appEnv: .production, logLevel: nil)),
(Logger.Level.trace, EnvVars(appEnv: .testing, logLevel: nil)),
(Logger.Level.info, EnvVars(appEnv: .staging, logLevel: .info)),
(Logger.Level.trace, EnvVars(appEnv: .development, logLevel: .trace)),
(Logger.Level.warning, EnvVars(appEnv: .production, logLevel: .warning)),
(Logger.Level.debug, EnvVars(appEnv: .testing, logLevel: .debug))
]
)
func logLevelFromEnvVars(expectedLevel: Logger.Level, environment: EnvVars) {
withDependencies {
$0.cliClient = .liveValue
} operation: {
@Dependency(\.cliClient) var cliClient
#expect(cliClient.logLevel(environment) == expectedLevel)
}
}
@Test(
arguments: [
(
CliClient.EnvVarsRequest(envFilePath: nil, logger: nil, version: nil),
EnvVars()
),
(
CliClient.EnvVarsRequest(envFilePath: nil, logger: nil, version: "3"),
EnvVars(version: "3")
),
(
CliClient.EnvVarsRequest(
envFilePath: "Tests/CliClientTests/test.env",
logger: nil,
version: nil
),
EnvVars(
appEnv: .testing,
host: "test.mqtt",
port: "1234",
identifier: "testing-mqtt",
userName: "test-user",
password: "super-secret",
logLevel: .debug,
version: "5.0"
)
)
]
)
func checkMakeEnvVars(
request: CliClient.EnvVarsRequest,
expectedEnvVars: EnvVars
) async throws {
try await withDependencies {
$0.cliClient = .liveValue
$0.environment = .liveValue
$0.environment.processInfo = { [:] }
} operation: {
@Dependency(\.cliClient) var cliClient
let result = try await cliClient.makeEnvVars(request)
#expect(result == expectedEnvVars)
}
}

View File

@@ -0,0 +1,8 @@
APP_ENV="testing"
MQTT_HOST="test.mqtt"
MQTT_PORT="1234"
MQTT_IDENTIFIER="testing-mqtt"
MQTT_USERNAME="test-user"
MQTT_PASSWORD="super-secret"
LOG_LEVEL="debug"
MQTT_VERSION="5.0"

View File

@@ -14,7 +14,6 @@ import ServiceLifecycleTestKit
import XCTest
final class IntegrationTests: XCTestCase {
static let hostname = ProcessInfo.processInfo.environment["MOSQUITTO_SERVER"] ?? "localhost"
static let logger: Logger = {
@@ -25,7 +24,6 @@ final class IntegrationTests: XCTestCase {
override func invokeTest() {
let client = createClient(identifier: "\(Self.self)")
withDependencies {
$0.mqtt = .live(client: client, logger: Self.logger)
$0.psychrometricClient = PsychrometricClient.liveValue
@@ -36,7 +34,7 @@ final class IntegrationTests: XCTestCase {
func testConnectionServiceShutdown() async throws {
@Dependency(\.mqtt) var mqtt
do {
let service = MQTTConnectionService(logger: Self.logger)
let task = Task { try await service.run() }
defer { task.cancel() }
@@ -50,11 +48,14 @@ final class IntegrationTests: XCTestCase {
mqtt.shutdown()
try await Task.sleep(for: .milliseconds(500))
// check the connection is active here.
try await mqtt.withClient { client in
XCTAssertFalse(client.isActive())
}
} catch {
mqtt.shutdown()
try await Task.sleep(for: .milliseconds(500))
}
}
func testMQTTConnectionStream() async throws {
@@ -188,11 +189,9 @@ final class IntegrationTests: XCTestCase {
configuration: config
)
}
}
// - MARK: Helpers
struct TopicNotFoundError: Error {}
actor ResultContainer: Sendable {