import Hummingbird import HummingbirdTesting import Logging import NIOCore import Persistence import Testing @testable import WebsiteLibrary @Suite( "TradeController controller", .tags(.controller) ) struct TradeControllerTests { // MARK: Constants private let listing = "/listing/nightward-1-cgc-9-8" private let seller = "joost@kestrelrow.example" // MARK: Gate tests @Test(arguments: [ "/listing/nightward-1-cgc-9-8/offer", "/listing/nightward-1-cgc-9-8/buy", "/messages", "/sell", "/sales/48213", ]) func `leads a signed-out visitor to the sign-in screen, and back`( uri: String ) async throws { try await withApp { client in try await client.execute(uri: uri, method: .get) { response in #expect(response.status == .found) #expect(response.headers[.location] == "/sign-in?return=\(uri)") } } } @Test func `takes no step for a signed-out visitor`() async throws { try await withApp { client in let location = try await post(client, "\(listing)/buy", "", as: nil) #expect(location == "/sign-in?return=\(listing)") try await client.execute(uri: listing, method: .get) { response in #expect(String(buffer: response.body).contains("Buy now")) } } } // MARK: Offers tests @Test func `shows the sheet to a buyer, and leads the listing's own seller back to it`() async throws { try await withApp { client in let mara = try await signIn(client) let joost = try await signIn(client, email: seller) try await client.execute(uri: "\(listing)/offer?amount=204", method: .get, headers: [.cookie: mara]) { response in let body = String(buffer: response.body) #expect(response.status == .ok) #expect(response.headers[.cacheControl] == "private, no-cache") #expect(body.contains(#"name="amount" value="204""#)) } try await client.execute(uri: "\(listing)/offer", method: .get, headers: [.cookie: joost]) { response in #expect(response.status == .found) #expect(response.headers[.location] == listing) } // A listing that takes no offers can still be bought. try await client.execute(uri: "/listing/echo-country-3/offer", method: .get, headers: [.cookie: mara]) { response in #expect(response.headers[.location] == "/listing/echo-country-3") } try await client.execute(uri: "/listing/echo-country-3/buy", method: .get, headers: [.cookie: mara]) { response in #expect(response.status == .ok) } } } @Test(arguments: zip( ["amount=150", "amount=abc", "amount=241"], ["auto-declines offers under €192", "Enter an amount above zero", "Enter an amount above zero"] )) func `gives the sheet back with a 422 for an offer that breaks a rule`( form: String, expects explanation: String ) async throws { try await withApp { client in let mara = try await signIn(client) try await client.execute( uri: "\(listing)/offer", method: .post, headers: [.cookie: mara, .contentType: "application/x-www-form-urlencoded"], body: ByteBuffer(string: "\(form)¬e=Hello") ) { response in let body = String(buffer: response.body) #expect(response.status == .unprocessableContent) #expect(body.contains(explanation)) #expect(body.contains(">Hello")) } } } // MARK: Flow tests @Test func `walks from an offer to a rated sale, between two signed-in accounts`() async throws { try await withApp { client in let mara = try await signIn(client) let joost = try await signIn(client, email: seller) let conversation = try await post(client, "\(listing)/offer", "amount=215,00¬e=Would+you+take+215%3F", as: mara) #expect(conversation.hasPrefix("/messages/")) _ = try await post(client, conversation, "body=Is+the+case+clean%3F", as: mara) // The seller hears of it in the header and the dashboard, and answers from the conversation. let offer = try await client.execute(uri: "/sell", method: .get, headers: [.cookie: joost]) { response in let body = String(buffer: response.body) #expect(body.contains(#"aria-label="Messages, 1 unread""#)) #expect(body.contains("from Mara Visser")) return try #require(body.firstMatch(of: /\/sell\/offers\/[0-9a-f-]+/)).output } try await client.execute(uri: conversation, method: .get, headers: [.cookie: joost]) { response in let body = String(buffer: response.body) #expect(body.contains("Their offer")) #expect(body.contains("“Would you take 215?”")) #expect(body.contains("Is the case clean?")) } let sale = try await post(client, String(offer), "answer=accept&return=/sell", as: joost) #expect(sale == "/sales/48213") // The copy is off sale, for everyone. try await client.execute(uri: listing, method: .get) { response in let body = String(buffer: response.body) #expect(body.contains("This copy sold on ")) // Three other copies of Nightward #1 remain listed, none of them in this shop. #expect(body.contains("has 0 more copies of Nightward #1, and 3 are listed across CCN.")) } // A step out of turn changes nothing. _ = try await post(client, sale, "step=sent", as: joost) _ = try await post(client, sale, "step=paid", as: joost) try await client.execute(uri: sale, method: .get, headers: [.cookie: mara]) { response in let body = String(buffer: response.body) #expect(body.contains("Awaiting payment")) #expect(body.contains("you are the buyer")) #expect(body.contains("€215 · offer accepted (asked €240)")) } _ = try await post(client, sale, "step=paid¬e=iDEAL", as: mara) _ = try await post(client, sale, "step=sent¬e=DHL+3S123", as: joost) _ = try await post(client, sale, "step=received", as: mara) _ = try await post(client, sale, "step=rated&stars=5", as: mara) _ = try await post(client, sale, "step=rated&stars=4", as: joost) try await client.execute(uri: sale, method: .get, headers: [.cookie: joost]) { response in let body = String(buffer: response.body) #expect(body.contains("Complete")) #expect(body.contains("DHL 3S123")) #expect(body.contains("★★★★★")) #expect(body.contains("★★★★☆")) } } } @Test func `buys at the asking price, straight to the sale record`() async throws { try await withApp { client in let mara = try await signIn(client) let sale = try await post(client, "\(listing)/buy", "", as: mara) #expect(sale == "/sales/48213") // Sold: a second buyer gets the sheet's way out, not a second sale. let sam = try await signIn(client, email: "sam@example.com") try await client.execute(uri: "\(listing)/buy", method: .get, headers: [.cookie: sam]) { response in #expect(response.headers[.location] == listing) } } } @Test func `declining leads back to where the answer was given`() async throws { try await withApp { client in let mara = try await signIn(client) let joost = try await signIn(client, email: seller) let conversation = try await post(client, "\(listing)/offer", "amount=215", as: mara) let offer = try await client.execute(uri: conversation, method: .get, headers: [.cookie: joost]) { response in String(try #require(String(buffer: response.body).firstMatch(of: /\/sell\/offers\/[0-9a-f-]+/)).output) } let location = try await post(client, offer, "answer=decline&return=\(conversation)", as: joost) #expect(location == conversation) try await client.execute(uri: conversation, method: .get, headers: [.cookie: mara]) { response in let body = String(buffer: response.body) #expect(body.contains(">Declined<")) #expect(body.contains("Kestrel Row Comics declined the offer")) // Free to offer again. #expect(body.contains(#"href="\#(listing)/offer""#)) } } } // MARK: Privacy tests @Test func `keeps conversations and sales to their parties`() async throws { try await withApp { client in let mara = try await signIn(client) let sam = try await signIn(client, email: "sam@example.com") let conversation = try await post(client, "\(listing)/offer", "amount=215", as: mara) let sale = try await post(client, "/listing/echo-country-3/buy", "", as: mara) for uri in [conversation, sale, "/messages/not-an-id", "/sales/1"] { try await client.execute(uri: uri, method: .get, headers: [.cookie: sam]) { response in #expect(response.status == .notFound) } } try await client.execute( uri: conversation, method: .post, headers: [.cookie: sam, .contentType: "application/x-www-form-urlencoded"], body: ByteBuffer(string: "body=Hello") ) { response in #expect(response.status == .notFound) } } } @Test func `starts one conversation per listing, and leads an account without a shop to where one is opened`() async throws { try await withApp { client in let mara = try await signIn(client) let first = try await get(client, "/messages/new?listing=nightward-1-cgc-9-8", as: mara) let again = try await get(client, "/messages/new?listing=nightward-1-cgc-9-8", as: mara) let shop = try await get(client, "/messages/new?shop=kestrel-row-comics", as: mara) let dashboard = try await get(client, "/sell", as: mara) #expect(first.hasPrefix("/messages/")) #expect(first == again) #expect(shop != first) #expect(dashboard == "/sell/open-a-shop") } } } // MARK: - Helpers private extension TradeControllerTests { /// Requests a path as an account, returning where it leads. func get( _ client: any TestClientProtocol, _ uri: String, as cookie: String ) async throws -> String { try await client.execute(uri: uri, method: .get, headers: [.cookie: cookie]) { response in try #require(response.headers[.location]) } } /// Posts a form as an account — or signed out — returning where it leads. func post( _ client: any TestClientProtocol, _ uri: String, _ form: String, as cookie: String? ) async throws -> String { var headers: HTTPFields = [.contentType: "application/x-www-form-urlencoded"] headers[.cookie] = cookie return try await client.execute(uri: uri, method: .post, headers: headers, body: ByteBuffer(string: form)) { response in #expect(response.status == .seeOther) return try #require(response.headers[.location]) } } /// Signs in — as the demo buyer, without an email — returning the session cookie as a `Cookie` header value. func signIn( _ client: any TestClientProtocol, email: String = "" ) async throws -> String { try await client.execute( uri: "/sign-in", method: .post, headers: [.contentType: "application/x-www-form-urlencoded"], body: ByteBuffer(string: "email=\(email)&return=/") ) { response in String(try #require(response.headers[.setCookie]).prefix { $0 != ";" }) } } /// Runs the body against the session, catalogue, and trade routes sharing one session store, over a migrated, seeded in-memory database. func withApp( _ body: @escaping @Sendable (any TestClientProtocol) async throws -> Void ) async throws { let fluent = try Service( driver: .inMemory, logger: Logger(label: "test") )() do { await PrepareDB()(for: fluent) try await fluent.migrate() let catalogue = CatalogueRepository(fluent: fluent) let trade = TradeRepository(fluent: fluent) let sessions = SessionStore(trade: trade) let router = Router(context: WebsiteRequestContext.self) router.addRoutes(CatalogueController(repository: catalogue, sessions: sessions).routes) router.addRoutes(SessionController(sessions: sessions, accounts: .init(fluent: fluent), catalogue: catalogue).routes) router.addRoutes(TradeController(trade: trade, catalogue: catalogue, sessions: sessions).routes) try await Application(router: router).test(.router, body) } catch { try? await fluent.shutdown() throw error } try await fluent.shutdown() } }