Files
ccn/Services/Website/Tests/Library/Cases/Public/Controllers/TradeControllerTests.swift
T

348 lines
14 KiB
Swift

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)&note=Hello")
) { response in
let body = String(buffer: response.body)
#expect(response.status == .unprocessableContent)
#expect(body.contains(explanation))
#expect(body.contains(">Hello</textarea>"))
}
}
}
// 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&note=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&note=iDEAL", as: mara)
_ = try await post(client, sale, "step=sent&note=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<WebsiteRequestContext>(repository: catalogue, sessions: sessions).routes)
router.addRoutes(SessionController<WebsiteRequestContext>(sessions: sessions, accounts: .init(fluent: fluent), catalogue: catalogue).routes)
router.addRoutes(TradeController<WebsiteRequestContext>(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()
}
}