341 lines
14 KiB
Swift
341 lines
14 KiB
Swift
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
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
}
|