From 95e0a455e886146cfce14660b66a7177cc7b02a0 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Mon, 21 Sep 2026 12:50:53 +0200 Subject: [PATCH] Added the catalogue models and seed to support phase 4 of the POC prototype in the Persistence package. --- Packages/Persistence/README.md | 12 +- .../Migrations/AddListingDeclarations.swift | 29 +++ .../Internal/Migrations/CreatePasskey.swift | 25 ++ .../Internal/Models/ListingRecord.swift | 14 +- .../Internal/Models/PasskeyRecord.swift | 69 ++++++ .../Sources/Public/Methods/PrepareDB.swift | 2 + .../Sources/Public/Models/Listing.swift | 19 +- .../Sources/Public/Models/ListingDraft.swift | 221 ++++++++++++++++++ .../Sources/Public/Models/Passkey.swift | 29 +++ .../Repositories/AccountRepository.swift | 92 ++++++++ .../Repositories/CatalogueRepository.swift | 43 ++++ .../Public/Repositories/TradeRepository.swift | 2 +- .../Public/Models/ListingDraftTests.swift | 137 +++++++++++ .../Repositories/AccountRepositoryTests.swift | 58 +++++ 14 files changed, 743 insertions(+), 9 deletions(-) create mode 100644 Packages/Persistence/Sources/Internal/Migrations/AddListingDeclarations.swift create mode 100644 Packages/Persistence/Sources/Internal/Migrations/CreatePasskey.swift create mode 100644 Packages/Persistence/Sources/Internal/Models/PasskeyRecord.swift create mode 100644 Packages/Persistence/Sources/Public/Models/ListingDraft.swift create mode 100644 Packages/Persistence/Sources/Public/Models/Passkey.swift create mode 100644 Packages/Persistence/Tests/Cases/Public/Models/ListingDraftTests.swift diff --git a/Packages/Persistence/README.md b/Packages/Persistence/README.md index 2c6ce70..080e656 100644 --- a/Packages/Persistence/README.md +++ b/Packages/Persistence/README.md @@ -8,16 +8,16 @@ 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 | +| Accounts | `AccountRepository`, which finds an account by email or creates it, returning a `User` snapshot, and keeps each account's `Passkey`s — one is only ever registered together with a new account; `CreateUser` seeds the prototype's demo accounts | | Trade | `TradeRepository`, which reads and writes `Conversation`, `Offer`, and `Sale` snapshots and enforces who may do what, throwing a `TradeError` otherwise; `Sale` carries the five-step handshake's rules as pure functions | -| Catalogue | `CatalogueRepository`, returning `Shop` and `Listing` snapshots narrowed and ordered by a `ListingFilter`; `SeedCatalogue` fills it with the prototype's demo data | +| Catalogue | `CatalogueRepository`, which publishes a seller's `ListingDraft` under a slug of its own, and returns `Shop` and `Listing` snapshots narrowed and ordered by a `ListingFilter`; `SeedCatalogue` fills it with the prototype's demo data | ## Design rules - **The package reads no configuration.** The executable maps its `database.*` keys onto a `Driver` and hands it over; connection values arrive as plain data. The Website service's `ConfigReader+Properties` has the mapping. - **One default database.** `Service` registers the selected backend as the *default* database, so repositories resolve it with a plain `fluent.db()` and stay agnostic of which driver is in use. - **Migrations are declared once, and append-only.** `PrepareDB` registers every migration in the order it must run; alter the schema by adding a migration, never by editing one that has already run. Registering does not apply them: the in-memory backend is migrated at launch, while a shared PostgreSQL database is migrated out of band (the executable's migrate-and-exit mode), so booting instances never race. - **Models never cross a concurrency boundary.** FluentKit models are mutable reference types, so they stay internal to the package and repositories return `Sendable` value-type snapshots (e.g. `Listing`) instead. -- **Registration order is not creation order.** `AddTradeColumns` is registered ahead of `SeedCatalogue`, whose models write its columns; Fluent tracks migrations by name, so a database already past the seed still gains them. +- **Registration order is not creation order.** `AddTradeColumns` and `AddListingDeclarations` are registered ahead of `SeedCatalogue`, whose models write their columns; Fluent tracks migrations by name, so a database already past the seed still gains them. - **Prototype shortcuts.** `CatalogueRepository` filters in memory, the catalogue fields (series, issue, publisher) are folded into the listing, and the demo data ships as a migration. All three go once real data arrives. - **Readiness never throws, and never hangs.** `Probe` runs a schema-independent `SELECT 1`, maps every failure to `false`, and races the query against a deadline (2 seconds by default) — a hanging database yields a prompt "not ready" instead of a stalled endpoint. - **A single connection for the in-memory store.** The SQLite backend is capped at one connection per event loop, so every query reaches the same in-memory database instead of each pooled connection getting a private one. @@ -32,12 +32,12 @@ Sources/ ├── Public/ │ ├── Enumerations/ Driver, TLS, TradeError │ ├── Methods/ Service, PrepareDB, Probe -│ ├── Models/ Shop, Listing, ListingFilter, User, Conversation, Offer, Sale +│ ├── Models/ Shop, Listing, ListingDraft, ListingFilter, User, Passkey, Conversation, Offer, Sale │ ├── Repositories/ CatalogueRepository, AccountRepository, TradeRepository │ └── Types/ Configuration └── Internal/ - ├── Migrations/ CreateShop, CreateListing, AddTradeColumns, SeedCatalogue, CreateUser, CreateTrade - ├── Models/ ShopRecord, ListingRecord, UserRecord, ConversationRecord, MessageRecord, OfferRecord, SaleRecord + ├── Migrations/ CreateShop, CreateListing, AddTradeColumns, AddListingDeclarations, SeedCatalogue, CreateUser, CreateTrade, CreatePasskey + ├── Models/ ShopRecord, ListingRecord, UserRecord, PasskeyRecord, ConversationRecord, MessageRecord, OfferRecord, SaleRecord └── Seeds/ Seed Tests/ ├── Cases/ the test suites, mirroring the Sources/ layout diff --git a/Packages/Persistence/Sources/Internal/Migrations/AddListingDeclarations.swift b/Packages/Persistence/Sources/Internal/Migrations/AddListingDeclarations.swift new file mode 100644 index 0000000..2e43e4b --- /dev/null +++ b/Packages/Persistence/Sources/Internal/Migrations/AddListingDeclarations.swift @@ -0,0 +1,29 @@ +import FluentKit + +/// Adds the declared defects and the photos to listings. +/// +/// Registered ahead of ``SeedCatalogue``, whose model writes both columns. +struct AddListingDeclarations: AsyncMigration { + + // MARK: Methods + + func prepare(on database: Database) async throws { + // One column per update: SQLite adds no more in a single statement. + try await database.schema(ListingRecord.schema) + .field("defects", .string) + .update() + try await database.schema(ListingRecord.schema) + .field("photos", .string) + .update() + } + + func revert(on database: Database) async throws { + try await database.schema(ListingRecord.schema) + .deleteField("photos") + .update() + try await database.schema(ListingRecord.schema) + .deleteField("defects") + .update() + } + +} diff --git a/Packages/Persistence/Sources/Internal/Migrations/CreatePasskey.swift b/Packages/Persistence/Sources/Internal/Migrations/CreatePasskey.swift new file mode 100644 index 0000000..5bb081d --- /dev/null +++ b/Packages/Persistence/Sources/Internal/Migrations/CreatePasskey.swift @@ -0,0 +1,25 @@ +import FluentKit + +/// Creates and drops the `passkeys` table backing ``PasskeyRecord``. +struct CreatePasskey: AsyncMigration { + + // MARK: Methods + + func prepare(on database: Database) async throws { + try await database.schema(PasskeyRecord.schema) + .id() + .field("user_id", .uuid, .required, .references(UserRecord.schema, "id", onDelete: .cascade)) + .field("credential_id", .string, .required) + .field("public_key", .string, .required) + .field("sign_count", .int, .required) + .field("created_at", .datetime, .required) + .unique(on: "credential_id") + .create() + } + + func revert(on database: Database) async throws { + try await database.schema(PasskeyRecord.schema) + .delete() + } + +} diff --git a/Packages/Persistence/Sources/Internal/Models/ListingRecord.swift b/Packages/Persistence/Sources/Internal/Models/ListingRecord.swift index b576d3f..f18674f 100644 --- a/Packages/Persistence/Sources/Internal/Models/ListingRecord.swift +++ b/Packages/Persistence/Sources/Internal/Models/ListingRecord.swift @@ -94,6 +94,14 @@ final class ListingRecord: Model, @unchecked Sendable { @OptionalField(key: "sold_at") var soldAt: Date? + /// The declared defects, one per line. + @OptionalField(key: "defects") + var defects: String? + + /// The URL paths of the photos, one per line, the front first. + @OptionalField(key: "photos") + var photos: String? + // MARK: Initializers init() {} @@ -132,6 +140,8 @@ final class ListingRecord: Model, @unchecked Sendable { self.coverImprint = listing.cover.imprint self.coverNumber = listing.cover.number self.listedAt = listing.listedAt + self.defects = listing.defects.isEmpty ? nil : listing.defects.joined(separator: "\n") + self.photos = listing.photos.isEmpty ? nil : listing.photos.joined(separator: "\n") } } @@ -173,7 +183,9 @@ extension ListingRecord { number: coverNumber ), listedAt: listedAt, - soldAt: soldAt + soldAt: soldAt, + defects: defects?.split(separator: "\n").map(String.init) ?? [], + photos: photos?.split(separator: "\n").map(String.init) ?? [] ) } diff --git a/Packages/Persistence/Sources/Internal/Models/PasskeyRecord.swift b/Packages/Persistence/Sources/Internal/Models/PasskeyRecord.swift new file mode 100644 index 0000000..377f43c --- /dev/null +++ b/Packages/Persistence/Sources/Internal/Models/PasskeyRecord.swift @@ -0,0 +1,69 @@ +import FluentKit +import Foundation + +/// A FluentKit model of a single `passkeys` row: a WebAuthn credential registered to an account. +final class PasskeyRecord: Model, @unchecked Sendable { + + // MARK: Properties + + static let schema = "passkeys" + + @ID(key: .id) + var id: UUID? + + @Parent(key: "user_id") + var user: UserRecord + + /// The credential's identifier, base64url-encoded without padding. + @Field(key: "credential_id") + var credentialID: String + + /// The credential's public key, COSE-encoded, as base64. + @Field(key: "public_key") + var publicKey: String + + @Field(key: "sign_count") + var signCount: Int + + @Field(key: "created_at") + var createdAt: Date + + // MARK: Initializers + + init() {} + + init( + userID: UUID, + credentialID: String, + publicKey: [UInt8], + signCount: UInt32, + at date: Date + ) { + self.$user.id = userID + self.credentialID = credentialID + self.publicKey = Data(publicKey).base64EncodedString() + self.signCount = Int(signCount) + self.createdAt = date + } + +} + +// MARK: - Extensions + +extension PasskeyRecord { + + // MARK: Computed + + /// The `Sendable` snapshot of the saved record; its user must be eager-loaded. + var snapshot: Passkey { + get throws { + .init( + credentialID: credentialID, + publicKey: Array(Data(base64Encoded: publicKey) ?? .init()), + signCount: UInt32(clamping: signCount), + user: try user.snapshot + ) + } + } + +} diff --git a/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift b/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift index d648de8..b48d10e 100644 --- a/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift +++ b/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift @@ -25,9 +25,11 @@ public struct PrepareDB: Sendable { CreateShop(), CreateListing(), AddTradeColumns(), + AddListingDeclarations(), SeedCatalogue(), CreateUser(), CreateTrade(), + CreatePasskey(), ]) } diff --git a/Packages/Persistence/Sources/Public/Models/Listing.swift b/Packages/Persistence/Sources/Public/Models/Listing.swift index 1290b3f..517488f 100644 --- a/Packages/Persistence/Sources/Public/Models/Listing.swift +++ b/Packages/Persistence/Sources/Public/Models/Listing.swift @@ -53,6 +53,10 @@ public struct Listing: Sendable, Equatable { public let listedAt: Date /// When a sale of the copy was agreed, or `nil` while it is for sale. public let soldAt: Date? + /// The defects the seller declared, from the grading guide's checklist. + public let defects: [String] + /// The URL paths of the photos of this copy, the front first; empty while the listing shows its placeholder cover. + public let photos: [String] // MARK: Initializers @@ -80,7 +84,9 @@ public struct Listing: Sendable, Equatable { acceptsOffers: Bool, cover: Cover, listedAt: Date, - soldAt: Date? = nil + soldAt: Date? = nil, + defects: [String] = [], + photos: [String] = [] ) { self.slug = slug self.shop = shop @@ -106,6 +112,8 @@ public struct Listing: Sendable, Equatable { self.cover = cover self.listedAt = listedAt self.soldAt = soldAt + self.defects = defects + self.photos = photos } } @@ -116,6 +124,15 @@ public extension Listing { // MARK: Computed + /// What the seller declared of the copy's condition, in a sentence: the defects, then the notes. Frozen into a sale at agreement. + var declaration: String { + let defects = defects.isEmpty + ? "No defects declared." + : "Declared defects: \(defects.joined(separator: "; "))." + + return sellerNotes.isEmpty ? defects : "\(defects) \(sellerNotes)" + } + /// The grade or condition in a few words: `CGC 9.8` for a slab, `Raw · NM` for a raw issue, the condition alone for a collected edition. var gradeLabel: String { if let grader, let grade { diff --git a/Packages/Persistence/Sources/Public/Models/ListingDraft.swift b/Packages/Persistence/Sources/Public/Models/ListingDraft.swift new file mode 100644 index 0000000..3a2833b --- /dev/null +++ b/Packages/Persistence/Sources/Public/Models/ListingDraft.swift @@ -0,0 +1,221 @@ +import Foundation + +/// What a seller fills in to list a copy. The listing's slug and placeholder cover are derived from it on publishing. +public struct ListingDraft: Sendable, Equatable { + + // MARK: Properties + + /// The series or book title, e.g. `Nightward`. + public var series: String + /// The issue number or volume, e.g. `1`; empty for a one-off book. + public var issue: String + /// The publisher's name. + public var publisher: String + /// The cover year. + public var year: Int + /// The format token, e.g. `single-issues`. + public var format: String + /// The printing, counted from one; zero for later or unknown. + public var printing: Int + /// The edition, e.g. `Direct`; empty when not applicable. + public var edition: String + /// The company that graded and slabbed the copy, or `nil` for a raw copy. + public var grader: String? + /// The numeric grade, or `nil` for formats that are not graded. + public var grade: Double? + /// The condition as the seller states it, e.g. `NM` or `Like new`. + public var condition: String + /// The grader's certificate number, or `nil` for a raw copy. + public var certNumber: String? + /// The page quality, or `nil` when not stated. + public var pageQuality: String? + /// Why the issue is a key, or `nil` when it is not one. + public var keyNotes: String? + /// The defects declared, from the grading guide's checklist. + public var defects: [String] + /// The seller's notes on the copy. + public var sellerNotes: String + /// The asking price, in euro cents. + public var priceCents: Int + /// The shipping cost to the Netherlands, in euro cents. + public var shippingCents: Int + /// Whether the seller takes offers on the copy. + public var acceptsOffers: Bool + /// The URL paths of the photos of this copy, the front first. + public var photos: [String] + + // MARK: Initializers + + public init( + series: String, + issue: String, + publisher: String, + year: Int, + format: String, + printing: Int, + edition: String, + grader: String?, + grade: Double?, + condition: String, + certNumber: String?, + pageQuality: String?, + keyNotes: String?, + defects: [String], + sellerNotes: String, + priceCents: Int, + shippingCents: Int, + acceptsOffers: Bool, + photos: [String] + ) { + self.series = series + self.issue = issue + self.publisher = publisher + self.year = year + self.format = format + self.printing = printing + self.edition = edition + self.grader = grader + self.grade = grade + self.condition = condition + self.certNumber = certNumber + self.pageQuality = pageQuality + self.keyNotes = keyNotes + self.defects = defects + self.sellerNotes = sellerNotes + self.priceCents = priceCents + self.shippingCents = shippingCents + self.acceptsOffers = acceptsOffers + self.photos = photos + } + +} + +// MARK: - Extensions + +extension ListingDraft { + + // MARK: Constants + + /// The tones a placeholder cover is drawn in. + static let tones = ["navy", "wine", "forest", "rust", "slate", "plum", "brick", "olive", "ink"] + + // MARK: Computed + + /// The placeholder cover: the series lettered over a few lines, on a tone picked by the series so every copy of it matches. + var cover: Listing.Cover { + var lines = [String]() + + for word in series.uppercased().split(separator: " ").map(String.init) { + if let last = lines.last, last.count + word.count < 10 { + lines[lines.count - 1] = "\(last) \(word)" + } else { + lines.append(word) + } + } + + let marker = switch format { + case "manga": issue.isEmpty ? "MANGA" : "VOL \(issue)" + case "trade-paperbacks": "TPB" + case "hardcovers": "HC" + default: issue + } + + return .init( + tone: Self.tones[series.unicodeScalars.reduce(0) { ($0 + Int($1.value)) % Self.tones.count }], + title: lines.prefix(3).joined(separator: "\n"), + imprint: publisher.uppercased(), + number: marker + ) + } + + /// Whether the copy is a single issue. + var isSingleIssue: Bool { + format == "single-issues" + } + + /// The printing in a few words, e.g. `1st print`. + var printingLabel: String { + let noun = isSingleIssue ? "print" : "printing" + + switch printing { + case 1: return "1st \(noun)" + case 2: return "2nd \(noun)" + case 3: return "3rd \(noun)" + default: return "later \(noun)" + } + } + + /// The printing in full, e.g. `First printing, direct edition`. + var printingDetail: String { + let ordinal = switch printing { + case 1: "First" + case 2: "Second" + case 3: "Third" + default: "Later" + } + + return edition.isEmpty ? "\(ordinal) printing" : "\(ordinal) printing, \(edition.lowercased()) edition" + } + + /// The listing's title, e.g. `Nightward #1`, or `Hollow Comet, Vol. 4`. + var title: String { + guard !issue.isEmpty else { + return series + } + + return switch format { + case "single-issues": "\(series) #\(issue)" + case "manga": "\(series), Vol. \(issue)" + default: "\(series) \(issue)" + } + } + + // MARK: Methods + + /// The listing the draft publishes as. + func listing( + slug: String, + shop: Shop, + at date: Date + ) -> Listing { + .init( + slug: slug, + shop: shop, + title: title, + seriesTitle: isSingleIssue ? "\(series) (\(year) series)" : series, + issueLabel: issue.isEmpty ? "—" : (isSingleIssue ? "#\(issue)" : issue), + publisher: publisher, + year: year, + format: format, + printing: printingLabel, + printingDetail: printingDetail, + isFirstPrint: printing == 1, + grader: grader, + grade: grade, + condition: condition, + certNumber: certNumber, + pageQuality: pageQuality, + keyNotes: keyNotes, + sellerNotes: sellerNotes, + priceCents: priceCents, + shippingCents: shippingCents, + acceptsOffers: acceptsOffers, + cover: cover, + listedAt: date, + defects: defects, + photos: photos + ) + } + + /// A string as a URL slug: lowercased, its letters and digits kept, everything else a single hyphen. + static func slug( + _ string: String + ) -> String { + string + .lowercased() + .folding(options: .diacriticInsensitive, locale: nil) + .split { !($0.isASCII && ($0.isLetter || $0.isNumber)) } + .joined(separator: "-") + } + +} diff --git a/Packages/Persistence/Sources/Public/Models/Passkey.swift b/Packages/Persistence/Sources/Public/Models/Passkey.swift new file mode 100644 index 0000000..0480901 --- /dev/null +++ b/Packages/Persistence/Sources/Public/Models/Passkey.swift @@ -0,0 +1,29 @@ +/// A `Sendable` snapshot of a WebAuthn credential registered to an account. +public struct Passkey: Sendable, Equatable { + + // MARK: Properties + + /// The credential's identifier, base64url-encoded without padding. + public let credentialID: String + /// The credential's public key, COSE-encoded. + public let publicKey: [UInt8] + /// The authenticator's signature counter as last seen; a lower one later on signals a cloned authenticator. + public let signCount: UInt32 + /// The account the credential signs in. + public let user: User + + // MARK: Initializers + + public init( + credentialID: String, + publicKey: [UInt8], + signCount: UInt32, + user: User + ) { + self.credentialID = credentialID + self.publicKey = publicKey + self.signCount = signCount + self.user = user + } + +} diff --git a/Packages/Persistence/Sources/Public/Repositories/AccountRepository.swift b/Packages/Persistence/Sources/Public/Repositories/AccountRepository.swift index 7a4baf5..ae2251d 100644 --- a/Packages/Persistence/Sources/Public/Repositories/AccountRepository.swift +++ b/Packages/Persistence/Sources/Public/Repositories/AccountRepository.swift @@ -1,4 +1,5 @@ import FluentKit +import Foundation import HummingbirdFluent /// Reads and creates accounts through the default database. @@ -25,6 +26,97 @@ public struct AccountRepository: Sendable { // MARK: Methods + /// Fetches the account with the given email, without creating one. + /// - Returns: the account, or `nil` when no account has the email. + public func existingAccount( + email: String + ) async throws -> User? { + try await UserRecord.query(on: fluent.db()) + .filter(\.$email == email) + .first()? + .snapshot + } + + /// Whether a credential identifier is still free to register. + public func isUnregistered( + credentialID: String + ) async throws -> Bool { + try await PasskeyRecord.query(on: fluent.db()) + .filter(\.$credentialID == credentialID) + .first() == nil + } + + /// Creates an account together with its first passkey: one step both joins and enrolls. + /// + /// A passkey is only ever added to an account created in the same step. Adding one to an existing account would hand that account to + /// whoever asks, since the prototype has no other proof of who is asking. + /// - Returns: the new account, or `nil` when the email already has one. + public func join( + email: String, + credentialID: String, + publicKey: [UInt8], + signCount: UInt32 + ) async throws -> User? { + try await fluent.db().transaction { database in + guard try await UserRecord.query(on: database) + .filter(\.$email == email) + .first() == nil + else { + return nil + } + + let user = UserRecord( + email: email, + displayName: String(email.prefix { $0 != "@" }) + ) + + try await user.save(on: database) + try await PasskeyRecord( + userID: try user.requireID(), + credentialID: credentialID, + publicKey: publicKey, + signCount: signCount, + at: .init() + ).save(on: database) + + return try user.snapshot + } + } + + /// Fetches the passkey with the given credential identifier, and the account it signs in. + public func passkey( + credentialID: String + ) async throws -> Passkey? { + try await PasskeyRecord.query(on: fluent.db()) + .filter(\.$credentialID == credentialID) + .with(\.$user) + .first()? + .snapshot + } + + /// Fetches the passkeys registered to the account with the given email. + public func passkeys( + email: String + ) async throws -> [Passkey] { + try await PasskeyRecord.query(on: fluent.db()) + .join(UserRecord.self, on: \PasskeyRecord.$user.$id == \UserRecord.$id) + .filter(UserRecord.self, \.$email == email) + .with(\.$user) + .all() + .map { try $0.snapshot } + } + + /// Records a passkey's signature counter after a sign-in. + public func recordUse( + ofPasskey credentialID: String, + signCount: UInt32 + ) async throws { + try await PasskeyRecord.query(on: fluent.db()) + .filter(\.$credentialID == credentialID) + .set(\.$signCount, to: Int(signCount)) + .update() + } + /// 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. diff --git a/Packages/Persistence/Sources/Public/Repositories/CatalogueRepository.swift b/Packages/Persistence/Sources/Public/Repositories/CatalogueRepository.swift index bf003ff..191e6ab 100644 --- a/Packages/Persistence/Sources/Public/Repositories/CatalogueRepository.swift +++ b/Packages/Persistence/Sources/Public/Repositories/CatalogueRepository.swift @@ -1,4 +1,5 @@ import FluentKit +import Foundation import HummingbirdFluent /// Reads the shops and their listings through the default database. @@ -49,6 +50,48 @@ public struct CatalogueRepository: Sendable { .sorted(by: filter.ordersBefore) } + /// Publishes a draft as a listing of the shop the seller owns, under a slug of its own. + /// - Parameters: + /// - draft: what the seller filled in. + /// - seller: the account listing the copy. + /// - date: the moment the copy is listed; the present by default. + /// - Returns: the listing, or `nil` when the seller owns no shop. + public func publish( + _ draft: ListingDraft, + by seller: User, + at date: Date = .init() + ) async throws -> Listing? { + try await fluent.db().transaction { database in + guard let shop = try await ShopRecord.query(on: database) + .filter(\.$owner.$id == seller.id) + .first() + else { + return nil + } + + let listing = draft.listing(slug: "", shop: shop.snapshot, at: date) + let base = ListingDraft.slug("\(listing.title) \(listing.gradeLabel)") + let taken = Set( + try await ListingRecord.query(on: database) + .filter(\.$slug =~ base) + .all(\.$slug) + ) + var slug = base + var suffix = 2 + + while taken.contains(slug) { + slug = "\(base)-\(suffix)" + suffix += 1 + } + + let published = draft.listing(slug: slug, shop: shop.snapshot, at: date) + + try await ListingRecord(published, shopID: try shop.requireID()).save(on: database) + + return published + } + } + /// Fetches the name of every publisher with a listing, in alphabetical order. public func publishers() async throws -> [String] { try await ListingRecord.query(on: fluent.db()) diff --git a/Packages/Persistence/Sources/Public/Repositories/TradeRepository.swift b/Packages/Persistence/Sources/Public/Repositories/TradeRepository.swift index baabd6e..49dd5e1 100644 --- a/Packages/Persistence/Sources/Public/Repositories/TradeRepository.swift +++ b/Packages/Persistence/Sources/Public/Repositories/TradeRepository.swift @@ -472,7 +472,7 @@ private extension TradeRepository { sale.declaredGrade = declared.gradeLabel + (declared.certNumber.map { " · cert \($0)" } ?? "") sale.declaredPageQuality = declared.pageQuality sale.declaredPrinting = declared.printingDetail - sale.declaredNotes = declared.sellerNotes + sale.declaredNotes = declared.declaration sale.agreedAt = now() listing.soldAt = now() diff --git a/Packages/Persistence/Tests/Cases/Public/Models/ListingDraftTests.swift b/Packages/Persistence/Tests/Cases/Public/Models/ListingDraftTests.swift new file mode 100644 index 0000000..db2e00e --- /dev/null +++ b/Packages/Persistence/Tests/Cases/Public/Models/ListingDraftTests.swift @@ -0,0 +1,137 @@ +import Foundation +import Testing + +@testable import Persistence + +@Suite( + "ListingDraft model", + .tags(.model) +) +struct ListingDraftTests { + + // MARK: Computed tests + + @Test + func `titles a copy by its format`() { + #expect(Self.draft().title == "Nightward #3") + #expect(Self.draft(format: "manga").title == "Nightward, Vol. 3") + #expect(Self.draft(issue: "", format: "hardcovers").title == "Nightward") + } + + @Test + func `words the printing short and in full`() { + #expect(Self.draft().printingLabel == "1st print") + #expect(Self.draft().printingDetail == "First printing, direct edition") + #expect(Self.draft(format: "manga", printing: 2, edition: "").printingLabel == "2nd printing") + #expect(Self.draft(printing: 0, edition: "").printingDetail == "Later printing") + } + + @Test + func `letters the placeholder cover over a few lines, toned by the series`() { + let cover = Self.draft(series: "The Gilded Hour of Saints", format: "trade-paperbacks").cover + + #expect(cover.title == "THE GILDED\nHOUR OF\nSAINTS") + #expect(cover.number == "TPB") + #expect(cover.imprint == "MERIDIAN COMICS") + #expect(ListingDraft.tones.contains(cover.tone)) + #expect(Self.draft(issue: "1").cover.tone == Self.draft(issue: "2").cover.tone) + #expect(Self.draft(format: "manga").cover.number == "VOL 3") + } + + // MARK: Methods tests + + @Test + func `publishes as a listing carrying what was declared`() { + let listing = Self.draft().listing(slug: "nightward-3-raw-nm", shop: Seed.shops[0], at: .init(timeIntervalSince1970: 0)) + + #expect(listing.seriesTitle == "Nightward (2019 series)") + #expect(listing.issueLabel == "#3") + #expect(listing.isFirstPrint) + #expect(listing.gradeLabel == "Raw · NM") + #expect(listing.defects == ["Spine stress / ticks"]) + #expect(listing.photos == ["/uploads/a.jpg"]) + #expect(listing.declaration == "Declared defects: Spine stress / ticks. Two ticks near the top staple.") + #expect(listing.isAvailable) + } + + @Test(arguments: zip( + ["Nightward #1 CGC 9.8", "Hollow Comet, Vol. 4 — Sōkai", " Raw · NM+ ", "???"], + ["nightward-1-cgc-9-8", "hollow-comet-vol-4-sokai", "raw-nm", ""] + )) + func `slugs a title down to letters, digits, and hyphens`( + _ title: String, + expects slug: String + ) { + #expect(ListingDraft.slug(title) == slug) + } + + // MARK: Repository tests + + @Test + func `publishes into the seller's shop, under a slug of its own, and into the search`() async throws { + try await withDatabase { fluent in + let catalogue = CatalogueRepository(fluent: fluent) + let joost = try await AccountRepository(fluent: fluent).account(email: "joost@kestrelrow.example") + let first = try #require(try await catalogue.publish(Self.draft(), by: joost)) + let second = try #require(try await catalogue.publish(Self.draft(), by: joost)) + let found = try await catalogue.listing(slug: first.slug) + let newest = try await catalogue.listings().first + + #expect(first.slug == "nightward-3-raw-nm") + #expect(second.slug == "nightward-3-raw-nm-2") + #expect(first.shop.slug == "kestrel-row-comics") + #expect(found?.defects == ["Spine stress / ticks"]) + #expect(found?.photos == ["/uploads/a.jpg"]) + #expect(newest?.slug == second.slug) + } + } + + @Test + func `publishes nothing for an account without a shop`() async throws { + try await withDatabase { fluent in + let mara = try await AccountRepository(fluent: fluent).account(email: "mara.v@example.com") + let listing = try await CatalogueRepository(fluent: fluent).publish(Self.draft(), by: mara) + + #expect(listing == nil) + } + } + + @Test + func `freezes the declared defects into a sale`() async throws { + try await withDatabase { fluent in + let accounts = AccountRepository(fluent: fluent) + let joost = try await accounts.account(email: "joost@kestrelrow.example") + let mara = try await accounts.account(email: "mara.v@example.com") + let listing = try #require(try await CatalogueRepository(fluent: fluent).publish(Self.draft(), by: joost)) + let trade = TradeRepository(fluent: fluent) + let number = try await trade.buy(listing: listing.slug, buyer: mara) + let sale = try await trade.sale(number: number, for: mara) + + #expect(sale?.declared.notes == "Declared defects: Spine stress / ticks. Two ticks near the top staple.") + } + } + +} + +// MARK: - Helpers + +extension ListingDraftTests { + + /// A draft of a raw Nightward #3 with one declared defect and one photo, unless told otherwise. + static func draft( + series: String = "Nightward", + issue: String = "3", + format: String = "single-issues", + printing: Int = 1, + edition: String = "Direct" + ) -> ListingDraft { + .init( + series: series, issue: issue, publisher: "Meridian Comics", year: 2019, format: format, + printing: printing, edition: edition, grader: nil, grade: 9.4, condition: "NM", certNumber: nil, + pageQuality: nil, keyNotes: nil, defects: ["Spine stress / ticks"], + sellerNotes: "Two ticks near the top staple.", priceCents: 3_000, shippingCents: 500, + acceptsOffers: true, photos: ["/uploads/a.jpg"] + ) + } + +} diff --git a/Packages/Persistence/Tests/Cases/Public/Repositories/AccountRepositoryTests.swift b/Packages/Persistence/Tests/Cases/Public/Repositories/AccountRepositoryTests.swift index 25b219e..e3b9099 100644 --- a/Packages/Persistence/Tests/Cases/Public/Repositories/AccountRepositoryTests.swift +++ b/Packages/Persistence/Tests/Cases/Public/Repositories/AccountRepositoryTests.swift @@ -33,3 +33,61 @@ struct AccountRepositoryTests { } } + +// MARK: - Passkeys + +extension AccountRepositoryTests { + + @Test + func `joins an account together with its first passkey`() async throws { + try await withDatabase { fluent in + let accounts = AccountRepository(fluent: fluent) + let free = try await accounts.isUnregistered(credentialID: "cred-1") + let sam = try #require(try await accounts.join(email: "sam@example.com", credentialID: "cred-1", publicKey: [1, 2, 3], signCount: 0)) + let passkey = try await accounts.passkey(credentialID: "cred-1") + let byEmail = try await accounts.passkeys(email: "sam@example.com") + let taken = try await accounts.isUnregistered(credentialID: "cred-1") + + #expect(free) + #expect(!taken) + #expect(sam.displayName == "sam") + #expect(passkey == Passkey(credentialID: "cred-1", publicKey: [1, 2, 3], signCount: 0, user: sam)) + #expect(byEmail == [passkey]) + } + } + + @Test + func `never adds a passkey to an account that already exists`() async throws { + try await withDatabase { fluent in + let accounts = AccountRepository(fluent: fluent) + let hijack = try await accounts.join(email: AccountRepository.demoEmail, credentialID: "cred-x", publicKey: [9], signCount: 0) + let passkeys = try await accounts.passkeys(email: AccountRepository.demoEmail) + let stillFree = try await accounts.isUnregistered(credentialID: "cred-x") + + #expect(hijack == nil) + #expect(passkeys.isEmpty) + #expect(stillFree) + } + } + + @Test + func `records a passkey's signature counter, and finds accounts without creating them`() async throws { + try await withDatabase { fluent in + let accounts = AccountRepository(fluent: fluent) + + _ = try await accounts.join(email: "sam@example.com", credentialID: "cred-1", publicKey: [1], signCount: 0) + try await accounts.recordUse(ofPasskey: "cred-1", signCount: 7) + + let passkey = try await accounts.passkey(credentialID: "cred-1") + let known = try await accounts.existingAccount(email: "sam@example.com") + let unknown = try await accounts.existingAccount(email: "nobody@example.com") + let stillUnknown = try await accounts.existingAccount(email: "nobody@example.com") + + #expect(passkey?.signCount == 7) + #expect(known?.email == "sam@example.com") + #expect(unknown == nil) + #expect(stillUnknown == nil) + } + } + +}