From b38dc0d4e3027cc27dfc4e47a912ef3c3b1e3755 Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Mon, 13 Jan 2025 16:33:18 -0500 Subject: [PATCH] feat: Adds basic tests for database client. --- .gitignore | 1 + Package.swift | 12 ++ Sources/DatabaseClientLive/Employees.swift | 18 +- Sources/SharedModels/Employee.swift | 33 ++- Tests/AppTests/AppTests.swift | 164 +++++++-------- .../DatabaseClientTests.swift | 194 ++++++++++++++++++ 6 files changed, 313 insertions(+), 109 deletions(-) create mode 100644 Tests/DatabaseClientTests/DatabaseClientTests.swift diff --git a/.gitignore b/.gitignore index 920fa82..1bda595 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ db.sqlite .env.* ! .env.example .vscode +.nvim diff --git a/Package.swift b/Package.swift index b621c08..7f511cb 100644 --- a/Package.swift +++ b/Package.swift @@ -6,6 +6,11 @@ let package = Package( platforms: [ .macOS(.v14) ], + products: [ + .library(name: "SharedModels", targets: ["SharedModels"]), + .library(name: "DatabaseClient", targets: ["DatabaseClient"]), + .library(name: "DatabaseClientLive", targets: ["DatabaseClientLive"]) + ], dependencies: [ // 💧 A server-side Swift web framework. .package(url: "https://github.com/vapor/vapor.git", from: "4.110.1"), @@ -62,6 +67,13 @@ let package = Package( ], swiftSettings: swiftSettings ), + .testTarget( + name: "DatabaseClientTests", + dependencies: [ + "DatabaseClientLive", + .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver") + ] + ), .target( name: "SharedModels", dependencies: [ diff --git a/Sources/DatabaseClientLive/Employees.swift b/Sources/DatabaseClientLive/Employees.swift index f3d042b..8faf733 100644 --- a/Sources/DatabaseClientLive/Employees.swift +++ b/Sources/DatabaseClientLive/Employees.swift @@ -9,7 +9,7 @@ public extension DatabaseClient.Employees { .init { create in let model = try create.toModel() try await model.save(on: database) - return model.toDTO() + return try model.toDTO() } delete: { id in guard let model = try await EmployeeModel.find(id, on: database) else { throw NotFoundError() @@ -28,17 +28,17 @@ public extension DatabaseClient.Employees { break } - return try await query.all().map { $0.toDTO() } + return try await query.all().map { try $0.toDTO() } } get: { id in - try await EmployeeModel.find(id, on: database).map { $0.toDTO() } + try await EmployeeModel.find(id, on: database).map { try $0.toDTO() } } update: { id, updates in guard let model = try await EmployeeModel.find(id, on: database) else { throw NotFoundError() } try model.applyUpdate(updates) try await model.save(on: database) - return model.toDTO() + return try model.toDTO() } } } @@ -150,14 +150,14 @@ final class EmployeeModel: Model, @unchecked Sendable { self.updatedAt = updatedAt } - func toDTO() -> Employee { - .init( - id: id, + func toDTO() throws -> Employee { + try .init( + id: requireID(), active: active, - createdAt: createdAt, + createdAt: createdAt!, firstName: firstName, lastName: lastName, - updatedAt: updatedAt + updatedAt: updatedAt! ) } diff --git a/Sources/SharedModels/Employee.swift b/Sources/SharedModels/Employee.swift index 775de64..c325130 100644 --- a/Sources/SharedModels/Employee.swift +++ b/Sources/SharedModels/Employee.swift @@ -10,32 +10,29 @@ public struct Employee: Codable, Equatable, Identifiable, Sendable { public var updatedAt: Date public init( - id: UUID? = nil, + id: UUID, active: Bool = true, - createdAt: Date? = nil, + createdAt: Date, firstName: String, lastName: String, - updatedAt: Date? = nil + updatedAt: Date ) { - @Dependency(\.date) var date - @Dependency(\.uuid) var uuid - - self.id = id ?? uuid() + self.id = id self.active = active - self.createdAt = createdAt ?? date.now + self.createdAt = createdAt self.firstName = firstName self.lastName = lastName - self.updatedAt = updatedAt ?? date.now + self.updatedAt = updatedAt } } -public extension Employee { - static var mocks: [Self] { - [ - .init(firstName: "Michael", lastName: "Housh"), - .init(firstName: "Blob", lastName: "Esquire"), - .init(firstName: "Testy", lastName: "McTestface") - ] - } -} +// public extension Employee { +// static var mocks: [Self] { +// [ +// .init(firstName: "Michael", lastName: "Housh"), +// .init(firstName: "Blob", lastName: "Esquire"), +// .init(firstName: "Testy", lastName: "McTestface") +// ] +// } +// } diff --git a/Tests/AppTests/AppTests.swift b/Tests/AppTests/AppTests.swift index ae0a80c..219654a 100644 --- a/Tests/AppTests/AppTests.swift +++ b/Tests/AppTests/AppTests.swift @@ -1,82 +1,82 @@ -@testable import App -import VaporTesting -import Testing -import Fluent - -@Suite("App Tests with DB", .serialized) -struct AppTests { - private func withApp(_ test: (Application) async throws -> ()) async throws { - let app = try await Application.make(.testing) - do { - try await configure(app) - try await app.autoMigrate() - try await test(app) - try await app.autoRevert() - } - catch { - try? await app.autoRevert() - try await app.asyncShutdown() - throw error - } - try await app.asyncShutdown() - } - - @Test("Test Hello World Route") - func helloWorld() async throws { - try await withApp { app in - try await app.testing().test(.GET, "hello", afterResponse: { res async in - #expect(res.status == .ok) - #expect(res.body.string == "Hello, world!") - }) - } - } - - @Test("Getting all the Todos") - func getAllTodos() async throws { - try await withApp { app in - let sampleTodos = [Todo(title: "sample1"), Todo(title: "sample2")] - try await sampleTodos.create(on: app.db) - - try await app.testing().test(.GET, "todos", afterResponse: { res async throws in - #expect(res.status == .ok) - #expect(try res.content.decode([TodoDTO].self) == sampleTodos.map { $0.toDTO()} ) - }) - } - } - - @Test("Creating a Todo") - func createTodo() async throws { - let newDTO = TodoDTO(id: nil, title: "test") - - try await withApp { app in - try await app.testing().test(.POST, "todos", beforeRequest: { req in - try req.content.encode(newDTO) - }, afterResponse: { res async throws in - #expect(res.status == .ok) - let models = try await Todo.query(on: app.db).all() - #expect(models.map({ $0.toDTO().title }) == [newDTO.title]) - }) - } - } - - @Test("Deleting a Todo") - func deleteTodo() async throws { - let testTodos = [Todo(title: "test1"), Todo(title: "test2")] - - try await withApp { app in - try await testTodos.create(on: app.db) - - try await app.testing().test(.DELETE, "todos/\(testTodos[0].requireID())", afterResponse: { res async throws in - #expect(res.status == .noContent) - let model = try await Todo.find(testTodos[0].id, on: app.db) - #expect(model == nil) - }) - } - } -} - -extension TodoDTO: Equatable { - public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.id == rhs.id && lhs.title == rhs.title - } -} +// @testable import App +// import VaporTesting +// import Testing +// import Fluent +// +// @Suite("App Tests with DB", .serialized) +// struct AppTests { +// private func withApp(_ test: (Application) async throws -> ()) async throws { +// let app = try await Application.make(.testing) +// do { +// try await configure(app) +// try await app.autoMigrate() +// try await test(app) +// try await app.autoRevert() +// } +// catch { +// try? await app.autoRevert() +// try await app.asyncShutdown() +// throw error +// } +// try await app.asyncShutdown() +// } +// +// @Test("Test Hello World Route") +// func helloWorld() async throws { +// try await withApp { app in +// try await app.testing().test(.GET, "hello", afterResponse: { res async in +// #expect(res.status == .ok) +// #expect(res.body.string == "Hello, world!") +// }) +// } +// } +// +// @Test("Getting all the Todos") +// func getAllTodos() async throws { +// try await withApp { app in +// let sampleTodos = [Todo(title: "sample1"), Todo(title: "sample2")] +// try await sampleTodos.create(on: app.db) +// +// try await app.testing().test(.GET, "todos", afterResponse: { res async throws in +// #expect(res.status == .ok) +// #expect(try res.content.decode([TodoDTO].self) == sampleTodos.map { $0.toDTO()} ) +// }) +// } +// } +// +// @Test("Creating a Todo") +// func createTodo() async throws { +// let newDTO = TodoDTO(id: nil, title: "test") +// +// try await withApp { app in +// try await app.testing().test(.POST, "todos", beforeRequest: { req in +// try req.content.encode(newDTO) +// }, afterResponse: { res async throws in +// #expect(res.status == .ok) +// let models = try await Todo.query(on: app.db).all() +// #expect(models.map({ $0.toDTO().title }) == [newDTO.title]) +// }) +// } +// } +// +// @Test("Deleting a Todo") +// func deleteTodo() async throws { +// let testTodos = [Todo(title: "test1"), Todo(title: "test2")] +// +// try await withApp { app in +// try await testTodos.create(on: app.db) +// +// try await app.testing().test(.DELETE, "todos/\(testTodos[0].requireID())", afterResponse: { res async throws in +// #expect(res.status == .noContent) +// let model = try await Todo.find(testTodos[0].id, on: app.db) +// #expect(model == nil) +// }) +// } +// } +// } +// +// extension TodoDTO: Equatable { +// public static func == (lhs: Self, rhs: Self) -> Bool { +// lhs.id == rhs.id && lhs.title == rhs.title +// } +// } diff --git a/Tests/DatabaseClientTests/DatabaseClientTests.swift b/Tests/DatabaseClientTests/DatabaseClientTests.swift new file mode 100644 index 0000000..15e65f0 --- /dev/null +++ b/Tests/DatabaseClientTests/DatabaseClientTests.swift @@ -0,0 +1,194 @@ +@testable import DatabaseClientLive +import Dependencies +import FluentSQLiteDriver +import Logging +import NIO +import SharedModels +import Testing + +@Suite("DatabaseClientTests") +struct DatabaseClientTests { + + let logger: Logger + + init() async throws { + let logger = Logger(label: "database-client-tests") + self.logger = logger + } + + @Test + func users() async throws { + try await withDatabase(migrations: User.Migrate()) { + $0.database.users = .live(database: $1) + } operation: { + @Dependency(\.database.users) var users + + let user = try await users.create(.init( + username: "blob", + email: "blob@example.com", + password: "super-secret", + confirmPassword: "super-secret" + )) + #expect(user.username == "blob") + #expect(user.email == "blob@example.com") + #expect(user.createdAt != nil) + #expect(user.updatedAt != nil) + + let allUsers = try await users.fetchAll() + #expect(allUsers.count == 1) + #expect(allUsers.first == user) + + let fetched = try await users.get(user.id) + #expect(fetched != nil) + #expect(fetched == user) + + try await users.delete(user.id) + let allUsers2 = try await users.fetchAll() + #expect(allUsers2.count == 0) + } + } + + @Test + func employees() async throws { + try await withDatabase(migrations: Employee.Migrate()) { + $0.database.employees = .live(database: $1) + } operation: { + @Dependency(\.database.employees) var employees + + let employee = try await employees.create(.init( + firstName: "Blob", + lastName: "Esquire" + )) + + #expect(employee.firstName == "Blob") + #expect(employee.lastName == "Esquire") + #expect(employee.active) + + let allEmployees = try await employees.fetchAll() + #expect(allEmployees.count == 1) + #expect(allEmployees.first == employee) + + let inActiveEmployees = try await employees.fetchAll(.inactive) + #expect(inActiveEmployees.count == 0) + + let activeEmployees = try await employees.fetchAll(.active) + #expect(activeEmployees == allEmployees) + + let fetched = try await employees.get(employee.id) + #expect(fetched == employee) + + let updated = try await employees.update(employee.id, Employee.Update(active: false)) + #expect(updated.active == false) + + let inActiveEmployees2 = try await employees.fetchAll(.inactive) + #expect(inActiveEmployees2.count == 1) + + try await employees.delete(employee.id) + + let shouldBeNone = try await employees.fetchAll() + #expect(shouldBeNone.count == 0) + } + } + + @Test + func vendors() async throws { + try await withDatabase(migrations: Vendor.Migrate(), VendorBranch.Migrate()) { + $0.database.vendors = .live(database: $1) + $0.database.vendorBranches = .live(database: $1) + } operation: { + @Dependency(\.database.vendorBranches) var branches + @Dependency(\.database.vendors) var vendors + + let vendor = try await vendors.create(.init(name: "Corken")) + #expect(vendor.name == "Corken") + + let branch = try await branches.create(.init(name: "Monroe", vendorID: vendor.id)) + #expect(branch.name == "Monroe") + + let all = try await vendors.fetchAll() + #expect(all.count == 1) + #expect(all.first == vendor) + + let allWithBranches = try await vendors.fetchAll(.withBranches) + #expect(allWithBranches.count == 1) + #expect(allWithBranches.first!.branches!.first == branch) + + let fetched = try await vendors.get(vendor.id) + #expect(fetched == vendor) + + let fetchedWithBranches = try await vendors.get(vendor.id, .withBranches) + #expect(fetchedWithBranches!.branches!.first == branch) + + let updated = try await vendors.update(vendor.id, .init(name: "Johnstone")) + #expect(updated.name == "Johnstone") + + try await vendors.delete(vendor.id) + + let shouldBeNone = try await vendors.fetchAll() + #expect(shouldBeNone.count == 0) + } + } + + @Test + func vendorBranches() async throws { + try await withDatabase(migrations: Vendor.Migrate(), VendorBranch.Migrate()) { + $0.database.vendors = .live(database: $1) + $0.database.vendorBranches = .live(database: $1) + } operation: { + @Dependency(\.database.vendorBranches) var branches + @Dependency(\.database.vendors) var vendors + + let vendor = try await vendors.create(.init(name: "Corken")) + #expect(vendor.name == "Corken") + + let branch = try await branches.create(.init(name: "Monroe", vendorID: vendor.id)) + #expect(branch.name == "Monroe") + + let all = try await branches.fetchAll() + #expect(all.count == 1) + + let fetched = try await branches.get(branch.id) + #expect(fetched == branch) + + let updated = try await branches.update(branch.id, .init(name: "Covington")) + #expect(updated.name == "Covington") + + try await branches.delete(branch.id) + + let shouldBeNone = try await branches.fetchAll() + #expect(shouldBeNone.count == 0) + } + } + + // Helper to create an in-memory database for testing. + func withDatabase( + migrations: (any AsyncMigration)..., + setupDependencies: (inout DependencyValues, any Database) -> Void, + operation: () async throws -> Void + ) async throws { + let dbs = Databases(threadPool: NIOThreadPool.singleton, on: MultiThreadedEventLoopGroup.singleton) + dbs.use(.sqlite(.memory), as: .sqlite) + + let database = dbs.database( + .sqlite, + logger: .init(label: "test.sqlite"), + on: MultiThreadedEventLoopGroup.singleton.any() + )! + + for migration in migrations { + try await migration.prepare(on: database) + } + + try await withDependencies { + setupDependencies(&$0, database) + } operation: { + try await operation() + } + + for migration in migrations { + try await migration.revert(on: database) + } + + await dbs.shutdownAsync() + } +}