2026-09-21 12:11:25 +02:00
|
|
|
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</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¬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
|
2026-09-21 13:53:13 +02:00
|
|
|
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."))
|
2026-09-21 12:11:25 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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<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()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|