diff --git a/Packages/Persistence/README.md b/Packages/Persistence/README.md index 7a0a8e9..b45defa 100644 --- a/Packages/Persistence/README.md +++ b/Packages/Persistence/README.md @@ -8,6 +8,7 @@ The [Fluent](https://github.com/hummingbird-project/hummingbird-fluent)-based da | Service | `Service`, which builds the `Fluent` service configured for the chosen driver | | Migrations | `PrepareDB`, the single registrar declaring every migration, in order | | Readiness | `Probe`, which reports whether the default database answers a `SELECT 1` within a deadline | +| Accounts | `AccountRepository`, which finds an account by email or creates it, returning a `User` snapshot; `CreateUser` seeds the prototype's demo accounts | | Catalogue | `CatalogueRepository`, returning `Shop` and `Listing` snapshots narrowed and ordered by a `ListingFilter`; `SeedCatalogue` fills it with the prototype's demo data | ## Design rules @@ -29,12 +30,12 @@ Sources/ ├── Public/ │ ├── Enumerations/ Driver, TLS │ ├── Methods/ Service, PrepareDB, Probe -│ ├── Models/ Shop, Listing, ListingFilter -│ ├── Repositories/ CatalogueRepository +│ ├── Models/ Shop, Listing, ListingFilter, User +│ ├── Repositories/ CatalogueRepository, AccountRepository │ └── Types/ Configuration └── Internal/ - ├── Migrations/ CreateShop, CreateListing, SeedCatalogue - ├── Models/ ShopRecord, ListingRecord + ├── Migrations/ CreateShop, CreateListing, SeedCatalogue, CreateUser + ├── Models/ ShopRecord, ListingRecord, UserRecord └── Seeds/ Seed Tests/ ├── Cases/ the test suites, mirroring the Sources/ layout diff --git a/Packages/Persistence/Sources/Internal/Migrations/CreateUser.swift b/Packages/Persistence/Sources/Internal/Migrations/CreateUser.swift new file mode 100644 index 0000000..67241d0 --- /dev/null +++ b/Packages/Persistence/Sources/Internal/Migrations/CreateUser.swift @@ -0,0 +1,26 @@ +import FluentKit + +/// Creates and drops the `users` table backing ``UserRecord``, seeded with the prototype's demo accounts. +struct CreateUser: AsyncMigration { + + // MARK: Methods + + func prepare(on database: Database) async throws { + try await database.schema(UserRecord.schema) + .id() + .field("email", .string, .required) + .field("display_name", .string, .required) + .unique(on: "email") + .create() + + for account in Seed.accounts { + try await UserRecord(email: account.email, displayName: account.displayName).save(on: database) + } + } + + func revert(on database: Database) async throws { + try await database.schema(UserRecord.schema) + .delete() + } + +} diff --git a/Packages/Persistence/Sources/Internal/Models/UserRecord.swift b/Packages/Persistence/Sources/Internal/Models/UserRecord.swift new file mode 100644 index 0000000..ddf36d4 --- /dev/null +++ b/Packages/Persistence/Sources/Internal/Models/UserRecord.swift @@ -0,0 +1,55 @@ +import FluentKit +import Foundation + +/// A FluentKit model of a single `users` row. +final class UserRecord: Model, @unchecked Sendable { + + // MARK: Properties + + static let schema = "users" + + @ID(key: .id) + var id: UUID? + + @Field(key: "email") + var email: String + + @Field(key: "display_name") + var displayName: String + + // MARK: Initializers + + init() {} + + /// Creates a record with the given values. + /// - Parameters: + /// - email: the account's normalized email address. + /// - displayName: the name other people see. + init( + email: String, + displayName: String + ) { + self.email = email + self.displayName = displayName + } + +} + +// MARK: - Extensions + +extension UserRecord { + + // MARK: Computed + + /// The `Sendable` snapshot of the saved record. + var snapshot: User { + get throws { + .init( + id: try requireID(), + email: email, + displayName: displayName + ) + } + } + +} diff --git a/Packages/Persistence/Sources/Internal/Seeds/Seed.swift b/Packages/Persistence/Sources/Internal/Seeds/Seed.swift index 01cd584..9273f24 100644 --- a/Packages/Persistence/Sources/Internal/Seeds/Seed.swift +++ b/Packages/Persistence/Sources/Internal/Seeds/Seed.swift @@ -5,6 +5,12 @@ enum Seed { // MARK: Constants + /// The demo accounts: the buyer the stubbed passkey signs in, then a seller. + static let accounts: [(email: String, displayName: String)] = [ + ("mara.v@example.com", "Mara Visser"), + ("joost@kestrelrow.example", "Joost de Wit"), + ] + /// The demo listings, newest first. static let listings: [Listing] = [ single( diff --git a/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift b/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift index 7aa60db..6c7a33b 100644 --- a/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift +++ b/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift @@ -25,6 +25,7 @@ public struct PrepareDB: Sendable { CreateShop(), CreateListing(), SeedCatalogue(), + CreateUser(), ]) } diff --git a/Packages/Persistence/Sources/Public/Models/User.swift b/Packages/Persistence/Sources/Public/Models/User.swift new file mode 100644 index 0000000..f32eed6 --- /dev/null +++ b/Packages/Persistence/Sources/Public/Models/User.swift @@ -0,0 +1,27 @@ +import Foundation + +/// A `Sendable` snapshot of an account. +public struct User: Sendable, Equatable { + + // MARK: Properties + + /// The account's identifier. + public let id: UUID + /// The account's normalized email address. + public let email: String + /// The name other people see. + public let displayName: String + + // MARK: Initializers + + public init( + id: UUID, + email: String, + displayName: String + ) { + self.id = id + self.email = email + self.displayName = displayName + } + +} diff --git a/Packages/Persistence/Sources/Public/Repositories/AccountRepository.swift b/Packages/Persistence/Sources/Public/Repositories/AccountRepository.swift new file mode 100644 index 0000000..7a4baf5 --- /dev/null +++ b/Packages/Persistence/Sources/Public/Repositories/AccountRepository.swift @@ -0,0 +1,53 @@ +import FluentKit +import HummingbirdFluent + +/// Reads and creates accounts through the default database. +public struct AccountRepository: Sendable { + + // MARK: Constants + + /// The email of the demo buyer the prototype's stubbed passkey signs in. + public static let demoEmail = Seed.accounts[0].email + + // MARK: Properties + + private let fluent: Fluent + + // MARK: Initializers + + /// Creates a repository backed by the given `Fluent` service. + /// - Parameter fluent: the service whose default database the repository operates on. + public init( + fluent: Fluent + ) { + self.fluent = fluent + } + + // MARK: Methods + + /// Fetches the account with the given email, creating it when there is none: one step both signs in and joins. + /// + /// A new account is named after the email's local part until its owner picks a display name. + /// - Parameter email: the account's normalized email address. + /// - Returns: the account, existing or new. + public func account( + email: String + ) async throws -> User { + if let record = try await UserRecord.query(on: fluent.db()) + .filter(\.$email == email) + .first() + { + return try record.snapshot + } + + let record = UserRecord( + email: email, + displayName: String(email.prefix { $0 != "@" }) + ) + + try await record.save(on: fluent.db()) + + return try record.snapshot + } + +} diff --git a/Packages/Persistence/Tests/Cases/Public/Repositories/AccountRepositoryTests.swift b/Packages/Persistence/Tests/Cases/Public/Repositories/AccountRepositoryTests.swift new file mode 100644 index 0000000..25b219e --- /dev/null +++ b/Packages/Persistence/Tests/Cases/Public/Repositories/AccountRepositoryTests.swift @@ -0,0 +1,35 @@ +import Testing + +@testable import Persistence + +@Suite( + "AccountRepository repository", + .tags(.repository) +) +struct AccountRepositoryTests { + + // MARK: Methods tests + + @Test + func `finds the seeded demo buyer`() async throws { + try await withDatabase { fluent in + let user = try await AccountRepository(fluent: fluent).account(email: AccountRepository.demoEmail) + + #expect(user.email == "mara.v@example.com") + #expect(user.displayName == "Mara Visser") + } + } + + @Test + func `joins an unknown email once, named after its local part`() async throws { + try await withDatabase { fluent in + let repository = AccountRepository(fluent: fluent) + let joined = try await repository.account(email: "sam@example.com") + let returning = try await repository.account(email: "sam@example.com") + + #expect(joined.displayName == "sam") + #expect(returning == joined) + } + } + +} diff --git a/Packages/Persistence/Tests/Cases/Public/Repositories/CatalogueRepositoryTests.swift b/Packages/Persistence/Tests/Cases/Public/Repositories/CatalogueRepositoryTests.swift index 7d2dbc1..b2c893b 100644 --- a/Packages/Persistence/Tests/Cases/Public/Repositories/CatalogueRepositoryTests.swift +++ b/Packages/Persistence/Tests/Cases/Public/Repositories/CatalogueRepositoryTests.swift @@ -78,27 +78,13 @@ struct CatalogueRepositoryTests { private extension CatalogueRepositoryTests { - /// Runs the body against a migrated, seeded in-memory catalogue, shutting the pool down after. + /// Runs the body against a migrated, seeded in-memory catalogue. func withCatalogue( _ body: (CatalogueRepository) async throws -> Void ) async throws { - let fluent = try Service( - driver: .inMemory, - logger: Logger(label: "test") - )() - - do { - await PrepareDB()(for: fluent) - - try await fluent.migrate() - try await body(.init(fluent: fluent)) - } catch { - try? await fluent.shutdown() - - throw error + try await withDatabase { + try await body(.init(fluent: $0)) } - - try await fluent.shutdown() } } diff --git a/Packages/Persistence/Tests/Utils/Methods/WithDatabase.swift b/Packages/Persistence/Tests/Utils/Methods/WithDatabase.swift new file mode 100644 index 0000000..435d334 --- /dev/null +++ b/Packages/Persistence/Tests/Utils/Methods/WithDatabase.swift @@ -0,0 +1,27 @@ +import HummingbirdFluent +import Logging + +@testable import Persistence + +/// Runs the body against a migrated, seeded in-memory database, shutting the pool down after — even on failure, or the pool asserts on `deinit`. +func withDatabase( + _ body: (Fluent) async throws -> Void +) async throws { + let fluent = try Service( + driver: .inMemory, + logger: Logger(label: "test") + )() + + do { + await PrepareDB()(for: fluent) + + try await fluent.migrate() + try await body(fluent) + } catch { + try? await fluent.shutdown() + + throw error + } + + try await fluent.shutdown() +}