diff --git a/Sources/DatabaseClient/Internal/Projects.swift b/Sources/DatabaseClient/Internal/Projects.swift index e4a2bd9..239cc25 100644 --- a/Sources/DatabaseClient/Internal/Projects.swift +++ b/Sources/DatabaseClient/Internal/Projects.swift @@ -21,28 +21,7 @@ extension DatabaseClient.Projects: TestDependencyKey { try await model.delete(on: database) }, detail: { id in - guard - let model = try await ProjectModel.query(on: database) - .with(\.$componentLosses) - .with(\.$equipment) - .with(\.$equivalentLengths) - .with(\.$rooms) - .with( - \.$trunks, - { trunk in - trunk.with( - \.$rooms, - { - $0.with(\.$room) - } - ) - } - ) - .filter(\.$id == id) - .first() - else { - throw NotFoundError() - } + let model = try await ProjectModel.fetchDetail(for: id, on: database) // TODO: Different error ?? guard let equipmentInfo = model.equipment else { return nil } @@ -62,44 +41,25 @@ extension DatabaseClient.Projects: TestDependencyKey { try await ProjectModel.find(id, on: database).map { try $0.toDTO() } }, getCompletedSteps: { id in - let roomsCount = try await RoomModel.query(on: database) - .with(\.$project) - .filter(\.$project.$id == id) - .count() - - let equivalentLengths = try await EffectiveLengthModel.query(on: database) - .with(\.$project) - .filter(\.$project.$id == id) - .all() - + let model = try await ProjectModel.fetchDetail(for: id, on: database) var equivalentLengthsCompleted = false - if equivalentLengths.filter({ $0.type == "supply" }).first != nil, - equivalentLengths.filter({ $0.type == "return" }).first != nil + if model.equivalentLengths.filter({ $0.type == "supply" }).first != nil, + model.equivalentLengths.filter({ $0.type == "return" }).first != nil { equivalentLengthsCompleted = true } - let componentLosses = try await ComponentLossModel.query(on: database) - .with(\.$project) - .filter(\.$project.$id == id) - .count() - - let equipmentInfo = try await EquipmentModel.query(on: database) - .with(\.$project) - .filter(\.$project.$id == id) - .first() - return .init( - equipmentInfo: equipmentInfo != nil, - rooms: roomsCount > 0, + equipmentInfo: model.equipment != nil, + rooms: model.rooms.count > 0, equivalentLength: equivalentLengthsCompleted, - frictionRate: componentLosses > 0 + frictionRate: model.componentLosses.count > 0 ) }, getSensibleHeatRatio: { id in guard - let shr = try await ProjectModel.query(on: database) + let model = try await ProjectModel.query(on: database) .field(\.$id) .field(\.$sensibleHeatRatio) .filter(\.$id == id) @@ -107,7 +67,7 @@ extension DatabaseClient.Projects: TestDependencyKey { else { throw NotFoundError() } - return shr.sensibleHeatRatio + return model.sensibleHeatRatio }, fetch: { userID, request in try await ProjectModel.query(on: database) @@ -227,7 +187,7 @@ extension Project { .field("sensibleHeatRatio", .double) .field("createdAt", .datetime) .field("updatedAt", .datetime) - .field("userID", .uuid, .required, .references(UserModel.schema, "id")) + .field("userID", .uuid, .required, .references(UserModel.schema, "id", onDelete: .cascade)) .unique(on: "userID", "name") .create() } @@ -350,4 +310,35 @@ final class ProjectModel: Model, @unchecked Sendable { self.sensibleHeatRatio = sensibleHeatRatio } } + + /// Returns a ``ProjectModel`` with all the relations eagerly loaded. + static func fetchDetail( + for projectID: Project.ID, + on database: any Database + ) async throws -> ProjectModel { + guard + let model = + try await ProjectModel.query(on: database) + .with(\.$componentLosses) + .with(\.$equipment) + .with(\.$equivalentLengths) + .with(\.$rooms) + .with( + \.$trunks, + { trunk in + trunk.with( + \.$rooms, + { + $0.with(\.$room) + } + ) + } + ) + .filter(\.$id == projectID) + .first() + else { + throw NotFoundError() + } + return model + } } diff --git a/Sources/DatabaseClient/Internal/Users.swift b/Sources/DatabaseClient/Internal/Users.swift index bb92c27..efcaa48 100644 --- a/Sources/DatabaseClient/Internal/Users.swift +++ b/Sources/DatabaseClient/Internal/Users.swift @@ -33,6 +33,11 @@ extension DatabaseClient.Users: TestDependencyKey { throw NotFoundError() } + // Verify the password matches the user's hashed password. + guard try user.verifyPassword(request.password) else { + throw Abort(.unauthorized) + } + let token: User.Token // Check if there's a user token diff --git a/Sources/ManualDCore/EquivalentLength.swift b/Sources/ManualDCore/EquivalentLength.swift index 9c29bad..c7f3fad 100644 --- a/Sources/ManualDCore/EquivalentLength.swift +++ b/Sources/ManualDCore/EquivalentLength.swift @@ -160,16 +160,16 @@ extension EquivalentLength { /// The longest return equivalent length. public let `return`: EquivalentLength? + public init(supply: EquivalentLength? = nil, return: EquivalentLength? = nil) { + self.supply = supply + self.return = `return` + } + public var totalEquivalentLength: Double? { guard let supply else { return nil } guard let `return` else { return nil } return supply.totalEquivalentLength + `return`.totalEquivalentLength } - - public init(supply: EquivalentLength? = nil, return: EquivalentLength? = nil) { - self.supply = supply - self.return = `return` - } } } diff --git a/Tests/DatabaseClientTests/Helpers.swift b/Tests/DatabaseClientTests/Helpers.swift index fd3abbd..fcc85d2 100644 --- a/Tests/DatabaseClientTests/Helpers.swift +++ b/Tests/DatabaseClientTests/Helpers.swift @@ -4,10 +4,11 @@ import Dependencies import Fluent import FluentSQLiteDriver import Foundation +import ManualDCore import NIO import Vapor -// Helper to create an in-memory database for testing. +// Helper to create an in-memory database used for testing. func withDatabase( setupDependencies: (inout DependencyValues) -> Void = { _ in }, operation: () async throws -> Void @@ -37,3 +38,18 @@ func withDatabase( } } + +/// Set's up the database and a test user for running tests that require a +/// a user. +func withTestUser( + setupDependencies: (inout DependencyValues) -> Void = { _ in }, + operation: (User) async throws -> Void +) async throws { + try await withDatabase(setupDependencies: setupDependencies) { + @Dependency(\.database.users) var users + let user = try await users.create( + .init(email: "testy@example.com", password: "super-secret", confirmPassword: "super-secret") + ) + try await operation(user) + } +} diff --git a/Tests/DatabaseClientTests/ProjectTests.swift b/Tests/DatabaseClientTests/ProjectTests.swift index 226d3e2..b91c203 100644 --- a/Tests/DatabaseClientTests/ProjectTests.swift +++ b/Tests/DatabaseClientTests/ProjectTests.swift @@ -12,18 +12,155 @@ import Vapor struct ProjectTests { @Test - func sanity() { - #expect(Bool(true)) + func projectHappyPaths() async throws { + try await withTestUser { user in + @Dependency(\.database.projects) var projects + + let project = try await projects.create(user.id, .mock) + + let got = try await projects.get(project.id) + #expect(got == project) + + let page = try await projects.fetch(user.id, .init(page: 1, per: 25)) + #expect(page.items.first! == project) + + let updated = try await projects.update(project.id, .init(sensibleHeatRatio: 0.83)) + #expect(updated.sensibleHeatRatio == 0.83) + #expect(updated.id == project.id) + + let shr = try await projects.getSensibleHeatRatio(project.id) + #expect(shr == 0.83) + + try await projects.delete(project.id) + + } + + } + + @Test + func notFound() async throws { + try await withDatabase { + @Dependency(\.database.projects) var projects + + await #expect(throws: NotFoundError.self) { + try await projects.delete(UUID(0)) + } + + await #expect(throws: NotFoundError.self) { + try await projects.update(UUID(0), .init(name: "Foo")) + } + + await #expect(throws: NotFoundError.self) { + try await projects.getSensibleHeatRatio(UUID(0)) + } + + await #expect(throws: NotFoundError.self) { + try await projects.getCompletedSteps(UUID(0)) + } + } + } + + @Test + func completedSteps() async throws { + try await withTestUser { user in + @Dependency(\.database) var database + + let project = try await database.projects.create(user.id, .mock) + + var completed = try await database.projects.getCompletedSteps(project.id) + #expect(completed.equipmentInfo == false) + #expect(completed.equivalentLength == false) + #expect(completed.frictionRate == false) + #expect(completed.rooms == false) + + _ = try await database.equipment.create( + .init(projectID: project.id, heatingCFM: 1000, coolingCFM: 1000) + ) + completed = try await database.projects.getCompletedSteps(project.id) + #expect(completed.equipmentInfo == true) + + _ = try await database.componentLosses.create( + .init(projectID: project.id, name: "Test", value: 0.2) + ) + completed = try await database.projects.getCompletedSteps(project.id) + #expect(completed.frictionRate == true) + + _ = try await database.rooms.create( + .init(projectID: project.id, name: "Test", heatingLoad: 12345, coolingTotal: 12345) + ) + completed = try await database.projects.getCompletedSteps(project.id) + #expect(completed.rooms == true) + + _ = try await database.equivalentLengths.create( + .init( + projectID: project.id, name: "Supply", type: .supply, straightLengths: [1], groups: []) + ) + completed = try await database.projects.getCompletedSteps(project.id) + // Should not be complete until we have both return and supply for a project. + #expect(completed.equivalentLength == false) + + _ = try await database.equivalentLengths.create( + .init( + projectID: project.id, name: "Return", type: .return, straightLengths: [1], groups: []) + ) + completed = try await database.projects.getCompletedSteps(project.id) + #expect(completed.equipmentInfo == true) + #expect(completed.equivalentLength == true) + #expect(completed.frictionRate == true) + #expect(completed.rooms == true) + + } + } + + @Test + func detail() async throws { + try await withTestUser { user in + @Dependency(\.database) var database + let project = try await database.projects.create(user.id, .mock) + + var detail = try await database.projects.detail(project.id) + #expect(detail == nil) + + let equipment = try await database.equipment.create( + .init(projectID: project.id, heatingCFM: 1000, coolingCFM: 1000) + ) + detail = try await database.projects.detail(project.id) + #expect(detail != nil) + + let componentLoss = try await database.componentLosses.create( + .init(projectID: project.id, name: "Test", value: 0.2) + ) + let room = try await database.rooms.create( + .init(projectID: project.id, name: "Test", heatingLoad: 12345, coolingTotal: 12345) + ) + let supplyLength = try await database.equivalentLengths.create( + .init( + projectID: project.id, name: "Supply", type: .supply, straightLengths: [1], groups: []) + ) + let returnLength = try await database.equivalentLengths.create( + .init( + projectID: project.id, name: "Return", type: .return, straightLengths: [1], groups: []) + ) + detail = try await database.projects.detail(project.id) + #expect(detail?.componentLosses == [componentLoss]) + #expect(detail?.equipmentInfo == equipment) + #expect(detail?.rooms == [room]) + #expect(detail?.equivalentLengths.contains(supplyLength) == true) + #expect(detail?.equivalentLengths.contains(returnLength) == 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 - // } - // } +} + +extension Project.Create { + + static let mock = Self( + name: "Testy McTestface", + streetAddress: "1234 Sesame St", + city: "Nowhere", + state: "MN", + zipCode: "55555", + sensibleHeatRatio: 0.83 + ) } diff --git a/Tests/DatabaseClientTests/UserTests.swift b/Tests/DatabaseClientTests/UserTests.swift index 6756fb1..a47f3d4 100644 --- a/Tests/DatabaseClientTests/UserTests.swift +++ b/Tests/DatabaseClientTests/UserTests.swift @@ -3,6 +3,7 @@ import Dependencies import Foundation import ManualDCore import Testing +import Vapor @testable import DatabaseClient @@ -10,7 +11,7 @@ import Testing struct UserDatabaseTests { @Test - func createUser() async throws { + func happyPaths() async throws { try await withDatabase { @Dependency(\.database.users) var users @@ -22,8 +23,14 @@ struct UserDatabaseTests { // Test login the user in let token = try await users.login( - .init(email: "testy@example.com", password: "super-secret") + .init(email: user.email, password: "super-secret") ) + #expect(token.userID == user.id) + // Test the same token is returned. + let token2 = try await users.login( + .init(email: user.email, password: "super-secret") + ) + #expect(token.id == token2.id) // Test logging out try await users.logout(token.id) @@ -31,7 +38,6 @@ struct UserDatabaseTests { let shouldBeNilUser = try await users.get(user.id) #expect(shouldBeNilUser == nil) - } } @@ -73,4 +79,73 @@ struct UserDatabaseTests { } } + @Test + func loginFails() async throws { + try await withDatabase { + @Dependency(\.database.users) var users + + await #expect(throws: NotFoundError.self) { + try await users.login( + .init(email: "foo@example.com", password: "super-secret") + ) + + } + + let user = try await users.create( + .init(email: "testy@example.com", password: "super-secret", confirmPassword: "super-secret") + ) + + // Ensure can not login with invalid password + await #expect(throws: Abort.self) { + try await users.login( + .init(email: user.email, password: "wrong-password") + ) + } + } + } + + @Test + func userProfileHappyPath() async throws { + try await withTestUser { user in + @Dependency(\.database.userProfiles) var profiles + let profile = try await profiles.create( + .init( + userID: user.id, + firstName: "Testy", + lastName: "McTestface", + companyName: "Acme Co.", + streetAddress: "12345 Sesame St", + city: "Nowhere", + state: "FL", + zipCode: "55555" + ) + ) + + let fetched = try await profiles.fetch(user.id) + #expect(fetched == profile) + + let got = try await profiles.get(profile.id) + #expect(got == profile) + + let updated = try await profiles.update(profile.id, .init(firstName: "Updated")) + #expect(updated.firstName == "Updated") + #expect(updated.id == profile.id) + + try await profiles.delete(profile.id) + } + } + + @Test + func testUserProfileFails() async throws { + try await withDatabase { + @Dependency(\.database.userProfiles) var profiles + await #expect(throws: NotFoundError.self) { + try await profiles.delete(UUID(0)) + } + await #expect(throws: NotFoundError.self) { + try await profiles.update(UUID(0), .init(firstName: "Foo")) + } + } + } + } diff --git a/justfile b/justfile index 0c5da50..1d79213 100644 --- a/justfile +++ b/justfile @@ -29,5 +29,5 @@ code-coverage: -ignore-filename-regex=".build|Tests" \ -use-color -test: - @swift test --enable-code-coverage +test *ARGS: + @swift test --enable-code-coverage {{ARGS}}