Added the catalogue models and seed to support phase 3 of the POC prototype in the Persistence package.

This commit is contained in:
javier committed 2026-09-21 12:11:01 +02:00
1 parent 43dcc03da6
commit b1ceed7790
20 files changed
+2035 -8

No files matched your search

@@ -0,0 +1,118 @@
import Foundation
import Testing
@testable import Persistence
@Suite(
"Sale model",
.tags(.model)
)
struct SaleTests {
// MARK: Computed tests
@Test
func `walks the handshake one step at a time`() {
#expect(sale().completed == .agreed)
#expect(sale().next == .paid)
#expect(sale(paid: true).completed == .paid)
#expect(sale(paid: true, sent: true).completed == .sent)
#expect(sale(paid: true, sent: true, received: true).completed == .received)
#expect(sale(paid: true, sent: true, received: true, buyerRating: 5).completed == .received)
#expect(sale(paid: true, sent: true, received: true, buyerRating: 5, sellerRating: 4).completed == .rated)
#expect(sale(paid: true, sent: true, received: true, buyerRating: 5, sellerRating: 4).next == nil)
}
@Test
func `adds the shipping to the price`() {
#expect(sale().totalCents == 22_100)
}
// MARK: Methods tests
@Test
func `gives each step to its party, once, in order`() {
#expect(sale().allows(.markPaid(note: ""), by: .buyer))
#expect(!sale().allows(.markPaid(note: ""), by: .seller))
#expect(!sale(paid: true).allows(.markPaid(note: ""), by: .buyer))
#expect(!sale().allows(.markSent(note: ""), by: .seller))
#expect(sale(paid: true).allows(.markSent(note: ""), by: .seller))
#expect(!sale(paid: true).allows(.markSent(note: ""), by: .buyer))
#expect(!sale(paid: true).allows(.markReceived, by: .buyer))
#expect(sale(paid: true, sent: true).allows(.markReceived, by: .buyer))
#expect(!sale(paid: true, sent: true).allows(.markReceived, by: .seller))
}
@Test
func `unlocks ratings only once the copy is received, one per party, out of five`() {
let received = sale(paid: true, sent: true, received: true)
#expect(!sale(paid: true, sent: true).allows(.rate(stars: 5), by: .buyer))
#expect(received.allows(.rate(stars: 5), by: .buyer))
#expect(received.allows(.rate(stars: 1), by: .seller))
#expect(!received.allows(.rate(stars: 0), by: .buyer))
#expect(!received.allows(.rate(stars: 6), by: .buyer))
#expect(!sale(paid: true, sent: true, received: true, buyerRating: 5).allows(.rate(stars: 4), by: .buyer))
#expect(sale(paid: true, sent: true, received: true, buyerRating: 5).allows(.rate(stars: 4), by: .seller))
}
@Test
func `knows its parties`() {
let sale = sale()
#expect(sale.role(of: sale.buyer.id) == .buyer)
#expect(sale.role(of: sale.seller.id) == .seller)
#expect(sale.role(of: .init()) == nil)
}
// MARK: Offer tests
@Test(arguments: zip(
[24_000, 1_400, 4_500, 100],
[19_200, 1_200, 3_600, 100]
))
func `floors offers at four fifths of the asking price, in whole euros`(
asking: Int,
expects floor: Int
) {
#expect(Offer.floor(forAsking: asking) == floor)
}
}
// MARK: - Helpers
private extension SaleTests {
/// A sale of the first demo listing at €215 + €6, as far along as told.
func sale(
paid: Bool = false,
sent: Bool = false,
received: Bool = false,
buyerRating: Int? = nil,
sellerRating: Int? = nil
) -> Sale {
let stamp = Date(timeIntervalSince1970: 0)
return .init(
number: 48_213,
conversationID: .init(),
listing: Seed.listings[0],
buyer: .init(id: .init(), email: "mara.v@example.com", displayName: "Mara Visser"),
seller: .init(id: .init(), email: "joost@kestrelrow.example", displayName: "Joost de Wit"),
priceCents: 21_500,
askedCents: 24_000,
shippingCents: 600,
declared: .init(grade: "CGC 9.8", pageQuality: "White", printing: "First printing", notes: ""),
agreedAt: stamp,
paidAt: paid ? stamp : nil,
sentAt: sent ? stamp : nil,
receivedAt: received ? stamp : nil,
buyerRating: buyerRating,
sellerRating: sellerRating
)
}
}
@@ -0,0 +1,341 @@
import Foundation
import HummingbirdFluent
import Testing
@testable import Persistence
@Suite(
"TradeRepository repository",
.tags(.repository)
)
struct TradeRepositoryTests {
// MARK: Constants
/// The first demo listing: Kestrel Row's Nightward #1, asking €240, open to offers.
private let slug = "nightward-1-cgc-9-8"
// MARK: Offers tests
@Test
func `makes an offer in the buyer's conversation about the listing`() async throws {
try await withTrade { trade, mara, joost, _ in
let id = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 21_500, note: " Would you take €215? ")
let buying = try #require(try await trade.conversation(id: id, for: mara))
let inbox = try await trade.openOffers(for: joost)
#expect(buying.side == .buying)
#expect(buying.listing?.slug == slug)
#expect(buying.entries.map(\.kind) == [.offer])
#expect(buying.entries[0].body == "Would you take €215?")
#expect(buying.openOffer?.amountCents == 21_500)
#expect(inbox.map(\.amountCents) == [21_500])
#expect(inbox[0].buyer == mara)
}
}
@Test
func `turns down offers that break the rules`() async throws {
try await withTrade { trade, mara, joost, _ in
await #expect(throws: TradeError.offerTooLow(floorCents: 19_200)) {
try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 19_100, note: "")
}
await #expect(throws: TradeError.invalidAmount) {
try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 24_100, note: "")
}
await #expect(throws: TradeError.invalidAmount) {
try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 0, note: "")
}
await #expect(throws: TradeError.offersNotAccepted) {
try await trade.makeOffer(onListing: "echo-country-3", buyer: mara, amountCents: 1_300, note: "")
}
await #expect(throws: TradeError.ownListing) {
try await trade.makeOffer(onListing: slug, buyer: joost, amountCents: 21_500, note: "")
}
await #expect(throws: TradeError.listingUnavailable) {
try await trade.makeOffer(onListing: "missing", buyer: mara, amountCents: 21_500, note: "")
}
_ = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 21_500, note: "")
await #expect(throws: TradeError.offerAlreadyOpen) {
try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 22_000, note: "")
}
}
}
@Test
func `lapses an offer left unanswered for 48 hours`() async throws {
try await withTrade { trade, mara, joost, clock in
let id = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 21_500, note: "")
let offer = try #require(try await trade.openOffers(for: joost).first)
clock.advance(by: Offer.lifetime)
let conversation = try #require(try await trade.conversation(id: id, for: mara))
let inbox = try await trade.openOffers(for: joost)
#expect(conversation.entries[0].offer?.status == .expired)
#expect(conversation.openOffer == nil)
#expect(inbox.isEmpty)
await #expect(throws: TradeError.notAllowed) {
try await trade.respond(toOffer: offer.id, accepting: true, by: joost)
}
// The listing is free for a new offer.
_ = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 22_000, note: "")
}
}
@Test
func `declines an offer, leaving the listing for sale`() async throws {
try await withTrade { trade, mara, joost, _ in
let id = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 21_500, note: "")
let offer = try #require(try await trade.openOffers(for: joost).first)
let number = try await trade.respond(toOffer: offer.id, accepting: false, by: joost)
let conversation = try #require(try await trade.conversation(id: id, for: mara))
#expect(number == nil)
#expect(conversation.entries.map(\.kind) == [.offer, .event])
#expect(conversation.entries[0].offer?.status == .declined)
#expect(conversation.entries[1].body == "declined the offer")
#expect(conversation.saleNumber == nil)
}
}
@Test
func `lets only the shop's owner answer an offer`() async throws {
try await withTrade { trade, mara, joost, _ in
_ = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 21_500, note: "")
let offer = try #require(try await trade.openOffers(for: joost).first)
await #expect(throws: TradeError.notAllowed) {
try await trade.respond(toOffer: offer.id, accepting: true, by: mara)
}
}
}
// MARK: Sales tests
@Test
func `accepting an offer opens a sale, takes the listing off sale, and lapses the other offers`() async throws {
try await withDatabase { fluent in
let clock = Clock()
let trade = TradeRepository(fluent: fluent, now: clock.now)
let accounts = AccountRepository(fluent: fluent)
let mara = try await accounts.account(email: "mara.v@example.com")
let sam = try await accounts.account(email: "sam@example.com")
let joost = try await accounts.account(email: "joost@kestrelrow.example")
_ = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 21_500, note: "")
let other = try await trade.makeOffer(onListing: slug, buyer: sam, amountCents: 20_000, note: "")
let offer = try #require(try await trade.openOffers(for: joost).first { $0.buyer == mara })
let number = try #require(try await trade.respond(toOffer: offer.id, accepting: true, by: joost))
let sale = try #require(try await trade.sale(number: number, for: mara))
let listing = try await CatalogueRepository(fluent: fluent).listing(slug: slug)
let forSale = try await CatalogueRepository(fluent: fluent).listings()
let lapsed = try #require(try await trade.conversation(id: other, for: sam))
let inbox = try await trade.openOffers(for: joost)
#expect(number == 48_213)
#expect(sale.priceCents == 21_500)
#expect(sale.askedCents == 24_000)
#expect(sale.shippingCents == 600)
#expect(sale.declared.grade == "CGC 9.8 · cert 4312087-002")
#expect(sale.buyer == mara)
#expect(sale.seller == joost)
#expect(sale.completed == .agreed)
#expect(listing?.isAvailable == false)
#expect(!forSale.contains { $0.slug == slug })
#expect(lapsed.entries[0].offer?.status == .expired)
#expect(inbox.isEmpty)
await #expect(throws: TradeError.listingUnavailable) {
try await trade.buy(listing: slug, buyer: sam)
}
}
}
@Test
func `buys at the asking price, numbering the sales in order`() async throws {
try await withTrade { trade, mara, _, _ in
let first = try await trade.buy(listing: slug, buyer: mara)
let second = try await trade.buy(listing: "echo-country-3", buyer: mara)
let sale = try #require(try await trade.sale(number: second, for: mara))
let sales = try await trade.sales(for: mara)
#expect(first == 48_213)
#expect(second == 48_214)
#expect(sale.priceCents == sale.askedCents)
#expect(sales.count == 2)
}
}
@Test
func `walks the handshake, stamping each step into the conversation`() async throws {
try await withTrade { trade, mara, joost, _ in
let number = try await trade.buy(listing: slug, buyer: mara)
await #expect(throws: TradeError.notAllowed) {
try await trade.perform(.markSent(note: ""), onSale: number, by: joost)
}
await #expect(throws: TradeError.notAllowed) {
try await trade.perform(.markPaid(note: ""), onSale: number, by: joost)
}
try await trade.perform(.markPaid(note: " iDEAL "), onSale: number, by: mara)
try await trade.perform(.markSent(note: "DHL 3S123"), onSale: number, by: joost)
try await trade.perform(.markReceived, onSale: number, by: mara)
try await trade.perform(.rate(stars: 5), onSale: number, by: mara)
let sale = try await trade.perform(.rate(stars: 4), onSale: number, by: joost)
let conversation = try #require(try await trade.conversation(id: sale.conversationID, for: joost))
#expect(sale.completed == .rated)
#expect(sale.paymentNote == "iDEAL")
#expect(sale.shippingNote == "DHL 3S123")
#expect(sale.buyerRating == 5)
#expect(sale.sellerRating == 4)
#expect(conversation.saleNumber == number)
#expect(conversation.entries.map(\.body) == [
"bought the copy at the asking price", "marked the sale paid", "marked the copy sent",
"marked the copy received", "left a rating", "left a rating",
])
}
}
@Test
func `shows a sale to its parties alone`() async throws {
try await withDatabase { fluent in
let trade = TradeRepository(fluent: fluent)
let accounts = AccountRepository(fluent: fluent)
let mara = try await accounts.account(email: "mara.v@example.com")
let sam = try await accounts.account(email: "sam@example.com")
let number = try await trade.buy(listing: slug, buyer: mara)
let strangers = try await trade.sale(number: number, for: sam)
let none = try await trade.sales(for: sam)
#expect(strangers == nil)
#expect(none.isEmpty)
}
}
// MARK: Conversations tests
@Test
func `keeps one conversation per buyer and listing, and one with the shop at large`() async throws {
try await withTrade { trade, mara, _, _ in
let first = try await trade.conversation(aboutListing: slug, buyer: mara)
let again = try await trade.conversation(aboutListing: slug, buyer: mara)
let shop = try await trade.conversation(withShop: "kestrel-row-comics", buyer: mara)
let shopAgain = try await trade.conversation(withShop: "kestrel-row-comics", buyer: mara)
#expect(first == again)
#expect(shop == shopAgain)
#expect(first != shop)
}
}
@Test
func `counts what the other side said as unread until the conversation is opened`() async throws {
try await withTrade { trade, mara, joost, clock in
let id = try await trade.conversation(aboutListing: slug, buyer: mara)
clock.advance(by: 60)
try await trade.post("Is the case clean?", to: id, from: mara)
try await trade.post(" ", to: id, from: mara)
let before = try await trade.unreadCount(for: joost)
let own = try await trade.unreadCount(for: mara)
clock.advance(by: 60)
let opened = try #require(try await trade.conversation(id: id, for: joost))
let after = try await trade.unreadCount(for: joost)
#expect(before == 1)
#expect(own == 0)
#expect(opened.side == .selling)
#expect(opened.unreadCount == 1)
#expect(opened.entries.map(\.body) == ["Is the case clean?"])
#expect(after == 0)
}
}
@Test
func `keeps strangers out of a conversation`() async throws {
try await withDatabase { fluent in
let trade = TradeRepository(fluent: fluent)
let accounts = AccountRepository(fluent: fluent)
let mara = try await accounts.account(email: "mara.v@example.com")
let sam = try await accounts.account(email: "sam@example.com")
let id = try await trade.conversation(aboutListing: slug, buyer: mara)
let peek = try await trade.conversation(id: id, for: sam)
#expect(peek == nil)
await #expect(throws: TradeError.notAllowed) {
try await trade.post("Hello", to: id, from: sam)
}
}
}
@Test
func `finds the shop an account owns`() async throws {
try await withTrade { trade, mara, joost, _ in
let owned = try await trade.shop(ownedBy: joost)
let none = try await trade.shop(ownedBy: mara)
#expect(owned?.slug == "kestrel-row-comics")
#expect(none == nil)
}
}
}
// MARK: - Helpers
/// A clock the tests move by hand.
final class Clock: @unchecked Sendable {
private let lock = NSLock()
private var date = Date(timeIntervalSince1970: 1_790_000_000)
/// The clock's reading.
@Sendable
func now() -> Date {
lock.withLock { date }
}
/// Moves the clock forward.
func advance(
by interval: TimeInterval
) {
lock.withLock { date.addTimeInterval(interval) }
}
}
private extension TradeRepositoryTests {
/// Runs the body against a seeded database, with the demo buyer, the first demo listing's seller, and the clock the repository reads.
func withTrade(
_ body: (TradeRepository, User, User, Clock) async throws -> Void
) async throws {
try await withDatabase { fluent in
let accounts = AccountRepository(fluent: fluent)
let clock = Clock()
try await body(
.init(fluent: fluent, now: clock.now),
try await accounts.account(email: "mara.v@example.com"),
try await accounts.account(email: "joost@kestrelrow.example"),
clock
)
}
}
}