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