Implemented phase 3 for the POC prototype in the Website service.
This commit is contained in:
1 parent
b1ceed7790
commit
0dc3fe6bea
29 files changed
+4089
-66
No files matched your search
@@ -0,0 +1,343 @@
|
||||
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
|
||||
#expect(String(buffer: response.body).contains("This copy is sold."))
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user