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
@@ -25,6 +25,7 @@ struct SiteHeaderTests {
|
||||
func `renders the signed-in actions for a viewer`() {
|
||||
let html = SiteHeader(viewer: .init(
|
||||
displayName: "Mara Visser",
|
||||
shopSlug: "kestrel-row-comics",
|
||||
newWantMatches: 2,
|
||||
unreadMessages: 3
|
||||
)).render()
|
||||
@@ -38,6 +39,11 @@ struct SiteHeaderTests {
|
||||
#expect(!html.contains(#"href="/sign-in""#))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `links the shop of a seller alone`() {
|
||||
#expect(!SiteHeader(viewer: .init(displayName: "Mara")).render().contains(#"href="/sell""#))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `omits a badge that counts nothing`() {
|
||||
let html = SiteHeader(viewer: .init(displayName: "Mara")).render()
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"Date+Stamps extension",
|
||||
.tags(.extensionTests)
|
||||
)
|
||||
struct DateStampsTests {
|
||||
|
||||
// MARK: Methods tests
|
||||
|
||||
@Test(arguments: zip(
|
||||
[0, -86_400, -172_800, -25_920_000] as [TimeInterval],
|
||||
["today, 09:14", "yesterday, 09:14", "21 Sep, 09:14", "27 Nov 2025, 08:14"]
|
||||
))
|
||||
func `stamps a date relative to today, on Amsterdam's clock`(
|
||||
offset: TimeInterval,
|
||||
expects stamp: String
|
||||
) {
|
||||
#expect(Date.stub.addingTimeInterval(offset).stamp(relativeTo: .stub) == stamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `tells days apart at Amsterdam's midnight, not UTC's`() {
|
||||
// 23:30 and 01:00 in Amsterdam are 21:30 and 23:00 UTC: one day to UTC, two to Amsterdam.
|
||||
let lateEvening = Date.stub.addingTimeInterval(-35_040)
|
||||
let smallHours = Date.stub.addingTimeInterval(-29_640)
|
||||
|
||||
#expect(lateEvening.stamp(relativeTo: smallHours) == "yesterday, 23:30")
|
||||
}
|
||||
|
||||
@Test(arguments: zip(
|
||||
[171_000, 1_800, 180_000, 259_260] as [TimeInterval],
|
||||
["47 h left", "under an hour left", "2 days left", "3 days left"]
|
||||
))
|
||||
func `counts down in hours, then days`(
|
||||
interval: TimeInterval,
|
||||
expects countdown: String
|
||||
) {
|
||||
#expect(Date.stub.addingTimeInterval(interval).countdown(from: .stub) == countdown)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
import Persistence
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"Trade pages",
|
||||
.tags(.page)
|
||||
)
|
||||
struct TradePagesTests {
|
||||
|
||||
// MARK: OfferPage tests
|
||||
|
||||
@Test
|
||||
func `offer sheet fills in a tenth under asking and offers the picks the seller would hear`() {
|
||||
let html = OfferPage(listing: .stub(), mode: .offer).render()
|
||||
|
||||
#expect(html.contains(#"action="/listing/nightward-1/offer" method="post""#))
|
||||
#expect(html.contains(#"name="amount" value="216""#))
|
||||
#expect(html.contains(#"href="/listing/nightward-1/offer?amount=216" aria-current="true">€216 · −10%"#))
|
||||
#expect(html.contains("€192 · −20%"))
|
||||
#expect(html.contains("auto-declines under <strong>€192</strong>"))
|
||||
#expect(html.contains("+ €6 shipping to NL"))
|
||||
#expect(html.contains(#"content="noindex""#))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `offer sheet leaves out a pick under the seller's floor`() {
|
||||
// €88 asking: the floor rounds up to €71, over the −20% pick of €70.
|
||||
let html = OfferPage(listing: .stub(priceCents: 8_800), mode: .offer).render()
|
||||
|
||||
#expect(html.contains("€75 · −15%"))
|
||||
#expect(!html.contains("−20%"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `offer sheet gives back what was typed, saying what went wrong`() {
|
||||
let html = OfferPage(
|
||||
listing: .stub(),
|
||||
mode: .offer,
|
||||
amountCents: 15_000,
|
||||
note: "Bundle?",
|
||||
error: TradeError.offerTooLow(floorCents: 19_200).explanation
|
||||
).render()
|
||||
|
||||
#expect(html.contains(#"value="150""#))
|
||||
#expect(html.contains(">Bundle?</textarea>"))
|
||||
#expect(html.contains(#"role="alert">The seller auto-declines offers under €192."#))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `buy sheet sums the asking price and the shipping`() {
|
||||
let html = OfferPage(listing: .stub(), mode: .buy).render()
|
||||
|
||||
#expect(html.contains(#"action="/listing/nightward-1/buy" method="post""#))
|
||||
#expect(html.contains("Agree to buy for €246"))
|
||||
#expect(html.contains("CCN handles no money"))
|
||||
#expect(!html.contains(#"name="amount""#))
|
||||
}
|
||||
|
||||
// MARK: SalePage tests
|
||||
|
||||
@Test
|
||||
func `sale record hands each party the step that is theirs`() {
|
||||
func page(_ sale: Sale, _ role: Sale.Role) -> String {
|
||||
SalePage(sale: sale, role: role, now: .stub).render()
|
||||
}
|
||||
|
||||
#expect(page(.stub(), .buyer).contains(#"name="step" value="paid""#))
|
||||
#expect(page(.stub(), .seller).contains("Waiting for the buyer to pay"))
|
||||
#expect(page(.stub(paid: true), .seller).contains(#"name="step" value="sent""#))
|
||||
#expect(page(.stub(paid: true), .buyer).contains("Waiting for the seller to dispatch"))
|
||||
#expect(page(.stub(paid: true, sent: true), .buyer).contains(#"name="step" value="received""#))
|
||||
#expect(page(.stub(paid: true, sent: true, received: true), .seller).contains(#"name="stars" value="5""#))
|
||||
#expect(page(.stub(paid: true, sent: true, received: true, buyerRating: 5), .buyer).contains("Waiting for the other side"))
|
||||
#expect(!page(.stub(paid: true, sent: true, received: true, buyerRating: 5, sellerRating: 4), .buyer).contains(#"name="step""#))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `sale record states what was agreed, frozen, and where the handshake stands`() {
|
||||
let html = SalePage(sale: .stub(paid: true), role: .buyer, now: .stub).render()
|
||||
|
||||
#expect(html.contains("Sale record · CCN-48213 · you are the buyer"))
|
||||
#expect(html.contains("Agreed today, 09:14 with Kestrel Row Comics · €215 + €6 tracked shipping"))
|
||||
#expect(html.contains("Awaiting dispatch"))
|
||||
#expect(html.contains("CGC 9.8 · cert 4312087-002"))
|
||||
#expect(html.contains("€215 · offer accepted (asked €240)"))
|
||||
#expect(html.contains("marked this as paid by iDEAL today, 09:20"))
|
||||
#expect(html.contains("stepper__step--done"))
|
||||
#expect(html.components(separatedBy: "stepper__step--current").count - 1 == 1)
|
||||
#expect(html.contains(#"href="/messages/00000000-0000-0000-0000-0000000000cc""#))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `sale record shows the seller their buyer, and both the ratings once given`() {
|
||||
let html = SalePage(
|
||||
sale: .stub(priceCents: 24_000, paid: true, sent: true, received: true, buyerRating: 5, sellerRating: 4),
|
||||
role: .seller,
|
||||
now: .stub
|
||||
).render()
|
||||
|
||||
#expect(html.contains("you are the seller"))
|
||||
#expect(html.contains("Mara Visser"))
|
||||
#expect(html.contains("bought at the asking price"))
|
||||
#expect(html.contains("Complete"))
|
||||
#expect(html.contains("★★★★★"))
|
||||
#expect(html.contains("★★★★☆"))
|
||||
#expect(!html.contains("Visit shop"))
|
||||
}
|
||||
|
||||
// MARK: MessagesPage tests
|
||||
|
||||
@Test
|
||||
func `messages tell the viewer's words from the other side's`() {
|
||||
let html = MessagesPage(
|
||||
conversations: [],
|
||||
open: .stub(entries: [
|
||||
.text("Is the case clean?", from: .mara),
|
||||
.text("Clean all round.", from: .joost),
|
||||
]),
|
||||
now: .stub
|
||||
).render()
|
||||
|
||||
#expect(html.contains(#"entry--text entry--own"><p class="entry__bubble">Is the case clean?"#))
|
||||
#expect(html.contains(#"entry--text entry--theirs"><p class="entry__bubble">Clean all round."#))
|
||||
#expect(html.contains("You · today, 09:14"))
|
||||
#expect(html.contains("Kestrel Row Comics · today, 09:14"))
|
||||
#expect(html.contains(#"action="/messages/00000000-0000-0000-0000-0000000000cc" method="post""#))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `messages let the seller alone answer an open offer`() {
|
||||
let entries = [Conversation.Entry(kind: .offer, sender: .mara, body: "Would you take €215?", offer: .stub(), createdAt: .stub)]
|
||||
let selling = MessagesPage(conversations: [], open: .stub(side: .selling, entries: entries), now: .stub).render()
|
||||
let buying = MessagesPage(conversations: [], open: .stub(side: .buying, entries: entries), now: .stub).render()
|
||||
|
||||
#expect(selling.contains("Their offer"))
|
||||
#expect(selling.contains(#"action="/sell/offers/00000000-0000-0000-0000-0000000000aa""#))
|
||||
#expect(selling.contains("47 h left"))
|
||||
#expect(selling.contains("“Would you take €215?”"))
|
||||
#expect(buying.contains("Your offer"))
|
||||
#expect(buying.contains("Offer open · €215"))
|
||||
#expect(!buying.contains("/sell/offers/"))
|
||||
// One open offer per listing: no way to a second while it waits.
|
||||
#expect(!buying.contains(#"href="/listing/nightward-1/offer""#))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `messages lead to the sale record from the event that opened it, once`() {
|
||||
let html = MessagesPage(
|
||||
conversations: [],
|
||||
open: .stub(
|
||||
entries: [
|
||||
.init(kind: .offer, sender: .mara, body: "", offer: .stub(status: .accepted), createdAt: .stub),
|
||||
.init(kind: .event, sender: .joost, body: "accepted the offer", saleNumber: 48_213, createdAt: .stub),
|
||||
.init(kind: .event, sender: .mara, body: "marked the sale paid", saleNumber: 48_213, createdAt: .stub),
|
||||
],
|
||||
saleNumber: 48_213
|
||||
),
|
||||
now: .stub
|
||||
).render()
|
||||
|
||||
#expect(html.contains("Kestrel Row Comics accepted the offer · today, 09:14"))
|
||||
#expect(html.contains("You marked the sale paid"))
|
||||
#expect(html.components(separatedBy: "Sale record CCN-48213 created").count - 1 == 1)
|
||||
#expect(html.contains("Agreed · CCN-48213"))
|
||||
#expect(html.contains(">Accepted<"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `messages list the side asked for, counting what is unread on each`() {
|
||||
let conversations: [Conversation] = [
|
||||
.stub(side: .buying, entries: [.text("Shipping Thursday.", from: .joost)], unreadCount: 2),
|
||||
.stub(side: .selling, entries: [.text("Still available?", from: .mara)], unreadCount: 1),
|
||||
]
|
||||
let buying = MessagesPage(conversations: conversations, now: .stub).render()
|
||||
let selling = MessagesPage(conversations: conversations, box: .selling, now: .stub).render()
|
||||
|
||||
#expect(buying.contains("2 unread"))
|
||||
#expect(buying.contains(#"href="/messages?box=buying" aria-current="true">Buying · 1"#))
|
||||
#expect(buying.contains("Shipping Thursday."))
|
||||
#expect(!buying.contains("Still available?"))
|
||||
#expect(selling.contains("Still available?"))
|
||||
#expect(selling.contains(">MV<"))
|
||||
#expect(buying.contains("Pick a conversation."))
|
||||
}
|
||||
|
||||
// MARK: SellerDashboardPage tests
|
||||
|
||||
@Test
|
||||
func `seller dashboard lists what waits for the seller`() {
|
||||
let html = SellerDashboardPage(
|
||||
shop: .stub(),
|
||||
offers: [.stub()],
|
||||
sales: [.stub(paid: true), .stub(paid: true, sent: true)],
|
||||
listings: [.stub()],
|
||||
viewer: .init(displayName: "Joost de Wit", shopSlug: "kestrel-row-comics", unreadMessages: 2),
|
||||
now: .stub
|
||||
).render()
|
||||
|
||||
#expect(html.contains("Hello, Joost"))
|
||||
#expect(html.contains("1 offer waiting · 1 sale to dispatch · 2 unread"))
|
||||
#expect(html.contains("from Mara Visser"))
|
||||
#expect(html.contains(#"name="return" value="/sell""#))
|
||||
#expect(html.contains(#"href="/sales/48213""#))
|
||||
#expect(html.contains("1 awaiting your dispatch"))
|
||||
#expect(html.contains(#"handshake__step handshake__step--done">Paid"#))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `seller dashboard says so when nothing waits`() {
|
||||
let html = SellerDashboardPage(shop: .stub(), offers: [], sales: [], listings: [], now: .stub).render()
|
||||
|
||||
#expect(html.contains("No offers waiting."))
|
||||
#expect(html.contains("No sales yet."))
|
||||
}
|
||||
|
||||
// MARK: ListingPage tests
|
||||
|
||||
@Test
|
||||
func `listing page closes its actions once sold, and to its own seller`() {
|
||||
let sold = ListingPage(listing: .stub(soldAt: .stub), related: []).render()
|
||||
let own = ListingPage(
|
||||
listing: .stub(),
|
||||
related: [],
|
||||
viewer: .init(displayName: "Joost", shopSlug: "kestrel-row-comics")
|
||||
).render()
|
||||
|
||||
#expect(sold.contains("This copy is sold."))
|
||||
#expect(!sold.contains("Buy now"))
|
||||
#expect(own.contains("This is your listing."))
|
||||
#expect(!own.contains("Make an offer"))
|
||||
}
|
||||
|
||||
// MARK: Shared tests
|
||||
|
||||
@Test
|
||||
func `no trade page renders an inline style`() {
|
||||
let pages = [
|
||||
OfferPage(listing: .stub(), mode: .offer).render(),
|
||||
SalePage(sale: .stub(), role: .buyer, now: .stub).render(),
|
||||
MessagesPage(conversations: [.stub()], open: .stub(), now: .stub).render(),
|
||||
SellerDashboardPage(shop: .stub(), offers: [.stub()], sales: [.stub()], listings: [.stub()], now: .stub).render(),
|
||||
]
|
||||
|
||||
for html in pages {
|
||||
#expect(!html.contains("style="))
|
||||
#expect(!html.contains("<style"))
|
||||
#expect(html.contains("/css/trade.css"))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -111,6 +111,7 @@ private extension StaticFileTests {
|
||||
(.signIn, ["css/sign-in.css"]),
|
||||
(.site, ["site.webmanifest"]),
|
||||
(.sitemap, ["sitemap.xml"]),
|
||||
(.trade, ["css/trade.css"]),
|
||||
]
|
||||
|
||||
}
|
||||
@@ -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