diff --git a/.gitignore b/.gitignore index bfd357a..6ae492c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ tailwindcss *.pdf .env .env* +default.profraw diff --git a/Package.swift b/Package.swift index 985690c..17066e6 100644 --- a/Package.swift +++ b/Package.swift @@ -87,7 +87,15 @@ let package = Package( .product(name: "Vapor", package: "vapor"), ] ), - + .testTarget( + name: "DatabaseClientTests", + dependencies: [ + .target(name: "App"), + .target(name: "DatabaseClient"), + .product(name: "DependenciesTestSupport", package: "swift-dependencies"), + .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"), + ] + ), .target( name: "EnvClient", dependencies: [ diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift index 53479b1..06f5041 100644 --- a/Sources/App/configure.swift +++ b/Sources/App/configure.swift @@ -65,9 +65,7 @@ private func setupDatabase( let databaseClient = makeDatabaseClient(app.db) - if app.environment != .testing { - try await app.migrations.add(databaseClient.migrations()) - } + try await app.migrations.add(databaseClient.migrations()) return databaseClient } diff --git a/Sources/DatabaseClient/Internal/ComponentLosses.swift b/Sources/DatabaseClient/Internal/ComponentLosses.swift index e140fe3..0eb58e7 100644 --- a/Sources/DatabaseClient/Internal/ComponentLosses.swift +++ b/Sources/DatabaseClient/Internal/ComponentLosses.swift @@ -3,6 +3,7 @@ import DependenciesMacros import Fluent import Foundation import ManualDCore +import SQLKit extension DatabaseClient.ComponentLosses: TestDependencyKey { public static let testValue = Self() diff --git a/Sources/HTMLSnapshotTesting/Snapshotting.swift b/Sources/HTMLSnapshotTesting/Snapshotting.swift index fa11701..93a52d6 100644 --- a/Sources/HTMLSnapshotTesting/Snapshotting.swift +++ b/Sources/HTMLSnapshotTesting/Snapshotting.swift @@ -11,12 +11,12 @@ extension Snapshotting where Value == (any HTML), Format == String { } } -extension Snapshotting where Value == String, Format == String { - public static var html: Snapshotting { - var snapshotting = SimplySnapshotting.lines - .pullback { $0 } - - snapshotting.pathExtension = "html" - return snapshotting - } -} +// extension Snapshotting where Value == String, Format == String { +// public static var html: Snapshotting { +// var snapshotting = SimplySnapshotting.lines +// .pullback { $0 } +// +// snapshotting.pathExtension = "html" +// return snapshotting +// } +// } diff --git a/Sources/PdfClient/Interface.swift b/Sources/PdfClient/Interface.swift index 1bc3bcd..a995068 100644 --- a/Sources/PdfClient/Interface.swift +++ b/Sources/PdfClient/Interface.swift @@ -32,8 +32,7 @@ public struct PdfClient: Sendable { /// - Parameters: /// - request: The project data used to generate the pdf. public func generatePdf(request: Request) async throws -> Response { - let html = try await self.html(request) - return try await self.generatePdf(request.project.id, html) + try await self.generatePdf(request.project.id, html(request)) } } @@ -76,17 +75,26 @@ extension PdfClient: DependencyKey { } extension PdfClient { - /// Container for the data required to generate a pdf for a given project. + /// Represents the data required to generate a pdf for a given project. public struct Request: Codable, Equatable, Sendable { + /// The project we're generating a pdf for. public let project: Project + /// The rooms in the project. public let rooms: [Room] + /// The component pressure losses for the project. public let componentLosses: [ComponentPressureLoss] + /// The calculated duct sizes for the project. public let ductSizes: DuctSizes + /// The equipment information for the project. public let equipmentInfo: EquipmentInfo + /// The max supply equivalent length for the project. public let maxSupplyTEL: EquivalentLength + /// The max return equivalent length for the project. public let maxReturnTEL: EquivalentLength + /// The calculated design friction rate for the project. public let frictionRate: FrictionRate + /// The project wide sensible heat ratio. public let projectSHR: Double var totalEquivalentLength: Double { @@ -116,9 +124,12 @@ extension PdfClient { } } + /// Represents the response after generating a pdf. public struct Response: Equatable, Sendable { + /// The path to the html file used to generate the pdf from. public let htmlPath: String + /// The path to the pdf file. public let pdfPath: String public init(htmlPath: String, pdfPath: String) { diff --git a/Sources/ProjectClient/DuctCalcClientError.swift b/Sources/ProjectClient/DuctCalcClientError.swift deleted file mode 100644 index 1e676cb..0000000 --- a/Sources/ProjectClient/DuctCalcClientError.swift +++ /dev/null @@ -1,9 +0,0 @@ -import Foundation - -public struct ProjectClientError: Error { - public let reason: String - - public init(_ reason: String) { - self.reason = reason - } -} diff --git a/Sources/ProjectClient/Interface.swift b/Sources/ProjectClient/Interface.swift index 41d798b..53bb4a3 100644 --- a/Sources/ProjectClient/Interface.swift +++ b/Sources/ProjectClient/Interface.swift @@ -18,9 +18,12 @@ extension DependencyValues { /// for the view controller to render views. @DependencyClient public struct ProjectClient: Sendable { - public var calculateDuctSizes: @Sendable (Project.ID) async throws -> DuctSizes + + /// Calculates the room duct sizes for the given project. public var calculateRoomDuctSizes: @Sendable (Project.ID) async throws -> [DuctSizes.RoomContainer] + + /// Calculates the trunk duct sizes for the given project. public var calculateTrunkDuctSizes: @Sendable (Project.ID) async throws -> [DuctSizes.TrunkContainer] @@ -28,6 +31,14 @@ public struct ProjectClient: Sendable { @Sendable (User.ID, Project.Create) async throws -> CreateProjectResponse public var frictionRate: @Sendable (Project.ID) async throws -> FrictionRateResponse + public var generatePdf: @Sendable (Project.ID) async throws -> Response + + public func calculateDuctSizes(_ projectID: Project.ID) async throws -> DuctSizes { + .init( + rooms: try await calculateRoomDuctSizes(projectID), + trunks: try await calculateTrunkDuctSizes(projectID) + ) + } } extension ProjectClient: TestDependencyKey { diff --git a/Sources/ProjectClient/Internal/DatabaseClient+calculateDuctSizes.swift b/Sources/ProjectClient/Internal/DatabaseClient+calculateDuctSizes.swift index d5ce992..d0ff608 100644 --- a/Sources/ProjectClient/Internal/DatabaseClient+calculateDuctSizes.swift +++ b/Sources/ProjectClient/Internal/DatabaseClient+calculateDuctSizes.swift @@ -9,32 +9,18 @@ extension DatabaseClient { details: Project.Detail ) async throws -> (DuctSizes, DuctSizeSharedRequest) { let (rooms, shared) = try await calculateRoomDuctSizes(details: details) - let (trunks, _) = try await calculateTrunkDuctSizes(details: details) - return (.init(rooms: rooms, trunks: trunks), shared) - } - - func calculateDuctSizes( - projectID: Project.ID - ) async throws -> (DuctSizes, DuctSizeSharedRequest, [Room]) { - @Dependency(\.manualD) var manualD - - let shared = try await sharedDuctRequest(projectID) - let rooms = try await rooms.fetch(projectID) - return try await ( - manualD.calculateDuctSizes( + .init( rooms: rooms, - trunks: trunkSizes.fetch(projectID), - sharedRequest: shared + trunks: calculateTrunkDuctSizes(details: details, shared: shared) ), - shared, - rooms + shared ) } func calculateRoomDuctSizes( details: Project.Detail - ) async throws -> ([DuctSizes.RoomContainer], DuctSizeSharedRequest) { + ) async throws -> (rooms: [DuctSizes.RoomContainer], shared: DuctSizeSharedRequest) { @Dependency(\.manualD) var manualD let shared = try sharedDuctRequest(details: details) @@ -42,54 +28,29 @@ extension DatabaseClient { return (rooms, shared) } - func calculateRoomDuctSizes( - projectID: Project.ID - ) async throws -> ([DuctSizes.RoomContainer], DuctSizeSharedRequest) { - @Dependency(\.manualD) var manualD - - let shared = try await sharedDuctRequest(projectID) - - return try await ( - manualD.calculateRoomSizes( - rooms: rooms.fetch(projectID), - sharedRequest: shared - ), - shared - ) - } - func calculateTrunkDuctSizes( - details: Project.Detail - ) async throws -> ([DuctSizes.TrunkContainer], DuctSizeSharedRequest) { + details: Project.Detail, + shared: DuctSizeSharedRequest? = nil + ) async throws -> [DuctSizes.TrunkContainer] { @Dependency(\.manualD) var manualD - let shared = try sharedDuctRequest(details: details) - let trunks = try await manualD.calculateTrunkSizes( + let sharedRequest: DuctSizeSharedRequest + if let shared { + sharedRequest = shared + } else { + sharedRequest = try sharedDuctRequest(details: details) + } + + return try await manualD.calculateTrunkSizes( rooms: details.rooms, trunks: details.trunks, - sharedRequest: shared - ) - return (trunks, shared) - } - - func calculateTrunkDuctSizes( - projectID: Project.ID - ) async throws -> ([DuctSizes.TrunkContainer], DuctSizeSharedRequest) { - @Dependency(\.manualD) var manualD - - let shared = try await sharedDuctRequest(projectID) - - return try await ( - manualD.calculateTrunkSizes( - rooms: rooms.fetch(projectID), - trunks: trunkSizes.fetch(projectID), - sharedRequest: shared - ), - shared + sharedRequest: sharedRequest ) } func sharedDuctRequest(details: Project.Detail) throws -> DuctSizeSharedRequest { + let projectSHR = try details.project.ensuredSHR() + guard let dfrResponse = designFrictionRate( componentLosses: details.componentLosses, @@ -100,10 +61,6 @@ extension DatabaseClient { throw ProjectClientError("Project not complete.") } - guard let projectSHR = details.project.sensibleHeatRatio else { - throw ProjectClientError("Project sensible heat ratio not set.") - } - let ensuredTEL = try dfrResponse.ensureMaxContainer() return .init( @@ -115,32 +72,6 @@ extension DatabaseClient { ) } - func sharedDuctRequest(_ projectID: Project.ID) async throws -> DuctSizeSharedRequest { - - guard let dfrResponse = try await designFrictionRate(projectID: projectID) else { - throw ProjectClientError("Project not complete.") - } - - let ensuredTEL = try dfrResponse.ensureMaxContainer() - - return try await .init( - equipmentInfo: dfrResponse.equipmentInfo, - maxSupplyLength: ensuredTEL.supply, - maxReturnLenght: ensuredTEL.return, - designFrictionRate: dfrResponse.designFrictionRate, - projectSHR: ensuredSHR(projectID) - ) - - } - - // Fetches the project sensible heat ratio or throws an error if it's nil. - func ensuredSHR(_ projectID: Project.ID) async throws -> Double { - guard let projectSHR = try await projects.getSensibleHeatRatio(projectID) else { - throw ProjectClientError("Project sensible heat ratio not set.") - } - return projectSHR - } - // Internal container. struct DesignFrictionRateResponse: Equatable, Sendable { @@ -183,18 +114,13 @@ extension DatabaseClient { } - func designFrictionRate( - projectID: Project.ID - ) async throws -> DesignFrictionRateResponse? { +} - guard let equipmentInfo = try await equipment.fetch(projectID) else { - return nil +extension Project { + func ensuredSHR() throws -> Double { + guard let shr = sensibleHeatRatio else { + throw ProjectClientError("Sensible heat ratio not set on project id: \(id)") } - - return try await designFrictionRate( - componentLosses: componentLosses.fetch(projectID), - equipmentInfo: equipmentInfo, - equivalentLengths: equivalentLengths.fetchMax(projectID) - ) + return shr } } diff --git a/Sources/ProjectClient/Internal/DatabaseClient+makePdfRequest.swift b/Sources/ProjectClient/Internal/DatabaseClient+makePdfRequest.swift new file mode 100644 index 0000000..f6e1924 --- /dev/null +++ b/Sources/ProjectClient/Internal/DatabaseClient+makePdfRequest.swift @@ -0,0 +1,53 @@ +import DatabaseClient +import Dependencies +import ManualDClient +import ManualDCore +import PdfClient + +extension DatabaseClient { + + /// Generate a pdf request for the given project. + func makePdfRequest(_ projectID: Project.ID) async throws -> PdfClient.Request { + @Dependency(\.manualD) var manualD + + guard let projectDetails = try await projects.detail(projectID) else { + throw ProjectClientError.notFound(.project(projectID)) + } + + let (ductSizes, shared) = try await calculateDuctSizes(details: projectDetails) + + let frictionRateResponse = try await manualD.frictionRate(details: projectDetails) + guard let frictionRate = frictionRateResponse.frictionRate else { + throw ProjectClientError.notFound(.frictionRate(projectID)) + } + + return .init( + details: projectDetails, + ductSizes: ductSizes, + shared: shared, + frictionRate: frictionRate + ) + } + +} + +extension PdfClient.Request { + fileprivate init( + details: Project.Detail, + ductSizes: DuctSizes, + shared: DuctSizeSharedRequest, + frictionRate: FrictionRate + ) { + self.init( + project: details.project, + rooms: details.rooms, + componentLosses: details.componentLosses, + ductSizes: ductSizes, + equipmentInfo: details.equipmentInfo, + maxSupplyTEL: shared.maxSupplyLength, + maxReturnTEL: shared.maxReturnLenght, + frictionRate: frictionRate, + projectSHR: shared.projectSHR + ) + } +} diff --git a/Sources/ProjectClient/Live.swift b/Sources/ProjectClient/Live.swift index fa0e4ee..b13fc5d 100644 --- a/Sources/ProjectClient/Live.swift +++ b/Sources/ProjectClient/Live.swift @@ -15,14 +15,17 @@ extension ProjectClient: DependencyKey { @Dependency(\.fileClient) var fileClient return .init( - calculateDuctSizes: { projectID in - try await database.calculateDuctSizes(projectID: projectID).0 - }, calculateRoomDuctSizes: { projectID in - try await database.calculateRoomDuctSizes(projectID: projectID).0 + guard let details = try await database.projects.detail(projectID) else { + throw ProjectClientError.notFound(.project(projectID)) + } + return try await database.calculateRoomDuctSizes(details: details).rooms }, calculateTrunkDuctSizes: { projectID in - try await database.calculateTrunkDuctSizes(projectID: projectID).0 + guard let details = try await database.projects.detail(projectID) else { + throw ProjectClientError.notFound(.project(projectID)) + } + return try await database.calculateTrunkDuctSizes(details: details) }, createProject: { userID, request in let project = try await database.projects.create(userID, request) @@ -58,50 +61,3 @@ extension ProjectClient: DependencyKey { } } - -extension DatabaseClient { - - fileprivate func makePdfRequest(_ projectID: Project.ID) async throws -> PdfClient.Request { - @Dependency(\.manualD) var manualD - - guard let projectDetails = try await projects.detail(projectID) else { - throw ProjectClientError("Project not found. id: \(projectID)") - } - - let (ductSizes, shared) = try await calculateDuctSizes(details: projectDetails) - - let frictionRateResponse = try await manualD.frictionRate(details: projectDetails) - guard let frictionRate = frictionRateResponse.frictionRate else { - throw ProjectClientError("Friction rate not found. id: \(projectID)") - } - - return .init( - details: projectDetails, - ductSizes: ductSizes, - shared: shared, - frictionRate: frictionRate - ) - } - -} - -extension PdfClient.Request { - init( - details: Project.Detail, - ductSizes: DuctSizes, - shared: DuctSizeSharedRequest, - frictionRate: FrictionRate - ) { - self.init( - project: details.project, - rooms: details.rooms, - componentLosses: details.componentLosses, - ductSizes: ductSizes, - equipmentInfo: details.equipmentInfo, - maxSupplyTEL: shared.maxSupplyLength, - maxReturnTEL: shared.maxReturnLenght, - frictionRate: frictionRate, - projectSHR: shared.projectSHR - ) - } -} diff --git a/Sources/ProjectClient/ProjectClientError.swift b/Sources/ProjectClient/ProjectClientError.swift new file mode 100644 index 0000000..909676c --- /dev/null +++ b/Sources/ProjectClient/ProjectClientError.swift @@ -0,0 +1,35 @@ +import Foundation +import ManualDCore + +public struct ProjectClientError: Error { + public let reason: String + + public init(_ reason: String) { + self.reason = reason + } + + static func notFound(_ notFound: NotFound) -> Self { + .init(notFound.reason) + } + + enum NotFound { + case project(Project.ID) + case frictionRate(Project.ID) + + var reason: String { + switch self { + case .project(let id): + return "Project not found. id: \(id)" + case .frictionRate(let id): + return """ + Friction unable to be calculated. id: \(id) + + This usually means that not all the required steps have been completed. + + Calculating the friction rate requires the component pressure losses to be set and + have a max equivalent length for both the supply and return. + """ + } + } + } +} diff --git a/Tests/DatabaseClientTests/Helpers.swift b/Tests/DatabaseClientTests/Helpers.swift new file mode 100644 index 0000000..fd3abbd --- /dev/null +++ b/Tests/DatabaseClientTests/Helpers.swift @@ -0,0 +1,39 @@ +import App +import DatabaseClient +import Dependencies +import Fluent +import FluentSQLiteDriver +import Foundation +import NIO +import Vapor + +// Helper to create an in-memory database for testing. +func withDatabase( + setupDependencies: (inout DependencyValues) -> Void = { _ in }, + operation: () async throws -> Void +) async throws { + let app = try await Application.make(.testing) + do { + try await configure(app) + let database = app.db + try await app.autoMigrate() + + try await withDependencies { + $0.uuid = .incrementing + $0.date = .init { Date() } + $0.database = .live(database: database) + setupDependencies(&$0) + } operation: { + try await operation() + } + + try await app.autoRevert() + try await app.asyncShutdown() + + } catch { + try? await app.autoRevert() + try await app.asyncShutdown() + throw error + } + +} diff --git a/Tests/DatabaseClientTests/ProjectTests.swift b/Tests/DatabaseClientTests/ProjectTests.swift new file mode 100644 index 0000000..226d3e2 --- /dev/null +++ b/Tests/DatabaseClientTests/ProjectTests.swift @@ -0,0 +1,29 @@ +import Dependencies +import DependenciesTestSupport +import Fluent +import FluentSQLiteDriver +import ManualDCore +import Testing +import Vapor + +@testable import DatabaseClient + +@Suite +struct ProjectTests { + + @Test + func sanity() { + #expect(Bool(true)) + } + + // @Test + // func createProject() { + // try await withDatabase(migrations: Project.Migrate()) { + // $0.database.projects = .live(database: $1) + // } operation: { + // @Dependency(\.database.projects) var projects + // + // let project = try await projects.c + // } + // } +} diff --git a/Tests/DatabaseClientTests/UserTests.swift b/Tests/DatabaseClientTests/UserTests.swift new file mode 100644 index 0000000..6756fb1 --- /dev/null +++ b/Tests/DatabaseClientTests/UserTests.swift @@ -0,0 +1,76 @@ +import DatabaseClient +import Dependencies +import Foundation +import ManualDCore +import Testing + +@testable import DatabaseClient + +@Suite +struct UserDatabaseTests { + + @Test + func createUser() async throws { + try await withDatabase { + @Dependency(\.database.users) var users + + let user = try await users.create( + .init(email: "testy@example.com", password: "super-secret", confirmPassword: "super-secret") + ) + + #expect(user.email == "testy@example.com") + + // Test login the user in + let token = try await users.login( + .init(email: "testy@example.com", password: "super-secret") + ) + // Test logging out + try await users.logout(token.id) + + try await users.delete(user.id) + + let shouldBeNilUser = try await users.get(user.id) + #expect(shouldBeNilUser == nil) + + } + } + + @Test + func createUserFails() async throws { + try await withDatabase { + @Dependency(\.database.users) var users + + await #expect(throws: ValidationError.self) { + try await users.create(.init(email: "", password: "", confirmPassword: "")) + } + + await #expect(throws: ValidationError.self) { + try await users.create(.init(email: "testy@example.com", password: "", confirmPassword: "")) + } + + await #expect(throws: ValidationError.self) { + try await users.create( + .init(email: "testy@example.com", password: "super-secret", confirmPassword: "")) + } + } + } + + @Test + func deleteFailsWithInvalidUserID() async throws { + try await withDatabase { + @Dependency(\.database.users) var users + await #expect(throws: NotFoundError.self) { + try await users.delete(UUID(0)) + } + } + } + + @Test + func logoutIgnoresUnfoundTokenID() async throws { + try await withDatabase { + @Dependency(\.database.users) var users + try await users.logout(UUID(0)) + } + } + +} diff --git a/Tests/ViewControllerTests/ViewControllerTests.swift b/Tests/ViewControllerTests/ViewControllerTests.swift index 96178b8..682fd65 100644 --- a/Tests/ViewControllerTests/ViewControllerTests.swift +++ b/Tests/ViewControllerTests/ViewControllerTests.swift @@ -100,6 +100,12 @@ struct ViewControllerTests { ) } + let mockDuctSizes = DuctSizes.mock( + equipmentInfo: equipment, + rooms: rooms, + trunks: trunks + ) + try await withDefaultDependencies { $0.database.projects.get = { _ in project } $0.database.projects.getCompletedSteps = { _ in @@ -113,8 +119,11 @@ struct ViewControllerTests { .init(supply: tels.first, return: tels.last) } $0.database.componentLosses.fetch = { _ in componentLosses } - $0.projectClient.calculateDuctSizes = { _ in - .mock(equipmentInfo: equipment, rooms: rooms, trunks: trunks) + $0.projectClient.calculateRoomDuctSizes = { _ in + mockDuctSizes.rooms + } + $0.projectClient.calculateTrunkDuctSizes = { _ in + mockDuctSizes.trunks } } operation: { @Dependency(\.viewController) var viewController diff --git a/justfile b/justfile index c454cd1..0c5da50 100644 --- a/justfile +++ b/justfile @@ -21,3 +21,13 @@ run-docker: test-docker: (build-docker "docker/Dockerfile.test") @docker run --rm {{docker_image}}:{{docker_tag}} swift test + +code-coverage: + @llvm-cov report \ + "$(find $(swift build --show-bin-path) -name '*.xctest')" \ + -instr-profile=.build/debug/codecov/default.profdata \ + -ignore-filename-regex=".build|Tests" \ + -use-color + +test: + @swift test --enable-code-coverage