Files
ccn/Packages/Persistence/Tests/Cases/Public/Repositories/TradeRepositoryTests.swift
T

341 lines
14 KiB
Swift
Raw Normal View History

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
)
}
}
}